@12-apps/mcp 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ADOPTING.md CHANGED
@@ -156,6 +156,38 @@ library updates, every host updates with **no app changes**. Same contract
156
156
  | POST | `/api/oauth/token` | `authorization_code` (single-use, PKCE-verified, bound `redirect_uri`) and `refresh_token` (rotated, client-bound, narrow-only scope) |
157
157
  | POST | `/api/oauth/register` | 201 RFC 7591 client information; the secret exactly once, hashed at rest |
158
158
 
159
+ ## The MCP transport (`/api/mcp`)
160
+
161
+ `handleMcpJsonRpc` is the JSON-RPC 2.0 request half of the Streamable HTTP
162
+ transport: the envelope, the method table (`initialize`, `ping`, `tools/list`,
163
+ `tools/call`), the error codes and the notification convention. The host keeps
164
+ what is its own — the server's name, its advertised surface version, and the
165
+ `instructions` an agent reads on connect:
166
+
167
+ ```ts
168
+ import { handleMcpJsonRpc, UNAUTHORIZED_CODE } from '@12-apps/mcp';
169
+
170
+ const response = await handleMcpJsonRpc(body, registry, auth, {
171
+ serverInfo: { name: 'example-host', version: `${MCP_SURFACE_VERSION}.0.0` },
172
+ instructions: 'Resolve the tenant with `listUserTenants` before acting.',
173
+ });
174
+ ```
175
+
176
+ Three rules the signature enforces rather than documents:
177
+
178
+ - **Discovery stays open.** `auth` is `null` when the request carried no valid
179
+ bearer; only `tools/call` refuses, with `UNAUTHORIZED_CODE` (-32001), which the
180
+ host maps to HTTP 401. A client can read the surface before it has a token.
181
+ - **`null` means "no reply".** Notifications (`notifications/*`) return `null`,
182
+ and the host must send no body for them.
183
+ - **A malformed payload is answered, not thrown.** A `null` batch element or an
184
+ object with no `method` returns -32600, so one bad element cannot 500 the route.
185
+
186
+ `serverInfo.version` is the ONLY signal a connected client gets that the tool
187
+ surface moved — this transport has no server→client stream, so
188
+ `capabilities.tools` deliberately does not claim `listChanged`. Pair it with the
189
+ surface lock (`mcp:generate`) so forgetting the bump is a build error.
190
+
159
191
  ## Minimal host (Hono)
160
192
 
161
193
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/mcp",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic MCP server core: generate one MCP tool per OpenAPI operation and proxy each call carrying the caller's bearer token (permission passthrough). Also ships the OAuth 2.1 authorization server (./oauth, ./hono: register/authorize/token, JWKS and both .well-known documents), the package-owned Prisma partial + migration for its three tables, the mcp:generate/mcp:check (./generate) and mcp:coverage (./coverage) gates, and the reusable AI-connect onboarding UI (./react).",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -44,6 +44,19 @@ export {
44
44
  type McpToolDescriptor,
45
45
  type McpToolResult,
46
46
  } from "./server/registry";
47
+ // The JSON-RPC 2.0 request half of the Streamable HTTP transport. The envelope,
48
+ // the method table and the error codes are the specification's and live here; the
49
+ // server NAME, its version and the `instructions` an agent reads on connect are
50
+ // one product's vocabulary and arrive as options.
51
+ export {
52
+ handleMcpJsonRpc,
53
+ MCP_PROTOCOL_VERSION,
54
+ UNAUTHORIZED_CODE,
55
+ type JsonRpcRequest,
56
+ type JsonRpcResponse,
57
+ type McpJsonRpcOptions,
58
+ type McpServerInfo,
59
+ } from "./server/jsonrpc";
47
60
  export {
48
61
  buildManifest,
49
62
  serializeManifest,
@@ -0,0 +1,167 @@
1
+ import type { RequestAuth } from "../types";
2
+
3
+ import type { ToolRegistry } from "./registry";
4
+
5
+ /**
6
+ * The MCP JSON-RPC 2.0 request half of the Streamable HTTP transport.
7
+ *
8
+ * Implemented directly rather than via the MCP SDK because the SDK's transport is
9
+ * Node-`http` oriented, and a host serving Web `Request`/`Response` (a Next route
10
+ * handler, a Hono route) has no `http.IncomingMessage` to hand it. It covers the
11
+ * methods a client needs to discover and call tools: `initialize`, `tools/list`,
12
+ * `tools/call`, plus `ping`.
13
+ *
14
+ * WHAT IS MECHANISM AND LIVES HERE: the envelope, the method table, the error
15
+ * codes, the well-formedness rule, and the notification convention. None of it
16
+ * varies per host — it is JSON-RPC 2.0 and the MCP specification.
17
+ *
18
+ * WHAT IS VOCABULARY AND STAYS WITH THE HOST: the server's NAME, its version, and
19
+ * the `instructions` string an agent reads on connect. Those describe one
20
+ * particular product's tool surface, so they arrive as {@link McpJsonRpcOptions}
21
+ * rather than being written here.
22
+ */
23
+
24
+ /** The MCP protocol revision this transport implements. */
25
+ export const MCP_PROTOCOL_VERSION = "2025-06-18";
26
+
27
+ /**
28
+ * JSON-RPC error code for "authentication required", returned by `tools/call`
29
+ * when the request carried no valid bearer. Outside the reserved -32768..-32000
30
+ * band's *defined* codes on purpose: it is an implementation-defined server
31
+ * error, and a host maps it to HTTP 401.
32
+ */
33
+ export const UNAUTHORIZED_CODE = -32001;
34
+
35
+ /** JSON-RPC "Invalid Request" — a payload that isn't a well-formed request object. */
36
+ const INVALID_REQUEST_CODE = -32600;
37
+
38
+ /** JSON-RPC "Method not found". */
39
+ const METHOD_NOT_FOUND_CODE = -32601;
40
+
41
+ /** JSON-RPC "Invalid params". */
42
+ const INVALID_PARAMS_CODE = -32602;
43
+
44
+ export interface JsonRpcRequest {
45
+ jsonrpc: "2.0";
46
+ id?: string | number | null;
47
+ method: string;
48
+ params?: unknown;
49
+ }
50
+
51
+ export interface JsonRpcResponse {
52
+ jsonrpc: "2.0";
53
+ id: string | number | null;
54
+ result?: unknown;
55
+ error?: { code: number; message: string };
56
+ }
57
+
58
+ /** What a client is told it connected to, in `initialize`'s `serverInfo`. */
59
+ export interface McpServerInfo {
60
+ /** The server's name, as a connected host displays it. */
61
+ name: string;
62
+ /**
63
+ * The advertised surface version.
64
+ *
65
+ * This is the ONLY signal a client gets that the tool surface changed: the
66
+ * transport is request/response only, so `notifications/tools/list_changed`
67
+ * can never be sent, and a host that cached `tools/list` at the handshake has
68
+ * no other reason to ask again. See `server/surface-lock.ts` for the guard
69
+ * that makes forgetting to move it a build error instead of a comment.
70
+ */
71
+ version: string;
72
+ }
73
+
74
+ export interface McpJsonRpcOptions {
75
+ /** The host's identity, returned verbatim in `initialize`. */
76
+ serverInfo: McpServerInfo;
77
+ /**
78
+ * Server-level guidance surfaced to the model on `initialize` (the MCP spec's
79
+ * optional `instructions` field). Omitted from the result when absent, rather
80
+ * than sent empty — a blank string is a claim that there is guidance.
81
+ */
82
+ instructions?: string;
83
+ /**
84
+ * Override the advertised protocol revision. Defaults to
85
+ * {@link MCP_PROTOCOL_VERSION}; a host should not normally set it.
86
+ */
87
+ protocolVersion?: string;
88
+ }
89
+
90
+ function ok(id: JsonRpcRequest["id"], result: unknown): JsonRpcResponse {
91
+ return { jsonrpc: "2.0", id: id ?? null, result };
92
+ }
93
+
94
+ function fail(id: JsonRpcRequest["id"], code: number, message: string): JsonRpcResponse {
95
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
96
+ }
97
+
98
+ /** A parsed body is a usable request only if it's an object carrying a string `method`. */
99
+ function isWellFormed(request: JsonRpcRequest): boolean {
100
+ return request != null && typeof request === "object" && typeof request.method === "string";
101
+ }
102
+
103
+ async function handleToolsCall(
104
+ request: JsonRpcRequest,
105
+ registry: ToolRegistry,
106
+ auth: RequestAuth | null,
107
+ ): Promise<JsonRpcResponse> {
108
+ if (!auth) return fail(request.id, UNAUTHORIZED_CODE, "Authentication required");
109
+ const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
110
+ if (!params.name) return fail(request.id, INVALID_PARAMS_CODE, "Missing tool name");
111
+ const result = await registry.callTool(params.name, params.arguments ?? {}, auth);
112
+ return ok(request.id, result);
113
+ }
114
+
115
+ function handleInitialize(
116
+ request: JsonRpcRequest,
117
+ options: McpJsonRpcOptions,
118
+ ): JsonRpcResponse {
119
+ return ok(request.id, {
120
+ protocolVersion: options.protocolVersion ?? MCP_PROTOCOL_VERSION,
121
+ // Deliberately does NOT claim `listChanged`: this transport has no
122
+ // server→client stream, so the notification could never be sent, and
123
+ // advertising it would stop a host from ever re-reading `tools/list`.
124
+ capabilities: { tools: {} },
125
+ serverInfo: options.serverInfo,
126
+ ...(options.instructions ? { instructions: options.instructions } : {}),
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Handle one MCP JSON-RPC request.
132
+ *
133
+ * Returns `null` for notifications (no id, no reply expected). `auth` is the
134
+ * verified caller identity, or `null` when the request carried no valid bearer —
135
+ * `tools/call` then returns {@link UNAUTHORIZED_CODE}, which the host surfaces as
136
+ * HTTP 401. Discovery (`initialize`, `ping`, `tools/list`) stays open, so a client
137
+ * can read the surface before it has a token.
138
+ */
139
+ export async function handleMcpJsonRpc(
140
+ request: JsonRpcRequest,
141
+ registry: ToolRegistry,
142
+ auth: RequestAuth | null,
143
+ options: McpJsonRpcOptions,
144
+ ): Promise<JsonRpcResponse | null> {
145
+ // A host casts the parsed body to JsonRpcRequest without validating it, so a
146
+ // malformed payload can arrive here: a `null` body/batch element, a non-object,
147
+ // or an object with no `method`. Reject any of these as Invalid Request rather
148
+ // than dereferencing `request`/`request.method` and throwing a 500 below.
149
+ if (!isWellFormed(request)) {
150
+ return fail(request?.id ?? null, INVALID_REQUEST_CODE, "Invalid Request");
151
+ }
152
+ switch (request.method) {
153
+ case "initialize":
154
+ return handleInitialize(request, options);
155
+ case "ping":
156
+ return ok(request.id, {});
157
+ case "tools/list":
158
+ return ok(request.id, { tools: registry.listTools(auth ?? undefined) });
159
+ case "tools/call":
160
+ return handleToolsCall(request, registry, auth);
161
+ default:
162
+ // JSON-RPC notifications (`notifications/*`) expect no reply — silently
163
+ // ignore any we don't explicitly handle, rather than returning an error.
164
+ if (request.method.startsWith("notifications/")) return null;
165
+ return fail(request.id, METHOD_NOT_FOUND_CODE, `Method not found: ${request.method}`);
166
+ }
167
+ }