@softure-ai/mcp-access 0.4.0 → 0.6.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +71 -0
  3. package/dist/index.js +1 -1
  4. package/dist/server/endpoint.d.ts +20 -2
  5. package/dist/server/endpoint.d.ts.map +1 -1
  6. package/dist/server/endpoint.js +82 -2
  7. package/dist/server/endpoint.js.map +1 -1
  8. package/dist/server/index.d.ts +3 -1
  9. package/dist/server/index.d.ts.map +1 -1
  10. package/dist/server/index.js +2 -0
  11. package/dist/server/index.js.map +1 -1
  12. package/dist/server/request-diagnostics.d.ts +59 -0
  13. package/dist/server/request-diagnostics.d.ts.map +1 -0
  14. package/dist/server/request-diagnostics.js +84 -0
  15. package/dist/server/request-diagnostics.js.map +1 -0
  16. package/dist/server/server-factory.d.ts +7 -3
  17. package/dist/server/server-factory.d.ts.map +1 -1
  18. package/dist/server/server-factory.js +9 -3
  19. package/dist/server/server-factory.js.map +1 -1
  20. package/dist/server/tool-list-session.d.ts +22 -0
  21. package/dist/server/tool-list-session.d.ts.map +1 -0
  22. package/dist/server/tool-list-session.js +97 -0
  23. package/dist/server/tool-list-session.js.map +1 -0
  24. package/dist/testing/index.d.ts +1 -0
  25. package/dist/testing/index.d.ts.map +1 -1
  26. package/dist/testing/index.js +1 -0
  27. package/dist/testing/index.js.map +1 -1
  28. package/dist/testing/sessions.d.ts +8 -0
  29. package/dist/testing/sessions.d.ts.map +1 -0
  30. package/dist/testing/sessions.js +40 -0
  31. package/dist/testing/sessions.js.map +1 -0
  32. package/module.json +1 -1
  33. package/package.json +1 -1
  34. package/src/index.ts +1 -1
  35. package/src/server/endpoint.ts +124 -3
  36. package/src/server/index.ts +9 -0
  37. package/src/server/request-diagnostics.ts +130 -0
  38. package/src/server/server-factory.ts +11 -5
  39. package/src/server/tool-list-session.ts +103 -0
  40. package/src/testing/index.ts +1 -0
  41. package/src/testing/sessions.ts +41 -0
@@ -0,0 +1,22 @@
1
+ /** The header that carries the session id, both ways. */
2
+ export declare const MCP_SESSION_ID_HEADER = "mcp-session-id";
3
+ /** A new session id for the version: `<base64url(version)>.<32 hex>`, visible ASCII whatever the version holds. */
4
+ export declare function createToolListSessionId(toolListVersion: string): string;
5
+ /** The tool list version a session id carries, or `null` when the id is not one the endpoint issues. */
6
+ export declare function readToolListSessionVersion(sessionId: string): string | null;
7
+ /** Whether a request in this session may be served: its version is the current one. */
8
+ export declare function isToolListSessionCurrent(sessionId: string, toolListVersion: string): boolean;
9
+ /** The answer to a request in a session from another tool list version: the client starts a new session. */
10
+ export declare function createSessionRenewalResponse(): Response;
11
+ /**
12
+ * Whether the request is a JSON-RPC `initialize`: a POST without a session id whose JSON body asks for it. The body
13
+ * is read from a clone, and a body over `maxBytes` is not read (the SDK refuses it with 413 anyway).
14
+ */
15
+ export declare function isInitializeRequest(request: Request, maxBytes: number): Promise<boolean>;
16
+ /**
17
+ * The `initialize` answer with `capabilities.tools.listChanged: false`, since no answer of the endpoint ever carries
18
+ * the notification, and with the session id when there is one. The body keeps its format (JSON or one SSE event);
19
+ * an answer that is not a successful `InitializeResult` is returned as it is.
20
+ */
21
+ export declare function rewriteInitializeAnswer(response: Response, sessionId: string | null): Promise<Response>;
22
+ //# sourceMappingURL=tool-list-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-list-session.d.ts","sourceRoot":"","sources":["../../src/server/tool-list-session.ts"],"names":[],"mappings":"AAQA,yDAAyD;AACzD,eAAO,MAAM,qBAAqB,mBAAmB,CAAC;AAOtD,mHAAmH;AACnH,wBAAgB,uBAAuB,CAAC,eAAe,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED,wGAAwG;AACxG,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI3E;AAED,uFAAuF;AACvF,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,OAAO,CAE5F;AAED,4GAA4G;AAC5G,wBAAgB,4BAA4B,IAAI,QAAQ,CAKvD;AAED;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc9F;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,QAAQ,CAAC,CAsB7G"}
@@ -0,0 +1,97 @@
1
+ // Sessions tied to the tool list version (#1055). A hosted client keeps its cached `tools/list` for as long as its
2
+ // connection lives, even after a release changed a tool, and the endpoint has no channel to send
3
+ // `notifications/tools/list_changed`. So the endpoint answers `initialize` with an `Mcp-Session-Id` that carries the
4
+ // tool list version of the identity's access level, and a later request whose session carries another version gets
5
+ // `404 Not Found`: Streamable HTTP defines that as "start a new session", so the client initializes again and lists
6
+ // the tools again. Nothing is stored: the id itself holds the version, so any instance can check it.
7
+ import { randomBytes } from "node:crypto";
8
+ /** The header that carries the session id, both ways. */
9
+ export const MCP_SESSION_ID_HEADER = "mcp-session-id";
10
+ /** Random bytes after the version, as hex. */
11
+ const RANDOM_BYTES = 16;
12
+ const SESSION_ID_PATTERN = /^([A-Za-z0-9_-]+)\.[0-9a-f]+$/;
13
+ const SSE_DATA_PREFIX = "data: ";
14
+ /** A new session id for the version: `<base64url(version)>.<32 hex>`, visible ASCII whatever the version holds. */
15
+ export function createToolListSessionId(toolListVersion) {
16
+ return `${Buffer.from(toolListVersion, "utf8").toString("base64url")}.${randomBytes(RANDOM_BYTES).toString("hex")}`;
17
+ }
18
+ /** The tool list version a session id carries, or `null` when the id is not one the endpoint issues. */
19
+ export function readToolListSessionVersion(sessionId) {
20
+ const match = SESSION_ID_PATTERN.exec(sessionId);
21
+ if (match?.[1] === undefined)
22
+ return null;
23
+ return Buffer.from(match[1], "base64url").toString("utf8");
24
+ }
25
+ /** Whether a request in this session may be served: its version is the current one. */
26
+ export function isToolListSessionCurrent(sessionId, toolListVersion) {
27
+ return readToolListSessionVersion(sessionId) === toolListVersion;
28
+ }
29
+ /** The answer to a request in a session from another tool list version: the client starts a new session. */
30
+ export function createSessionRenewalResponse() {
31
+ return Response.json({ jsonrpc: "2.0", error: { code: -32001, message: "Session not found: the tool list changed, initialize again" }, id: null }, { status: 404, headers: { "cache-control": "no-store" } });
32
+ }
33
+ /**
34
+ * Whether the request is a JSON-RPC `initialize`: a POST without a session id whose JSON body asks for it. The body
35
+ * is read from a clone, and a body over `maxBytes` is not read (the SDK refuses it with 413 anyway).
36
+ */
37
+ export async function isInitializeRequest(request, maxBytes) {
38
+ if (request.method !== "POST" || request.headers.has(MCP_SESSION_ID_HEADER))
39
+ return false;
40
+ const length = Number(request.headers.get("content-length") ?? "0");
41
+ if (!Number.isFinite(length) || length > maxBytes)
42
+ return false;
43
+ let body;
44
+ try {
45
+ const text = await request.clone().text();
46
+ if (text.length > maxBytes)
47
+ return false;
48
+ body = JSON.parse(text);
49
+ }
50
+ catch {
51
+ // Not JSON: the SDK answers the parse error itself.
52
+ return false;
53
+ }
54
+ return typeof body === "object" && body !== null && !Array.isArray(body) && body.method === "initialize";
55
+ }
56
+ /**
57
+ * The `initialize` answer with `capabilities.tools.listChanged: false`, since no answer of the endpoint ever carries
58
+ * the notification, and with the session id when there is one. The body keeps its format (JSON or one SSE event);
59
+ * an answer that is not a successful `InitializeResult` is returned as it is.
60
+ */
61
+ export async function rewriteInitializeAnswer(response, sessionId) {
62
+ if (response.status !== 200)
63
+ return response;
64
+ const text = await response.text();
65
+ const isEventStream = (response.headers.get("content-type") ?? "").includes("text/event-stream");
66
+ const lines = text.split("\n");
67
+ const dataIndex = isEventStream ? lines.findIndex((line) => line.startsWith(SSE_DATA_PREFIX)) : -1;
68
+ const json = isEventStream ? (dataIndex === -1 ? null : (lines[dataIndex] ?? "").slice(SSE_DATA_PREFIX.length)) : text;
69
+ const answer = parseInitializeAnswer(json);
70
+ const headers = new Headers(response.headers);
71
+ // The body changes length, so the old one would be wrong.
72
+ headers.delete("content-length");
73
+ const tools = answer?.result?.capabilities?.tools;
74
+ if (answer === null || answer.result?.protocolVersion === undefined) {
75
+ return new Response(text, { status: response.status, statusText: response.statusText, headers });
76
+ }
77
+ if (tools !== undefined && tools !== null && typeof tools === "object")
78
+ tools.listChanged = false;
79
+ if (sessionId !== null)
80
+ headers.set(MCP_SESSION_ID_HEADER, sessionId);
81
+ const rewritten = JSON.stringify(answer);
82
+ if (isEventStream)
83
+ lines[dataIndex] = `${SSE_DATA_PREFIX}${rewritten}`;
84
+ return new Response(isEventStream ? lines.join("\n") : rewritten, { status: response.status, statusText: response.statusText, headers });
85
+ }
86
+ /** The answer as JSON, or `null` when there is none or it is not JSON (the SDK's own error text). */
87
+ function parseInitializeAnswer(json) {
88
+ if (json === null)
89
+ return null;
90
+ try {
91
+ return JSON.parse(json);
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ //# sourceMappingURL=tool-list-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-list-session.js","sourceRoot":"","sources":["../../src/server/tool-list-session.ts"],"names":[],"mappings":"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"}
@@ -1,2 +1,3 @@
1
1
  export { expectToolCatalogMatchesServer, getToolCatalogDifferences, measureServedToolLists, type ServedToolLists, type ToolCatalogDifferences, } from "./catalog.js";
2
+ export { expectToolListSessionRenewed } from "./sessions.js";
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,EACzB,sBAAsB,EACtB,KAAK,eAAe,EACpB,KAAK,sBAAsB,GAC5B,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,EACzB,sBAAsB,EACtB,KAAK,eAAe,EACpB,KAAK,sBAAsB,GAC5B,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,4BAA4B,EAAE,MAAM,eAAe,CAAC"}
@@ -1,3 +1,4 @@
1
1
  // `@softure-ai/mcp-access/testing`: checks for an app's own tests.
2
2
  export { expectToolCatalogMatchesServer, getToolCatalogDifferences, measureServedToolLists, } from "./catalog.js";
3
+ export { expectToolListSessionRenewed } from "./sessions.js";
3
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,EACzB,sBAAsB,GAGvB,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,OAAO,EACL,8BAA8B,EAC9B,yBAAyB,EACzB,sBAAsB,GAGvB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,4BAA4B,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,8 @@
1
+ import type { McpServerFactory } from "../server/endpoint.js";
2
+ /**
3
+ * Throws unless a session opened under `before` (the previous release's factory) is renewed under `after` for every
4
+ * access level whose served tool list changed, and kept for every level whose list did not. A pair where no level
5
+ * changed throws too: the check would prove nothing.
6
+ */
7
+ export declare function expectToolListSessionRenewed(before: McpServerFactory, after: McpServerFactory): Promise<void>;
8
+ //# sourceMappingURL=sessions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/testing/sessions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,uBAAuB,CAAC;AAWzF;;;;GAIG;AACH,wBAAsB,4BAA4B,CAAC,MAAM,EAAE,gBAAgB,EAAE,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAanH"}
@@ -0,0 +1,40 @@
1
+ import { createToolListSessionId, isToolListSessionCurrent } from "../server/tool-list-session.js";
2
+ import { fingerprintToolList, listServedTools } from "../server/tool-list.js";
3
+ const READER = { userId: "00000000-0000-4000-8000-000000000001", canWrite: false, tokenId: "session-check" };
4
+ const WRITER = { ...READER, canWrite: true };
5
+ const LEVELS = [
6
+ ["read", READER],
7
+ ["write", WRITER],
8
+ ];
9
+ /**
10
+ * Throws unless a session opened under `before` (the previous release's factory) is renewed under `after` for every
11
+ * access level whose served tool list changed, and kept for every level whose list did not. A pair where no level
12
+ * changed throws too: the check would prove nothing.
13
+ */
14
+ export async function expectToolListSessionRenewed(before, after) {
15
+ const problems = [];
16
+ const unchanged = [];
17
+ for (const [level, identity] of LEVELS) {
18
+ const sessionId = createToolListSessionId(await readVersion(before, identity));
19
+ const isCurrent = isToolListSessionCurrent(sessionId, await readVersion(after, identity));
20
+ const hasChanged = fingerprintToolList(await listServedTools(() => before(identity))) !== fingerprintToolList(await listServedTools(() => after(identity)));
21
+ if (!hasChanged)
22
+ unchanged.push(level);
23
+ if (hasChanged && isCurrent)
24
+ problems.push(`the ${level} tool list changed but a session opened before is kept`);
25
+ if (!hasChanged && !isCurrent)
26
+ problems.push(`the ${level} tool list did not change but a session opened before is renewed`);
27
+ }
28
+ if (unchanged.length === LEVELS.length)
29
+ problems.push(`the tool list did not change for any access level (${unchanged.join(", ")})`);
30
+ if (problems.length > 0)
31
+ throw new Error(`@softure-ai/mcp-access: sessions do not follow the tool list: ${problems.join("; ")}`);
32
+ }
33
+ async function readVersion(factory, identity) {
34
+ const reader = factory.toolListVersion;
35
+ if (typeof reader !== "function") {
36
+ throw new Error("@softure-ai/mcp-access: the factory has no toolListVersion; build it with createMcpServerFactory or pass toolListVersion to createMcpEndpoint");
37
+ }
38
+ return reader(identity);
39
+ }
40
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessions.js","sourceRoot":"","sources":["../../src/testing/sessions.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,gCAAgC,CAAC;AACnG,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAE9E,MAAM,MAAM,GAAsB,EAAE,MAAM,EAAE,sCAAsC,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,eAAe,EAAE,CAAC;AAChI,MAAM,MAAM,GAAsB,EAAE,GAAG,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;AAChE,MAAM,MAAM,GAAG;IACb,CAAC,MAAM,EAAE,MAAM,CAAC;IAChB,CAAC,OAAO,EAAE,MAAM,CAAC;CACT,CAAC;AAEX;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,4BAA4B,CAAC,MAAwB,EAAE,KAAuB;IAClG,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,SAAS,GAAa,EAAE,CAAC;IAC/B,KAAK,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QACvC,MAAM,SAAS,GAAG,uBAAuB,CAAC,MAAM,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;QAC/E,MAAM,SAAS,GAAG,wBAAwB,CAAC,SAAS,EAAE,MAAM,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC;QAC1F,MAAM,UAAU,GAAG,mBAAmB,CAAC,MAAM,eAAe,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,mBAAmB,CAAC,MAAM,eAAe,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;QAC5J,IAAI,CAAC,UAAU;YAAE,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACvC,IAAI,UAAU,IAAI,SAAS;YAAE,QAAQ,CAAC,IAAI,CAAC,OAAO,KAAK,wDAAwD,CAAC,CAAC;QACjH,IAAI,CAAC,UAAU,IAAI,CAAC,SAAS;YAAE,QAAQ,CAAC,IAAI,CAAC,OAAO,KAAK,kEAAkE,CAAC,CAAC;IAC/H,CAAC;IACD,IAAI,SAAS,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM;QAAE,QAAQ,CAAC,IAAI,CAAC,sDAAsD,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACrI,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,iEAAiE,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACnI,CAAC;AAED,KAAK,UAAU,WAAW,CAAC,OAAyB,EAAE,QAA2B;IAC/E,MAAM,MAAM,GAAI,OAA8C,CAAC,eAAe,CAAC;IAC/E,IAAI,OAAO,MAAM,KAAK,UAAU,EAAE,CAAC;QACjC,MAAM,IAAI,KAAK,CAAC,+IAA+I,CAAC,CAAC;IACnK,CAAC;IACD,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC;AAC1B,CAAC"}
package/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "mcp-access",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "dependsOn": { "security": "^0.2.0", "auth": "^0.2.0", "jobs": "^0.1.1?", "ops": "^0.2.0?" },
5
5
  "dbSchema": "mcp",
6
6
  "tables": ["access_tokens", "oauth_clients", "oauth_authorization_codes", "oauth_grants", "oauth_registration_window", "used_confirmations", "operation_tasks"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/mcp-access",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "SOFTURE AI MCP access module: hashed, scoped, expiring access tokens, a rate-limited Bearer endpoint around the app's MCP server factory, and a token page with client setup instructions for Next.js; tool result helpers with a safe error boundary, a stdio entry, a catalog parity check and the softure-mcp prune command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -51,7 +51,7 @@ export const API_TOKEN_RATE_LIMIT_BUCKETS = {
51
51
  export const mcpAccess = defineModule({
52
52
  manifest: {
53
53
  id: MODULE_ID,
54
- version: "0.4.0",
54
+ version: "0.6.0",
55
55
  dependsOn: { security: "^0.2.0", auth: "^0.2.0", jobs: "^0.1.1?", ops: "^0.2.0?" },
56
56
  dbSchema: "mcp",
57
57
  tables: ["access_tokens", "oauth_clients", "oauth_authorization_codes", "oauth_grants", "oauth_registration_window", "used_confirmations", "operation_tasks"],
@@ -19,7 +19,25 @@ import { getProtectedResourceMetadataUrl, isOAuthEnabled } from "./oauth-http.js
19
19
  import { resolveMcpOrigins } from "./origins.js";
20
20
  import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "./scopes.js";
21
21
  import { verifyAccessToken, type McpAccessContext } from "./tokens.js";
22
+ import {
23
+ createSessionRenewalResponse,
24
+ createToolListSessionId,
25
+ isInitializeRequest,
26
+ isToolListSessionCurrent,
27
+ MCP_SESSION_ID_HEADER,
28
+ readToolListSessionVersion,
29
+ rewriteInitializeAnswer,
30
+ } from "./tool-list-session.js";
22
31
  import { isWritingEnabled } from "./writes-switch.js";
32
+ import {
33
+ createDiagnosticsSink,
34
+ readJsonRpcSummary,
35
+ sanitizeDiagnosticText,
36
+ type JsonRpcSummary,
37
+ type McpRequestDiagnostic,
38
+ type McpRequestDiagnostics,
39
+ type McpSessionState,
40
+ } from "./request-diagnostics.js";
23
41
 
24
42
  export { MCP_READ_SCOPE, MCP_WRITE_SCOPE };
25
43
 
@@ -37,8 +55,35 @@ const NO_STORE = { "cache-control": "no-store" };
37
55
  /** The app's MCP server for one request. It registers write tools only when `canWrite` is true. */
38
56
  export type McpServerFactory = (identity: McpServerIdentity) => McpServer | Server | Promise<McpServer | Server>;
39
57
 
58
+ /** Reads the tool list version the factory's servers answer at `initialize` for the identity. */
59
+ export type ToolListVersionReader = (identity: McpServerIdentity) => Promise<string>;
60
+
61
+ /** A factory that can tell its tool list version without building a server; `createMcpServerFactory` returns one. */
62
+ export interface VersionedMcpServerFactory extends McpServerFactory {
63
+ readonly toolListVersion: ToolListVersionReader;
64
+ }
65
+
40
66
  export interface McpEndpointOptions {
41
- readonly createServer: McpServerFactory;
67
+ readonly createServer: McpServerFactory | VersionedMcpServerFactory;
68
+ /**
69
+ * The tool list version per identity, for sessions tied to it (#1055). Default: the factory's own
70
+ * `toolListVersion` (`createMcpServerFactory`). Without either, the endpoint issues no session id.
71
+ */
72
+ readonly toolListVersion?: ToolListVersionReader;
73
+ /**
74
+ * Request diagnostics (#1066), off by default: `true` logs one `console.info` line per request, a function receives
75
+ * each entry. An entry holds the JSON-RPC method, the tool name, the session state, the served tool list version,
76
+ * the status and the client's `clientInfo` on `initialize`; never a token, body, argument value or identity.
77
+ */
78
+ readonly diagnostics?: McpRequestDiagnostics;
79
+ }
80
+
81
+ /** What serving a request found out, for the diagnostics. */
82
+ interface SessionTrace {
83
+ session: McpSessionState;
84
+ sessionVersion: string | null;
85
+ toolListVersion: string | null;
86
+ sessionIssued: boolean;
42
87
  }
43
88
 
44
89
  /** Serves one request to the MCP endpoint. */
@@ -50,7 +95,9 @@ export type McpEndpoint = (ctx: McpAccessContext, request: Request) => Promise<R
50
95
  * (`responseMode: "json"`): a stream would add proxy buffering and timeouts for no tool that needs
51
96
  * it, and mid-call notifications are dropped.
52
97
  */
53
- export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndpoint {
98
+ export function createMcpEndpoint({ createServer, toolListVersion, diagnostics }: McpEndpointOptions): McpEndpoint {
99
+ const readToolListVersion = toolListVersion ?? getFactoryToolListVersion(createServer);
100
+ const report = createDiagnosticsSink(diagnostics);
54
101
  const handler = createMcpHandler(
55
102
  (requestContext) => createServer(readIdentity(requestContext.authInfo)),
56
103
  {
@@ -61,6 +108,17 @@ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndp
61
108
  );
62
109
 
63
110
  return async (ctx, request) => {
111
+ if (report === undefined) return serve(ctx, request, null);
112
+ const trace: SessionTrace = { session: "unchecked", sessionVersion: null, toolListVersion: null, sessionIssued: false };
113
+ // Read before serving: the SDK consumes the body. Only the method, tool name and clientInfo are kept.
114
+ const summary = await readJsonRpcSummary(request, MAX_MCP_REQUEST_BYTES);
115
+ const response = await serve(ctx, request, trace);
116
+ report(createDiagnostic(request, summary, trace, response.status));
117
+ return response;
118
+ };
119
+
120
+ async function serve(ctx: McpAccessContext, request: Request, trace: SessionTrace | null): Promise<Response> {
121
+ if (trace !== null && !request.headers.has(MCP_SESSION_ID_HEADER)) trace.session = "none";
64
122
  assertRateLimitBucket(ctx);
65
123
  const client = identifyClient(ctx, request.headers);
66
124
  if (!client.ok) return Response.json({ error: "client_unidentified" }, { status: 400, headers: NO_STORE });
@@ -83,10 +141,73 @@ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndp
83
141
 
84
142
  const authInfo = await authenticate(ctx, request);
85
143
  if (authInfo instanceof Response) return authInfo;
86
- return handler.fetch(request, { authInfo });
144
+ return serveInSession(request, authInfo, trace);
145
+ }
146
+
147
+ /**
148
+ * Sessions tied to the tool list version (#1055): a session from another version (a release changed a tool, or the
149
+ * access level changed) answers 404, so the client initializes again and lists the tools again; `initialize` gets a
150
+ * new session id. A request without a session id is served as it always was.
151
+ */
152
+ async function serveInSession(request: Request, authInfo: AuthInfo, trace: SessionTrace | null): Promise<Response> {
153
+ const sessionId = request.headers.get(MCP_SESSION_ID_HEADER);
154
+ const isInitialize = sessionId === null && (await isInitializeRequest(request, MAX_MCP_REQUEST_BYTES));
155
+ if (readToolListVersion === undefined || (sessionId === null && !isInitialize)) {
156
+ const response = await handler.fetch(request, { authInfo });
157
+ return isInitialize ? rewriteInitializeAnswer(response, null) : response;
158
+ }
159
+ let version;
160
+ try {
161
+ version = await readToolListVersion(readIdentity(authInfo));
162
+ } catch (error) {
163
+ // Without the version no session can be checked or issued: serve the request statelessly, as before #1055.
164
+ console.error(`@softure-ai/mcp-access: reading the tool list version failed: ${errorLogLabel(error)}`);
165
+ const response = await handler.fetch(request, { authInfo });
166
+ return isInitialize ? rewriteInitializeAnswer(response, null) : response;
167
+ }
168
+ if (trace !== null) trace.toolListVersion = version;
169
+ if (sessionId !== null) {
170
+ if (isToolListSessionCurrent(sessionId, version)) {
171
+ if (trace !== null) trace.session = "current";
172
+ return handler.fetch(request, { authInfo });
173
+ }
174
+ if (trace !== null) recordRenewedSession(trace, sessionId);
175
+ return createSessionRenewalResponse();
176
+ }
177
+ const response = await rewriteInitializeAnswer(await handler.fetch(request, { authInfo }), createToolListSessionId(version));
178
+ if (trace !== null) trace.sessionIssued = response.headers.has(MCP_SESSION_ID_HEADER);
179
+ return response;
180
+ }
181
+ }
182
+
183
+ /** A session answered 404: stale when the id carries a version, not issued by the endpoint otherwise. */
184
+ function recordRenewedSession(trace: SessionTrace, sessionId: string): void {
185
+ const sessionVersion = readToolListSessionVersion(sessionId);
186
+ trace.session = sessionVersion === null ? "not_issued" : "stale";
187
+ trace.sessionVersion = sessionVersion === null ? null : sanitizeDiagnosticText(sessionVersion);
188
+ }
189
+
190
+ /** The diagnostics entry of one served request. */
191
+ function createDiagnostic(request: Request, summary: JsonRpcSummary, trace: SessionTrace, status: number): McpRequestDiagnostic {
192
+ return {
193
+ httpMethod: request.method,
194
+ method: summary.method,
195
+ tool: summary.tool,
196
+ session: trace.session,
197
+ sessionVersion: trace.sessionVersion,
198
+ toolListVersion: trace.toolListVersion,
199
+ sessionIssued: trace.sessionIssued,
200
+ status,
201
+ client: summary.client,
202
+ protocolVersion: summary.protocolVersion,
87
203
  };
88
204
  }
89
205
 
206
+ /** The factory's own version reader, when it has one (`createMcpServerFactory`). */
207
+ function getFactoryToolListVersion(factory: McpServerFactory | VersionedMcpServerFactory): ToolListVersionReader | undefined {
208
+ return "toolListVersion" in factory && typeof factory.toolListVersion === "function" ? factory.toolListVersion : undefined;
209
+ }
210
+
90
211
  /**
91
212
  * The verified identity as the SDK's `AuthInfo`, or the challenge response. Unknown, revoked,
92
213
  * expired and malformed tokens get the same `401 invalid_token`. Expiry is decided by the query on
@@ -9,6 +9,8 @@ export {
9
9
  type McpEndpoint,
10
10
  type McpEndpointOptions,
11
11
  type McpServerFactory,
12
+ type ToolListVersionReader,
13
+ type VersionedMcpServerFactory,
12
14
  } from "./endpoint.js";
13
15
  export { actionTool, toFormData, type ActionToolOptions, type FormActionHandler } from "./action-tool.js";
14
16
  export {
@@ -155,6 +157,13 @@ export {
155
157
  type ServedTool,
156
158
  type ToolListMeasurement,
157
159
  } from "./tool-list.js";
160
+ export {
161
+ createToolListSessionId,
162
+ isToolListSessionCurrent,
163
+ MCP_SESSION_ID_HEADER,
164
+ readToolListSessionVersion,
165
+ } from "./tool-list-session.js";
166
+ export { type McpRequestDiagnostic, type McpRequestDiagnostics, type McpSessionState } from "./request-diagnostics.js";
158
167
  export {
159
168
  toolError,
160
169
  toolResult,
@@ -0,0 +1,130 @@
1
+ // Opt-in request diagnostics for the MCP endpoint (#1066). A hosted client can keep a stale tool list although the
2
+ // server serves the current one, and nothing showed what the client actually sends. One entry per request says which
3
+ // JSON-RPC method came, in which session state, with which answer. No token, body, argument value, session id or
4
+ // user identity is ever part of an entry; client-supplied strings are cut and stripped of control characters.
5
+ import { errorLogLabel } from "@softure-ai/core";
6
+
7
+ /** How a request's `Mcp-Session-Id` relates to the tool list version (#1055). */
8
+ export type McpSessionState =
9
+ /** No session id came with the request. */
10
+ | "none"
11
+ /** The session carries the current version and was served. */
12
+ | "current"
13
+ /** The session carries another version and was answered 404, so the client should initialize again. */
14
+ | "stale"
15
+ /** The session id is not one the endpoint issues; answered 404 like a stale one. */
16
+ | "not_issued"
17
+ /** A session id came, but it was not checked (refused before authentication, or no version to check against). */
18
+ | "unchecked";
19
+
20
+ /** One request as the diagnostics see it. */
21
+ export interface McpRequestDiagnostic {
22
+ /** The HTTP method. */
23
+ readonly httpMethod: string;
24
+ /** The JSON-RPC method, `"batch"` for an array, `null` when the body was not read or is not JSON-RPC. */
25
+ readonly method: string | null;
26
+ /** The tool name of a `tools/call`, otherwise `null`. */
27
+ readonly tool: string | null;
28
+ readonly session: McpSessionState;
29
+ /** The version a stale session carries, otherwise `null`. */
30
+ readonly sessionVersion: string | null;
31
+ /** The tool list version served to the identity, `null` when unknown (refused before authentication, no reader). */
32
+ readonly toolListVersion: string | null;
33
+ /** Whether the answer issued a new session id (a successful `initialize`). */
34
+ readonly sessionIssued: boolean;
35
+ /** The HTTP status of the answer. */
36
+ readonly status: number;
37
+ /** The client's `clientInfo` on `initialize`, otherwise `null`. */
38
+ readonly client: { readonly name: string; readonly version: string } | null;
39
+ /** The protocol version the client asked for on `initialize`, otherwise the `MCP-Protocol-Version` header, or `null`. */
40
+ readonly protocolVersion: string | null;
41
+ }
42
+
43
+ /** `true` logs one `console.info` line per request; a function receives each entry instead. */
44
+ export type McpRequestDiagnostics = boolean | ((entry: McpRequestDiagnostic) => void);
45
+
46
+ /** What the diagnostics read from a request body. */
47
+ export interface JsonRpcSummary {
48
+ readonly method: string | null;
49
+ readonly tool: string | null;
50
+ readonly client: { readonly name: string; readonly version: string } | null;
51
+ readonly protocolVersion: string | null;
52
+ }
53
+
54
+ const MAX_TEXT_LENGTH = 128;
55
+ const PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
56
+ const EMPTY_SUMMARY: JsonRpcSummary = { method: null, tool: null, client: null, protocolVersion: null };
57
+
58
+ /** Client-supplied text as it may be logged: control characters replaced, at most 128 characters. */
59
+ export function sanitizeDiagnosticText(value: string): string {
60
+ const clean = Array.from(value, (character) => (isControlCharacter(character) ? "?" : character)).join("");
61
+ return clean.length > MAX_TEXT_LENGTH ? `${clean.slice(0, MAX_TEXT_LENGTH)}…` : clean;
62
+ }
63
+
64
+ /**
65
+ * The method, tool name, `clientInfo` and protocol version of a request, read from a clone of its body. A body over
66
+ * `maxBytes` or one that is not JSON gives no method (the SDK answers it itself). Nothing else of the body is kept.
67
+ */
68
+ export async function readJsonRpcSummary(request: Request, maxBytes: number): Promise<JsonRpcSummary> {
69
+ const header = request.headers.get(PROTOCOL_VERSION_HEADER);
70
+ const headerVersion = header === null ? null : sanitizeDiagnosticText(header);
71
+ const withHeader = { ...EMPTY_SUMMARY, protocolVersion: headerVersion };
72
+ if (request.method !== "POST") return withHeader;
73
+ const length = Number(request.headers.get("content-length") ?? "0");
74
+ if (!Number.isFinite(length) || length > maxBytes) return withHeader;
75
+ let body: unknown;
76
+ try {
77
+ const text = await request.clone().text();
78
+ if (text.length > maxBytes) return withHeader;
79
+ body = JSON.parse(text);
80
+ } catch {
81
+ // Not JSON or no readable body: the SDK answers that itself; the entry just has no method.
82
+ return withHeader;
83
+ }
84
+ if (Array.isArray(body)) return { ...withHeader, method: "batch" };
85
+ if (!isRecord(body) || typeof body.method !== "string") return withHeader;
86
+
87
+ const method = sanitizeDiagnosticText(body.method);
88
+ const params = isRecord(body.params) ? body.params : {};
89
+ if (body.method === "tools/call") {
90
+ return { ...withHeader, method, tool: typeof params.name === "string" ? sanitizeDiagnosticText(params.name) : null };
91
+ }
92
+ if (body.method !== "initialize") return { ...withHeader, method };
93
+ const clientInfo = isRecord(params.clientInfo) ? params.clientInfo : null;
94
+ const client =
95
+ clientInfo === null
96
+ ? null
97
+ : {
98
+ name: typeof clientInfo.name === "string" ? sanitizeDiagnosticText(clientInfo.name) : "",
99
+ version: typeof clientInfo.version === "string" ? sanitizeDiagnosticText(clientInfo.version) : "",
100
+ };
101
+ const protocolVersion = typeof params.protocolVersion === "string" ? sanitizeDiagnosticText(params.protocolVersion) : headerVersion;
102
+ return { method, tool: null, client, protocolVersion };
103
+ }
104
+
105
+ /** The sink for the option, or `undefined` when diagnostics are off. A throwing sink is logged, never rethrown. */
106
+ export function createDiagnosticsSink(diagnostics: McpRequestDiagnostics | undefined): ((entry: McpRequestDiagnostic) => void) | undefined {
107
+ if (diagnostics === undefined || diagnostics === false) return undefined;
108
+ const write = diagnostics === true ? logDiagnostic : diagnostics;
109
+ return (entry) => {
110
+ try {
111
+ write(entry);
112
+ } catch (error) {
113
+ console.error(`@softure-ai/mcp-access: the request diagnostics sink failed: ${errorLogLabel(error)}`);
114
+ }
115
+ };
116
+ }
117
+
118
+ function logDiagnostic(entry: McpRequestDiagnostic): void {
119
+ console.info(`@softure-ai/mcp-access: mcp request ${JSON.stringify(entry)}`);
120
+ }
121
+
122
+ /** C0 controls, DEL and C1 controls: anything that could break or forge a log line. */
123
+ function isControlCharacter(character: string): boolean {
124
+ const code = character.codePointAt(0) ?? 0;
125
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
126
+ }
127
+
128
+ function isRecord(value: unknown): value is Record<string, unknown> {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value);
130
+ }
@@ -10,7 +10,7 @@
10
10
  // });
11
11
  import { McpServer, type McpServerOptions } from "@modelcontextprotocol/server";
12
12
  import type { McpServerIdentity } from "../contract.js";
13
- import type { McpServerFactory } from "./endpoint.js";
13
+ import type { VersionedMcpServerFactory } from "./endpoint.js";
14
14
  import { withStrictToolInput, type StrictToolInputOptions } from "./strict-input.js";
15
15
  import { registerServerInfoTool, type ServerInfoToolOptions } from "./server-info.js";
16
16
  import { measureToolList, type ToolListMeasurement } from "./tool-list.js";
@@ -39,8 +39,12 @@ export interface McpServerFactoryOptions {
39
39
  readonly serverInfoTool?: ServerInfoToolOptions;
40
40
  }
41
41
 
42
- /** An `McpServerFactory` whose servers carry the tool list's version for the identity's access level. */
43
- export function createMcpServerFactory(options: McpServerFactoryOptions): McpServerFactory {
42
+ /**
43
+ * An `McpServerFactory` whose servers carry the tool list's version for the identity's access level. Its
44
+ * `toolListVersion(identity)` reads that version without building a server, so `createMcpEndpoint` ties each session
45
+ * to it (#1055).
46
+ */
47
+ export function createMcpServerFactory(options: McpServerFactoryOptions): VersionedMcpServerFactory {
44
48
  const strictOptions = options.strictToolInput === true ? {} : options.strictToolInput === false ? undefined : options.strictToolInput;
45
49
  // The served version reaches `get_server_info` here; the recording server registers it with a placeholder, since
46
50
  // its handler never runs there and the version is not part of the listed tool.
@@ -66,8 +70,9 @@ export function createMcpServerFactory(options: McpServerFactoryOptions): McpSer
66
70
  return measurement;
67
71
  };
68
72
 
69
- return async (identity) => {
70
- const version = options.version ?? (await measure(identity)).toolListVersion;
73
+ const toolListVersion = async (identity: McpServerIdentity): Promise<string> => options.version ?? (await measure(identity)).toolListVersion;
74
+ const factory = async (identity: McpServerIdentity): Promise<McpServer> => {
75
+ const version = await toolListVersion(identity);
71
76
  const server = new McpServer(
72
77
  { name: options.name, version, ...(options.title === undefined ? {} : { title: options.title }) },
73
78
  options.serverOptions,
@@ -75,4 +80,5 @@ export function createMcpServerFactory(options: McpServerFactoryOptions): McpSer
75
80
  await registerTools(server, identity, version);
76
81
  return server;
77
82
  };
83
+ return Object.assign(factory, { toolListVersion });
78
84
  }