mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
feat(nextcloud): serve MCP through the Context Agent ExApp
Nextcloud carries no MCP server of its own, so the role runs the Context Agent as an ExApp: render its container while MCP is enabled, wait for its heartbeat, register it with AppAPI, add the MCP route, enable it, and only then mint the app password clients present. Split the credentials by origin. The shared secret between the AppAPI proxy and the ExApp is generated by this deployment and stays a vault credential; the app password is minted by Nextcloud through occ and is persisted through sys-token-store instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f2fe5e94b1
commit
a2be4f081a
16 files changed
+495
-7
No files matched your search
@@ -24,12 +24,14 @@ flowchart LR
|
||||
dep_web_app_dashboard["web-app-dashboard 🐳🐝"]
|
||||
dep_web_app_discourse["web-app-discourse 🐳🐝"]
|
||||
dep_web_app_gitlab["web-app-gitlab 🐳🐝"]
|
||||
dep_web_app_hermes["web-app-hermes 🐳🐝"]
|
||||
dep_web_app_keycloak["web-app-keycloak 🐳🐝"]
|
||||
dep_web_app_mailu["web-app-mailu 🐳🐝"]
|
||||
dep_web_app_mastodon["web-app-mastodon 🐳🐝"]
|
||||
dep_web_app_matomo["web-app-matomo 🐳🐝"]
|
||||
dep_web_app_matrix["web-app-matrix 🐳🐝"]
|
||||
dep_web_app_mattermost["web-app-mattermost 🐳🐝"]
|
||||
dep_web_app_openclaw["web-app-openclaw 🐳🐝"]
|
||||
dep_web_app_openproject["web-app-openproject 🐳🐝"]
|
||||
dep_web_app_openwebui["web-app-openwebui 🐳🐝"]
|
||||
dep_web_app_peertube["web-app-peertube 🐳🐝"]
|
||||
@@ -55,6 +57,8 @@ flowchart LR
|
||||
svc_mariadb["mariadb"]
|
||||
svc_nextcloud["nextcloud"]
|
||||
svc_proxy["proxy"]
|
||||
svc_mcp["mcp"]
|
||||
svc_context_agent["context_agent"]
|
||||
svc_cron["cron"]
|
||||
svc_talk["talk"]
|
||||
svc_whiteboard["whiteboard"]
|
||||
@@ -91,12 +95,16 @@ flowchart LR
|
||||
dep_web_app_dashboard -. "0..1" .-> svc_dashboard
|
||||
dep_web_app_discourse -. "0..1" .-> svc_discourse
|
||||
dep_web_app_gitlab -. "0..1" .-> svc_gitlab
|
||||
dep_web_app_hermes -. "0..1" .-> svc_context_agent
|
||||
dep_web_app_hermes -. "0..1" .-> svc_mcp
|
||||
dep_web_app_keycloak -. "0..1" .-> svc_sso
|
||||
dep_web_app_mailu -. "0..1" .-> svc_email
|
||||
dep_web_app_mastodon -. "0..1" .-> svc_mastodon
|
||||
dep_web_app_matomo -. "0..1" .-> svc_matomo
|
||||
dep_web_app_matrix -. "0..1" .-> svc_matrix
|
||||
dep_web_app_mattermost -. "0..1" .-> svc_mattermost
|
||||
dep_web_app_openclaw -. "0..1" .-> svc_context_agent
|
||||
dep_web_app_openclaw -. "0..1" .-> svc_mcp
|
||||
dep_web_app_openproject -. "0..1" .-> svc_openproject
|
||||
dep_web_app_openwebui -. "0..1" .-> svc_openwebui
|
||||
dep_web_app_peertube -. "0..1" .-> svc_peertube
|
||||
@@ -180,6 +188,57 @@ The enable-only appstore apps stay under `nextcloud.plugins` in [`meta/services.
|
||||
|
||||
The SSO (`sociallogin`) and LDAP (`user_ldap`) login surfaces are covered by the OIDC/LDAP Playwright specs (requirements 017/018).
|
||||
|
||||
## MCP Server
|
||||
|
||||
Nextcloud serves a Model Context Protocol endpoint through the AppAPI proxy of the `context_agent` ExApp.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Endpoint | `/index.php/apps/app_api/proxy/context_agent/mcp/` on the canonical Nextcloud vhost |
|
||||
| Container-network URL | `http://proxy:80/index.php/apps/app_api/proxy/context_agent/mcp/` |
|
||||
| Transport | Streamable HTTP |
|
||||
| Exposure | public (same nginx vhost that serves the web UI) |
|
||||
| Auth | Nextcloud app password, sent as `Authorization: Bearer <app-password>` |
|
||||
| Identity | the Nextcloud user the app password belongs to; every tool call runs with that user's permissions |
|
||||
| Implementation | plugin (the `context_agent` ExApp container, registered through the `manual_install` deploy daemon) |
|
||||
| Default state | off; `services.mcp.enabled` turns on when `web-app-hermes` or `web-app-openclaw` is deployed |
|
||||
|
||||
### Deployment
|
||||
|
||||
The `context_agent` service in [`meta/services.yml`](./meta/services.yml) renders only while `services.mcp.enabled` is true. It runs `ghcr.io/nextcloud/context_agent`, listens on its internal port for AppAPI only, and shares `credentials.context_agent_app_secret` with the ExApp registration as `APP_SECRET`.
|
||||
|
||||
[`tasks/09_mcp.yml`](./tasks/09_mcp.yml) waits for the ExApp heartbeat, registers the deploy daemon and the ExApp (route `^/mcp`, verbs `POST,GET,DELETE`, access level `1`), enables the ExApp, mints an app password via `occ user:auth-tokens:add`, persists it through `sys-token-store` under `users.administrator.tokens['web-app-nextcloud']`, and asserts that an authenticated `initialize` call answers `200`.
|
||||
|
||||
### Tool categories
|
||||
|
||||
The ExApp registers every tool category the Context Agent ships, read-only and mutating alike, and each call runs with the permissions of the app-password owner. List the categories and narrow the exposed set with:
|
||||
|
||||
```bash
|
||||
occ app_api:app:config:get context_agent tool_status
|
||||
occ app_api:app:config:set context_agent tool_status --value '<json map of category to bool>'
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
curl -i -X POST \
|
||||
-H 'Authorization: Bearer <app-password>' \
|
||||
-H 'Accept: application/json, text/event-stream' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"cli","version":"1"}}}' \
|
||||
https://<nextcloud-domain>/index.php/apps/app_api/proxy/context_agent/mcp/
|
||||
```
|
||||
|
||||
An unauthenticated request to the same path answers `404`; [`files/playwright/test-mcp-guest.js`](./files/playwright/test-mcp-guest.js) asserts the `>= 400`.
|
||||
|
||||
### Default state
|
||||
|
||||
Off. `services.mcp.enabled` is true only while `web-app-hermes` or `web-app-openclaw` is part of the deployment.
|
||||
|
||||
### How to disable
|
||||
|
||||
Remove the MCP client roles, or pin `services.mcp.enabled: false` for this role. The `context_agent` service is then not rendered, the ExApp is not registered with AppAPI, and no app password is minted.
|
||||
|
||||
## Documentation
|
||||
|
||||
A detailed documentation for the use and administration of Nextcloud on Infinito.Nexus you will find [here](docs/README.md).
|
||||
|
||||
@@ -24,6 +24,7 @@ require("./test-login-admin-native").register(shared);
|
||||
require("./test-login-biber-oidc").register(shared);
|
||||
require("./test-login-biber-ldap").register(shared);
|
||||
require("./test-guest-persona").register(shared);
|
||||
require("./test-mcp-guest").register(shared);
|
||||
require("./test-biber-persona").register(shared);
|
||||
require("./test-administrator-persona").register(shared);
|
||||
require("./test-seaweedfs");
|
||||
@@ -0,0 +1,33 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { decodeDotenvQuotedValue } = require("./personas");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
|
||||
const mcpEndpointPath = decodeDotenvQuotedValue(process.env.NEXTCLOUD_MCP_ENDPOINT_PATH);
|
||||
|
||||
exports.register = function (shared) {
|
||||
test("guest: the MCP endpoint rejects unauthenticated access", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("mcp");
|
||||
|
||||
expect(mcpEndpointPath, "NEXTCLOUD_MCP_ENDPOINT_PATH must be set in the Playwright env file").toBeTruthy();
|
||||
|
||||
const endpointUrl = new URL(mcpEndpointPath, shared.env.nextcloudBaseUrl).toString();
|
||||
const response = await page.request.post(endpointUrl, {
|
||||
failOnStatusCode: false,
|
||||
headers: {
|
||||
Accept: "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "playwright", version: "1" } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.status(),
|
||||
"an unauthenticated MCP probe must not be served a 2xx; the endpoint is app-password guarded",
|
||||
).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,11 @@
|
||||
local:
|
||||
subnet: 192.168.41.0/24
|
||||
overlay:
|
||||
modes: [swarm]
|
||||
modes: [compose, swarm]
|
||||
topology: shared_net
|
||||
aliases: ["proxy"]
|
||||
consumer:
|
||||
kind: services_flags
|
||||
key: mcp
|
||||
proxy_resolvable: true
|
||||
aliases: ["{{ lookup('domain', 'web-app-nextcloud') }}"]
|
||||
proxy_aliases: ["{{ lookup('domain', 'web-app-nextcloud') }}"]
|
||||
@@ -29,3 +29,8 @@ credentials:
|
||||
description: Internal secret for AIO Talk
|
||||
algorithm: base64_prefixed_32
|
||||
validation: ^base64:[A-Za-z0-9+/]{43}=$
|
||||
context_agent_app_secret:
|
||||
description: Shared secret between the Nextcloud AppAPI proxy and the Context Agent ExApp serving the MCP endpoint
|
||||
algorithm: alphanumeric
|
||||
validation:
|
||||
min_length: 64
|
||||
@@ -108,6 +108,49 @@ proxy:
|
||||
mem_limit: 256m
|
||||
pids_limit: 512
|
||||
min_storage: 226MB
|
||||
ports:
|
||||
internal:
|
||||
http: 80
|
||||
mcp:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-hermes' in group_names or 'web-app-openclaw' in group_names }}"
|
||||
shared: "{{ 'web-app-hermes' in group_names or 'web-app-openclaw' in group_names }}"
|
||||
direction: server
|
||||
source_url: https://docs.nextcloud.com/server/latest/admin_manual/ai/app_context_agent.html
|
||||
minimum_version: "30"
|
||||
notes: Context Agent ExApp behind the AppAPI proxy, app-password auth.
|
||||
transport: streamable_http
|
||||
exposure: public
|
||||
auth: app_password
|
||||
auth_subject: user
|
||||
implementation: plugin
|
||||
endpoint:
|
||||
service_key: proxy
|
||||
path: /index.php/apps/app_api/proxy/context_agent/mcp/
|
||||
port_key: http
|
||||
tools:
|
||||
read_only_default: true
|
||||
mutating_tools_enabled: false
|
||||
# nocheck: playwright-service-flag
|
||||
context_agent:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-hermes' in group_names or 'web-app-openclaw' in group_names }}"
|
||||
shared: "{{ 'web-app-hermes' in group_names or 'web-app-openclaw' in group_names }}"
|
||||
name: nextcloud-context-agent
|
||||
image: ghcr.io/nextcloud/context_agent
|
||||
version: 2.7.0
|
||||
backup:
|
||||
no_stop_required: true
|
||||
# nocheck: single-replica
|
||||
replicas: 1
|
||||
cpus: "1.0"
|
||||
mem_reservation: 512m
|
||||
mem_limit: 1g
|
||||
pids_limit: 512
|
||||
min_storage: 512MB
|
||||
ports:
|
||||
internal:
|
||||
http: 23000
|
||||
cron:
|
||||
# nocheck: single-replica singleton cron.sh; replicas double-run background jobs on the shared data volume
|
||||
replicas: 1
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
dashboard:
|
||||
enabled: true
|
||||
shared: true
|
||||
mcp:
|
||||
enabled: true
|
||||
shared: true
|
||||
context_agent:
|
||||
enabled: true
|
||||
shared: true
|
||||
matomo:
|
||||
enabled: true
|
||||
shared: true
|
||||
@@ -113,6 +119,12 @@
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
mcp:
|
||||
enabled: false
|
||||
shared: false
|
||||
context_agent:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
@@ -198,6 +210,12 @@
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
mcp:
|
||||
enabled: false
|
||||
shared: false
|
||||
context_agent:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
@@ -283,6 +301,12 @@
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
mcp:
|
||||
enabled: false
|
||||
shared: false
|
||||
context_agent:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
@@ -368,6 +392,12 @@
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
mcp:
|
||||
enabled: false
|
||||
shared: false
|
||||
context_agent:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
@@ -453,6 +483,12 @@
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
mcp:
|
||||
enabled: false
|
||||
shared: false
|
||||
context_agent:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
@@ -538,6 +574,12 @@
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
mcp:
|
||||
enabled: false
|
||||
shared: false
|
||||
context_agent:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
@@ -623,6 +665,12 @@
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
mcp:
|
||||
enabled: false
|
||||
shared: false
|
||||
context_agent:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
|
||||
@@ -37,3 +37,7 @@
|
||||
|
||||
- name: 🔧 Reconcile administrator identities after plugin setup
|
||||
include_tasks: 07_admin_identities.yml
|
||||
|
||||
- name: "🔌 Provision the MCP server surface"
|
||||
include_tasks: 09_mcp.yml
|
||||
when: NEXTCLOUD_MCP_ENABLED | bool
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
- name: "🔍 Resolve the Nextcloud hosting node + container id"
|
||||
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
|
||||
vars:
|
||||
resolve_service_key: "{{ NEXTCLOUD_SERVICE }}"
|
||||
resolve_output_prefix: NEXTCLOUD
|
||||
resolve_compose_cid: "{{ NEXTCLOUD_CONTAINER }}"
|
||||
|
||||
- name: "🔍 Resolve the proxy hosting node + container id"
|
||||
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
|
||||
vars:
|
||||
resolve_service_key: "{{ NEXTCLOUD_PROXY_SERVICE }}"
|
||||
resolve_output_prefix: NEXTCLOUD_PROXY
|
||||
resolve_compose_cid: "{{ NEXTCLOUD_PROXY_CONTAINER }}"
|
||||
|
||||
- name: "🔍 Resolve the Context Agent hosting node + container id"
|
||||
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
|
||||
vars:
|
||||
resolve_service_key: "{{ NEXTCLOUD_CONTEXT_AGENT_SERVICE }}"
|
||||
resolve_output_prefix: NEXTCLOUD_CONTEXT_AGENT
|
||||
resolve_compose_cid: "{{ NEXTCLOUD_CONTEXT_AGENT_CONTAINER }}"
|
||||
|
||||
- name: "⏳ Wait until the Context Agent answers its heartbeat"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- "{{ NEXTCLOUD_CONTEXT_AGENT_LOCAL_CID }}" # nocheck: container-exec-resolver
|
||||
- python3
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:{{ NEXTCLOUD_CONTEXT_AGENT_PORT }}/heartbeat', timeout=5)"
|
||||
register: nextcloud_context_agent_heartbeat
|
||||
retries: 60
|
||||
delay: 5
|
||||
until: nextcloud_context_agent_heartbeat.rc == 0
|
||||
changed_when: false
|
||||
delegate_to: "{{ NEXTCLOUD_CONTEXT_AGENT_HOST_NODE }}"
|
||||
run_once: true
|
||||
|
||||
- name: "🚀 Register the Context Agent as an AppAPI ExApp"
|
||||
ansible.builtin.include_tasks:
|
||||
file: 09_mcp/register.yml
|
||||
apply:
|
||||
delegate_to: "{{ NEXTCLOUD_HOST_NODE }}"
|
||||
run_once: true
|
||||
|
||||
- name: "🔍 Read the MCP app password this deployment already issued"
|
||||
ansible.builtin.set_fact:
|
||||
nextcloud_mcp_token: >-
|
||||
{{ lookup('users', 'administrator').tokens[application_id] | default('') | trim }}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "🩺 Check the stored app password against the MCP endpoint"
|
||||
ansible.builtin.include_tasks: 09_mcp/probe.yml
|
||||
when: nextcloud_mcp_token | length > 0
|
||||
|
||||
- name: "🔑 Mint and persist a fresh MCP app password"
|
||||
ansible.builtin.include_tasks: 09_mcp/token.yml
|
||||
when: nextcloud_mcp_token | length == 0 or nextcloud_mcp_probe.stdout | default('') != '200'
|
||||
|
||||
- name: "🩺 Re-check the freshly minted app password"
|
||||
ansible.builtin.include_tasks: 09_mcp/probe.yml
|
||||
when: nextcloud_mcp_probe is not defined or nextcloud_mcp_probe.stdout != '200'
|
||||
|
||||
- name: "🛑 Refuse an MCP endpoint that rejects the stored app password"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- nextcloud_mcp_probe.stdout == '200'
|
||||
fail_msg: >-
|
||||
The MCP endpoint {{ NEXTCLOUD_MCP_PATH }} answered
|
||||
{{ nextcloud_mcp_probe.stdout | default('no status') }} for the app password stored
|
||||
for {{ application_id }}, so every MCP client would be configured with a
|
||||
bearer the endpoint rejects.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
- name: "🩺 Ask the MCP endpoint whether the stored app password authenticates"
|
||||
ansible.builtin.shell: |
|
||||
# nocheck: container-exec-resolver
|
||||
container exec {{ NEXTCLOUD_PROXY_LOCAL_CID | quote }} \
|
||||
{{ lookup('timeout', 30) | curl }} -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Host: {{ NEXTCLOUD_DOMAIN }}' \
|
||||
-H 'Authorization: Bearer {{ nextcloud_mcp_token }}' \
|
||||
-H 'Accept: application/json, text/event-stream' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"infinito","version":"1"}}}' \
|
||||
'http://127.0.0.1:{{ container_port }}{{ NEXTCLOUD_MCP_PATH }}'
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: nextcloud_mcp_probe
|
||||
retries: 30
|
||||
delay: 10
|
||||
until: nextcloud_mcp_probe.stdout in ['200', '401', '404']
|
||||
changed_when: false
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
delegate_to: "{{ NEXTCLOUD_PROXY_HOST_NODE }}"
|
||||
run_once: true
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
- name: "🔍 Read the registered AppAPI deploy daemons"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- -u
|
||||
- "{{ NEXTCLOUD_DOCKER_USER }}"
|
||||
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
|
||||
- php
|
||||
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
|
||||
- app_api:daemon:list
|
||||
register: nextcloud_appapi_daemons
|
||||
changed_when: false
|
||||
|
||||
- name: "🚀 Register the manual-install deploy daemon"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- -u
|
||||
- "{{ NEXTCLOUD_DOCKER_USER }}"
|
||||
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
|
||||
- php
|
||||
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
|
||||
- app_api:daemon:register
|
||||
- "{{ NEXTCLOUD_CONTEXT_AGENT_DAEMON }}"
|
||||
- Manual Install
|
||||
- manual-install
|
||||
- http
|
||||
- "{{ NEXTCLOUD_CONTEXT_AGENT_DNS_NAME }}"
|
||||
- "{{ NEXTCLOUD_CONTEXT_AGENT_NEXTCLOUD_URL }}"
|
||||
when: NEXTCLOUD_CONTEXT_AGENT_DAEMON not in nextcloud_appapi_daemons.stdout
|
||||
changed_when: true
|
||||
|
||||
- name: "🔍 Read the registered ExApps"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- -u
|
||||
- "{{ NEXTCLOUD_DOCKER_USER }}"
|
||||
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
|
||||
- php
|
||||
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
|
||||
- app_api:app:list
|
||||
register: nextcloud_registered_exapps
|
||||
changed_when: false
|
||||
|
||||
- name: "🚀 Register the Context Agent ExApp with its MCP route"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- -u
|
||||
- "{{ NEXTCLOUD_DOCKER_USER }}"
|
||||
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
|
||||
- php
|
||||
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
|
||||
- app_api:app:register
|
||||
- "{{ NEXTCLOUD_CONTEXT_AGENT_SERVICE }}"
|
||||
- "{{ NEXTCLOUD_CONTEXT_AGENT_DAEMON }}"
|
||||
- --json-info
|
||||
- "{{ NEXTCLOUD_CONTEXT_AGENT_JSON_INFO }}"
|
||||
- --force-scopes
|
||||
- --wait-finish
|
||||
when: NEXTCLOUD_CONTEXT_AGENT_SERVICE not in nextcloud_registered_exapps.stdout
|
||||
changed_when: true
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "🟢 Enable the Context Agent ExApp"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- -u
|
||||
- "{{ NEXTCLOUD_DOCKER_USER }}"
|
||||
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
|
||||
- php
|
||||
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
|
||||
- app_api:app:enable
|
||||
- "{{ NEXTCLOUD_CONTEXT_AGENT_SERVICE }}"
|
||||
register: nextcloud_context_agent_enable
|
||||
changed_when: "'already enabled' not in nextcloud_context_agent_enable.stdout"
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
- name: "🔑 Mint an app password for the MCP clients"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- -u
|
||||
- "{{ NEXTCLOUD_DOCKER_USER }}"
|
||||
- -e
|
||||
- "NC_PASS={{ NEXTCLOUD_ADMINISTRATOR_PASSWORD }}"
|
||||
- "{{ NEXTCLOUD_LOCAL_CID }}" # nocheck: container-exec-resolver
|
||||
- php
|
||||
- "{{ NEXTCLOUD_INTERNAL_OCC_COMMAND }}"
|
||||
- user:auth-tokens:add
|
||||
- "{{ NEXTCLOUD_ADMINISTRATOR_USERNAME }}"
|
||||
- --password-from-env
|
||||
- "--name={{ NEXTCLOUD_MCP_TOKEN_NAME }}"
|
||||
register: nextcloud_mcp_token_mint
|
||||
changed_when: true
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
delegate_to: "{{ NEXTCLOUD_HOST_NODE }}"
|
||||
run_once: true
|
||||
|
||||
- name: "🔧 Take the minted app password from the command output"
|
||||
ansible.builtin.set_fact:
|
||||
nextcloud_mcp_token: "{{ nextcloud_mcp_token_mint.stdout_lines | last | trim }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
run_once: true
|
||||
|
||||
- name: "🛑 Refuse an instance that issued no usable app password"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- nextcloud_mcp_token | length >= 64
|
||||
fail_msg: >-
|
||||
occ user:auth-tokens:add returned no app password, so every MCP client
|
||||
would be configured with an empty bearer and fail at first use.
|
||||
|
||||
- name: "💾 Persist the app password so MCP clients can read it"
|
||||
ansible.builtin.include_role:
|
||||
name: sys-token-store
|
||||
tasks_from: write.yml
|
||||
vars:
|
||||
sys_token_store_user_key: administrator
|
||||
sys_token_store_app: "{{ application_id }}"
|
||||
sys_token_store_token: "{{ nextcloud_mcp_token }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
@@ -39,7 +39,32 @@
|
||||
start_period: {{ container_healthcheck_start_period }}
|
||||
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
|
||||
{{ lookup('container_depends_on', application_id) | indent(4) }}
|
||||
{{ lookup('container_networks') | indent(4) }}
|
||||
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
|
||||
|
||||
{% if NEXTCLOUD_MCP_ENABLED | bool %}
|
||||
{% set service_name = NEXTCLOUD_CONTEXT_AGENT_SERVICE %}
|
||||
{% set container_port = NEXTCLOUD_CONTEXT_AGENT_PORT %}
|
||||
{{ service_name }}:
|
||||
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
|
||||
{{ lookup('container_image', application_id, 'context_agent') }}
|
||||
{{ lookup('compose_only', 'container_name', NEXTCLOUD_CONTEXT_AGENT_CONTAINER) }}
|
||||
environment:
|
||||
APP_ID: {{ NEXTCLOUD_CONTEXT_AGENT_SERVICE }}
|
||||
APP_VERSION: "{{ NEXTCLOUD_CONTEXT_AGENT_VERSION }}"
|
||||
APP_HOST: 0.0.0.0
|
||||
APP_PORT: "{{ container_port }}"
|
||||
APP_SECRET: "{{ NEXTCLOUD_CONTEXT_AGENT_SECRET }}"
|
||||
NEXTCLOUD_URL: "{{ NEXTCLOUD_CONTEXT_AGENT_NEXTCLOUD_URL }}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:{{ container_port }}/heartbeat', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
expose:
|
||||
- "{{ container_port }}"
|
||||
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
|
||||
{% endif %}
|
||||
|
||||
{% if NEXTCLOUD_HPB_SIGNALING_ENABLED | bool %}
|
||||
{% set service_name = NEXTCLOUD_HPB_SERVICE %}
|
||||
@@ -60,7 +85,7 @@
|
||||
{% endif %}
|
||||
expose:
|
||||
- "{{ container_port }}"
|
||||
{{ lookup('container_networks') | indent(4) }}
|
||||
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
|
||||
{% else %}
|
||||
network_mode: {{ NEXTCLOUD_HPB_NETWORK_MODE }}
|
||||
{% endif %}
|
||||
@@ -84,7 +109,7 @@
|
||||
expose:
|
||||
- "{{ container_port }}"
|
||||
shm_size: 1g
|
||||
{{ lookup('container_networks') | indent(4) }}
|
||||
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
|
||||
{%+ if lookup('engine', 'redis', application_id, 'local') %}{{ lookup('depends_on', {'redis': 'service_healthy'}) }}{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -108,7 +133,7 @@
|
||||
retries: 10
|
||||
start_period: {{ container_healthcheck_start_period }}
|
||||
{{ lookup('container_depends_on', application_id) | indent(4) }}
|
||||
{{ lookup('container_networks') | indent(4) }}
|
||||
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
|
||||
|
||||
{% if NEXTCLOUD_RECORDING_ENABLED | bool %}
|
||||
{% set service_name = 'talk_recording' %}
|
||||
@@ -124,7 +149,7 @@
|
||||
- TZ={{ HOST_TIMEZONE }}
|
||||
expose:
|
||||
- "{{ NEXTCLOUD_RECORDING_PORT }}"
|
||||
{{ lookup('container_networks') | indent(4) }}
|
||||
{{ lookup('container_networks', provider_self_alias=False) | indent(4) }}
|
||||
{{ lookup('container_volumes', application_id, service_name) | indent(4) }}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -25,12 +25,16 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
|
||||
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
|
||||
MCP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.mcp.enabled') | string | lower }}
|
||||
|
||||
MOODLE_BASE_URL={{ (lookup('tls', 'web-app-moodle', 'url.base') if (lookup('config', application_id, 'services.moodle.enabled') | bool) else '') | dotenv_quote }}
|
||||
|
||||
NEXTCLOUD_BASE_URL={{ lookup('tls', 'web-app-nextcloud', 'url.base') | dotenv_quote }}
|
||||
|
||||
NEXTCLOUD_DIRECT_LOGIN_PASSWORD={{ lookup('config', application_id, 'credentials.administrator_password') | dotenv_quote }}
|
||||
|
||||
NEXTCLOUD_MCP_ENDPOINT_PATH={{ lookup('config', application_id, 'services.mcp.endpoint.path') | dotenv_quote }}
|
||||
|
||||
NEXTCLOUD_ONLYOFFICE_EXPECTED_DOCUMENT_SERVER_URL={{ NEXTCLOUD_ONLYOFFICE_URL | dotenv_quote }}
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,36 @@ NEXTCLOUD_ZAMMAD_CONTAINER_ADDRESS: "{{ lookup('container_address', 'web-app-zam
|
||||
|
||||
NEXTCLOUD_PROXY_SERVICE: "proxy"
|
||||
NEXTCLOUD_PROXY_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_PROXY_SERVICE ~ '.name') }}"
|
||||
NEXTCLOUD_PROXY_DNS_NAME: "{{ (entity_name ~ '_' ~ NEXTCLOUD_PROXY_SERVICE) if DEPLOYMENT_MODE == 'swarm' else NEXTCLOUD_PROXY_CONTAINER }}"
|
||||
|
||||
NEXTCLOUD_MCP_ENABLED: "{{ lookup('config', application_id, 'services.mcp.enabled') }}"
|
||||
NEXTCLOUD_MCP_PATH: "{{ lookup('config', application_id, 'services.mcp.endpoint.path') }}"
|
||||
NEXTCLOUD_MCP_TOKEN_NAME: "infinito-mcp"
|
||||
|
||||
NEXTCLOUD_CONTEXT_AGENT_SERVICE: "context_agent"
|
||||
NEXTCLOUD_CONTEXT_AGENT_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_CONTEXT_AGENT_SERVICE ~ '.name') }}"
|
||||
NEXTCLOUD_CONTEXT_AGENT_VERSION: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_CONTEXT_AGENT_SERVICE ~ '.version') }}"
|
||||
NEXTCLOUD_CONTEXT_AGENT_PORT: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_CONTEXT_AGENT_SERVICE ~ '.ports.internal.http') }}"
|
||||
NEXTCLOUD_CONTEXT_AGENT_SECRET: "{{ lookup('config', application_id, 'credentials.context_agent_app_secret') }}"
|
||||
NEXTCLOUD_CONTEXT_AGENT_DNS_NAME: "{{ (entity_name ~ '_' ~ NEXTCLOUD_CONTEXT_AGENT_SERVICE) if DEPLOYMENT_MODE == 'swarm' else NEXTCLOUD_CONTEXT_AGENT_CONTAINER }}"
|
||||
NEXTCLOUD_CONTEXT_AGENT_DAEMON: "manual_install"
|
||||
NEXTCLOUD_CONTEXT_AGENT_NEXTCLOUD_URL: "http://{{ NEXTCLOUD_PROXY_DNS_NAME }}:{{ container_port }}"
|
||||
NEXTCLOUD_CONTEXT_AGENT_ROUTES:
|
||||
- url: "^/mcp"
|
||||
verb: "POST,GET,DELETE"
|
||||
access_level: 1
|
||||
headers_to_exclude: []
|
||||
NEXTCLOUD_CONTEXT_AGENT_JSON_INFO: >-
|
||||
{{
|
||||
{
|
||||
'id': NEXTCLOUD_CONTEXT_AGENT_SERVICE,
|
||||
'name': 'Nextcloud Context Agent',
|
||||
'version': NEXTCLOUD_CONTEXT_AGENT_VERSION,
|
||||
'secret': NEXTCLOUD_CONTEXT_AGENT_SECRET,
|
||||
'port': NEXTCLOUD_CONTEXT_AGENT_PORT | int,
|
||||
'routes': NEXTCLOUD_CONTEXT_AGENT_ROUTES
|
||||
} | to_json
|
||||
}}
|
||||
|
||||
NEXTCLOUD_CRON_SERVICE: "cron"
|
||||
NEXTCLOUD_CRON_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_CRON_SERVICE ~ '.name') }}"
|
||||
|
||||
@@ -18,6 +18,12 @@ nextcloud_system_config:
|
||||
- parameter: "trusted_domains 0"
|
||||
value: "{{ NEXTCLOUD_DOMAIN }}"
|
||||
|
||||
- parameter: "trusted_domains 1"
|
||||
value: "{{ NEXTCLOUD_PROXY_SERVICE }}"
|
||||
|
||||
- parameter: "trusted_domains 2"
|
||||
value: "{{ NEXTCLOUD_PROXY_DNS_NAME }}"
|
||||
|
||||
- parameter: "overwrite.cli.url"
|
||||
value: "{{ NEXTCLOUD_URL }}"
|
||||
|
||||
|
||||
Reference in new issue
Block a user