@softure-ai/mcp-access 0.2.6 → 0.2.9

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 (79) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +141 -10
  3. package/dist/index.d.ts +16 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +8 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/messages/en.d.ts +10 -1
  8. package/dist/messages/en.d.ts.map +1 -1
  9. package/dist/messages/en.js +10 -1
  10. package/dist/messages/en.js.map +1 -1
  11. package/dist/messages/index.d.ts +10 -1
  12. package/dist/messages/index.d.ts.map +1 -1
  13. package/dist/next/oauth-page.d.ts +6 -1
  14. package/dist/next/oauth-page.d.ts.map +1 -1
  15. package/dist/next/oauth-page.js +6 -5
  16. package/dist/next/oauth-page.js.map +1 -1
  17. package/dist/server/endpoint.d.ts.map +1 -1
  18. package/dist/server/endpoint.js +6 -4
  19. package/dist/server/endpoint.js.map +1 -1
  20. package/dist/server/index.d.ts +5 -0
  21. package/dist/server/index.d.ts.map +1 -1
  22. package/dist/server/index.js +5 -0
  23. package/dist/server/index.js.map +1 -1
  24. package/dist/server/oauth-http.d.ts +5 -0
  25. package/dist/server/oauth-http.d.ts.map +1 -1
  26. package/dist/server/oauth-http.js +11 -7
  27. package/dist/server/oauth-http.js.map +1 -1
  28. package/dist/server/server-factory.d.ts +31 -0
  29. package/dist/server/server-factory.d.ts.map +1 -0
  30. package/dist/server/server-factory.js +49 -0
  31. package/dist/server/server-factory.js.map +1 -0
  32. package/dist/server/server-info.d.ts +34 -0
  33. package/dist/server/server-info.d.ts.map +1 -0
  34. package/dist/server/server-info.js +30 -0
  35. package/dist/server/server-info.js.map +1 -0
  36. package/dist/server/strict-input.d.ts +17 -0
  37. package/dist/server/strict-input.d.ts.map +1 -0
  38. package/dist/server/strict-input.js +157 -0
  39. package/dist/server/strict-input.js.map +1 -0
  40. package/dist/server/tool-list.d.ts +35 -0
  41. package/dist/server/tool-list.d.ts.map +1 -0
  42. package/dist/server/tool-list.js +68 -0
  43. package/dist/server/tool-list.js.map +1 -0
  44. package/dist/server/writes-switch.d.ts +8 -0
  45. package/dist/server/writes-switch.d.ts.map +1 -0
  46. package/dist/server/writes-switch.js +25 -0
  47. package/dist/server/writes-switch.js.map +1 -0
  48. package/dist/stdio/serve.d.ts.map +1 -1
  49. package/dist/stdio/serve.js +5 -7
  50. package/dist/stdio/serve.js.map +1 -1
  51. package/dist/testing/catalog.d.ts +8 -0
  52. package/dist/testing/catalog.d.ts.map +1 -1
  53. package/dist/testing/catalog.js +8 -19
  54. package/dist/testing/catalog.js.map +1 -1
  55. package/dist/testing/index.d.ts +1 -1
  56. package/dist/testing/index.d.ts.map +1 -1
  57. package/dist/testing/index.js +1 -1
  58. package/dist/testing/index.js.map +1 -1
  59. package/dist/ui/consent-form.d.ts +2 -2
  60. package/dist/ui/consent-form.d.ts.map +1 -1
  61. package/dist/ui/consent-form.js +3 -2
  62. package/dist/ui/consent-form.js.map +1 -1
  63. package/module.json +2 -2
  64. package/package.json +1 -1
  65. package/src/index.ts +9 -2
  66. package/src/messages/en.ts +11 -1
  67. package/src/next/oauth-page.tsx +12 -6
  68. package/src/server/endpoint.ts +6 -4
  69. package/src/server/index.ts +23 -0
  70. package/src/server/oauth-http.ts +16 -7
  71. package/src/server/server-factory.ts +78 -0
  72. package/src/server/server-info.ts +76 -0
  73. package/src/server/strict-input.ts +191 -0
  74. package/src/server/tool-list.ts +105 -0
  75. package/src/server/writes-switch.ts +24 -0
  76. package/src/stdio/serve.ts +5 -7
  77. package/src/testing/catalog.ts +14 -23
  78. package/src/testing/index.ts +7 -1
  79. package/src/ui/consent-form.tsx +12 -8
@@ -25,6 +25,7 @@ import { resolveMcpOrigins } from "./origins.js";
25
25
  import { isClientRegistrationOpen } from "./registration-window.js";
26
26
  import { isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
27
27
  import type { McpAccessContext } from "./tokens.js";
28
+ import { isWritingEnabled } from "./writes-switch.js";
28
29
 
29
30
  /** The security bucket OAuth registration and token requests count in. */
30
31
  export const MCP_OAUTH_RATE_LIMIT_BUCKET = "mcp-oauth";
@@ -175,10 +176,11 @@ export function isWriteScopeRequested(scope: string | null): boolean {
175
176
 
176
177
  /**
177
178
  * Whether the request asks to write, where a missing or blank `scope` is the server's default
178
- * (RFC 6749 §3.3): both scopes while the app allows writes, `mcp:read` otherwise.
179
+ * (RFC 6749 §3.3): both scopes while the app allows writes (`allowWrites` and the writes switch),
180
+ * `mcp:read` otherwise.
179
181
  */
180
- function isWriteRequestedOrDefault(config: SoftureConfig, scope: string | null): boolean {
181
- if (scope === null || scope.trim() === "") return getMcpAccessOptions(config).allowWrites;
182
+ function isWriteRequestedOrDefault(isWriteAllowed: boolean, scope: string | null): boolean {
183
+ if (scope === null || scope.trim() === "") return isWriteAllowed;
182
184
  return isWriteScopeRequested(scope);
183
185
  }
184
186
 
@@ -399,6 +401,11 @@ export interface AuthorizationRequest {
399
401
  readonly codeChallenge: string;
400
402
  /** The client asked for `mcp:write`. */
401
403
  readonly isWriteRequested: boolean;
404
+ /**
405
+ * Writes are on offer: the client asked for them (or sent no scope while writes are allowed) and the
406
+ * app allows writes right now (`allowWrites` and the `mcp-access.writes_enabled` switch).
407
+ */
408
+ readonly isWriteOffered: boolean;
402
409
  }
403
410
 
404
411
  /** Why the consent page shows an error instead of sending one back: the client is not confirmed. */
@@ -467,7 +474,9 @@ export async function validateAuthorizationRequest(
467
474
  return back("invalid_request", "PKCE is required: code_challenge with code_challenge_method S256.");
468
475
  }
469
476
  if (!isAcceptableResource(ctx.config, params.get("resource"), origins)) return back("invalid_target", "The resource parameter does not name this MCP server.");
470
- return { kind: "valid", request: { client, redirectUri, state, codeChallenge, isWriteRequested: isWriteRequestedOrDefault(ctx.config, params.get("scope")) } };
477
+ const isWriteAllowed = await isWritingEnabled(ctx);
478
+ const isWriteRequested = isWriteRequestedOrDefault(isWriteAllowed, params.get("scope"));
479
+ return { kind: "valid", request: { client, redirectUri, state, codeChallenge, isWriteRequested, isWriteOffered: isWriteRequested && isWriteAllowed } };
471
480
  }
472
481
 
473
482
  /** The consent page with the request's parameters: where the decision sends the person back. */
@@ -531,7 +540,7 @@ export async function handleAuthorizationDecision(ctx: McpAccessContext, request
531
540
  if (outcome.kind === "show-error") return seeOther(consent);
532
541
  if (outcome.kind === "redirect-error") return seeOther(outcome.location);
533
542
 
534
- const { client, redirectUri, state, codeChallenge, isWriteRequested } = outcome.request;
543
+ const { client, redirectUri, state, codeChallenge, isWriteOffered } = outcome.request;
535
544
  if (form.get("decision") !== "allow") {
536
545
  return seeOther(buildClientRedirect(ctx.config, redirectUri, { error: "access_denied", error_description: "The person denied access.", state }, origins));
537
546
  }
@@ -540,8 +549,8 @@ export async function handleAuthorizationDecision(ctx: McpAccessContext, request
540
549
  userId,
541
550
  redirectUri,
542
551
  codeChallenge,
543
- // Writes need the client's request, the app's allowWrites and the person's tick.
544
- canWrite: isWriteRequested && getMcpAccessOptions(ctx.config).allowWrites && form.get("canWrite") !== null,
552
+ // Writes need the client's request, the app's allowWrites, the writes switch and the person's tick.
553
+ canWrite: isWriteOffered && form.get("canWrite") !== null,
545
554
  });
546
555
  return seeOther(buildClientRedirect(ctx.config, redirectUri, { code, state }, origins));
547
556
  } catch (error) {
@@ -0,0 +1,78 @@
1
+ // The app's MCP server factory with a version that follows its tool list. The tools are measured once
2
+ // per access level (a read-only token sees fewer tools) on a recording server, and each request's
3
+ // server answers `initialize` with that level's version, so a client drops its cached `tools/list`
4
+ // whenever a release changes a tool. The factory plugs into `createMcpEndpoint`, `serveMcpStdio` and
5
+ // the catalog check like a hand-written one.
6
+ //
7
+ // export const createServer = createMcpServerFactory({
8
+ // name: "acme",
9
+ // registerTools: (server, identity) => registerNoteTools(server, identity),
10
+ // });
11
+ import { McpServer, type McpServerOptions } from "@modelcontextprotocol/server";
12
+ import type { McpServerIdentity } from "../contract.js";
13
+ import type { McpServerFactory } from "./endpoint.js";
14
+ import { withStrictToolInput, type StrictToolInputOptions } from "./strict-input.js";
15
+ import { registerServerInfoTool, type ServerInfoToolOptions } from "./server-info.js";
16
+ import { measureToolList, type ToolListMeasurement } from "./tool-list.js";
17
+
18
+ export interface McpServerFactoryOptions {
19
+ /** `serverInfo.name`. */
20
+ readonly name: string;
21
+ /** `serverInfo.title`, the name a client shows. */
22
+ readonly title?: string;
23
+ /** The `MAJOR.MINOR.PATCH` before `-tools-`. Default: `1.0.0`. */
24
+ readonly release?: string;
25
+ /** A fixed `serverInfo.version` instead of the measured one. */
26
+ readonly version?: string;
27
+ readonly serverOptions?: McpServerOptions;
28
+ /**
29
+ * Registers the tools for one request. The tool list may depend on `identity.canWrite` only: it is
30
+ * measured once per access level. A list that depends on the account needs `measureToolList` per call.
31
+ */
32
+ readonly registerTools: (server: McpServer, identity: McpServerIdentity) => unknown;
33
+ /** Every tool's input is strict (`withStrictToolInput`), on the served and the measured server alike. */
34
+ readonly strictToolInput?: boolean | StrictToolInputOptions;
35
+ /**
36
+ * Adds the read-only `get_server_info` tool for every access level: the build info (pass `readBuildInfo` of
37
+ * `@softure-ai/ops/server`) with the served name and version. Off by default.
38
+ */
39
+ readonly serverInfoTool?: ServerInfoToolOptions;
40
+ }
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 {
44
+ const strictOptions = options.strictToolInput === true ? {} : options.strictToolInput === false ? undefined : options.strictToolInput;
45
+ // The served version reaches `get_server_info` here; the recording server registers it with a placeholder, since
46
+ // its handler never runs there and the version is not part of the listed tool.
47
+ const registerTools = async (server: McpServer, identity: McpServerIdentity, version = ""): Promise<void> => {
48
+ const target = strictOptions === undefined ? server : withStrictToolInput(server, strictOptions);
49
+ await options.registerTools(target, identity);
50
+ if (options.serverInfoTool !== undefined) registerServerInfoTool(target, { ...options.serverInfoTool, server: { name: options.name, version } });
51
+ };
52
+ const measurements = new Map<boolean, Promise<ToolListMeasurement>>();
53
+
54
+ // The promise is cached, so parallel first requests measure once; a failed measurement is dropped so
55
+ // the next request tries again instead of failing for good.
56
+ const measure = (identity: McpServerIdentity): Promise<ToolListMeasurement> => {
57
+ const cached = measurements.get(identity.canWrite);
58
+ if (cached !== undefined) return cached;
59
+ const measurement = measureToolList((server) => registerTools(server, identity), {
60
+ ...(options.release === undefined ? {} : { release: options.release }),
61
+ name: options.name,
62
+ ...(options.serverOptions === undefined ? {} : { serverOptions: options.serverOptions }),
63
+ });
64
+ measurements.set(identity.canWrite, measurement);
65
+ void measurement.catch(() => measurements.delete(identity.canWrite));
66
+ return measurement;
67
+ };
68
+
69
+ return async (identity) => {
70
+ const version = options.version ?? (await measure(identity)).toolListVersion;
71
+ const server = new McpServer(
72
+ { name: options.name, version, ...(options.title === undefined ? {} : { title: options.title }) },
73
+ options.serverOptions,
74
+ );
75
+ await registerTools(server, identity, version);
76
+ return server;
77
+ };
78
+ }
@@ -0,0 +1,76 @@
1
+ // The `get_server_info` tool (#885): an assistant checks a "done" against production in one call. It answers the
2
+ // public facts of the ops build info route (release, commit, build and start times, the optional changelog) and the
3
+ // name and version the server answered at `initialize`, which follows the tool list. It reads no user data.
4
+ //
5
+ // The app passes the build info reader, so `/server` never loads `@softure-ai/ops` (an optional peer):
6
+ //
7
+ // import { readBuildInfo } from "@softure-ai/ops/server";
8
+ // createMcpServerFactory({ name: "acme", registerTools, serverInfoTool: { buildInfo: () => readBuildInfo() } });
9
+ import type { McpServer } from "@modelcontextprotocol/server";
10
+ import { errorLogLabel } from "@softure-ai/core";
11
+ import { z } from "zod";
12
+ import { en } from "../messages/en.js";
13
+ import { toolResult } from "./tools.js";
14
+
15
+ export const SERVER_INFO_TOOL_NAME = "get_server_info";
16
+
17
+ /** The build info of `@softure-ai/ops` (`readBuildInfo()`), typed by shape so mcp-access does not import ops. */
18
+ export interface McpBuildInfo {
19
+ readonly release: string | null;
20
+ readonly commit: string | null;
21
+ readonly builtAt: string | null;
22
+ readonly startedAt: string;
23
+ }
24
+
25
+ export interface ServerInfoToolOptions {
26
+ /** The build info, e.g. `() => readBuildInfo()` from `@softure-ai/ops/server` with the route's options. */
27
+ readonly buildInfo: () => McpBuildInfo | Promise<McpBuildInfo>;
28
+ /** The changelog the build info route answers, when the app passes one there. Only what is public. */
29
+ readonly changelog?: () => unknown;
30
+ /** Default: `serverInfoTool.description` of the module dictionary. */
31
+ readonly description?: string;
32
+ }
33
+
34
+ /** What `initialize` answers as `serverInfo`. */
35
+ export interface ServedServerInfo {
36
+ readonly name: string;
37
+ readonly version: string;
38
+ }
39
+
40
+ export interface RegisterServerInfoToolOptions extends ServerInfoToolOptions {
41
+ /** The name and version the server answers at `initialize`; `createMcpServerFactory` passes them in. */
42
+ readonly server: ServedServerInfo;
43
+ }
44
+
45
+ /** The tool's answer: the served name and version, the build info and, with a provider, the changelog. */
46
+ export type ServerInfoAnswer = McpBuildInfo & { readonly server: ServedServerInfo; readonly changelog?: unknown };
47
+
48
+ /** Registers `get_server_info`: no input fields, read only. A failing `buildInfo` reaches the SDK, which answers a tool error. */
49
+ export function registerServerInfoTool(server: McpServer, options: RegisterServerInfoToolOptions): void {
50
+ server.registerTool(
51
+ SERVER_INFO_TOOL_NAME,
52
+ {
53
+ description: options.description ?? en.serverInfoTool.description,
54
+ // An empty object rather than no schema, so strict input refuses a field sent to it.
55
+ inputSchema: z.object({}),
56
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
57
+ },
58
+ async () => toolResult(await readServerInfo(options)),
59
+ );
60
+ }
61
+
62
+ async function readServerInfo(options: RegisterServerInfoToolOptions): Promise<ServerInfoAnswer> {
63
+ const { release, commit, builtAt, startedAt } = await options.buildInfo();
64
+ const answer: ServerInfoAnswer = { server: { name: options.server.name, version: options.server.version }, release, commit, builtAt, startedAt };
65
+ return options.changelog === undefined ? answer : { ...answer, changelog: await readChangelog(options.changelog) };
66
+ }
67
+
68
+ /** As the build info route: a failing provider answers `null` and logs the error class, so the build info still answers. */
69
+ async function readChangelog(provider: () => unknown): Promise<unknown> {
70
+ try {
71
+ return (await provider()) ?? null;
72
+ } catch (error) {
73
+ console.error(`get_server_info: the changelog provider failed: ${errorLogLabel(error)}`);
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,191 @@
1
+ // Strict tool input. zod objects strip unknown keys, so a tool call with a misspelt field (`notes` for
2
+ // `note`) or a field of a newer tool list answers as a write that worked while the value was dropped.
3
+ // `withStrictToolInput` rewrites every input schema once, at registration: each object refuses a field
4
+ // it does not know and names the ones it accepts, a field sent under its old name is refused with the
5
+ // stable code `input.field_renamed`, and boolean and number fields also take their exact text (a client
6
+ // on an older tool list sends a field it does not know as text). The SDK validates before the handler
7
+ // runs, so a refused call saves nothing.
8
+ //
9
+ // const server = withStrictToolInput(new McpServer({ name: "acme", version }), { renamedFields: { notes: "note" } });
10
+ import type { McpServer } from "@modelcontextprotocol/server";
11
+ import { formatMessage } from "@softure-ai/core";
12
+ import { z } from "zod";
13
+ import type * as core from "zod/v4/core";
14
+ import { en } from "../messages/en.js";
15
+
16
+ /** The texts of a refused field: `toolInput` of the module dictionary (`getMcpAccessMessages(config).toolInput`). */
17
+ export type StrictToolInputMessages = typeof en.toolInput;
18
+
19
+ export interface StrictToolInputOptions {
20
+ /** Old field name → new field name, refused as `input.field_renamed` wherever an object accepts the new name. */
21
+ readonly renamedFields?: Readonly<Record<string, string>>;
22
+ /** Boolean fields also take `"true"`/`"false"` and number fields a number written as text. Default: `true`. */
23
+ readonly coerceTextScalars?: boolean;
24
+ /** Default: the module's English dictionary. */
25
+ readonly messages?: StrictToolInputMessages;
26
+ }
27
+
28
+ /** A JSON number literal: no spaces, no hex, no empty text. */
29
+ const NUMBER_TEXT = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
30
+
31
+ const strictServers = new WeakSet<McpServer>();
32
+
33
+ type ToolConfig = Readonly<Record<string, unknown>> & { readonly inputSchema?: unknown };
34
+ type RegisterTool = (name: string, config: ToolConfig, callback: unknown) => unknown;
35
+
36
+ /** Makes every tool registered on the server from now on strict; wrapping a server twice changes nothing. */
37
+ export function withStrictToolInput<Server extends McpServer>(server: Server, options: StrictToolInputOptions = {}): Server {
38
+ if (strictServers.has(server)) return server;
39
+ // The SDK's overloads only differ in how the schema is typed; at runtime it is one method.
40
+ const register = server.registerTool.bind(server) as unknown as RegisterTool;
41
+ const registerStrict: RegisterTool = (name, config, callback) =>
42
+ register(name, config.inputSchema === undefined ? config : { ...config, inputSchema: toStrictToolInput(config.inputSchema, options) }, callback);
43
+ Object.defineProperty(server, "registerTool", { value: registerStrict, configurable: true, writable: true });
44
+ strictServers.add(server);
45
+ return server;
46
+ }
47
+
48
+ /** The strict form of a tool's input schema: a zod 4 schema or raw shape is rewritten, anything else passes unchanged. */
49
+ export function toStrictToolInput(schema: unknown, options: StrictToolInputOptions = {}): unknown {
50
+ const zodSchema = isZodSchema(schema) ? schema : isRawShape(schema) ? z.object(schema) : null;
51
+ if (zodSchema === null) return schema;
52
+ const rewriter = new StrictRewriter({
53
+ renamedFields: options.renamedFields ?? {},
54
+ coerceTextScalars: options.coerceTextScalars ?? true,
55
+ messages: options.messages ?? en.toolInput,
56
+ });
57
+ return rewriter.rewrite(zodSchema);
58
+ }
59
+
60
+ function isZodSchema(value: unknown): value is core.$ZodType {
61
+ return typeof value === "object" && value !== null && "_zod" in value;
62
+ }
63
+
64
+ function isRawShape(value: unknown): value is Record<string, core.$ZodType> {
65
+ if (typeof value !== "object" || value === null || Object.getPrototypeOf(value) !== Object.prototype) return false;
66
+ return Object.values(value).every(isZodSchema);
67
+ }
68
+
69
+ type RewriteSettings = Required<StrictToolInputOptions>;
70
+
71
+ /** A schema node as the rewriter reads it: its kind and the parts it rewrites. */
72
+ type AnyDef = core.$ZodTypeDef & Record<string, unknown>;
73
+
74
+ class StrictRewriter {
75
+ // The same node (a shared field, a recursive `z.lazy`) is rewritten once.
76
+ private readonly rewritten = new Map<core.$ZodType, core.$ZodType>();
77
+
78
+ constructor(private readonly settings: RewriteSettings) {}
79
+
80
+ rewrite(schema: core.$ZodType): core.$ZodType {
81
+ const known = this.rewritten.get(schema);
82
+ if (known !== undefined) return known;
83
+ const result = this.rewriteNode(schema);
84
+ this.rewritten.set(schema, result);
85
+ return result;
86
+ }
87
+
88
+ private rewriteNode(schema: core.$ZodType): core.$ZodType {
89
+ const def = schema._zod.def as AnyDef;
90
+ switch (def.type) {
91
+ case "object":
92
+ return this.rewriteObject(schema as core.$ZodObject);
93
+ case "array":
94
+ return this.cloneWith(schema, { element: this.rewrite(def.element as core.$ZodType) });
95
+ case "optional":
96
+ case "nullable":
97
+ case "default":
98
+ case "prefault":
99
+ case "nonoptional":
100
+ case "readonly":
101
+ case "catch":
102
+ return this.cloneWith(schema, { innerType: this.rewrite(def.innerType as core.$ZodType) });
103
+ case "union":
104
+ return this.cloneWith(schema, { options: (def.options as core.$ZodType[]).map((option) => this.rewrite(option)) });
105
+ case "intersection":
106
+ return this.cloneWith(schema, { left: this.rewrite(def.left as core.$ZodType), right: this.rewrite(def.right as core.$ZodType) });
107
+ case "record":
108
+ return this.cloneWith(schema, { valueType: this.rewrite(def.valueType as core.$ZodType) });
109
+ case "tuple":
110
+ return this.cloneWith(schema, {
111
+ items: (def.items as core.$ZodType[]).map((item) => this.rewrite(item)),
112
+ rest: def.rest === null ? null : this.rewrite(def.rest as core.$ZodType),
113
+ });
114
+ case "pipe":
115
+ return this.rewritePipe(schema, def);
116
+ case "lazy": {
117
+ const getter = def.getter as () => core.$ZodType;
118
+ return this.cloneWith(schema, { getter: () => this.rewrite(getter()) });
119
+ }
120
+ case "boolean":
121
+ return this.settings.coerceTextScalars && def.coerce !== true ? z.preprocess(readBooleanText, schema) : schema;
122
+ case "number":
123
+ return this.settings.coerceTextScalars && def.coerce !== true ? z.preprocess(readNumberText, schema) : schema;
124
+ default:
125
+ return schema;
126
+ }
127
+ }
128
+
129
+ /** Only the input side: `in`, or `out` after a preprocess (whose `in` is the transform). */
130
+ private rewritePipe(schema: core.$ZodType, def: AnyDef): core.$ZodType {
131
+ const input = def.in as core.$ZodType;
132
+ if (input._zod.traits.has("$ZodTransform")) return this.cloneWith(schema, { out: this.rewrite(def.out as core.$ZodType) });
133
+ return this.cloneWith(schema, { in: this.rewrite(input) });
134
+ }
135
+
136
+ private rewriteObject(schema: core.$ZodObject): core.$ZodType {
137
+ const def = schema._zod.def;
138
+ const shape = Object.fromEntries(Object.entries(def.shape).map(([name, field]) => [name, this.rewrite(field)]));
139
+ // An object with its own catchall (`looseObject`, `.catchall(x)`) accepts extra fields on purpose.
140
+ if (def.catchall !== undefined && def.catchall._zod.def.type !== "never") return this.cloneWith(schema, { shape });
141
+ // The object's own map, read for any issue: its parameter type is narrowed to the issues an object raises.
142
+ const ownError = def.error as core.$ZodErrorMap | undefined;
143
+ const describe = (issue: core.$ZodRawIssue): string | undefined => describeUnknownFields(issue, Object.keys(shape), this.settings);
144
+ const error: core.$ZodErrorMap = (issue) => describe(issue) ?? ownError?.(issue);
145
+ return this.cloneWith(schema, { shape, catchall: z.never(), error });
146
+ }
147
+
148
+ /** A copy of the node with some parts replaced; its description and other metadata stay with it. */
149
+ private cloneWith(schema: core.$ZodType, parts: Record<string, unknown>): core.$ZodType {
150
+ const copy = (schema as unknown as z.ZodType).clone({ ...schema._zod.def, ...parts });
151
+ const meta = z.globalRegistry.get(schema);
152
+ if (meta !== undefined) {
153
+ // An id names one schema in the JSON Schema output; the copy replaces the original, so it may not carry it twice.
154
+ const rest = { ...meta };
155
+ delete rest.id;
156
+ z.globalRegistry.add(copy, rest);
157
+ }
158
+ return copy;
159
+ }
160
+ }
161
+
162
+ /** The text for an object's unknown fields: renamed ones first, then the rest with the fields it accepts. */
163
+ function describeUnknownFields(issue: core.$ZodRawIssue, accepted: readonly string[], settings: RewriteSettings): string | undefined {
164
+ if (issue.code !== "unrecognized_keys") return undefined;
165
+ const { messages, renamedFields } = settings;
166
+ const renamed: string[] = [];
167
+ const unknown: string[] = [];
168
+ for (const field of issue.keys) {
169
+ const newName = Object.hasOwn(renamedFields, field) ? renamedFields[field] : undefined;
170
+ if (newName !== undefined && accepted.includes(newName)) renamed.push(formatMessage(messages.fieldRenamed, { field, renamed: newName }));
171
+ else unknown.push(field);
172
+ }
173
+ if (unknown.length > 0) {
174
+ renamed.push(formatMessage(messages.unknownFields, { fields: quoteAll(unknown), accepted: accepted.length > 0 ? quoteAll(accepted) : messages.noFields }));
175
+ }
176
+ return renamed.join(" ");
177
+ }
178
+
179
+ function quoteAll(names: readonly string[]): string {
180
+ return names.map((name) => JSON.stringify(name)).join(", ");
181
+ }
182
+
183
+ function readBooleanText(value: unknown): unknown {
184
+ if (value === "true") return true;
185
+ if (value === "false") return false;
186
+ return value;
187
+ }
188
+
189
+ function readNumberText(value: unknown): unknown {
190
+ return typeof value === "string" && NUMBER_TEXT.test(value) ? Number(value) : value;
191
+ }
@@ -0,0 +1,105 @@
1
+ // The server version that follows the tool list. MCP clients keep a cached `tools/list` until the
2
+ // version they read at `initialize` changes, so a release that adds a tool or changes a schema under
3
+ // the same version leaves the assistant on the old schemas. `measureToolList` fingerprints the list
4
+ // as the client reads it, `<release>-tools-<12 hex>`: a semver prerelease, since comparisons ignore
5
+ // build metadata. The same registrations always give the same version.
6
+ import { createHash } from "node:crypto";
7
+ import { createMcpHandler, McpServer, METHOD_NOT_FOUND, type McpServerOptions, type Server } from "@modelcontextprotocol/server";
8
+
9
+ /** The release part when none is given. */
10
+ export const DEFAULT_TOOL_LIST_RELEASE = "1.0.0";
11
+
12
+ /** Hex digits of the fingerprint in the version. */
13
+ const FINGERPRINT_LENGTH = 12;
14
+ const RELEASE_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
15
+
16
+ /** What a client reads from `tools/list`: one entry per tool, as JSON. */
17
+ export type ServedTool = Readonly<Record<string, unknown>> & { readonly name: string };
18
+
19
+ export interface ToolListMeasurement {
20
+ /** `<release>-tools-<12 hex>`, for `serverInfo.version`. */
21
+ readonly toolListVersion: string;
22
+ readonly toolCount: number;
23
+ }
24
+
25
+ /** Registers the app's tools on the server it is given, e.g. `(server) => registerNoteTools(server, identity)`. */
26
+ export type RegisterTools = (server: McpServer) => unknown;
27
+
28
+ export interface MeasureToolListOptions {
29
+ /** The `MAJOR.MINOR.PATCH` before `-tools-`. Default: `1.0.0`. */
30
+ readonly release?: string;
31
+ /** The served server's name, so the recording server is shaped like it. Default: `tool-list`. */
32
+ readonly name?: string;
33
+ /** The served server's options (capabilities, instructions), for the same reason. */
34
+ readonly serverOptions?: McpServerOptions;
35
+ }
36
+
37
+ /**
38
+ * Registers the tools on a recording server shaped like the served one, lists them the way the endpoint
39
+ * serves them and returns the version with the tool count. Any change of a tool's name, title,
40
+ * description, annotations or input or output schema changes the version; the order of registration does not.
41
+ */
42
+ export async function measureToolList(registerTools: RegisterTools, options: MeasureToolListOptions = {}): Promise<ToolListMeasurement> {
43
+ const release = options.release ?? DEFAULT_TOOL_LIST_RELEASE;
44
+ assertRelease(release);
45
+ const server = new McpServer({ name: options.name ?? "tool-list", version: release }, options.serverOptions);
46
+ await registerTools(server);
47
+ return describeToolList(await listServedTools(() => server), release);
48
+ }
49
+
50
+ /** The version and count for a listed tool set. */
51
+ export function describeToolList(tools: readonly ServedTool[], release: string = DEFAULT_TOOL_LIST_RELEASE): ToolListMeasurement {
52
+ assertRelease(release);
53
+ return { toolListVersion: `${release}-tools-${fingerprintToolList(tools)}`, toolCount: tools.length };
54
+ }
55
+
56
+ /** The first 12 hex digits of a sha256 over the tools in name order, each as canonical JSON (keys sorted at every level). */
57
+ export function fingerprintToolList(tools: readonly ServedTool[]): string {
58
+ const ordered = [...tools].sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
59
+ return createHash("sha256").update(toCanonicalJson(ordered)).digest("hex").slice(0, FINGERPRINT_LENGTH);
60
+ }
61
+
62
+ /** Every tool a fresh server from the factory lists (none when it registered none), asked with one stateless `tools/list` as the endpoint serves it. */
63
+ export async function listServedTools(createServer: () => McpServer | Server | Promise<McpServer | Server>): Promise<ServedTool[]> {
64
+ const handler = createMcpHandler(createServer, { responseMode: "json" });
65
+ const response = await handler.fetch(
66
+ new Request("http://localhost/api/mcp", {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
69
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
70
+ }),
71
+ );
72
+ const answer = readAnswer(await response.text());
73
+ // A server that registered no tool declares no tools capability, so it does not know the method.
74
+ if (answer.error?.code === METHOD_NOT_FOUND) return [];
75
+ if (answer.result?.tools === undefined) {
76
+ throw new Error(`@softure-ai/mcp-access: listing the server's tools failed: ${typeof answer.error?.message === "string" ? answer.error.message : `HTTP ${String(response.status)}`}`);
77
+ }
78
+ return answer.result.tools.filter((tool): tool is ServedTool => typeof tool.name === "string" && tool.name !== "");
79
+ }
80
+
81
+ interface ToolsListAnswer {
82
+ readonly result?: { readonly tools?: readonly (Readonly<Record<string, unknown>> & { readonly name?: unknown })[] };
83
+ readonly error?: { readonly code?: unknown; readonly message?: unknown };
84
+ }
85
+
86
+ /** The JSON-RPC answer, sent as JSON or as one SSE `message` event. */
87
+ function readAnswer(body: string): ToolsListAnswer {
88
+ const data = body.startsWith("event:") ? (body.split("\n").find((line) => line.startsWith("data: "))?.slice("data: ".length) ?? "null") : body;
89
+ return (JSON.parse(data) ?? {}) as ToolsListAnswer;
90
+ }
91
+
92
+ function toCanonicalJson(value: unknown): string {
93
+ return JSON.stringify(value, (_key, entry: unknown) =>
94
+ entry !== null && typeof entry === "object" && !Array.isArray(entry)
95
+ ? Object.fromEntries(Object.entries(entry).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
96
+ : entry,
97
+ );
98
+ }
99
+
100
+ /** A release that is not `MAJOR.MINOR.PATCH` would make an invalid version: a setup bug, thrown. */
101
+ function assertRelease(release: string): void {
102
+ if (!RELEASE_PATTERN.test(release)) {
103
+ throw new Error(`@softure-ai/mcp-access: the tool list release must be MAJOR.MINOR.PATCH, got "${release}"`);
104
+ }
105
+ }
@@ -0,0 +1,24 @@
1
+ // The runtime kill switch for writes. `mcp-access.writes_enabled` is read through the switch-reader
2
+ // contract of `@softure-ai/core` on every request: when the app defines it in
3
+ // `@softure-ai/feature-switches`, turning it off takes writes away at once, without a deploy. An app that
4
+ // does not define it keeps `allowWrites` alone.
5
+ import { errorLogLabel, readSwitch } from "@softure-ai/core";
6
+ import { WRITES_ENABLED_SWITCH } from "../index.js";
7
+ import { getMcpAccessOptions } from "./options.js";
8
+ import type { McpAccessContext } from "./tokens.js";
9
+
10
+ /**
11
+ * Whether the app lets anyone write right now: `allowWrites` and the switch (on when the app does not
12
+ * define it). Without `allowWrites` the switch is not read. A read that throws counts as off (writes
13
+ * are the risky side) and is logged by the switch's name.
14
+ */
15
+ export async function isWritingEnabled(ctx: McpAccessContext): Promise<boolean> {
16
+ if (!getMcpAccessOptions(ctx.config).allowWrites) return false;
17
+ try {
18
+ const reading = await readSwitch(ctx, WRITES_ENABLED_SWITCH);
19
+ return reading.kind === "undeclared" || reading.isEnabled;
20
+ } catch (error) {
21
+ console.error(`@softure-ai/mcp-access: reading the ${WRITES_ENABLED_SWITCH} switch failed, writes are off: ${errorLogLabel(error)}`);
22
+ return false;
23
+ }
24
+ }
@@ -8,11 +8,11 @@
8
8
  import type { Transport } from "@modelcontextprotocol/server";
9
9
  import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
10
10
  import { resolveOnlyAccount } from "@softure-ai/auth/server";
11
- import { errorLogLabel, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
11
+ import { errorLogLabel, systemClock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
12
12
  import { openCommandDatabase, type CommandDatabase } from "@softure-ai/db";
13
13
  import type { McpServerIdentity } from "../contract.js";
14
14
  import type { McpServerFactory } from "../server/endpoint.js";
15
- import { getMcpAccessOptions } from "../server/options.js";
15
+ import { isWritingEnabled } from "../server/writes-switch.js";
16
16
 
17
17
  /** The variable naming the account to serve, unless the entry is told another one. */
18
18
  export const DEFAULT_MCP_USER_ID_ENV = "MCP_USER_ID";
@@ -72,11 +72,9 @@ export async function serveMcpStdio(options: ServeMcpStdioOptions): Promise<McpS
72
72
  return { ok: false, error: userId };
73
73
  }
74
74
 
75
- const identity: McpServerIdentity = {
76
- userId,
77
- canWrite: (options.allowWrites ?? false) && getMcpAccessOptions(config).allowWrites,
78
- tokenId: STDIO_TOKEN_ID,
79
- };
75
+ // The writes switch is read once, at start: a stdio server serves one local session.
76
+ const canWrite = (options.allowWrites ?? false) && (await isWritingEnabled({ db: database.handle.db, clock: systemClock, config }));
77
+ const identity: McpServerIdentity = { userId, canWrite, tokenId: STDIO_TOKEN_ID };
80
78
  let closed = false;
81
79
  const closeDatabase = async () => {
82
80
  if (closed) return;
@@ -1,11 +1,11 @@
1
1
  // Catalog parity: the tools listed in softure.config.ts (the token page shows them, agent docs read
2
2
  // them) against the tools the app's MCP server really registers, for a read-only and a write identity.
3
3
  // Framework-free: `expectToolCatalogMatchesServer` throws, so it works in any test runner.
4
- import { createMcpHandler } from "@modelcontextprotocol/server";
5
4
  import type { SoftureConfig } from "@softure-ai/core";
6
5
  import type { McpServerIdentity } from "../contract.js";
7
6
  import type { McpServerFactory } from "../server/endpoint.js";
8
7
  import { getMcpAccessOptions } from "../server/options.js";
8
+ import { describeToolList, listServedTools, type ToolListMeasurement } from "../server/tool-list.js";
9
9
 
10
10
  export interface ToolCatalogDifferences {
11
11
  /** Configured tools the server does not register for the identity that should get them. */
@@ -16,6 +16,12 @@ export interface ToolCatalogDifferences {
16
16
  readonly writeForReaders: readonly string[];
17
17
  }
18
18
 
19
+ /** What `measureServedToolLists` reports: a read-only and a write identity. */
20
+ export interface ServedToolLists {
21
+ readonly read: ToolListMeasurement;
22
+ readonly write: ToolListMeasurement;
23
+ }
24
+
19
25
  const READER: McpServerIdentity = { userId: "00000000-0000-4000-8000-000000000001", canWrite: false, tokenId: "catalog-check" };
20
26
  const WRITER: McpServerIdentity = { ...READER, canWrite: true };
21
27
 
@@ -47,28 +53,13 @@ export async function expectToolCatalogMatchesServer(config: SoftureConfig, crea
47
53
 
48
54
  /** The tool names a fresh server lists for the identity, asked the way the endpoint serves it. */
49
55
  async function listServerTools(createServer: McpServerFactory, identity: McpServerIdentity): Promise<string[]> {
50
- const handler = createMcpHandler(() => createServer(identity), { responseMode: "json" });
51
- const response = await handler.fetch(
52
- new Request("http://localhost/api/mcp", {
53
- method: "POST",
54
- headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
55
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
56
- }),
57
- );
58
- const answer = readAnswer(await response.text());
59
- if (answer.result?.tools === undefined) {
60
- throw new Error(`@softure-ai/mcp-access: listing the server's tools failed: ${typeof answer.error?.message === "string" ? answer.error.message : `HTTP ${String(response.status)}`}`);
61
- }
62
- return answer.result.tools.map((tool) => (typeof tool.name === "string" ? tool.name : "")).filter((name) => name !== "");
56
+ return (await listServedTools(() => createServer(identity))).map((tool) => tool.name);
63
57
  }
64
58
 
65
- interface ToolsListAnswer {
66
- readonly result?: { readonly tools?: readonly { readonly name?: unknown }[] };
67
- readonly error?: { readonly message?: unknown };
68
- }
69
-
70
- /** The JSON-RPC answer, sent as JSON or as one SSE `message` event. */
71
- function readAnswer(body: string): ToolsListAnswer {
72
- const data = body.startsWith("event:") ? (body.split("\n").find((line) => line.startsWith("data: "))?.slice("data: ".length) ?? "null") : body;
73
- return (JSON.parse(data) ?? {}) as ToolsListAnswer;
59
+ /** The tool list's version and count for each access level, for a snapshot that changes whenever a tool's schema does. */
60
+ export async function measureServedToolLists(createServer: McpServerFactory, release?: string): Promise<ServedToolLists> {
61
+ return {
62
+ read: describeToolList(await listServedTools(() => createServer(READER)), release),
63
+ write: describeToolList(await listServedTools(() => createServer(WRITER)), release),
64
+ };
74
65
  }
@@ -1,2 +1,8 @@
1
1
  // `@softure-ai/mcp-access/testing`: checks for an app's own tests.
2
- export { expectToolCatalogMatchesServer, getToolCatalogDifferences, type ToolCatalogDifferences } from "./catalog.js";
2
+ export {
3
+ expectToolCatalogMatchesServer,
4
+ getToolCatalogDifferences,
5
+ measureServedToolLists,
6
+ type ServedToolLists,
7
+ type ToolCatalogDifferences,
8
+ } from "./catalog.js";