@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,103 @@
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
+
9
+ /** The header that carries the session id, both ways. */
10
+ export const MCP_SESSION_ID_HEADER = "mcp-session-id";
11
+
12
+ /** Random bytes after the version, as hex. */
13
+ const RANDOM_BYTES = 16;
14
+ const SESSION_ID_PATTERN = /^([A-Za-z0-9_-]+)\.[0-9a-f]+$/;
15
+ const SSE_DATA_PREFIX = "data: ";
16
+
17
+ /** A new session id for the version: `<base64url(version)>.<32 hex>`, visible ASCII whatever the version holds. */
18
+ export function createToolListSessionId(toolListVersion: string): string {
19
+ return `${Buffer.from(toolListVersion, "utf8").toString("base64url")}.${randomBytes(RANDOM_BYTES).toString("hex")}`;
20
+ }
21
+
22
+ /** The tool list version a session id carries, or `null` when the id is not one the endpoint issues. */
23
+ export function readToolListSessionVersion(sessionId: string): string | null {
24
+ const match = SESSION_ID_PATTERN.exec(sessionId);
25
+ if (match?.[1] === undefined) return null;
26
+ return Buffer.from(match[1], "base64url").toString("utf8");
27
+ }
28
+
29
+ /** Whether a request in this session may be served: its version is the current one. */
30
+ export function isToolListSessionCurrent(sessionId: string, toolListVersion: string): boolean {
31
+ return readToolListSessionVersion(sessionId) === toolListVersion;
32
+ }
33
+
34
+ /** The answer to a request in a session from another tool list version: the client starts a new session. */
35
+ export function createSessionRenewalResponse(): Response {
36
+ return Response.json(
37
+ { jsonrpc: "2.0", error: { code: -32001, message: "Session not found: the tool list changed, initialize again" }, id: null },
38
+ { status: 404, headers: { "cache-control": "no-store" } },
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Whether the request is a JSON-RPC `initialize`: a POST without a session id whose JSON body asks for it. The body
44
+ * is read from a clone, and a body over `maxBytes` is not read (the SDK refuses it with 413 anyway).
45
+ */
46
+ export async function isInitializeRequest(request: Request, maxBytes: number): Promise<boolean> {
47
+ if (request.method !== "POST" || request.headers.has(MCP_SESSION_ID_HEADER)) return false;
48
+ const length = Number(request.headers.get("content-length") ?? "0");
49
+ if (!Number.isFinite(length) || length > maxBytes) return false;
50
+ let body: unknown;
51
+ try {
52
+ const text = await request.clone().text();
53
+ if (text.length > maxBytes) return false;
54
+ body = JSON.parse(text);
55
+ } catch {
56
+ // Not JSON: the SDK answers the parse error itself.
57
+ return false;
58
+ }
59
+ return typeof body === "object" && body !== null && !Array.isArray(body) && (body as { method?: unknown }).method === "initialize";
60
+ }
61
+
62
+ /**
63
+ * The `initialize` answer with `capabilities.tools.listChanged: false`, since no answer of the endpoint ever carries
64
+ * the notification, and with the session id when there is one. The body keeps its format (JSON or one SSE event);
65
+ * an answer that is not a successful `InitializeResult` is returned as it is.
66
+ */
67
+ export async function rewriteInitializeAnswer(response: Response, sessionId: string | null): Promise<Response> {
68
+ if (response.status !== 200) return response;
69
+ const text = await response.text();
70
+ const isEventStream = (response.headers.get("content-type") ?? "").includes("text/event-stream");
71
+ const lines = text.split("\n");
72
+ const dataIndex = isEventStream ? lines.findIndex((line) => line.startsWith(SSE_DATA_PREFIX)) : -1;
73
+ const json = isEventStream ? (dataIndex === -1 ? null : (lines[dataIndex] ?? "").slice(SSE_DATA_PREFIX.length)) : text;
74
+
75
+ const answer = parseInitializeAnswer(json);
76
+ const headers = new Headers(response.headers);
77
+ // The body changes length, so the old one would be wrong.
78
+ headers.delete("content-length");
79
+ const tools = answer?.result?.capabilities?.tools;
80
+ if (answer === null || answer.result?.protocolVersion === undefined) {
81
+ return new Response(text, { status: response.status, statusText: response.statusText, headers });
82
+ }
83
+ if (tools !== undefined && tools !== null && typeof tools === "object") tools.listChanged = false;
84
+ if (sessionId !== null) headers.set(MCP_SESSION_ID_HEADER, sessionId);
85
+
86
+ const rewritten = JSON.stringify(answer);
87
+ if (isEventStream) lines[dataIndex] = `${SSE_DATA_PREFIX}${rewritten}`;
88
+ return new Response(isEventStream ? lines.join("\n") : rewritten, { status: response.status, statusText: response.statusText, headers });
89
+ }
90
+
91
+ /** The answer as JSON, or `null` when there is none or it is not JSON (the SDK's own error text). */
92
+ function parseInitializeAnswer(json: string | null): InitializeAnswer | null {
93
+ if (json === null) return null;
94
+ try {
95
+ return JSON.parse(json) as InitializeAnswer;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ interface InitializeAnswer {
102
+ result?: { protocolVersion?: unknown; capabilities?: { tools?: { listChanged?: boolean } | null } };
103
+ }
@@ -6,3 +6,4 @@ export {
6
6
  type ServedToolLists,
7
7
  type ToolCatalogDifferences,
8
8
  } from "./catalog.js";
9
+ export { expectToolListSessionRenewed } from "./sessions.js";
@@ -0,0 +1,41 @@
1
+ // Sessions tied to the tool list version (#1055), checked on the app's factories: a release that changes a tool must
2
+ // renew the sessions opened before it, or a connected client keeps the old schemas. Framework-free: it throws.
3
+ import type { McpServerIdentity } from "../contract.js";
4
+ import type { McpServerFactory, VersionedMcpServerFactory } from "../server/endpoint.js";
5
+ import { createToolListSessionId, isToolListSessionCurrent } from "../server/tool-list-session.js";
6
+ import { fingerprintToolList, listServedTools } from "../server/tool-list.js";
7
+
8
+ const READER: McpServerIdentity = { userId: "00000000-0000-4000-8000-000000000001", canWrite: false, tokenId: "session-check" };
9
+ const WRITER: McpServerIdentity = { ...READER, canWrite: true };
10
+ const LEVELS = [
11
+ ["read", READER],
12
+ ["write", WRITER],
13
+ ] as const;
14
+
15
+ /**
16
+ * Throws unless a session opened under `before` (the previous release's factory) is renewed under `after` for every
17
+ * access level whose served tool list changed, and kept for every level whose list did not. A pair where no level
18
+ * changed throws too: the check would prove nothing.
19
+ */
20
+ export async function expectToolListSessionRenewed(before: McpServerFactory, after: McpServerFactory): Promise<void> {
21
+ const problems: string[] = [];
22
+ const unchanged: string[] = [];
23
+ for (const [level, identity] of LEVELS) {
24
+ const sessionId = createToolListSessionId(await readVersion(before, identity));
25
+ const isCurrent = isToolListSessionCurrent(sessionId, await readVersion(after, identity));
26
+ const hasChanged = fingerprintToolList(await listServedTools(() => before(identity))) !== fingerprintToolList(await listServedTools(() => after(identity)));
27
+ if (!hasChanged) unchanged.push(level);
28
+ if (hasChanged && isCurrent) problems.push(`the ${level} tool list changed but a session opened before is kept`);
29
+ if (!hasChanged && !isCurrent) problems.push(`the ${level} tool list did not change but a session opened before is renewed`);
30
+ }
31
+ if (unchanged.length === LEVELS.length) problems.push(`the tool list did not change for any access level (${unchanged.join(", ")})`);
32
+ if (problems.length > 0) throw new Error(`@softure-ai/mcp-access: sessions do not follow the tool list: ${problems.join("; ")}`);
33
+ }
34
+
35
+ async function readVersion(factory: McpServerFactory, identity: McpServerIdentity): Promise<string> {
36
+ const reader = (factory as Partial<VersionedMcpServerFactory>).toolListVersion;
37
+ if (typeof reader !== "function") {
38
+ throw new Error("@softure-ai/mcp-access: the factory has no toolListVersion; build it with createMcpServerFactory or pass toolListVersion to createMcpEndpoint");
39
+ }
40
+ return reader(identity);
41
+ }