@ultimat3/mcp 22.9.1 → 22.11.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.
package/src/server.ts CHANGED
@@ -3,13 +3,14 @@
3
3
  // and an already-resolved caller, and returns a response or `null` for a notification.
4
4
  // Both transports (http, stdio) and every test drive this one function.
5
5
 
6
- import { formatIssues } from '@ultimat3/schema';
7
- import { auditResourceRead, auditToolCall, outcomeForCode, outcomeForResult } from './audit';
6
+ import type { ErrorAudience } from '@ultimat3/core';
7
+ import { fixFor } from '@ultimat3/core';
8
+ import { auditResourceRead, auditToolCall, outcomeForResult } from './audit';
8
9
  import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
9
- import { asFrameworkError, renderFrameworkError } from './framework-error';
10
+ import { asFrameworkError } from './framework-error';
10
11
  import { metaCall } from './meta-call';
11
12
  import { META_UNKNOWN_FIX } from './meta-errors';
12
- import type { McpResourceGroups, McpSurfaceOption } from './meta-surface';
13
+ import type { McpResourceGroups, McpSurfaceOption, MetaResource } from './meta-surface';
13
14
  import { MANAGE_RESOURCE, META_TOOL_ENTRIES, META_TOOL_NAMES, MetaSurface } from './meta-surface';
14
15
  import { promptListEntry, promptsGet } from './prompts-get';
15
16
  import type {
@@ -23,6 +24,9 @@ import type {
23
24
  import { ToolRegistry } from './registry';
24
25
  import type { McpPrompt, McpResource } from './resources';
25
26
  import { ResourceRegistry } from './resources';
27
+ import type { McpInstructions, McpServerVoice } from './server-voice';
28
+ import { instructionsFor, invalidArgsResult } from './server-voice';
29
+ import { admitted, thrownResponse } from './tool-thrown';
26
30
  import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
27
31
  import {
28
32
  defaultServerInfo,
@@ -64,6 +68,20 @@ export interface CreateMcpServerInput {
64
68
  readonly surface?: McpSurfaceOption | undefined;
65
69
  /** The meta catalog's resources. Required with a surface that can be `'meta'`. */
66
70
  readonly groups?: McpResourceGroups | undefined;
71
+ /**
72
+ * `initialize`'s `instructions`: how to use this server, for a client that injects it into the
73
+ * model's context (Claude Code does; many clients do not — never load-bear on it). A function
74
+ * answers per caller population — a staff surface and a customer one read different advice —
75
+ * and a function that throws or answers anything but a non-empty string sends none.
76
+ */
77
+ readonly instructions?: McpInstructions | undefined;
78
+ /**
79
+ * Whose `fix:` line a refusal carries. `'developer'` (the default, and the dev server's): the
80
+ * fix the author wrote — `x policy explain …`, a declaration to edit. `'caller'`: the error's
81
+ * `callerFix` where it declares one — what a REMOTE agent can do about it. `defineAppMcp`
82
+ * serves `'caller'`.
83
+ */
84
+ readonly errorAudience?: ErrorAudience | undefined;
67
85
  }
68
86
 
69
87
  /** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
@@ -92,6 +110,7 @@ export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
92
110
  input.prompts ?? [],
93
111
  input.serverInfo ?? defaultServerInfo(),
94
112
  meta,
113
+ { instructions: input.instructions, errorAudience: input.errorAudience ?? 'developer' },
95
114
  );
96
115
  }
97
116
 
@@ -102,6 +121,7 @@ export class McpServer {
102
121
  private readonly serverInfo: ServerInfo;
103
122
  /** `undefined` for a flat-only server — every existing app, byte for byte. */
104
123
  private readonly meta: MetaSurface | undefined;
124
+ private readonly voice: McpServerVoice;
105
125
 
106
126
  constructor(
107
127
  tools: ToolRegistry,
@@ -109,12 +129,14 @@ export class McpServer {
109
129
  prompts: readonly McpPrompt[],
110
130
  serverInfo: ServerInfo,
111
131
  meta?: MetaSurface,
132
+ voice: McpServerVoice = { errorAudience: 'developer' },
112
133
  ) {
113
134
  this.tools = tools;
114
135
  this.resources = resources;
115
136
  this.prompts = prompts;
116
137
  this.serverInfo = serverInfo;
117
138
  this.meta = meta;
139
+ this.voice = voice;
118
140
  }
119
141
 
120
142
  async handle(
@@ -151,7 +173,8 @@ export class McpServer {
151
173
  const id = body.id ?? null;
152
174
 
153
175
  switch (body.method) {
154
- case 'initialize':
176
+ case 'initialize': {
177
+ const instructions = instructionsFor(this.voice.instructions, caller);
155
178
  return resultResponse(id, {
156
179
  protocolVersion: MCP_PROTOCOL_VERSION,
157
180
  capabilities: {
@@ -160,7 +183,9 @@ export class McpServer {
160
183
  prompts: { listChanged: false },
161
184
  },
162
185
  serverInfo: this.serverInfo,
186
+ ...(instructions === undefined ? {} : { instructions }),
163
187
  });
188
+ }
164
189
  case 'tools/list':
165
190
  return resultResponse(id, { tools: this.list(caller) });
166
191
  case 'tools/call':
@@ -194,6 +219,14 @@ export class McpServer {
194
219
  );
195
220
  }
196
221
 
222
+ /**
223
+ * The meta catalog THIS caller would read from `list_resources`, as data — `undefined` for a caller
224
+ * served the flat surface. For a test or a tool asserting on the catalog: the wire form is text.
225
+ */
226
+ catalog(caller: McpCaller): readonly MetaResource[] | undefined {
227
+ return this.metaFor(caller)?.listResources(caller);
228
+ }
229
+
197
230
  /** The meta surface when THIS caller is served it, else `undefined`. */
198
231
  private metaFor(caller: McpCaller): MetaSurface | undefined {
199
232
  const meta = this.meta;
@@ -272,6 +305,7 @@ export class McpServer {
272
305
  resolved: ToolResolution,
273
306
  caller: McpCaller,
274
307
  ): Promise<JsonRpcResponse> {
308
+ const audience = this.voice.errorAudience;
275
309
  // Three outcomes, deliberately different — and every one of them audited, including the
276
310
  // one that tells the caller nothing. See `audit.ts`.
277
311
  switch (resolved.kind) {
@@ -301,16 +335,20 @@ export class McpServer {
301
335
  return errorResponse(id, INVALID_REQUEST, `missing scope: ${resolved.scope}`, {
302
336
  code: denial.code,
303
337
  scope: resolved.scope,
304
- fix: denial.fix,
338
+ fix: fixFor(denial, audience),
305
339
  docs: denial.docs,
306
340
  });
307
341
  }
308
- case 'invalid-args':
309
- auditToolCall({ tool: name, outcome: 'invalid-args', caller, code: 'X_MCP_ARGS_INVALID' });
310
- return errorResponse(id, INVALID_PARAMS, `invalid arguments for ${name}`, {
311
- code: 'X_MCP_ARGS_INVALID',
312
- issues: formatIssues(resolved.issues),
313
- });
342
+ case 'invalid-args': {
343
+ // The policy's actor half outranks the argument check: a caller the tool refuses whatever
344
+ // they send gets the denial `handle` would give, never the issue list — which describes
345
+ // the arguments of a tool they may not call.
346
+ const refused = admitted(resolved.tool, caller);
347
+ if (refused !== undefined) return thrownResponse(id, name, refused, caller, audience);
348
+ const invalid = invalidArgsResult(name, resolved.issues, audience);
349
+ auditToolCall({ tool: name, outcome: 'invalid-args', caller, code: invalid.code });
350
+ return resultResponse(id, invalid.result);
351
+ }
314
352
  case 'ok':
315
353
  break;
316
354
  }
@@ -319,25 +357,7 @@ export class McpServer {
319
357
  try {
320
358
  result = await resolved.tool.handle(resolved.args, caller);
321
359
  } catch (error) {
322
- // OUTCOME 3 arrives here: the tool ran its policy through `guard()` and the policy
323
- // said no. An UltimateError is an EXPECTED outcome the model can act on, so it comes
324
- // back as an `isError` result carrying code/cause/fix — the same three lines an HTTP
325
- // caller gets for the same call — rather than an opaque transport failure.
326
- const framework = asFrameworkError(error);
327
- if (framework !== undefined) {
328
- auditToolCall({
329
- tool: name,
330
- outcome: outcomeForCode(framework.code),
331
- caller,
332
- code: framework.code,
333
- });
334
- return resultResponse(id, {
335
- content: [{ type: 'text', text: renderFrameworkError(framework) }],
336
- isError: true,
337
- });
338
- }
339
- auditToolCall({ tool: name, outcome: 'failed', caller });
340
- return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
360
+ return thrownResponse(id, name, error, caller, audience);
341
361
  }
342
362
 
343
363
  // A tool may answer `isError` itself (admin renders its own denial). Outcome 3 unless it
@@ -351,6 +371,11 @@ export class McpServer {
351
371
  });
352
372
  // `code` is audit-only and never reaches the wire: it is already inside the rendered body.
353
373
  const payload: Record<string, unknown> = { content: result.content };
374
+ // Through `manage_resource` too, byte for byte: the meta door answers what the flat one does,
375
+ // and MCP allows `structuredContent` beside a tool (the dispatcher) that publishes no schema.
376
+ if (result.structuredContent !== undefined && result.isError !== true) {
377
+ payload['structuredContent'] = result.structuredContent;
378
+ }
354
379
  if (result.isError === true) payload['isError'] = true;
355
380
  return resultResponse(id, payload);
356
381
  }
@@ -399,7 +424,7 @@ export class McpServer {
399
424
  return errorResponse(id, INVALID_REQUEST, `missing scope: ${resolved.scope}`, {
400
425
  code: denial.code,
401
426
  scope: resolved.scope,
402
- fix: denial.fix,
427
+ fix: fixFor(denial, this.voice.errorAudience),
403
428
  docs: denial.docs,
404
429
  });
405
430
  }
@@ -429,7 +454,8 @@ export class McpServer {
429
454
  return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
430
455
  code: framework.code,
431
456
  cause: framework.cause,
432
- fix: framework.fix,
457
+ fix: fixFor(framework, this.voice.errorAudience),
458
+ ...(framework.docs === undefined ? {} : { docs: framework.docs }),
433
459
  });
434
460
  }
435
461
  // No internals: a provider's own message names a path, a query or a host the caller has no
@@ -0,0 +1,113 @@
1
+ // The size of what an agent READS from this server, measured off the wire, and a budget an app's
2
+ // test holds it to. `tools/list` is re-sent with every step of every turn and `list_resources` is
3
+ // read once a session and then sits in the transcript — both are standing context the model pays
4
+ // for whether or not it uses them, so a number with a test beside it is the only way they stay
5
+ // small (gold standard: context-budget.md, "guard the numbers with tests").
6
+
7
+ import { McpSurfaceOverBudgetError } from './errors';
8
+ import { LIST_RESOURCES } from './meta-surface';
9
+ import type { McpCaller } from './registry';
10
+ import type { McpServer } from './server';
11
+
12
+ /** Characters, as serialized on the wire — what a client hands its model. */
13
+ export interface McpSurfaceSize {
14
+ /** `JSON.stringify` of the `tools/list` result this caller gets. */
15
+ readonly toolsList: number;
16
+ /** The text of `list_resources({})`, or `undefined` for a caller served the flat surface. */
17
+ readonly listResources: number | undefined;
18
+ /** `initialize`'s `instructions`; `0` when none are sent to this caller. */
19
+ readonly instructions: number;
20
+ }
21
+
22
+ /** A ceiling per surface, in characters. An omitted surface is measured and not judged. */
23
+ export interface McpSurfaceBudget {
24
+ readonly toolsList?: number;
25
+ readonly listResources?: number;
26
+ readonly instructions?: number;
27
+ }
28
+
29
+ /** Measure the three surfaces for ONE caller — a per-population server answers each differently. */
30
+ export async function measureMcpSurface(
31
+ server: McpServer,
32
+ caller: McpCaller,
33
+ ): Promise<McpSurfaceSize> {
34
+ const init = resultOf(
35
+ await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, caller),
36
+ );
37
+ const instructions = init?.['instructions'];
38
+ const list = resultOf(
39
+ await server.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, caller),
40
+ );
41
+ const tools = Array.isArray(list?.['tools'])
42
+ ? (list['tools'] as readonly { name?: unknown }[])
43
+ : [];
44
+ let listResources: number | undefined;
45
+ if (tools.some((tool) => tool.name === LIST_RESOURCES)) {
46
+ const called = resultOf(
47
+ await server.handle(
48
+ {
49
+ jsonrpc: '2.0',
50
+ id: 3,
51
+ method: 'tools/call',
52
+ params: { name: LIST_RESOURCES, arguments: {} },
53
+ },
54
+ caller,
55
+ ),
56
+ );
57
+ listResources = textLength(called?.['content']);
58
+ }
59
+ return {
60
+ toolsList: JSON.stringify(list ?? {}).length,
61
+ listResources,
62
+ instructions: typeof instructions === 'string' ? instructions.length : 0,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Measure, then refuse any surface over its budget with `X_MCP_SURFACE_OVER_BUDGET` naming every
68
+ * one. Returns the measurement so a test can print it next to the number it chose.
69
+ *
70
+ * ```ts
71
+ * test('the staff surface stays small', async () => {
72
+ * await assertMcpSurfaceBudget(mcp.server, staffCaller, { toolsList: 4_000, listResources: 12_000 });
73
+ * });
74
+ * ```
75
+ *
76
+ * Size a budget from the measured value times a margin, and write the derivation beside it.
77
+ */
78
+ export async function assertMcpSurfaceBudget(
79
+ server: McpServer,
80
+ caller: McpCaller,
81
+ budget: McpSurfaceBudget,
82
+ ): Promise<McpSurfaceSize> {
83
+ const size = await measureMcpSurface(server, caller);
84
+ const over: { surface: string; size: number; budget: number }[] = [];
85
+ const judge = (surface: string, measured: number | undefined, ceiling: number | undefined) => {
86
+ if (measured !== undefined && ceiling !== undefined && measured > ceiling) {
87
+ over.push({ surface, size: measured, budget: ceiling });
88
+ }
89
+ };
90
+ judge('tools/list', size.toolsList, budget.toolsList);
91
+ judge('list_resources', size.listResources, budget.listResources);
92
+ judge('instructions', size.instructions, budget.instructions);
93
+ if (over.length > 0) throw new McpSurfaceOverBudgetError({ over });
94
+ return size;
95
+ }
96
+
97
+ function resultOf(
98
+ response: { readonly result?: unknown } | null,
99
+ ): Record<string, unknown> | undefined {
100
+ const result = response?.result;
101
+ return typeof result === 'object' && result !== null
102
+ ? (result as Record<string, unknown>)
103
+ : undefined;
104
+ }
105
+
106
+ function textLength(content: unknown): number {
107
+ if (!Array.isArray(content)) return 0;
108
+ let total = 0;
109
+ for (const block of content as readonly { readonly text?: unknown }[]) {
110
+ if (typeof block.text === 'string') total += block.text.length;
111
+ }
112
+ return total;
113
+ }
@@ -0,0 +1,51 @@
1
+ // What a `tools/call` answers when the tool threw — or its `admit` refused the caller before the
2
+ // arguments were read. One renderer for both, so a policy denial reads the same whichever gate
3
+ // raised it. Split from `server.ts` for the file ceiling; the server is its only caller.
4
+
5
+ import type { ErrorAudience } from '@ultimat3/core';
6
+ import { auditToolCall, outcomeForCode } from './audit';
7
+ import { asFrameworkError, renderFrameworkError } from './framework-error';
8
+ import type { AnyMcpTool, McpCaller } from './registry';
9
+ import type { JsonRpcId, JsonRpcResponse } from './wire';
10
+ import { errorResponse, INTERNAL_ERROR, resultResponse } from './wire';
11
+
12
+ /**
13
+ * OUTCOME 3 arrives here: the tool ran its policy through `guard()` and the policy said no. An
14
+ * UltimateError is an EXPECTED outcome the model can act on, so it comes back as an `isError`
15
+ * result carrying code/cause/fix — the same three lines an HTTP caller gets for the same call —
16
+ * rather than an opaque transport failure.
17
+ */
18
+ export function thrownResponse(
19
+ id: JsonRpcId,
20
+ name: string,
21
+ error: unknown,
22
+ caller: McpCaller,
23
+ audience: ErrorAudience,
24
+ ): JsonRpcResponse {
25
+ const framework = asFrameworkError(error);
26
+ if (framework !== undefined) {
27
+ auditToolCall({
28
+ tool: name,
29
+ outcome: outcomeForCode(framework.code),
30
+ caller,
31
+ code: framework.code,
32
+ });
33
+ return resultResponse(id, {
34
+ content: [{ type: 'text', text: renderFrameworkError(framework, audience) }],
35
+ isError: true,
36
+ });
37
+ }
38
+ auditToolCall({ tool: name, outcome: 'failed', caller });
39
+ return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
40
+ }
41
+
42
+ /** What the tool's `admit` threw for this caller, or `undefined` when it let them through. */
43
+ export function admitted(tool: AnyMcpTool | undefined, caller: McpCaller): unknown {
44
+ if (tool?.admit === undefined) return undefined;
45
+ try {
46
+ tool.admit(caller);
47
+ return undefined;
48
+ } catch (error) {
49
+ return error;
50
+ }
51
+ }