@ultimat3/mcp 24.0.0 → 25.1.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 +42 -44
- package/README.md +147 -18
- package/package.json +13 -11
- package/src/app-tool.ts +6 -6
- package/src/app-tools.ts +32 -8
- package/src/audit-hook.ts +108 -0
- package/src/audit.ts +17 -0
- package/src/caller-context.ts +2 -4
- package/src/confirmation-decide.ts +116 -0
- package/src/confirmation-errors.ts +137 -0
- package/src/confirmation-gate.ts +101 -0
- package/src/confirmation-postgres.ts +143 -0
- package/src/confirmation-schema.ts +34 -0
- package/src/confirmation-store.ts +136 -0
- package/src/confirmations.ts +147 -0
- package/src/dev-host.ts +4 -4
- package/src/dev-ui-interact.ts +5 -4
- package/src/dev-ui-tools.ts +3 -3
- package/src/errors-transport.ts +94 -0
- package/src/errors.ts +44 -102
- package/src/framework-error.ts +1 -1
- package/src/from-action.ts +28 -50
- package/src/index.ts +55 -17
- package/src/meta-call.ts +5 -4
- package/src/meta-surface.ts +87 -22
- package/src/mount-errors.ts +1 -1
- package/src/oauth-metadata.ts +1 -1
- package/src/projectable.ts +18 -34
- package/src/readonly-sql.ts +23 -25
- package/src/registry.ts +6 -5
- package/src/scopes.ts +15 -4
- package/src/server-voice.ts +1 -1
- package/src/server.ts +34 -33
- package/src/tool-thrown.ts +5 -3
- package/src/transport-http.ts +42 -18
- package/src/transport-stdio.ts +1 -1
- package/src/type-pins.ts +52 -0
- package/src/validate-args.ts +9 -0
- package/src/wire.ts +17 -0
package/src/server.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
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 type { ErrorAudience } from '@ultimat3/core';
|
|
6
|
+
import type { Clock, ErrorAudience } from '@ultimat3/core';
|
|
7
7
|
import { fixFor } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { outcomeForResult } from './audit';
|
|
9
|
+
import type { McpAuditHook, McpAuditor } from './audit-hook';
|
|
10
|
+
import { LOG_ONLY_AUDITOR, mcpAuditor } from './audit-hook';
|
|
9
11
|
import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
|
|
10
12
|
import { asFrameworkError, forAudience } from './framework-error';
|
|
11
13
|
import { metaCall } from './meta-call';
|
|
@@ -27,7 +29,7 @@ import { ResourceRegistry } from './resources';
|
|
|
27
29
|
import type { McpInstructions, McpServerVoice } from './server-voice';
|
|
28
30
|
import { instructionsFor, invalidArgsResult } from './server-voice';
|
|
29
31
|
import { admitted, thrownResponse } from './tool-thrown';
|
|
30
|
-
import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
|
|
32
|
+
import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, McpWire, ServerInfo } from './wire';
|
|
31
33
|
import {
|
|
32
34
|
defaultServerInfo,
|
|
33
35
|
errorResponse,
|
|
@@ -38,28 +40,14 @@ import {
|
|
|
38
40
|
isNotification,
|
|
39
41
|
MCP_PROTOCOL_VERSION,
|
|
40
42
|
METHOD_NOT_FOUND,
|
|
43
|
+
messageUnitOf,
|
|
41
44
|
paramsOf,
|
|
42
45
|
resultResponse,
|
|
43
46
|
} from './wire';
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
* Which wire one message arrived on, so a refusal can name the unit THAT transport counts in.
|
|
47
|
-
* `handle` is transport-independent and stays so — this is a hint for the wording of one fix, never
|
|
48
|
-
* a branch in dispatch. HTTP carries its mounted path because `mcpHttpRoute({ path })` is a knob:
|
|
49
|
-
* a batch refusal that told every client to retry `POST /mcp` was wrong for an app mounted at
|
|
50
|
-
* `/app-mcp`, which is how this came to be threaded rather than spelled.
|
|
51
|
-
*/
|
|
52
|
-
export type McpWire =
|
|
53
|
-
| { readonly transport: 'http'; readonly path: string }
|
|
54
|
-
| { readonly transport: 'stdio' };
|
|
55
|
-
|
|
56
|
-
/** The unit a transport counts one request in — or the neutral word when no wire said. */
|
|
57
|
-
const messageUnitOf = (wire: McpWire | undefined): string => {
|
|
58
|
-
if (wire === undefined) return 'message';
|
|
59
|
-
return wire.transport === 'http' ? `POST ${wire.path}` : 'line';
|
|
60
|
-
};
|
|
48
|
+
export type { McpWire } from './wire';
|
|
61
49
|
|
|
62
|
-
export interface
|
|
50
|
+
export interface McpServerInput {
|
|
63
51
|
readonly tools?: readonly AnyMcpTool[];
|
|
64
52
|
readonly resources?: readonly McpResource[];
|
|
65
53
|
readonly prompts?: readonly McpPrompt[];
|
|
@@ -82,6 +70,10 @@ export interface CreateMcpServerInput {
|
|
|
82
70
|
* serves `'caller'`.
|
|
83
71
|
*/
|
|
84
72
|
readonly errorAudience?: ErrorAudience | undefined;
|
|
73
|
+
/** Every gate decision as data, after its log line — see `audit-hook.ts`. The route reads it too. */
|
|
74
|
+
readonly onAudit?: McpAuditHook | undefined;
|
|
75
|
+
/** Stamps each `onAudit` event's `at`. */
|
|
76
|
+
readonly clock?: Clock | undefined;
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
/** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
|
|
@@ -96,7 +88,7 @@ const METHODS = [
|
|
|
96
88
|
'prompts/get',
|
|
97
89
|
] as const;
|
|
98
90
|
|
|
99
|
-
export function
|
|
91
|
+
export function mcpServer(input: McpServerInput = {}): McpServer {
|
|
100
92
|
const tools = new ToolRegistry().registerAll(input.tools ?? []);
|
|
101
93
|
const resources = new ResourceRegistry().registerAll(input.resources ?? []);
|
|
102
94
|
const meta = MetaSurface.build({
|
|
@@ -111,6 +103,9 @@ export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
|
|
|
111
103
|
input.serverInfo ?? defaultServerInfo(),
|
|
112
104
|
meta,
|
|
113
105
|
{ instructions: input.instructions, errorAudience: input.errorAudience ?? 'developer' },
|
|
106
|
+
input.onAudit === undefined && input.clock === undefined
|
|
107
|
+
? LOG_ONLY_AUDITOR
|
|
108
|
+
: mcpAuditor({ onAudit: input.onAudit, clock: input.clock }),
|
|
114
109
|
);
|
|
115
110
|
}
|
|
116
111
|
|
|
@@ -122,6 +117,8 @@ export class McpServer {
|
|
|
122
117
|
/** `undefined` for a flat-only server — every existing app, byte for byte. */
|
|
123
118
|
private readonly meta: MetaSurface | undefined;
|
|
124
119
|
private readonly voice: McpServerVoice;
|
|
120
|
+
/** Where every decision is audited — the log line, then `onAudit`. Public for the HTTP route. */
|
|
121
|
+
readonly audit: McpAuditor;
|
|
125
122
|
|
|
126
123
|
constructor(
|
|
127
124
|
tools: ToolRegistry,
|
|
@@ -130,6 +127,7 @@ export class McpServer {
|
|
|
130
127
|
serverInfo: ServerInfo,
|
|
131
128
|
meta?: MetaSurface,
|
|
132
129
|
voice: McpServerVoice = { errorAudience: 'developer' },
|
|
130
|
+
audit: McpAuditor = LOG_ONLY_AUDITOR,
|
|
133
131
|
) {
|
|
134
132
|
this.tools = tools;
|
|
135
133
|
this.resources = resources;
|
|
@@ -137,6 +135,7 @@ export class McpServer {
|
|
|
137
135
|
this.serverInfo = serverInfo;
|
|
138
136
|
this.meta = meta;
|
|
139
137
|
this.voice = voice;
|
|
138
|
+
this.audit = audit;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
async handle(
|
|
@@ -275,6 +274,7 @@ export class McpServer {
|
|
|
275
274
|
const answered = await metaCall(
|
|
276
275
|
{
|
|
277
276
|
tools: this.tools,
|
|
277
|
+
audit: this.audit,
|
|
278
278
|
dispatch: (at, tool, resolved, who) => this.dispatch(at, tool, resolved, who),
|
|
279
279
|
notFound: (at, tool, who, fix) => this.notFound(at, tool, who, fix),
|
|
280
280
|
},
|
|
@@ -293,7 +293,7 @@ export class McpServer {
|
|
|
293
293
|
|
|
294
294
|
/** OUTCOME 1, the one answer for absent and hidden alike. See `dispatch`. */
|
|
295
295
|
private notFound(id: JsonRpcId, name: string, caller: McpCaller, fix: string): JsonRpcResponse {
|
|
296
|
-
|
|
296
|
+
this.audit.toolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
|
|
297
297
|
return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name} — ${fix}`);
|
|
298
298
|
}
|
|
299
299
|
|
|
@@ -327,7 +327,7 @@ export class McpServer {
|
|
|
327
327
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
328
328
|
case 'scope-denied': {
|
|
329
329
|
const denial = new McpScopeDeniedError({ name, scope: resolved.scope });
|
|
330
|
-
|
|
330
|
+
this.audit.toolCall({
|
|
331
331
|
tool: name,
|
|
332
332
|
outcome: 'scope-denied',
|
|
333
333
|
caller,
|
|
@@ -346,9 +346,10 @@ export class McpServer {
|
|
|
346
346
|
// they send gets the denial `handle` would give, never the issue list — which describes
|
|
347
347
|
// the arguments of a tool they may not call.
|
|
348
348
|
const refused = admitted(resolved.tool, caller);
|
|
349
|
-
if (refused !== undefined)
|
|
349
|
+
if (refused !== undefined)
|
|
350
|
+
return thrownResponse(id, name, refused, caller, audience, this.audit);
|
|
350
351
|
const invalid = invalidArgsResult(name, resolved.issues, audience);
|
|
351
|
-
|
|
352
|
+
this.audit.toolCall({ tool: name, outcome: 'invalid-args', caller, code: invalid.code });
|
|
352
353
|
return resultResponse(id, invalid.result);
|
|
353
354
|
}
|
|
354
355
|
case 'ok':
|
|
@@ -359,13 +360,13 @@ export class McpServer {
|
|
|
359
360
|
try {
|
|
360
361
|
result = await resolved.tool.handle(resolved.args, caller);
|
|
361
362
|
} catch (error) {
|
|
362
|
-
return thrownResponse(id, name, error, caller, audience);
|
|
363
|
+
return thrownResponse(id, name, error, caller, audience, this.audit);
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
// A tool may answer `isError` itself (admin renders its own denial). Outcome 3 unless it
|
|
366
367
|
// NAMED the code it refused with, in which case the same classifier a thrown error goes
|
|
367
368
|
// through decides — a tool's own argument check is not a denial a prober drove.
|
|
368
|
-
|
|
369
|
+
this.audit.toolCall({
|
|
369
370
|
tool: name,
|
|
370
371
|
outcome: outcomeForResult(result),
|
|
371
372
|
caller,
|
|
@@ -406,7 +407,7 @@ export class McpServer {
|
|
|
406
407
|
// `code` on the audit line either, because the wire carries none — the tool surface's
|
|
407
408
|
// `X_MCP_TOOL_UNKNOWN` is an error class this branch has no twin for.
|
|
408
409
|
case 'not-found':
|
|
409
|
-
|
|
410
|
+
this.audit.resourceRead({ uri, outcome: 'hidden', caller });
|
|
410
411
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
411
412
|
// OUTCOME 2. The caller can already see this resource, so naming the missing scope leaks
|
|
412
413
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
@@ -416,7 +417,7 @@ export class McpServer {
|
|
|
416
417
|
scope: resolved.scope,
|
|
417
418
|
subject: 'resource',
|
|
418
419
|
});
|
|
419
|
-
|
|
420
|
+
this.audit.resourceRead({
|
|
420
421
|
uri,
|
|
421
422
|
outcome: 'scope-denied',
|
|
422
423
|
caller,
|
|
@@ -444,16 +445,16 @@ export class McpServer {
|
|
|
444
445
|
if (contents === undefined) {
|
|
445
446
|
// The resolver said `ok` and the registry then had nothing: a bug here, not a walk, so it
|
|
446
447
|
// is `failed` in the log while the caller still gets the same not-found it would have.
|
|
447
|
-
|
|
448
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller });
|
|
448
449
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
449
450
|
}
|
|
450
|
-
|
|
451
|
+
this.audit.resourceRead({ uri, outcome: 'ok', caller });
|
|
451
452
|
return resultResponse(id, { contents: [contents] });
|
|
452
453
|
} catch (error) {
|
|
453
454
|
const thrown = asFrameworkError(error);
|
|
454
455
|
if (thrown !== undefined) {
|
|
455
456
|
const framework = forAudience(thrown, this.voice.errorAudience);
|
|
456
|
-
|
|
457
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller, code: framework.code });
|
|
457
458
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
|
|
458
459
|
code: framework.code,
|
|
459
460
|
cause: framework.cause,
|
|
@@ -463,7 +464,7 @@ export class McpServer {
|
|
|
463
464
|
}
|
|
464
465
|
// No internals: a provider's own message names a path, a query or a host the caller has no
|
|
465
466
|
// business seeing, exactly as a failing tool's does.
|
|
466
|
-
|
|
467
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller });
|
|
467
468
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`);
|
|
468
469
|
}
|
|
469
470
|
}
|
package/src/tool-thrown.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// raised it. Split from `server.ts` for the file ceiling; the server is its only caller.
|
|
4
4
|
|
|
5
5
|
import type { ErrorAudience } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
6
|
+
import { outcomeForCode } from './audit';
|
|
7
|
+
import type { McpAuditor } from './audit-hook';
|
|
7
8
|
import { asFrameworkError, renderFrameworkError } from './framework-error';
|
|
8
9
|
import type { AnyMcpTool, McpCaller } from './registry';
|
|
9
10
|
import type { JsonRpcId, JsonRpcResponse } from './wire';
|
|
@@ -21,10 +22,11 @@ export function thrownResponse(
|
|
|
21
22
|
error: unknown,
|
|
22
23
|
caller: McpCaller,
|
|
23
24
|
audience: ErrorAudience,
|
|
25
|
+
audit: McpAuditor,
|
|
24
26
|
): JsonRpcResponse {
|
|
25
27
|
const framework = asFrameworkError(error);
|
|
26
28
|
if (framework !== undefined) {
|
|
27
|
-
|
|
29
|
+
audit.toolCall({
|
|
28
30
|
tool: name,
|
|
29
31
|
outcome: outcomeForCode(framework.code),
|
|
30
32
|
caller,
|
|
@@ -35,7 +37,7 @@ export function thrownResponse(
|
|
|
35
37
|
isError: true,
|
|
36
38
|
});
|
|
37
39
|
}
|
|
38
|
-
|
|
40
|
+
audit.toolCall({ tool: name, outcome: 'failed', caller });
|
|
39
41
|
return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
|
|
40
42
|
}
|
|
41
43
|
|
package/src/transport-http.ts
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import type { Actor, Clock } from '@ultimat3/core';
|
|
23
23
|
import { finiteCount, readWithinLimit, systemClock } from '@ultimat3/core';
|
|
24
|
-
import type { Bucket, RateLimitStore } from '@ultimat3/http';
|
|
25
|
-
import { memoryRateLimitStore, toBucket } from '@ultimat3/http';
|
|
26
|
-
import { McpBodyTooLargeError, McpRateLimitedError } from './errors';
|
|
24
|
+
import type { Bucket, RequestFacts as McpRequestFacts, RateLimitStore } from '@ultimat3/http';
|
|
25
|
+
import { memoryRateLimitStore, requestFacts, toBucket } from '@ultimat3/http';
|
|
26
|
+
import { McpBodyTooLargeError, McpRateLimitedError } from './errors-transport';
|
|
27
27
|
import type { McpOAuth } from './oauth-metadata';
|
|
28
28
|
import {
|
|
29
29
|
assertMcpOAuth,
|
|
@@ -43,7 +43,7 @@ import { errorResponse, INVALID_REQUEST, PARSE_ERROR, refusalMessage } from './w
|
|
|
43
43
|
/**
|
|
44
44
|
* The same 1 MiB `@ultimat3/http`'s `bodyLimitBytes` defaults to. This descriptor is driven from a
|
|
45
45
|
* bare `Request` and never passes through that pipeline, so without a cap here Bun's 128 MiB
|
|
46
|
-
* default was the only ceiling — and `x mcp serve` and `
|
|
46
|
+
* default was the only ceiling — and `x mcp serve` and `httpServer` both pass no
|
|
47
47
|
* `maxRequestBodySize`.
|
|
48
48
|
*/
|
|
49
49
|
export const DEFAULT_MCP_BODY_LIMIT_BYTES = 1_048_576;
|
|
@@ -79,10 +79,16 @@ export interface ResolvedToken {
|
|
|
79
79
|
export interface McpHttpTransportInput {
|
|
80
80
|
readonly server: McpServer;
|
|
81
81
|
/**
|
|
82
|
-
* Resolve an OAuth bearer / personal token to a caller. The framework supplies the token
|
|
83
|
-
*
|
|
82
|
+
* Resolve an OAuth bearer / personal token to a caller. The framework supplies the token and
|
|
83
|
+
* how the request arrived (`McpRequestFacts`: the host-resolved address, user agent, `Origin`,
|
|
84
|
+
* path) — so a token can be bound to a network or refused from a browser origin. Credential
|
|
85
|
+
* storage belongs to `@ultimat3/policy` and the app, never here. A one-argument resolver is
|
|
86
|
+
* still one: the facts are an extra argument it may ignore.
|
|
84
87
|
*/
|
|
85
|
-
resolveToken(
|
|
88
|
+
resolveToken(
|
|
89
|
+
token: string,
|
|
90
|
+
facts: McpRequestFacts,
|
|
91
|
+
): Promise<ResolvedToken | null> | ResolvedToken | null;
|
|
86
92
|
/** Route path. Overridable so an app can mount a second, app-scoped surface. */
|
|
87
93
|
readonly path?: string;
|
|
88
94
|
/** Bytes this transport will hold for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES`. */
|
|
@@ -231,24 +237,42 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
231
237
|
// An address that has spent its failures is refused BEFORE the credential store is asked:
|
|
232
238
|
// counting only after the fact turned a 401 into a 429 and still answered a valid guess.
|
|
233
239
|
const address = seen?.address;
|
|
240
|
+
// Built once per request: the resolver's second argument and every refusal's audit facts.
|
|
241
|
+
// The address inside is the host's answer (`seen.address`), never a header read here.
|
|
242
|
+
const facts = requestFacts({ headers: request.headers, url: request.url, address });
|
|
243
|
+
const audit = server.audit;
|
|
234
244
|
const exhausted = await failures.refusing(address);
|
|
235
|
-
if (exhausted !== undefined)
|
|
245
|
+
if (exhausted !== undefined) {
|
|
246
|
+
audit.authRefused('throttled', exhausted.status, facts);
|
|
247
|
+
return exhausted;
|
|
248
|
+
}
|
|
249
|
+
// A failure spends the address's allowance; the 429 wins when that one did not fit.
|
|
250
|
+
const refuse = async (
|
|
251
|
+
reason: 'missing-token' | 'rejected-token',
|
|
252
|
+
challenge: string,
|
|
253
|
+
): Promise<Response> => {
|
|
254
|
+
const throttledNow = await failures.spend(address);
|
|
255
|
+
const response = throttledNow ?? unauthorized(challenge);
|
|
256
|
+
audit.authRefused(
|
|
257
|
+
throttledNow === undefined ? reason : 'throttled',
|
|
258
|
+
response.status,
|
|
259
|
+
facts,
|
|
260
|
+
);
|
|
261
|
+
return response;
|
|
262
|
+
};
|
|
236
263
|
const token = bearerToken(request);
|
|
237
264
|
if (token === null) {
|
|
238
|
-
return (
|
|
239
|
-
(await failures.spend(address)) ??
|
|
240
|
-
unauthorized(bearerChallenge(metadataUrl, false, challengeScopes))
|
|
241
|
-
);
|
|
265
|
+
return refuse('missing-token', bearerChallenge(metadataUrl, false, challengeScopes));
|
|
242
266
|
}
|
|
243
267
|
|
|
244
|
-
const resolved = await input.resolveToken(token);
|
|
268
|
+
const resolved = await input.resolveToken(token, facts);
|
|
245
269
|
if (resolved === null) {
|
|
246
|
-
return (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
);
|
|
270
|
+
return refuse('rejected-token', bearerChallenge(metadataUrl, true, challengeScopes));
|
|
271
|
+
}
|
|
272
|
+
if (!isAgentActor(resolved.actor)) {
|
|
273
|
+
audit.authRefused('not-an-agent', 403, facts);
|
|
274
|
+
return notAnAgent();
|
|
250
275
|
}
|
|
251
|
-
if (!isAgentActor(resolved.actor)) return notAnAgent();
|
|
252
276
|
|
|
253
277
|
// Read through the counting reader, never `request.json()`: the cap has to be enforced
|
|
254
278
|
// WHILE the bytes arrive, or a `transfer-encoding: chunked` payload is materialised in full
|
package/src/transport-stdio.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// stderr and this file never calls `console.log`.
|
|
10
10
|
|
|
11
11
|
import { finiteCount } from '@ultimat3/core';
|
|
12
|
-
import { McpBodyTooLargeError } from './errors';
|
|
12
|
+
import { McpBodyTooLargeError } from './errors-transport';
|
|
13
13
|
import type { McpCaller } from './registry';
|
|
14
14
|
import type { McpServer } from './server';
|
|
15
15
|
import { errorResponse, INVALID_REQUEST, PARSE_ERROR, refusalMessage } from './wire';
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Compile-time pins for this package's public types. Source, not a `.test.ts`, on purpose:
|
|
2
|
+
// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
|
|
3
|
+
// type-level claim written in one can never fail. This module emits nothing and exports nothing
|
|
4
|
+
// anybody imports — a regression here is a build error, the only enforcement that counts.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
McpListParams as CoreListParams,
|
|
8
|
+
McpAnnotationHints,
|
|
9
|
+
McpExposureDeclaration,
|
|
10
|
+
} from '@ultimat3/core';
|
|
11
|
+
import type { ProjectablePrimitive } from './from-action';
|
|
12
|
+
import type { McpListParams } from './list-params';
|
|
13
|
+
import type { McpToolAnnotations } from './registry';
|
|
14
|
+
|
|
15
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
16
|
+
type Assert<T extends true> = T;
|
|
17
|
+
|
|
18
|
+
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* IDENTICAL, not merely mutually assignable: an object type without an optional key is assignable
|
|
22
|
+
* to one with it and back, so `Equals` cannot see an optional field added on one side only — and
|
|
23
|
+
* every field of the `mcp` block is optional but `expose`.
|
|
24
|
+
*/
|
|
25
|
+
type Identical<A, B> =
|
|
26
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
27
|
+
|
|
28
|
+
type Projected = NonNullable<ProjectablePrimitive['mcp']>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 25.0.0: the projection reads core's ONE `mcp` block. `McpExposure` restated it here, a field
|
|
32
|
+
* looser (`expose?`) than the block an action or query is held to; a twin coming back as a
|
|
33
|
+
* different shape flips this to `false`.
|
|
34
|
+
*/
|
|
35
|
+
export type _ProjectionReadsCoresBlock = Assert<Identical<Projected, McpExposureDeclaration>>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 25.0.0 (plan 101, M4): a projected tool is named by its primitive's `name` and by nothing
|
|
39
|
+
* else. `McpExposure.name` was a second naming field no declaration could set; one coming back
|
|
40
|
+
* on core's block flips this to `false`.
|
|
41
|
+
*/
|
|
42
|
+
export type _McpBlockCarriesNoName = Assert<Equals<Extract<keyof Projected, 'name'>, never>>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The tool's own wire types (`McpTool.annotations`, `McpTool.listParams`) are what the block's
|
|
46
|
+
* fields become, so they must stay the block's shapes exactly: a hint or a whitelist key added
|
|
47
|
+
* on one side only is a declaration the other silently drops.
|
|
48
|
+
*/
|
|
49
|
+
export type _ToolAnnotationsAreTheBlocks = Assert<
|
|
50
|
+
Identical<McpToolAnnotations, McpAnnotationHints>
|
|
51
|
+
>;
|
|
52
|
+
export type _ToolListParamsAreTheBlocks = Assert<Identical<McpListParams, CoreListParams>>;
|
package/src/validate-args.ts
CHANGED
|
@@ -121,6 +121,15 @@ function array(schema: JsonSchema, input: unknown, path: string, issues: ArgIssu
|
|
|
121
121
|
issues.push({ path, message: 'must be an array' });
|
|
122
122
|
return input;
|
|
123
123
|
}
|
|
124
|
+
// `@ultimat3/schema` publishes `t.array(items, { min, max })` as these two; a bound on the wire
|
|
125
|
+
// that nothing here checks is an agent obeying a rule a hand-written tool never enforced.
|
|
126
|
+
const count = (n: number): string => `${n} item${n === 1 ? '' : 's'}`;
|
|
127
|
+
if (schema.minItems !== undefined && input.length < schema.minItems) {
|
|
128
|
+
issues.push({ path, message: `must have at least ${count(schema.minItems)}` });
|
|
129
|
+
}
|
|
130
|
+
if (schema.maxItems !== undefined && input.length > schema.maxItems) {
|
|
131
|
+
issues.push({ path, message: `must have at most ${count(schema.maxItems)}` });
|
|
132
|
+
}
|
|
124
133
|
const items = schema.items;
|
|
125
134
|
if (items === undefined) return input;
|
|
126
135
|
return input.map((item, index) => walk(items, item, `${path}[${index}]`, issues));
|
package/src/wire.ts
CHANGED
|
@@ -113,3 +113,20 @@ export function paramsOf(req: JsonRpcRequest): Record<string, unknown> | null {
|
|
|
113
113
|
if (typeof req.params !== 'object' || req.params === null) return null;
|
|
114
114
|
return req.params as Record<string, unknown>;
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Which wire one message arrived on, so a refusal can name the unit THAT transport counts in.
|
|
119
|
+
* `handle` is transport-independent and stays so — this is a hint for the wording of one fix, never
|
|
120
|
+
* a branch in dispatch. HTTP carries its mounted path because `mcpHttpRoute({ path })` is a knob:
|
|
121
|
+
* a batch refusal that told every client to retry `POST /mcp` was wrong for an app mounted at
|
|
122
|
+
* `/app-mcp`, which is how this came to be threaded rather than spelled.
|
|
123
|
+
*/
|
|
124
|
+
export type McpWire =
|
|
125
|
+
| { readonly transport: 'http'; readonly path: string }
|
|
126
|
+
| { readonly transport: 'stdio' };
|
|
127
|
+
|
|
128
|
+
/** The unit a transport counts one request in — or the neutral word when no wire said. */
|
|
129
|
+
export const messageUnitOf = (wire: McpWire | undefined): string => {
|
|
130
|
+
if (wire === undefined) return 'message';
|
|
131
|
+
return wire.transport === 'http' ? `POST ${wire.path}` : 'line';
|
|
132
|
+
};
|