Files
kevinveenbirkenbachandClaude Opus 5.5 ad99ded588 fix(web-app-n8n): stop paging when n8n hands back a page it already sent
Both provisioners follow nextCursor for workflows and step skip for API
keys until a short page arrives. A cursor n8n repeats, or a key listing
that ignores skip, would keep that loop running forever and hang the
deploy instead of failing it.

workflows_named now remembers every cursor it followed and exits with
"FAILED listing workflows: nextCursor repeats" when one comes back.
api_keys_labelled exits when a non-empty page brings no key id it has not
already seen. Normal paging is unchanged.

Four regression tests drive both scripts against a fake API that repeats
its cursor or ignores skip; without the guards exactly those four fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:06:29 +02:00

415 lines
14 KiB
Python

"""Provision the deployment-managed MCP surfaces of n8n 1.100.1.
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_MCP_PATH: webhook path segment of the managed trigger.
N8N_MCP_TOKEN: bearer the trigger requires from clients.
N8N_MCP_WORKFLOW: deterministic name of the managed workflow.
N8N_MCP_CREDENTIAL: deterministic name of the managed bearer credential.
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
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", "")
MCP_PATH = os.environ.get("N8N_MCP_PATH", "")
MCP_TOKEN = os.environ.get("N8N_MCP_TOKEN", "")
WORKFLOW_NAME = os.environ.get("N8N_MCP_WORKFLOW", "")
CREDENTIAL_NAME = os.environ.get("N8N_MCP_CREDENTIAL", "")
REST = "/rest"
PUBLIC = "/api/v1"
PAGE = 250
TRIGGER_TYPE = "@n8n/n8n-nodes-langchain.mcpTrigger"
TRIGGER_VERSION = 2
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": ""}
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 shape(value):
"""Describe a value by its type and, for a mapping, its keys, never its content.
Args:
value: decoded response fragment.
"""
if isinstance(value, dict):
return f"a mapping with keys {sorted(map(str, value))}"
if isinstance(value, list):
return f"a list of {sorted({type(item).__name__ for item in value})}"
return f"a {type(value).__name__}"
def records(body, what):
"""Return the records of a list response.
Args:
body: decoded body: ``{"data": [...]}``, a bare list, or the paged
``{"data": {"items": [...], ...}}`` of n8n 2.
what: noun naming the collection, for the failure message.
"""
listed = body.get("data", body) if isinstance(body, dict) else body
if isinstance(listed, dict) and "items" in listed:
listed = listed["items"]
if listed is None:
return []
if not isinstance(listed, list) or not all(
isinstance(item, dict) for item in listed
):
sys.exit(f"FAILED listing {what}: expected records, got {shape(listed)}")
return listed
def record(body, what):
"""Return the single record of a create response.
Args:
body: decoded body, either ``{"data": {...}}`` or a bare mapping.
what: noun naming the record, for the failure message.
"""
created = body.get("data", body) if isinstance(body, dict) else body
if not isinstance(created, dict):
sys.exit(f"FAILED creating {what}: expected a record, got {shape(created)}")
return created
def api_keys_labelled(label):
"""Return every API key whose label n8n matches against ``label``, all pages.
Args:
label: text n8n looks for inside each label.
"""
found = []
seen = set()
while True:
query = urllib.parse.urlencode(
{"label": label, "take": PAGE, "skip": len(found)}
)
status, body = call(f"{REST}/api-keys?{query}")
if status != 200:
sys.exit(f"FAILED listing api keys: {status} {body}")
page = records(body, "api keys")
ids = {str(key.get("id")) for key in page}
if page and ids <= seen:
sys.exit(f"FAILED listing api keys: page at skip {len(found)} repeats")
seen |= ids
found.extend(page)
if len(page) < PAGE:
return found
def workflows_named(key, name):
"""Return every workflow n8n matches against ``name``, following ``nextCursor``.
Args:
key: the public-API key.
name: workflow name n8n filters on.
"""
found = []
cursors = set()
query = {"name": name, "limit": PAGE}
while True:
status, body = call(
f"{PUBLIC}/workflows?{urllib.parse.urlencode(query)}", api_key=key
)
if status != 200:
sys.exit(f"FAILED listing workflows: {status} {body}")
found.extend(records(body, "workflows"))
cursor = body.get("nextCursor") if isinstance(body, dict) else None
if not cursor:
return found
if cursor in cursors:
sys.exit("FAILED listing workflows: nextCursor repeats")
cursors.add(cursor)
query = {"name": name, "cursor": cursor}
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. n8n matches
``label`` as a substring, so the exact match happens here.
"""
matches = [
key for key in api_keys_labelled(KEY_NAME) 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:activate",
],
"expiresAt": None,
},
)
if status not in (200, 201):
sys.exit(f"FAILED creating api key {KEY_NAME}: {status} {body}")
created = record(body, f"api key {KEY_NAME}")
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():
"""Return ``(reference, created)`` of the managed ``httpBearerAuth`` credential.
The reference is the ``{"id", "name"}`` shape a node's ``credentials`` map
expects. n8n encrypts credential data at rest and never hands it back, so an
existing managed credential is overwritten rather than compared.
"""
status, body = call(f"{REST}/credentials")
if status != 200:
sys.exit(f"FAILED listing credentials: {status} {body}")
matches = [
item
for item in records(body, "credentials")
if item.get("name") == CREDENTIAL_NAME
]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} credentials named {CREDENTIAL_NAME}")
payload = {
"name": CREDENTIAL_NAME,
"type": CREDENTIAL_TYPE,
"data": {"token": MCP_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 {CREDENTIAL_NAME}: {status} {body}")
created = record(body, CREDENTIAL_NAME)
return {"id": str(created["id"]), "name": CREDENTIAL_NAME}, True
status, body = call(
f"{REST}/credentials/{matches[0]['id']}", method="PATCH", payload=payload
)
if status != 200:
sys.exit(f"FAILED updating {CREDENTIAL_NAME}: {status} {body}")
return {"id": str(matches[0]["id"]), "name": CREDENTIAL_NAME}, False
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.
"""
return {
"name": WORKFLOW_NAME,
"settings": {"executionOrder": "v1"},
"nodes": [
{
"id": "infinito-mcp-trigger",
"name": TRIGGER_NAME,
"type": TRIGGER_TYPE,
"typeVersion": TRIGGER_VERSION,
"position": [0, 0],
"webhookId": "infinito-mcp",
"parameters": {
"path": MCP_PATH,
"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": {
TOOL_NAME: {
TOOL_CONNECTION: [
[{"node": TRIGGER_NAME, "type": TOOL_CONNECTION, "index": 0}]
]
}
},
}
def activate_workflow(key, workflow_id):
"""Activate the managed workflow, failing loudly when n8n refuses.
Args:
key: the public-API key.
workflow_id: id of the managed workflow.
"""
status, body = call(
f"{PUBLIC}/workflows/{workflow_id}/activate", method="POST", api_key=key
)
if status != 200:
sys.exit(f"FAILED activating {WORKFLOW_NAME}: {status} {body}")
def upsert_workflow(key, credential):
"""Create or reconcile the managed MCP server workflow, then activate it.
Args:
key: the public-API key.
credential: ``{"id", "name"}`` reference to the managed bearer credential.
"""
matches = [
flow
for flow in workflows_named(key, WORKFLOW_NAME)
if flow.get("name") == WORKFLOW_NAME
]
if len(matches) > 1:
sys.exit(f"FAILED: {len(matches)} workflows named {WORKFLOW_NAME}")
payload = workflow_body(credential)
if not matches:
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}")
if not isinstance(body, dict) or "id" not in body:
sys.exit(f"FAILED creating {WORKFLOW_NAME}: no id in {body}")
activate_workflow(key, body["id"])
return True
flow = matches[0]
changed = flow.get("nodes") != payload["nodes"] or not flow.get("active")
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}")
activate_workflow(key, flow["id"])
return changed
def main():
if not MCP_TOKEN:
sys.exit("FAILED: no bearer configured; the trigger would be unauthenticated")
login()
key, key_id = api_key()
credential, minted = bearer_credential()
changed = upsert_workflow(key, credential)
revoke_key(key_id)
print(f"{'CHANGED' if changed or minted else 'OK'}")
if __name__ == "__main__":
main()