Remote MCP over Streamable HTTP
For an application-owned local harness endpoint, see the loopback bridge. 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:
npm install @modelcontextprotocol/server@2.3.0
Import the adapter from its isolated package subpath:
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:
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 and the
E07 playlist fixture.
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:
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 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:
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
give the exact local settings and cleanup. This is loopback protocol evidence.