Enterprise

External MCP access

Connecting external MCP clients: authentication and the exposed surface.

Maturity: stable (85 %)

MCP. Each agent's gated tools are served to outside MCP clients with OAuth 2.1 or per-agent keys, and agents use external MCP servers and interactive MCP App cards.

  • Keep the MCP port private unless remote MCP clients are intended.
  • Treat a per-agent MCP key like its creator's own credential.
  • The MCP Apps of one user share a single sandbox origin per deployment.

How maturity is measured

The package protocol runs in both directions: packages bring capabilities into Neuralis, and the embedded MCP (Model Context Protocol) HTTP service projects them back out — so external clients like Claude Desktop, Cursor, VS Code, ChatGPT, or any custom MCP client can use the platform's tools, prompts, and resources from outside the workspace. The same security layers that govern the in-app surface apply at this boundary; an external client is just another authenticated caller with a scope.

This page covers the inbound direction — how an outside client authenticates to Neuralis. The opposite direction (an agent connecting out to someone else's MCP server, and the credentials that go with it) is described under consuming external MCP servers.

The endpoint

The service listens on its own port (3101 by default), separate from the app on 3100:

  • POST /mcp, GET /mcp (SSE), DELETE /mcp — the MCP protocol endpoint, implementing initialize, tools/list, tools/call, resources/list, resources/read, resources/templates/list, resources/subscribe, resources/unsubscribe, prompts/list, prompts/get, and logging control.
  • /healthz — process liveness only (readiness lives on the app port; see deployment).
  • OAuth discovery and flow routes — /.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server, /oauth/authorize, /oauth/token, /oauth/jwks.json, and /oauth/register for dynamic client registration (RFC 7591). A URL-shaped client_id — the Client ID Metadata Document convention — is accepted and kept as that client's stable identifier, but the document it points at is never fetched. The capability stays advertised in the discovery document deliberately: a client that sees it withdrawn registers dynamically instead, and since a refresh token is bound to the client_id it was issued for, each re-registration orphans the previous token and sends the user back through a browser sign-in. Fetching the document is not implemented, and that is a considered choice — it would put an outbound request to a caller-supplied URL on an endpoint that requires no authentication.

The agent configuration panel generates ready-to-paste connection snippets along two axes that together cover every MCP client: transport (remote streamable HTTP, or a local mcp-remote stdio bridge for clients that only speak stdio) and auth (OAuth or a static API-key Bearer header). All use the near-universal mcpServers config key, with the common divergences called out inline (VS Code uses servers; Gemini CLI keys remote HTTP under httpUrl). OAuth is presented as the primary path — the per-agent API key is optional and only needed for headless clients that cannot run the browser sign-in.

Authentication

Exactly two paths authenticate an external request; there is no anonymous access:

  • OAuth 2.1 — the client registers (or is registered) and obtains a Bearer JWT; the caller's user/project/agent scope comes from the verified token claims. The token is audience-bound to this server's canonical resource, and an RFC 8707 resource indicator on the authorize/token requests is validated when present (a mismatch is rejected) — so a token can never be minted for, or replayed against, a different resource server. Refresh requests must present both refresh_token and the registered client_id. Neuralis validates the client binding before rotating the token; an omitted or mismatched id cannot consume a valid token. File-backed rotations are serialized so concurrent requests have one persisted successor. During an up-to-five-minute, process-local reuse interval that never outlives the successor it points at, multiple long-lived CLI processes sharing that client credential receive the same latest successor instead of invalidating one another; another client_id cannot use the interval. That window lives in memory only — it expires on its own clock and is cleared when the service restarts; nothing about it is written to disk.

    A refresh token's lifetime slides with use. Every successful rotation moves the token's expiry to thirty days from that moment, so a client that keeps refreshing is never sent back through a browser sign-in — including across a server restart or upgrade. The other half is the honest one: a token that is abandoned, or stolen and never used, still dies thirty days after its last use, and the expiry is checked before it slides, so a token whose last use is already older than the window is rejected however busy that client once was.

    Only a SHA-256 token hash is persisted in the private OAuth file. If that file ever becomes unreadable, the token endpoint answers a fixed 503 temporarily_unavailable instead of behaving as though no tokens existed — the store is rewritten as a whole, so a read that reported it empty would take every other user's connection with it on the next sign-in. Error responses on the OAuth routes are fixed strings; the underlying detail is written to the server log, never to the client.

  • API keys — sent either as a Bearer token or in an x-api-key header, for clients that cannot run the browser sign-in. A per-agent API key resolves to exactly that agent's user, project, and agent scope — ideal for wiring one agent into an external tool. The platform-level key resolves to the host's system identity and should be treated as an operator secret. Only the Bearer form is ever parsed as an OAuth token: a credential arriving in x-api-key is matched against known keys and nothing else.

API keys are stored in the encrypted credential store like every other secret.

Sessions

An MCP session is a routing handle, not a credential. The Mcp-Session-Id header says which conversation with the service a request belongs to. It never says who the caller is, and holding one grants nothing.

  • Every request re-authenticates. POST, GET and DELETE all resolve the caller's token or key before the request body is read and before the session is looked up. There is no path on which an established session excuses the Authorization header, and a caller whose token has expired or been withdrawn is answered 401 with a WWW-Authenticate challenge — the signal that makes a client refresh and retry on its own.
  • Authorization is re-resolved, not remembered. A session dispatches under the scope resolved for the request in hand. When a role loses a feature mid-session, the next tools/call is denied and the next tools/list stops advertising what it lost — no waiting for the session to age out. One limit belongs in the same breath: an SSE stream that is already open stays open, so notifications already flowing stop at the idle sweep rather than at the next request. Requests are denied immediately, and a denied request does not count as activity, which is what brings that sweep forward.
  • A session belongs to the principal that opened it. Presenting your own valid credential together with somebody else's session id is answered exactly as an unknown session is — same status, same body — so the answer never reveals whether that session exists.
  • A session the server no longer knows says so. An unknown or expired id is answered 404 with the standard Session not found JSON-RPC error; a request that omits the header entirely is answered 400. The 404 is the signal clients listen for: after a restart or an upgrade, a connected client opens a fresh session by itself, so reconnecting is not an operator step.
  • Reaching the per-user cap reclaims a slot rather than refusing the connection. Because of that self-healing — and because most clients never send an explicit close — the superseded session lingers until the idle sweep collects it. When a new session would exceed the per-user cap, the service closes that same user's oldest session that is not in use — never one holding an open SSE stream or a request in flight — and admits the new one. Only when every one of that user's own sessions is in use is the new connection refused with 429. The instance-wide cap behaves differently and always refuses: relieving it would mean closing somebody else's session to make room for this one.
  • Session limits are operational, not a security boundary — and they take effect on restart. The per-user and instance-wide concurrency caps, the idle timeout, the absolute lifetime and the sweep interval are platform settings, and each one's description says so, because the service reads all five once as it starts: saving a new value leaves the running service on the value it started with until it is restarted. (The MCP Allowed Origins list under boundary rules is the exception on this surface — it is re-read per request and takes effect immediately.) Raising a limit does not widen what any request may do, because the grant does not live in the session. What a longer idle timeout does lengthen is the one window above: how long an already-open stream survives before the sweep closes it. Shortening it to narrow that window is therefore a restart-time change, not a live revocation control — treat it as neither.

What is exposed

The service exposes the union of every loaded package's hosted contributions — tools, prompts, and resources — filtered for the authenticated scope. The listings reflect:

  • package enablement: contributions from a package disabled for the scope are absent,
  • feature grants: a contribution requiring features the caller does not hold is absent from the listing, never advertised as locked,
  • hosted-surface selection: packages declare which of their contributions are exposed over MCP at all.

A tools/call is dispatched to the owning package's lifecycle handler — the same code path as in-app tool execution. Results are size-bounded: text content is capped at 50,000 characters and structured content at 20,000, with JSON-safe shrinking rather than truncation mid-structure.

Hosted execute accepts authenticated connector execution without an agent stream when cwd names an exact registered executable source. For Webtop, the provider still requires exec.machine, current source scope and lifecycle authority, plus current provider hosting and package access. App/host shell, skill activation and missing connector cwd require a stream; MCP never creates a synthetic one or projects a platform ticket into Webtop.

Boundary rules

The MCP boundary enforces a stricter contract than internal surfaces:

  • No identity override. x-user-id, x-project-id, and x-agent-id headers from external callers are rejected — scope comes only from the verified token or key.
  • No synthetic identities. Token claims naming internal system identities are rejected before any dispatch.
  • Method allowlist. Only the MCP methods listed above (plus session notifications and ping) are accepted; anything else returns a JSON-RPC error. Batches are capped at 10 requests.
  • Origin allowlist. A request that carries an Origin header is rejected 403 unless that origin is allowlisted, and the check runs before any session lookup or tool work. A request with no Origin header is allowed — that is every non-browser MCP client — so an empty allowlist takes no capability away; what it stops is a web page on an unlisted origin driving the service through a visitor's browser. The list is the MCP Allowed Origins (mcpAllowedOrigins) platform setting: comma-separated, compared case-insensitively and without a trailing slash.
  • Session hygiene. Sessions have a per-user concurrency cap — at the cap a new session closes that same user's oldest not-in-use session instead of being refused — an instance-wide cap that always refuses, idle and absolute timeouts, and a periodic sweep; SSE connections carry TCP keep-alive. What a session does not carry is authority — see Sessions.

Deployment guidance

Keep port 3101 unexposed unless remote MCP clients are an intentional part of your deployment, and put it behind TLS when they are. Internally, packages never talk to each other through this endpoint — cross-package calls use typed APIs in-process; MCP is for the model and external clients (see the package system).

On this page