// agent.ts — the unified action registry. // // One `defineAction()` entry is the single source of truth: it is the UI server // action, the MCP tool at /mcp, AND the browser WebMCP tool — all invoked by the // same id against this one registry. The RSC server-action path registers into // THIS registry too, so a server action and an MCP tool are no longer "the same // thing described twice." // // 1. defineAction(): id + description + input schema + run. // 2. invokeAction(id, input): the JSON dispatch path (MCP). RSC's Flight // dispatch resolves the same registry. import { currentTrace } from "./instrumentation"; import type { ActionContext } from "./context"; export type JsonSchema = { type: "object"; properties: Record; // readonly so an `as const` schema (which an extracted schema variable needs to // keep its literals for InferInput) still satisfies the constraint. Inline // schema literals infer via the `const` type param without `as const`. required?: readonly string[]; }; // Map June's flat JSON-Schema subset → a TS type. June's schema is intentionally // minimal (type:"object" + flat properties + required[]), so this is a small // in-house mapped type — no json-schema-to-ts, no recursive constraint, no // TS2589, no new dependency. `defineAction` captures the schema as a `const` // literal so the property `type` strings survive as literals for this to read. type JsonPrimitive = T extends "string" ? string : T extends "number" | "integer" ? number : T extends "boolean" ? boolean : T extends "array" ? unknown[] : T extends "object" ? Record : unknown; type RequiredName = S["required"] extends readonly string[] ? S["required"][number] : never; export type InferInput = { [K in keyof S["properties"] as K extends RequiredName ? K : never]: JsonPrimitive< S["properties"][K]["type"] >; } & { [K in keyof S["properties"] as K extends RequiredName ? never : K]?: JsonPrimitive< S["properties"][K]["type"] >; }; // In-house runtime validation matching the schema's expressiveness: required keys // present + each declared property's primitive type. Extra keys are allowed // (June's schema has no additionalProperties). Returns an error string, or null // when valid. Lives in core (pure, no deps) — the dispatch boundary that needs it // is here, and June's flat schema needs no ajv. export function validateInput(schema: JsonSchema, input: unknown): string | null { if (typeof input !== "object" || input === null || Array.isArray(input)) { return "input must be an object"; } const obj = input as Record; for (const key of schema.required ?? []) { if (obj[key] === undefined) return `missing required property "${key}"`; } for (const [key, prop] of Object.entries(schema.properties)) { const v = obj[key]; if (v === undefined) continue; // absent optional (absent required caught above) const t = prop.type; const ok = t === "string" ? typeof v === "string" : t === "number" ? typeof v === "number" : t === "integer" ? typeof v === "number" && Number.isInteger(v) : t === "boolean" ? typeof v === "boolean" : t === "array" ? Array.isArray(v) : t === "object" ? typeof v === "object" && v !== null && !Array.isArray(v) : true; // unknown declared type → don't reject if (!ok) return `property "${key}" must be ${t}`; } return null; } // MCP-aligned behavior hints (spec 2025-11-25 `ToolAnnotations`). Advisory // metadata, never semantics: June's /mcp gateway re-serves them so MCP clients // (Claude Code et al.) can drive permission UX — e.g. auto-approve readOnlyHint // tools, confirm destructiveHint ones. A connection also carries a remote // tool's annotations through unchanged (gateway fidelity). export type ToolAnnotations = { title?: string; readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean; }; export type ActionDefinition = { id: string; description: string; input: JsonSchema; // run() receives the request-scoped IDENTITY (principal/session) as its second // arg, so the SAME authorization runs on the UI and /mcp paths. Data is the // ambient `db`/`kv`/`blob`, not on ctx. An action that ignores ctx (one-param // run) is still assignable here. run: (input: I, ctx: ActionContext) => O | Promise; // See ToolAnnotations above. Optional; absent = no hints. annotations?: ToolAnnotations; // Identity gate, enforced on EVERY dispatch path by construction: // • invokeAction (UI POST, /mcp tools/call) rejects when ctx.user is absent; // • the agent turn engine hides the bridged tool from anonymous turns // entirely (Tool.requiresPrincipal via actionToTool); // • the Flight server reference registers as a fail-closed wrapper — an // RSC dispatch that doesn't thread an identified ActionContext throws. // Mark every action that reads user/tenant-scoped data. requiresPrincipal?: boolean; // How an agent turn runs it — see Tool.mode (#233). Absent = an `async` run is "remote" // (at-least-once), anything else "local" (exactly-once, must return synchronously). mode?: "local" | "remote"; }; // `ActionDefinition` is invariant in its input type (the `run` param), so a // concretely-typed action is not assignable to `ActionDefinition`. // Collections of heterogeneous actions use this loose alias. // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyAction = ActionDefinition; // The single, unified action registry — keyed on globalThis so any dispatch // path (agent POST, MCP tools/call, RSC Flight) resolves an action by id even // when the resolver loads this module twice (workspace symlinks can give the // app and the framework different @junejs/core paths; a module-level Map would // then split registrations across two instances). const REGISTRY_KEY = Symbol.for("june.actionRegistry"); export const ACTION_REGISTRY: Map = (( globalThis as Record | undefined> )[REGISTRY_KEY] ??= new Map()); // The RSC runtime (which owns the `react-server` condition) injects how to mark // a run() as a React server reference, so the SAME action is passable as a UI // prop without agent.ts importing react-server. No-op outside the RSC runtime. type ServerReferenceRegistrar = ( fn: (...args: unknown[]) => unknown, id: string, ) => unknown; let serverReferenceRegistrar: ServerReferenceRegistrar | null = null; export function setServerReferenceRegistrar(fn: ServerReferenceRegistrar) { serverReferenceRegistrar = fn; } // `input` is the SINGLE source: run()'s param type is INFERRED from the schema // (no second hand-written type), and invokeAction validates against the SAME // schema. `const S` captures the schema literal so InferInput can read it. export function defineAction(def: { id: string; description: string; input: S; run: (input: InferInput, ctx: ActionContext) => O | Promise; annotations?: ToolAnnotations; requiresPrincipal?: boolean; mode?: "local" | "remote"; }): ActionDefinition, O> { const action = def as unknown as ActionDefinition, O>; const existing = ACTION_REGISTRY.get(def.id); if (existing && existing !== (action as unknown as AnyAction)) { // Don't silently overwrite — a clashing id is almost always a bug. console.warn(`[june] defineAction: id "${def.id}" is already registered — overwriting.`); } ACTION_REGISTRY.set(def.id, action as unknown as AnyAction); // Flight/server-reference dispatch invokes the REGISTERED function directly — // it never passes through invokeAction. The wrapper is bound to THIS EXACT // action: it only runs when the registry still maps this id to this very // action. So a rolled-back action (deleted) OR an id later overwritten by a // DIFFERENT action both make this reference inert — a stale/forged Flight // dispatch can never invoke a replacement's run with this reference's id, and // the identity gate reflects this action's own requiresPrincipal. const flightRun = (...args: unknown[]) => { if (ACTION_REGISTRY.get(def.id) !== action) { throw new Error(`Action "${def.id}" is not the currently registered action (rolled back or replaced)`); } if (action.requiresPrincipal) { const ctx = args[1] as ActionContext | undefined; if (!ctx?.user) throw new Error(`Action "${def.id}" requires an authenticated principal (ctx.user)`); } return (action.run as (...a: unknown[]) => unknown)(...args); }; serverReferenceRegistrar?.(flightRun, def.id); return action; } // Why a dispatch was refused before the action ran — a stable machine code on the // thrown Error (`error.code`), so a surface (/mcp) can report it structurally // instead of parsing messages. The code is only trusted through // actionDispatchCode(): an action's run() (or a dependency) can throw an error // with ANY `code` ("ECONNRESET", even "invalid_input"), and that is an execution // failure, not a dispatch refusal — the private marker tells the two apart. export type ActionDispatchErrorCode = "unknown_action" | "unauthorized" | "invalid_input"; const DISPATCH_ERROR = Symbol("june.actionDispatchError"); function dispatchError(code: ActionDispatchErrorCode, message: string): Error & { code: ActionDispatchErrorCode } { return Object.assign(new Error(message), { code, [DISPATCH_ERROR]: true }); } // The dispatch code of an error invokeAction raised itself; undefined for anything else. export function actionDispatchCode(error: unknown): ActionDispatchErrorCode | undefined { if (typeof error !== "object" || error === null || !(DISPATCH_ERROR in error)) return undefined; return (error as unknown as { code: ActionDispatchErrorCode }).code; } // An error escaping an action's run() is an EXECUTION failure of that action — // even when it is a dispatch refusal from a NESTED invokeAction the action let // propagate (run() awaits invokeAction("missing") → unknown_action). The marker // is scoped to the dispatch that raised it; that inner dispatch has finished, and // the only reader left is the outer surface, for which "unknown_action" would // misreport ITS call. So the marker is cleared here, on the same object: identity, // class, message, `code`, and stack stay intact for app code that catches it // further out. A nested refusal the action CATCHES never reaches this line. // Only an object that refuses the delete (frozen) is wrapped instead, with the // original as `cause`. function escapedFromRun(error: unknown): unknown { if (actionDispatchCode(error) === undefined) return error; if (Reflect.deleteProperty(error as object, DISPATCH_ERROR)) return error; return new Error((error as Error).message, { cause: error }); } // JSON dispatch path (agent / MCP): invoke an action by id with a single input // and the request-scoped identity (principal/session). ctx defaults to {} so // callers that don't have one (tests, anonymous dispatch) still work. export async function invokeAction( id: string, input: unknown, ctx: ActionContext = {}, ): Promise { const action = ACTION_REGISTRY.get(id); if (!action) throw dispatchError("unknown_action", `Unknown action: ${id}`); // Identity gate at the SAME boundary as schema validation: every dispatch // path funnels through here, so a requiresPrincipal action cannot be reached // anonymously via /mcp or a UI POST. (Agent turns enforce the same gate // earlier — the tool is hidden from the model's list; see actionToTool.) if (action.requiresPrincipal && !ctx.user) throw dispatchError("unauthorized", `Action "${id}" requires an authenticated principal (ctx.user)`); // Enforce the schema at the dispatch boundary — /mcp is untrusted input. This // used to be a no-op (the schema only described, never enforced). const invalid = validateInput(action.input, input); if (invalid) throw dispatchError("invalid_input", `Invalid input for "${id}": ${invalid}`); let result: unknown; try { result = await action.run(input, ctx); } catch (error) { throw escapedFromRun(error); } // Cache coherence is a property of the ACTION, not of one dispatch path: // every table this action wrote invalidates its `table:` tag (plus the // coarse `flight` tag), no matter how it was invoked — UI POST, /mcp // tools/call, or Flight. (Idempotent if a caller also invalidates.) const writes = currentTrace()?.writes; if (writes && writes.size > 0) { const { invalidate } = await import("./cache"); for (const table of writes) await invalidate(`table:${table}`); await invalidate("flight"); } return result; }