@ultimat3/mcp 22.10.0 → 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/package.json +9 -9
- package/src/app-tool.ts +11 -3
- package/src/from-action.ts +8 -0
- package/src/meta-call.ts +3 -1
- package/src/projectable.ts +28 -4
- package/src/registry.ts +18 -3
- package/src/server.ts +9 -21
- package/src/tool-thrown.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.11.0",
|
|
4
4
|
"description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "22.
|
|
35
|
-
"@ultimat3/core": "22.
|
|
36
|
-
"@ultimat3/entity": "22.
|
|
37
|
-
"@ultimat3/http": "22.
|
|
38
|
-
"@ultimat3/jobs": "22.
|
|
39
|
-
"@ultimat3/policy": "22.
|
|
40
|
-
"@ultimat3/query": "22.
|
|
41
|
-
"@ultimat3/schema": "22.
|
|
34
|
+
"@ultimat3/action": "22.11.0",
|
|
35
|
+
"@ultimat3/core": "22.11.0",
|
|
36
|
+
"@ultimat3/entity": "22.11.0",
|
|
37
|
+
"@ultimat3/http": "22.11.0",
|
|
38
|
+
"@ultimat3/jobs": "22.11.0",
|
|
39
|
+
"@ultimat3/policy": "22.11.0",
|
|
40
|
+
"@ultimat3/query": "22.11.0",
|
|
41
|
+
"@ultimat3/schema": "22.11.0"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/app-tool.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// tool therefore has no authz of its own — it borrows the action tier's, so a rule change cannot
|
|
11
11
|
// apply to routes and miss tools.
|
|
12
12
|
|
|
13
|
-
import { actorOf, guard, InputInvalidError } from '@ultimat3/action';
|
|
13
|
+
import { actorOf, guard, guardBeforeInput, InputInvalidError } from '@ultimat3/action';
|
|
14
14
|
import type { Ctx } from '@ultimat3/core';
|
|
15
15
|
import { useContext } from '@ultimat3/core';
|
|
16
16
|
import type { KnownPermission } from '@ultimat3/policy';
|
|
@@ -103,14 +103,22 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
|
|
|
103
103
|
},
|
|
104
104
|
inputJsonSchema: toWireSchema(def.input),
|
|
105
105
|
mutates: def.destructive ?? true,
|
|
106
|
+
admit: (actor) =>
|
|
107
|
+
asCallerContext(actor, () => {
|
|
108
|
+
const ctx = useContext();
|
|
109
|
+
guardBeforeInput(policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
110
|
+
}),
|
|
106
111
|
run: ({ input, actor }) =>
|
|
107
112
|
// The caller is the actor for the WHOLE call. A child context is how the framework
|
|
108
113
|
// impersonates, so the policy subject and whatever `handle` reads off `ctx.actor` are
|
|
109
114
|
// the same identity by construction rather than by two call sites agreeing.
|
|
110
115
|
asCallerContext(actor, async () => {
|
|
111
116
|
const ctx = useContext();
|
|
112
|
-
// The
|
|
113
|
-
//
|
|
117
|
+
// The policy's actor half first, as in `invoke`: a caller refused whatever they send is
|
|
118
|
+
// told X_FORBIDDEN, never the tool's argument schema.
|
|
119
|
+
guardBeforeInput(policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
120
|
+
// The AUTHORITATIVE parse, in the same slot `invoke` puts it: before the full policy,
|
|
121
|
+
// before the handler. A projected action re-parses inside `invoke`; a hand-written tool has no
|
|
114
122
|
// second parse, so `handle` was typed `InferOutput<TInput>` and handed whatever
|
|
115
123
|
// `validate-args.ts` let past the wire subset — which carries no `format`, so a `t.uuid`
|
|
116
124
|
// arrived as any string at all. `InputInvalidError` because a tool argument is an action
|
package/src/from-action.ts
CHANGED
|
@@ -96,6 +96,11 @@ export interface ProjectablePrimitive {
|
|
|
96
96
|
readonly outputWrap?: string;
|
|
97
97
|
/** The one server-authoritative entry point. Runs policy, then the handler. */
|
|
98
98
|
run(args: { input: unknown; actor: Actor }): Promise<unknown>;
|
|
99
|
+
/**
|
|
100
|
+
* The policy's actor half, alone: throws the denial `run` would raise for this actor whatever
|
|
101
|
+
* the input, returns otherwise. Becomes the tool's `admit`. Absent: no before-args gate.
|
|
102
|
+
*/
|
|
103
|
+
admit?(actor: Actor): void;
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/**
|
|
@@ -132,6 +137,9 @@ export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
|
|
|
132
137
|
destructive: mutates,
|
|
133
138
|
...(visibleTo !== undefined ? { visibleTo } : {}),
|
|
134
139
|
...(listParams !== undefined ? { listParams } : {}),
|
|
140
|
+
...(primitive.admit === undefined
|
|
141
|
+
? {}
|
|
142
|
+
: { admit: (caller: McpCaller) => primitive.admit?.(caller.actor) }),
|
|
135
143
|
// No `scope` from here: see the header. `defineAppMcp`'s `scopes:` map may add one.
|
|
136
144
|
async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
|
|
137
145
|
// ONE authz system, TWO surfaces. This `run` IS `invoke`; the HTTP route reaches the
|
package/src/meta-call.ts
CHANGED
|
@@ -83,7 +83,9 @@ export async function metaCall(
|
|
|
83
83
|
let resolved = host.tools.resolve(action, input, caller);
|
|
84
84
|
if (resolved.kind !== 'scope-denied' && resolved.kind !== 'not-found') {
|
|
85
85
|
const listed = validateArgs(schemaOf(tool), input);
|
|
86
|
-
if (!listed.ok)
|
|
86
|
+
if (!listed.ok) {
|
|
87
|
+
resolved = { kind: 'invalid-args', name: action, issues: listed.issues, tool };
|
|
88
|
+
}
|
|
87
89
|
}
|
|
88
90
|
return host.dispatch(id, action, resolved, caller);
|
|
89
91
|
}
|
package/src/projectable.ts
CHANGED
|
@@ -4,10 +4,21 @@
|
|
|
4
4
|
// primitive out is a different way to NAME a tool, never a second way to run one.
|
|
5
5
|
|
|
6
6
|
import type { AnyAction } from '@ultimat3/action';
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
guardBeforeInput as actionGuardBeforeInput,
|
|
9
|
+
actionName,
|
|
10
|
+
actorOf,
|
|
11
|
+
invoke,
|
|
12
|
+
isAction,
|
|
13
|
+
} from '@ultimat3/action';
|
|
14
|
+
import { type Actor, isMcpExposed, useContext } from '@ultimat3/core';
|
|
9
15
|
import type { AnyQuery } from '@ultimat3/query';
|
|
10
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
isQuery,
|
|
18
|
+
guardBeforeInput as queryGuardBeforeInput,
|
|
19
|
+
queryName,
|
|
20
|
+
sourceFor,
|
|
21
|
+
} from '@ultimat3/query';
|
|
11
22
|
import { asCallerContext } from './caller-context';
|
|
12
23
|
import type { McpExposure, ProjectablePrimitive } from './from-action';
|
|
13
24
|
import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
|
|
@@ -58,6 +69,12 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
58
69
|
...(output === undefined ? {} : { outputJsonSchema: output }),
|
|
59
70
|
mutates: true,
|
|
60
71
|
idempotent: keyed,
|
|
72
|
+
// The same gate `invoke` opens with, asked by the server before it answers `invalid-args`.
|
|
73
|
+
admit: (actor: Actor) =>
|
|
74
|
+
asCallerContext(actor, () => {
|
|
75
|
+
const ctx = useContext();
|
|
76
|
+
actionGuardBeforeInput(target.policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
77
|
+
}),
|
|
61
78
|
// The actor rides in on the options: `invoke` swaps it inside the one execution path.
|
|
62
79
|
run: keyed
|
|
63
80
|
? ({ input: args, actor }) => {
|
|
@@ -74,13 +91,20 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
|
74
91
|
// answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent` is an
|
|
75
92
|
// object by the spec.
|
|
76
93
|
const output = toRowsOutputSchema(target.rows);
|
|
94
|
+
const name = queryName(target);
|
|
77
95
|
return {
|
|
78
|
-
name
|
|
96
|
+
name,
|
|
79
97
|
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
80
98
|
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
81
99
|
inputJsonSchema: toWireSchema(target.input),
|
|
82
100
|
...(output === undefined ? {} : { outputJsonSchema: output, outputWrap: 'rows' }),
|
|
83
101
|
mutates: false,
|
|
102
|
+
// `sourceFor`'s opening gate, on the surface it runs on ('server'), asked before `invalid-args`.
|
|
103
|
+
admit: (actor: Actor) =>
|
|
104
|
+
asCallerContext(actor, () => {
|
|
105
|
+
const ctx = useContext();
|
|
106
|
+
queryGuardBeforeInput(target.policy, { actor: actorOf(ctx), ctx, query: name }, 'server');
|
|
107
|
+
}),
|
|
84
108
|
run: ({ input, actor }) =>
|
|
85
109
|
asCallerContext(actor, async () => {
|
|
86
110
|
// `sourceFor` is the authorized front half of `runQuery` — validate, guard, build —
|
package/src/registry.ts
CHANGED
|
@@ -147,6 +147,14 @@ export interface McpTool<A extends ToolArgs = ToolArgs> {
|
|
|
147
147
|
* `list_resources` so an agent plans for the hand-off; it changes nothing about the call.
|
|
148
148
|
*/
|
|
149
149
|
readonly confirms?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* The tool's policy, decided on the CALLER alone — throws the same denial `handle` would, or
|
|
152
|
+
* returns when the caller may call it (or when only the arguments can decide). The server asks
|
|
153
|
+
* it before answering `invalid-args`, so a caller who may never call this tool gets the 403,
|
|
154
|
+
* never an issue list describing its arguments. Projected actions and queries and app tools
|
|
155
|
+
* carry one; a hand-written tool without it answers `invalid-args` as before.
|
|
156
|
+
*/
|
|
157
|
+
admit?(caller: McpCaller): void;
|
|
150
158
|
// Method syntax (not a property) so a tool declared with narrower args stays assignable.
|
|
151
159
|
handle(args: A, caller: McpCaller): Promise<McpToolResult>;
|
|
152
160
|
}
|
|
@@ -222,7 +230,13 @@ export type ToolResolution =
|
|
|
222
230
|
| { readonly kind: 'ok'; readonly tool: AnyMcpTool; readonly args: ToolArgs }
|
|
223
231
|
| { readonly kind: 'not-found'; readonly name: string }
|
|
224
232
|
| { readonly kind: 'scope-denied'; readonly name: string; readonly scope: string }
|
|
225
|
-
| {
|
|
233
|
+
| {
|
|
234
|
+
readonly kind: 'invalid-args';
|
|
235
|
+
readonly name: string;
|
|
236
|
+
readonly issues: readonly ArgIssue[];
|
|
237
|
+
/** The tool the arguments were refused for — asked to `admit` the caller first. */
|
|
238
|
+
readonly tool?: AnyMcpTool;
|
|
239
|
+
};
|
|
226
240
|
|
|
227
241
|
export class ToolRegistry {
|
|
228
242
|
readonly #tools = new Map<string, AnyMcpTool>();
|
|
@@ -268,7 +282,8 @@ export class ToolRegistry {
|
|
|
268
282
|
* Both gates then validation, in the only order that is safe:
|
|
269
283
|
* 1. visibility → not-found (never reveals existence)
|
|
270
284
|
* 2. scope → scope-denied (safe: the caller was already shown the tool)
|
|
271
|
-
* 3. args → invalid-args
|
|
285
|
+
* 3. args → invalid-args — unless the tool's `admit` refuses the caller first, which
|
|
286
|
+
* the server asks before answering (the policy's actor half, `dispatch`)
|
|
272
287
|
* 4. policy → inside `tool.handle`, which is why it is not here
|
|
273
288
|
* Validating before the gates would leak a schema to a caller that may not see the tool;
|
|
274
289
|
* running the policy before the scope gate would decide a refusal from attacker-supplied
|
|
@@ -283,7 +298,7 @@ export class ToolRegistry {
|
|
|
283
298
|
return { kind: 'scope-denied', name, scope: tool.scope };
|
|
284
299
|
}
|
|
285
300
|
const validation = validateArgs(tool.inputSchema, rawArgs ?? {});
|
|
286
|
-
if (!validation.ok) return { kind: 'invalid-args', name, issues: validation.issues };
|
|
301
|
+
if (!validation.ok) return { kind: 'invalid-args', name, issues: validation.issues, tool };
|
|
287
302
|
return { kind: 'ok', tool, args: validation.value };
|
|
288
303
|
}
|
|
289
304
|
|
package/src/server.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ErrorAudience } from '@ultimat3/core';
|
|
7
7
|
import { fixFor } from '@ultimat3/core';
|
|
8
|
-
import { auditResourceRead, auditToolCall,
|
|
8
|
+
import { auditResourceRead, auditToolCall, outcomeForResult } from './audit';
|
|
9
9
|
import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
|
|
10
|
-
import { asFrameworkError
|
|
10
|
+
import { asFrameworkError } from './framework-error';
|
|
11
11
|
import { metaCall } from './meta-call';
|
|
12
12
|
import { META_UNKNOWN_FIX } from './meta-errors';
|
|
13
13
|
import type { McpResourceGroups, McpSurfaceOption, MetaResource } from './meta-surface';
|
|
@@ -26,6 +26,7 @@ import type { McpPrompt, McpResource } from './resources';
|
|
|
26
26
|
import { ResourceRegistry } from './resources';
|
|
27
27
|
import type { McpInstructions, McpServerVoice } from './server-voice';
|
|
28
28
|
import { instructionsFor, invalidArgsResult } from './server-voice';
|
|
29
|
+
import { admitted, thrownResponse } from './tool-thrown';
|
|
29
30
|
import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
|
|
30
31
|
import {
|
|
31
32
|
defaultServerInfo,
|
|
@@ -339,6 +340,11 @@ export class McpServer {
|
|
|
339
340
|
});
|
|
340
341
|
}
|
|
341
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);
|
|
342
348
|
const invalid = invalidArgsResult(name, resolved.issues, audience);
|
|
343
349
|
auditToolCall({ tool: name, outcome: 'invalid-args', caller, code: invalid.code });
|
|
344
350
|
return resultResponse(id, invalid.result);
|
|
@@ -351,25 +357,7 @@ export class McpServer {
|
|
|
351
357
|
try {
|
|
352
358
|
result = await resolved.tool.handle(resolved.args, caller);
|
|
353
359
|
} catch (error) {
|
|
354
|
-
|
|
355
|
-
// said no. An UltimateError is an EXPECTED outcome the model can act on, so it comes
|
|
356
|
-
// back as an `isError` result carrying code/cause/fix — the same three lines an HTTP
|
|
357
|
-
// caller gets for the same call — rather than an opaque transport failure.
|
|
358
|
-
const framework = asFrameworkError(error);
|
|
359
|
-
if (framework !== undefined) {
|
|
360
|
-
auditToolCall({
|
|
361
|
-
tool: name,
|
|
362
|
-
outcome: outcomeForCode(framework.code),
|
|
363
|
-
caller,
|
|
364
|
-
code: framework.code,
|
|
365
|
-
});
|
|
366
|
-
return resultResponse(id, {
|
|
367
|
-
content: [{ type: 'text', text: renderFrameworkError(framework, audience) }],
|
|
368
|
-
isError: true,
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
auditToolCall({ tool: name, outcome: 'failed', caller });
|
|
372
|
-
return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
|
|
360
|
+
return thrownResponse(id, name, error, caller, audience);
|
|
373
361
|
}
|
|
374
362
|
|
|
375
363
|
// A tool may answer `isError` itself (admin renders its own denial). Outcome 3 unless it
|
|
@@ -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
|
+
}
|