@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/CLAUDE.md +5 -0
- package/README.md +44 -4
- package/package.json +9 -9
- package/src/app-tool.ts +23 -4
- package/src/app-tools.ts +27 -0
- package/src/errors.ts +25 -0
- package/src/framework-error.ts +44 -7
- package/src/from-action.ts +83 -3
- package/src/index.ts +16 -2
- package/src/input-schema.ts +63 -0
- package/src/meta-call.ts +12 -4
- package/src/meta-surface.ts +42 -0
- package/src/projectable.ts +42 -5
- package/src/registry.ts +118 -12
- package/src/server-voice.ts +64 -0
- package/src/server.ts +59 -33
- package/src/surface-budget.ts +113 -0
- package/src/tool-thrown.ts +51 -0
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 {
|
|
7
|
-
import {
|
|
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
|
|
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
|
|
338
|
+
fix: fixFor(denial, audience),
|
|
305
339
|
docs: denial.docs,
|
|
306
340
|
});
|
|
307
341
|
}
|
|
308
|
-
case 'invalid-args':
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|