@ultimat3/action 1.0.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/src/http.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Projections 1 and 2: an action becomes `POST /api/<resource>/<verb>` plus the
3
+ * OpenAPI operation describing it. Policy enforcement, input validation,
4
+ * idempotency and cache invalidation are wired here and are not optional —
5
+ * there is no way to mount an action without them.
6
+ */
7
+
8
+ import { isUltimateError } from '@ultimat3/core';
9
+ import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
10
+ import { json, problem } from '@ultimat3/http';
11
+ import type { ActionRateLimit, AnyAction } from './action';
12
+ import { actionName, defOf, invoke } from './invoke';
13
+ import {
14
+ derivePath,
15
+ inputSchemaName,
16
+ outputSchemaName,
17
+ PROBLEM_SCHEMA_NAME,
18
+ schemaRef,
19
+ toOperationId,
20
+ toToolName,
21
+ } from './naming';
22
+ import { policyCapability } from './policy-gate';
23
+ import { tagKeys } from './tags';
24
+
25
+ /** Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.buildId`. */
26
+ export const BUILD_ID_HEADER = 'x-ultimate-build';
27
+ export const IDEMPOTENCY_HEADER = 'idempotency-key';
28
+ export const REPLAYED_HEADER = 'x-ultimate-replayed';
29
+
30
+ /**
31
+ * `publishPost` -> `POST /api/posts/publish`. Derivation: the first camelCase word
32
+ * is the verb, the rest is the resource with its last word pluralized and
33
+ * kebab-cased (`updateUserProfile` -> `/api/user-profiles/update`). See `naming.ts`.
34
+ */
35
+ export function toRoute(target: AnyAction): Route {
36
+ const name = actionName(target);
37
+ const { path, resource } = derivePath(name);
38
+ const def = defOf(target);
39
+
40
+ const handler = async (req: UltimateRequest): Promise<Response> => {
41
+ try {
42
+ // The pipeline already parsed and size-capped the body; parsing it again here
43
+ // would be a second, differently-behaved parser for the same bytes.
44
+ const raw = await req.bodyRaw();
45
+ const key = def.idempotent === true ? req.header(IDEMPOTENCY_HEADER) : null;
46
+ let replayed = false;
47
+ const result = await invoke(target, raw, {
48
+ surface: 'http',
49
+ idempotencyKey: key,
50
+ onReplay: () => {
51
+ replayed = true;
52
+ },
53
+ });
54
+ const response = json(result);
55
+ if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
56
+ return response;
57
+ } catch (error) {
58
+ // Framework errors carry their own code, status and fix line; anything else is
59
+ // a bug and belongs to the server's error boundary, not to this route.
60
+ if (isUltimateError(error)) return problem(error);
61
+ throw error;
62
+ }
63
+ };
64
+
65
+ const meta: RouteMeta = {
66
+ name,
67
+ // `allow(...)` is the only way an action is public, and saying so explicitly is
68
+ // what keeps "forgot the policy" from ever looking like "meant to be public".
69
+ auth: def.policy.kind === 'allow' ? 'public' : 'required',
70
+ policy: policyCapability(def.policy),
71
+ // Named so the pipeline's authz stage stands down: `invoke` is this route's one
72
+ // evaluation, and it is the only one that has run `row` by the time it decides. A
73
+ // stage deciding first would decide from `row: null` — a denial for the row's own
74
+ // author, from an authz system that never saw the row.
75
+ enforcedBy: 'handler',
76
+ input: def.input,
77
+ cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
78
+ tags: [resource],
79
+ ...(def.rateLimit === undefined ? {} : { rateLimit: name }),
80
+ ...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
81
+ };
82
+
83
+ return { method: 'POST', path, handler, meta };
84
+ }
85
+
86
+ export interface OpenApiOperation {
87
+ readonly operationId: string;
88
+ readonly tags: readonly string[];
89
+ readonly summary: string;
90
+ readonly parameters: readonly Record<string, unknown>[];
91
+ readonly requestBody: Record<string, unknown>;
92
+ readonly responses: Record<string, unknown>;
93
+ readonly 'x-ultimate': Record<string, unknown>;
94
+ }
95
+
96
+ /** The operation object for this action. `openapi.ts` assembles them into a document. */
97
+ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
98
+ const name = actionName(target);
99
+ const def = defOf(target);
100
+ const path = derivePath(name);
101
+ const idempotent = def.idempotent === true;
102
+ return {
103
+ operationId: toOperationId(name),
104
+ tags: [path.resource],
105
+ summary: def.mcp?.description ?? name,
106
+ parameters: idempotent ? [IDEMPOTENCY_PARAMETER] : [],
107
+ requestBody: {
108
+ required: true,
109
+ content: { 'application/json': { schema: { $ref: schemaRef(inputSchemaName(name)) } } },
110
+ },
111
+ responses: {
112
+ '200': {
113
+ description: 'ok',
114
+ content: { 'application/json': { schema: { $ref: schemaRef(outputSchemaName(name)) } } },
115
+ },
116
+ '400': problemResponse('X_INPUT_INVALID'),
117
+ '403': problemResponse('policy denied'),
118
+ ...(idempotent ? { '409': problemResponse('X_IDEMPOTENCY_CONFLICT') } : {}),
119
+ },
120
+ 'x-ultimate': {
121
+ capability: policyCapability(def.policy),
122
+ idempotent,
123
+ invalidates: tagKeys(def.cache?.invalidates ?? []),
124
+ mcpTool: def.mcp?.expose === false ? null : toToolName(name),
125
+ rateLimit: rateLimitMeta(def.rateLimit),
126
+ },
127
+ };
128
+ }
129
+
130
+ function rateLimitMeta(limit: ActionRateLimit | undefined): Record<string, number> | null {
131
+ return limit === undefined ? null : { limit: limit.limit, windowMs: limit.windowMs };
132
+ }
133
+
134
+ const IDEMPOTENCY_PARAMETER: Record<string, unknown> = {
135
+ name: 'Idempotency-Key',
136
+ in: 'header',
137
+ required: false,
138
+ schema: { type: 'string', maxLength: 255 },
139
+ description: 'Replays the first response for a repeated key.',
140
+ };
141
+
142
+ function problemResponse(description: string): Record<string, unknown> {
143
+ return {
144
+ description,
145
+ content: {
146
+ 'application/problem+json': { schema: { $ref: schemaRef(PROBLEM_SCHEMA_NAME) } },
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Idempotency for actions marked `idempotent`. A retried key replays the first
3
+ * response; a concurrent duplicate is refused rather than run twice, because a
4
+ * double charge is worse than a 409.
5
+ */
6
+ import { uuid } from '@ultimat3/core';
7
+ import { IdempotencyConflictError } from './errors';
8
+ import { fingerprint } from './stable';
9
+
10
+ export interface IdempotencyRecord {
11
+ readonly id: string;
12
+ readonly key: string;
13
+ /** Fingerprint of the parsed input — a reused key with a new payload is a bug. */
14
+ readonly requestHash: string;
15
+ readonly status: 'in-flight' | 'settled';
16
+ readonly value: unknown;
17
+ readonly createdAt: number;
18
+ }
19
+
20
+ export interface IdempotencyReservation {
21
+ readonly record: IdempotencyRecord;
22
+ /** True only for the caller that won the race and must therefore run the handler. */
23
+ readonly created: boolean;
24
+ }
25
+
26
+ export interface IdempotencyStore {
27
+ /** Atomically create-or-fetch the record for `key`. The atomicity is the point. */
28
+ reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
29
+ settle(key: string, value: unknown): Promise<void>;
30
+ /** Drop a reservation whose handler threw, so a retry can run. */
31
+ release(key: string): Promise<void>;
32
+ get(key: string): Promise<IdempotencyRecord | undefined>;
33
+ }
34
+
35
+ /**
36
+ * Default store: process memory. Correct for one web process and for tests;
37
+ * production swaps in a Postgres-backed store behind the same interface (a
38
+ * single `insert ... on conflict do nothing returning` gives the same atomicity).
39
+ */
40
+ export class MemoryIdempotencyStore implements IdempotencyStore {
41
+ readonly #records = new Map<string, IdempotencyRecord>();
42
+
43
+ async reserve(key: string, requestHash: string): Promise<IdempotencyReservation> {
44
+ const existing = this.#records.get(key);
45
+ if (existing !== undefined) return { record: existing, created: false };
46
+ const record: IdempotencyRecord = {
47
+ id: uuid(),
48
+ key,
49
+ requestHash,
50
+ status: 'in-flight',
51
+ value: undefined,
52
+ createdAt: Date.now(),
53
+ };
54
+ this.#records.set(key, record);
55
+ return { record, created: true };
56
+ }
57
+
58
+ async settle(key: string, value: unknown): Promise<void> {
59
+ const existing = this.#records.get(key);
60
+ if (existing === undefined) return;
61
+ this.#records.set(key, { ...existing, status: 'settled', value });
62
+ }
63
+
64
+ async release(key: string): Promise<void> {
65
+ this.#records.delete(key);
66
+ }
67
+
68
+ async get(key: string): Promise<IdempotencyRecord | undefined> {
69
+ return this.#records.get(key);
70
+ }
71
+ }
72
+
73
+ let defaultStore: IdempotencyStore = new MemoryIdempotencyStore();
74
+
75
+ export function setIdempotencyStore(store: IdempotencyStore): void {
76
+ defaultStore = store;
77
+ }
78
+
79
+ export function getIdempotencyStore(): IdempotencyStore {
80
+ return defaultStore;
81
+ }
82
+
83
+ /** Keys are namespaced per action: the same key under two actions is two keys. */
84
+ export function idempotencyKeyFor(actionName: string, key: string): string {
85
+ return `${actionName}:${key}`;
86
+ }
87
+
88
+ export interface IdempotentOutcome<T> {
89
+ readonly value: T;
90
+ readonly replayed: boolean;
91
+ }
92
+
93
+ /**
94
+ * Replay-or-run. Four outcomes: fresh run, replay of a settled record,
95
+ * X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a
96
+ * duplicate that is still in flight.
97
+ */
98
+ export async function withIdempotency<T>(
99
+ store: IdempotencyStore,
100
+ key: string,
101
+ input: unknown,
102
+ run: () => Promise<T>,
103
+ ): Promise<IdempotentOutcome<T>> {
104
+ const requestHash = fingerprint(input);
105
+ const { record, created } = await store.reserve(key, requestHash);
106
+ if (record.requestHash !== requestHash) {
107
+ throw new IdempotencyConflictError(key, 'payload-mismatch');
108
+ }
109
+ if (!created) {
110
+ if (record.status === 'in-flight') throw new IdempotencyConflictError(key, 'in-flight');
111
+ // The stored value is the previous return of this very handler.
112
+ return { value: record.value as T, replayed: true };
113
+ }
114
+ try {
115
+ const value = await run();
116
+ await store.settle(key, value);
117
+ return { value, replayed: false };
118
+ } catch (error) {
119
+ await store.release(key);
120
+ throw error;
121
+ }
122
+ }
package/src/index.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Public API of @ultimat3/action: the primitive plus its six projections.
3
+ *
4
+ * `handle` is deliberately absent. An action's declaration lives in `invoke.ts`'s
5
+ * private store, and `invoke` is the only thing that reads it — so no adapter can
6
+ * parse, authorize or run on its own. Two authz systems is how every Meteor-like
7
+ * framework died; there is exactly one here, structurally.
8
+ */
9
+
10
+ /** Re-exported so an `action` file needs one import, not two. Same object as schema's. */
11
+ export type { Infer } from '@ultimat3/schema';
12
+ export { t } from '@ultimat3/schema';
13
+ export type {
14
+ Action,
15
+ ActionCache,
16
+ ActionDef,
17
+ ActionDescriptor,
18
+ ActionFacade,
19
+ ActionHandlerArgs,
20
+ ActionMcp,
21
+ ActionRateLimit,
22
+ ActionRowArgs,
23
+ AnyAction,
24
+ InvokeOptions,
25
+ McpDescriptorMeta,
26
+ } from './action';
27
+ export { action, describeAction, isAction } from './action';
28
+ export type {
29
+ ActionLike,
30
+ ActionMap,
31
+ CallOptions,
32
+ Client,
33
+ ClientMethod,
34
+ ClientOptions,
35
+ FetchLike,
36
+ } from './client';
37
+ export { rpc } from './client';
38
+ export type { ContractTest, ContractTestOptions } from './contract-test';
39
+ export { anonymousCtx, contractTestsFor, policyTestStubFor } from './contract-test';
40
+ export type { Api, ApiDef, ApiModule, ApiModules } from './define-api';
41
+ export { defineApi } from './define-api';
42
+ export type { IdempotencyConflictReason } from './errors';
43
+ export {
44
+ ActionDeniedError,
45
+ ActionDuplicateError,
46
+ ActionForeignError,
47
+ ActionPolicyMissingError,
48
+ ActionUnregisteredError,
49
+ ContractDriftError,
50
+ IdempotencyConflictError,
51
+ InputInvalidError,
52
+ OutputInvalidError,
53
+ RpcFailedError,
54
+ } from './errors';
55
+ export type { OpenApiOperation } from './http';
56
+ export {
57
+ BUILD_ID_HEADER,
58
+ IDEMPOTENCY_HEADER,
59
+ REPLAYED_HEADER,
60
+ toOpenApiOperation,
61
+ toRoute,
62
+ } from './http';
63
+ export type {
64
+ IdempotencyRecord,
65
+ IdempotencyReservation,
66
+ IdempotencyStore,
67
+ IdempotentOutcome,
68
+ } from './idempotency';
69
+ export {
70
+ getIdempotencyStore,
71
+ idempotencyKeyFor,
72
+ MemoryIdempotencyStore,
73
+ setIdempotencyStore,
74
+ withIdempotency,
75
+ } from './idempotency';
76
+ /** The one execution path. `defOf` stays unexported — that is the enforcement. */
77
+ export { actionName, invoke } from './invoke';
78
+ export type { ActionJobHandle } from './job-handle';
79
+ export { toJobHandle } from './job-handle';
80
+ export type { JsonSchemaObject } from './json-schema';
81
+ export { jsonSchemaOf, mcpSchemaOf } from './json-schema';
82
+ export type { McpInvokeOptions, McpToolDescriptor } from './mcp-tool';
83
+ export { isExposed, toMcpTool, toMcpTools } from './mcp-tool';
84
+ export type {
85
+ Conflict,
86
+ CustomConflict,
87
+ LocalRow,
88
+ LocalTable,
89
+ LocalTableName,
90
+ LocalTables,
91
+ LocalTx,
92
+ Mutator,
93
+ MutatorDef,
94
+ MutatorDescriptor,
95
+ } from './mutator';
96
+ export { custom, isMutator, mutator, resolveConflict, strategyOf } from './mutator';
97
+ export type { ActionPath } from './naming';
98
+ export { derivePath, inputSchemaName, outputSchemaName, pluralize, toToolName } from './naming';
99
+ export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
100
+ export { buildOpenApi, serializeOpenApi } from './openapi';
101
+ export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
102
+ export { actorOf, guard, policyCapability } from './policy-gate';
103
+ export {
104
+ describeActions,
105
+ getAction,
106
+ listActions,
107
+ registerAction,
108
+ registerActions,
109
+ resetRegistry,
110
+ } from './registry';
package/src/invoke.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The one invocation core: parse input, evaluate policy, run the handler, parse
3
+ * output. The declaration lives in this module's private store, so `handle` is
4
+ * unreachable from anywhere else — HTTP, MCP, jobs and `.as()` hand `invoke` a
5
+ * payload, and none of them can become a second execution path.
6
+ */
7
+
8
+ import { invalidateTags } from '@ultimat3/cache';
9
+ import type { Ctx } from '@ultimat3/core';
10
+ import {
11
+ anonymousActor,
12
+ createContext,
13
+ runWithContext,
14
+ tryUseContext,
15
+ useContext,
16
+ withChildContext,
17
+ withSpan,
18
+ } from '@ultimat3/core';
19
+ import type { AnyAction, AnyActionDef, InvokeOptions } from './action';
20
+ import { ActionForeignError, ActionUnregisteredError } from './errors';
21
+ import { getIdempotencyStore, idempotencyKeyFor, withIdempotency } from './idempotency';
22
+ import { actorOf, guard } from './policy-gate';
23
+ import { validateInput, validateOutput } from './validate';
24
+
25
+ /**
26
+ * Private on purpose. `@ultimat3/action` exports no way to read this back, which
27
+ * is what makes "the only way to reach `handle` is `invoke`" structural rather
28
+ * than a rule someone has to remember.
29
+ */
30
+ const DECLARATIONS = new WeakMap<object, AnyActionDef>();
31
+
32
+ /** Called once per built action, by `action()` and by every rename it produces. */
33
+ export function stashDef(target: object, def: AnyActionDef): void {
34
+ DECLARATIONS.set(target, def);
35
+ }
36
+
37
+ /** True only for objects this package built — `isAction` leans on it. */
38
+ export function hasDef(target: object): boolean {
39
+ return DECLARATIONS.has(target);
40
+ }
41
+
42
+ /** Internal read of the declaration. Never re-exported from `src/index.ts`. */
43
+ export function defOf(target: AnyAction): AnyActionDef {
44
+ const def = DECLARATIONS.get(target);
45
+ if (def === undefined) throw new ActionForeignError(target.name);
46
+ return def;
47
+ }
48
+
49
+ /** Projections need a stable name; an unregistered action has none yet. */
50
+ export function actionName(target: AnyAction): string {
51
+ if (target.name.length === 0) throw new ActionUnregisteredError();
52
+ return target.name;
53
+ }
54
+
55
+ /**
56
+ * Run an action. Surfaces differ only in the `surface` they pass, which selects
57
+ * how a denial is rendered — never whether authz runs, never how input is parsed,
58
+ * never whether the handler's return value is checked against `output`.
59
+ */
60
+ export function invoke(
61
+ target: AnyAction,
62
+ raw: unknown,
63
+ options: InvokeOptions = {},
64
+ ): Promise<unknown> {
65
+ if (options.actor === undefined) return core(target, raw, options.ctx ?? useContext(), options);
66
+
67
+ // Impersonation keeps the surrounding context whole — services, clock, locale,
68
+ // trace — and swaps only the actor. Policy models "nobody" as null; core models
69
+ // it as the anonymous actor.
70
+ const patch = { actor: options.actor ?? anonymousActor() };
71
+ const run = (): Promise<unknown> => core(target, raw, useContext(), options);
72
+ const base = options.ctx ?? tryUseContext();
73
+ return base === undefined
74
+ ? runWithContext(createContext(patch), run)
75
+ : runWithContext(base, () => withChildContext(patch, run));
76
+ }
77
+
78
+ async function core(
79
+ target: AnyAction,
80
+ raw: unknown,
81
+ ctx: Ctx,
82
+ options: InvokeOptions,
83
+ ): Promise<unknown> {
84
+ const def = defOf(target);
85
+ const name = actionName(target);
86
+ const input = await validateInput(def.input, raw, name);
87
+ // The one place a row-level rule gets its row. Once per invocation, never per row:
88
+ // that asymmetry is what lets the predicate stay synchronous, so a live query can
89
+ // re-evaluate the same policy per subscriber without a query per change event. An
90
+ // action with no loader hands the rule `null` — unchanged, and never a silent allow,
91
+ // because a rule that reads `row` has to decide what `null` means.
92
+ const row = def.row === undefined ? null : ((await def.row({ input, ctx })) ?? null);
93
+ guard(
94
+ def.policy,
95
+ { actor: actorOf(ctx), input, row, ctx, action: name },
96
+ options.surface ?? 'server',
97
+ );
98
+
99
+ // Output parsing sits inside `run` so a replayed idempotent response is the
100
+ // parsed value too — one shape on the wire, first call and every retry.
101
+ const run = async (): Promise<unknown> => {
102
+ const produced = await withSpan(`action.${name}`, () =>
103
+ Promise.resolve(def.handle({ input, ctx })),
104
+ );
105
+ return validateOutput(def.output, produced, name);
106
+ };
107
+
108
+ const key = def.idempotent === true ? (options.idempotencyKey ?? null) : null;
109
+ let value: unknown;
110
+ if (key === null) {
111
+ value = await run();
112
+ } else {
113
+ const store = options.store ?? getIdempotencyStore();
114
+ const outcome = await withIdempotency(store, idempotencyKeyFor(name, key), input, run);
115
+ if (outcome.replayed) options.onReplay?.();
116
+ value = outcome.value;
117
+ }
118
+ if (def.cache !== undefined) await invalidateTags(def.cache.invalidates);
119
+ return value;
120
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Projection 5: an action as durable work. `@ultimat3/jobs` consumes this shape,
3
+ * so enqueueing an existing action costs zero rewriting — and the queued run
4
+ * goes through the same validation and policy evaluation as the HTTP call.
5
+ */
6
+ import type { Ctx } from '@ultimat3/core';
7
+ import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
8
+ import type { Action } from './action';
9
+ import { actionName, invoke } from './invoke';
10
+ import { fingerprint } from './stable';
11
+
12
+ export interface ActionJobHandle<
13
+ TInput extends StandardSchemaV1 = StandardSchemaV1,
14
+ TOutput extends StandardSchemaV1 = StandardSchemaV1,
15
+ > {
16
+ readonly kind: 'action-job';
17
+ /** Namespaced so an action-backed job never collides with a hand-written job. */
18
+ readonly name: string;
19
+ readonly input: TInput;
20
+ /** Required by the job type: derived from the payload, stable across retries. */
21
+ idempotencyKey(input: InferInput<TInput>): string;
22
+ invoke(input: InferInput<TInput>, ctx: Ctx): Promise<InferOutput<TOutput>>;
23
+ }
24
+
25
+ export function toJobHandle<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
26
+ target: Action<TInput, TOutput>,
27
+ ): ActionJobHandle<TInput, TOutput> {
28
+ const name = actionName(target);
29
+ return {
30
+ kind: 'action-job',
31
+ name: `action:${name}`,
32
+ input: target.input,
33
+ idempotencyKey: (input) => `action:${name}:${fingerprint(input)}`,
34
+ // Schema-erased at the seam; the output type is this action's by construction.
35
+ invoke: (input, ctx) =>
36
+ invoke(target, input, { surface: 'job', ctx }) as Promise<InferOutput<TOutput>>,
37
+ };
38
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Standard Schema -> JSON Schema, normalized to a plain record. OpenAPI, MCP
3
+ * descriptors and the manifest all need the same object, produced the same way.
4
+ */
5
+
6
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
7
+ import { toJsonSchema, toMcpInputSchema } from '@ultimat3/schema';
8
+ import { isJsonObject, stableStringify } from './stable';
9
+
10
+ export type JsonSchemaObject = Record<string, unknown>;
11
+
12
+ /**
13
+ * Never throws: a schema that cannot be converted degrades to a permissive
14
+ * object node, because a missing OpenAPI detail must not break a deploy.
15
+ */
16
+ export function jsonSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
17
+ return normalize(() => toJsonSchema(schema));
18
+ }
19
+
20
+ /** Draft-07, no `$schema` — the exact shape an MCP `tools/list` entry needs. */
21
+ export function mcpSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
22
+ return normalize(() => toMcpInputSchema(schema));
23
+ }
24
+
25
+ function normalize(convert: () => unknown): JsonSchemaObject {
26
+ try {
27
+ const raw: unknown = convert();
28
+ if (isJsonObject(raw)) return raw;
29
+ } catch {
30
+ // fall through to the permissive node
31
+ }
32
+ return { type: 'object', additionalProperties: true };
33
+ }
34
+
35
+ /** Key-sorted copy — deterministic ordering for the committed contract file. */
36
+ export function sortSchema(schema: JsonSchemaObject): JsonSchemaObject {
37
+ const parsed: unknown = JSON.parse(stableStringify(schema));
38
+ return isJsonObject(parsed) ? parsed : {};
39
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Projection 4: an action as an MCP tool. Its `invoke` is the package's `invoke`
3
+ * — the same core the HTTP route calls — so the tool cannot drift from the
4
+ * endpoint and cannot acquire a second authz path. One authz system, never two.
5
+ */
6
+ import type { Ctx } from '@ultimat3/core';
7
+ import type { AnyAction } from './action';
8
+ import { actionName, defOf, invoke } from './invoke';
9
+ import { type JsonSchemaObject, mcpSchemaOf, sortSchema } from './json-schema';
10
+ import { toToolName } from './naming';
11
+ import type { ActionPolicy } from './policy-gate';
12
+ import { listActions } from './registry';
13
+
14
+ export interface McpToolDescriptor {
15
+ readonly name: string;
16
+ /** The action's `mcp.description`, or its name when the author gave none. */
17
+ readonly description: string;
18
+ readonly action: string;
19
+ /**
20
+ * The action's own policy object, not a copy — `tool().policy === action.policy`
21
+ * is what makes "an MCP call cannot reach a different authz path" checkable.
22
+ */
23
+ readonly policy: ActionPolicy;
24
+ readonly inputSchema: JsonSchemaObject;
25
+ readonly outputSchema: JsonSchemaObject;
26
+ invoke(input: unknown, options?: McpInvokeOptions): Promise<unknown>;
27
+ }
28
+
29
+ export interface McpInvokeOptions {
30
+ readonly ctx?: Ctx;
31
+ readonly idempotencyKey?: string | null;
32
+ }
33
+
34
+ export function toMcpTool(target: AnyAction): McpToolDescriptor {
35
+ const name = actionName(target);
36
+ const def = defOf(target);
37
+ return {
38
+ name: toToolName(name),
39
+ description: def.mcp?.description ?? name,
40
+ action: name,
41
+ policy: def.policy,
42
+ inputSchema: sortSchema(mcpSchemaOf(def.input)),
43
+ outputSchema: sortSchema(mcpSchemaOf(def.output)),
44
+ invoke: (input, options = {}) =>
45
+ invoke(target, input, {
46
+ surface: 'mcp',
47
+ ...(options.ctx === undefined ? {} : { ctx: options.ctx }),
48
+ idempotencyKey: options.idempotencyKey ?? null,
49
+ }),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Opt-in, exactly like a query's: only a literal `mcp: { expose: true }` exposes an action.
55
+ *
56
+ * It read `!== false` until 2026-08, which made writing an action silently hand every agent a
57
+ * new write capability — and disagreed with `@ultimat3/mcp`'s `exposedPrimitives`, the projection
58
+ * that actually builds a catalog. Two functions answering "is this a tool?" differently is the
59
+ * ambiguity axiom 1 rejects, so the fail-closed one wins.
60
+ */
61
+ export function isExposed(target: AnyAction): boolean {
62
+ return target.mcp?.expose === true;
63
+ }
64
+
65
+ /** Deterministic order — the tool list is part of the agent-visible contract. */
66
+ export function toMcpTools(
67
+ actions: readonly AnyAction[] = listActions(),
68
+ ): readonly McpToolDescriptor[] {
69
+ return actions
70
+ .filter(isExposed)
71
+ .map(toMcpTool)
72
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
73
+ }