@softure-ai/mcp-access 0.2.5 → 0.2.8

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 (65) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +121 -2
  3. package/dist/index.d.ts +8 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/messages/en.d.ts +8 -0
  7. package/dist/messages/en.d.ts.map +1 -1
  8. package/dist/messages/en.js +8 -0
  9. package/dist/messages/en.js.map +1 -1
  10. package/dist/messages/index.d.ts +8 -0
  11. package/dist/messages/index.d.ts.map +1 -1
  12. package/dist/server/action-tool.d.ts +6 -2
  13. package/dist/server/action-tool.d.ts.map +1 -1
  14. package/dist/server/action-tool.js +10 -3
  15. package/dist/server/action-tool.js.map +1 -1
  16. package/dist/server/index.d.ts +6 -2
  17. package/dist/server/index.d.ts.map +1 -1
  18. package/dist/server/index.js +6 -2
  19. package/dist/server/index.js.map +1 -1
  20. package/dist/server/server-factory.d.ts +31 -0
  21. package/dist/server/server-factory.d.ts.map +1 -0
  22. package/dist/server/server-factory.js +49 -0
  23. package/dist/server/server-factory.js.map +1 -0
  24. package/dist/server/server-info.d.ts +34 -0
  25. package/dist/server/server-info.d.ts.map +1 -0
  26. package/dist/server/server-info.js +30 -0
  27. package/dist/server/server-info.js.map +1 -0
  28. package/dist/server/strict-input.d.ts +17 -0
  29. package/dist/server/strict-input.d.ts.map +1 -0
  30. package/dist/server/strict-input.js +157 -0
  31. package/dist/server/strict-input.js.map +1 -0
  32. package/dist/server/tokens.d.ts +5 -0
  33. package/dist/server/tokens.d.ts.map +1 -1
  34. package/dist/server/tokens.js +10 -0
  35. package/dist/server/tokens.js.map +1 -1
  36. package/dist/server/tool-list.d.ts +35 -0
  37. package/dist/server/tool-list.d.ts.map +1 -0
  38. package/dist/server/tool-list.js +68 -0
  39. package/dist/server/tool-list.js.map +1 -0
  40. package/dist/server/tools.d.ts +40 -1
  41. package/dist/server/tools.d.ts.map +1 -1
  42. package/dist/server/tools.js +41 -3
  43. package/dist/server/tools.js.map +1 -1
  44. package/dist/testing/catalog.d.ts +8 -0
  45. package/dist/testing/catalog.d.ts.map +1 -1
  46. package/dist/testing/catalog.js +8 -19
  47. package/dist/testing/catalog.js.map +1 -1
  48. package/dist/testing/index.d.ts +1 -1
  49. package/dist/testing/index.d.ts.map +1 -1
  50. package/dist/testing/index.js +1 -1
  51. package/dist/testing/index.js.map +1 -1
  52. package/module.json +1 -1
  53. package/package.json +1 -1
  54. package/src/index.ts +1 -1
  55. package/src/messages/en.ts +9 -0
  56. package/src/server/action-tool.ts +15 -4
  57. package/src/server/index.ts +34 -1
  58. package/src/server/server-factory.ts +78 -0
  59. package/src/server/server-info.ts +76 -0
  60. package/src/server/strict-input.ts +191 -0
  61. package/src/server/tokens.ts +10 -0
  62. package/src/server/tool-list.ts +105 -0
  63. package/src/server/tools.ts +78 -5
  64. package/src/testing/catalog.ts +14 -23
  65. package/src/testing/index.ts +7 -1
@@ -1,8 +1,9 @@
1
1
  // Tool results for the app's MCP server and the boundary that keeps failures safe: the assistant gets
2
2
  // a value, a `PublicError`'s message or a hint, never a thrown error's text (a failed query carries
3
- // its SQL and parameters). The log gets the error's kind only (`errorLogLabel`).
3
+ // its SQL and parameters). The log gets the error's kind only (`errorLogLabel`). An optional hook sees
4
+ // each call as a whole: which tool, which client, the outcome with its code, and how long it took.
4
5
  import { isCallToolResult, type CallToolResult } from "@modelcontextprotocol/server";
5
- import { errorLogLabel, getPublicMessage, safeError } from "@softure-ai/core";
6
+ import { errorLogLabel, getPublicMessage, safeError, type CoreErrorCode } from "@softure-ai/core";
6
7
 
7
8
  /** What the assistant reads for an error code: the app's own text per code, e.g. `{ "orders.not_found": "No order with that number." }`. */
8
9
  export type ToolErrorHints = Readonly<Record<string, string>>;
@@ -10,10 +11,46 @@ export type ToolErrorHints = Readonly<Record<string, string>>;
10
11
  export interface ToolErrorOptions {
11
12
  /** Texts per error code; a code without one is answered as the code itself. */
12
13
  readonly hints?: ToolErrorHints;
13
- /** The tool's name in the log line. */
14
+ /** The tool's name in the log line and in the `onToolCall` record. */
14
15
  readonly label?: string;
16
+ /** Called once after each call with its record. Its failure is logged by kind and never changes the answer. */
17
+ readonly onToolCall?: ToolCallHook;
18
+ /** Who called, for the record: built once per request in the app's server factory. */
19
+ readonly caller?: ToolCaller;
20
+ /** The tool's arguments, for the record: only their names are read, never their values. */
21
+ readonly args?: Readonly<Record<string, unknown>>;
15
22
  }
16
23
 
24
+ /** The client behind a call: the token's id (`McpServerIdentity.tokenId`) and its name (`findTokenClientName`). */
25
+ export interface ToolCaller {
26
+ readonly tokenId: string;
27
+ readonly clientName: string | null;
28
+ }
29
+
30
+ /**
31
+ * How a call ended. `succeeded`: `itemCount` is the length of an array value, else `null`. `failed`: the
32
+ * result's error code, `null` for a `CallToolResult` the tool marked `isError` itself. `thrown`: the
33
+ * `safeError` code, `null` for a `PublicError` (a deliberate message, no code).
34
+ */
35
+ export type ToolCallOutcome =
36
+ | { readonly kind: "succeeded"; readonly itemCount: number | null }
37
+ | { readonly kind: "failed"; readonly code: string | null }
38
+ | { readonly kind: "thrown"; readonly code: CoreErrorCode | null };
39
+
40
+ /** One tool call as the `onToolCall` hook sees it. */
41
+ export interface ToolCallRecord {
42
+ /** The tool's `label`, else `"call"`. */
43
+ readonly tool: string;
44
+ readonly tokenId: string | null;
45
+ readonly clientName: string | null;
46
+ readonly argumentNames: readonly string[];
47
+ readonly outcome: ToolCallOutcome;
48
+ readonly startedAt: Date;
49
+ readonly durationMs: number;
50
+ }
51
+
52
+ export type ToolCallHook = (record: ToolCallRecord) => unknown;
53
+
17
54
  /** For failures the tool did not expect: what `safeError` makes of them, unless a hint replaces it. */
18
55
  const DEFAULT_FAILURE_TEXT: ToolErrorHints = {
19
56
  "core.database_failed": "The database could not answer; try again later.",
@@ -37,11 +74,47 @@ export function toolError(message: string): CallToolResult {
37
74
  * anything else is logged by kind and answered with the hint for its `safeError` code, else a fixed text.
38
75
  */
39
76
  export async function withToolErrors(work: () => unknown, options: ToolErrorOptions = {}): Promise<CallToolResult> {
77
+ const startedAt = Date.now();
78
+ let answer: CallToolResult;
79
+ let outcome: ToolCallOutcome;
40
80
  try {
41
- return answerValue(await work(), options.hints);
81
+ const value = await work();
82
+ answer = answerValue(value, options.hints);
83
+ outcome = describeValueOutcome(value);
42
84
  } catch (error) {
43
- return answerFailure(error, options);
85
+ answer = answerFailure(error, options);
86
+ outcome = { kind: "thrown", code: getPublicMessage(error) === null ? safeError(error).error : null };
44
87
  }
88
+ if (options.onToolCall !== undefined) await reportToolCall(options, outcome, startedAt);
89
+ return answer;
90
+ }
91
+
92
+ async function reportToolCall(options: ToolErrorOptions, outcome: ToolCallOutcome, startedAt: number): Promise<void> {
93
+ const tool = options.label ?? "call";
94
+ const record: ToolCallRecord = {
95
+ tool,
96
+ tokenId: options.caller?.tokenId ?? null,
97
+ clientName: options.caller?.clientName ?? null,
98
+ argumentNames: Object.keys(options.args ?? {}),
99
+ outcome,
100
+ startedAt: new Date(startedAt),
101
+ durationMs: Date.now() - startedAt,
102
+ };
103
+ try {
104
+ await options.onToolCall?.(record);
105
+ } catch (error) {
106
+ console.error(`@softure-ai/mcp-access: the onToolCall hook failed for tool ${tool}: ${errorLogLabel(error)}`);
107
+ }
108
+ }
109
+
110
+ function describeValueOutcome(value: unknown): ToolCallOutcome {
111
+ if (isCallToolResult(value)) return value.isError === true ? { kind: "failed", code: null } : { kind: "succeeded", itemCount: null };
112
+ if (isResult(value)) return value.ok ? countItems(value.value) : { kind: "failed", code: value.error };
113
+ return countItems(value);
114
+ }
115
+
116
+ function countItems(value: unknown): ToolCallOutcome {
117
+ return { kind: "succeeded", itemCount: Array.isArray(value) ? value.length : null };
45
118
  }
46
119
 
47
120
  /** The answer for a value a tool or an action returned. */
@@ -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";