@softure-ai/mcp-access 0.5.0 → 0.7.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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +78 -0
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/messages/en.d.ts +4 -0
  7. package/dist/messages/en.d.ts.map +1 -1
  8. package/dist/messages/en.js +4 -0
  9. package/dist/messages/en.js.map +1 -1
  10. package/dist/messages/index.d.ts +4 -0
  11. package/dist/messages/index.d.ts.map +1 -1
  12. package/dist/server/endpoint.d.ts +8 -1
  13. package/dist/server/endpoint.d.ts.map +1 -1
  14. package/dist/server/endpoint.js +54 -7
  15. package/dist/server/endpoint.js.map +1 -1
  16. package/dist/server/index.d.ts +2 -1
  17. package/dist/server/index.d.ts.map +1 -1
  18. package/dist/server/index.js +1 -0
  19. package/dist/server/index.js.map +1 -1
  20. package/dist/server/request-diagnostics.d.ts +59 -0
  21. package/dist/server/request-diagnostics.d.ts.map +1 -0
  22. package/dist/server/request-diagnostics.js +84 -0
  23. package/dist/server/request-diagnostics.js.map +1 -0
  24. package/dist/server/server-factory.d.ts +2 -1
  25. package/dist/server/server-factory.d.ts.map +1 -1
  26. package/dist/server/server-factory.js +16 -10
  27. package/dist/server/server-factory.js.map +1 -1
  28. package/dist/server/server-info.d.ts +30 -2
  29. package/dist/server/server-info.d.ts.map +1 -1
  30. package/dist/server/server-info.js +28 -6
  31. package/dist/server/server-info.js.map +1 -1
  32. package/dist/server/tool-list.d.ts +2 -0
  33. package/dist/server/tool-list.d.ts.map +1 -1
  34. package/dist/server/tool-list.js +6 -2
  35. package/dist/server/tool-list.js.map +1 -1
  36. package/module.json +1 -1
  37. package/package.json +1 -1
  38. package/src/index.ts +1 -1
  39. package/src/messages/en.ts +6 -0
  40. package/src/server/endpoint.ts +73 -6
  41. package/src/server/index.ts +3 -0
  42. package/src/server/request-diagnostics.ts +130 -0
  43. package/src/server/server-factory.ts +32 -12
  44. package/src/server/server-info.ts +60 -8
  45. package/src/server/tool-list.ts +7 -2
@@ -13,7 +13,7 @@ import type { McpServerIdentity } from "../contract.js";
13
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
- import { measureToolList, type ToolListMeasurement } from "./tool-list.js";
16
+ import { describeToolList, listRecordedTools, type ServedTool, type ToolListMeasurement } from "./tool-list.js";
17
17
 
18
18
  export interface McpServerFactoryOptions {
19
19
  /** `serverInfo.name`. */
@@ -34,7 +34,8 @@ export interface McpServerFactoryOptions {
34
34
  readonly strictToolInput?: boolean | StrictToolInputOptions;
35
35
  /**
36
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.
37
+ * `@softure-ai/ops/server`) with the served name and version. Off by default. With `tools: true` it also answers the
38
+ * served tool contract of the caller's access level, taken from the measured list (#1078).
38
39
  */
39
40
  readonly serverInfoTool?: ServerInfoToolOptions;
40
41
  }
@@ -46,25 +47,32 @@ export interface McpServerFactoryOptions {
46
47
  */
47
48
  export function createMcpServerFactory(options: McpServerFactoryOptions): VersionedMcpServerFactory {
48
49
  const strictOptions = options.strictToolInput === true ? {} : options.strictToolInput === false ? undefined : options.strictToolInput;
49
- // The served version reaches `get_server_info` here; the recording server registers it with a placeholder, since
50
- // its handler never runs there and the version is not part of the listed tool.
51
- const registerTools = async (server: McpServer, identity: McpServerIdentity, version = ""): Promise<void> => {
50
+ // The served version and the measured tools reach `get_server_info` here; the recording server registers it with a
51
+ // placeholder version and no tools, since its handler never runs there and neither is part of the listed tool.
52
+ const registerTools = async (server: McpServer, identity: McpServerIdentity, served?: ServedToolList): Promise<void> => {
52
53
  const target = strictOptions === undefined ? server : withStrictToolInput(server, strictOptions);
53
54
  await options.registerTools(target, identity);
54
- if (options.serverInfoTool !== undefined) registerServerInfoTool(target, { ...options.serverInfoTool, server: { name: options.name, version } });
55
+ if (options.serverInfoTool !== undefined) {
56
+ registerServerInfoTool(target, {
57
+ ...options.serverInfoTool,
58
+ server: { name: options.name, version: served?.version ?? "" },
59
+ servedTools: async () => (served === undefined ? [] : (await served.measure()).tools),
60
+ });
61
+ }
55
62
  };
56
- const measurements = new Map<boolean, Promise<ToolListMeasurement>>();
63
+ const measurements = new Map<boolean, Promise<MeasuredToolList>>();
57
64
 
58
65
  // The promise is cached, so parallel first requests measure once; a failed measurement is dropped so
59
66
  // the next request tries again instead of failing for good.
60
- const measure = (identity: McpServerIdentity): Promise<ToolListMeasurement> => {
67
+ const measure = (identity: McpServerIdentity): Promise<MeasuredToolList> => {
61
68
  const cached = measurements.get(identity.canWrite);
62
69
  if (cached !== undefined) return cached;
63
- const measurement = measureToolList((server) => registerTools(server, identity), {
64
- ...(options.release === undefined ? {} : { release: options.release }),
70
+ const release = options.release;
71
+ const measurement = listRecordedTools((server) => registerTools(server, identity), {
72
+ ...(release === undefined ? {} : { release }),
65
73
  name: options.name,
66
74
  ...(options.serverOptions === undefined ? {} : { serverOptions: options.serverOptions }),
67
- });
75
+ }).then((tools) => ({ ...describeToolList(tools, release), tools }));
68
76
  measurements.set(identity.canWrite, measurement);
69
77
  void measurement.catch(() => measurements.delete(identity.canWrite));
70
78
  return measurement;
@@ -77,8 +85,20 @@ export function createMcpServerFactory(options: McpServerFactoryOptions): Versio
77
85
  { name: options.name, version, ...(options.title === undefined ? {} : { title: options.title }) },
78
86
  options.serverOptions,
79
87
  );
80
- await registerTools(server, identity, version);
88
+ await registerTools(server, identity, { version, measure: () => measure(identity) });
81
89
  return server;
82
90
  };
83
91
  return Object.assign(factory, { toolListVersion });
84
92
  }
93
+
94
+ /** The measured version with the tools it was computed from, as `tools/list` answers them. */
95
+ interface MeasuredToolList extends ToolListMeasurement {
96
+ readonly tools: readonly ServedTool[];
97
+ }
98
+
99
+ /** What a served server's `get_server_info` reads: its version and its access level's measurement. */
100
+ interface ServedToolList {
101
+ readonly version: string;
102
+ /** Read only when the tool answers the contract, so a fixed version still measures nothing otherwise. */
103
+ readonly measure: () => Promise<MeasuredToolList>;
104
+ }
@@ -2,6 +2,10 @@
2
2
  // public facts of the ops build info route (release, commit, build and start times, the optional changelog) and the
3
3
  // name and version the server answered at `initialize`, which follows the tool list. It reads no user data.
4
4
  //
5
+ // With `tools: true` (#1078) it also answers the served tool contract: each tool's name, description and input schema
6
+ // as `tools/list` answers them for the caller's access level. A hosted client may keep a stale tool list for good, but
7
+ // it never caches a tool result, so the model can always read the current contract in-band.
8
+ //
5
9
  // The app passes the build info reader, so `/server` never loads `@softure-ai/ops` (an optional peer):
6
10
  //
7
11
  // import { readBuildInfo } from "@softure-ai/ops/server";
@@ -10,6 +14,7 @@ import type { McpServer } from "@modelcontextprotocol/server";
10
14
  import { errorLogLabel } from "@softure-ai/core";
11
15
  import { z } from "zod";
12
16
  import { en } from "../messages/en.js";
17
+ import type { ServedTool } from "./tool-list.js";
13
18
  import { toolResult } from "./tools.js";
14
19
 
15
20
  export const SERVER_INFO_TOOL_NAME = "get_server_info";
@@ -27,10 +32,22 @@ export interface ServerInfoToolOptions {
27
32
  readonly buildInfo: () => McpBuildInfo | Promise<McpBuildInfo>;
28
33
  /** The changelog the build info route answers, when the app passes one there. Only what is public. */
29
34
  readonly changelog?: () => unknown;
35
+ /**
36
+ * Also answers the served tool contract (`tools`), with an optional `name` input that narrows it to one tool. Turning
37
+ * it on changes the tool's input schema, so the version changes once. Default: off.
38
+ */
39
+ readonly tools?: boolean;
30
40
  /** Default: `serverInfoTool.description` of the module dictionary. */
31
41
  readonly description?: string;
32
42
  }
33
43
 
44
+ /** One served tool's contract, exactly as `tools/list` answers it. */
45
+ export interface ServedToolContract {
46
+ readonly name: string;
47
+ readonly description?: unknown;
48
+ readonly inputSchema: unknown;
49
+ }
50
+
34
51
  /** What `initialize` answers as `serverInfo`. */
35
52
  export interface ServedServerInfo {
36
53
  readonly name: string;
@@ -40,22 +57,48 @@ export interface ServedServerInfo {
40
57
  export interface RegisterServerInfoToolOptions extends ServerInfoToolOptions {
41
58
  /** The name and version the server answers at `initialize`; `createMcpServerFactory` passes them in. */
42
59
  readonly server: ServedServerInfo;
60
+ /**
61
+ * The tools the caller's `tools/list` answers, read when `tools` is on. `createMcpServerFactory` passes its measured
62
+ * list; a hand-built server passes `() => listServedTools(createServer)` for the caller's access level.
63
+ */
64
+ readonly servedTools?: () => readonly ServedTool[] | Promise<readonly ServedTool[]>;
65
+ }
66
+
67
+ /** The contract part of the answer, with `tools` on. */
68
+ export interface ServerInfoToolsAnswer {
69
+ /** Tells the model that the server's contract wins over its own copy. */
70
+ readonly toolsHint: string;
71
+ readonly tools: readonly ServedToolContract[];
72
+ /** Every served tool's name, when `name` matched none. */
73
+ readonly toolNames?: readonly string[];
43
74
  }
44
75
 
45
76
  /** 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 };
77
+ export type ServerInfoAnswer = McpBuildInfo & { readonly server: ServedServerInfo; readonly changelog?: unknown } & Partial<ServerInfoToolsAnswer>;
47
78
 
48
- /** Registers `get_server_info`: no input fields, read only. A failing `buildInfo` reaches the SDK, which answers a tool error. */
79
+ /**
80
+ * Registers `get_server_info`, read only: no input fields, or an optional `name` with `tools` on. A failing `buildInfo`
81
+ * reaches the SDK, which answers a tool error.
82
+ */
49
83
  export function registerServerInfoTool(server: McpServer, options: RegisterServerInfoToolOptions): void {
84
+ const annotations = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
85
+ const description = options.description ?? (options.tools === true ? en.serverInfoTool.descriptionWithTools : en.serverInfoTool.description);
86
+ if (options.tools !== true) {
87
+ // An empty object rather than no schema, so strict input refuses a field sent to it.
88
+ server.registerTool(SERVER_INFO_TOOL_NAME, { description, inputSchema: z.object({}), annotations }, async () => toolResult(await readServerInfo(options)));
89
+ return;
90
+ }
91
+ const readServedTools = options.servedTools;
92
+ if (readServedTools === undefined) {
93
+ throw new Error("@softure-ai/mcp-access: get_server_info with tools: true needs servedTools, e.g. () => listServedTools(createServer)");
94
+ }
50
95
  server.registerTool(
51
96
  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 },
97
+ { description, inputSchema: z.object({ name: z.string().optional().describe(en.serverInfoTool.nameInputDescription) }), annotations },
98
+ async ({ name }) => {
99
+ const answer = await readServerInfo(options);
100
+ return toolResult({ ...answer, ...describeToolContract(await readServedTools(), name) });
57
101
  },
58
- async () => toolResult(await readServerInfo(options)),
59
102
  );
60
103
  }
61
104
 
@@ -65,6 +108,15 @@ async function readServerInfo(options: RegisterServerInfoToolOptions): Promise<S
65
108
  return options.changelog === undefined ? answer : { ...answer, changelog: await readChangelog(options.changelog) };
66
109
  }
67
110
 
111
+ /** The contract of every served tool, or of the one named; an unknown name answers no tool and every name. */
112
+ function describeToolContract(served: readonly ServedTool[], name: string | undefined): ServerInfoToolsAnswer {
113
+ const tools = served.map((tool): ServedToolContract => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }));
114
+ if (name === undefined) return { toolsHint: en.serverInfoTool.toolsHint, tools };
115
+ const named = tools.filter((tool) => tool.name === name);
116
+ if (named.length > 0) return { toolsHint: en.serverInfoTool.toolsHint, tools: named };
117
+ return { toolsHint: en.serverInfoTool.unknownToolHint, tools: [], toolNames: tools.map((tool) => tool.name).sort() };
118
+ }
119
+
68
120
  /** As the build info route: a failing provider answers `null` and logs the error class, so the build info still answers. */
69
121
  async function readChangelog(provider: () => unknown): Promise<unknown> {
70
122
  try {
@@ -42,9 +42,14 @@ export interface MeasureToolListOptions {
42
42
  export async function measureToolList(registerTools: RegisterTools, options: MeasureToolListOptions = {}): Promise<ToolListMeasurement> {
43
43
  const release = options.release ?? DEFAULT_TOOL_LIST_RELEASE;
44
44
  assertRelease(release);
45
- const server = new McpServer({ name: options.name ?? "tool-list", version: release }, options.serverOptions);
45
+ return describeToolList(await listRecordedTools(registerTools, options), release);
46
+ }
47
+
48
+ /** The tools as `tools/list` answers them, registered on a recording server shaped like the served one. */
49
+ export async function listRecordedTools(registerTools: RegisterTools, options: MeasureToolListOptions = {}): Promise<ServedTool[]> {
50
+ const server = new McpServer({ name: options.name ?? "tool-list", version: options.release ?? DEFAULT_TOOL_LIST_RELEASE }, options.serverOptions);
46
51
  await registerTools(server);
47
- return describeToolList(await listServedTools(() => server), release);
52
+ return listServedTools(() => server);
48
53
  }
49
54
 
50
55
  /** The version and count for a listed tool set. */