Skip to content

Expose the MCP server to external Coding Agents: OAuth 2.1 Resource Server + authorization hardening #86

Description

@IzBrain67

Summary

We want external Coding Agents (Claude Code and similar MCP clients) to be able to drive NeuroWorkflow directly, so an agent loop can run against the platform from outside the browser.

The MCP server itself is already written and useful (46 tools in gui/mcp_server/workflow_mcp.py). What is missing is an authentication and authorization layer around it. Today the MCP server performs no authentication of its ownmcp = FastMCP("workflow") (gui/mcp_server/workflow_mcp.py:117) is constructed without an auth provider, and _build_headers() (workflow_mcp.py:25-47) simply forwards whatever Authorization header the caller sent on to the Django API.

That is the exact pattern the MCP specification calls "token passthrough" and forbids: an MCP server acts as an OAuth 2.1 Resource Server and must validate that a token was issued for it before doing anything with it.

Note that /mcp/ is already routed by the reverse proxy (gui/nginx/neuro-workflow-proxy.conf:29-43, gui/nginx/neuro-workflow.conf:86-97), so this is not a greenfield "add a new endpoint" task — the route exists and needs to be brought under authentication before we point external agents at it.

This issue covers the full path: close the current gap, make the server spec-compliant, tighten authorization to a level appropriate for automated callers, and document how to connect Claude Code.

Assumptions

Stated up front so they can be challenged:

Item Assumption
Network exposure Internet-facing over HTTPS on the existing domains. If we instead restrict to campus/VPN, Phase B discovery routing gets simpler and Phase C drops in priority.
Identity provider Reuse the existing Keycloak realm neuroworkflow as the OAuth 2.1 authorization server. There is no PAT/API-key mechanism in the backend today, and adding one is more work and more risk than using what we already run.
Tool scope Phase 1 ships the current read + edit tools. A workflow execution tool is deliberately deferred to Phase D, gated on a separate scope.

Phase A — Close the current gap

Small, self-contained PR. Should land before anything else.

  • Remove the public /mcp/ route from gui/nginx/neuro-workflow-proxy.conf and gui/nginx/neuro-workflow.conf until Phase B lands.
    Safe to remove: the frontend never calls MCP directly. MCP_BASE_URL in gui/workflow_frontend/src/config/urls.ts is exported but never imported anywhere. Browser chat goes /api/chat/stream/ → Django MCPClienthttp://mcp:8001 over the internal workflow Docker network, so nothing user-facing breaks.
  • Delete the DJANGO_API_TOKEN fallback (gui/mcp_server/workflow_mcp.py:20,46). It is not set in any compose file or .env, so it is currently dead — but "if no auth header was supplied, use a privileged token instead" is a fail-open shape we should not keep around. Return an error instead.
  • Add a visibility check to viewer_file (app/workflow/views.py:1063-1076, routed at config/urls.py:31). It currently has no authentication class and no visibility check, so project files are served to anyone who knows the project UUID. Path traversal is already blocked by django.views.static.serve, but private project data is not. Minimum: serve only visibility == PUBLIC. If the brain-viewer iframe genuinely needs private projects, replace with a short-lived signed URL (project UUID + expiry + HMAC).
  • Remove stale artifacts: gui/mcp_server/.ipynb_checkpoints/ still contains a Dockerfile referencing a proxy.py and mcp_config.json that no longer exist.
  • X-Internal-Secret is dead code — the frontend always sends it (src/api/authHeaders.ts:8, value from src/api/config.ts:20) and it is allow-listed in CORS_ALLOW_HEADERS (config/settings.py:168), but no middleware or view ever reads it. Remove it, or comment it as non-functional, before someone mistakes it for a security control.

Verification: POST /mcp returns 404 through the proxy; browser chat and the notebook %chat agent still work unchanged.


Phase B — Make the MCP server an OAuth 2.1 Resource Server

Goal: comply with the MCP authorization spec so that claude mcp login works out of the box via browser-based OAuth.

B-1. Dedicated Keycloak client

Add neuroworkflow-mcp to gui/keycloak/realm-export.json (today the realm has only neuroworkflow-app):

  • publicClient: true, standard flow, PKCE required (pkce.code.challenge.method: S256), direct access grants off
  • Redirect URIs http://localhost:8123/callback and http://127.0.0.1:8123/callback (fixed port, matched by --callback-port on the client side)
  • Audience mapper injecting neuroworkflow-mcp into aud
  • Client scopes workflow:read, workflow:write, and optional nodes:write (used in Phase C)

Two Keycloak-specific constraints worth recording, because both will silently break the flow otherwise:

  1. Keycloak does not implement RFC 8707. MCP clients are required to send resource=https://<host>/mcp on both the authorization and token request, and Keycloak ignores it. The audience must therefore be injected by a hardcoded audience mapper on the client. Without this, aud validation fails every time.
  2. Do not enable Dynamic Client Registration. Keycloak's DCR is off by default and enabling anonymous registration widens the attack surface. Use a pre-registered client instead; Claude Code supports this via --client-id / --callback-port.

B-2. Wire up RemoteAuthProvider

FastMCP v2 (already pinned at fastmcp>=2.12.4 in gui/mcp_server/Dockerfile) supports this natively. Replace workflow_mcp.py:117:

from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from pydantic import AnyHttpUrl

auth = RemoteAuthProvider(
    token_verifier=JWTVerifier(
        jwks_uri=f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs",
        issuer=KEYCLOAK_ISSUER,
        audience=MCP_AUDIENCE,          # "neuroworkflow-mcp"
    ),
    authorization_servers=[AnyHttpUrl(KEYCLOAK_ISSUER)],
    base_url=MCP_PUBLIC_URL,            # https://<host>/mcp
)
mcp = FastMCP("workflow", auth=auth)

This gives us, for free:

  • 401 + WWW-Authenticate: Bearer resource_metadata="..." on unauthenticated requests

  • a generated /.well-known/oauth-protected-resource document (RFC 9728)

  • signature / iss / aud / expiry validation

  • Rewrite _build_headers() to read the verified token rather than blindly forwarding get_http_headers(include={"authorization"})

  • Add KEYCLOAK_ISSUER, MCP_PUBLIC_URL, MCP_AUDIENCE to the mcp service in gui/docker-compose.yml / docker-compose.prod.yml (note: docker-compose.prod.yml currently adds no override for mcp at all and inherits the dev definition verbatim, including the bind-mounted source file — worth fixing at the same time, related to Minimize accidental Dev/Prod configuration drift #45)

B-3. Reverse-proxy routes for discovery

Easy to miss, and the flow fails without it. Under RFC 9728, metadata for the resource https://<host>/mcp lives at https://<host>/.well-known/oauth-protected-resource/mcp. Since location / currently proxies to the frontend, that path is swallowed by the SPA and client discovery fails.

location ~ ^/\.well-known/oauth-protected-resource {
    proxy_pass http://127.0.0.1:8001;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
}
  • Also confirm on a real deployment that Keycloak's authorization-server metadata is reachable in the form clients probe for. Keycloak sits under the /auth/ prefix, so both /auth/realms/neuroworkflow/.well-known/oauth-authorization-server and the RFC 8414 path-insertion form /.well-known/oauth-authorization-server/auth/realms/neuroworkflow should be checked; add an nginx rewrite if the latter is required.

B-4. Accept MCP-issued tokens in Django

  • _verify_keycloak_client() (app/auth/authentication.py:55-77) currently accepts a token only when azp == KEYCLOAK_CLIENT_ID or KEYCLOAK_CLIENT_ID in aud. Generalize to a list, KEYCLOAK_CLIENT_IDS, mirroring how KEYCLOAK_ISSUERS already handles multiple issuers.
  • Add a TTL to _jwks_cache (app/auth/authentication.py:17-30). It is a module-level dict that is only refreshed on a kid miss, so a key retired during rotation stays trusted until the process restarts. ~10 minutes is fine.

On the downstream hop. Strictly, forwarding the received token onward is the shape the spec dislikes. The clean answer is Keycloak Token Exchange (RFC 8693; standard token exchange is GA in Keycloak 26.2) to swap for a neuroworkflow-app-audience token, but that needs a confidential client and more plumbing.

Proposal for this phase: have the audience mapper emit aud: ["neuroworkflow-mcp", "neuroworkflow-app"], and have the MCP server validate that the token is addressed to it before forwarding. That satisfies the core requirement — the server no longer accepts tokens that were not issued for it — and leaves Token Exchange as a follow-up once Phase C is done. Recorded as a TODO rather than silently skipped.

Verification:

# unauthenticated -> 401 with WWW-Authenticate
curl -i -X POST https://<host>/mcp -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'

# protected resource metadata resolves
curl -s https://<host>/.well-known/oauth-protected-resource/mcp | jq

# authorization server metadata resolves
curl -s https://<host>/auth/realms/neuroworkflow/.well-known/oauth-authorization-server | jq

# a token minted for a different client is rejected
curl -i -X POST https://<host>/mcp -H "Authorization: Bearer <token with wrong aud>" ...

Backend tests to add under gui/workflow_backend/django-project/tests/: Django accepts a neuroworkflow-mcp-issued token; the JWKS cache refetches after TTL expiry.


Phase C — Authorization hardening

Phase B establishes who the caller is. It does not establish what they may do — and right now that is close to "any authenticated user can do almost anything." This needs to be tightened before we hand credentials to an automated agent.

C-1. Node code library ownership check (highest priority)

app/box/views.py:27:

def _can_modify_python_file(user, python_file):
    return not python_file.uploaded_by_id or python_file.uploaded_by_id == user.id

Node files synced from disk have uploaded_by = NULL, so the first clause treats every built-in node as editable by any authenticated user. python_file_service.py:170 writes the submitted body straight to codes/nodes/<category>/<name>.py, and that directory is bind-mounted read-write into the Jupyter kernel container and sits on the kernel's PYTHONPATH (jupyterhub_config.py:41,67; gui/docker-compose.yml:21).

In other words, the node library is a write channel into the code that later executes, and modifying a trusted existing node is currently no harder than adding a new one. The corresponding MCP tools (update_python_file_code, upload_python_file, copy_python_file, bulk_sync_nodes) sit directly on top of it.

  • Change the rule to owner-only, with NULL (catalog-synced) files restricted to administrators
  • Require an explicit scope (nodes:write, below) on the MCP side for all four tools

This one should land regardless of whether we proceed with external exposure.

C-2. Scope separation

Define as Keycloak client scopes, enforce via JWTVerifier(required_scopes=...) plus per-tool checks:

Scope Tools
workflow:read list_projects, get_flow, get_node, list_edges, get_workflow_facts, read-side viewer_*, health
workflow:write create_project, update_flow, add_node, update_node*, delete_node, add_edge, delete_edge, save_report, generate_code_batch
nodes:write upload_python_file, update_python_file_code, copy_python_file, bulk_sync_nodes — effectively code-execution authority, see C-1
  • Grant workflow:read + workflow:write by default; make nodes:write an optional scope requiring explicit consent
  • On insufficient scope, return 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="nodes:write" so clients can perform step-up authorization (Claude Code handles this automatically)

Related consideration: app/workflow/permissions.py makes public projects writable by any authenticated user (get_accessible_project(..., write=True) returns the project when visibility == PUBLIC; only DELETE and visibility changes are owner-only). That is an intentional product decision for humans in the GUI, but it reads differently once automated agents are calling the same API. Worth an explicit decision rather than inheriting it by default.

C-3. Rate limiting

There is none anywhere today: no DEFAULT_THROTTLE_CLASSES in config/settings.py:121-134, no limit_req in either nginx config.

  • nginx limit_req on /mcp/ and /api/chat/
  • DRF throttles, with tighter limits on app/chat/* since those calls bill against the org's OpenAI/Anthropic keys

C-4. Audit logging

  • Emit structured logs from the MCP server: verified token sub / client_id, tool name, target project ID. If agents are writing autonomously, we need to be able to reconstruct what happened after the fact.

Phase D — Claude Code connectivity

D-1. Connection procedure

claude mcp add --transport http \
  --client-id neuroworkflow-mcp \
  --callback-port 8123 \
  --scope user \
  neuroworkflow https://<host>/mcp

claude mcp login neuroworkflow      # browser-based Keycloak login
  • claude mcp login neuroworkflow --no-browser for headless/SSH environments (prints the authorization URL, accepts the redirect URL pasted back; needs ssh -t)
  • claude mcp list shows ✔ Connected / ! Needs authentication
  • Tokens are stored by Claude Code and refreshed automatically; claude mcp logout neuroworkflow clears them

For CI or non-interactive use, headersHelper generates headers at connection time so no secret is written into the config file:

{ "mcpServers": { "neuroworkflow": {
    "type": "http",
    "url": "https://<host>/mcp",
    "headersHelper": "/opt/bin/neuroworkflow-token.sh"
}}}

(That script would use a client-credentials grant against a separate confidential Keycloak client, subject to the same scope model as C-2.)

D-2. Whether to add an execution tool — decision needed

There is currently no run_workflow MCP tool. Fully realizing "run an agent loop from outside" needs one, wrapping POST /api/workflow/{id}/run/.

Recommend holding off until the following are true, because workflow execution currently runs in a shared context:

  1. C-1 landed
  2. execute:workflow exists as its own scope and is not granted by default
  3. Execution is separated per user — today JUPYTER_EXECUTION_USER defaults to a single shared account (user1), so every user's workflow runs in the same kernel container, which is spawned with GRANT_SUDO=yes. This overlaps with JupyterLab project files are not isolated per user or named per project (incl. output folder) #28.
  4. NEUROWORKFLOW_SERVICE_TOKEN is no longer the same value as the JupyterHub admin token. Today JUPYTERHUB_API_TOKEN serves as the JupyterHub admin API token, the credential for AnthropicProxyView (app/chat/views.py:274-352), and an environment variable readable from inside every user kernel. docs/NOTEBOOK_CHAT_AGENT.md:211 accepts this explicitly as a trusted-lab tradeoff, which is a reasonable call for the current deployment but should be revisited before external agents are in scope.

Items 3 and 4 are separable work. If we want to ship MCP execution sooner, the alternative is to document explicitly that MCP-driven execution is limited to trusted users within the lab.

D-3. Documentation

  • New docs/MCP_REMOTE_ACCESS.md: architecture, Keycloak client setup, claude mcp add walkthrough, scope table, troubleshooting
  • docs/ARCHITECTURE_FULL.md §3.4/§4.8/§5 and docs/ARCHITECTURE_DIAGRAM.svg still describe a proxy.py + mcp_config.json entry point and a utils/mcp/ directory, none of which exist. The actual entry point is workflow_mcp.py, configured via MCP_PORT + DJANGO_API_URL, authenticated by Keycloak.
  • Update CLAUDE.md:134-135

Files touched

File Phase
gui/nginx/neuro-workflow-proxy.conf, neuro-workflow.conf A, B-3, C-3
gui/mcp_server/workflow_mcp.py A, B-2, C-2, C-4
gui/keycloak/realm-export.json B-1, C-2
gui/workflow_backend/django-project/app/auth/authentication.py B-4
gui/workflow_backend/django-project/app/box/views.py C-1
gui/workflow_backend/django-project/app/workflow/views.py A
gui/workflow_backend/django-project/config/settings.py C-3
gui/docker-compose.yml, docker-compose.prod.yml B-2
docs/MCP_REMOTE_ACCESS.md (new), docs/ARCHITECTURE_FULL.md, CLAUDE.md A, D-3

End-to-end check once Phase B lands: claude mcp add ... && claude mcp login neuroworkflowclaude mcp list reports ✔ Connected → asking Claude Code to list projects results in an actual tool call.

Note for whoever runs the backend tests: /app inside the backend container is a stale build-time snapshot. The live code is the /django-app bind mount, which is also the working directory. Don't cd /app.


Open questions

  1. Network exposure — internet-facing, or restrict to campus/VPN? Restricting simplifies Phase B and de-prioritizes Phase C, at the cost of ruling out external collaborators and cloud-hosted agents.
  2. Execution tool (D-2) — add it, and if so, which prerequisites do we clear first?
  3. Token Exchange — migrate to RFC 8693 after Phase C, or keep the dual-audience approach?
  4. Shared service token — out of scope here, but JUPYTERHUB_API_TOKEN doing three jobs at once deserves its own issue.

Related: #28 (per-user execution isolation, prerequisite for D-2), #45 (dev/prod configuration drift, overlaps with the compose changes in B-2).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions