/** * MCP tool handlers — Manifest, schemas, effects, traces, safe actions. * * All returns go through {@link asData}. Write tools require a fresh * confirmation token (no session-level consent cache). */ import { lazyRequire } from "../kernel/lazy-require.ts"; import type { Manifest } from "../manifest/types.ts"; import type { WideEvent } from "../runs/types.ts"; import { authorizeToolCall, MCP_TOOL_POLICIES, type McpToolPolicy } from "./authorization.ts"; import { createConfirmationGate, MCP_CONFIRM_PHRASE, type ConfirmationGate, type ConfirmationGateOptions, type ConfirmIssueResult, } from "./confirmation.ts"; import { asData, freezeData, type McpDataEnvelope } from "./data.ts"; import type { McpRequester } from "./session.ts"; /** Narrow context the MCP surface needs — no token forwarding. */ export interface McpContext { /** Current Manifest snapshot. */ readonly getManifest: () => Manifest | null; /** List runs/traces (already operator-scoped by the provider). */ readonly listRuns: () => Promise; /** Agent runs for `oke.ai.runs.*`. Absent providers return an empty list. */ readonly listAgentRuns?: () => Promise; /** One agent run plus follow events. */ readonly getAgentRun?: ( runId: string, ) => Promise<{ readonly run: unknown; readonly events: readonly unknown[] } | undefined>; /** Pending tool approvals. Resolve is not an MCP tool. */ readonly listApprovals?: () => Promise; /** Decision catalogue: state, pending count, metrics, drift. */ readonly listDecisions?: (tenant?: string) => Promise; /** * Safe invoke adapter. Called only after confirmation. Must NOT receive * the caller's Bearer token — capabilities are passed as structured input. */ readonly invokeFlow?: (input: { readonly flowId: string; readonly body: unknown; readonly operatorId: string; readonly reason: string; }) => Promise; /** * Structural propose adapter. Called only after confirmation. */ readonly proposeStructural?: (input: { readonly title: string; readonly relativePath: string; readonly contents: string; readonly operatorId: string; readonly reason: string; }) => Promise; } /** Result of a tool call — always an inert data envelope or an error envelope. */ export type ToolCallResult = | { readonly ok: true; readonly data: McpDataEnvelope } | { readonly ok: false; readonly code: "unauthorized" | "forbidden" | "invalid" | "not-found"; readonly message: string; readonly data: McpDataEnvelope; }; /** * Create the tool runtime bound to a context + confirmation gate. * * `listTools` receives the requester and omits tools their scopes cannot call. * Confirmation is not required to appear in the list; `callTool` still enforces it. * * @param ctx - Manifest / runs / action adapters * @param confirmOptions - Confirmation gate options */ export function createToolRuntime( ctx: McpContext, confirmOptions: ConfirmationGateOptions = {}, ): { readonly listTools: (requester: McpRequester) => readonly McpToolPolicy[]; readonly callTool: ( requester: McpRequester, name: string, args: Record, ) => Promise; /** Exposed for tests — confirmation gate has no session consent cache. */ readonly confirmationSize: () => number; } { const confirm = createConfirmationGate(confirmOptions); return { listTools(requester) { // Same scope check as authorizeToolCall. Listing ignores confirmation; // a later call to a write tool still requires a token. return MCP_TOOL_POLICIES.filter((policy) => { const decision = authorizeToolCall(policy.name, {}, requester.scopes, { confirmed: true }); return decision.ok; }); }, confirmationSize: () => confirm.size(), async callTool(requester, name, args) { const policy = MCP_TOOL_POLICIES.find((p) => p.name === name); // Scope / param checks first (do not burn a confirmation token on ACL miss). const preflight = authorizeToolCall(name, args, requester.scopes, { confirmed: policy?.mutability !== "write", }); if (!preflight.ok && preflight.reason !== "confirmation-required") { return { ok: false, code: preflight.reason === "unknown-tool" ? "not-found" : "forbidden", message: preflight.detail ?? preflight.reason, data: asData( { tool: name, reason: preflight.reason, detail: preflight.detail ?? null, }, "error", ), }; } // Write path: consume a fresh per-call confirmation (no session cache). // A call with no token is rejected and returns an opaque confirmationId // bound to this requester — never the token. if (policy?.mutability === "write") { const token = typeof args.confirmToken === "string" ? args.confirmToken : ""; const phrase = typeof args.confirmation === "string" ? args.confirmation : ""; const reason = typeof args.reason === "string" ? args.reason : ""; const boundArgs = stripConfirmFields(args); if (token.length === 0) { const opened = confirm.open({ tool: name, args: boundArgs, target: { principalId: requester.principalId, sid: requester.sessionId, }, }); return { ok: false, code: "forbidden", message: "write tool requires fresh human confirmation (missing)", data: asData( { tool: name, confirmationId: opened.confirmationId, requiredPhrase: MCP_CONFIRM_PHRASE, confirmVia: "oke.action.confirm", reason: "missing", }, "error", ), }; } const consumed = confirm.consume({ tool: name, args: boundArgs, consumerSid: requester.sessionId, token, phrase, reason, }); if (!consumed.ok) { return { ok: false, code: "forbidden", message: `write tool requires fresh human confirmation (${consumed.reason})`, data: asData( { tool: name, requiredPhrase: MCP_CONFIRM_PHRASE, confirmVia: "oke.action.confirm", reason: consumed.reason, }, "error", ), }; } } switch (name) { case "oke.manifest.get": return okData(freezeData({ manifest: ctx.getManifest() }), "manifest"); case "oke.schema.get": return schemaGet(ctx, args); case "oke.effects.get": return effectsGet(ctx, args); case "oke.traces.list": return tracesList(ctx, args); case "oke.traces.get": return tracesGet(ctx, args); case "oke.ai.runs.list": return aiRunsList(ctx, requester, args); case "oke.ai.runs.get": return aiRunsGet(ctx, requester, args); case "oke.ai.approvals.list": return aiApprovalsList(ctx, requester, args); case "oke.decisions.list": return decisionsList(ctx, requester, args); case "oke.action.confirm": return actionConfirm(confirm, requester, args); case "oke.action.invoke": return actionInvoke(ctx, requester, args); case "oke.action.structural_propose": return actionStructural(ctx, requester, args); default: return { ok: false, code: "not-found", message: `unknown tool: ${name}`, data: asData({ tool: name }, "error"), }; } }, }; } function okData(content: T, provenance: Parameters[1]): ToolCallResult { return { ok: true, data: asData(content, provenance) }; } function stripConfirmFields(args: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(args)) { if (k === "confirmation" || k === "confirmToken" || k === "reason") { continue; } out[k] = v; } return out; } function schemaGet(ctx: McpContext, args: Record): ToolCallResult { const flowId = String(args.flowId ?? ""); const manifest = ctx.getManifest(); const flow = manifest?.flows?.[flowId]; if (!flow) { return { ok: false, code: "not-found", message: `unknown flow: ${flowId}`, data: asData({ flowId }, "error"), }; } return okData( freezeData({ flowId, in: flow.in ?? null, out: flow.out ?? null, errors: flow.errors ?? null, }), "schema", ); } function effectsGet(ctx: McpContext, args: Record): ToolCallResult { const flowId = String(args.flowId ?? ""); const manifest = ctx.getManifest(); const flow = manifest?.flows?.[flowId]; if (!flow) { return { ok: false, code: "not-found", message: `unknown flow: ${flowId}`, data: asData({ flowId }, "error"), }; } return okData(freezeData({ flowId, effects: flow.effects ?? {} }), "effects"); } async function tracesList(ctx: McpContext, args: Record): Promise { const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.min(200, Math.max(1, Math.floor(args.limit))) : 50; const flowId = typeof args.flowId === "string" && args.flowId.length > 0 ? args.flowId : null; const all = await ctx.listRuns(); const filtered = flowId ? all.filter((r) => r.flow === flowId) : all; const runs = filtered.slice(0, limit).map(projectRun); return okData(freezeData({ runs }), "trace"); } async function tracesGet(ctx: McpContext, args: Record): Promise { const runId = String(args.runId ?? ""); const all = await ctx.listRuns(); const run = all.find((r) => r.id === runId); if (!run) { return { ok: false, code: "not-found", message: `unknown run: ${runId}`, data: asData({ runId }, "error"), }; } // Provenance `store-record` marks user-supplied fields as untrusted. return okData(freezeData({ run: projectRun(run) }), "store-record"); } function tenantArg(args: Record): string | undefined { return typeof args.tenant === "string" && args.tenant.length > 0 ? args.tenant : undefined; } function sameTenant(row: unknown, tenant: string | undefined): boolean { if (!tenant) return true; if (!row || typeof row !== "object") return false; return (row as { tenant?: unknown }).tenant === tenant; } /** `console:*` and `mcp:*` may list every tenant. A named tool scope stays on its token. */ function operatorTenantScope(scopes: readonly string[]): boolean { return scopes.includes("console:*") || scopes.includes("mcp:*"); } /** * Tenant a read is allowed to see. * * The token's `tid` is the default. Naming another tenant, or listing every * tenant, requires an operator scope. A non-operator token with no tenant * sees nothing. */ function visibleTenant( requester: McpRequester, args: Record, ): { readonly ok: true; readonly tenant: string | undefined } | { readonly ok: false } { const requested = tenantArg(args); const own = requester.claims.tid; if (operatorTenantScope(requester.scopes)) { return { ok: true, tenant: requested }; } if (!own) return { ok: false }; if (requested !== undefined && requested !== own) return { ok: false }; return { ok: true, tenant: own }; } function loadClassify(): typeof import("../elements/store/classify.ts") { return lazyRequire(`${import.meta.dir}/../elements/store`, ["class", "ify"].join("")); } function loadRedacted(): typeof import("../kernel/redacted.ts") { return lazyRequire(`${import.meta.dir}/../kernel`, ["redact", "ed"].join("")); } /** * PII-classified and sensitive column names, plus {@link Redacted} values. * Same mask token the Console traces projection uses. */ function maskAiValue(manifest: Manifest | null | undefined, value: unknown): unknown { return maskPiiWalk(loadRedacted().maskRedactedDeep(value), classifiedFieldNames(manifest)); } function classifiedFieldNames(manifest: Manifest | null | undefined): ReadonlySet { const names = new Set(); if (!manifest?.stores) return names; for (const store of Object.values(manifest.stores)) { for (const table of Object.values(store.tables ?? {})) { for (const [col, tags] of Object.entries(table.columns ?? {})) { if (tags?.pii || tags?.sensitive) loadClassify().addPiiFieldName(names, col); } } } return names; } function maskPiiWalk(value: unknown, fields: ReadonlySet): unknown { if (fields.size === 0 || value === null || value === undefined) return value; if (Array.isArray(value)) return value.map((item) => maskPiiWalk(item, fields)); if (typeof value === "object") { const out: Record = {}; for (const [key, child] of Object.entries(value as Record)) { out[key] = fields.has(key) ? loadClassify().PII_MASK : maskPiiWalk(child, fields); } return out; } return value; } async function aiRunsList( ctx: McpContext, requester: McpRequester, args: Record, ): Promise { const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? Math.min(200, Math.max(1, Math.floor(args.limit))) : 50; const scope = visibleTenant(requester, args); if (!scope.ok) return okData(freezeData({ runs: [] }), "trace"); const all = (await ctx.listAgentRuns?.()) ?? []; const runs = all .filter((row) => sameTenant(row, scope.tenant)) .slice(0, limit) .map((row) => maskAiValue(ctx.getManifest(), row)); return okData(freezeData({ runs }), "trace"); } async function aiRunsGet( ctx: McpContext, requester: McpRequester, args: Record, ): Promise { const runId = String(args.runId ?? ""); const scope = visibleTenant(requester, args); const found = scope.ok ? await ctx.getAgentRun?.(runId) : undefined; if (!found || !sameTenant(found.run, scope.ok ? scope.tenant : undefined)) { return { ok: false, code: "not-found", message: `unknown run: ${runId}`, data: asData({ runId }, "error"), }; } return okData( freezeData({ run: maskAiValue(ctx.getManifest(), found.run), events: maskAiValue(ctx.getManifest(), found.events), }), "store-record", ); } async function aiApprovalsList( ctx: McpContext, requester: McpRequester, args: Record, ): Promise { const scope = visibleTenant(requester, args); if (!scope.ok) return okData(freezeData({ rows: [] }), "store-record"); const all = (await ctx.listApprovals?.()) ?? []; const rows = all .filter((row) => sameTenant(row, scope.tenant)) .map((row) => maskAiValue(ctx.getManifest(), row)); return okData(freezeData({ rows }), "store-record"); } async function decisionsList( ctx: McpContext, requester: McpRequester, args: Record, ): Promise { const scope = visibleTenant(requester, args); if (!scope.ok) return okData(freezeData({ decisions: [] }), "manifest"); const decisions = ((await ctx.listDecisions?.(scope.tenant)) ?? []).filter((row) => sameTenant(row, scope.tenant), ); return okData(freezeData({ decisions: maskAiValue(ctx.getManifest(), decisions) }), "manifest"); } function actionConfirm( confirm: ConfirmationGate, requester: McpRequester, args: Record, ): ToolCallResult { const confirmationId = typeof args.confirmationId === "string" ? args.confirmationId : ""; const reason = typeof args.reason === "string" ? args.reason : ""; const issued = confirm.issue({ confirmationId, reason, callerSid: requester.sessionId, }); if (!issued.ok) { return { ok: false, code: issued.reason === "reason-short" ? "invalid" : "forbidden", message: confirmIssueMessage(issued.reason), data: asData({ error: issued.reason }, "error"), }; } return okData( { confirmToken: issued.token, tool: issued.tool, phrase: MCP_CONFIRM_PHRASE, expiresAt: issued.expiresAt, notice: "Present confirmToken + confirmation phrase on the write call from the session that requested it. Tokens are single-use; the issuer session cannot consume them.", }, "action-result", ); } function confirmIssueMessage(reason: Extract["reason"]): string { switch (reason) { case "reason-short": return "reason must be at least 3 characters"; case "same-session": return "confirmation must be issued by a different auth session"; case "unknown": return "unknown confirmation"; case "expired": return "confirmation expired"; case "already-issued": return "confirmation already issued"; } } async function actionInvoke( ctx: McpContext, requester: McpRequester, args: Record, ): Promise { if (!ctx.invokeFlow) { return { ok: false, code: "forbidden", message: "invoke adapter not configured", data: asData({ tool: "oke.action.invoke" }, "error"), }; } const flowId = String(args.flowId ?? ""); const reason = String(args.reason ?? ""); const result = await ctx.invokeFlow({ flowId, body: args.body, operatorId: requester.principalId, reason, }); return okData(freezeData({ result }), "action-result"); } async function actionStructural( ctx: McpContext, requester: McpRequester, args: Record, ): Promise { if (!ctx.proposeStructural) { return { ok: false, code: "forbidden", message: "structural adapter not configured", data: asData({ tool: "oke.action.structural_propose" }, "error"), }; } const result = await ctx.proposeStructural({ title: String(args.title ?? ""), relativePath: String(args.relativePath ?? ""), contents: String(args.contents ?? ""), operatorId: requester.principalId, reason: String(args.reason ?? ""), }); return okData(freezeData({ result }), "action-result"); } /** * Project a wide event for MCP. Dimensions / logs may contain poisoned * strings — they remain in `content` under a store-record provenance. * * @param run - Wide event */ export function projectRun(run: WideEvent): Record { return { id: run.id, parentId: run.parentId ?? null, flow: run.flow, unit: run.unit ?? null, trigger: run.trigger, plane: run.plane, startedAt: run.startedAt, endedAt: run.endedAt, durationMs: run.durationMs, error: run.error ?? null, cost: run.cost ?? null, effects: run.effects, // Attacker-controlled fields — still data, never instruction. logs: run.logs, dimensions: run.dimensions, }; }