@12-apps/mcp 3.0.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 +52 -9
- package/package.json +2 -2
- package/src/index.ts +13 -0
- package/src/oauth/connections.ts +104 -0
- package/src/oauth/index.ts +7 -0
- package/src/server/jsonrpc.ts +167 -0
package/ADOPTING.md
CHANGED
|
@@ -109,11 +109,14 @@ library updates, every host updates with **no app changes**. Same contract
|
|
|
109
109
|
FENCED: a failing directory can never turn a valid grant into a 500, and nothing
|
|
110
110
|
about the attempt is logged, because the only values in hand are an email and a
|
|
111
111
|
client id.
|
|
112
|
-
11. **Disconnecting means BOTH halves
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
live refresh token simply rotates its way back in
|
|
116
|
-
the next grant.
|
|
112
|
+
11. **Disconnecting means BOTH halves — call `disconnectAiHost`, not the stores.**
|
|
113
|
+
`connections.revokeByHost(...)` ends the rows and returns the OAuth client ids
|
|
114
|
+
behind them, and every live refresh token of those clients must be ended in the
|
|
115
|
+
same act — a host holding a live refresh token simply rotates its way back in
|
|
116
|
+
and the card lights green on the next grant. Since 12-48 the rule IS a
|
|
117
|
+
function: `disconnectAiHost(stores, { userId, email }, host)` does both and
|
|
118
|
+
reports what it ended, so a host cannot import one half without the other.
|
|
119
|
+
Neither half invalidates an outstanding ACCESS token: those are
|
|
117
120
|
self-contained JWTs, so a disconnected host keeps working for at most their
|
|
118
121
|
15-minute TTL and can then obtain nothing further.
|
|
119
122
|
12. **These bodies are NOT the `{ data }` envelope.** A 302 with a `Location`, RFC
|
|
@@ -153,6 +156,38 @@ library updates, every host updates with **no app changes**. Same contract
|
|
|
153
156
|
| POST | `/api/oauth/token` | `authorization_code` (single-use, PKCE-verified, bound `redirect_uri`) and `refresh_token` (rotated, client-bound, narrow-only scope) |
|
|
154
157
|
| POST | `/api/oauth/register` | 201 RFC 7591 client information; the secret exactly once, hashed at rest |
|
|
155
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
|
+
|
|
156
191
|
## Minimal host (Hono)
|
|
157
192
|
|
|
158
193
|
```ts
|
|
@@ -234,10 +269,18 @@ Deliberate deltas to reconcile:
|
|
|
234
269
|
|
|
235
270
|
## What deliberately did NOT move into the package
|
|
236
271
|
|
|
237
|
-
- **The account/connection SCREENS'
|
|
238
|
-
/api/account/mcp-connections`) — they
|
|
239
|
-
|
|
240
|
-
|
|
272
|
+
- **The account/connection SCREENS' route files** (`GET/DELETE
|
|
273
|
+
/api/account/mcp-connections`) — they answer in the HOST's app-wide response
|
|
274
|
+
envelope and mix its session resolution, published plugin URLs and logger, so
|
|
275
|
+
the handler stays host code (unlike the OAuth endpoints, whose shapes are
|
|
276
|
+
fixed by RFC — rule 12 — these are ordinary host API routes). What DID move
|
|
277
|
+
(12-48) is the operations under them: `listAiConnections` (the stored open
|
|
278
|
+
`host` string narrowed to the package's own `AiProvider` union) and
|
|
279
|
+
`disconnectAiHost`, which owns the disconnect's both-halves rule — revoke the
|
|
280
|
+
connection rows AND end every live refresh token of each returned client id in
|
|
281
|
+
one call. A host that imports the disconnect cannot get only half of it; half
|
|
282
|
+
is the failure mode where the assistant rotates its live token and the card
|
|
283
|
+
the user just disconnected lights green again on the next grant (rule 11).
|
|
241
284
|
- **The MCP registry itself** — which endpoints become tools, their annotations
|
|
242
285
|
and redactions, is the host's catalogue. The package generates, dispatches and
|
|
243
286
|
gates it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/mcp",
|
|
3
|
-
"version": "3.
|
|
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": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@12-apps/onboarding": "^2.0.0",
|
|
27
|
-
"@12-apps/rbac": "^4.0.
|
|
27
|
+
"@12-apps/rbac": "^4.0.1",
|
|
28
28
|
"@12-apps/ui": "^5.0.0",
|
|
29
29
|
"@mui/icons-material": "^6.5.0",
|
|
30
30
|
"jose": "^6.1.3",
|
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,104 @@
|
|
|
1
|
+
import { providerForHostId, type AiProvider } from "../guide";
|
|
2
|
+
import type {
|
|
3
|
+
McpConnectionStore,
|
|
4
|
+
RefreshTokenStore,
|
|
5
|
+
StoredMcpConnection,
|
|
6
|
+
} from "./stores";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The account surface's connection OPERATIONS (12-48) — the half of the
|
|
10
|
+
* `GET/DELETE /api/account/mcp-connections` endpoints that is contract rather
|
|
11
|
+
* than host vocabulary.
|
|
12
|
+
*
|
|
13
|
+
* The ROUTE stays in the host on purpose: it mixes the host's session
|
|
14
|
+
* resolution, its response envelope, its published plugin URLs and its logger,
|
|
15
|
+
* and injecting all four here would make the config surface bigger than the
|
|
16
|
+
* handler it replaces. What must NOT stay in each host is the disconnect's
|
|
17
|
+
* both-halves rule, because getting it half right LOOKS right:
|
|
18
|
+
*
|
|
19
|
+
* `connections.revokeByHost` ends the connection rows and returns the OAuth
|
|
20
|
+
* client ids behind them — and a host that stops there has revoked nothing that
|
|
21
|
+
* matters. The assistant still holds a live refresh token for each of those
|
|
22
|
+
* clients, rotates it on schedule, and the very next grant records fresh
|
|
23
|
+
* activity: the card the user just disconnected lights green again on its own.
|
|
24
|
+
* So the rule is one function: revoke the rows AND end every live refresh token
|
|
25
|
+
* of each returned client, in the same call, with no way to import one half
|
|
26
|
+
* without the other.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately NOT invalidated here: the assistant's current ACCESS token.
|
|
29
|
+
* Those are self-contained JWTs the server does not track; a just-disconnected
|
|
30
|
+
* host keeps working for at most their TTL (15 minutes by default) and can then
|
|
31
|
+
* obtain nothing further.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** An active AI connection, narrowed for display. */
|
|
35
|
+
export interface AiConnectionSnapshot {
|
|
36
|
+
oauthClientId: string;
|
|
37
|
+
clientName: string | null;
|
|
38
|
+
/** The provider this connection is attributed to (`null` = pre-attribution). */
|
|
39
|
+
host: AiProvider | null;
|
|
40
|
+
connectedAt: Date;
|
|
41
|
+
lastActiveAt: Date;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The caller the operations act for — always the session's own user. */
|
|
45
|
+
export interface AiConnectionCaller {
|
|
46
|
+
/** The host's user id — what `mcp_connections` rows are keyed by. */
|
|
47
|
+
userId: string;
|
|
48
|
+
/** The identity refresh tokens are bound to (the AS binds by email). */
|
|
49
|
+
email: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What one disconnect actually ended, for the host's log and response. */
|
|
53
|
+
export interface AiDisconnectResult {
|
|
54
|
+
/** OAuth client ids whose connection rows were revoked. */
|
|
55
|
+
disconnectedClientIds: string[];
|
|
56
|
+
/** Live refresh tokens ended across those clients — the half that cuts access. */
|
|
57
|
+
revokedRefreshTokens: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Narrow a stored `host` string to a known provider, or `null`. */
|
|
61
|
+
function asProvider(host: string | null): AiProvider | null {
|
|
62
|
+
return host === null ? null : providerForHostId(host);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A user's active connections, most-recently-active first, with the stored open
|
|
67
|
+
* `host` string narrowed to the package's closed {@link AiProvider} union — the
|
|
68
|
+
* store cannot know which assistants have screens, but the union is this
|
|
69
|
+
* package's own vocabulary (`guide.ts`), so the narrowing lives beside it
|
|
70
|
+
* rather than being re-derived in every host.
|
|
71
|
+
*/
|
|
72
|
+
export async function listAiConnections(
|
|
73
|
+
connections: McpConnectionStore,
|
|
74
|
+
userId: string,
|
|
75
|
+
): Promise<AiConnectionSnapshot[]> {
|
|
76
|
+
const rows: StoredMcpConnection[] = await connections.listActive(userId);
|
|
77
|
+
return rows.map((row) => ({ ...row, host: asProvider(row.host) }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Disconnect one provider for this user — BOTH halves, atomically from the
|
|
82
|
+
* caller's point of view (see the module doc for why one half alone is a
|
|
83
|
+
* disconnect that undoes itself).
|
|
84
|
+
*
|
|
85
|
+
* Idempotent: disconnecting a provider that was never connected returns zero
|
|
86
|
+
* counts rather than failing, so a double-click is harmless. Repeat calls also
|
|
87
|
+
* report zero — `revokeLiveForClient` skips already-revoked tokens by contract.
|
|
88
|
+
*/
|
|
89
|
+
export async function disconnectAiHost(
|
|
90
|
+
stores: { connections: McpConnectionStore; refreshTokens: RefreshTokenStore },
|
|
91
|
+
caller: AiConnectionCaller,
|
|
92
|
+
host: AiProvider,
|
|
93
|
+
): Promise<AiDisconnectResult> {
|
|
94
|
+
const disconnectedClientIds = await stores.connections.revokeByHost(caller.userId, host);
|
|
95
|
+
const revoked = await Promise.all(
|
|
96
|
+
disconnectedClientIds.map((clientId) =>
|
|
97
|
+
stores.refreshTokens.revokeLiveForClient(caller.email, clientId),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
return {
|
|
101
|
+
disconnectedClientIds,
|
|
102
|
+
revokedRefreshTokens: revoked.reduce((total, count) => total + count, 0),
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -115,3 +115,10 @@ export type {
|
|
|
115
115
|
StoredRefreshToken,
|
|
116
116
|
TokenEndpointAuthMethod,
|
|
117
117
|
} from "./stores";
|
|
118
|
+
export {
|
|
119
|
+
disconnectAiHost,
|
|
120
|
+
listAiConnections,
|
|
121
|
+
type AiConnectionCaller,
|
|
122
|
+
type AiConnectionSnapshot,
|
|
123
|
+
type AiDisconnectResult,
|
|
124
|
+
} from "./connections";
|
|
@@ -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
|
+
}
|