@ultimat3/mcp 22.10.0 → 22.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "22.10.0",
3
+ "version": "22.12.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.10.0",
35
- "@ultimat3/core": "22.10.0",
36
- "@ultimat3/entity": "22.10.0",
37
- "@ultimat3/http": "22.10.0",
38
- "@ultimat3/jobs": "22.10.0",
39
- "@ultimat3/policy": "22.10.0",
40
- "@ultimat3/query": "22.10.0",
41
- "@ultimat3/schema": "22.10.0"
34
+ "@ultimat3/action": "22.12.0",
35
+ "@ultimat3/core": "22.12.0",
36
+ "@ultimat3/entity": "22.12.0",
37
+ "@ultimat3/http": "22.12.0",
38
+ "@ultimat3/jobs": "22.12.0",
39
+ "@ultimat3/policy": "22.12.0",
40
+ "@ultimat3/query": "22.12.0",
41
+ "@ultimat3/schema": "22.12.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 AUTHORITATIVE parse, in the same slot `invoke` puts it: before the policy, before
113
- // the handler. A projected action re-parses inside `invoke`; a hand-written tool has no
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
@@ -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) resolved = { kind: 'invalid-args', name: action, issues: listed.issues };
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
  }
@@ -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 { actionName, invoke, isAction } from '@ultimat3/action';
8
- import { isMcpExposed } from '@ultimat3/core';
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 { isQuery, queryName, sourceFor } from '@ultimat3/query';
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: queryName(target),
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
- | { readonly kind: 'invalid-args'; readonly name: string; readonly issues: readonly ArgIssue[] };
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, outcomeForCode, outcomeForResult } from './audit';
8
+ import { auditResourceRead, auditToolCall, outcomeForResult } from './audit';
9
9
  import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
10
- import { asFrameworkError, renderFrameworkError } from './framework-error';
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
- // OUTCOME 3 arrives here: the tool ran its policy through `guard()` and the policy
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
+ }