commit 7bf9f6022cd07795438d10989d8f10fedbf1ed16 Author: Claude Date: Sat Nov 7 12:38:15 2026 -0500 Add cluster-internal MCP servers for the LocalAI agent pool Gitea, Grafana, Nextcloud and Discourse MCP servers as ClusterIP-only Deployments in namespace mcp, read-only, acting as LDAP user localai. Co-Authored-By: Claude Opus 5.5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0730fa0 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# ansible-role-mcp-servers + +Cluster-internal MCP servers for the eom.dev LocalAI agent pool: Gitea, Grafana, +Nextcloud (calendar + notes) and Discourse, each a Deployment plus a ClusterIP +Service in namespace `mcp` on port 8000, path `/mcp` (streamable HTTP). There is +no ingress; only pods inside the cluster can reach them. + +Agents reach them at `http://.mcp.svc.cluster.local:8000/mcp`. + +## Credentials + +All servers act as the LDAP service user `localai` +(`cn=LocalAI,ou=LocalAI,ou=Services,dc=eom,dc=dev`) and are read-only: + +| Secret (ns `mcp`) | What | Scope | +|---|---|---| +| `mcp-gitea` | `GITEA_ACCESS_TOKEN` for Gitea user `localai` | read:repository,issue,user,organization,misc; server runs `-read-only` with a small tool allowlist | +| `mcp-grafana` | `GRAFANA_URL`, `GRAFANA_SERVICE_ACCOUNT_TOKEN` for service account `localai` | Viewer; server runs `--disable-write` | +| `mcp-nextcloud` | `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` (app password `agents-mcp`) | only what `localai` can see; the "Eric" calendar is shared read-only | +| `mcp-discourse` | `discourse-profile.json` with an API key for forum user `localai` | key scoped `global:read` (GET only), `allow_writes: false` | + +The role never creates or reads these Secrets; create them with `kubectl` and +keep the values out of git. + +## Usage + +```bash +ansible-playbook -i inventories/eom.dev.yml mcp-servers.yaml +``` + +See `defaults/main.yml` for image pins and tool allowlists. diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..dfd48e5 --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,28 @@ +#SPDX-License-Identifier: MIT-0 +--- +# Namespace holding the MCP servers and their credential Secrets. +mcp_namespace: mcp + +# Credentials are NOT managed here. Each server reads a pre-existing Secret in +# {{ mcp_namespace }} that belongs to the LDAP service user "localai": +# mcp-gitea GITEA_ACCESS_TOKEN (read:repository,issue,user,organization,misc) +# mcp-grafana GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN (service account "localai", Viewer) +# mcp-nextcloud NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD (app password) +# mcp-discourse discourse-profile.json (API key scoped global:read, allow_writes false) + +mcp_gitea_image: gitea/gitea-mcp-server@sha256:7abff2c58718f80fc777b9b1a04885332e182814921cc61967313eea4136c3b2 # 1.12.0 +mcp_gitea_host: http://gitea-http.gitea.svc.cluster.local:3000 +# Read-only code/issue/PR tools only, to keep the tool list small for a 9B model. +mcp_gitea_tools: get_me,search_repos,repo_read,get_repository_tree,get_dir_contents,get_file_contents,branch_read,commit_read,list_issues,issue_read,search_issues,list_pull_requests,pull_request_read,release_read,actions_run_read + +mcp_grafana_image: mcp/grafana@sha256:9362bcf6aa0e44e61f645b905cec03fb346a946a34a4dafecd7f3e28d3724014 +mcp_grafana_enabled_tools: search,datasource,prometheus,loki,dashboard + +mcp_nextcloud_image: ghcr.io/cbcoutinho/nextcloud-mcp-server@sha256:233c0fd48e617f68611ea5820a3f114a2279ee07e6fb5d32c4f893bd6c4e220e +mcp_nextcloud_apps: [calendar, notes] + +# Discourse's own HTTP transport is loopback-only and single-session, so the +# stdio server runs behind supergateway (one child process per MCP session). +mcp_discourse_node_image: node:24-alpine +mcp_discourse_mcp_version: 0.3.1 +mcp_supergateway_version: 4.2.1 diff --git a/meta/main.yml b/meta/main.yml new file mode 100644 index 0000000..8ec0a78 --- /dev/null +++ b/meta/main.yml @@ -0,0 +1,7 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Eric O'Neill Meehan + description: Cluster-internal MCP servers (Gitea, Grafana, Nextcloud, Discourse) for the LocalAI agent pool + license: MIT + min_ansible_version: "2.14" +dependencies: [] diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..b8a0d00 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,90 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for ansible-role-mcp-servers +- name: Create namespace for MCP servers + k8s: + state: present + definition: + apiVersion: v1 + kind: Namespace + metadata: + name: "{{ mcp_namespace }}" + +- name: Deploy MCP servers + k8s: + state: present + definition: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: "{{ item.name }}" + namespace: "{{ mcp_namespace }}" + spec: + replicas: 1 + selector: + matchLabels: + app: "{{ item.name }}" + template: + metadata: + labels: + app: "{{ item.name }}" + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: "{{ item.name }}" + image: "{{ item.image }}" + command: "{{ item.command | default(omit) }}" + args: "{{ item.args | default(omit) }}" + env: "{{ item.env | default([]) }}" + envFrom: "{{ item.envFrom | default([]) }}" + ports: + - name: http + containerPort: 8000 + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + memory: "{{ item.memory | default('256Mi') }}" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: "{{ [{'name': 'tmp', 'mountPath': '/tmp'}] + (item.mounts | default([])) }}" + volumes: "{{ [{'name': 'tmp', 'emptyDir': {}}] + (item.volumes | default([])) }}" + loop: "{{ mcp_servers }}" + loop_control: + label: "{{ item.name }}" + +- name: Create ClusterIP services for MCP servers (no ingress) + k8s: + state: present + definition: + apiVersion: v1 + kind: Service + metadata: + name: "{{ item.name }}" + namespace: "{{ mcp_namespace }}" + spec: + type: ClusterIP + selector: + app: "{{ item.name }}" + ports: + - name: http + port: 8000 + targetPort: http + loop: "{{ mcp_servers }}" + loop_control: + label: "{{ item.name }}" diff --git a/vars/main.yml b/vars/main.yml new file mode 100644 index 0000000..302e6fa --- /dev/null +++ b/vars/main.yml @@ -0,0 +1,55 @@ +#SPDX-License-Identifier: MIT-0 +--- +mcp_servers: + - name: gitea + image: "{{ mcp_gitea_image }}" + command: [/app/gitea-mcp] + args: [-t, http, -p, "8000", -read-only, -H, "{{ mcp_gitea_host }}", -tools, "{{ mcp_gitea_tools }}"] + envFrom: [{secretRef: {name: mcp-gitea}}] + memory: 128Mi + + - name: grafana + image: "{{ mcp_grafana_image }}" + command: [/app/mcp-grafana] + args: + - -t + - streamable-http + - --address + - 0.0.0.0:8000 + - --allowed-hosts + - "grafana.{{ mcp_namespace }}.svc.cluster.local:8000,grafana.{{ mcp_namespace }}.svc:8000,grafana.{{ mcp_namespace }}:8000" + - --disable-write + - --enabled-tools + - "{{ mcp_grafana_enabled_tools }}" + - --log-level + - error + envFrom: [{secretRef: {name: mcp-grafana}}] + memory: 128Mi + + - name: nextcloud + image: "{{ mcp_nextcloud_image }}" + args: "{{ ['--port', '8000', '--transport', 'streamable-http', '--no-oauth', '--log-level', 'warning'] + (mcp_nextcloud_apps | map('regex_replace', '^', '--enable-app=') | list) }}" + envFrom: [{secretRef: {name: mcp-nextcloud}}] + env: + - {name: HOME, value: /tmp} + memory: 512Mi + + - name: discourse + image: "{{ mcp_discourse_node_image }}" + command: [sh, -c] + args: + - >- + npm install -g --prefix /tmp/npm --no-fund --no-audit + @discourse/mcp@{{ mcp_discourse_mcp_version }} supergateway@{{ mcp_supergateway_version }} + && exec /tmp/npm/bin/supergateway + --stdio "/tmp/npm/bin/discourse-mcp --profile /secrets/discourse-profile.json --site https://discourse.eom.dev" + --outputTransport streamableHttp --stateful --sessionTimeout 3600000 + --port 8000 --streamableHttpPath /mcp --healthEndpoint /healthz --logLevel none + env: + - {name: HOME, value: /tmp} + - {name: npm_config_cache, value: /tmp/npm-cache} + mounts: + - {name: profile, mountPath: /secrets, readOnly: true} + volumes: + - {name: profile, secret: {secretName: mcp-discourse}} + memory: 384Mi