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.
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, implementinginitialize,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/registerfor dynamic client registration (RFC 7591). A URL-shapedclient_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 theclient_idit 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
resourceindicator 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 bothrefresh_tokenand the registeredclient_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; anotherclient_idcannot 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_unavailableinstead 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-keyheader, 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 inx-api-keyis 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,GETandDELETEall 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 theAuthorizationheader, and a caller whose token has expired or been withdrawn is answered401with aWWW-Authenticatechallenge — 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/callis denied and the nexttools/liststops 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
404with the standardSession not foundJSON-RPC error; a request that omits the header entirely is answered400. The404is 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, andx-agent-idheaders 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
Originheader is rejected403unless that origin is allowlisted, and the check runs before any session lookup or tool work. A request with noOriginheader 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).