# Remote MCP over Streamable HTTP

For an application-owned local harness endpoint, see the
[loopback bridge](harness-mcp-bridge.md). Harness control does not grant capability
permissions; discovery and execution still use the policies below.

The MCP subpath needs the official server SDK as an explicit optional peer in the host project:

~~~sh
npm install @modelcontextprotocol/server@2.3.0
~~~

Import the adapter from its isolated package subpath:

~~~ts
import { createMcpHandler } from '@uppercut-labs/agent-native/mcp';

const handler = createMcpHandler(registry, {
  resolveExecutionContext(request) {
    // Return a caller and AuthorizationPort from the application's trusted auth boundary.
    return { caller, authorization };
  },
});
~~~

Mount the Web-standard Request-to-Response function at /mcp. The adapter delegates protocol
handling, version negotiation, tool registration and Streamable HTTP transport to the official
MCP server SDK. It does not implement MCP wire messages itself. The root contract and executor
entrypoints remain free of MCP SDK imports.

By default, the adapter registers only capabilities whose access is explicitly public and whose
risk is read. The optional `canDiscover` callback can narrow that set; it cannot reveal protected
capabilities. Protected discovery requires an explicit `discoverProtected` callback, official SDK
bearer verification, trusted principal resolution, and grant authorization. Destructive capabilities
also require an exact per-app MCP exposure entry:

~~~ts
surfaceExposure: { mcp: { destructive: ['account:delete@1'] } }
~~~

The server factory applies discovery on every protocol request, and the shared executor rechecks
authorization for each `tools/call`. Granting a scope alone never exposes a destructive tool. A direct call to a hidden or filtered tool name receives the SDK's normal
unknown-tool protocol error without running a binding. See
[permissions and discovery](permissions-and-discovery.md) and the
[E07 playlist fixture](https://github.com/uppercut-labs/agent-native/blob/main/examples/e07-playlist-permissions/README.md).

The SDK receives JSON Schema generated by each capability's SchemaPort. The shared executor
validates input and output again. Successful tool results place the canonical output under
structuredContent.result so object and scalar capability results use one predictable shape.
The text content carries the same JSON value. Error text is bounded and omits raw inputs, scopes,
handler exceptions and output values.

The default request body limit is 32 KiB and can be configured from 1 byte through 1 MiB with
maxRequestBytes. Tool execution defaults to a 10-second deadline, configurable up to 300 seconds.
A timed-out call receives an MCP tool error and its binding receives an aborted signal; bindings
must observe that signal to stop their work. For a write or destructive capability, the error says
that the write may have completed. The adapter does not retry; callers must check state before any
retry unless the application defines an idempotency contract. Host code remains responsible for
identity-provider configuration, transport security, grant persistence and rate limiting.

A real local HTTP socket fixture uses the official client and server packages, both pinned to
version 2.3.0. The observed client session negotiated protocol version 2025-11-25. It discovers
and invokes the sample album capability, checks the structured result against the canonical output
schema, and exercises malformed transport input, oversized input, invalid tool arguments, hidden
definitions and a request-specific discovery filter. This is fixture-tested protocol evidence; it
does not establish support for every commercial MCP client or a deployed host.

Destructive definitions require an exact app surface entry, in addition to a current grant:

~~~ts
surfaceExposure: { mcp: { destructive: ['account:delete@1'] } }
~~~

The adapter filters out definitions without one unique server binding and requires current token scopes plus a matching, unexpired, unrevoked grant before consulting `discoverProtected`; that callback only narrows discovery. The shared executor still checks every call. Exposure is also checked at the call boundary so a stale listing cannot retain destructive access.

For the optional interactive MCP Apps presentation, use the isolated [/mcp-apps](mcp-apps.md) adapter and its E09 SDK conformance fixture. It keeps the regular text/structured tool result for hosts without MCP Apps rendering.

## Protected-only servers

When `bearerAuth` is configured and no capability is a public read with a server binding, a request without an `Authorization` header receives the SDK's 401 challenge with `WWW-Authenticate: Bearer`. Hosts then ask for credentials instead of connecting and seeing an empty tool list. Set `bearerAuth.required` to `true` to require a token even when public reads exist, or to `false` to keep anonymous handshakes.

## Caller identity in bindings

Every binding receives the caller the executor authorized as `context.caller`. For a verified MCP principal, `context.caller.identity` carries its `issuer`, `subject`, `tenantId` and `clientId`, so a binding can scope records to the caller without rebuilding the registry per request. Authorization still happens first: `authorizeResource` must enforce ownership before the binding runs.

## Operator failure hook

Tool errors returned to the client are deliberately generic. To see why a call failed, pass `onToolFailure`. It receives the capability ID, the tool name, a reason (an executor failure kind, or `context-unavailable`, `not-exposed`, `result-not-serializable` or `adapter-error`) and the executor's `DiagnosticObservation`. It never receives input, output, credentials or exception text. Errors thrown by the hook are ignored. To log a binding exception, catch it inside the binding.

## Tool names

The default name, from `mcpToolName`, is collision-free but long, for example `cap_7_example_6_lookup_v1`. The `toolName` option can return shorter names. Each must be 1 to 128 characters of `A-Z a-z 0-9 _ - .` and unique within the registry; otherwise the handler is not created.

## Cloudflare Workers: keep request resources open until the response ends

A Streamable HTTP response is a stream. For a tool call, the SDK returns the `Response` first and runs the binding while the body is being written. Anything the binding uses that is scoped to the request, such as a database pool, must stay open until the body ends, not merely until the handler returns:

~~~ts
const response = await handler(request);
if (!response.body) return response;
const { readable, writable } = new TransformStream();
ctx.waitUntil(response.body.pipeTo(writable).finally(() => pool.end()));
return new Response(readable, response);
~~~

Closing the pool in a `finally` around `await handler(request)` makes every tool call fail as `handler-failed`, while discovery still works.

## Reproduce a local protocol call

Use Node.js 22 or newer, `npm ci`, then `npm run example:e04` from the repository
root. The exported Worker starts locally under Wrangler and the official SDK client
discovers and calls the public album tool through `/mcp`. A wrong configured
`SIDECAR_HOST` yields a 421 response; set it to the local request host before
retesting. The [E04 Worker instructions](https://github.com/uppercut-labs/agent-native/blob/main/examples/e04-worker-sidecar/project/README.md)
give the exact local settings and cleanup. This is loopback protocol evidence.
