ADR-0055: Serve a read-only REST API over the SDK in the OCI image, and reach MCP clients through it
| Field | Value |
|---|---|
| Status | accepted |
| Date | 2026-10-10 |
| Schema version | 0.2.0 |
| Reversibility | two-way-door |
| Blast radius | component |
| Scope | component |
| Tags | architecture, server, container, mcp, api |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Decided at | 2026-10-10T16:17:54Z |
| Relates to | 0007, 0014, 0016, 0018, 0022, 0029, 0031, 0032, 0044, 0051, 0052, 0053, 0054, 0056 |
| Affects | path:packages/server/**, path:Containerfile, path:scripts/container-entrypoint.sh |
| Source | docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-t.md |
Status: accepted. Agent-drafted, ratified by
@mbeacomon 2026-10-10. It depends on ADR-0053 (an implemented@adrkit/sdk) and pairs with ADR-0054 (the portal bundle it serves) and ADR-0056 (the deployment that syncs its corpora and puts a gateway in front of it).This server authenticates no one. In its first version it must run on localhost or behind a gateway that does (ADR-0056 uses Azure API Management). It binds
127.0.0.1unless told otherwise.
Context
Section titled “Context”The surfaces that read a decision corpus today are all local: the CLI, the stdio MCP server, the CI Actions, and the agent plugin. A team that wants one place to browse decisions across repositories, or an agent that cannot spawn a local process, has nothing to call. ADR-0056 proposes an Azure deployment for that case and needs something in this repository to deploy.
Facts that shape the design, each read from the tree at this revision:
- The SDK is the contract, and it is a handle. ADR-0031 makes
@adrkit/sdkthe consumer contract. Its declared surface (packages/sdk/src/index.ts) is one I/O door,openDecisions, returning aDecisionSetwithrecords,issues,get,queue,graph, andgoverning. Its own doc comment names “a Backstage backend serving many requests against one corpus” as the reason it is a handle. Nothing is implemented yet; ADR-0053 implements it. - A long-lived server is the use the in-repo portal does not exercise. ADR-0053 counts the GitHub Pages portal (ADR-0054) as ADR-0031 clause 8’s real consumer, and names the gap: the portal is a build-time consumer. A server holding a handle across requests is the other half.
- The stdio MCP server cannot host a synced corpus.
@adrkit/mcp’s startup check refuses a root without a.gitentry (root-not-gitinpackages/mcp/src/corpus/projection.ts). A deployment that copies onlydocs/adrdoes not satisfy it, and stdio is not reachable remotely in any case. - ADR-0018 removed an HTTP stack on purpose. The MCP SDK v1 line shipped
Express, Hono, and
@hono/node-serverto a server that never ran them, and one of them carried the advisory ADR-0017 could only accept. The v2 server package has two dependencies. - The image publishes one target. ADR-0032 clause 4: the all-in-one
adrkittarget is published;cli,mcp,ci, andqueue-actionare local isolation targets. Its selector script (scripts/container-entrypoint.sh) passes any unknown first argument to the CLI, so a new selector is additive. TheContainerfilecopiespackages/sdk/package.jsonbut notpackages/sdk/src. - The stdio MCP container is networkless. Its documented run is
--read-only --network nonewith a:rorepository mount (README, AGENTS.md). An HTTP server is network-facing by construction, so that rule cannot carry over whole. governingreads the file at the supplied path unlessreadMarkers: falseis passed, to scan for an inbound@adrmarker (ADR-0022). A deployment that holdsdocs/adrholds none of the source files those paths name.- The SDK as sketched carries no record body and no commit. The sketch’s
DecisionRecordomits the body deliberately; ADR-0053 decision 11 adds it, as raw Markdown, within the SDK’s implementation scope. Nothing in the surface names the revision a corpus was read at, so the commit has to come from outside the SDK (clause 4). - The SDK has no search.
@adrkit/mcphassearch_decisions; the SDK’s seven entry points do not include one.
Decision
Section titled “Decision”We will add a private package, packages/server (@adrkit/server), that
serves read-only HTTP routes over @adrkit/sdk, ship it as a server
selector in the existing all-in-one image, and make it the first path by
which a remote MCP client reaches adrkit.
- Routes, all
GET. Under/api/v1/corpora/{corpus}:records,records/{id},graph(optionalkinds, a comma-separated subset ofsupersedes,relatesTo,conflictsWith; without it, the SDK’s default of supersession edges only),queue(optionalasOf=YYYY-MM-DD),issues(the corpus findingsDecisionSet.issuescarries, the lint view), andgoverning?path=<path>. Plus/api/v1/corpora(the configured keys and each one’s commit),/openapi.json,/healthz, and the portal at/(clause 9). Each route is one SDK call and a serialization; the server resolves nothing itself (ADR-0029 clause 8). There is no search route until the SDK has search, so the remote tool set is not the stdio server’s. - No write routes, enforced twice. Only
GETandHEADare routed; every other method gets 405 withAllow: GET, HEAD, and no route reads a request body. A test enumerates the route table and fails on any other method. A second test applies the regexes ofpackages/mcp/test/writing-verbs.test.tstopackages/server/src, so neither a CLI verb (accept,approve,object,resolve) nor a writing transition (acceptAdrSourceand its siblings) can appear. Recording a person’s act stays a human-run CLI command and a pull request (ADR-0044, ADR-0051, ADR-0052). Each test is observed failing against a planted violation before it counts (ADR-0016). - Every response names its commit and is deterministic. Data responses
are
{ corpus, commit, data }, withasOfadded on the queue. For the same commit and the same parameters the body is byte-identical: no timestamp, request id, or host name in it.ETagderives from the commit and the canonical query. A queue request withoutasOfuses the UTC date at request time and echoes it, so it is deterministic given the echoed value./healthzis the one route that is not. - The corpus is a local directory, and the commit comes with it. The
server never calls GitHub, never runs
git, and spawns no process. It reads each corpus from a directory that something else populates (ADR-0056’s sync job, or a person’s checkout). The commit SHA comes from a sidecar file the populator writes into that directory. That file is the contract between this record and ADR-0056, worded identically in both:.adrkit-corpus.jsonis a JSON object holding at least{ "commit": "<40 hex>" }, wherecommitis the full 40-hex SHA of the tree written; the populator is its only writer, writes it last, into a tree written aside and swapped in whole; a field added later is additive. A sidecar is valid when it parses as a JSON object whosecommitmatches^[0-9a-f]{40}$. A corpus without a valid sidecar is not served: its routes answer 503 with a fixed message, never an answer without a commit. - A new sync becomes visible by swap, never mid-read. The server keeps one
DecisionSetper corpus. When the sidecar’s commit changes it opens a fresh handle, reads the sidecar again, and keeps the new handle only if the commit did not move during the load; requests already in flight finish on the old one. The populator must write the new tree aside and swap it in whole (ADR-0056), so a load never sees half a sync. This reopens per sync, never per request, usingopenDecisionsalone. If the SDK turns out to need a reload or invalidation method for this, that is the signal ADR-0053 lists for its handle’s shape, and the change belongs in the SDK, not here. - Many corpora, configured, confined. A read-only JSON config maps a key
(
^[a-z0-9][a-z0-9-]{0,63}$, the{corpus}path segment) to a directory. Each directory isrealpath-resolved at start and must stay inside a configured root, as the agent plugin’sstaysInsidedoes. An unknown key is 404 with a fixed message. Keys are names a person chose, so/api/v1/corporalists them; a deployment that considers repository names private must name keys accordingly, because the server has no per-caller view. governingnever reads a file. The server always passesreadMarkers: false. It holdsdocs/adr, not the source tree, so a marker scan has nothing to read; passing a path through would also turn a query parameter into a file read. Evidence is thereforeaffectsmatches only, and responses say so in a fixed field. The path is supplied by the caller, which is ADR-0029’s Tier 1; nothing derives it from a catalog. It is validated first: relative, no..segment, no leading-, no control character, at most 1024 characters. An{id}must match the schema’s id grammar before lookup. When the corpus has any error finding, the SDK returns all three groups empty with the corpus issues, asadr explaindoes; the route then returns those issues and a fixed state saying resolution was withheld because the corpus has errors, never a bare empty result that reads as “ungoverned”.- Errors are fixed text. Every error response is
{ error: { code, message } }with the message looked up by code. No stderr, exception message, stack, or file system path reaches a response or the access log, mirroring the agent plugin’spublicMessagerule. A test plants a sentinel in a thrown error and in a corpus that fails to load and asserts it appears in no response, aspackages/adapters/agent-plugin/test/error-text.test.tsdoes. Corpus findings inissuesare data, served as the SDK returns them; they are not error text. A query value the SDK refuses with aRangeError(an impossible or non-calendarasOf; an empty or unknownkinds) is a 400 with fixed text, validated before the call where the server can. - It serves the portal.
/serves the static bundle ADR-0054 builds, and the bundle’s data file is generated per request from the same loaded handle at the same commit as the API, by calling@adrkit/portal’s pure builder (ADR-0054 clause 3) with the handle, the sidecar’s commit, the resolvedasOf, and an empty path table. The server holds no source tree, so the governance-by-path view says it has no table and points at this server’s owngoverning?path=route; every other view is the same on both hosts. Letting the server-hosted page call that route itself is deliberately left out of the first version: it would give the one renderer a network path the Pages host does not have, and a looser CSP. Because the output is a function of commit andasOf, it can be cached per commit andasOf. The server always serves/; whether a gateway publishes it is the deployment’s choice, and ADR-0056’s first version publishes it in private mode only. ADR-0054 owns the bundle being base-path-relative and its renderer’s textContent-only rule. This server sets the headers, because this is the origin where untrusted titles render:Content-Security-Policywithdefault-src 'none','self'scripts, styles, and data fetches, andframe-ancestors 'none'(which ADR-0054 clause 7 relies on, because a meta policy cannot carry it),X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer, and no CORS headers. Record bodies in the data file come through the SDK (ADR-0053 decision 11), not from a file read here. node:http, no framework. The package depends at runtime on@adrkit/sdkand on the private@adrkit/portal(for its pure builder only), and nothing else; both are workspace packages bundled into the same image. Fact 4 is why there is no framework.scripts/check-deps.tsgains an@adrkit/serverentry admitting exactly those two, with a negative case, so the package is not silently unconstrained. Its source builds with--target=nodeand uses noBunglobal, enforced aspackages/cli/test/node-compatibility.test.tsdoes for the CLI.- An OpenAPI document is part of the surface.
/openapi.jsondescribes every route with a stableoperationId. It is the input APIM’s “REST API as MCP server” imports, and its operation ids become tool names, so renaming one is a break for remote agents. A test fails if the document and the route table disagree. - The image gains a selector, not a package. The all-in-one
adrkittarget adds/opt/adrkit/server.jsand aserver/adrkit-serverselector inscripts/container-entrypoint.sh. A localservertarget mirrorscliandmcpfor isolation and SBOM checks. Nothing new is published; ADR-0032 clause 4 stands. The build stage copiespackages/sdk/src,packages/portal/src, andpackages/server/src. The package is private and rides the lockstep image; it bundles the SDK and the portal builder from the same tree, so the SDK’s independent version (ADR-0031 clause 7) does not enter the image’s versioning. - Container posture: one way in, no way out, nothing writable. The stdio
MCP rule (
--read-only --network none,:romount) stays as it is formcp. The server keeps everything but--network none: a read-only root file system, corpora and config mounted:ro, the non-rootnodeuser, one listening port (default8080), and no outbound connection of its own. A test runs the server under a variant ofpackages/mcp/test/side-effect-denial-preload.mjsthat permits that one listener and denies file system mutation, child processes, and outbound connections. A writable mount is never part of a documented server run. - Bind localhost by default; no auth in the first version. The default
host is
127.0.0.1, which inside a container is unreachable from outside it. Reaching it needs an explicitADRKIT_SERVER_HOST=0.0.0.0(or another address). Setting it is the operator asserting that a gateway that authenticates, or the host’s own loopback, is in front. On a non-loopback bind the server logs one fixed warning saying it authenticates no one. The README section for this selector leads with that sentence. - Remote MCP goes through the REST API first. The first remote path is
APIM’s “REST API as MCP server” over these routes (ADR-0056): APIM
presents the operations as MCP tools, tools only. A native streamable-HTTP
transport for
@adrkit/mcpis deferred to a later record. Through APIM, a client sees APIM’s tools, named byoperationId, not the stdio server’s four (get_decision,get_decision_context,search_decisions,list_superseded), and ADR-0018’s dual-era property does not carry over: which protocol revisions APIM serves is APIM’s. Whether it serves the stateless2026-07-28era is unmeasured.
Options considered
Section titled “Options considered”Option A: a read-only node:http server over the SDK, in the existing image (chosen)
Section titled “Option A: a read-only node:http server over the SDK, in the existing image (chosen)”One renderer for the API and the portal, one image, no new published package, and the long-lived consumer ADR-0053 says the portal does not provide. Costs a network-facing process the project has not run before, and a gateway it does not ship.
Option B: native streamable-HTTP for @adrkit/mcp first
Section titled “Option B: native streamable-HTTP for @adrkit/mcp first”Agents get the four tools they already know, with dual-era serving. But it
gives the portal and other HTTP clients nothing, @adrkit/mcp would need a
non-git root mode (fact 3) plus HTTP session and auth handling ADR-0018 kept
out of the package, and APIM’s “expose an existing MCP server” path still
needs it. Deferred, not rejected.
Option C: a separate published image for the server
Section titled “Option C: a separate published image for the server”Narrower SBOM for the deployment. Rejected by ADR-0032’s reasoning: a second registry package, tag set, and recovery path for an entry point the one image can select.
Option D: an HTTP framework (Hono, Express, Fastify)
Section titled “Option D: an HTTP framework (Hono, Express, Fastify)”Routing and middleware for free. Rejected: the route table is ten GET
paths (six per corpus, plus /api/v1/corpora, /openapi.json, /healthz,
and the portal at /),
and fact 4 is a measured cost of exactly this dependency class.
Option E: shell out to adr ... --json per request
Section titled “Option E: shell out to adr ... --json per request”No SDK dependency. Rejected: a process per request, and the CLI JSON for
explain and lint is assembled in the CLI (ADR-0031’s own table). It would
also put a child process in a server that otherwise spawns none.
Option F: authentication in the server
Section titled “Option F: authentication in the server”A bearer-token or OIDC check in-process. Deferred: the first deployment
authenticates in APIM (validate-azure-ad-token, ADR-0056), and a second,
weaker check here would invite running without the gateway. Revisit if a
non-Azure deployment needs it.
Trade-offs
Section titled “Trade-offs”- An unauthenticated server can be exposed by mistake. Binding
0.0.0.0with a published port and no gateway serves every configured corpus to anyone who can reach it, private repositories included. The localhost default, the one-line warning, and the README are the only guards in this repository; the gateway is ADR-0056’s. governingis weaker thanadr explain. No marker evidence (clause 7), so a file governed only by an@adrmarker shows nothing. Fixed text in the response says so; it cannot show what the server cannot read.- The remote tool set differs from the stdio one (clause 15), and there is no search. An agent configured for both sees two vocabularies.
- A new serialization contract. The route shapes and
operationIds are a surface that remote agents and the portal depend on. They are versioned under/api/v1and change additively, as ADR-0031 asks of the SDK. - The commit is only as true as the sidecar. The server trusts what the populator wrote. A sync job that writes the wrong SHA produces confidently attributed answers about the wrong revision.
- Stale between syncs. Answers lag the default branch by the sync interval; the commit in every response is how a reader tells.
Consequences
Section titled “Consequences”- Easier: one HTTP surface for a portal, a team dashboard, and remote agents, every answer pinned to a commit; and a real long-lived consumer of the SDK.
- Harder: a network-facing process to secure and operate, a sidecar contract with ADR-0056, an OpenAPI document to keep in step, and a container target to add to CI.
- How we would know this was wrong: a route accepts a method other than
GET/HEADor changes a file; a response omits the commit, or two responses for the same commit and parameters differ; the server makes an outbound connection or spawns a process; stderr, a stack, or a path appears in an error response; the server binds a non-loopback address without being told to; or a deployment reaches it without a gateway because the docs made that the easy path. - Revisit if: the SDK gains search; APIM is measured
with a
2026-07-28client (either way); a deployment needs in-process authentication; or a native streamable-HTTP transport for@adrkit/mcpis taken up.
Evidence rung
Section titled “Evidence rung”Rung 1 under ADR-0014, since
#314 (2026-10-10). The unit,
contract, method-guard, verb-guard, error-text, side-effect-denial, and
OpenAPI-agreement tests in packages/server/test/ were each observed failing
against a planted break first (ADR-0016), as #314 lists; the side-effect-denial
test runs under Bun on the source and under Node on the bundle. A local Podman
smoke ran the all-in-one image read-only with a :ro corpus, and the CI
container smoke (“Smoke test the read-only server through the all-in-one
image”) runs the server selector under --read-only with a :ro mount and
passed on main. The APIM path (clause 15, action item 8) is unmeasured, and
no reference-repository or external run exists, so neither rung 2 nor rung 3
is claimed.
Action items
Section titled “Action items”- Create
packages/server(private) onnode:httpover@adrkit/sdk, with the routes, envelope, sidecar read, and swap of clauses 1 to 7, and itscheck-depsentry (@adrkit/sdkand@adrkit/portalonly) with a negative case. Done 2026-10-10 in #314:scripts/check-deps.tsadmits exactly the two, andscripts/check-deps.test.tsrejects a third workspace package and an HTTP framework. - Add the method guard, the verb and transition guard, the error-text
sentinel test, and the side-effect-denial variant, each observed failing.
Done 2026-10-10 in #314:
methods,writing-verbs,error-text, andside-effect-denialtests, observed failing against a planted POST route, writing verb,String(error)in a 500, andexecSync(under both runtimes). - Serve
/openapi.jsonand test it against the route table. Done 2026-10-10 in #314: generated fromsrc/routes.ts;openapi.test.tswas observed failing on planted drift. - Serve ADR-0054’s bundle and its data file from the loaded handle, with
the headers of clause 9. Done 2026-10-10 in #314: the data file is built
per request by
buildPortalDatafrom the loaded handle at the sidecar’s commit, served with the clause 9 CSP (frame-ancestors 'none'observed failing when dropped),nosniff,no-referrer, and no CORS header. - Add the
serverselector and local target to theContainerfileandscripts/container-entrypoint.sh, and a CI smoke under--read-onlywith:romounts. Done 2026-10-10 in #314: theservertarget and selector, and a CI step that builds the target and smokesadrkit:ci serverunder--read-onlywith a:rocorpus mount, green onmainat a5b0646 and e491a78. - Agree the
.adrkit-corpus.jsonsidecar with ADR-0056. Done 2026-10-10: clause 4 here and ADR-0056 decision 4 carry the same contract sentence, identical after whitespace and emphasis are normalized. - Document the selector in the README, leading with “this server authenticates no one”. Done 2026-10-10 in #314: the README’s “The read-only REST server” section opens with that sentence.
- Measure APIM’s “REST API as MCP server” over these routes with a
2025-era and a
2026-07-28client (ADR-0056). - Ratify or reject this record. Ratified by
@mbeacomon 2026-10-10.