@ultimat3/mcp 22.6.0 → 22.6.2

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/README.md CHANGED
@@ -287,6 +287,24 @@ implements the emitted subset (objects, arrays, enums, `required`, `additionalPr
287
287
  bounds, `default`) and applies declared defaults. Actions still re-parse authoritatively
288
288
  inside their own handler.
289
289
 
290
+ ## Idempotency over MCP
291
+
292
+ An `idempotent: true` action's tool carries one reserved, optional argument,
293
+ `MCP_IDEMPOTENCY_KEY_ARG` (`'idempotencyKey'`, string, 1–255), advertised in `tools/list` and held
294
+ to that schema here. `tools/call` takes it out of the arguments and hands it to `invoke` as the
295
+ idempotency key — what the `Idempotency-Key` header is over HTTP, filed under action, caller and
296
+ key — so an agent retrying a timed-out call with the same key gets the first result back:
297
+
298
+ ```json
299
+ { "method": "tools/call", "params": { "name": "sendNotification",
300
+ "arguments": { "caseId": "c1", "idempotencyKey": "7f0c…" } } }
301
+ ```
302
+
303
+ The action's own input never sees the key. A non-idempotent tool does not advertise it and refuses
304
+ it (`X_MCP_ARGS_INVALID`). An argument, not `params._meta`: `_meta` is for host metadata and a
305
+ model-driven client cannot set it. An idempotent action whose input already names `idempotencyKey`
306
+ is `X_MCP_IDEMPOTENCY_KEY_SHADOWED` at boot.
307
+
290
308
  ## Errors
291
309
 
292
310
  | Code | Meaning |
@@ -296,6 +314,7 @@ inside their own handler.
296
314
  | `X_MCP_SCOPE_UNKNOWN` | `defineAppMcp`'s `scopes:` names a tool this server does not project |
297
315
  | `X_MCP_SCOPE_CONFLICT` | two scopes in `defineAppMcp`'s `scopes:` claim one tool |
298
316
  | `X_MCP_ARGS_INVALID` | arguments failed the declared schema |
317
+ | `X_MCP_IDEMPOTENCY_KEY_SHADOWED` | an idempotent action's input declares `idempotencyKey`, the reserved retry-key argument |
299
318
  | `X_MCP_PROTOCOL` | malformed envelope, unknown method, bad auth header |
300
319
  | `X_MCP_QUERY_REJECTED` | `db.query` given anything but one read-only statement |
301
320
  | `X_MCP_NOT_BRANCH_DB` | `db.migrate` aimed at a production or otherwise non-branch database |
@@ -315,6 +334,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
315
334
  | `McpAppUnmountedError` | `X_MCP_APP_UNMOUNTED` | `src/errors.ts` |
316
335
  | `McpArgsInvalidError` | `X_MCP_ARGS_INVALID` | `src/errors.ts` |
317
336
  | `McpBodyTooLargeError` | `X_MCP_BODY_TOO_LARGE` | `src/errors.ts` |
337
+ | `McpIdempotencyKeyShadowedError` | `X_MCP_IDEMPOTENCY_KEY_SHADOWED` | `src/errors.ts` |
318
338
  | `McpGroupConflictError` | `X_MCP_GROUP_CONFLICT` | `src/meta-errors.ts` |
319
339
  | `McpGroupUnknownError` | `X_MCP_GROUP_UNKNOWN` | `src/meta-errors.ts` |
320
340
  | `McpListParamsInvalidError` | `X_MCP_LIST_PARAMS_INVALID` | `src/meta-errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "22.6.0",
3
+ "version": "22.6.2",
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.6.0",
35
- "@ultimat3/core": "22.6.0",
36
- "@ultimat3/entity": "22.6.0",
37
- "@ultimat3/http": "22.6.0",
38
- "@ultimat3/jobs": "22.6.0",
39
- "@ultimat3/policy": "22.6.0",
40
- "@ultimat3/query": "22.6.0",
41
- "@ultimat3/schema": "22.6.0"
34
+ "@ultimat3/action": "22.6.2",
35
+ "@ultimat3/core": "22.6.2",
36
+ "@ultimat3/entity": "22.6.2",
37
+ "@ultimat3/http": "22.6.2",
38
+ "@ultimat3/jobs": "22.6.2",
39
+ "@ultimat3/policy": "22.6.2",
40
+ "@ultimat3/query": "22.6.2",
41
+ "@ultimat3/schema": "22.6.2"
42
42
  }
43
43
  }
package/src/errors.ts CHANGED
@@ -24,6 +24,7 @@ export const MCP_ERROR_CODES = [
24
24
  'X_MCP_SURFACE_INVALID',
25
25
  'X_MCP_LIST_PARAMS_INVALID',
26
26
  'X_MCP_OAUTH_INVALID',
27
+ 'X_MCP_IDEMPOTENCY_KEY_SHADOWED',
27
28
  ] as const;
28
29
 
29
30
  export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
@@ -49,6 +50,8 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
49
50
  X_MCP_SURFACE_INVALID: 'the MCP meta surface is declared inconsistently',
50
51
  X_MCP_LIST_PARAMS_INVALID: "a tool's listParams names a key its own input does not accept",
51
52
  X_MCP_OAUTH_INVALID: 'the MCP oauth block cannot be published as protected-resource metadata',
53
+ X_MCP_IDEMPOTENCY_KEY_SHADOWED:
54
+ "an idempotent action's input declares idempotencyKey, the MCP argument reserved for the idempotency key",
52
55
  };
53
56
 
54
57
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -430,3 +433,19 @@ export class McpOAuthInvalidError extends UltimateError {
430
433
  });
431
434
  }
432
435
  }
436
+
437
+ /**
438
+ * An idempotent action's tool carries the reserved `idempotencyKey` argument, and this action's
439
+ * own input already names a field that. One argument cannot be both the input a handler reads and
440
+ * the key a retry replays under, so the projection refuses at boot instead of guessing which.
441
+ */
442
+ export class McpIdempotencyKeyShadowedError extends UltimateError {
443
+ constructor(input: { readonly name: string; readonly argument: string }) {
444
+ super({
445
+ code: 'X_MCP_IDEMPOTENCY_KEY_SHADOWED',
446
+ cause: `${input.name} is idempotent and its input declares "${input.argument}", the MCP argument reserved for the idempotency key`,
447
+ fix: `rename ${input.name}'s "${input.argument}" input field — MCP clients send the Idempotency-Key as the reserved "${input.argument}" argument, and the action receives the rest`,
448
+ meta: { action: input.name, argument: input.argument },
449
+ });
450
+ }
451
+ }
@@ -0,0 +1,73 @@
1
+ // The MCP half of `idempotent: true`: the Idempotency-Key an HTTP caller sends as a header, sent
2
+ // by an MCP caller as one reserved, optional tool ARGUMENT. An argument and not `params._meta`:
3
+ // `_meta` is protocol metadata a host fills (a progress token), and no model-driven client lets the
4
+ // model set it — the retry that needs a key is the agent's own, so the key goes where the agent
5
+ // writes. It is advertised in `tools/list`, enforced by `validate-args.ts` like every other field,
6
+ // and taken back out before `invoke`, so the action's own input parse never sees it.
7
+
8
+ import { MAX_IDEMPOTENCY_KEY_LENGTH } from '@ultimat3/action';
9
+ import { McpIdempotencyKeyShadowedError } from './errors';
10
+ import type { JsonSchema } from './wire';
11
+
12
+ /** The reserved argument name on every idempotent action's tool. */
13
+ export const MCP_IDEMPOTENCY_KEY_ARG = 'idempotencyKey';
14
+
15
+ /**
16
+ * Contract text, not UI text — it is what `tools/list` publishes, the same bytes for every caller,
17
+ * like an action's `mcp.description`.
18
+ */
19
+ const DESCRIPTION =
20
+ 'Idempotency key. Retrying this call with the same key and the same arguments returns the ' +
21
+ 'first result instead of running it again; the same key with different arguments is refused. ' +
22
+ 'Any unique string, e.g. a UUID — reuse it only to retry.';
23
+
24
+ const KEY_SCHEMA: JsonSchema = {
25
+ type: 'string',
26
+ description: DESCRIPTION,
27
+ minLength: 1,
28
+ maxLength: MAX_IDEMPOTENCY_KEY_LENGTH,
29
+ };
30
+
31
+ /**
32
+ * `schema` with the optional key argument. An input that is not an object has no argument to
33
+ * add, and is left alone; one that already names the argument is refused, never merged.
34
+ */
35
+ export function withIdempotencyKeyArg(name: string, schema: JsonSchema): JsonSchema {
36
+ if (schema.type !== 'object') return schema;
37
+ const properties = schema.properties ?? {};
38
+ if (Object.hasOwn(properties, MCP_IDEMPOTENCY_KEY_ARG)) {
39
+ throw new McpIdempotencyKeyShadowedError({ name, argument: MCP_IDEMPOTENCY_KEY_ARG });
40
+ }
41
+ return { ...schema, properties: { ...properties, [MCP_IDEMPOTENCY_KEY_ARG]: KEY_SCHEMA } };
42
+ }
43
+
44
+ /**
45
+ * The call's arguments split into the action's input and the key. Absent, empty-handed or not an
46
+ * object: the input as given and no key — `invoke` then runs the action un-keyed, as HTTP does
47
+ * without the header.
48
+ */
49
+ export function takeIdempotencyKeyArg(args: unknown): {
50
+ readonly input: unknown;
51
+ readonly idempotencyKey: string | null;
52
+ } {
53
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) {
54
+ return { input: args, idempotencyKey: null };
55
+ }
56
+ if (!Object.hasOwn(args, MCP_IDEMPOTENCY_KEY_ARG)) return { input: args, idempotencyKey: null };
57
+ const input: Record<string, unknown> = {};
58
+ let key: unknown;
59
+ for (const [field, value] of Object.entries(args)) {
60
+ if (field === MCP_IDEMPOTENCY_KEY_ARG) key = value;
61
+ // defineProperty, never `input[field] =`: a `__proto__` field would re-prototype the copy.
62
+ else
63
+ Object.defineProperty(input, field, {
64
+ value,
65
+ writable: true,
66
+ enumerable: true,
67
+ configurable: true,
68
+ });
69
+ }
70
+ // `validate-args.ts` already held the value to the advertised string schema, so a non-string can
71
+ // only come from a caller that skipped it — and a value that is not a string is not a key.
72
+ return { input, idempotencyKey: typeof key === 'string' ? key : null };
73
+ }
package/src/index.ts CHANGED
@@ -63,6 +63,7 @@ export {
63
63
  McpAppUnmountedError,
64
64
  McpArgsInvalidError,
65
65
  McpBodyTooLargeError,
66
+ McpIdempotencyKeyShadowedError,
66
67
  McpNotBranchDbError,
67
68
  McpOAuthInvalidError,
68
69
  McpProtocolError,
@@ -86,6 +87,7 @@ export {
86
87
  toolsFrom,
87
88
  toolsListed,
88
89
  } from './from-action';
90
+ export { MCP_IDEMPOTENCY_KEY_ARG } from './idempotency-arg';
89
91
  export { toWireSchema } from './input-schema';
90
92
  export type { ListFilterOp, McpListParams } from './list-params';
91
93
  export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
@@ -10,6 +10,7 @@ import type { AnyQuery } from '@ultimat3/query';
10
10
  import { isQuery, queryName, sourceFor } from '@ultimat3/query';
11
11
  import { asCallerContext } from './caller-context';
12
12
  import type { McpExposure, ProjectablePrimitive } from './from-action';
13
+ import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
13
14
  import { toWireSchema } from './input-schema';
14
15
  import type { McpListParams } from './list-params';
15
16
 
@@ -40,16 +41,26 @@ export function asProjectable(listed: ListedPrimitive): ProjectablePrimitive {
40
41
 
41
42
  export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
42
43
  const exposure = exposureOf(target.mcp);
44
+ // Throws `X_ACTION_UNREGISTERED` on an unnamed action rather than projecting a tool called
45
+ // `''`: a nameless tool is unaddressable by the scope map, by `tools/call`, and by the author.
46
+ const name = actionName(target);
47
+ const wire = toWireSchema(target.input);
48
+ // `idempotent: true` is honoured on this surface exactly as over HTTP: the caller's key reaches
49
+ // `invoke`, which files it under the action, the caller and the key (`idempotencyKeyFor`).
50
+ const keyed = target.describe().idempotent;
43
51
  return {
44
- // Throws `X_ACTION_UNREGISTERED` on an unnamed action rather than projecting a tool called
45
- // `''`: a nameless tool is unaddressable by the scope map, by `tools/call`, and by the author.
46
- name: actionName(target),
52
+ name,
47
53
  ...(exposure === undefined ? {} : { mcp: exposure }),
48
54
  ...(exposure?.description === undefined ? {} : { description: exposure.description }),
49
- inputJsonSchema: toWireSchema(target.input),
55
+ inputJsonSchema: keyed ? withIdempotencyKeyArg(name, wire) : wire,
50
56
  mutates: true,
51
57
  // The actor rides in on the options: `invoke` swaps it inside the one execution path.
52
- run: ({ input, actor }) => invoke(target, input, { surface: 'mcp', actor }),
58
+ run: keyed
59
+ ? ({ input: args, actor }) => {
60
+ const { input, idempotencyKey } = takeIdempotencyKeyArg(args);
61
+ return invoke(target, input, { surface: 'mcp', actor, idempotencyKey });
62
+ }
63
+ : ({ input, actor }) => invoke(target, input, { surface: 'mcp', actor }),
53
64
  };
54
65
  }
55
66