feat(n8n): provision the MCP server and client, and close the auth gate

The oauth2 gate declared no allowed_groups at all while the role carried an mcp RBAC role nothing consulted, so it admitted anyone the proxy authenticated. It now admits the role's own administrator and mcp groups, and a lint derives the client set from meta/mcp.yml rather than a list, which is how the gap surfaced.

Two idempotence defects went with it. The run minted a public-API key, found it again next time and counted that as a change, so the task never converged; and it left that workflow-scoped key standing after every deploy although nothing read it. It is now minted per run and revoked before the run ends. The server workflow also connected nothing, and the trigger serves exactly the tools that reach it over an ai_tool connection, so it served none; it now carries one checked-in read tool with an explicit input schema.

The client side did not exist. It renders an agent workflow with one MCP Client Tool per discovered provider, each pinned to that provider's allowlist, because the node defaults to including every tool an upstream happens to serve. Credentials go through the internal API since the public one of this tag can only create and delete them, never converge an existing one. Every node type, typeVersion and connection literal was read from the pinned source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-29 00:46:57 +02:00
1 parent d9f497688d
commit 4e46858202
9 files changed
+1002 -15

No files matched your search

@@ -0,0 +1,386 @@
"""Provision the deployment-managed MCP client surface of n8n 1.95.3.
Renders one agent workflow whose connected MCP Client Tool nodes are exactly
the providers this deployment discovered, each pinned to that provider's
declared tool allowlist.
Prints ``CHANGED`` or ``OK``.
Environment:
N8N_BASE: origin of the n8n API.
N8N_OWNER_EMAIL: owner account email.
N8N_OWNER_PASSWORD: owner account password.
N8N_API_KEY_NAME: deterministic name of the managed API key.
N8N_CLIENT_WORKFLOW: deterministic name of the managed agent workflow.
N8N_AI_CREDENTIAL: name of the managed gateway credential.
N8N_CHAT_MODEL: model the agent asks the gateway for.
N8N_MCP_PROVIDERS: JSON array of discovered providers, each carrying
``id``, ``url``, ``token`` and ``tools``.
"""
from __future__ import annotations
import json
import os
import re
import sys
import urllib.error
import urllib.request
BASE = os.environ.get("N8N_BASE", "").rstrip("/")
EMAIL = os.environ.get("N8N_OWNER_EMAIL", "")
PASSWORD = os.environ.get("N8N_OWNER_PASSWORD", "")
KEY_NAME = os.environ.get("N8N_API_KEY_NAME", "")
WORKFLOW_NAME = os.environ.get("N8N_CLIENT_WORKFLOW", "")
AI_CREDENTIAL = os.environ.get("N8N_AI_CREDENTIAL", "")
CHAT_MODEL = os.environ.get("N8N_CHAT_MODEL", "")
PROVIDERS = json.loads(os.environ.get("N8N_MCP_PROVIDERS", "[]"))
REST = "/rest"
PUBLIC = "/api/v1"
CREDENTIAL_TYPE = "httpBearerAuth"
BEARER_AUTH = "bearerAuth"
CLIENT_PREFIX = "infinito:mcp-client:"
TRIGGER_TYPE = "n8n-nodes-base.executeWorkflowTrigger"
TRIGGER_VERSION = 1.1
TRIGGER_NAME = "Infinito Client Trigger"
AGENT_TYPE = "@n8n/n8n-nodes-langchain.agent"
AGENT_VERSION = 2
AGENT_NAME = "Infinito Agent"
MODEL_TYPE = "@n8n/n8n-nodes-langchain.lmChatOpenAi"
MODEL_VERSION = 1.2
MODEL_NAME = "Infinito Model"
CLIENT_TYPE = "@n8n/n8n-nodes-langchain.mcpClientTool"
CLIENT_VERSION = 1
MAIN = "main"
AI_TOOL = "ai_tool"
AI_LANGUAGE_MODEL = "ai_languageModel"
INCLUDE_SELECTED = "selected"
SESSION = {"cookie": ""}
def call(path, method="GET", payload=None, api_key=None):
"""Return ``(status, body)`` of one n8n API call.
Args:
path: API path below the n8n origin.
method: HTTP method.
payload: JSON-serialisable request body, or None.
api_key: public-API key; omitted calls ride the owner cookie instead.
"""
data = json.dumps(payload).encode() if payload is not None else None
request = urllib.request.Request( # noqa: S310 - fixed http:// base from N8N_BASE, no user-supplied scheme
f"{BASE}{path}", data=data, method=method
)
request.add_header("Content-Type", "application/json")
if api_key:
request.add_header("X-N8N-API-KEY", api_key)
elif SESSION["cookie"]:
request.add_header("Cookie", SESSION["cookie"])
try:
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - fixed http:// base from N8N_BASE
for header in response.headers.get_all("Set-Cookie") or []:
if header.startswith("n8n-auth="):
SESSION["cookie"] = header.split(";", 1)[0]
return response.status, json.loads(response.read() or b"null")
except urllib.error.HTTPError as error:
return error.code, error.read().decode(errors="replace")
def login():
status, body = call(
f"{REST}/login",
method="POST",
payload={"emailOrLdapLoginId": EMAIL, "password": PASSWORD},
)
if status != 200:
sys.exit(f"FAILED logging in as {EMAIL}: {status} {body}")
def api_key():
"""Return ``(secret, id)`` of a public-API key minted for this run alone.
n8n returns the usable secret only once, as ``rawApiKey``, and a listed key
is redacted, so the key cannot be carried between runs. It is minted here
and revoked before the run ends; an entry left behind under the managed name
belongs to a run that died and is deleted rather than guessed at.
"""
status, body = call(f"{REST}/api-keys")
if status != 200:
sys.exit(f"FAILED listing api keys: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
matches = [key for key in existing or [] if key.get("label") == KEY_NAME]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} api keys named {KEY_NAME}")
for key in matches:
call(f"{REST}/api-keys/{key['id']}", method="DELETE")
status, body = call(
f"{REST}/api-keys",
method="POST",
payload={
"label": KEY_NAME,
"scopes": [
"workflow:create",
"workflow:read",
"workflow:update",
"workflow:list",
"workflow:delete",
],
"expiresAt": None,
},
)
if status not in (200, 201):
sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}")
created = (body or {}).get("data") if isinstance(body, dict) else body
return str(created["rawApiKey"]), str(created["id"])
def revoke_key(key_id):
"""Delete the public-API key this run minted.
Args:
key_id: id of the key to delete.
"""
status, body = call(f"{REST}/api-keys/{key_id}", method="DELETE")
if status != 200:
sys.exit(f"FAILED revoking api key {KEY_NAME}: {status} {body}")
def node_name(provider_id):
"""Return the stable node name of one provider's client tool.
Args:
provider_id: the provider's application_id.
"""
return "mcp_" + re.sub(r"[^a-zA-Z0-9]+", "_", provider_id).strip("_")
def credential_name(provider_id):
"""Return the deterministic name of one provider's bearer credential.
Args:
provider_id: the provider's application_id.
"""
return f"{CLIENT_PREFIX}{provider_id}"
def listed_credentials():
"""Return the credentials n8n currently holds."""
status, body = call(f"{REST}/credentials")
if status != 200:
sys.exit(f"FAILED listing credentials: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
return list(existing or [])
def credential(provider):
"""Return ``({"id", "name"}, created)`` of one provider's bearer credential.
Args:
provider: a discovered provider carrying ``id`` and ``token``.
"""
name = credential_name(provider["id"])
matches = [item for item in listed_credentials() if item.get("name") == name]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} credentials named {name}")
payload = {
"name": name,
"type": CREDENTIAL_TYPE,
"data": {"token": provider["token"]},
}
if not matches:
status, body = call(f"{REST}/credentials", method="POST", payload=payload)
if status not in (200, 201):
sys.exit(f"FAILED creating {name}: {status} {body}")
created = (body or {}).get("data") if isinstance(body, dict) else body
return {"id": str(created["id"]), "name": name}, True
status, body = call(
f"{REST}/credentials/{matches[0]['id']}", method="PATCH", payload=payload
)
if status != 200:
sys.exit(f"FAILED updating {name}: {status} {body}")
return {"id": str(matches[0]["id"]), "name": name}, False
def drop_stale_credentials(keep):
"""Delete managed client credentials no discovered provider claims.
Args:
keep: credential names the current provider set still needs.
A name outside ``CLIENT_PREFIX`` belongs to a human and is never touched.
"""
dropped = False
for item in listed_credentials():
name = str(item.get("name") or "")
if name.startswith(CLIENT_PREFIX) and name not in keep:
status, body = call(f"{REST}/credentials/{item['id']}", method="DELETE")
if status != 200:
sys.exit(f"FAILED deleting {name}: {status} {body}")
dropped = True
return dropped
def client_node(provider, reference, index):
"""Return one MCP Client Tool node pinned to a provider's allowlist.
Args:
provider: a discovered provider carrying ``id``, ``url`` and ``tools``.
reference: ``{"id", "name"}`` reference to its bearer credential.
index: position of the node, so the rendering stays stable.
"""
name = node_name(provider["id"])
mutating = set(provider.get("mutating") or [])
return {
"id": name,
"name": name,
"type": CLIENT_TYPE,
"typeVersion": CLIENT_VERSION,
"position": [220 * index, 220],
"parameters": {
"sseEndpoint": provider["url"],
"authentication": BEARER_AUTH,
"include": INCLUDE_SELECTED,
"includeTools": sorted(set(provider.get("tools") or []) - mutating),
},
"credentials": {CREDENTIAL_TYPE: reference},
}
def workflow_body(references):
"""Return the managed agent workflow as the public API accepts it.
Args:
references: ``{provider_id: {"id", "name"}}`` bearer references.
"""
nodes = [
{
"id": "infinito-client-trigger",
"name": TRIGGER_NAME,
"type": TRIGGER_TYPE,
"typeVersion": TRIGGER_VERSION,
"position": [0, 0],
"parameters": {},
},
{
"id": "infinito-agent",
"name": AGENT_NAME,
"type": AGENT_TYPE,
"typeVersion": AGENT_VERSION,
"position": [220, 0],
"parameters": {},
},
{
"id": "infinito-model",
"name": MODEL_NAME,
"type": MODEL_TYPE,
"typeVersion": MODEL_VERSION,
"position": [220, 220],
"parameters": {"model": CHAT_MODEL},
"credentials": {"openAiApi": {"name": AI_CREDENTIAL}},
},
]
connections = {
TRIGGER_NAME: {MAIN: [[{"node": AGENT_NAME, "type": MAIN, "index": 0}]]},
MODEL_NAME: {
AI_LANGUAGE_MODEL: [
[{"node": AGENT_NAME, "type": AI_LANGUAGE_MODEL, "index": 0}]
]
},
}
for index, provider in enumerate(PROVIDERS):
nodes.append(client_node(provider, references[provider["id"]], index + 2))
connections[node_name(provider["id"])] = {
AI_TOOL: [[{"node": AGENT_NAME, "type": AI_TOOL, "index": 0}]]
}
return {
"name": WORKFLOW_NAME,
"settings": {"executionOrder": "v1"},
"nodes": nodes,
"connections": connections,
}
def managed_workflow(key):
"""Return the managed agent workflow, or None, refusing to guess between two.
Args:
key: the public-API key.
"""
status, body = call(f"{PUBLIC}/workflows", api_key=key)
if status != 200:
sys.exit(f"FAILED listing workflows: {status} {body}")
existing = (body or {}).get("data") if isinstance(body, dict) else body
matches = [flow for flow in existing or [] if flow.get("name") == WORKFLOW_NAME]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}")
return matches[0] if matches else None
def reconcile(key):
"""Render the agent workflow over exactly the providers this run discovered.
Args:
key: the public-API key.
The workflow is never activated: it answers a sub-workflow call, so it runs
only once an operator wires something to it.
"""
flow = managed_workflow(key)
if not PROVIDERS:
dropped = drop_stale_credentials(set())
if flow is not None:
status, body = call(
f"{PUBLIC}/workflows/{flow['id']}", method="DELETE", api_key=key
)
if status != 200:
sys.exit(f"FAILED deleting {WORKFLOW_NAME}: {status} {body}")
return dropped or flow is not None
references = {}
minted = False
for provider in PROVIDERS:
references[provider["id"]], created = credential(provider)
minted = minted or created
dropped = drop_stale_credentials(
{credential_name(provider["id"]) for provider in PROVIDERS}
)
payload = workflow_body(references)
if flow is None:
status, body = call(
f"{PUBLIC}/workflows", method="POST", payload=payload, api_key=key
)
if status not in (200, 201):
sys.exit(f"FAILED creating {WORKFLOW_NAME}: {status} {body}")
return True
changed = flow.get("nodes") != payload["nodes"]
status, body = call(
f"{PUBLIC}/workflows/{flow['id']}",
method="PUT",
payload=payload,
api_key=key,
)
if status != 200:
sys.exit(f"FAILED updating {WORKFLOW_NAME}: {status} {body}")
return changed or minted or dropped
def main():
login()
key, key_id = api_key()
changed = reconcile(key)
revoke_key(key_id)
print("CHANGED" if changed else "OK")
if __name__ == "__main__":
main()
+75 -12
View File
@@ -1,6 +1,6 @@
"""Provision the deployment-managed MCP surfaces of n8n 1.95.3.
Prints ``CHANGED``/``OK`` and then one JSON line carrying ``api_key``.
Prints ``CHANGED`` or ``OK``.
Environment:
N8N_BASE: origin of the n8n API.
@@ -34,9 +34,36 @@ REST = "/rest"
PUBLIC = "/api/v1"
TRIGGER_TYPE = "@n8n/n8n-nodes-langchain.mcpTrigger"
TRIGGER_VERSION = 1
TRIGGER_NAME = "Infinito MCP Trigger"
BEARER_AUTH = "bearerAuth"
CREDENTIAL_TYPE = "httpBearerAuth"
TOOL_TYPE = "@n8n/n8n-nodes-langchain.toolCode"
TOOL_VERSION = 1.2
TOOL_NAME = "infinito_health"
TOOL_CONNECTION = "ai_tool"
TOOL_DESCRIPTION = (
"Report that the managed MCP server is reachable and echo the caller's text "
"back. Reads nothing: it reaches no application, database, or network."
)
TOOL_CODE = (
'return JSON.stringify({status: "ok", echo: query.echo, '
"checked_at: new Date().toISOString()});"
)
TOOL_SCHEMA = json.dumps(
{
"type": "object",
"properties": {
"echo": {
"type": "string",
"description": "Text returned unchanged, to prove the round trip.",
}
},
"required": ["echo"],
"additionalProperties": False,
}
)
SESSION = {"cookie": ""}
@@ -79,11 +106,12 @@ def login():
def api_key():
"""Return the managed public-API key, reusing a previous run's when present.
"""Return ``(secret, id)`` of a public-API key minted for this run alone.
n8n returns the usable secret only once, as ``rawApiKey``. A listed key is
redacted, so an existing entry under the managed name is deleted and minted
again rather than guessed at.
n8n returns the usable secret only once, as ``rawApiKey``, and a listed key
is redacted, so the key cannot be carried between runs. It is minted here
and revoked before the run ends; an entry left behind under the managed name
belongs to a run that died and is deleted rather than guessed at.
"""
status, body = call(f"{REST}/api-keys")
if status != 200:
@@ -113,7 +141,18 @@ def api_key():
if status not in (200, 201):
sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}")
created = (body or {}).get("data") if isinstance(body, dict) else body
return str(created["rawApiKey"]), bool(matches)
return str(created["rawApiKey"]), str(created["id"])
def revoke_key(key_id):
"""Delete the public-API key this run minted.
Args:
key_id: id of the key to delete.
"""
status, body = call(f"{REST}/api-keys/{key_id}", method="DELETE")
if status != 200:
sys.exit(f"FAILED revoking api key {KEY_NAME}: {status} {body}")
def bearer_credential():
@@ -155,6 +194,9 @@ def bearer_credential():
def workflow_body(credential):
"""Return the managed MCP server workflow as the public API accepts it.
The trigger serves exactly the tools reaching it over an ``ai_tool``
connection, so the connection map is the tool allowlist.
Args:
credential: ``{"id", "name"}`` reference to the managed bearer credential.
"""
@@ -164,7 +206,7 @@ def workflow_body(credential):
"nodes": [
{
"id": "infinito-mcp-trigger",
"name": "Infinito MCP Trigger",
"name": TRIGGER_NAME,
"type": TRIGGER_TYPE,
"typeVersion": TRIGGER_VERSION,
"position": [0, 0],
@@ -174,9 +216,30 @@ def workflow_body(credential):
"authentication": BEARER_AUTH,
},
"credentials": {CREDENTIAL_TYPE: credential},
}
},
{
"id": "infinito-mcp-health",
"name": TOOL_NAME,
"type": TOOL_TYPE,
"typeVersion": TOOL_VERSION,
"position": [0, 220],
"parameters": {
"description": TOOL_DESCRIPTION,
"language": "javaScript",
"jsCode": TOOL_CODE,
"specifyInputSchema": True,
"schemaType": "manual",
"inputSchema": TOOL_SCHEMA,
},
},
],
"connections": {},
"connections": {
TOOL_NAME: {
TOOL_CONNECTION: [
[{"node": TRIGGER_NAME, "type": TOOL_CONNECTION, "index": 0}]
]
}
},
}
@@ -240,11 +303,11 @@ def main():
if not MCP_TOKEN:
sys.exit("FAILED: no bearer configured; the trigger would be unauthenticated")
login()
key, rotated = api_key()
key, key_id = api_key()
credential, minted = bearer_credential()
changed = upsert_workflow(key, credential)
print(f"{'CHANGED' if changed or minted or rotated else 'OK'}")
print(json.dumps({"api_key": key}))
revoke_key(key_id)
print(f"{'CHANGED' if changed or minted else 'OK'}")
if __name__ == "__main__":
+2
View File
@@ -30,5 +30,7 @@ endpoint:
path: /webhook/infinito-mcp/sse
port_key: http
tools:
allowlist:
- infinito_health
read_only_default: true
mutating_tools_enabled: false
+3
View File
@@ -9,6 +9,9 @@ sso:
origin:
host: n8n
port: 5678
allowed_groups:
- "{{ lookup('rbac_group_path', application_id='web-app-n8n', role='administrator') }}"
- "{{ lookup('rbac_group_path', application_id='web-app-n8n', role='mcp') }}"
email:
bond: 1
enabled: "{{ 'web-app-mailu' in group_names }}"
+32
View File
@@ -31,3 +31,35 @@
sys_token_store_user_key: "{{ lookup('config', application_id, 'mcp.credential.owner') }}"
sys_token_store_app: "{{ application_id }}"
sys_token_store_token: "{{ N8N_MCP_TOKEN }}"
- name: "🔌 Reconcile the managed agent over every discovered MCP provider"
ansible.builtin.command:
argv:
- python3
- "-"
stdin: "{{ lookup('file', 'python/provision/client.py') }}"
environment:
N8N_AI_CREDENTIAL: "{{ N8N_LITELLM_CREDENTIAL_NAME }}"
N8N_API_KEY_NAME: "{{ N8N_MCP_API_KEY_NAME }}"
N8N_BASE: "{{ N8N_INTERNAL_API_URL }}"
N8N_CHAT_MODEL: "{{ LITELLM_CHAT_MODEL }}"
N8N_CLIENT_WORKFLOW: "{{ N8N_MCP_CLIENT_WORKFLOW_NAME }}"
N8N_MCP_PROVIDERS: "{{ N8N_MCP_PROVIDERS | to_json }}"
N8N_OWNER_EMAIL: "{{ N8N_OWNER_EMAIL }}"
N8N_OWNER_PASSWORD: "{{ N8N_OWNER_PASSWORD }}"
register: n8n_client_provision
changed_when: n8n_client_provision.stdout.startswith('CHANGED')
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
delegate_to: "{{ STACK_HOST }}"
run_once: true
when: N8N_LITELLM_ENABLED | bool
- name: "🩺 Prove the MCP contract of {{ application_id }}"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/svc-ai-mcp-adapter/tasks/probe.yml') }}"
vars:
mcp_probe_bearer: "{{ lookup('mcp_credential', application_id) }}"
mcp_probe_path_key: ""
mcp_probe_read_tool: "infinito_health"
mcp_probe_read_args:
echo: "infinito"
mcp_probe_host: ""
+2
View File
@@ -22,6 +22,8 @@ N8N_MCP_API_KEY_NAME: "infinito:mcp"
N8N_MCP_WORKFLOW_NAME: "infinito:mcp-server"
N8N_MCP_CREDENTIAL_NAME: "infinito:mcp-bearer"
N8N_MCP_WEBHOOK_PATH: "infinito-mcp"
N8N_MCP_CLIENT_WORKFLOW_NAME: "infinito:mcp-agent"
N8N_MCP_PROVIDERS: "{{ MCP_DISCOVERED_SERVERS if N8N_MCP_ENABLED else [] }}"
N8N_LITELLM_ENABLED: "{{ lookup('config', application_id, 'services.litellm.enabled') | bool }}"
N8N_LITELLM_BASE_URL: "{{ LITELLM_OPENAI_BASE_LOCAL_URL }}"
@@ -0,0 +1,123 @@
"""Lint: signing in to an MCP client does not by itself grant its MCP surface.
An MCP client holds credentials the deployment issued, not the caller's own, so
whoever reaches its UI reaches every provider that client is registered with,
under an identity that is not theirs. None of these upstreams offers a tested
per-server user authorization boundary, so the boundary has to be the gate in
front of them: the connection is restricted to an administrator and to the
application's own ``mcp`` group, never offered to every account that happens to
have a login.
An oauth2 gate with no ``allowed_groups`` admits every authenticated user in the
realm, which is exactly the failure this rule exists for — ``web-app-n8n``
shipped that way while declaring an ``mcp`` role nothing consulted.
Two relations per client role whose gate is an oauth2 proxy:
* the gate names ``allowed_groups`` at all;
* every group it names is scoped to that role's own ``application_id``, so a
group belonging to another application cannot open this one.
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
* ``# nocheck: mcp-client-gate-scoped`` in the head of the role's
``meta/mcp.yml``, together with the per-server boundary that replaces it.
"""
from __future__ import annotations
import unittest
from collections.abc import Mapping
from pathlib import Path
from utils.annotations.suppress import is_suppressed_in_head
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_MCP, ROLE_FILE_META_SERVICES
from . import PROJECT_ROOT
_RULE = "mcp-client-gate-scoped"
_CLIENT_DIRECTIONS = frozenset({"client", "both"})
def _gated_client_roles() -> dict[str, Mapping]:
"""Return ``{role: sso block}`` for MCP clients standing behind an oauth2 gate."""
roles: dict[str, Mapping] = {}
for mcp_path in sorted(Path(PROJECT_ROOT, "roles").glob(f"*/{ROLE_FILE_META_MCP}")):
mcp = load_yaml_any(str(mcp_path), default_if_missing={})
if not isinstance(mcp, Mapping):
continue
if str(mcp.get("direction") or "").strip().lower() not in _CLIENT_DIRECTIONS:
continue
if is_suppressed_in_head(read_text(str(mcp_path)).splitlines(), _RULE):
continue
role_dir = mcp_path.parent.parent
services = load_yaml_any(
str(role_dir / ROLE_FILE_META_SERVICES), default_if_missing={}
)
sso = (services or {}).get("sso")
if not isinstance(sso, Mapping):
continue
if str(sso.get("flavor") or "").strip() != "oauth2":
continue
roles[role_dir.name] = sso
return roles
def _allowed_groups(sso: Mapping) -> list[str]:
oauth2 = sso.get("oauth2")
if not isinstance(oauth2, Mapping):
return []
groups = oauth2.get("allowed_groups")
return [str(g) for g in groups] if isinstance(groups, list) else []
class TestMcpClientGateScoped(unittest.TestCase):
def test_every_gated_client_restricts_its_groups(self) -> None:
open_gates = [
f"{role}: its oauth2 gate names no allowed_groups, so every "
f"authenticated account in the realm reaches an MCP client that "
f"acts with the deployment's provider credentials"
for role, sso in sorted(_gated_client_roles().items())
if not _allowed_groups(sso)
]
self.assertEqual(
[],
open_gates,
f"MCP client(s) behind an unrestricted gate ({len(open_gates)}):\n"
+ "\n".join(f" - {g}" for g in open_gates),
)
def test_every_allowed_group_is_application_scoped(self) -> None:
foreign = []
for role, sso in sorted(_gated_client_roles().items()):
for group in _allowed_groups(sso):
if (
f"application_id='{role}'" in group
or f'application_id="{role}"' in group
):
continue
foreign.append(
f"{role}: allows '{group}', which is not scoped to this "
f"role's own application_id, so another application's "
f"membership opens this client"
)
self.assertEqual(
[],
foreign,
"MCP client(s) allowing a foreign group:\n"
+ "\n".join(f" - {f}" for f in foreign),
)
def test_the_scan_finds_gated_clients(self) -> None:
self.assertTrue(
_gated_client_roles(),
"no MCP client role sits behind an oauth2 gate, so both rules "
"would pass vacuously; check that the scan still reads the right "
"topic",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,267 @@
"""Unit tests for ``roles/web-app-n8n/files/python/provision/client.py``."""
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import unittest
from unittest.mock import patch
from . import PROJECT_ROOT
SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-n8n/files/python/provision/client.py"
PROVIDER = {
"id": "web-app-gitea",
"url": "http://gitea:3000/mcp/sse",
"token": "g" * 40,
"tools": ["gitea_list_repos", "gitea_get_issue"],
}
ENV = {
"N8N_BASE": "http://n8n:5678",
"N8N_OWNER_EMAIL": "administrator@example.org",
"N8N_OWNER_PASSWORD": "x" * 48,
"N8N_API_KEY_NAME": "infinito:mcp",
"N8N_CLIENT_WORKFLOW": "infinito:mcp-agent",
"N8N_AI_CREDENTIAL": "infinito:litellm",
"N8N_CHAT_MODEL": "llama3",
"N8N_MCP_PROVIDERS": json.dumps([PROVIDER]),
}
REFERENCE = {"id": "c1", "name": "infinito:mcp-client:web-app-gitea"}
def load_script(overrides=None) -> object:
spec = importlib.util.spec_from_file_location("provision_client", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", {**ENV, **(overrides or {})}):
spec.loader.exec_module(module)
return module
class FakeApi:
"""Minimal stand-in for the n8n rest and public API surfaces."""
def __init__(self, keys=(), workflows=(), credentials=()):
self.keys = [dict(key) for key in keys]
self.workflows = [dict(flow) for flow in workflows]
self.credentials = [dict(item) for item in credentials]
self.created_workflows = []
self.updated_workflows = []
self.deleted_workflows = []
self.written_credentials = []
self.deleted_credentials = []
self.deleted_keys = []
self.logins = 0
self.minted = 0
def __call__(self, path, method="GET", payload=None, api_key=None):
if path == "/rest/login":
self.logins += 1
return 200, {"data": {"id": "owner"}}
if path == "/rest/credentials" and method == "GET":
return 200, {"data": [dict(item) for item in self.credentials]}
if path == "/rest/credentials" and method == "POST":
self.minted += 1
new = {"id": f"c{self.minted}", "name": payload["name"]}
self.written_credentials.append(payload)
self.credentials.append(new)
return 200, {"data": new}
if path.startswith("/rest/credentials/") and method == "PATCH":
self.written_credentials.append(payload)
return 200, {"data": {"id": path.rsplit("/", 1)[-1]}}
if path.startswith("/rest/credentials/") and method == "DELETE":
dead = path.rsplit("/", 1)[-1]
self.deleted_credentials.append(dead)
self.credentials = [c for c in self.credentials if c["id"] != dead]
return 200, {"success": True}
if path == "/rest/api-keys" and method == "GET":
return 200, {"data": [dict(key) for key in self.keys]}
if path == "/rest/api-keys" and method == "POST":
self.keys = [{"id": "k1", "label": payload["label"]}]
return 200, {"data": {"id": "k1", "rawApiKey": "n8n-raw-key"}}
if path.startswith("/rest/api-keys/") and method == "DELETE":
self.deleted_keys.append(path.rsplit("/", 1)[-1])
return 200, {"success": True}
if path == "/api/v1/workflows" and method == "GET":
return 200, {"data": [dict(flow) for flow in self.workflows]}
if path == "/api/v1/workflows" and method == "POST":
self.created_workflows.append(payload)
return 200, dict(payload, id="w1", active=False)
if path.startswith("/api/v1/workflows/") and method == "PUT":
self.updated_workflows.append(payload)
return 200, payload
if path.startswith("/api/v1/workflows/") and method == "DELETE":
self.deleted_workflows.append(path.rsplit("/", 1)[-1])
return 200, {"success": True}
raise AssertionError(f"unexpected {method} {path}")
class TestProvisionClient(unittest.TestCase):
def test_a_client_node_is_pinned_to_the_providers_allowlist(self) -> None:
module = load_script()
node = module.client_node(PROVIDER, REFERENCE, 2)
self.assertEqual("@n8n/n8n-nodes-langchain.mcpClientTool", node["type"])
self.assertEqual("selected", node["parameters"]["include"])
self.assertEqual(
["gitea_get_issue", "gitea_list_repos"],
node["parameters"]["includeTools"],
)
self.assertEqual("http://gitea:3000/mcp/sse", node["parameters"]["sseEndpoint"])
self.assertEqual("bearerAuth", node["parameters"]["authentication"])
def test_a_tool_the_provider_calls_mutating_never_reaches_the_agent(self) -> None:
module = load_script()
provider = {
"id": "web-app-baserow",
"url": "http://baserow:8080/mcp",
"token": "b" * 40,
"tools": ["list_databases", "list_tables", "create_rows"],
"mutating": ["create_rows"],
}
node = module.client_node(provider, REFERENCE, 2)
self.assertEqual(
["list_databases", "list_tables"], node["parameters"]["includeTools"]
)
def test_a_provider_without_an_allowlist_gets_no_tools(self) -> None:
module = load_script()
bare = {"id": "web-app-x", "url": "http://x/sse", "token": "t"}
node = module.client_node(bare, REFERENCE, 2)
self.assertEqual([], node["parameters"]["includeTools"])
self.assertEqual("selected", node["parameters"]["include"])
def test_every_client_node_reaches_the_agent(self) -> None:
module = load_script()
body = module.workflow_body({"web-app-gitea": REFERENCE})
self.assertEqual(
{"ai_tool": [[{"node": "Infinito Agent", "type": "ai_tool", "index": 0}]]},
body["connections"]["mcp_web_app_gitea"],
)
self.assertEqual("infinito:mcp-agent", body["name"])
def test_the_agent_reaches_the_gateway_model(self) -> None:
module = load_script()
body = module.workflow_body({"web-app-gitea": REFERENCE})
model = next(node for node in body["nodes"] if node["id"] == "infinito-model")
self.assertEqual("llama3", model["parameters"]["model"])
self.assertEqual(
{"openAiApi": {"name": "infinito:litellm"}}, model["credentials"]
)
def test_each_provider_gets_its_own_named_bearer(self) -> None:
module = load_script()
api = FakeApi()
with patch.object(module, "call", api):
module.main()
self.assertEqual(
["infinito:mcp-client:web-app-gitea"],
[item["name"] for item in api.written_credentials],
)
self.assertEqual({"token": "g" * 40}, api.written_credentials[0]["data"])
def test_the_agent_workflow_is_never_activated(self) -> None:
module = load_script()
api = FakeApi()
with patch.object(module, "call", api):
module.main()
self.assertEqual(
["infinito:mcp-agent"], [flow["name"] for flow in api.created_workflows]
)
def test_the_run_leaves_no_api_key_behind(self) -> None:
module = load_script()
api = FakeApi()
with patch.object(module, "call", api):
module.main()
self.assertEqual(["k1"], api.deleted_keys)
def test_a_rotated_provider_token_replaces_the_stored_one(self) -> None:
module = load_script()
api = FakeApi(credentials=[dict(REFERENCE)])
with patch.object(module, "call", api):
module.main()
sent = api.written_credentials
self.assertEqual([{"token": "g" * 40}], [item["data"] for item in sent])
self.assertNotIn("stale", json.dumps(sent))
def test_a_provider_that_vanished_loses_its_credential(self) -> None:
module = load_script({"N8N_MCP_PROVIDERS": "[]"})
api = FakeApi(
credentials=[
{"id": "gone", "name": "infinito:mcp-client:web-app-openproject"},
{"id": "human", "name": "my own bearer"},
]
)
with patch.object(module, "call", api):
module.main()
self.assertEqual(["gone"], api.deleted_credentials)
def test_the_agent_workflow_goes_when_no_provider_is_left(self) -> None:
module = load_script({"N8N_MCP_PROVIDERS": "[]"})
api = FakeApi(
workflows=[{"id": "agent", "name": "infinito:mcp-agent", "active": False}]
)
with patch.object(module, "call", api):
module.main()
self.assertEqual(["agent"], api.deleted_workflows)
def test_nothing_happens_when_there_is_nothing_to_reach(self) -> None:
module = load_script({"N8N_MCP_PROVIDERS": "[]"})
api = FakeApi(credentials=[{"id": "human", "name": "my own bearer"}])
out = io.StringIO()
with patch.object(module, "call", api), contextlib.redirect_stdout(out):
module.main()
self.assertEqual("OK", out.getvalue().strip())
self.assertEqual([], api.deleted_credentials)
self.assertEqual([], api.created_workflows)
def test_a_converged_deployment_reruns_unchanged(self) -> None:
module = load_script()
api = FakeApi(
credentials=[REFERENCE],
workflows=[
{
"id": "agent",
"name": "infinito:mcp-agent",
"nodes": module.workflow_body({"web-app-gitea": REFERENCE})[
"nodes"
],
}
],
)
out = io.StringIO()
with patch.object(module, "call", api), contextlib.redirect_stdout(out):
module.main()
self.assertEqual("OK", out.getvalue().strip())
def test_two_managed_workflows_abort_rather_than_guess(self) -> None:
module = load_script()
api = FakeApi(
workflows=[
{"id": "a", "name": "infinito:mcp-agent"},
{"id": "b", "name": "infinito:mcp-agent"},
]
)
with patch.object(module, "call", api), self.assertRaises(SystemExit) as exit_:
module.main()
self.assertIn("2 workflows", str(exit_.exception))
def test_two_managed_credentials_abort_rather_than_guess(self) -> None:
module = load_script()
api = FakeApi(
credentials=[
{"id": "a", "name": "infinito:mcp-client:web-app-gitea"},
{"id": "b", "name": "infinito:mcp-client:web-app-gitea"},
]
)
with patch.object(module, "call", api), self.assertRaises(SystemExit) as exit_:
module.main()
self.assertIn("2 credentials", str(exit_.exception))
if __name__ == "__main__":
unittest.main()
@@ -2,7 +2,10 @@
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import unittest
from unittest.mock import patch
@@ -42,9 +45,12 @@ class FakeApi:
self.created_workflows = []
self.updated_workflows = []
self.activated_workflows = []
self.deleted_workflows = []
self.written_credentials = []
self.deleted_credentials = []
self.deleted_keys = []
self.logins = 0
self.minted = 0
def __call__(self, path, method="GET", payload=None, api_key=None):
if path == "/rest/login":
@@ -53,12 +59,19 @@ class FakeApi:
if path == "/rest/credentials" and method == "GET":
return 200, {"data": [dict(item) for item in self.credentials]}
if path == "/rest/credentials" and method == "POST":
self.minted += 1
new = {"id": f"c{self.minted}", "name": payload["name"]}
self.written_credentials.append(payload)
self.credentials = [{"id": "c1", "name": payload["name"]}]
return 200, {"data": {"id": "c1", "name": payload["name"]}}
self.credentials.append(new)
return 200, {"data": new}
if path.startswith("/rest/credentials/") and method == "PATCH":
self.written_credentials.append(payload)
return 200, {"data": {"id": path.rsplit("/", 1)[-1]}}
if path.startswith("/rest/credentials/") and method == "DELETE":
dead = path.rsplit("/", 1)[-1]
self.deleted_credentials.append(dead)
self.credentials = [c for c in self.credentials if c["id"] != dead]
return 200, {"success": True}
if path == "/rest/api-keys" and method == "GET":
return 200, {"data": [dict(key) for key in self.keys]}
if path == "/rest/api-keys" and method == "POST":
@@ -78,6 +91,9 @@ class FakeApi:
if path.endswith("/activate") and method == "POST":
self.activated_workflows.append(path.split("/")[-2])
return 200, {"active": True}
if path.startswith("/api/v1/workflows/") and method == "DELETE":
self.deleted_workflows.append(path.rsplit("/", 1)[-1])
return 200, {"success": True}
raise AssertionError(f"unexpected {method} {path}")
@@ -128,6 +144,19 @@ class TestProvisionMcp(unittest.TestCase):
node = api.created_workflows[0]["nodes"][0]
self.assertEqual("c9", node["credentials"]["httpBearerAuth"]["id"])
def test_a_rotated_bearer_replaces_the_one_the_credential_carries(self) -> None:
module = load_script()
api = FakeApi(credentials=[{"id": "c9", "name": "infinito:mcp-bearer"}])
with patch.object(module, "call", api):
module.main()
self.assertEqual(
[{"token": "t" * 48}],
[item["data"] for item in api.written_credentials],
"n8n never hands an existing credential's secret back, so the "
"managed one is overwritten rather than compared; a run that "
"skipped the write would leave the revoked bearer in place",
)
def test_two_managed_credentials_abort_rather_than_guess(self) -> None:
module = load_script()
api = FakeApi(
@@ -153,7 +182,14 @@ class TestProvisionMcp(unittest.TestCase):
api = FakeApi(keys=[{"id": "old", "label": "infinito:mcp"}])
with patch.object(module, "call", api):
module.main()
self.assertEqual(["old"], api.deleted_keys)
self.assertEqual(["old", "k1"], api.deleted_keys)
def test_the_run_leaves_no_api_key_behind(self) -> None:
module = load_script()
api = FakeApi()
with patch.object(module, "call", api):
module.main()
self.assertEqual(["k1"], api.deleted_keys)
def test_two_managed_keys_abort_rather_than_guess(self) -> None:
module = load_script()
@@ -198,6 +234,79 @@ class TestProvisionMcp(unittest.TestCase):
self.assertEqual("bearerAuth", node["parameters"]["authentication"])
self.assertIn("httpBearerAuth", node["credentials"])
def test_the_only_served_tool_is_the_connected_one(self) -> None:
module = load_script()
body = module.workflow_body(REFERENCE)
tool = body["nodes"][1]
self.assertEqual("@n8n/n8n-nodes-langchain.toolCode", tool["type"])
self.assertEqual(
{
"infinito_health": {
"ai_tool": [
[
{
"node": "Infinito MCP Trigger",
"type": "ai_tool",
"index": 0,
}
]
]
}
},
body["connections"],
)
def test_the_served_tool_declares_its_input_schema(self) -> None:
module = load_script()
tool = module.workflow_body(REFERENCE)["nodes"][1]
self.assertIs(True, tool["parameters"]["specifyInputSchema"])
self.assertEqual("manual", tool["parameters"]["schemaType"])
schema = json.loads(tool["parameters"]["inputSchema"])
self.assertEqual(["echo"], schema["required"])
self.assertIs(False, schema["additionalProperties"])
def test_the_served_tool_returns_a_string(self) -> None:
module = load_script()
tool = module.workflow_body(REFERENCE)["nodes"][1]
self.assertIn("JSON.stringify", tool["parameters"]["jsCode"])
def test_a_converged_deployment_reruns_unchanged(self) -> None:
module = load_script()
credential = {"id": "c9", "name": "infinito:mcp-bearer"}
api = FakeApi(
credentials=[credential],
workflows=[
{
"id": "w1",
"name": "infinito:mcp-server",
"active": True,
"nodes": module.workflow_body(credential)["nodes"],
}
],
)
out = io.StringIO()
with patch.object(module, "call", api), contextlib.redirect_stdout(out):
module.main()
self.assertEqual("OK", out.getvalue().strip())
def test_resources_outside_the_managed_names_are_left_alone(self) -> None:
module = load_script()
api = FakeApi(
keys=[{"id": "human-key", "label": "someone else"}],
credentials=[{"id": "human-cred", "name": "someone else"}],
workflows=[{"id": "human-flow", "name": "someone else", "active": True}],
)
with patch.object(module, "call", api):
module.main()
self.assertNotIn("human-key", api.deleted_keys)
self.assertEqual([], api.updated_workflows)
self.assertEqual(
["infinito:mcp-server"], [flow["name"] for flow in api.created_workflows]
)
self.assertEqual(
["infinito:mcp-bearer"], [item["name"] for item in api.written_credentials]
)
def test_two_managed_workflows_abort_rather_than_guess(self) -> None:
module = load_script()
api = FakeApi(