This page lives with the code
The authoritative copy is
mcp/servers/witan/docs/adr/0011-witan-ui-read-transport.md.
11. Witan UI read transport: the UI is an MCP client, same-origin, in both modes¶
- Status: Accepted, amended 2026-09-22 (
memory_contradictionsjoins the bound set) - Date: 2026-09-17
- Deciders: witan platform owners
- Tracking: task
tk-decide-the-witan-ui-read-transport-cli-subproces-3b691b, projectwp-cross-platform-witan-gui-11c03d - Related:
docs/adr/0004-keycloak-jwt-per-user-actor-mapping.md(the JWT→actor→token mapping this reuses, and the RFC 9728 discovery surface its addendum added);docs/adr/0005-secure-cli-path-into-deployed-witan.md(the CLI's own remote MCP-client mode, whose identity path this reuses);docs/adr/0009-stateless-mcp-protocol-era.md(no handshake, no session id — what makes a browser client cheap);docs/adr/0010-private-code-graph-read-scoping.md(the read scoping a second read surface would have to honour); ol-infrastructuresrc/ol_infrastructure/applications/witan/ingress.py
Context¶
Witan's graph has no human-readable surface, and the Witan UI project sets out
to build one served two ways: witan ui against the local store, and mounted on
the deployment for the shared graph. Scope item 1 requires one read layer
shared by every client, so the transport has to work in both modes or the
layer forks on day one.
Discovery measured the CLI as that transport and found three gaps
(pf-witan-cli-json-surface-available-to-a-gui-drill--0d161d): no way to get
task detail; witan project status does not honour the global
--output-format; an empty list prints prose and exits 0. A fourth
memory (les-parsing-witan-output-format-json-from-a-subproce-d1ddd1) measured
the parse contract: branch on exit code, then attempt a JSON parse, then treat a
parse failure at exit 0 as the empty state, and ignore ~1.4KB of structlog noise
on stderr.
Three candidates were on the table: the CLI as a subprocess, an MCP client, or
JSON HTTP routes on the server via FastMCP custom_route.
Three premises this decision inherited are wrong¶
There is no existing OIDC session to mount behind. The project scope assumed
the deployed UI could sit behind one. It cannot, because none exists. APISIX
routes /* to witan's Service with plugins=[] and its own module docstring
says so outright: "APISIX does not participate in authentication." Witan's
_auth is a RemoteAuthProvider wrapping a JWTVerifier (witan/server.py:190),
which is bearer-token verification on the MCP protocol endpoint. There is no
cookie session anywhere in the path. So every candidate owes an answer for
how a browser gets a credential; that is not a cost unique to one of them.
--output-format json emits a view, not a record. This is new to this
decision and neither discovery memory records it. render_table
(witan/cli/_common.py:216) dumps the display rows the command already built,
so witan tasks --output-format json returns repo already shortened by
_short_repo, blocked_by as a comma-joined string, and every None
normalized to "" because TOML has no null. witan projects does the same to
repos. A subprocess client therefore parses a presentation projection and has
to reverse it — and closing the three known gaps would not change that, because
the lossiness is in the rendering path every table command shares.
Task detail is not missing from the CLI. Discovery recorded that no CLI
command shows a task, and this ADR repeated it. Wrong: task_app is built with
default_command=_task_show (witan/cli/tasks.py:196), so witan task <slug>
already prints description, comments, tags, symbol_refs and the parent/project
links. Only the named show alias is absent.
What is actually missing is a machine-readable detail view. _task_show is
console.print end to end, with no render_table call and no structured branch
at all, so it has no --output-format mode to honour — unlike the table
commands, which at least emit something. So the gap is narrower than recorded
and worse in kind: the detail exists, as human text only.
One narrower correction in the same vein: witan project status does have a
--json flag (witan/cli/projects.py:156) that prints the raw
workflow_project_status payload. What it ignores is the global
--output-format. The bug is that the global flag loses to a per-command one,
not that JSON is unavailable.
Forces¶
- Task detail has to arrive as data. It is the original MVP. The CLI can
already display it, but only as rendered text, so
task_getis the only source that returns it in a shape a view can bind to. - Per-actor read scoping must not be bypassed. Deployed memory reads are
scoped per actor by
_resolve_client(), and ADR-0010 is actively narrowing code-graph reads. A read surface that authenticates differently from the tools is a way to lose that. - The composition the views need is in the tools, not the queries.
task_ready(witan/server.py:6878) callsreadiness.is_readywith a resolver that fetches blocker status from outside the candidate set, andreadiness.status_pickabledecides lease expiry;workflow_project_status(4347) does the project rollup;recall(7482) composes BM25, graph expansion, superseded-pruning, and re-ranking.queries/read.gqis 1,376 lines of single-hop reads underneath all of that. Note thatreadiness.filter_readyis a different rule, not the one the board should reproduce: it works over an in-memory set and treats a blocker missing from that set as closed, which is right for the context hook and wrong for a view that pages. - Local and deployed must run the same client code, or scope item 1 is lost.
Decision¶
The UI is an MCP client. It speaks MCP over streamable-http to a witan server
on its own origin, in both local and deployed mode. The read layer sits on the
tools, not on queries/read.gq.
1. Topology¶
One process serves both the app bundle and /mcp, so the UI never makes a
cross-origin call:
- Local.
witan uiruns witan with--transport streamable-httpbound to127.0.0.1and serves the static bundle from the same process. The SPA points at that origin's/mcp. - Deployed. The bundle is served by the witan tier the agents already call,
and the SPA points at the same origin's
/mcp— the URL clients use today (https://witan.<env>.ol.mit.edu/mcp).
Same-origin serving is what keeps CORS out of the picture: nothing in witan
configures it (no allow_origin, no CORS middleware), and a same-origin SPA
never needs it. Moving the bundle to a separate origin later makes CORS a
prerequisite.
★ SAME-ORIGIN IS NOT ORIGIN VALIDATION, AND THE LOCAL MODE IS WHERE THAT BITES.
An earlier draft of this ADR said same-origin serving meant the UI needed no
origin protection at all. That is wrong, and it is wrong in the direction that
matters. CORS governs what a browser will let a page read back; it does not
stop the request being sent, and it is not a server-side control. Any page the
user visits can POST to http://127.0.0.1:<port>/mcp, and in local mode that
endpoint has no credential in front of it at all — identity_cfg.oidc_issuer
is unset, so every tool call is served as the default actor. DNS rebinding is
the same attack with the origin check defeated.
So server-side Origin/Host validation on /mcp is a prerequisite of this
decision, in both modes, not a consequence of the topology. It is not
implemented today. Binding to 127.0.0.1 narrows the network path but does not
close this, because the attacker's page runs inside the browser on that host.
The read layer work does not start until witan ui serves a /mcp that
rejects unexpected origins.
2. Auth story¶
- Local:
identity_cfg.oidc_issueris unset, so_resolve_client()returns the single_default_client(witan/server.py:246) — byte-identical to whatwitan tasksdoes today. No credential, one user,127.0.0.1only. The trust model is the existing CLI's. - Deployed: the SPA runs browser OIDC (authorization code + PKCE) against
Keycloak for the
witanaudience and sendsAuthorization: Beareron every/mcprequest.RemoteAuthProvideralready serves RFC 9728 protected-resource metadata and aWWW-Authenticate401 pointing at it (witan/server.py:182) — the discovery surface ADR-0004's addendum added for MCP clients that guess/authorize. A discovery-capable browser MCP client consumes it unchanged.
Per-actor scoping and the Cedar bundle then apply to a UI reader exactly as to an agent, through the code path that already enforces them. No new authorization plane.
The one new piece of configuration is a Keycloak public client for the UI
(PKCE, no secret, witan audience). It needs both a redirect URI and the
deployed UI origin in the client's allowed web origins: the SPA exchanges its
authorization code at Keycloak's token endpoint, which is a different origin
from the UI, so without that entry the browser blocks the exchange and the
redirect alone buys nothing. That lives in Keycloak/ol-infrastructure config,
not in witan.
3. Read layer shape¶
The read layer is a thin typed client over the existing tools, unwrapping MCP
content blocks (structuredContent, or the JSON text block) in exactly one
place. Every view in scope item 3 composes from those results.
The tool set is enumerated rather than globbed, because the UI is read-only and
several families mix reads with mutations — memory_* alone contains
memory_store, memory_update, memory_delete and memory_link. The read
layer binds exactly these and nothing else:
- tasks:
task_ready,task_get,task_list,task_search - projects and sessions:
workflow_project_get,workflow_project_status,workflow_project_list,workflow_session_list - memory:
recall,memory_get,memory_list,memory_search,memory_neighbors,topic_get, andmemory_contradictions(added by the 2026-09-22 amendment below) - code graph, optional:
code_repo_dependencies,code_interface_providers,code_interface_consumers(added by the 2026-09-23 amendment below)
An implementation that needs a tool outside this list is making a scope change, not filling in a gap, and amends this ADR.
It does not read queries/read.gq. The sibling task
tk-build-the-witan-ui-read-layer-over-queries-read--825c85 is named for that
altitude and its premise is superseded by this ADR: a layer over the raw
queries would re-implement lease expiry, the project rollup, and
superseded-pruning, which are precisely the parts the board and memory views
depend on and the parts most likely to drift from the tools.
4. What happens to the CLI gaps¶
They are real bugs regardless of this decision, and they stay with the sibling
task tk-close-the-cli-json-gaps-the-gui-discovery-found--28dad2 at p2. What
changes is their framing: that task is not a UI prerequisite and should not
be scoped as one. Because --output-format json emits a display projection,
closing the three gaps still would not make the CLI a viable GUI transport. The
work is worth doing for humans and shell scripts:
- Give task detail a structured mode.
witan task <slug>already renders it (task_appsetsdefault_command=_task_show), so the missing piece is not the view but a machine-readable one:_task_showisconsole.printthroughout and has no--output-formatbranch. Ashowalias is cosmetic next to that. - Make the global
--output-formatwin forproject statusrather than requiring its own--json. - Emit an empty structured result (
{"title": …, "rows": []}) instead of prose when--output-formatis nottxt. The early return on an empty row set (witan/cli/tasks.py:86) fires beforerender_tableis ever reached, which is why the format flag has no say today.
A fourth, larger item falls out of the display-projection finding and is worth recording even though nothing in this project blocks on it: structured output should serialize the tool records, not the table cells. That is a breaking change to every table command's JSON shape, so it needs its own decision.
Alternatives considered¶
CLI subprocess. Rejected, but not for the reason an earlier draft gave. That
draft said the CLI cannot reach a deployed graph, citing ADR-0005. ADR-0005 says
the opposite: it is the ADR that fixed this, and _srv()
(witan/cli/_common.py:46) returns a RemoteServerProxy whenever a remote
target resolves, so witan tasks against the deployment works today over the
same /mcp endpoint with the same per-actor mapping. The draft described
ADR-0005's Context and mistook it for its Decision.
Corrected, the rejection is stronger rather than weaker. The CLI is an MCP client, so routing the UI through it means spawning a subprocess that speaks the protocol this decision picks anyway, and then recovering data from its rendered output. Three costs, none of which the MCP path pays:
- A browser cannot spawn a subprocess at all. The web app in scope item 2 is the primary surface, so this option only ever serves the optional Tauri shell, which would then need its own transport. That forks the read layer by itself.
- The output is a projection, not a record — the display rows above for the
table commands, and pure
console.printfor task detail. - The parse contract (exit code, then parse, then parse-failure-as-empty), re-implemented in every client.
JSON HTTP routes via custom_route. Rejected, and the reason is
authentication rather than the duplication the task description anticipated.
FastMCP applies auth= to the protocol endpoint only, so a custom_route is
reachable with no bearer token. That invariant is pinned by a test rather than
an anecdote: tests/test_health.py:38-73 mounts witan's own handler on a
FastMCP carrying a verifier that can never succeed, and asserts GET /health
200 alongside POST /mcp 401. It runs against the fastmcp the repo actually
pins, so a version bump that reversed this would fail the suite. _resolve_client() then refuses it outright: a request with no
access token raises, deliberately, rather than borrowing _default_client
(witan/server.py:249). A JSON read route would therefore have to re-implement
JWT verification, derive_actor_id, and actor_token_resolver.resolve in a
second place. Getting that wrong yields an unauthenticated read surface over
per-actor-scoped memory and over the code graphs ADR-0010 is narrowing. Keeping
a second read surface in step with the tools is the smaller of the two costs,
and it was the only one the task weighed.
In-process tool calls behind a local HTTP API. This is the shape witan ui
would take most naturally, and it is the previous option's problem wearing the
CLI's clothes: it works locally, has no deployed story, and so forks the read
layer.
Consequences¶
- MCP framing for plain GETs. Every read is a JSON-RPC POST. On the
2026-07-28 era it carries its protocol version and capabilities in
params._meta; on the handshake era fallback below it is aninitializeexchange and a session id instead. Accepted either way: the framing buys authentication, per-actor scoping, and the composed reads, none of which we then own twice. - A browser MCP client dependency. Unverified which client version speaks the 2026-07-28 stateless envelope; FastMCP 4 negotiates the era per request (ADR-0009), so the handshake era is a working fallback if it does not. To be confirmed when the read layer is built.
- The stateless era makes this cheap. No handshake and no
Mcp-Session-Id(ADR-0009), so a read is one POST and any replica can answer it. The lost server→client back-channel costs the UI nothing for the tools enumerated in §3, none of which elicit. That is a property of the selected set, not of reads in general:code_find_definitionelicits on an ambiguous repo andcode_symbols_in_fileelicits for a repo URI (witan_code/server.py:448-459,602-612), both read tools. Adding a view over the code graph means either handlinginput_requiredin the browser client or passingrepoexplicitly so the elicitation never fires. - No second read surface. The tools stay the only way into the graph, so read scoping has one implementation and the views cannot silently diverge from what an agent sees.
- The MCP Apps widgets (scope item 4) are unaffected. A
ui://widget is handed its tool's result by the host and needs no client of its own, so it reuses the read layer's types and renderers without its own transport. /*on the APISIX route now matters.ingress.pyalready flags that the wildcard makes/healthpublicly reachable and suggests narrowing to/mcplater. Serving the UI bundle from this origin adds paths that must stay reachable, so that follow-up has to account for them rather than assume/mcpis the whole surface.
Rollout¶
- This ADR, recorded as a decision memory linked to the project.
- Re-scope
tk-build-the-witan-ui-read-layer-over-queries-read--825c85to sit on the tools (a comment records the premise change; the title is stale). - Add
Origin/Hostvalidation to/mcpbeforewitan uiserves anything. This is first among the code changes, not a hardening pass afterwards: the local endpoint is unauthenticated, so the browser-reachable window opens the moment the bundle does. - Build the read layer against a local
witan ui, where the auth story is "none" — the deployed Keycloak client is not on the critical path for any of the views. - Add the Keycloak public client, with both the redirect URI and the UI origin in its allowed web origins, before the deployment mount rather than before the local one.
- Correct the discovery memories this ADR contradicts, so the next reader does not re-derive the CLI premises from them.
Amendment (2026-09-22): memory_contradictions joins the bound set¶
The memory view's contradictions inbox had no read to sit on. recall reports
a pair only when both memories land in its ranked, limited result;
memory_neighbors needs a slug to start from; and the graph-wide
contradicts_edges_from/_to queries feed only the private _edge_index, not
any tool. The spec (docs/internals/design/witan-ui-spec.md §3.5) settled this
by adding a tool rather than a recall mode, because recall is a ranked,
seeded read and an inbox is an unranked enumeration.
memory_contradictions(repo) is that tool, and §3's list above now names it.
It is read-only, returns one row per unordered pair (newest link wins when a
pair is stored both ways, matching memory_neighbors), and scopes a pair in
when either memory is in the requested repo. recall applies the same repo
rule to the pairs it reports, so a pair recall flags is in the inbox for the
same repo (with no repo detected and none passed, recall reports every pair
in its result instead, because its query seed spans every repo; the UI always
passes repo). It sits in the same read allowlists as the other memory reads
(the CLI's local dispatch and the remote proxy), so nothing about the UI's
position as an ordinary MCP client changes.
Tracked as task tk-add-a-read-only-memory-contradictions-tool-and-a-44e4a1.
Amendment (2026-09-23): three code-graph reads join the bound set, as optional¶
The cross-repo bridge explorer
(tk-cross-repo-dependency-bridge-explorer-shown-only-64ca2d) shows which
repos are coupled through shared env vars, endpoints, packages and services,
and the bindings behind each coupling. That knowledge lives in witan-code's
bridge store and is reachable only through code_* tools, so the explorer
binds three of them:
code_repo_dependencies: the repo-to-repo graph, each edge carrying the contracts behind it (kind,key,confidence, andgenericfor a stoplisted key such asPORT).code_interface_providersandcode_interface_consumers: theInterfaceBindingrows for one contract, which is how one edge's contract drills to file, line, framework and enclosing symbol. The page passesin_repo(the edge's repo at that end) where the server declares it, and narrows the rows itself as well for a witan-code that predates it.
They are optional. witan-code is a separate package that witan serve and
witan ui mount only when it is installed, so a server without it is normal.
The page records the three as optional, tolerates their absence from
tools/list, and shows the tab only when they are present.
The Consequences above name the risk this carries: some code-graph reads elicit, and the stateless era has no back-channel for the page to answer on. Three rules keep that out:
- Never bind the tools that elicit to choose something.
code_find_definitionasks which repo on an ambiguous name andcode_symbols_in_fileasks for a repo URI. Neither is in the set. - Pass arguments explicitly.
kindandkeyalways, so nothing is left for the server to infer from a working directory the deployment does not have. - The page does not advertise elicitation. All three tools can offer to
index the current repo when the bridge store does not exist yet
(
_confirm_and_reindex_bridge), which would turn a read into a write. On the 2026-07-28 era the browser client pins, a client with noelicitationcapability makeswitan_core.elicitreport the ask as unsupported (_wire_mode),confirmreturns itsdefault_when_unsupportedofFalse, and the tool returns its empty shape without indexing (test_client_that_cannot_elicit_gets_the_defaultscovers the mechanism). A later change that gives the page an elicitation handler would re-open that path and has to amend this ADR.
Jumping from a binding to its symbol's source is not in this set. The
explorer shows the enclosing symbol_id and the Stage-2 symbol string as
text; a symbol view is its own scope change and amends this ADR again.