@appport/mcp
The MCP adapter for AppPort: exposes an AppPort application's capabilities to
any MCP client (agents, IDEs, other hosts) over the official
@modelcontextprotocol/sdk.
Architecture
AppPort (authority: capabilities, authorization, idempotency, timeouts)
│
┌─────────┴──────────┐
native clients @appport/mcp ──► MCP clients
AppPort is the authority; MCP is a projection. This package defines no
capabilities, permissions, tenants, operations, persistence or business logic,
and retains no state between calls. Each MCP tools/call is translated into an
AppRequest and dispatched through AppPortApplication.handleRequest — the same
pipeline every other transport uses — so validation, authorization, idempotency,
timeouts, telemetry and handler execution are unchanged. JSON-RPC, protocol
negotiation, framing and transports are the MCP SDK's; none is reimplemented here.
MCP tools/call → @appport/mcp → AppRequest → application.handleRequest
→ validate → authorize → capability handler
Server setup
createMcpHandler returns a standard (Request) => Promise<Response> function
serving MCP Streamable HTTP. It is stateless — each HTTP request gets its own
short-lived SDK server and transport and is answered as one JSON response — so it
runs on serverless platforms and any fetch-compatible runtime without sticky
sessions or a long-lived process. GET and DELETE (server-initiated streams,
session teardown) are declined with 405, which MCP permits.
import { createMcpHandler } from "@appport/mcp";
import { errors } from "@appport/protocol";
const handler = createMcpHandler(application, {
// Runs per MCP request. Verify credentials, then say who the caller is.
async context({ headers, authInfo }) {
const token = headers["authorization"]?.toString().replace(/^Bearer /, "");
if (!token) return undefined; // anonymous: AppPort allows only public capabilities
const session = await myApp.verify(token); // your AppPort `Session`
if (!session) throw errors.unauthorized("Invalid credentials");
return { session };
}
});
// e.g. a Next.js / Vercel route handler
export const POST = (request: Request) => handler(request);
export const GET = POST;
export const DELETE = POST;
For stdio or in-memory use, createMcpServer(application, options) returns the
SDK Server to connect to any SDK transport.
Options (McpServerOptions): context, serverInfo (defaults to the
application's name and version), include, prefix, includeBuiltins.
The host owns deployment, routing, TLS, origin/DNS-rebinding checks, rate
limiting and credential verification. The host may also verify the credential
before calling the handler and pass it as handler(request, { authInfo }); it
then reaches context as authInfo.
Authentication
The adapter never authenticates callers and never trusts them implicitly. The
host supplies the authenticated session and/or principal — the same values a
native transport passes in AppPort's DispatchOptions — through context.
- No
contextoption, orcontextreturns nothing → the call is dispatched anonymously. AppPort's authorizer refuses any capability that requires permissions (UNAUTHORIZED); only public capabilities run. contextthrows anAppPortError(UNAUTHORIZED/FORBIDDEN) → reported as a tool error. Any other exception is reported as a JSON-RPC internal error with no detail.- Identity cannot be supplied by the MCP caller in tool arguments or
_meta.
Idempotency keys are scoped by AppPort to the session, so provide a stable,
per-caller session.id.
Capability exposure
Tools are derived from the application manifest on every request; there is no second list.
| AppPort | MCP tool |
|---|---|
request capability call.create | tool call_create (. → _, optional prefix) |
capability inputSchema | tool inputSchema (must be an object schema) |
capability outputSchema | tool outputSchema (when an object schema) |
| capability name/version/permissions | tool _meta["dev.appport/capability"] |
The tool name is a label, not an identity: calls resolve through the manifest.
Two capabilities projecting to one tool name is an error, not an overwrite.
Stream capabilities, non-object inputs and the reserved appport.* namespace
(unless includeBuiltins) are not exposed. Tool discovery lists what the
application publishes; it does not filter by caller — authorization is enforced
when a tool is called.
Metadata
AppPort request metadata is carried in the MCP tools/call _meta object and
placed, unchanged, on the AppRequest (MCP_META exports the keys):
_meta key | AppPort field | Notes |
|---|---|---|
dev.appport/idempotencyKey | idempotencyKey | replay is AppPort's (§13.1); this package keeps no cache |
dev.appport/timeoutMs | timeoutMs | becomes the capability's deadline; AppPort clamps it (§13.3); no MCP default is added |
dev.appport/traceId | traceId | reaches context.traceId; AppPort generates one if absent; echoed on the result _meta |
Malformed values (empty strings, non-positive timeouts) are rejected as
INVALID_REQUEST. MCP request cancellation and HTTP client disconnects abort
the capability's context.signal.
Errors
Protocol-level problems use JSON-RPC errors from the SDK: malformed JSON or
messages (-32700/-32600), unknown method (-32601), unknown tool (-32602),
and adapter failures (-32603, no detail). Every AppPort outcome is a tool
result: success has content (JSON text) and structuredContent; failure has
isError: true, text CODE: message, and _meta["dev.appport/error"]
(MCP_ERROR_META) with { code, category, retryable, details? }. details is
what AppPort already sanitized (§16).
| AppPort code | category |
|---|---|
INVALID_REQUEST, INVALID_INPUT, INVALID_PROTOCOL, UNSUPPORTED_VERSION, UNSUPPORTED_CAPABILITY_VERSION, PAYLOAD_TOO_LARGE, NO_COMPATIBLE_MATERIALIZATION | invalid_request |
UNAUTHORIZED | unauthenticated |
FORBIDDEN | forbidden |
NOT_FOUND, UNKNOWN_CAPABILITY | not_found |
CONFLICT | conflict |
TIMEOUT | timeout |
RATE_LIMITED | rate_limited |
CANCELLED | cancelled |
STREAM_ERROR, namespaced extension codes (acme.UPSTREAM_DOWN) | capability_failure |
INTERNAL_ERROR, anything else | internal |
Security
Exposing a capability through MCP does not bypass AppPort authorization: every
call passes AppPort validation and the application's authorizer, and handlers
receive the host-supplied session/principal. Tenant and resource isolation remain
the application's responsibility, enforced in capability handlers through that
context (for example context.session.attributes); a caller cannot reach
another tenant's data through MCP any more than through any other transport.
State
None. Nothing is cached, stored or shared between calls; a fresh SDK server is built per HTTP request. Durable and ephemeral state (idempotency records, sessions, operations) belong to the AppPort application.
Compatibility
createMcpBridge, toMcpTools, toToolName, fromToolName and describeError
remain. createMcpBridge(...).callTool(name, args, metadata?) now forwards the
AppPort idempotency key, timeout and trace ID (previously dropped), and its
results now carry structuredContent and _meta. Prefer createMcpHandler /
createMcpServer, which speak MCP; fromToolName is lossy for capability names
containing _ and is not used internally.
@appport/mcp/bridge (1.3.0)
@appport/mcp/bridge exports the projection only: createMcpBridge, toMcpTools, toToolName, fromToolName, MCP_META,
MCP_ERROR_META, mcpErrorCategory and their types. It never imports @modelcontextprotocol/sdk, at runtime or in its declarations.
The root @appport/mcp exports the same names plus the MCP server (createMcpServer, createMcpHandler), which does. The MCP SDK's own
declarations reference the DOM-only HeadersInit, so a Node-only TypeScript project (no DOM lib, skipLibCheck: false) can import
@appport/mcp/bridge but must either add the DOM lib or enable skipLibCheck to import the @appport/mcp root.
@appport/sdk takes its MCP bridge exports from this subpath.