@archstone/runtime
Advanced tools
| // src/server.ts | ||
| import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| import { | ||
| CallToolRequestSchema, | ||
| ListToolsRequestSchema | ||
| } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { | ||
| inputJsonSchema, | ||
| objectJsonSchema, | ||
| applyResponseMapping, | ||
| contractViolationMessage, | ||
| evaluatePolicy, | ||
| evaluateRateLimit, | ||
| auditNow, | ||
| buildExecutionRecord, | ||
| emitExecutionRecord, | ||
| LIFECYCLE_BLOCKED_REASON, | ||
| LIFECYCLE_UNEVALUATABLE_REASON | ||
| } from "@archstone/emitter-support"; | ||
| import { invokeRest } from "@archstone/provider-rest"; | ||
| function effectAnnotations(effect) { | ||
| switch (effect) { | ||
| case "read": | ||
| return { readOnlyHint: true }; | ||
| case "irreversible": | ||
| return { destructiveHint: true, idempotentHint: false }; | ||
| case "write": | ||
| return { destructiveHint: false }; | ||
| default: | ||
| return void 0; | ||
| } | ||
| } | ||
| function toolDefinitions(registry) { | ||
| const resources = registry.ir.resources; | ||
| return registry.invocableTools().filter(({ tool: t }) => registry.getExposure(t.id).listed).map(({ name, tool: t }) => { | ||
| const hint = registry.getExposure(t.id).hint; | ||
| const def = { | ||
| name, | ||
| description: hint ? `${t.description} (${hint.text})` : t.description, | ||
| inputSchema: inputJsonSchema(t.input, resources) | ||
| }; | ||
| if (t.output.length > 0) def.outputSchema = objectJsonSchema(t.output, resources); | ||
| const annotations = effectAnnotations(t.effect); | ||
| if (annotations) def.annotations = annotations; | ||
| return def; | ||
| }); | ||
| } | ||
| var CONTRACT_VIOLATION_META_KEY = "dev.archstone/contract_violation"; | ||
| var LIFECYCLE_BLOCKED_META_KEY = "dev.archstone/lifecycle_blocked"; | ||
| var LIFECYCLE_UNEVALUATABLE_META_KEY = "dev.archstone/lifecycle_unevaluatable"; | ||
| var POLICY_DENIED_META_KEY = "dev.archstone/policy_denied"; | ||
| async function callTool(registry, name, args, opts) { | ||
| const tool = registry.getCapability(name); | ||
| if (!tool) { | ||
| return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true }; | ||
| } | ||
| const auditSink = opts?.auditSink; | ||
| const startedAt = auditSink ? auditNow() : ""; | ||
| const audit = (status) => { | ||
| if (!auditSink) return; | ||
| emitExecutionRecord( | ||
| auditSink, | ||
| buildExecutionRecord({ | ||
| tool, | ||
| input: args, | ||
| // Fixed by this call site, never host-configurable: an auditor must be able to trust | ||
| // that a record claiming `mcp` came from the MCP path. `mcpHandler` mounts this same | ||
| // path and therefore also records `mcp` — the value names the protocol surface the call | ||
| // arrived on, not the npm package that mounted it. | ||
| consumer: "mcp", | ||
| caller: opts?.caller, | ||
| sessionId: opts?.sessionId, | ||
| workflowId: opts?.workflowId, | ||
| startedAt, | ||
| status | ||
| }) | ||
| ); | ||
| }; | ||
| const exposure = registry.getExposure(tool.id); | ||
| if (!exposure.invocable) { | ||
| if (exposure.blockedReason === "unevaluatable") { | ||
| const text2 = `capability '${tool.id}' declares a lifecycle this build does not recognize and cannot evaluate \u2014 refusing (fail-closed).`; | ||
| audit({ phase: "denied", message: text2, denialReason: LIFECYCLE_UNEVALUATABLE_REASON }); | ||
| return { | ||
| content: [{ type: "text", text: text2 }], | ||
| _meta: { | ||
| [LIFECYCLE_UNEVALUATABLE_META_KEY]: { error: "lifecycle_unevaluatable", capability: tool.id, lifecycle: tool.lifecycle } | ||
| }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const text = `capability '${tool.id}' is retired and can no longer be invoked.`; | ||
| audit({ phase: "denied", message: text, denialReason: LIFECYCLE_BLOCKED_REASON }); | ||
| return { | ||
| content: [{ type: "text", text }], | ||
| _meta: { [LIFECYCLE_BLOCKED_META_KEY]: { error: "lifecycle_blocked", capability: tool.id, lifecycle: tool.lifecycle } }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const decision = opts?.callerResolutionFailed ? { | ||
| allowed: false, | ||
| denial: { | ||
| reason: "policy_unevaluatable", | ||
| message: `capability '${tool.id}' could not be evaluated \u2014 caller identity could not be established (resolveCaller failed) \u2014 refusing (fail-closed).` | ||
| } | ||
| } : evaluatePolicy(tool, { | ||
| principal: opts?.caller?.principal, | ||
| credentialPresent: opts?.caller?.accessToken !== void 0 | ||
| }); | ||
| if (!decision.allowed) { | ||
| audit({ phase: "denied", message: decision.denial.message, denialReason: decision.denial.reason }); | ||
| return { | ||
| content: [{ type: "text", text: decision.denial.message }], | ||
| _meta: { | ||
| [POLICY_DENIED_META_KEY]: { | ||
| error: "policy_denied", | ||
| // `tool.id` — the unsanitized CDL id, never the MCP-sanitized advertised `name` | ||
| // lookup key (BR-28, mirroring ADD-19 and ADD-30 BR-7). | ||
| capability: tool.id, | ||
| reason: decision.denial.reason | ||
| } | ||
| }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const rateDecision = await evaluateRateLimit(tool, { principal: opts?.caller?.principal }, opts?.rateLimitCounter); | ||
| if (!rateDecision.allowed) { | ||
| audit({ phase: "denied", message: rateDecision.denial.message, denialReason: rateDecision.denial.reason }); | ||
| return { | ||
| content: [{ type: "text", text: rateDecision.denial.message }], | ||
| _meta: { | ||
| [POLICY_DENIED_META_KEY]: { | ||
| error: "policy_denied", | ||
| capability: tool.id, | ||
| reason: rateDecision.denial.reason | ||
| } | ||
| }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const result = await invokeRest(tool, args, opts); | ||
| if (!result.ok) { | ||
| const text = result.error ?? "invocation failed"; | ||
| audit({ phase: "failed", message: text }); | ||
| return { content: [{ type: "text", text }], isError: true }; | ||
| } | ||
| if (tool.response) { | ||
| const mapped = applyResponseMapping(tool, result.data, registry.ir.resources); | ||
| if (mapped.status === "violation") { | ||
| const missing = mapped.missing ?? []; | ||
| const text = contractViolationMessage(tool.id, missing); | ||
| audit({ phase: "failed", message: text }); | ||
| return { | ||
| content: [{ type: "text", text }], | ||
| _meta: { [CONTRACT_VIOLATION_META_KEY]: { error: "contract_violation", capability: tool.id, missing } }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const content = [{ type: "text", text: JSON.stringify(mapped.data, null, 2) }]; | ||
| if (mapped.status === "degraded") { | ||
| content.push({ type: "text", text: `note: optional field(s) absent (degraded): ${(mapped.degraded ?? []).join(", ")}` }); | ||
| } | ||
| audit({ phase: "succeeded" }); | ||
| return { content, structuredContent: mapped.data, isError: false }; | ||
| } | ||
| const out = { content: [{ type: "text", text: JSON.stringify(result.data ?? null, null, 2) }], isError: false }; | ||
| if (tool.output.length > 0) { | ||
| const data = result.data; | ||
| if (data && typeof data === "object" && !Array.isArray(data)) { | ||
| out.structuredContent = data; | ||
| } | ||
| } | ||
| audit({ phase: "succeeded" }); | ||
| return out; | ||
| } | ||
| function createMcpServer(registry, opts) { | ||
| const server = new Server({ name: "archstone", version: "0" }, { capabilities: { tools: {} } }); | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions(registry) })); | ||
| server.setRequestHandler(CallToolRequestSchema, async (req) => { | ||
| const args = req.params.arguments ?? {}; | ||
| const result = await callTool(registry, req.params.name, args, opts); | ||
| return result; | ||
| }); | ||
| return server; | ||
| } | ||
| export { | ||
| effectAnnotations, | ||
| toolDefinitions, | ||
| CONTRACT_VIOLATION_META_KEY, | ||
| LIFECYCLE_BLOCKED_META_KEY, | ||
| LIFECYCLE_UNEVALUATABLE_META_KEY, | ||
| POLICY_DENIED_META_KEY, | ||
| callTool, | ||
| createMcpServer | ||
| }; | ||
| //# sourceMappingURL=chunk-7GP6HYWZ.js.map |
| {"version":3,"sources":["../src/server.ts"],"sourcesContent":["// @archstone/runtime — MCP server construction (fs-free)\n//\n// Builds an MCP Server from a Registry and routes invocations through the REST provider\n// (#6). This is the ONLY place the MCP SDK appears (alongside stdio's transport wiring in\n// mcp.ts and the /http subpath's transport wiring in http.ts) — semantic-type → JSON-Schema\n// lowering itself now lives in @archstone/emitter-support (ADD-0008 #27), never here.\n//\n// Extracted out of mcp.ts (ADD-0008 #27) specifically so this module's graph never reaches\n// registry.ts's buildRegistry/@archstone/schema `load()` (the fs edge) — only stdio's\n// `serveStdio` (mcp.ts) needs disk access. `http.ts` (the /http subpath) imports only this\n// file, so a consumer depending on that subpath alone stays fs-free.\n\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n type CallToolResult,\n type ToolAnnotations,\n} from \"@modelcontextprotocol/sdk/types.js\";\nimport type { IRTool } from \"@archstone/compiler\";\nimport {\n Registry,\n inputJsonSchema,\n objectJsonSchema,\n applyResponseMapping,\n contractViolationMessage,\n evaluatePolicy,\n evaluateRateLimit,\n auditNow,\n buildExecutionRecord,\n emitExecutionRecord,\n LIFECYCLE_BLOCKED_REASON,\n LIFECYCLE_UNEVALUATABLE_REASON,\n type ExecutionStatus,\n type PolicyDecision,\n} from \"@archstone/emitter-support\";\nimport { invokeRest, type InvokeOptions } from \"@archstone/provider-rest\";\n\ntype JsonSchema = Record<string, unknown>;\n\n/**\n * #126: the subset of MCP's `ToolAnnotations` this emitter is entitled to populate from CDL.\n *\n * Derived from the SDK's own `ToolAnnotations` via `Pick` rather than re-declared, so the three\n * field names are checked against the installed SDK at compile time instead of being trusted to\n * a comment. Confirmed present on the tool definition at the version `^1.12.0` resolves to\n * (1.30.0): `ToolSchema.annotations` is `ToolAnnotationsSchema.optional()`, and that schema\n * declares `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`.\n *\n * The two members deliberately NOT picked, because no CDL field means them:\n * - `openWorldHint` — whether the tool's domain of interaction is open or closed. Archstone\n * knows a capability's `effect`, never the shape of the world behind its connector. A guess\n * here would be indistinguishable, to a client, from a fact.\n * - `title` — a display concern with no CDL source; `description` already carries the\n * business-authored text (plus any exposure hint, ADD-24).\n */\nexport type McpToolAnnotations = Pick<ToolAnnotations, \"readOnlyHint\" | \"destructiveHint\" | \"idempotentHint\">;\n\n/**\n * #126: lower a capability's `effect` — the one field `archstone init` refuses to guess and\n * insists a human confirm — into MCP tool annotations, so the client's tool-confirmation dialog\n * can tell `tourism.search` from a capability that charges a card. Before this, `effect` was\n * compiled, carried through the IR, and dropped here: `McpToolDef` had no field for it and\n * `toolDefinitions` never read it, so the ONLY human-in-the-loop mechanism that exists today\n * (`human-approval` is declared and unenforced — `apply` says so) decided blind.\n *\n * The mapping, exactly and only:\n * read → readOnlyHint: true\n * irreversible → destructiveHint: true, idempotentHint: false\n * write → destructiveHint: false\n *\n * `write`'s single `false` is not a no-op: per the SDK's own schema docs, `destructiveHint`\n * DEFAULTS TO TRUE when absent, so stating it is the only thing that keeps a `write` from\n * reaching the client indistinguishable from an `irreversible`. Conversely `read` needs no\n * `destructiveHint` — the same docs describe that field as meaningful only when `readOnlyHint`\n * is false.\n *\n * The seam in this mapping, named rather than papered over: MCP describes\n * `destructiveHint: false` as a tool whose updates are purely additive, whereas CDL's `write`\n * means \"modifies, reversibly\" — `examples/manifests/booking`'s `tourism.cancel` is an\n * `effect: write` and is plainly not additive. The two vocabularies are adjacent, not\n * identical, and #126 ratified this pairing as the closest available fit, not a perfect one.\n * Read `destructiveHint: false` here as *not irreversible*, the distinction CDL actually draws.\n * Do not \"improve\" this into a finer per-capability judgement: CDL has no additivity primitive,\n * so anything more specific would be invented rather than derived — the same reasoning that\n * keeps `openWorldHint` off the list above.\n *\n * **This is a hint, not a control.** Nothing in Archstone gates, refuses, or retries on the\n * value — deliberately (#126, Not in scope). MCP's own spec says as much of every annotation,\n * and a client is free to ignore all of it.\n *\n * WHY THIS LIVES HERE AND NOT IN @archstone/emitter-support. CLAUDE.md puts lowering that is\n * *shared* between emitters in emitter-support; `readOnlyHint`/`destructiveHint`/\n * `idempotentHint` are MCP-protocol vocabulary with, as of this increment, exactly one\n * consumer. Every target format `@archstone/agent`'s `tools()` emits was checked against its\n * live reference before this call was made — the field lists, references and dates are in\n * `packages/agent/src/tools.ts`'s header comment, pinned by `packages/agent/test/tools.test.ts`\n * — and NONE has an equivalent field, so there is nothing to share. Note that `effect` itself\n * still reaches every `@archstone/agent` consumer: they hold the Registry and read it off the\n * IR directly, which is exactly why nothing needed inventing there. `exposure.ts` — the neutral\n * presentation module, and the obvious tempting\n * home — states the rule this follows verbatim: \"MCP-specific rendering ... belongs only in\n * @archstone/runtime's server.ts, never here.\" Move it to emitter-support the day a second\n * emitter needs it, and not before: re-encoding `read|write|irreversible` into some other\n * neutral vocabulary one layer down would add a translation with no second reader, when the IR\n * already carries the fact in the neutral vocabulary that matters.\n *\n * TOTAL, on purpose — same trust boundary and same reasoning as `lifecycleExposure`'s\n * `default` branch (ADD-56 D-1). `effect`'s static type is a closed three-member union, but the\n * value reaches this function un-runtime-validated whenever the Registry was built by\n * `fromIR`'s `json as IR` cast (`agent/src/index.ts`, which validates only `version === \"0\"`)\n * and served through `mcpHandler` → `createHttpHandler` → `createMcpServer` → here. A\n * hand-written or forward-versioned artifact can carry any string. That case returns\n * `undefined` — NO annotations at all — rather than any positive claim, so the client falls\n * back to MCP's own documented defaults (`readOnlyHint: false`, `destructiveHint: true`), which\n * are the cautious reading. The one outcome that must never be reachable from an unrecognized\n * value is `readOnlyHint: true`, and returning nothing is the only answer that guarantees it.\n */\nexport function effectAnnotations(effect: IRTool[\"effect\"]): McpToolAnnotations | undefined {\n switch (effect) {\n case \"read\":\n return { readOnlyHint: true };\n case \"irreversible\":\n return { destructiveHint: true, idempotentHint: false };\n case \"write\":\n return { destructiveHint: false };\n default:\n // Unrecognized `effect` across the `fromIR` trust boundary — claim nothing. See above.\n return undefined;\n }\n}\n\nexport interface McpToolDef {\n name: string;\n description: string;\n inputSchema: JsonSchema;\n outputSchema?: JsonSchema;\n /** #126: derived from the capability's `effect` — see `effectAnnotations`. Absent only when\n * `effect` is a value this build does not recognize (possible solely via `fromIR`). */\n annotations?: McpToolAnnotations;\n}\n\n/** The MCP tool list: only invocable (bound) capabilities become tools — Registry's\n * `invocableTools()` (ADD-30 D-3) is the single source of truth shared by this function\n * (what's listed) and `callTool` (what's resolvable, via `getCapability`), keeping the two\n * consistent. Input and output fields lower against the IR resource registry, so a\n * `collection: Stay` output emits a typed, described `outputSchema` (not a bare\n * `{type:object}`).\n *\n * ADD-24: a bound tool whose combined exposure (`registry.getExposure`, lifecycle + optional\n * health) is `listed:false` (lifecycle `experimental`/`retired`) is dropped from the returned\n * list entirely — unlisted, per D-10, though `experimental` remains callable by id (see\n * `callTool`). A tool carrying a `hint` (beta/deprecated, or a yellow/red health reading) has\n * its text appended to `description` — the only MCP-specific rendering of the neutral\n * exposure the emitter-support layer computed.\n *\n * #126: every listed tool also carries `annotations` derived from its `effect`\n * (`effectAnnotations`), so the client's confirmation dialog stops treating a search and a\n * payment identically. Both transports reach this one function — stdio via `serveStdio`'s\n * `createMcpServer`, HTTP via `createHttpHandler`'s — so there is no second place to keep in\n * step. */\nexport function toolDefinitions(registry: Registry): McpToolDef[] {\n const resources = registry.ir.resources;\n return registry\n .invocableTools()\n .filter(({ tool: t }) => registry.getExposure(t.id).listed)\n .map(({ name, tool: t }) => {\n const hint = registry.getExposure(t.id).hint;\n const def: McpToolDef = {\n name,\n description: hint ? `${t.description} (${hint.text})` : t.description,\n inputSchema: inputJsonSchema(t.input, resources),\n };\n if (t.output.length > 0) def.outputSchema = objectJsonSchema(t.output, resources);\n const annotations = effectAnnotations(t.effect);\n if (annotations) def.annotations = annotations;\n return def;\n });\n}\n\nexport interface CallResult {\n content: { type: \"text\"; text: string }[];\n structuredContent?: Record<string, unknown>;\n isError: boolean;\n _meta?: Record<string, unknown>;\n}\n\n/** #19 ADD-19 Rev 2 D-6: the namespaced `_meta` key a VIOLATION result's structured error\n * object is carried under. Never populated on `structuredContent` (D-3′) — the reference\n * MCP SDK client validates `structuredContent` against `outputSchema` whenever the tool\n * declares one, regardless of `isError`, so a non-conforming error object there crashes the\n * client. `_meta` is untouched by that validation and passes through the client's zod parse\n * unstripped (`ResultSchema`/`RequestMetaSchema` are `z.looseObject`). */\nexport const CONTRACT_VIOLATION_META_KEY = \"dev.archstone/contract_violation\";\n\n/** ADD-24 D-11: the namespaced `_meta` key a `retired` (or otherwise `invocable:false`)\n * tool's rejection is carried under — reuses `CONTRACT_VIOLATION_META_KEY`'s precedent\n * (ADD-19 Rev 2 D-3′/D-6) verbatim: never `structuredContent`, so the reference SDK client's\n * unconditional `structuredContent`-against-`outputSchema` validation never sees it. A\n * distinct key (not `CONTRACT_VIOLATION_META_KEY`) so a client distinguishes \"this call was\n * blocked before any connector work\" from \"the provider's response violated the contract\". */\nexport const LIFECYCLE_BLOCKED_META_KEY = \"dev.archstone/lifecycle_blocked\";\n\n/** ADD-56 D-2/OQ-56-B: the namespaced `_meta` key an unrecognized-lifecycle rejection is\n * carried under — a DISTINCT key from `LIFECYCLE_BLOCKED_META_KEY`, mirroring\n * `POLICY_DENIED_META_KEY` vs. `LIFECYCLE_BLOCKED_META_KEY` being genuinely distinct keys\n * rather than one key with a varying `error` string inside it. `retired` (a governance\n * refusal) and an unrecognized `lifecycle` (a compatibility refusal) are different facts with\n * different remediations and must be trivially distinguishable to a client — see\n * `exposure.ts`'s `Exposure.blockedReason` doc comment. */\nexport const LIFECYCLE_UNEVALUATABLE_META_KEY = \"dev.archstone/lifecycle_unevaluatable\";\n\n/** #43 ADD-43: the namespaced `_meta` key a POLICY denial is carried under — the third use of\n * the ADD-19 Rev 2 D-3′/D-6 precedent, verbatim: never `structuredContent`, because the\n * reference SDK client validates that against the tool's `outputSchema` unconditionally (not\n * gated on `isError`) and an error object there crashes it. A distinct key from the other two\n * so a client can tell \"refused by policy before any connector work\" from \"blocked by\n * lifecycle\" from \"the provider's response violated the contract\" — the three are mutually\n * exclusive on one call (BR-27). The object discloses the reason code and the capability id\n * and NOTHING about the policy itself: no metadata.id, no allow/deny entry, no other\n * principal's identifier (BR-30, Rule #7 — the MCP client is the untrusted side). */\nexport const POLICY_DENIED_META_KEY = \"dev.archstone/policy_denied\";\n\n/** Route an MCP tool call to the REST provider and format the result as MCP content. */\nexport async function callTool(\n registry: Registry,\n name: string,\n args: Record<string, unknown>,\n opts?: InvokeOptions,\n): Promise<CallResult> {\n const tool = registry.getCapability(name);\n if (!tool) {\n // #44: NO audit record. `metadata.capabilityId` is required and the only value available\n // here is an unvalidated, caller-chosen string that is not a CDL id — writing it there\n // would put unbounded attacker-controlled values into the audit log's primary correlation\n // key, in a file a compliance process treats as evidence. An `Execution` record audits a\n // capability invocation attempt; a call naming no capability is a protocol-level event and\n // belongs to the host's own mount-point instrumentation. Named residual, deliberately\n // accepted: tool-name probing (and the collision defence, which also lands here) is\n // invisible to the audit trail.\n return { content: [{ type: \"text\", text: `unknown tool: ${name}` }], isError: true };\n }\n\n // #44: the attempt clock starts HERE — before the exposure gate and before policy evaluation,\n // so every refused attempt still carries a real `startedAt` and can be placed on a timeline.\n // With no sink configured this is a strict no-op: no clock read, no id, no record, no\n // allocation, and every result byte-for-byte what it was before this increment.\n const auditSink = opts?.auditSink;\n const startedAt = auditSink ? auditNow() : \"\";\n const audit = (status: ExecutionStatus): void => {\n if (!auditSink) return;\n emitExecutionRecord(\n auditSink,\n buildExecutionRecord({\n tool,\n input: args,\n // Fixed by this call site, never host-configurable: an auditor must be able to trust\n // that a record claiming `mcp` came from the MCP path. `mcpHandler` mounts this same\n // path and therefore also records `mcp` — the value names the protocol surface the call\n // arrived on, not the npm package that mounted it.\n consumer: \"mcp\",\n caller: opts?.caller,\n sessionId: opts?.sessionId,\n workflowId: opts?.workflowId,\n startedAt,\n status,\n }),\n );\n };\n\n // ADD-24 D-10/D-11: `lifecycle: retired` sets `invocable:false` (health never does, D-9) —\n // checked immediately after resolution, before any connector/response work, same call-site\n // discipline the `contract_violation` check already uses downstream.\n //\n // ADD-56 D-1/D-2: `lifecycleExposure` is now TOTAL — an unrecognized `lifecycle` value (only\n // reachable via a hand-written or forward-versioned `fromIR` artifact, ADD-0008 D-2) ALSO sets\n // `invocable:false`, distinguished from `retired` by `exposure.blockedReason`. The two are\n // different facts with different remediations (governance vs. compatibility — see\n // `exposure.ts`'s `Exposure.blockedReason` doc comment) and MUST NOT share a message or a\n // `denialReason`. `exposure.blockedReason === \"unevaluatable\"` is the only branch this can take\n // here: the `undefined` case (D-4's unknown-id fallback) cannot occur, because `tool` above was\n // already resolved via `getCapability`, which reads the identical `exposureById` map.\n const exposure = registry.getExposure(tool.id);\n if (!exposure.invocable) {\n if (exposure.blockedReason === \"unevaluatable\") {\n const text = `capability '${tool.id}' declares a lifecycle this build does not recognize and cannot evaluate — refusing (fail-closed).`;\n // #44: `denied`, never `failed` — refusing on a compatibility gap is not a backend\n // failure. Deliberately distinct denialReason/message/meta-key from the `retired` branch\n // below (ADD-56 D-2/D-3) — never `LIFECYCLE_BLOCKED_REASON`.\n audit({ phase: \"denied\", message: text, denialReason: LIFECYCLE_UNEVALUATABLE_REASON });\n return {\n content: [{ type: \"text\", text }],\n _meta: {\n [LIFECYCLE_UNEVALUATABLE_META_KEY]: { error: \"lifecycle_unevaluatable\", capability: tool.id, lifecycle: tool.lifecycle },\n },\n isError: true,\n };\n }\n const text = `capability '${tool.id}' is retired and can no longer be invoked.`;\n // #44: `denied`, never `failed` — a refusal by lifecycle is a refusal by governance, and\n // recording it as a failure would conflate it with \"the backend broke\" in the one log where\n // that distinction is the entire product. This gate is the SECOND (and only other) producer\n // of `phase: \"denied\"`, and the reason code the policy evaluator can never return. It runs\n // before policy deliberately, so a capability that is both retired and policy-denied records\n // `lifecycle_blocked`, matching the pinned gate order.\n audit({ phase: \"denied\", message: text, denialReason: LIFECYCLE_BLOCKED_REASON });\n return {\n content: [{ type: \"text\", text }],\n _meta: { [LIFECYCLE_BLOCKED_META_KEY]: { error: \"lifecycle_blocked\", capability: tool.id, lifecycle: tool.lifecycle } },\n isError: true,\n };\n }\n\n // #43 (ADD-43 D-5/D-6): THE policy evaluation point, called unconditionally — for every tool,\n // including one with no resolved policy, because `authenticated` enforcement now lives here\n // rather than inside `invokeRest` (D-4). Deliberately AFTER the ADD-24 exposure gate above, so\n // a `retired` capability reports `lifecycle_blocked` and never `policy_denied` (BR-34), and\n // strictly BEFORE `invokeRest`, so a denial does zero connector work: no env/caller\n // resolution, no URL building, no fetch, and no `onResponse` firing (BR-25).\n // #48: a `resolveCaller` that THREW for this request (rather than returning, even\n // `undefined`) short-circuits straight to a `policy_unevaluatable` denial for every\n // capability, bypassing `evaluatePolicy` entirely — identity extraction itself failed here,\n // which is strictly less trustworthy than \"no credential offered\" and must fail closed\n // regardless of whether THIS capability happens to declare `policies:[authenticated]`\n // (ADD-42 R-11). Reuses the evaluator's own reason code and this function's existing\n // policy-denial response shaping verbatim — no parallel response path.\n const decision: PolicyDecision = opts?.callerResolutionFailed\n ? {\n allowed: false,\n denial: {\n reason: \"policy_unevaluatable\",\n message: `capability '${tool.id}' could not be evaluated — caller identity could not be established (resolveCaller failed) — refusing (fail-closed).`,\n },\n }\n : evaluatePolicy(tool, {\n principal: opts?.caller?.principal,\n credentialPresent: opts?.caller?.accessToken !== undefined,\n });\n if (!decision.allowed) {\n // #44: the evaluator's OWN reason code, copied verbatim — no re-spelling, no mapping table,\n // no superset for the policy case. The message is likewise the evaluator's, unaltered:\n // the record copies, it never authors.\n audit({ phase: \"denied\", message: decision.denial.message, denialReason: decision.denial.reason });\n return {\n content: [{ type: \"text\", text: decision.denial.message }],\n _meta: {\n [POLICY_DENIED_META_KEY]: {\n error: \"policy_denied\",\n // `tool.id` — the unsanitized CDL id, never the MCP-sanitized advertised `name`\n // lookup key (BR-28, mirroring ADD-19 and ADD-30 BR-7).\n capability: tool.id,\n reason: decision.denial.reason,\n },\n },\n isError: true,\n };\n }\n\n // #45 (ADD-45 D-2/D-3): the rate-limit evaluation step, called at the SAME point as the policy\n // evaluator above — immediately after it allows, strictly before `invokeRest` — so a\n // rate-limited call does exactly as much connector work as a policy-denied one: none. Reuses\n // the identical `policy_denied` `_meta` shape and audit wiring; only the reason code differs.\n const rateDecision = await evaluateRateLimit(tool, { principal: opts?.caller?.principal }, opts?.rateLimitCounter);\n if (!rateDecision.allowed) {\n audit({ phase: \"denied\", message: rateDecision.denial.message, denialReason: rateDecision.denial.reason });\n return {\n content: [{ type: \"text\", text: rateDecision.denial.message }],\n _meta: {\n [POLICY_DENIED_META_KEY]: {\n error: \"policy_denied\",\n capability: tool.id,\n reason: rateDecision.denial.reason,\n },\n },\n isError: true,\n };\n }\n\n const result = await invokeRest(tool, args, opts);\n if (!result.ok) {\n // #44: every attempt that never completed a usable round-trip — unbound capability, missing\n // env var, missing caller credential, no baseUrl, an allowlist rejection, a missing path\n // parameter, a network error, a non-2xx response — records `failed` carrying the shipped\n // error text verbatim, so a deployer greps the audit log and finds the same string the\n // agent was shown.\n const text = result.error ?? \"invocation failed\";\n audit({ phase: \"failed\", message: text });\n return { content: [{ type: \"text\", text }], isError: true };\n }\n\n // #12 (ADD-12): a binding with a `response:` mapping is now MAPPED + VALIDATED against the\n // resource — the outputSchema (ADD-11) becomes an enforced contract, not just declared.\n if (tool.response) {\n const mapped = applyResponseMapping(tool, result.data, registry.ir.resources);\n if (mapped.status === \"violation\") {\n // Fail closed (D-6): the declared output shape was not met — no raw pass-through.\n const missing = mapped.missing ?? [];\n // The text is unchanged byte-for-byte; it moved into a shared helper (#44) only so the\n // embedded consumer, whose own result carries no text, records the identical sentence.\n const text = contractViolationMessage(tool.id, missing);\n // #19 (ADD-19 Rev 2 D-3′/D-6): structured error object lives in `_meta`, never\n // `structuredContent` — the reference SDK client validates `structuredContent` against\n // the tool's `outputSchema` unconditionally (not gated on `isError`), so a VIOLATION\n // object there (which never conforms to the success outputSchema) crashes the client\n // (verified live against the SDK's own InMemoryTransport, R2.0/R2.2). `capability` is\n // `tool.id`, the unsanitized CDL id — never the MCP-sanitized `name` lookup key (BR-7).\n // #44: a VIOLATION is `failed` — the declared output shape was not met.\n audit({ phase: \"failed\", message: text });\n return {\n content: [{ type: \"text\", text }],\n _meta: { [CONTRACT_VIOLATION_META_KEY]: { error: \"contract_violation\", capability: tool.id, missing } },\n isError: true,\n };\n }\n const content: CallResult[\"content\"] = [{ type: \"text\", text: JSON.stringify(mapped.data, null, 2) }];\n if (mapped.status === \"degraded\") {\n content.push({ type: \"text\", text: `note: optional field(s) absent (degraded): ${(mapped.degraded ?? []).join(\", \")}` });\n }\n // #44: `degraded` records `succeeded`, NOT `failed` — every *required* field was present and\n // an optional one was not, so the invocation succeeded. Pinned in a comment because\n // \"degraded\" reads like a failure and the next reader will guess otherwise.\n audit({ phase: \"succeeded\" });\n return { content, structuredContent: mapped.data, isError: false };\n }\n\n // No response mapping: today's raw pass-through (rollout-safe). The declared outputSchema is\n // NOT yet enforced for these tools — add a `response:` block to close the loop (ADD-12 R-3).\n const out: CallResult = { content: [{ type: \"text\", text: JSON.stringify(result.data ?? null, null, 2) }], isError: false };\n if (tool.output.length > 0) {\n const data = result.data;\n if (data && typeof data === \"object\" && !Array.isArray(data)) {\n out.structuredContent = data as Record<string, unknown>;\n }\n }\n // #44: `status.output` is deliberately NOT populated here (nor anywhere) — `result.data` is\n // exactly the payload the record must never carry.\n audit({ phase: \"succeeded\" });\n return out;\n}\n\n/** Build an MCP Server that lists and invokes the registry's tools. */\nexport function createMcpServer(registry: Registry, opts?: InvokeOptions): Server {\n const server = new Server({ name: \"archstone\", version: \"0\" }, { capabilities: { tools: {} } });\n\n server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions(registry) }));\n\n server.setRequestHandler(CallToolRequestSchema, async (req) => {\n const args = (req.params.arguments ?? {}) as Record<string, unknown>;\n const result = await callTool(registry, req.params.name, args, opts);\n return result as CallToolResult;\n });\n\n return server;\n}\n"],"mappings":";AAYA,SAAS,cAAc;AACvB;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AAEP;AAAA,EAEE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AACP,SAAS,kBAAsC;AAkFxC,SAAS,kBAAkB,QAA0D;AAC1F,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO,EAAE,cAAc,KAAK;AAAA,IAC9B,KAAK;AACH,aAAO,EAAE,iBAAiB,MAAM,gBAAgB,MAAM;AAAA,IACxD,KAAK;AACH,aAAO,EAAE,iBAAiB,MAAM;AAAA,IAClC;AAEE,aAAO;AAAA,EACX;AACF;AA+BO,SAAS,gBAAgB,UAAkC;AAChE,QAAM,YAAY,SAAS,GAAG;AAC9B,SAAO,SACJ,eAAe,EACf,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,SAAS,YAAY,EAAE,EAAE,EAAE,MAAM,EACzD,IAAI,CAAC,EAAE,MAAM,MAAM,EAAE,MAAM;AAC1B,UAAM,OAAO,SAAS,YAAY,EAAE,EAAE,EAAE;AACxC,UAAM,MAAkB;AAAA,MACtB;AAAA,MACA,aAAa,OAAO,GAAG,EAAE,WAAW,KAAK,KAAK,IAAI,MAAM,EAAE;AAAA,MAC1D,aAAa,gBAAgB,EAAE,OAAO,SAAS;AAAA,IACjD;AACA,QAAI,EAAE,OAAO,SAAS,EAAG,KAAI,eAAe,iBAAiB,EAAE,QAAQ,SAAS;AAChF,UAAM,cAAc,kBAAkB,EAAE,MAAM;AAC9C,QAAI,YAAa,KAAI,cAAc;AACnC,WAAO;AAAA,EACT,CAAC;AACL;AAeO,IAAM,8BAA8B;AAQpC,IAAM,6BAA6B;AASnC,IAAM,mCAAmC;AAWzC,IAAM,yBAAyB;AAGtC,eAAsB,SACpB,UACA,MACA,MACA,MACqB;AACrB,QAAM,OAAO,SAAS,cAAc,IAAI;AACxC,MAAI,CAAC,MAAM;AAST,WAAO,EAAE,SAAS,CAAC,EAAE,MAAM,QAAQ,MAAM,iBAAiB,IAAI,GAAG,CAAC,GAAG,SAAS,KAAK;AAAA,EACrF;AAMA,QAAM,YAAY,MAAM;AACxB,QAAM,YAAY,YAAY,SAAS,IAAI;AAC3C,QAAM,QAAQ,CAAC,WAAkC;AAC/C,QAAI,CAAC,UAAW;AAChB;AAAA,MACE;AAAA,MACA,qBAAqB;AAAA,QACnB;AAAA,QACA,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA,QAKP,UAAU;AAAA,QACV,QAAQ,MAAM;AAAA,QACd,WAAW,MAAM;AAAA,QACjB,YAAY,MAAM;AAAA,QAClB;AAAA,QACA;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AAcA,QAAM,WAAW,SAAS,YAAY,KAAK,EAAE;AAC7C,MAAI,CAAC,SAAS,WAAW;AACvB,QAAI,SAAS,kBAAkB,iBAAiB;AAC9C,YAAMA,QAAO,eAAe,KAAK,EAAE;AAInC,YAAM,EAAE,OAAO,UAAU,SAASA,OAAM,cAAc,+BAA+B,CAAC;AACtF,aAAO;AAAA,QACL,SAAS,CAAC,EAAE,MAAM,QAAQ,MAAAA,MAAK,CAAC;AAAA,QAChC,OAAO;AAAA,UACL,CAAC,gCAAgC,GAAG,EAAE,OAAO,2BAA2B,YAAY,KAAK,IAAI,WAAW,KAAK,UAAU;AAAA,QACzH;AAAA,QACA,SAAS;AAAA,MACX;AAAA,IACF;AACA,UAAM,OAAO,eAAe,KAAK,EAAE;AAOnC,UAAM,EAAE,OAAO,UAAU,SAAS,MAAM,cAAc,yBAAyB,CAAC;AAChF,WAAO;AAAA,MACL,SAAS,CAAC,EAAE,MAAM,QAAQ,KAAK,CAAC;AAAA,MAChC,OAAO,EAAE,CAAC,0BAA0B,GAAG,EAAE,OAAO,qBAAqB,YAAY,KAAK,IAAI,WAAW,KAAK,UAAU,EAAE;AAAA,MACtH,SAAS;AAAA,IACX;AAAA,EACF;AAeA,QAAM,WAA2B,MAAM,yBACnC;AAAA,IACE,SAAS;AAAA,IACT,QAAQ;AAAA,MACN,QAAQ;AAAA,MACR,SAAS,eAAe,KAAK,EAAE;AAAA,IACjC;AAAA,EACF,IACA,eAAe,MAAM;AAAA,IACnB,WAAW,MAAM,QAAQ;AAAA,IACzB,mBAAmB,MAAM,QAAQ,gBAAgB;AAAA,EACnD,CAAC;AACL,MAAI,CAAC,SAAS,SAAS;AAIrB,UAAM,EAAE,OAAO,UAAU,SAAS,SAAS,OAAO,SAAS,cAAc,SAAS,OAAO,OAAO,CAAC;AACjG,WAAO;AAAA,MACL,SAAS,CAAC,EAAE,MAAM,QAAQ,MAAM,SAAS,OAAO,QAAQ,CAAC;AAAA,MACzD,OAAO;AAAA,QACL,CAAC,sBAAsB,GAAG;AAAA,UACxB,OAAO;AAAA;AAAA;AAAA,UAGP,YAAY,KAAK;AAAA,UACjB,QAAQ,SAAS,OAAO;AAAA,QAC1B;AAAA,MACF;AAAA,MACA,SAAS;AAAA,IACX;AAAA,EACF;AAMA,QAAM,eAAe,MAAM,kBAAkB,MAAM,EAAE,WAAW,MAAM,QAAQ,UAAU,GAAG,MAAM,gBAAgB;AACjH,MAAI,CAAC,aAAa,SAAS;AACzB,UAAM,EAAE,OAAO,UAAU,SAAS,aAAa,OAAO,SAAS,cAAc,aAAa,OAAO,OAAO,CAAC;AACzG,WAAO;AAAA,MACL,SAAS,CAAC,EAAE,MAAM,QAAQ,MAAM,aAAa,OAAO,QAAQ,CAAC;AAAA,MAC7D,OAAO;AAAA,QACL,CAAC,sBAAsB,GAAG;AAAA,UACxB,OAAO;AAAA,UACP,YAAY,KAAK;AAAA,UACjB,QAAQ,aAAa,OAAO;AAAA,QAC9B;AAAA,MACF;AAAA,MACA,SAAS;AAAA,IACX;AAAA,EACF;AAEA,QAAM,SAAS,MAAM,WAAW,MAAM,MAAM,IAAI;AAChD,MAAI,CAAC,OAAO,IAAI;AAMd,UAAM,OAAO,OAAO,SAAS;AAC7B,UAAM,EAAE,OAAO,UAAU,SAAS,KAAK,CAAC;AACxC,WAAO,EAAE,SAAS,CAAC,EAAE,MAAM,QAAQ,KAAK,CAAC,GAAG,SAAS,KAAK;AAAA,EAC5D;AAIA,MAAI,KAAK,UAAU;AACjB,UAAM,SAAS,qBAAqB,MAAM,OAAO,MAAM,SAAS,GAAG,SAAS;AAC5E,QAAI,OAAO,WAAW,aAAa;AAEjC,YAAM,UAAU,OAAO,WAAW,CAAC;AAGnC,YAAM,OAAO,yBAAyB,KAAK,IAAI,OAAO;AAQtD,YAAM,EAAE,OAAO,UAAU,SAAS,KAAK,CAAC;AACxC,aAAO;AAAA,QACL,SAAS,CAAC,EAAE,MAAM,QAAQ,KAAK,CAAC;AAAA,QAChC,OAAO,EAAE,CAAC,2BAA2B,GAAG,EAAE,OAAO,sBAAsB,YAAY,KAAK,IAAI,QAAQ,EAAE;AAAA,QACtG,SAAS;AAAA,MACX;AAAA,IACF;AACA,UAAM,UAAiC,CAAC,EAAE,MAAM,QAAQ,MAAM,KAAK,UAAU,OAAO,MAAM,MAAM,CAAC,EAAE,CAAC;AACpG,QAAI,OAAO,WAAW,YAAY;AAChC,cAAQ,KAAK,EAAE,MAAM,QAAQ,MAAM,+CAA+C,OAAO,YAAY,CAAC,GAAG,KAAK,IAAI,CAAC,GAAG,CAAC;AAAA,IACzH;AAIA,UAAM,EAAE,OAAO,YAAY,CAAC;AAC5B,WAAO,EAAE,SAAS,mBAAmB,OAAO,MAAM,SAAS,MAAM;AAAA,EACnE;AAIA,QAAM,MAAkB,EAAE,SAAS,CAAC,EAAE,MAAM,QAAQ,MAAM,KAAK,UAAU,OAAO,QAAQ,MAAM,MAAM,CAAC,EAAE,CAAC,GAAG,SAAS,MAAM;AAC1H,MAAI,KAAK,OAAO,SAAS,GAAG;AAC1B,UAAM,OAAO,OAAO;AACpB,QAAI,QAAQ,OAAO,SAAS,YAAY,CAAC,MAAM,QAAQ,IAAI,GAAG;AAC5D,UAAI,oBAAoB;AAAA,IAC1B;AAAA,EACF;AAGA,QAAM,EAAE,OAAO,YAAY,CAAC;AAC5B,SAAO;AACT;AAGO,SAAS,gBAAgB,UAAoB,MAA8B;AAChF,QAAM,SAAS,IAAI,OAAO,EAAE,MAAM,aAAa,SAAS,IAAI,GAAG,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC;AAE9F,SAAO,kBAAkB,wBAAwB,aAAa,EAAE,OAAO,gBAAgB,QAAQ,EAAE,EAAE;AAEnG,SAAO,kBAAkB,uBAAuB,OAAO,QAAQ;AAC7D,UAAM,OAAQ,IAAI,OAAO,aAAa,CAAC;AACvC,UAAM,SAAS,MAAM,SAAS,UAAU,IAAI,OAAO,MAAM,MAAM,IAAI;AACnE,WAAO;AAAA,EACT,CAAC;AAED,SAAO;AACT;","names":["text"]} |
| import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { IRTool } from '@archstone/compiler'; | ||
| import { Registry } from '@archstone/emitter-support'; | ||
| import { InvokeOptions } from '@archstone/provider-rest'; | ||
| type JsonSchema = Record<string, unknown>; | ||
| /** | ||
| * #126: the subset of MCP's `ToolAnnotations` this emitter is entitled to populate from CDL. | ||
| * | ||
| * Derived from the SDK's own `ToolAnnotations` via `Pick` rather than re-declared, so the three | ||
| * field names are checked against the installed SDK at compile time instead of being trusted to | ||
| * a comment. Confirmed present on the tool definition at the version `^1.12.0` resolves to | ||
| * (1.30.0): `ToolSchema.annotations` is `ToolAnnotationsSchema.optional()`, and that schema | ||
| * declares `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. | ||
| * | ||
| * The two members deliberately NOT picked, because no CDL field means them: | ||
| * - `openWorldHint` — whether the tool's domain of interaction is open or closed. Archstone | ||
| * knows a capability's `effect`, never the shape of the world behind its connector. A guess | ||
| * here would be indistinguishable, to a client, from a fact. | ||
| * - `title` — a display concern with no CDL source; `description` already carries the | ||
| * business-authored text (plus any exposure hint, ADD-24). | ||
| */ | ||
| type McpToolAnnotations = Pick<ToolAnnotations, "readOnlyHint" | "destructiveHint" | "idempotentHint">; | ||
| /** | ||
| * #126: lower a capability's `effect` — the one field `archstone init` refuses to guess and | ||
| * insists a human confirm — into MCP tool annotations, so the client's tool-confirmation dialog | ||
| * can tell `tourism.search` from a capability that charges a card. Before this, `effect` was | ||
| * compiled, carried through the IR, and dropped here: `McpToolDef` had no field for it and | ||
| * `toolDefinitions` never read it, so the ONLY human-in-the-loop mechanism that exists today | ||
| * (`human-approval` is declared and unenforced — `apply` says so) decided blind. | ||
| * | ||
| * The mapping, exactly and only: | ||
| * read → readOnlyHint: true | ||
| * irreversible → destructiveHint: true, idempotentHint: false | ||
| * write → destructiveHint: false | ||
| * | ||
| * `write`'s single `false` is not a no-op: per the SDK's own schema docs, `destructiveHint` | ||
| * DEFAULTS TO TRUE when absent, so stating it is the only thing that keeps a `write` from | ||
| * reaching the client indistinguishable from an `irreversible`. Conversely `read` needs no | ||
| * `destructiveHint` — the same docs describe that field as meaningful only when `readOnlyHint` | ||
| * is false. | ||
| * | ||
| * The seam in this mapping, named rather than papered over: MCP describes | ||
| * `destructiveHint: false` as a tool whose updates are purely additive, whereas CDL's `write` | ||
| * means "modifies, reversibly" — `examples/manifests/booking`'s `tourism.cancel` is an | ||
| * `effect: write` and is plainly not additive. The two vocabularies are adjacent, not | ||
| * identical, and #126 ratified this pairing as the closest available fit, not a perfect one. | ||
| * Read `destructiveHint: false` here as *not irreversible*, the distinction CDL actually draws. | ||
| * Do not "improve" this into a finer per-capability judgement: CDL has no additivity primitive, | ||
| * so anything more specific would be invented rather than derived — the same reasoning that | ||
| * keeps `openWorldHint` off the list above. | ||
| * | ||
| * **This is a hint, not a control.** Nothing in Archstone gates, refuses, or retries on the | ||
| * value — deliberately (#126, Not in scope). MCP's own spec says as much of every annotation, | ||
| * and a client is free to ignore all of it. | ||
| * | ||
| * WHY THIS LIVES HERE AND NOT IN @archstone/emitter-support. CLAUDE.md puts lowering that is | ||
| * *shared* between emitters in emitter-support; `readOnlyHint`/`destructiveHint`/ | ||
| * `idempotentHint` are MCP-protocol vocabulary with, as of this increment, exactly one | ||
| * consumer. Every target format `@archstone/agent`'s `tools()` emits was checked against its | ||
| * live reference before this call was made — the field lists, references and dates are in | ||
| * `packages/agent/src/tools.ts`'s header comment, pinned by `packages/agent/test/tools.test.ts` | ||
| * — and NONE has an equivalent field, so there is nothing to share. Note that `effect` itself | ||
| * still reaches every `@archstone/agent` consumer: they hold the Registry and read it off the | ||
| * IR directly, which is exactly why nothing needed inventing there. `exposure.ts` — the neutral | ||
| * presentation module, and the obvious tempting | ||
| * home — states the rule this follows verbatim: "MCP-specific rendering ... belongs only in | ||
| * @archstone/runtime's server.ts, never here." Move it to emitter-support the day a second | ||
| * emitter needs it, and not before: re-encoding `read|write|irreversible` into some other | ||
| * neutral vocabulary one layer down would add a translation with no second reader, when the IR | ||
| * already carries the fact in the neutral vocabulary that matters. | ||
| * | ||
| * TOTAL, on purpose — same trust boundary and same reasoning as `lifecycleExposure`'s | ||
| * `default` branch (ADD-56 D-1). `effect`'s static type is a closed three-member union, but the | ||
| * value reaches this function un-runtime-validated whenever the Registry was built by | ||
| * `fromIR`'s `json as IR` cast (`agent/src/index.ts`, which validates only `version === "0"`) | ||
| * and served through `mcpHandler` → `createHttpHandler` → `createMcpServer` → here. A | ||
| * hand-written or forward-versioned artifact can carry any string. That case returns | ||
| * `undefined` — NO annotations at all — rather than any positive claim, so the client falls | ||
| * back to MCP's own documented defaults (`readOnlyHint: false`, `destructiveHint: true`), which | ||
| * are the cautious reading. The one outcome that must never be reachable from an unrecognized | ||
| * value is `readOnlyHint: true`, and returning nothing is the only answer that guarantees it. | ||
| */ | ||
| declare function effectAnnotations(effect: IRTool["effect"]): McpToolAnnotations | undefined; | ||
| interface McpToolDef { | ||
| name: string; | ||
| description: string; | ||
| inputSchema: JsonSchema; | ||
| outputSchema?: JsonSchema; | ||
| /** #126: derived from the capability's `effect` — see `effectAnnotations`. Absent only when | ||
| * `effect` is a value this build does not recognize (possible solely via `fromIR`). */ | ||
| annotations?: McpToolAnnotations; | ||
| } | ||
| /** The MCP tool list: only invocable (bound) capabilities become tools — Registry's | ||
| * `invocableTools()` (ADD-30 D-3) is the single source of truth shared by this function | ||
| * (what's listed) and `callTool` (what's resolvable, via `getCapability`), keeping the two | ||
| * consistent. Input and output fields lower against the IR resource registry, so a | ||
| * `collection: Stay` output emits a typed, described `outputSchema` (not a bare | ||
| * `{type:object}`). | ||
| * | ||
| * ADD-24: a bound tool whose combined exposure (`registry.getExposure`, lifecycle + optional | ||
| * health) is `listed:false` (lifecycle `experimental`/`retired`) is dropped from the returned | ||
| * list entirely — unlisted, per D-10, though `experimental` remains callable by id (see | ||
| * `callTool`). A tool carrying a `hint` (beta/deprecated, or a yellow/red health reading) has | ||
| * its text appended to `description` — the only MCP-specific rendering of the neutral | ||
| * exposure the emitter-support layer computed. | ||
| * | ||
| * #126: every listed tool also carries `annotations` derived from its `effect` | ||
| * (`effectAnnotations`), so the client's confirmation dialog stops treating a search and a | ||
| * payment identically. Both transports reach this one function — stdio via `serveStdio`'s | ||
| * `createMcpServer`, HTTP via `createHttpHandler`'s — so there is no second place to keep in | ||
| * step. */ | ||
| declare function toolDefinitions(registry: Registry): McpToolDef[]; | ||
| interface CallResult { | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| structuredContent?: Record<string, unknown>; | ||
| isError: boolean; | ||
| _meta?: Record<string, unknown>; | ||
| } | ||
| /** #19 ADD-19 Rev 2 D-6: the namespaced `_meta` key a VIOLATION result's structured error | ||
| * object is carried under. Never populated on `structuredContent` (D-3′) — the reference | ||
| * MCP SDK client validates `structuredContent` against `outputSchema` whenever the tool | ||
| * declares one, regardless of `isError`, so a non-conforming error object there crashes the | ||
| * client. `_meta` is untouched by that validation and passes through the client's zod parse | ||
| * unstripped (`ResultSchema`/`RequestMetaSchema` are `z.looseObject`). */ | ||
| declare const CONTRACT_VIOLATION_META_KEY = "dev.archstone/contract_violation"; | ||
| /** ADD-24 D-11: the namespaced `_meta` key a `retired` (or otherwise `invocable:false`) | ||
| * tool's rejection is carried under — reuses `CONTRACT_VIOLATION_META_KEY`'s precedent | ||
| * (ADD-19 Rev 2 D-3′/D-6) verbatim: never `structuredContent`, so the reference SDK client's | ||
| * unconditional `structuredContent`-against-`outputSchema` validation never sees it. A | ||
| * distinct key (not `CONTRACT_VIOLATION_META_KEY`) so a client distinguishes "this call was | ||
| * blocked before any connector work" from "the provider's response violated the contract". */ | ||
| declare const LIFECYCLE_BLOCKED_META_KEY = "dev.archstone/lifecycle_blocked"; | ||
| /** ADD-56 D-2/OQ-56-B: the namespaced `_meta` key an unrecognized-lifecycle rejection is | ||
| * carried under — a DISTINCT key from `LIFECYCLE_BLOCKED_META_KEY`, mirroring | ||
| * `POLICY_DENIED_META_KEY` vs. `LIFECYCLE_BLOCKED_META_KEY` being genuinely distinct keys | ||
| * rather than one key with a varying `error` string inside it. `retired` (a governance | ||
| * refusal) and an unrecognized `lifecycle` (a compatibility refusal) are different facts with | ||
| * different remediations and must be trivially distinguishable to a client — see | ||
| * `exposure.ts`'s `Exposure.blockedReason` doc comment. */ | ||
| declare const LIFECYCLE_UNEVALUATABLE_META_KEY = "dev.archstone/lifecycle_unevaluatable"; | ||
| /** #43 ADD-43: the namespaced `_meta` key a POLICY denial is carried under — the third use of | ||
| * the ADD-19 Rev 2 D-3′/D-6 precedent, verbatim: never `structuredContent`, because the | ||
| * reference SDK client validates that against the tool's `outputSchema` unconditionally (not | ||
| * gated on `isError`) and an error object there crashes it. A distinct key from the other two | ||
| * so a client can tell "refused by policy before any connector work" from "blocked by | ||
| * lifecycle" from "the provider's response violated the contract" — the three are mutually | ||
| * exclusive on one call (BR-27). The object discloses the reason code and the capability id | ||
| * and NOTHING about the policy itself: no metadata.id, no allow/deny entry, no other | ||
| * principal's identifier (BR-30, Rule #7 — the MCP client is the untrusted side). */ | ||
| declare const POLICY_DENIED_META_KEY = "dev.archstone/policy_denied"; | ||
| /** Route an MCP tool call to the REST provider and format the result as MCP content. */ | ||
| declare function callTool(registry: Registry, name: string, args: Record<string, unknown>, opts?: InvokeOptions): Promise<CallResult>; | ||
| /** Build an MCP Server that lists and invokes the registry's tools. */ | ||
| declare function createMcpServer(registry: Registry, opts?: InvokeOptions): Server; | ||
| export { CONTRACT_VIOLATION_META_KEY as C, LIFECYCLE_BLOCKED_META_KEY as L, type McpToolAnnotations as M, POLICY_DENIED_META_KEY as P, type CallResult as a, LIFECYCLE_UNEVALUATABLE_META_KEY as b, createMcpServer as c, type McpToolDef as d, callTool as e, effectAnnotations as f, toolDefinitions as t }; |
+3
-1
| import { Registry } from '@archstone/emitter-support'; | ||
| import { InvokeOptions, CallerContext } from '@archstone/provider-rest'; | ||
| export { c as createMcpServer } from './server-D3gsLwzV.js'; | ||
| export { c as createMcpServer } from './server-Bs4Zr35L.js'; | ||
| import '@modelcontextprotocol/sdk/server/index.js'; | ||
| import '@modelcontextprotocol/sdk/types.js'; | ||
| import '@archstone/compiler'; | ||
@@ -6,0 +8,0 @@ interface CreateHttpHandlerOptions { |
+1
-1
| import { | ||
| createMcpServer | ||
| } from "./chunk-YFNW2UHK.js"; | ||
| } from "./chunk-7GP6HYWZ.js"; | ||
@@ -5,0 +5,0 @@ // src/http.ts |
+2
-1
@@ -7,4 +7,5 @@ import { LoadIssue } from '@archstone/schema'; | ||
| export { ContractRecording, GoldenFixture, ProbeOutcome, RecordContractOptions, SkippedVerification, ToolVerification, VerifyRun, VerifyScope, recordContract, runVerify, verifyTool } from './verify.js'; | ||
| export { C as CONTRACT_VIOLATION_META_KEY, a as CallResult, L as LIFECYCLE_BLOCKED_META_KEY, b as LIFECYCLE_UNEVALUATABLE_META_KEY, M as McpToolDef, P as POLICY_DENIED_META_KEY, d as callTool, c as createMcpServer, t as toolDefinitions } from './server-D3gsLwzV.js'; | ||
| export { C as CONTRACT_VIOLATION_META_KEY, a as CallResult, L as LIFECYCLE_BLOCKED_META_KEY, b as LIFECYCLE_UNEVALUATABLE_META_KEY, M as McpToolAnnotations, d as McpToolDef, P as POLICY_DENIED_META_KEY, e as callTool, c as createMcpServer, f as effectAnnotations, t as toolDefinitions } from './server-Bs4Zr35L.js'; | ||
| import '@modelcontextprotocol/sdk/server/index.js'; | ||
| import '@modelcontextprotocol/sdk/types.js'; | ||
@@ -11,0 +12,0 @@ /** Conventional health-snapshot file, read once next to the manifest dir (ADD-24 D-8): the |
+3
-1
@@ -8,4 +8,5 @@ import { | ||
| createMcpServer, | ||
| effectAnnotations, | ||
| toolDefinitions | ||
| } from "./chunk-YFNW2UHK.js"; | ||
| } from "./chunk-7GP6HYWZ.js"; | ||
| import { | ||
@@ -203,2 +204,3 @@ applyResponseMapping, | ||
| createMcpServer, | ||
| effectAnnotations, | ||
| inputJsonSchema, | ||
@@ -205,0 +207,0 @@ jsonLinesAuditSink, |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/registry.ts","../src/mcp.ts","../src/adopt.ts","../src/audit-file.ts"],"sourcesContent":["// @archstone/runtime — Capability Registry (#5)\n//\n// The product kernel: capabilities queryable at runtime, indexed over the IR.\n// File-backed (no DB) — the IR is derived from manifests on disk. The MCP emitter\n// (#7) consumes this to list and resolve tools.\n//\n// `Registry` (index-only) moved to @archstone/emitter-support (ADD-0008 #27) — re-exported\n// here for back-compat so nothing downstream breaks. This file keeps the fs-touching\n// pipeline (`buildRegistry`), which is why the /http subpath (http.ts) never imports it.\n//\n// ADD-24 (#24): `buildRegistry` also optionally reads a conventional health-snapshot file\n// (`readHealthSnapshot`, below) — the ONE other fs-touching, network-free addition this ADD\n// makes. Binding health itself is never computed here (that's `archstone verify`'s own live\n// probe, ADD-18 D-5) — only its already-serialized `--json` output is read back.\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { load, type LoadResult, type LoadIssue } from \"@archstone/schema\";\nimport { validateSemantics, compile, type Diagnostic } from \"@archstone/compiler\";\nimport { Registry, type HealthStatus } from \"@archstone/emitter-support\";\n\nexport { Registry } from \"@archstone/emitter-support\";\n\n/** Conventional health-snapshot file, read once next to the manifest dir (ADD-24 D-8): the\n * operator/CI populates it by redirecting the ALREADY-shipped `archstone verify --json`\n * output here — no new serialization. `buildRegistry` reads it (fs, but no network — the\n * live probe stays exclusively `verify`'s, ADD-18 D-5) and hands the parsed map to\n * `Registry`, which composes it with each tool's lifecycle exposure (ADD-24 §7 step 5). */\nexport const HEALTH_SNAPSHOT_FILE = \".archstone-health.json\";\n\nconst HEALTH_STATUSES: ReadonlySet<string> = new Set([\"green\", \"yellow\", \"red\"]);\n\n/**\n * Parse the `{results: ToolVerification[]}` shape `archstone verify --json` already produces\n * (ADD-20) into a capabilityId -> HealthStatus map. Fail-open (ADD-24 D-9): a missing file, a\n * parse error, or a malformed/unexpected shape all return `undefined` — the caller then\n * proceeds with lifecycle-only exposure, never mistaking \"no snapshot\" for \"known bad\".\n *\n * #43 (ADD-43 D-14): an entry marked `policyDenied` is SKIPPED. A refusal that happened before\n * the call is not a health fact — no request was issued, so nothing about the backend's contract\n * was observed. Left in, it would reach `combineExposure` as a `red` and append\n * `\"binding health: red — the last contract verification failed\"` to the tool's agent-facing\n * description at the highest severity: a statement that never happened, shown to every caller\n * including permitted ones, making policy affect listing (which BR-36 forbids). Skipping leaves\n * the tool with NO health entry, which is exactly ADD-24 D-9's ratified posture — absent health\n * must never be manufactured into known-bad.\n */\nfunction readHealthSnapshot(dir: string): Map<string, HealthStatus> | undefined {\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(join(dir, HEALTH_SNAPSHOT_FILE), \"utf8\"));\n } catch {\n return undefined; // absent, unreadable, or invalid JSON — fail-open\n }\n\n const results = (parsed as { results?: unknown } | null)?.results;\n if (!Array.isArray(results)) return undefined;\n\n const map = new Map<string, HealthStatus>();\n for (const r of results) {\n if (!r || typeof r !== \"object\") continue;\n // ADD-43 D-14: a policy denial is not a health reading — drop it rather than let it become\n // an agent-facing \"the last contract verification failed\" hint for a verification that\n // never ran. See this function's doc comment.\n if ((r as { policyDenied?: unknown }).policyDenied === true) continue;\n const capabilityId = (r as { capabilityId?: unknown }).capabilityId;\n const status = (r as { status?: unknown }).status;\n if (typeof capabilityId === \"string\" && typeof status === \"string\" && HEALTH_STATUSES.has(status)) {\n map.set(capabilityId, status as HealthStatus);\n }\n }\n return map;\n}\n\nexport interface BuildResult {\n ok: boolean;\n registry?: Registry;\n issues: LoadIssue[];\n diagnostics: Diagnostic[];\n}\n\n/**\n * File-backed pipeline: load (#2) → semantic-validate (#3) → compile (#4) → Registry (#5).\n * `registry` is present only when shapes are valid, there are no semantic errors, AND no\n * tool-name collision (ADD-30 D-2) — folded into this function's existing `diagnostics`/\n * `ok` contract (new `tool-name-collision` diagnostic code) rather than a new mechanism, so\n * `serveStdio`/`runServeHttp` (which already refuse to proceed on `!built.ok`) inherit the\n * gate for free.\n */\nexport function buildRegistry(dir: string): BuildResult {\n const model: LoadResult = load(dir);\n const diagnostics = validateSemantics(model);\n const hasErrors = diagnostics.some((d) => d.severity === \"error\");\n let ok = model.ok && !hasErrors;\n\n const registry = ok ? new Registry(compile(model), readHealthSnapshot(dir)) : undefined;\n if (registry) {\n for (const c of registry.toolNameCollisions) {\n ok = false;\n diagnostics.push({\n severity: \"error\",\n code: \"tool-name-collision\",\n message: `tool name '${c.name}' is ambiguous — capabilities ${c.ids.join(\", \")} all sanitize to it`,\n });\n }\n }\n\n return {\n ok,\n registry: ok ? registry : undefined,\n issues: model.issues,\n diagnostics,\n };\n}\n","// @archstone/runtime — MCP emitter (#7) — stdio entrypoint\n//\n// serveStdio builds the registry from disk (buildRegistry, registry.ts) and serves it over\n// stdio, the channel Claude Desktop uses. The fs-free MCP server construction\n// (toolDefinitions/callTool/createMcpServer) lives in ./server (ADD-0008 #27) — re-exported\n// here, alongside the semantic-lowering functions from @archstone/emitter-support, for\n// back-compat so nothing downstream breaks.\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { InvokeOptions } from \"@archstone/provider-rest\";\nimport { buildRegistry } from \"./registry\";\nimport { toolDefinitions, createMcpServer } from \"./server\";\n\nexport { toolName, inputJsonSchema, objectJsonSchema } from \"@archstone/emitter-support\";\n/** #44: the audit sink surface, re-exported so a deployer wiring `serveStdio`/`createMcpServer`\n * imports it from the package they already depend on. See `AuditSink`'s own doc comment for\n * the fire-and-forget contract and for the statement that the trail is best-effort and lossy. */\nexport {\n jsonLinesAuditSink,\n REDACTED,\n LIFECYCLE_BLOCKED_REASON,\n LIFECYCLE_UNEVALUATABLE_REASON,\n} from \"@archstone/emitter-support\";\nexport type {\n AuditSink,\n AuditWritable,\n ExecutionRecord,\n ExecutionStatus,\n ExecutionPhase,\n ExecutionConsumer,\n ExecutionDenialReason,\n} from \"@archstone/emitter-support\";\nexport * from \"./server\";\n\n/**\n * Build the registry from a manifest dir and serve it over stdio (blocks).\n *\n * `invoke` (ADD-32) is forwarded verbatim to `createMcpServer` — this closes a real gap: prior\n * to #32, `serveStdio` passed NO `InvokeOptions` at all, so nothing (not `env`, not `caller`)\n * could ever be injected here. A stdio server is one child process per conversation (Claude\n * Desktop's model) — single-process, single-user by construction — so a static per-process\n * `invoke.caller` is architecturally sound here, unlike the HTTP case (`createHttpHandler`'s\n * `resolveCaller`, which must vary per inbound request).\n */\nexport async function serveStdio(dir: string, invoke?: InvokeOptions): Promise<void> {\n const built = buildRegistry(dir);\n if (!built.ok || !built.registry) {\n // stdout is the MCP channel — all human output goes to stderr.\n console.error(`archstone: cannot serve '${dir}' — manifest invalid:`);\n for (const i of built.issues) console.error(` - ${i.file}: ${i.message}`);\n for (const d of built.diagnostics.filter((x) => x.severity === \"error\")) console.error(` - ${d.message}`);\n process.exit(1);\n }\n const tools = toolDefinitions(built.registry);\n console.error(`archstone: serving ${tools.length} tool(s) over stdio: ${tools.map((t) => t.name).join(\", \") || \"(none)\"}`);\n const server = createMcpServer(built.registry, invoke);\n await server.connect(new StdioServerTransport());\n}\n","// @archstone/runtime — Adoption planner (ADD-117 / ADR-0008).\n//\n// Turns a field ADD-114's drift NAMED into a field the manifest DECLARES. This module is the\n// pure half: it decides what could be adopted and why each rejected candidate was rejected.\n// It touches no disk, asks no human, and mutates nothing — the CLI owns all three (D-6), which\n// is what lets every rule below be tested without a temp directory or a terminal.\n//\n// The rules exist because ADR-0008 draws a hard line: an undeclared field never reaches a\n// model. Adoption is the ONLY way across that line, and it is deliberately a human act.\n\nimport type { IRTool, IRResourceRegistry, JsonType, SemanticType, ShapeDiff } from \"@archstone/compiler\";\n\n/** Why a named path cannot be adopted. Stated, never silently skipped — a candidate that\n * disappears from the report reads as \"there was nothing there\". */\nexport type AdoptionRefusal =\n | \"outside-collection\"\n | \"nested\"\n | \"no-boolean-type\"\n | \"not-a-leaf\"\n | \"already-declared\";\n\nexport interface AdoptableField {\n adoptable: true;\n /** The JSONPath the drift reported, e.g. `$.stays[].boardType`. */\n path: string;\n /** The resource field name it would become, e.g. `boardType`. */\n field: string;\n /** The path written into the binding's `response.map`, relative to the collection item. */\n itemPath: string;\n observed: JsonType;\n /** What it is declared as. See ADD-117 §3 — the table is deliberately dull. */\n semantic: SemanticType;\n}\n\nexport interface UnadoptableField {\n adoptable: false;\n path: string;\n observed: JsonType;\n reason: AdoptionRefusal;\n /** One sentence an operator can act on, or at least understand. */\n detail: string;\n}\n\nexport type AdoptionCandidate = AdoptableField | UnadoptableField;\n\nexport interface AdoptionPlan {\n capabilityId: string;\n /** The resource the binding's `response:` maps onto — the file a field would be added to. */\n resource?: string;\n candidates: AdoptionCandidate[];\n}\n\n/**\n * ADD-117 §3. An observed JSON type becomes exactly one CDL semantic type, or nothing.\n *\n * Deliberately NOT clever. `string` does not become `date` however much a value looked like\n * one, because the shape records types and never values — there is nothing here to\n * pattern-match, and inferring a date from a field NAME is exactly the guess this project\n * refuses to make. `number` does not become `money`, because whether a number is a price is a\n * business fact and `money` carries a currency this field does not have. The human can widen\n * either afterwards; the manifest is theirs.\n */\nfunction semanticFor(observed: JsonType): SemanticType | undefined {\n if (observed === \"string\") return \"text\";\n if (observed === \"number\") return \"quantity\";\n return undefined;\n}\n\nfunction refusalDetail(reason: AdoptionRefusal, observed: JsonType): string {\n switch (reason) {\n case \"outside-collection\":\n return \"outside the collection this capability maps; it is not a field of the resource\";\n case \"nested\":\n return \"nested, or a provider key containing a dot — indistinguishable here; either way the resource field would have to be another resource, which adoption does not create\";\n case \"no-boolean-type\":\n return \"CDL has no boolean semantic type, and declaring it as text would state a lie about the shape\";\n case \"not-a-leaf\":\n return `observed as ${observed}, which is a structure rather than a value`;\n case \"already-declared\":\n return \"already declared by this capability\";\n }\n}\n\n/**\n * The path prefix every field of one collection item shares.\n *\n * A binding's `collection` is a JSONPath over the payload (`$.stays[*]`); a recorded shape\n * flattens an array to its first element (`$.stays[]`). One translation, here, rather than two\n * conventions leaking into every comparison below. A capability with no `collection` maps a\n * single object, whose fields hang off the root.\n */\nfunction itemPrefix(collection: string | undefined): string {\n if (!collection) return \"$\";\n return collection.replace(/\\[\\*\\]/g, \"[]\");\n}\n\n/**\n * What could be declared, and why the rest could not.\n *\n * Only `drift.added` is considered: `removed` is a loss with nothing to declare, and `retyped`\n * needs a judgment no shape comparison can make — is `price_per_night` the old `pricePerNight`,\n * or a new field that happens to look like it? ADR-0008 puts both out of scope, and the diff\n * still names them so a human can act.\n */\nexport function planAdoption(tool: IRTool, drift: ShapeDiff, resources: IRResourceRegistry): AdoptionPlan {\n const mapping = tool.response;\n if (!mapping) return { capabilityId: tool.id, candidates: [] };\n\n const prefix = itemPrefix(mapping.collection);\n const declared = new Set<string>([\n ...mapping.fields.map((f) => f.name),\n ...(resources[mapping.resource] ?? []).map((f) => f.name),\n ]);\n\n const refuse = (path: string, observed: JsonType, reason: AdoptionRefusal): UnadoptableField => ({\n adoptable: false,\n path,\n observed,\n reason,\n detail: refusalDetail(reason, observed),\n });\n\n const candidates = drift.added.map<AdoptionCandidate>(({ path, type: observed }) => {\n if (!path.startsWith(`${prefix}.`)) return refuse(path, observed, \"outside-collection\");\n const rest = path.slice(prefix.length + 1);\n if (rest.includes(\"[\")) return refuse(path, observed, \"nested\");\n // A dot here is either a nested object (`address.city`) or a single provider key that\n // contains a dot. Those two are INDISTINGUISHABLE in this flattened space — the same\n // collision `describeShape` documents — so there is one refusal, not a coin flip between\n // two, and its detail says so. Either way the answer is the same: not adopted.\n if (rest.includes(\".\")) return refuse(path, observed, \"nested\");\n if (observed === \"boolean\") return refuse(path, observed, \"no-boolean-type\");\n const semantic = semanticFor(observed);\n if (!semantic) return refuse(path, observed, \"not-a-leaf\");\n if (declared.has(rest)) return refuse(path, observed, \"already-declared\");\n return { adoptable: true, path, field: rest, itemPath: `$.${rest}`, observed, semantic };\n });\n\n return { capabilityId: tool.id, resource: mapping.resource, candidates };\n}\n\n/** The adoptable candidates, in the order they would be offered. */\nexport function adoptable(plan: AdoptionPlan): AdoptableField[] {\n return plan.candidates.filter((c): c is AdoptableField => c.adoptable);\n}\n","// @archstone/runtime — file-backed audit retention.\n//\n// `@archstone/emitter-support` ships `jsonLinesAuditSink`: one line, one write, no retention,\n// because that package imports no `node:` module and must stay usable on an edge runtime. A\n// self-hosted deployment that has to *keep* its audit trail — the whole point of an evidentiary\n// log — then has to solve rotation itself, which is where this lives: `runtime` already reads\n// the filesystem (`registry`), so fs belongs here and only here.\n//\n// Deliberately NOT a shipping/collector sink. Sending records to Splunk, an OTLP endpoint or an\n// S3 bucket is HTTP, and HTTP appears in exactly one package in this repository (`providers/rest`)\n// — a rule worth more than the convenience. Wrap this sink, or write your own; a sink is a\n// function.\n\nimport { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport type { AuditSink, ExecutionRecord } from \"@archstone/emitter-support\";\n\nexport interface RotatingFileAuditSinkOptions {\n /** Where the live file goes. Rotated generations are `<path>.1` … `<path>.<maxFiles>`. */\n path: string;\n /** Rotate once the live file would exceed this. Default 64 MiB. */\n maxBytes?: number;\n /** How many rotated generations to keep. The oldest is deleted on rotation. Default 10. */\n maxFiles?: number;\n}\n\nconst DEFAULT_MAX_BYTES = 64 * 1024 * 1024;\nconst DEFAULT_MAX_FILES = 10;\n\n/**\n * A JSON Lines audit sink that rotates by size and bounds its own disk use.\n *\n * **Size, not time.** An audit stream grows with invocations, not with the clock: hourly\n * rotation on a quiet deployment produces a directory of empty files, and on a busy one produces\n * a single file that outgrows the disk between rotations. Size-based rotation gives the one\n * guarantee an operator actually needs — total footprint is at most\n * `maxBytes × (maxFiles + 1)`, computable before deployment and independent of traffic.\n *\n * **Synchronous, on purpose.** `appendFileSync` per record costs a syscall; a buffered writer\n * would be faster and would lose the last N records exactly when they matter most — a crash,\n * an OOM kill, a `SIGKILL` during an incident. An audit record still in a buffer when the\n * process dies is a record that never existed. Evidentiary logs trade throughput for durability;\n * if that trade is wrong for a deployment, wrap a buffered writer yourself and own the loss.\n *\n * **Single writer.** The live file's size is tracked in memory (seeded from `statSync` at\n * construction) so the common path is one `append` and no `stat`. Two processes appending to the\n * same path therefore rotate on each other's estimates — give each instance its own path, which\n * a shared volume makes trivial and which also keeps records attributable to an instance.\n *\n * Rotation is `rename`, so the live inode is replaced and no record is ever rewritten in place.\n * A failure to rotate (permissions, a full disk) surfaces as an ordinary sink failure:\n * `emitExecutionRecord` catches it, announces the loss on stderr, and the invocation itself is\n * unaffected — an audit backend must never be able to take the capability down.\n */\nexport function rotatingFileAuditSink(opts: RotatingFileAuditSinkOptions): AuditSink {\n const { path } = opts;\n const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;\n const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;\n\n if (!path) throw new Error(\"rotatingFileAuditSink: `path` is required.\");\n if (!Number.isInteger(maxBytes) || maxBytes <= 0) {\n throw new Error(`rotatingFileAuditSink: maxBytes must be a positive integer, got ${String(maxBytes)}.`);\n }\n if (!Number.isInteger(maxFiles) || maxFiles < 1) {\n throw new Error(`rotatingFileAuditSink: maxFiles must be a positive integer, got ${String(maxFiles)}.`);\n }\n\n // Fail at wiring time, not at the first denied invocation: a deployer who mistyped the path\n // should learn now, while they are looking at the config, and not from a stream of caught\n // sink failures under load.\n mkdirSync(dirname(path), { recursive: true });\n\n let liveBytes = existsSync(path) ? statSync(path).size : 0;\n\n function rotate(): void {\n // Oldest first, so nothing is overwritten before it has been moved along.\n const oldest = `${path}.${maxFiles}`;\n if (existsSync(oldest)) unlinkSync(oldest);\n for (let i = maxFiles - 1; i >= 1; i--) {\n const from = `${path}.${i}`;\n if (existsSync(from)) renameSync(from, `${path}.${i + 1}`);\n }\n if (existsSync(path)) renameSync(path, `${path}.1`);\n liveBytes = 0;\n }\n\n return (record: ExecutionRecord) => {\n const line = `${JSON.stringify(record)}\\n`;\n const size = Buffer.byteLength(line);\n // A single record larger than the whole budget still gets written, to its own generation,\n // rather than being silently dropped: losing an oversized record is losing evidence, and a\n // deployer who sees one file over budget can raise maxBytes. Dropping it teaches nothing.\n if (liveBytes > 0 && liveBytes + size > maxBytes) rotate();\n appendFileSync(path, line);\n liveBytes += size;\n };\n}\n"],"mappings":"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","names":["Registry"]} | ||
| {"version":3,"sources":["../src/registry.ts","../src/mcp.ts","../src/adopt.ts","../src/audit-file.ts"],"sourcesContent":["// @archstone/runtime — Capability Registry (#5)\n//\n// The product kernel: capabilities queryable at runtime, indexed over the IR.\n// File-backed (no DB) — the IR is derived from manifests on disk. The MCP emitter\n// (#7) consumes this to list and resolve tools.\n//\n// `Registry` (index-only) moved to @archstone/emitter-support (ADD-0008 #27) — re-exported\n// here for back-compat so nothing downstream breaks. This file keeps the fs-touching\n// pipeline (`buildRegistry`), which is why the /http subpath (http.ts) never imports it.\n//\n// ADD-24 (#24): `buildRegistry` also optionally reads a conventional health-snapshot file\n// (`readHealthSnapshot`, below) — the ONE other fs-touching, network-free addition this ADD\n// makes. Binding health itself is never computed here (that's `archstone verify`'s own live\n// probe, ADD-18 D-5) — only its already-serialized `--json` output is read back.\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { load, type LoadResult, type LoadIssue } from \"@archstone/schema\";\nimport { validateSemantics, compile, type Diagnostic } from \"@archstone/compiler\";\nimport { Registry, type HealthStatus } from \"@archstone/emitter-support\";\n\nexport { Registry } from \"@archstone/emitter-support\";\n\n/** Conventional health-snapshot file, read once next to the manifest dir (ADD-24 D-8): the\n * operator/CI populates it by redirecting the ALREADY-shipped `archstone verify --json`\n * output here — no new serialization. `buildRegistry` reads it (fs, but no network — the\n * live probe stays exclusively `verify`'s, ADD-18 D-5) and hands the parsed map to\n * `Registry`, which composes it with each tool's lifecycle exposure (ADD-24 §7 step 5). */\nexport const HEALTH_SNAPSHOT_FILE = \".archstone-health.json\";\n\nconst HEALTH_STATUSES: ReadonlySet<string> = new Set([\"green\", \"yellow\", \"red\"]);\n\n/**\n * Parse the `{results: ToolVerification[]}` shape `archstone verify --json` already produces\n * (ADD-20) into a capabilityId -> HealthStatus map. Fail-open (ADD-24 D-9): a missing file, a\n * parse error, or a malformed/unexpected shape all return `undefined` — the caller then\n * proceeds with lifecycle-only exposure, never mistaking \"no snapshot\" for \"known bad\".\n *\n * #43 (ADD-43 D-14): an entry marked `policyDenied` is SKIPPED. A refusal that happened before\n * the call is not a health fact — no request was issued, so nothing about the backend's contract\n * was observed. Left in, it would reach `combineExposure` as a `red` and append\n * `\"binding health: red — the last contract verification failed\"` to the tool's agent-facing\n * description at the highest severity: a statement that never happened, shown to every caller\n * including permitted ones, making policy affect listing (which BR-36 forbids). Skipping leaves\n * the tool with NO health entry, which is exactly ADD-24 D-9's ratified posture — absent health\n * must never be manufactured into known-bad.\n */\nfunction readHealthSnapshot(dir: string): Map<string, HealthStatus> | undefined {\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(join(dir, HEALTH_SNAPSHOT_FILE), \"utf8\"));\n } catch {\n return undefined; // absent, unreadable, or invalid JSON — fail-open\n }\n\n const results = (parsed as { results?: unknown } | null)?.results;\n if (!Array.isArray(results)) return undefined;\n\n const map = new Map<string, HealthStatus>();\n for (const r of results) {\n if (!r || typeof r !== \"object\") continue;\n // ADD-43 D-14: a policy denial is not a health reading — drop it rather than let it become\n // an agent-facing \"the last contract verification failed\" hint for a verification that\n // never ran. See this function's doc comment.\n if ((r as { policyDenied?: unknown }).policyDenied === true) continue;\n const capabilityId = (r as { capabilityId?: unknown }).capabilityId;\n const status = (r as { status?: unknown }).status;\n if (typeof capabilityId === \"string\" && typeof status === \"string\" && HEALTH_STATUSES.has(status)) {\n map.set(capabilityId, status as HealthStatus);\n }\n }\n return map;\n}\n\nexport interface BuildResult {\n ok: boolean;\n registry?: Registry;\n issues: LoadIssue[];\n diagnostics: Diagnostic[];\n}\n\n/**\n * File-backed pipeline: load (#2) → semantic-validate (#3) → compile (#4) → Registry (#5).\n * `registry` is present only when shapes are valid, there are no semantic errors, AND no\n * tool-name collision (ADD-30 D-2) — folded into this function's existing `diagnostics`/\n * `ok` contract (new `tool-name-collision` diagnostic code) rather than a new mechanism, so\n * `serveStdio`/`runServeHttp` (which already refuse to proceed on `!built.ok`) inherit the\n * gate for free.\n */\nexport function buildRegistry(dir: string): BuildResult {\n const model: LoadResult = load(dir);\n const diagnostics = validateSemantics(model);\n const hasErrors = diagnostics.some((d) => d.severity === \"error\");\n let ok = model.ok && !hasErrors;\n\n const registry = ok ? new Registry(compile(model), readHealthSnapshot(dir)) : undefined;\n if (registry) {\n for (const c of registry.toolNameCollisions) {\n ok = false;\n diagnostics.push({\n severity: \"error\",\n code: \"tool-name-collision\",\n message: `tool name '${c.name}' is ambiguous — capabilities ${c.ids.join(\", \")} all sanitize to it`,\n });\n }\n }\n\n return {\n ok,\n registry: ok ? registry : undefined,\n issues: model.issues,\n diagnostics,\n };\n}\n","// @archstone/runtime — MCP emitter (#7) — stdio entrypoint\n//\n// serveStdio builds the registry from disk (buildRegistry, registry.ts) and serves it over\n// stdio, the channel Claude Desktop uses. The fs-free MCP server construction\n// (toolDefinitions/callTool/createMcpServer) lives in ./server (ADD-0008 #27) — re-exported\n// here, alongside the semantic-lowering functions from @archstone/emitter-support, for\n// back-compat so nothing downstream breaks.\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { InvokeOptions } from \"@archstone/provider-rest\";\nimport { buildRegistry } from \"./registry\";\nimport { toolDefinitions, createMcpServer } from \"./server\";\n\nexport { toolName, inputJsonSchema, objectJsonSchema } from \"@archstone/emitter-support\";\n/** #44: the audit sink surface, re-exported so a deployer wiring `serveStdio`/`createMcpServer`\n * imports it from the package they already depend on. See `AuditSink`'s own doc comment for\n * the fire-and-forget contract and for the statement that the trail is best-effort and lossy. */\nexport {\n jsonLinesAuditSink,\n REDACTED,\n LIFECYCLE_BLOCKED_REASON,\n LIFECYCLE_UNEVALUATABLE_REASON,\n} from \"@archstone/emitter-support\";\nexport type {\n AuditSink,\n AuditWritable,\n ExecutionRecord,\n ExecutionStatus,\n ExecutionPhase,\n ExecutionConsumer,\n ExecutionDenialReason,\n} from \"@archstone/emitter-support\";\nexport * from \"./server\";\n\n/**\n * Build the registry from a manifest dir and serve it over stdio (blocks).\n *\n * `invoke` (ADD-32) is forwarded verbatim to `createMcpServer` — this closes a real gap: prior\n * to #32, `serveStdio` passed NO `InvokeOptions` at all, so nothing (not `env`, not `caller`)\n * could ever be injected here. A stdio server is one child process per conversation (Claude\n * Desktop's model) — single-process, single-user by construction — so a static per-process\n * `invoke.caller` is architecturally sound here, unlike the HTTP case (`createHttpHandler`'s\n * `resolveCaller`, which must vary per inbound request).\n */\nexport async function serveStdio(dir: string, invoke?: InvokeOptions): Promise<void> {\n const built = buildRegistry(dir);\n if (!built.ok || !built.registry) {\n // stdout is the MCP channel — all human output goes to stderr.\n console.error(`archstone: cannot serve '${dir}' — manifest invalid:`);\n for (const i of built.issues) console.error(` - ${i.file}: ${i.message}`);\n for (const d of built.diagnostics.filter((x) => x.severity === \"error\")) console.error(` - ${d.message}`);\n process.exit(1);\n }\n const tools = toolDefinitions(built.registry);\n console.error(`archstone: serving ${tools.length} tool(s) over stdio: ${tools.map((t) => t.name).join(\", \") || \"(none)\"}`);\n const server = createMcpServer(built.registry, invoke);\n await server.connect(new StdioServerTransport());\n}\n","// @archstone/runtime — Adoption planner (ADD-117 / ADR-0008).\n//\n// Turns a field ADD-114's drift NAMED into a field the manifest DECLARES. This module is the\n// pure half: it decides what could be adopted and why each rejected candidate was rejected.\n// It touches no disk, asks no human, and mutates nothing — the CLI owns all three (D-6), which\n// is what lets every rule below be tested without a temp directory or a terminal.\n//\n// The rules exist because ADR-0008 draws a hard line: an undeclared field never reaches a\n// model. Adoption is the ONLY way across that line, and it is deliberately a human act.\n\nimport type { IRTool, IRResourceRegistry, JsonType, SemanticType, ShapeDiff } from \"@archstone/compiler\";\n\n/** Why a named path cannot be adopted. Stated, never silently skipped — a candidate that\n * disappears from the report reads as \"there was nothing there\". */\nexport type AdoptionRefusal =\n | \"outside-collection\"\n | \"nested\"\n | \"no-boolean-type\"\n | \"not-a-leaf\"\n | \"already-declared\";\n\nexport interface AdoptableField {\n adoptable: true;\n /** The JSONPath the drift reported, e.g. `$.stays[].boardType`. */\n path: string;\n /** The resource field name it would become, e.g. `boardType`. */\n field: string;\n /** The path written into the binding's `response.map`, relative to the collection item. */\n itemPath: string;\n observed: JsonType;\n /** What it is declared as. See ADD-117 §3 — the table is deliberately dull. */\n semantic: SemanticType;\n}\n\nexport interface UnadoptableField {\n adoptable: false;\n path: string;\n observed: JsonType;\n reason: AdoptionRefusal;\n /** One sentence an operator can act on, or at least understand. */\n detail: string;\n}\n\nexport type AdoptionCandidate = AdoptableField | UnadoptableField;\n\nexport interface AdoptionPlan {\n capabilityId: string;\n /** The resource the binding's `response:` maps onto — the file a field would be added to. */\n resource?: string;\n candidates: AdoptionCandidate[];\n}\n\n/**\n * ADD-117 §3. An observed JSON type becomes exactly one CDL semantic type, or nothing.\n *\n * Deliberately NOT clever. `string` does not become `date` however much a value looked like\n * one, because the shape records types and never values — there is nothing here to\n * pattern-match, and inferring a date from a field NAME is exactly the guess this project\n * refuses to make. `number` does not become `money`, because whether a number is a price is a\n * business fact and `money` carries a currency this field does not have. The human can widen\n * either afterwards; the manifest is theirs.\n */\nfunction semanticFor(observed: JsonType): SemanticType | undefined {\n if (observed === \"string\") return \"text\";\n if (observed === \"number\") return \"quantity\";\n return undefined;\n}\n\nfunction refusalDetail(reason: AdoptionRefusal, observed: JsonType): string {\n switch (reason) {\n case \"outside-collection\":\n return \"outside the collection this capability maps; it is not a field of the resource\";\n case \"nested\":\n return \"nested, or a provider key containing a dot — indistinguishable here; either way the resource field would have to be another resource, which adoption does not create\";\n case \"no-boolean-type\":\n return \"CDL has no boolean semantic type, and declaring it as text would state a lie about the shape\";\n case \"not-a-leaf\":\n return `observed as ${observed}, which is a structure rather than a value`;\n case \"already-declared\":\n return \"already declared by this capability\";\n }\n}\n\n/**\n * The path prefix every field of one collection item shares.\n *\n * A binding's `collection` is a JSONPath over the payload (`$.stays[*]`); a recorded shape\n * flattens an array to its first element (`$.stays[]`). One translation, here, rather than two\n * conventions leaking into every comparison below. A capability with no `collection` maps a\n * single object, whose fields hang off the root.\n */\nfunction itemPrefix(collection: string | undefined): string {\n if (!collection) return \"$\";\n return collection.replace(/\\[\\*\\]/g, \"[]\");\n}\n\n/**\n * What could be declared, and why the rest could not.\n *\n * Only `drift.added` is considered: `removed` is a loss with nothing to declare, and `retyped`\n * needs a judgment no shape comparison can make — is `price_per_night` the old `pricePerNight`,\n * or a new field that happens to look like it? ADR-0008 puts both out of scope, and the diff\n * still names them so a human can act.\n */\nexport function planAdoption(tool: IRTool, drift: ShapeDiff, resources: IRResourceRegistry): AdoptionPlan {\n const mapping = tool.response;\n if (!mapping) return { capabilityId: tool.id, candidates: [] };\n\n const prefix = itemPrefix(mapping.collection);\n const declared = new Set<string>([\n ...mapping.fields.map((f) => f.name),\n ...(resources[mapping.resource] ?? []).map((f) => f.name),\n ]);\n\n const refuse = (path: string, observed: JsonType, reason: AdoptionRefusal): UnadoptableField => ({\n adoptable: false,\n path,\n observed,\n reason,\n detail: refusalDetail(reason, observed),\n });\n\n const candidates = drift.added.map<AdoptionCandidate>(({ path, type: observed }) => {\n if (!path.startsWith(`${prefix}.`)) return refuse(path, observed, \"outside-collection\");\n const rest = path.slice(prefix.length + 1);\n if (rest.includes(\"[\")) return refuse(path, observed, \"nested\");\n // A dot here is either a nested object (`address.city`) or a single provider key that\n // contains a dot. Those two are INDISTINGUISHABLE in this flattened space — the same\n // collision `describeShape` documents — so there is one refusal, not a coin flip between\n // two, and its detail says so. Either way the answer is the same: not adopted.\n if (rest.includes(\".\")) return refuse(path, observed, \"nested\");\n if (observed === \"boolean\") return refuse(path, observed, \"no-boolean-type\");\n const semantic = semanticFor(observed);\n if (!semantic) return refuse(path, observed, \"not-a-leaf\");\n if (declared.has(rest)) return refuse(path, observed, \"already-declared\");\n return { adoptable: true, path, field: rest, itemPath: `$.${rest}`, observed, semantic };\n });\n\n return { capabilityId: tool.id, resource: mapping.resource, candidates };\n}\n\n/** The adoptable candidates, in the order they would be offered. */\nexport function adoptable(plan: AdoptionPlan): AdoptableField[] {\n return plan.candidates.filter((c): c is AdoptableField => c.adoptable);\n}\n","// @archstone/runtime — file-backed audit retention.\n//\n// `@archstone/emitter-support` ships `jsonLinesAuditSink`: one line, one write, no retention,\n// because that package imports no `node:` module and must stay usable on an edge runtime. A\n// self-hosted deployment that has to *keep* its audit trail — the whole point of an evidentiary\n// log — then has to solve rotation itself, which is where this lives: `runtime` already reads\n// the filesystem (`registry`), so fs belongs here and only here.\n//\n// Deliberately NOT a shipping/collector sink. Sending records to Splunk, an OTLP endpoint or an\n// S3 bucket is HTTP, and HTTP appears in exactly one package in this repository (`providers/rest`)\n// — a rule worth more than the convenience. Wrap this sink, or write your own; a sink is a\n// function.\n\nimport { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport type { AuditSink, ExecutionRecord } from \"@archstone/emitter-support\";\n\nexport interface RotatingFileAuditSinkOptions {\n /** Where the live file goes. Rotated generations are `<path>.1` … `<path>.<maxFiles>`. */\n path: string;\n /** Rotate once the live file would exceed this. Default 64 MiB. */\n maxBytes?: number;\n /** How many rotated generations to keep. The oldest is deleted on rotation. Default 10. */\n maxFiles?: number;\n}\n\nconst DEFAULT_MAX_BYTES = 64 * 1024 * 1024;\nconst DEFAULT_MAX_FILES = 10;\n\n/**\n * A JSON Lines audit sink that rotates by size and bounds its own disk use.\n *\n * **Size, not time.** An audit stream grows with invocations, not with the clock: hourly\n * rotation on a quiet deployment produces a directory of empty files, and on a busy one produces\n * a single file that outgrows the disk between rotations. Size-based rotation gives the one\n * guarantee an operator actually needs — total footprint is at most\n * `maxBytes × (maxFiles + 1)`, computable before deployment and independent of traffic.\n *\n * **Synchronous, on purpose.** `appendFileSync` per record costs a syscall; a buffered writer\n * would be faster and would lose the last N records exactly when they matter most — a crash,\n * an OOM kill, a `SIGKILL` during an incident. An audit record still in a buffer when the\n * process dies is a record that never existed. Evidentiary logs trade throughput for durability;\n * if that trade is wrong for a deployment, wrap a buffered writer yourself and own the loss.\n *\n * **Single writer.** The live file's size is tracked in memory (seeded from `statSync` at\n * construction) so the common path is one `append` and no `stat`. Two processes appending to the\n * same path therefore rotate on each other's estimates — give each instance its own path, which\n * a shared volume makes trivial and which also keeps records attributable to an instance.\n *\n * Rotation is `rename`, so the live inode is replaced and no record is ever rewritten in place.\n * A failure to rotate (permissions, a full disk) surfaces as an ordinary sink failure:\n * `emitExecutionRecord` catches it, announces the loss on stderr, and the invocation itself is\n * unaffected — an audit backend must never be able to take the capability down.\n */\nexport function rotatingFileAuditSink(opts: RotatingFileAuditSinkOptions): AuditSink {\n const { path } = opts;\n const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;\n const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;\n\n if (!path) throw new Error(\"rotatingFileAuditSink: `path` is required.\");\n if (!Number.isInteger(maxBytes) || maxBytes <= 0) {\n throw new Error(`rotatingFileAuditSink: maxBytes must be a positive integer, got ${String(maxBytes)}.`);\n }\n if (!Number.isInteger(maxFiles) || maxFiles < 1) {\n throw new Error(`rotatingFileAuditSink: maxFiles must be a positive integer, got ${String(maxFiles)}.`);\n }\n\n // Fail at wiring time, not at the first denied invocation: a deployer who mistyped the path\n // should learn now, while they are looking at the config, and not from a stream of caught\n // sink failures under load.\n mkdirSync(dirname(path), { recursive: true });\n\n let liveBytes = existsSync(path) ? statSync(path).size : 0;\n\n function rotate(): void {\n // Oldest first, so nothing is overwritten before it has been moved along.\n const oldest = `${path}.${maxFiles}`;\n if (existsSync(oldest)) unlinkSync(oldest);\n for (let i = maxFiles - 1; i >= 1; i--) {\n const from = `${path}.${i}`;\n if (existsSync(from)) renameSync(from, `${path}.${i + 1}`);\n }\n if (existsSync(path)) renameSync(path, `${path}.1`);\n liveBytes = 0;\n }\n\n return (record: ExecutionRecord) => {\n const line = `${JSON.stringify(record)}\\n`;\n const size = Buffer.byteLength(line);\n // A single record larger than the whole budget still gets written, to its own generation,\n // rather than being silently dropped: losing an oversized record is losing evidence, and a\n // deployer who sees one file over budget can raise maxBytes. Dropping it teaches nothing.\n if (liveBytes > 0 && liveBytes + size > maxBytes) rotate();\n appendFileSync(path, line);\n liveBytes += size;\n };\n}\n"],"mappings":"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","names":["Registry"]} |
+5
-5
| { | ||
| "name": "@archstone/runtime", | ||
| "version": "0.15.0", | ||
| "version": "0.16.0", | ||
| "private": false, | ||
@@ -50,6 +50,6 @@ "type": "module", | ||
| "@modelcontextprotocol/sdk": "^1.12.0", | ||
| "@archstone/emitter-support": "0.15.0", | ||
| "@archstone/provider-rest": "0.15.0", | ||
| "@archstone/compiler": "0.15.0", | ||
| "@archstone/schema": "0.15.0" | ||
| "@archstone/compiler": "0.16.0", | ||
| "@archstone/emitter-support": "0.16.0", | ||
| "@archstone/provider-rest": "0.16.0", | ||
| "@archstone/schema": "0.16.0" | ||
| }, | ||
@@ -56,0 +56,0 @@ "devDependencies": { |
| // src/server.ts | ||
| import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { | ||
| inputJsonSchema, | ||
| objectJsonSchema, | ||
| applyResponseMapping, | ||
| contractViolationMessage, | ||
| evaluatePolicy, | ||
| evaluateRateLimit, | ||
| auditNow, | ||
| buildExecutionRecord, | ||
| emitExecutionRecord, | ||
| LIFECYCLE_BLOCKED_REASON, | ||
| LIFECYCLE_UNEVALUATABLE_REASON | ||
| } from "@archstone/emitter-support"; | ||
| import { invokeRest } from "@archstone/provider-rest"; | ||
| function toolDefinitions(registry) { | ||
| const resources = registry.ir.resources; | ||
| return registry.invocableTools().filter(({ tool: t }) => registry.getExposure(t.id).listed).map(({ name, tool: t }) => { | ||
| const hint = registry.getExposure(t.id).hint; | ||
| const def = { | ||
| name, | ||
| description: hint ? `${t.description} (${hint.text})` : t.description, | ||
| inputSchema: inputJsonSchema(t.input, resources) | ||
| }; | ||
| if (t.output.length > 0) def.outputSchema = objectJsonSchema(t.output, resources); | ||
| return def; | ||
| }); | ||
| } | ||
| var CONTRACT_VIOLATION_META_KEY = "dev.archstone/contract_violation"; | ||
| var LIFECYCLE_BLOCKED_META_KEY = "dev.archstone/lifecycle_blocked"; | ||
| var LIFECYCLE_UNEVALUATABLE_META_KEY = "dev.archstone/lifecycle_unevaluatable"; | ||
| var POLICY_DENIED_META_KEY = "dev.archstone/policy_denied"; | ||
| async function callTool(registry, name, args, opts) { | ||
| const tool = registry.getCapability(name); | ||
| if (!tool) { | ||
| return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true }; | ||
| } | ||
| const auditSink = opts?.auditSink; | ||
| const startedAt = auditSink ? auditNow() : ""; | ||
| const audit = (status) => { | ||
| if (!auditSink) return; | ||
| emitExecutionRecord( | ||
| auditSink, | ||
| buildExecutionRecord({ | ||
| tool, | ||
| input: args, | ||
| // Fixed by this call site, never host-configurable: an auditor must be able to trust | ||
| // that a record claiming `mcp` came from the MCP path. `mcpHandler` mounts this same | ||
| // path and therefore also records `mcp` — the value names the protocol surface the call | ||
| // arrived on, not the npm package that mounted it. | ||
| consumer: "mcp", | ||
| caller: opts?.caller, | ||
| sessionId: opts?.sessionId, | ||
| workflowId: opts?.workflowId, | ||
| startedAt, | ||
| status | ||
| }) | ||
| ); | ||
| }; | ||
| const exposure = registry.getExposure(tool.id); | ||
| if (!exposure.invocable) { | ||
| if (exposure.blockedReason === "unevaluatable") { | ||
| const text2 = `capability '${tool.id}' declares a lifecycle this build does not recognize and cannot evaluate \u2014 refusing (fail-closed).`; | ||
| audit({ phase: "denied", message: text2, denialReason: LIFECYCLE_UNEVALUATABLE_REASON }); | ||
| return { | ||
| content: [{ type: "text", text: text2 }], | ||
| _meta: { | ||
| [LIFECYCLE_UNEVALUATABLE_META_KEY]: { error: "lifecycle_unevaluatable", capability: tool.id, lifecycle: tool.lifecycle } | ||
| }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const text = `capability '${tool.id}' is retired and can no longer be invoked.`; | ||
| audit({ phase: "denied", message: text, denialReason: LIFECYCLE_BLOCKED_REASON }); | ||
| return { | ||
| content: [{ type: "text", text }], | ||
| _meta: { [LIFECYCLE_BLOCKED_META_KEY]: { error: "lifecycle_blocked", capability: tool.id, lifecycle: tool.lifecycle } }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const decision = opts?.callerResolutionFailed ? { | ||
| allowed: false, | ||
| denial: { | ||
| reason: "policy_unevaluatable", | ||
| message: `capability '${tool.id}' could not be evaluated \u2014 caller identity could not be established (resolveCaller failed) \u2014 refusing (fail-closed).` | ||
| } | ||
| } : evaluatePolicy(tool, { | ||
| principal: opts?.caller?.principal, | ||
| credentialPresent: opts?.caller?.accessToken !== void 0 | ||
| }); | ||
| if (!decision.allowed) { | ||
| audit({ phase: "denied", message: decision.denial.message, denialReason: decision.denial.reason }); | ||
| return { | ||
| content: [{ type: "text", text: decision.denial.message }], | ||
| _meta: { | ||
| [POLICY_DENIED_META_KEY]: { | ||
| error: "policy_denied", | ||
| // `tool.id` — the unsanitized CDL id, never the MCP-sanitized advertised `name` | ||
| // lookup key (BR-28, mirroring ADD-19 and ADD-30 BR-7). | ||
| capability: tool.id, | ||
| reason: decision.denial.reason | ||
| } | ||
| }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const rateDecision = await evaluateRateLimit(tool, { principal: opts?.caller?.principal }, opts?.rateLimitCounter); | ||
| if (!rateDecision.allowed) { | ||
| audit({ phase: "denied", message: rateDecision.denial.message, denialReason: rateDecision.denial.reason }); | ||
| return { | ||
| content: [{ type: "text", text: rateDecision.denial.message }], | ||
| _meta: { | ||
| [POLICY_DENIED_META_KEY]: { | ||
| error: "policy_denied", | ||
| capability: tool.id, | ||
| reason: rateDecision.denial.reason | ||
| } | ||
| }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const result = await invokeRest(tool, args, opts); | ||
| if (!result.ok) { | ||
| const text = result.error ?? "invocation failed"; | ||
| audit({ phase: "failed", message: text }); | ||
| return { content: [{ type: "text", text }], isError: true }; | ||
| } | ||
| if (tool.response) { | ||
| const mapped = applyResponseMapping(tool, result.data, registry.ir.resources); | ||
| if (mapped.status === "violation") { | ||
| const missing = mapped.missing ?? []; | ||
| const text = contractViolationMessage(tool.id, missing); | ||
| audit({ phase: "failed", message: text }); | ||
| return { | ||
| content: [{ type: "text", text }], | ||
| _meta: { [CONTRACT_VIOLATION_META_KEY]: { error: "contract_violation", capability: tool.id, missing } }, | ||
| isError: true | ||
| }; | ||
| } | ||
| const content = [{ type: "text", text: JSON.stringify(mapped.data, null, 2) }]; | ||
| if (mapped.status === "degraded") { | ||
| content.push({ type: "text", text: `note: optional field(s) absent (degraded): ${(mapped.degraded ?? []).join(", ")}` }); | ||
| } | ||
| audit({ phase: "succeeded" }); | ||
| return { content, structuredContent: mapped.data, isError: false }; | ||
| } | ||
| const out = { content: [{ type: "text", text: JSON.stringify(result.data ?? null, null, 2) }], isError: false }; | ||
| if (tool.output.length > 0) { | ||
| const data = result.data; | ||
| if (data && typeof data === "object" && !Array.isArray(data)) { | ||
| out.structuredContent = data; | ||
| } | ||
| } | ||
| audit({ phase: "succeeded" }); | ||
| return out; | ||
| } | ||
| function createMcpServer(registry, opts) { | ||
| const server = new Server({ name: "archstone", version: "0" }, { capabilities: { tools: {} } }); | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions(registry) })); | ||
| server.setRequestHandler(CallToolRequestSchema, async (req) => { | ||
| const args = req.params.arguments ?? {}; | ||
| const result = await callTool(registry, req.params.name, args, opts); | ||
| return result; | ||
| }); | ||
| return server; | ||
| } | ||
| export { | ||
| toolDefinitions, | ||
| CONTRACT_VIOLATION_META_KEY, | ||
| LIFECYCLE_BLOCKED_META_KEY, | ||
| LIFECYCLE_UNEVALUATABLE_META_KEY, | ||
| POLICY_DENIED_META_KEY, | ||
| callTool, | ||
| createMcpServer | ||
| }; | ||
| //# sourceMappingURL=chunk-YFNW2UHK.js.map |
| {"version":3,"sources":["../src/server.ts"],"sourcesContent":["// @archstone/runtime — MCP server construction (fs-free)\n//\n// Builds an MCP Server from a Registry and routes invocations through the REST provider\n// (#6). This is the ONLY place the MCP SDK appears (alongside stdio's transport wiring in\n// mcp.ts and the /http subpath's transport wiring in http.ts) — semantic-type → JSON-Schema\n// lowering itself now lives in @archstone/emitter-support (ADD-0008 #27), never here.\n//\n// Extracted out of mcp.ts (ADD-0008 #27) specifically so this module's graph never reaches\n// registry.ts's buildRegistry/@archstone/schema `load()` (the fs edge) — only stdio's\n// `serveStdio` (mcp.ts) needs disk access. `http.ts` (the /http subpath) imports only this\n// file, so a consumer depending on that subpath alone stays fs-free.\n\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n Registry,\n inputJsonSchema,\n objectJsonSchema,\n applyResponseMapping,\n contractViolationMessage,\n evaluatePolicy,\n evaluateRateLimit,\n auditNow,\n buildExecutionRecord,\n emitExecutionRecord,\n LIFECYCLE_BLOCKED_REASON,\n LIFECYCLE_UNEVALUATABLE_REASON,\n type ExecutionStatus,\n type PolicyDecision,\n} from \"@archstone/emitter-support\";\nimport { invokeRest, type InvokeOptions } from \"@archstone/provider-rest\";\n\ntype JsonSchema = Record<string, unknown>;\n\nexport interface McpToolDef {\n name: string;\n description: string;\n inputSchema: JsonSchema;\n outputSchema?: JsonSchema;\n}\n\n/** The MCP tool list: only invocable (bound) capabilities become tools — Registry's\n * `invocableTools()` (ADD-30 D-3) is the single source of truth shared by this function\n * (what's listed) and `callTool` (what's resolvable, via `getCapability`), keeping the two\n * consistent. Input and output fields lower against the IR resource registry, so a\n * `collection: Stay` output emits a typed, described `outputSchema` (not a bare\n * `{type:object}`).\n *\n * ADD-24: a bound tool whose combined exposure (`registry.getExposure`, lifecycle + optional\n * health) is `listed:false` (lifecycle `experimental`/`retired`) is dropped from the returned\n * list entirely — unlisted, per D-10, though `experimental` remains callable by id (see\n * `callTool`). A tool carrying a `hint` (beta/deprecated, or a yellow/red health reading) has\n * its text appended to `description` — the only MCP-specific rendering of the neutral\n * exposure the emitter-support layer computed. */\nexport function toolDefinitions(registry: Registry): McpToolDef[] {\n const resources = registry.ir.resources;\n return registry\n .invocableTools()\n .filter(({ tool: t }) => registry.getExposure(t.id).listed)\n .map(({ name, tool: t }) => {\n const hint = registry.getExposure(t.id).hint;\n const def: McpToolDef = {\n name,\n description: hint ? `${t.description} (${hint.text})` : t.description,\n inputSchema: inputJsonSchema(t.input, resources),\n };\n if (t.output.length > 0) def.outputSchema = objectJsonSchema(t.output, resources);\n return def;\n });\n}\n\nexport interface CallResult {\n content: { type: \"text\"; text: string }[];\n structuredContent?: Record<string, unknown>;\n isError: boolean;\n _meta?: Record<string, unknown>;\n}\n\n/** #19 ADD-19 Rev 2 D-6: the namespaced `_meta` key a VIOLATION result's structured error\n * object is carried under. Never populated on `structuredContent` (D-3′) — the reference\n * MCP SDK client validates `structuredContent` against `outputSchema` whenever the tool\n * declares one, regardless of `isError`, so a non-conforming error object there crashes the\n * client. `_meta` is untouched by that validation and passes through the client's zod parse\n * unstripped (`ResultSchema`/`RequestMetaSchema` are `z.looseObject`). */\nexport const CONTRACT_VIOLATION_META_KEY = \"dev.archstone/contract_violation\";\n\n/** ADD-24 D-11: the namespaced `_meta` key a `retired` (or otherwise `invocable:false`)\n * tool's rejection is carried under — reuses `CONTRACT_VIOLATION_META_KEY`'s precedent\n * (ADD-19 Rev 2 D-3′/D-6) verbatim: never `structuredContent`, so the reference SDK client's\n * unconditional `structuredContent`-against-`outputSchema` validation never sees it. A\n * distinct key (not `CONTRACT_VIOLATION_META_KEY`) so a client distinguishes \"this call was\n * blocked before any connector work\" from \"the provider's response violated the contract\". */\nexport const LIFECYCLE_BLOCKED_META_KEY = \"dev.archstone/lifecycle_blocked\";\n\n/** ADD-56 D-2/OQ-56-B: the namespaced `_meta` key an unrecognized-lifecycle rejection is\n * carried under — a DISTINCT key from `LIFECYCLE_BLOCKED_META_KEY`, mirroring\n * `POLICY_DENIED_META_KEY` vs. `LIFECYCLE_BLOCKED_META_KEY` being genuinely distinct keys\n * rather than one key with a varying `error` string inside it. `retired` (a governance\n * refusal) and an unrecognized `lifecycle` (a compatibility refusal) are different facts with\n * different remediations and must be trivially distinguishable to a client — see\n * `exposure.ts`'s `Exposure.blockedReason` doc comment. */\nexport const LIFECYCLE_UNEVALUATABLE_META_KEY = \"dev.archstone/lifecycle_unevaluatable\";\n\n/** #43 ADD-43: the namespaced `_meta` key a POLICY denial is carried under — the third use of\n * the ADD-19 Rev 2 D-3′/D-6 precedent, verbatim: never `structuredContent`, because the\n * reference SDK client validates that against the tool's `outputSchema` unconditionally (not\n * gated on `isError`) and an error object there crashes it. A distinct key from the other two\n * so a client can tell \"refused by policy before any connector work\" from \"blocked by\n * lifecycle\" from \"the provider's response violated the contract\" — the three are mutually\n * exclusive on one call (BR-27). The object discloses the reason code and the capability id\n * and NOTHING about the policy itself: no metadata.id, no allow/deny entry, no other\n * principal's identifier (BR-30, Rule #7 — the MCP client is the untrusted side). */\nexport const POLICY_DENIED_META_KEY = \"dev.archstone/policy_denied\";\n\n/** Route an MCP tool call to the REST provider and format the result as MCP content. */\nexport async function callTool(\n registry: Registry,\n name: string,\n args: Record<string, unknown>,\n opts?: InvokeOptions,\n): Promise<CallResult> {\n const tool = registry.getCapability(name);\n if (!tool) {\n // #44: NO audit record. `metadata.capabilityId` is required and the only value available\n // here is an unvalidated, caller-chosen string that is not a CDL id — writing it there\n // would put unbounded attacker-controlled values into the audit log's primary correlation\n // key, in a file a compliance process treats as evidence. An `Execution` record audits a\n // capability invocation attempt; a call naming no capability is a protocol-level event and\n // belongs to the host's own mount-point instrumentation. Named residual, deliberately\n // accepted: tool-name probing (and the collision defence, which also lands here) is\n // invisible to the audit trail.\n return { content: [{ type: \"text\", text: `unknown tool: ${name}` }], isError: true };\n }\n\n // #44: the attempt clock starts HERE — before the exposure gate and before policy evaluation,\n // so every refused attempt still carries a real `startedAt` and can be placed on a timeline.\n // With no sink configured this is a strict no-op: no clock read, no id, no record, no\n // allocation, and every result byte-for-byte what it was before this increment.\n const auditSink = opts?.auditSink;\n const startedAt = auditSink ? auditNow() : \"\";\n const audit = (status: ExecutionStatus): void => {\n if (!auditSink) return;\n emitExecutionRecord(\n auditSink,\n buildExecutionRecord({\n tool,\n input: args,\n // Fixed by this call site, never host-configurable: an auditor must be able to trust\n // that a record claiming `mcp` came from the MCP path. `mcpHandler` mounts this same\n // path and therefore also records `mcp` — the value names the protocol surface the call\n // arrived on, not the npm package that mounted it.\n consumer: \"mcp\",\n caller: opts?.caller,\n sessionId: opts?.sessionId,\n workflowId: opts?.workflowId,\n startedAt,\n status,\n }),\n );\n };\n\n // ADD-24 D-10/D-11: `lifecycle: retired` sets `invocable:false` (health never does, D-9) —\n // checked immediately after resolution, before any connector/response work, same call-site\n // discipline the `contract_violation` check already uses downstream.\n //\n // ADD-56 D-1/D-2: `lifecycleExposure` is now TOTAL — an unrecognized `lifecycle` value (only\n // reachable via a hand-written or forward-versioned `fromIR` artifact, ADD-0008 D-2) ALSO sets\n // `invocable:false`, distinguished from `retired` by `exposure.blockedReason`. The two are\n // different facts with different remediations (governance vs. compatibility — see\n // `exposure.ts`'s `Exposure.blockedReason` doc comment) and MUST NOT share a message or a\n // `denialReason`. `exposure.blockedReason === \"unevaluatable\"` is the only branch this can take\n // here: the `undefined` case (D-4's unknown-id fallback) cannot occur, because `tool` above was\n // already resolved via `getCapability`, which reads the identical `exposureById` map.\n const exposure = registry.getExposure(tool.id);\n if (!exposure.invocable) {\n if (exposure.blockedReason === \"unevaluatable\") {\n const text = `capability '${tool.id}' declares a lifecycle this build does not recognize and cannot evaluate — refusing (fail-closed).`;\n // #44: `denied`, never `failed` — refusing on a compatibility gap is not a backend\n // failure. Deliberately distinct denialReason/message/meta-key from the `retired` branch\n // below (ADD-56 D-2/D-3) — never `LIFECYCLE_BLOCKED_REASON`.\n audit({ phase: \"denied\", message: text, denialReason: LIFECYCLE_UNEVALUATABLE_REASON });\n return {\n content: [{ type: \"text\", text }],\n _meta: {\n [LIFECYCLE_UNEVALUATABLE_META_KEY]: { error: \"lifecycle_unevaluatable\", capability: tool.id, lifecycle: tool.lifecycle },\n },\n isError: true,\n };\n }\n const text = `capability '${tool.id}' is retired and can no longer be invoked.`;\n // #44: `denied`, never `failed` — a refusal by lifecycle is a refusal by governance, and\n // recording it as a failure would conflate it with \"the backend broke\" in the one log where\n // that distinction is the entire product. This gate is the SECOND (and only other) producer\n // of `phase: \"denied\"`, and the reason code the policy evaluator can never return. It runs\n // before policy deliberately, so a capability that is both retired and policy-denied records\n // `lifecycle_blocked`, matching the pinned gate order.\n audit({ phase: \"denied\", message: text, denialReason: LIFECYCLE_BLOCKED_REASON });\n return {\n content: [{ type: \"text\", text }],\n _meta: { [LIFECYCLE_BLOCKED_META_KEY]: { error: \"lifecycle_blocked\", capability: tool.id, lifecycle: tool.lifecycle } },\n isError: true,\n };\n }\n\n // #43 (ADD-43 D-5/D-6): THE policy evaluation point, called unconditionally — for every tool,\n // including one with no resolved policy, because `authenticated` enforcement now lives here\n // rather than inside `invokeRest` (D-4). Deliberately AFTER the ADD-24 exposure gate above, so\n // a `retired` capability reports `lifecycle_blocked` and never `policy_denied` (BR-34), and\n // strictly BEFORE `invokeRest`, so a denial does zero connector work: no env/caller\n // resolution, no URL building, no fetch, and no `onResponse` firing (BR-25).\n // #48: a `resolveCaller` that THREW for this request (rather than returning, even\n // `undefined`) short-circuits straight to a `policy_unevaluatable` denial for every\n // capability, bypassing `evaluatePolicy` entirely — identity extraction itself failed here,\n // which is strictly less trustworthy than \"no credential offered\" and must fail closed\n // regardless of whether THIS capability happens to declare `policies:[authenticated]`\n // (ADD-42 R-11). Reuses the evaluator's own reason code and this function's existing\n // policy-denial response shaping verbatim — no parallel response path.\n const decision: PolicyDecision = opts?.callerResolutionFailed\n ? {\n allowed: false,\n denial: {\n reason: \"policy_unevaluatable\",\n message: `capability '${tool.id}' could not be evaluated — caller identity could not be established (resolveCaller failed) — refusing (fail-closed).`,\n },\n }\n : evaluatePolicy(tool, {\n principal: opts?.caller?.principal,\n credentialPresent: opts?.caller?.accessToken !== undefined,\n });\n if (!decision.allowed) {\n // #44: the evaluator's OWN reason code, copied verbatim — no re-spelling, no mapping table,\n // no superset for the policy case. The message is likewise the evaluator's, unaltered:\n // the record copies, it never authors.\n audit({ phase: \"denied\", message: decision.denial.message, denialReason: decision.denial.reason });\n return {\n content: [{ type: \"text\", text: decision.denial.message }],\n _meta: {\n [POLICY_DENIED_META_KEY]: {\n error: \"policy_denied\",\n // `tool.id` — the unsanitized CDL id, never the MCP-sanitized advertised `name`\n // lookup key (BR-28, mirroring ADD-19 and ADD-30 BR-7).\n capability: tool.id,\n reason: decision.denial.reason,\n },\n },\n isError: true,\n };\n }\n\n // #45 (ADD-45 D-2/D-3): the rate-limit evaluation step, called at the SAME point as the policy\n // evaluator above — immediately after it allows, strictly before `invokeRest` — so a\n // rate-limited call does exactly as much connector work as a policy-denied one: none. Reuses\n // the identical `policy_denied` `_meta` shape and audit wiring; only the reason code differs.\n const rateDecision = await evaluateRateLimit(tool, { principal: opts?.caller?.principal }, opts?.rateLimitCounter);\n if (!rateDecision.allowed) {\n audit({ phase: \"denied\", message: rateDecision.denial.message, denialReason: rateDecision.denial.reason });\n return {\n content: [{ type: \"text\", text: rateDecision.denial.message }],\n _meta: {\n [POLICY_DENIED_META_KEY]: {\n error: \"policy_denied\",\n capability: tool.id,\n reason: rateDecision.denial.reason,\n },\n },\n isError: true,\n };\n }\n\n const result = await invokeRest(tool, args, opts);\n if (!result.ok) {\n // #44: every attempt that never completed a usable round-trip — unbound capability, missing\n // env var, missing caller credential, no baseUrl, an allowlist rejection, a missing path\n // parameter, a network error, a non-2xx response — records `failed` carrying the shipped\n // error text verbatim, so a deployer greps the audit log and finds the same string the\n // agent was shown.\n const text = result.error ?? \"invocation failed\";\n audit({ phase: \"failed\", message: text });\n return { content: [{ type: \"text\", text }], isError: true };\n }\n\n // #12 (ADD-12): a binding with a `response:` mapping is now MAPPED + VALIDATED against the\n // resource — the outputSchema (ADD-11) becomes an enforced contract, not just declared.\n if (tool.response) {\n const mapped = applyResponseMapping(tool, result.data, registry.ir.resources);\n if (mapped.status === \"violation\") {\n // Fail closed (D-6): the declared output shape was not met — no raw pass-through.\n const missing = mapped.missing ?? [];\n // The text is unchanged byte-for-byte; it moved into a shared helper (#44) only so the\n // embedded consumer, whose own result carries no text, records the identical sentence.\n const text = contractViolationMessage(tool.id, missing);\n // #19 (ADD-19 Rev 2 D-3′/D-6): structured error object lives in `_meta`, never\n // `structuredContent` — the reference SDK client validates `structuredContent` against\n // the tool's `outputSchema` unconditionally (not gated on `isError`), so a VIOLATION\n // object there (which never conforms to the success outputSchema) crashes the client\n // (verified live against the SDK's own InMemoryTransport, R2.0/R2.2). `capability` is\n // `tool.id`, the unsanitized CDL id — never the MCP-sanitized `name` lookup key (BR-7).\n // #44: a VIOLATION is `failed` — the declared output shape was not met.\n audit({ phase: \"failed\", message: text });\n return {\n content: [{ type: \"text\", text }],\n _meta: { [CONTRACT_VIOLATION_META_KEY]: { error: \"contract_violation\", capability: tool.id, missing } },\n isError: true,\n };\n }\n const content: CallResult[\"content\"] = [{ type: \"text\", text: JSON.stringify(mapped.data, null, 2) }];\n if (mapped.status === \"degraded\") {\n content.push({ type: \"text\", text: `note: optional field(s) absent (degraded): ${(mapped.degraded ?? []).join(\", \")}` });\n }\n // #44: `degraded` records `succeeded`, NOT `failed` — every *required* field was present and\n // an optional one was not, so the invocation succeeded. Pinned in a comment because\n // \"degraded\" reads like a failure and the next reader will guess otherwise.\n audit({ phase: \"succeeded\" });\n return { content, structuredContent: mapped.data, isError: false };\n }\n\n // No response mapping: today's raw pass-through (rollout-safe). The declared outputSchema is\n // NOT yet enforced for these tools — add a `response:` block to close the loop (ADD-12 R-3).\n const out: CallResult = { content: [{ type: \"text\", text: JSON.stringify(result.data ?? null, null, 2) }], isError: false };\n if (tool.output.length > 0) {\n const data = result.data;\n if (data && typeof data === \"object\" && !Array.isArray(data)) {\n out.structuredContent = data as Record<string, unknown>;\n }\n }\n // #44: `status.output` is deliberately NOT populated here (nor anywhere) — `result.data` is\n // exactly the payload the record must never carry.\n audit({ phase: \"succeeded\" });\n return out;\n}\n\n/** Build an MCP Server that lists and invokes the registry's tools. */\nexport function createMcpServer(registry: Registry, opts?: InvokeOptions): Server {\n const server = new Server({ name: \"archstone\", version: \"0\" }, { capabilities: { tools: {} } });\n\n server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions(registry) }));\n\n server.setRequestHandler(CallToolRequestSchema, async (req) => {\n const args = (req.params.arguments ?? {}) as Record<string, unknown>;\n const result = await callTool(registry, req.params.name, args, opts);\n return result as CallToolResult;\n });\n\n return server;\n}\n"],"mappings":"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","names":["text"]} |
| import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { Registry } from '@archstone/emitter-support'; | ||
| import { InvokeOptions } from '@archstone/provider-rest'; | ||
| type JsonSchema = Record<string, unknown>; | ||
| interface McpToolDef { | ||
| name: string; | ||
| description: string; | ||
| inputSchema: JsonSchema; | ||
| outputSchema?: JsonSchema; | ||
| } | ||
| /** The MCP tool list: only invocable (bound) capabilities become tools — Registry's | ||
| * `invocableTools()` (ADD-30 D-3) is the single source of truth shared by this function | ||
| * (what's listed) and `callTool` (what's resolvable, via `getCapability`), keeping the two | ||
| * consistent. Input and output fields lower against the IR resource registry, so a | ||
| * `collection: Stay` output emits a typed, described `outputSchema` (not a bare | ||
| * `{type:object}`). | ||
| * | ||
| * ADD-24: a bound tool whose combined exposure (`registry.getExposure`, lifecycle + optional | ||
| * health) is `listed:false` (lifecycle `experimental`/`retired`) is dropped from the returned | ||
| * list entirely — unlisted, per D-10, though `experimental` remains callable by id (see | ||
| * `callTool`). A tool carrying a `hint` (beta/deprecated, or a yellow/red health reading) has | ||
| * its text appended to `description` — the only MCP-specific rendering of the neutral | ||
| * exposure the emitter-support layer computed. */ | ||
| declare function toolDefinitions(registry: Registry): McpToolDef[]; | ||
| interface CallResult { | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| structuredContent?: Record<string, unknown>; | ||
| isError: boolean; | ||
| _meta?: Record<string, unknown>; | ||
| } | ||
| /** #19 ADD-19 Rev 2 D-6: the namespaced `_meta` key a VIOLATION result's structured error | ||
| * object is carried under. Never populated on `structuredContent` (D-3′) — the reference | ||
| * MCP SDK client validates `structuredContent` against `outputSchema` whenever the tool | ||
| * declares one, regardless of `isError`, so a non-conforming error object there crashes the | ||
| * client. `_meta` is untouched by that validation and passes through the client's zod parse | ||
| * unstripped (`ResultSchema`/`RequestMetaSchema` are `z.looseObject`). */ | ||
| declare const CONTRACT_VIOLATION_META_KEY = "dev.archstone/contract_violation"; | ||
| /** ADD-24 D-11: the namespaced `_meta` key a `retired` (or otherwise `invocable:false`) | ||
| * tool's rejection is carried under — reuses `CONTRACT_VIOLATION_META_KEY`'s precedent | ||
| * (ADD-19 Rev 2 D-3′/D-6) verbatim: never `structuredContent`, so the reference SDK client's | ||
| * unconditional `structuredContent`-against-`outputSchema` validation never sees it. A | ||
| * distinct key (not `CONTRACT_VIOLATION_META_KEY`) so a client distinguishes "this call was | ||
| * blocked before any connector work" from "the provider's response violated the contract". */ | ||
| declare const LIFECYCLE_BLOCKED_META_KEY = "dev.archstone/lifecycle_blocked"; | ||
| /** ADD-56 D-2/OQ-56-B: the namespaced `_meta` key an unrecognized-lifecycle rejection is | ||
| * carried under — a DISTINCT key from `LIFECYCLE_BLOCKED_META_KEY`, mirroring | ||
| * `POLICY_DENIED_META_KEY` vs. `LIFECYCLE_BLOCKED_META_KEY` being genuinely distinct keys | ||
| * rather than one key with a varying `error` string inside it. `retired` (a governance | ||
| * refusal) and an unrecognized `lifecycle` (a compatibility refusal) are different facts with | ||
| * different remediations and must be trivially distinguishable to a client — see | ||
| * `exposure.ts`'s `Exposure.blockedReason` doc comment. */ | ||
| declare const LIFECYCLE_UNEVALUATABLE_META_KEY = "dev.archstone/lifecycle_unevaluatable"; | ||
| /** #43 ADD-43: the namespaced `_meta` key a POLICY denial is carried under — the third use of | ||
| * the ADD-19 Rev 2 D-3′/D-6 precedent, verbatim: never `structuredContent`, because the | ||
| * reference SDK client validates that against the tool's `outputSchema` unconditionally (not | ||
| * gated on `isError`) and an error object there crashes it. A distinct key from the other two | ||
| * so a client can tell "refused by policy before any connector work" from "blocked by | ||
| * lifecycle" from "the provider's response violated the contract" — the three are mutually | ||
| * exclusive on one call (BR-27). The object discloses the reason code and the capability id | ||
| * and NOTHING about the policy itself: no metadata.id, no allow/deny entry, no other | ||
| * principal's identifier (BR-30, Rule #7 — the MCP client is the untrusted side). */ | ||
| declare const POLICY_DENIED_META_KEY = "dev.archstone/policy_denied"; | ||
| /** Route an MCP tool call to the REST provider and format the result as MCP content. */ | ||
| declare function callTool(registry: Registry, name: string, args: Record<string, unknown>, opts?: InvokeOptions): Promise<CallResult>; | ||
| /** Build an MCP Server that lists and invokes the registry's tools. */ | ||
| declare function createMcpServer(registry: Registry, opts?: InvokeOptions): Server; | ||
| export { CONTRACT_VIOLATION_META_KEY as C, LIFECYCLE_BLOCKED_META_KEY as L, type McpToolDef as M, POLICY_DENIED_META_KEY as P, type CallResult as a, LIFECYCLE_UNEVALUATABLE_META_KEY as b, createMcpServer as c, callTool as d, toolDefinitions as t }; |
173397
9.05%1184
10.65%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated