MCP server — work on a safety case by chat
- Status: Implemented. Capability matrix rows
AF-AI-007(read),AF-AI-008(bridge),AF-AI-009(propose changes). - Architecture: ADR 0008 — one owner for the open project; ADR 0009 and ADR 0010 — one persisted integrated draft with human-controlled promotion.
- Consent: ADR 0007 — unchanged.
Expose Assurance Forge over the Model Context Protocol so a user can drive it from their own AI client (Claude Desktop, Claude Code, Cursor). Their subscription pays for inference, no API key is configured in Assurance Forge, and the interaction becomes a conversation that can read, reason about, and propose changes to a safety case.
This is a second, separate AI capability. The SCCG review keeps working
exactly as it does on its own OpenAI path, and the layer gate forbids mcp/
from including ai/ so the two cannot grow a shared inference path.
How it fits together
Claude Desktop / Code / Cursor
│ stdio, JSON-RPC 2.0
▼
assurance-forge-mcp ← MCP transport only; holds no model
│ src/bridge — versioned local IPC (named pipe / AF_UNIX, user-private, token)
▼
Assurance Forge
├── the single loaded model, always current
├── the single writer of every project file
├── requests execute on the frame thread → no lock, no stale copy
└── the agent's work drawn live on the GSN canvas
src/agent holds the operations. Both binaries link it, so an agent gets the
same answers whether or not a window is open.
Two modes
| Connected | Offline | |
|---|---|---|
| When | Assurance Forge has the project open | No application reachable |
| Reads | The complete integrated working draft the user is looking at, including MCP, SCCG and human draft groups | Accepted SACM from a copy the adapter loaded |
| Changes | Revision-checked groups in the application's persisted draft workspace | Refused |
Offline is read-only because the running application is the sole owner of the workspace, presents every unaccepted change to the user, and serializes edits from all contributors through one revision. A headless copy cannot safely do that. The refusal says so and says what to do.
Dynamic sessions and explicit offline mode
Launched with no project argument, the adapter is a dynamic session
(ADR 0014):
it initializes even when no application is running, discovers the running
instance at call time, and connects unbound. An unbound session can report
status — including the coarse fact that a project is open, never which one —
and receives no project content until the user grants it access (see Consent
below); the master mcp.enabled flag alone never discloses whatever project
the user has open. With more than one instance running, a dynamic session
refuses to choose — picking the newest would be picking a safety case by
timestamp.
--offline-project <path> is the offline mode above chosen deliberately:
accepted SACM from the named path, read-only, and it never connects — even
while an application with that project open is right there. --project
remains the project-bound mode described in the rest of this page.
The mode is re-evaluated on every call. MCP clients launch this process when
the client starts, so the application being absent, appearing later, or
restarting are ordinary events in a session's life, not faults: an offline
session promotes itself when the application appears, and a lost connection
reconnects on the next call — the user never has to restart their AI client.
An earlier design fixed the mode at connection time, reasoning that changing a
session's capability mid-conversation is worse than asking for a reconnect;
the permanently dead session turned out to be the worse trap. The change is
announced rather than quiet: every case-content result names its view, the
connection errors say the session heals by itself, initialize states the
mode in its instructions, and get_connection_status reports it on demand
(without returning case content, so it also works before consent is granted).
An interrupted read is retried once after a successful reconnect; an
interrupted change is reported but never replayed — the application may
have applied it before the connection broke, and a silent duplicate is exactly
what this surface exists to prevent. The session id survives reconnects, so
draft-group ownership persists across an application restart.
Reading
get_case_overview, find_elements, get_element, get_argument_tree,
list_case_files, open_case_file, list_terms.
suggest_placement(topic) answers a question substring search cannot: where
does new argument about this belong? It returns ranked goals and strategies
with the path from the top goal, the sub-claims already there and the context in
scope. The agent still decides; without it, an agent guesses and attaches at the
root.
list_assurance_claim_points returns the case's GSN assurance claim points —
what each one annotates and how it is resolved, with the confidence argument's
claim, package and top-goal ids as links get_element can follow. An ACP marks
where the argument's own assurance is questioned, which is exactly what an
agent should read before proposing changes nearby; get_element also carries
the ACPs on the element and on the relationships touching it. Read-only: the
patch vocabulary has no ACP operation, and adding one is an ADR 0009 decision
with its own GSN review, so ACP authoring stays in the application.
list_terms returns the case's terminology in term-domain names — each term's
value (the word or phrase being defined), name, definition, categories,
external_reference and origin, plus the categories the case defines and
the ids a term may be filed under — in one call. Terms are ordinary elements to the other read tools too:
get_case_overview counts them under term, find_elements matches them by
type or text, and get_element fetches one by id. What list_terms adds is
the definition in the listing, which find_elements summaries omit, so an
agent checking what a case's terms mean does not need one get_element round
trip per term.
Hard limits on find_elements and get_argument_tree are a correctness
requirement, not a nicety — an unbounded call on a real case spends the whole
conversation.
Every case-content response identifies what it returned. Connected reads name
view: working_draft or view: accepted, plus argument_file, the current
working_revision, and the session's context_generation; an active
workspace also supplies workspace_id. Offline reads name view: accepted.
A client never has to infer whether text has been accepted.
Every draft mutation names both values it read: expected_working_revision
(the draft moved) and expected_context_generation (the ground under the
whole session moved — a project switch, a fresh grant, a revocation). Either
being stale refuses the mutation before anything is stored, so a change
computed against one context can never land in another (ADR 0014).
Changing: the working draft
The working draft is a SACM document — a copy of the accepted argument that contributors edit directly (ADR 0016). An agent's operations are applied to that document through the same library seams the application uses on the accepted one, so a change the model cannot hold is refused in the call that made it, naming the operation by its position in the batch. There is no staging model to accept something the accept would later have to refuse.
An agent still contributes one coherent change group, which is now the record of who contributed what rather than a container of pending operations.
- Read the case or call
get_draft_statusand retainworking_revision. begin_change_groupwith a title, rationale and that expected revision.check_operationsrehearses operations on a copy of the draft first — the same validation and findings staging would return, with nothing stored, no ids allocated and nothing drawn on the user's canvas, so an agent iterates privately instead of flickering half-finished work in front of the user. It also works offline, against the accepted case.stage_operationsin small steps. Each call applies its batch to the draft document and returns the ids the document allocated, what the draft now changes about the accepted argument, findings against it, and the next revision.describe_change_groupanddescribe_working_draftinspect the contribution and what the draft as a whole now changes.get_draft_events(after_revision)reports what other contributors changed.submit_change_groupmarks the work ready for the user — and is refused while problem-severity findings stand against the group, each one named in the refusal. Staging deliberately never refuses on findings, because every intermediate shape is legitimately unfinished; submit is the author declaring itself done.acknowledge_findings: truesubmits anyway and records the acknowledged findings on the group, where the reviewer sees them; a later restage clears the record, since it described a superseded shape. Advisory findings never refuse anything.close_change_groupends the group without deleting its provenance record.
A batch is atomic: if any operation is refused, none of them is applied and
the draft is exactly as it was. Ids returned by stage_operations are real and
addressable immediately — the document allocated them when it created the
element, and nothing later reallocates them.
replace_change_group, remove_change_group and unstage_operations are
refused against a document-backed draft, and say so. They withdraw
operations from a log; the draft holds a document. Reporting them as done while
the change was still in the draft would be a success message over an edit that
never went away. To reverse a change, edit the draft back — set the text to what
it was, or remove the element that was added — or ask the user to discard the
whole draft.
Accept is all-or-nothing and belongs to the user alone. It writes the draft over the accepted argument as one atomic replacement, so it cannot half-apply.
Every modifying call carries expected_working_revision. The revision tracks
the draft document as well as the change-group record, so a user editing the
argument moves it — their edits go into the same draft. If a human edit, SCCG
review or another MCP client changed the shared draft, the call is refused with
current_working_revision; the agent rereads and decides whether its intended
operation still means the same thing. This is optimistic concurrency over the
semantic graph rather than silent last-writer-wins.
An MCP session may inspect the whole working draft but may mutate only groups it created. This prevents one client from rewriting human, SCCG, or another client's work while still letting all of them reason about the same argument.
Terminology operations
Glossary work goes through the same change groups as argument edits:
CreateTerm defines a term (text is the term itself, new_value its
definition), UpdateTerm revises one field of an existing term, and
RemoveTerm deletes one. CreateCategory and UpdateCategory manage the
categories terms are classified under (text is the category name, new_value
its description). A staged term is visible to list_terms and the other reads
in the working-draft view, revision-checked like every draft mutation, and
lands in the SACM document only when a human promotes the group.
UpdateTerm takes a field of value, definition, name, category
(one or more category ids, space separated; empty clears them),
external_reference (a citation string — a URL, a standard clause, a document)
or origin (the id of the element the definition comes from). The last three
are what answer the terminology check: it reports a term with no category, and
a term with neither an external reference nor an origin, and before those
fields were addressable an agent could read both findings and fix neither. A
category id that does not resolve, or names something that is not a category,
is refused at staging rather than at acceptance; an origin that looks like a
citation string is refused with a pointer to external_reference.
Each field is written by its own seam, so classifying a term cannot disturb its definition — routing the whole term through the library's replace-everything update would rewrite the definition in one language and drop its translations.
A staged glossary is read in the Terminology Package tab, which shows the
working glossary (ADR 0016): while the draft differs from the accepted
argument, the tab, the canvas's term detection and the terminology checks all
read the draft document's package, so a definition a client wrote or revised
is visible the moment it is staged. Every row the draft added or changed is
badged draft, with the fields that differ, under a notice counting the
unaccepted glossary changes; a glossary the draft itself created is shown even
though the accepted case has none. A term is deliberately not a GSN node, so
unlike an argument change there is no canvas rendering to read it from — the
tab is where it is read. While a draft document exists, the terms and
categories the user adds, changes or deletes in that tab go into the draft,
expressed as the same CreateTerm / UpdateTerm / RemoveTerm /
CreateCategory / UpdateCategory operations a client sends and applied
through the same seams — one operation per field that changed, so a human's
edit is accepted or refused by the model that will hold it, exactly as a
client's is, and the tab says so above its controls. What that vocabulary
cannot express — a terminology package's own name and description, deleting a
package or a category, linking a term to an element as context — writes to the
accepted document, which the draft no longer descends from, and is refused with
a reason until the draft is accepted or discarded, because accepting the draft
would silently undo it. A new term or category made in the tab lands in the
case's first glossary, the rule CreateTerm follows; with another glossary
selected the tab says so rather than filing the term somewhere unchosen.
When the case has no terminologyPackage, accepting the first CreateTerm
creates one under the root assurance case package rather than refusing — a case
with no glossary could otherwise never grow one over MCP. Definitions may carry
translations; a term's value is a single string (SACM 10.11) and cannot,
which staging enforces with an explanation rather than at acceptance.
Terms address SCCG CL.5 directly: a claim relying on a broad evaluative term (safe, timely, all) is answerable by defining the term once instead of repeating a bound in every claim. A term defined at case level is in scope for term detection across the whole case.
RemoveTerm of a term that an argument package references (for example as a
visible term context) is refused at acceptance by the SACM library's
cross-package delete guard; removing such a term remains an in-application
action where the cascade can be shown and confirmed.
Staging changes no accepted assurance data. It writes only recovery state
under .af/drafts, with source label, session identity, operations, stable
generated ids, dependencies and events. It performs no SACM write, no command
and no audit transaction. Restarting Assurance Forge restores the same graph and
revision.
Accepting a draft is exposed only in Assurance Forge. There is deliberately no
MCP apply, accept or promote tool; a registry-wide test enforces this.
Accepting a draft document writes it over the argument in one atomic replace
and records one AcceptWorkingDraft transaction whose event carries the
accepted document and the draft's provenance — groups, sources, guidelines and
rationales — so the audit log can reproduce the accepted argument without the
draft it consumed, the history slider reconstructs the states on either side of
the accept, and the next project open verifies instead of reporting the accept
as a divergence. The accept becomes the trusted replay root and an undo
boundary; the operation-staging fallback still promotes through the audited
ApplyProposalCommand.
For one migration release, begin_change_set, describe_change_set and
submit_change_set are aliases for the group operations and results include
both group_id and change_set_id. The older unstage, discard and list names
also operate on the integrated workspace. They no longer create private
in-memory change sets.
Naming and rereading staged elements
stage_operations answers with created_element_ids, for example
"$topGoal" -> "G3". The identity is allocated once, persisted and reused on
every materialization. Later calls can refer to G3, and ordinary read tools
can search or fetch it because connected reads use the complete working draft.
One workspace per argument
Each argument file has one persisted workspace, keyed by its project-relative
path. Switching arguments switches both the accepted baseline and its draft;
ids such as G1 that legitimately repeat in another argument cannot receive the
wrong file's operations. Offline mode never reads .af/drafts.
Watching the combination
The canvas, navigator, inspectors, search, MCP and SCCG review all consume the same materialized model. Additions, edits and removals are marked in place, with removed elements retained as presentation-only tombstones. When several groups touch one element, the inspector shows the ordered contribution history rather than choosing one proposal to preview.
SCCG
Three mechanisms, weakest first — then the doctrine, which exists because all three land only when the user or client asks. The plan for the rest of this surface is MCP authoring quality.
Resources. sccg://guidelines publishes the catalog, so a client can load
the rules at the start of a session.
Prompts. draft_argument_from_standard, add_argumentation and
restructure_case each carry the guidance for that job, quoted from the catalog
so prompt and guideline cannot drift. This is what makes an agent aware of the
rules before it writes.
Checks on staged work. Returned in the result of every staging call so the agent self-corrects, and shown on the integrated draft for the reviewer.
The authoring doctrine (AF-AI-023). A ~15-line condensation — one rule per
line, each naming the guideline it condenses, every SCCG family represented —
delivered through the channels that reach the model even when nobody asked:
initialize.instructions, an authoring_guidance field on the pre-write reads
(get_case_overview, get_argument_tree, suggest_placement,
get_draft_status — the looped reads stay lean deliberately), and the
operation schema's text description, which is in context at the moment a
claim's words are generated. Every id the doctrine names is resolved against
the catalog by a test, so the condensation cannot outlive the rules it
condenses.
What the checks do and do not cover
Most of SCCG is prose only a reader can judge — whether a claim is sufficiently justified, whether evidence is relevant. Those are not decidable here, and claiming otherwise would make a green result read as conformance.
The mechanical set is the twelve catalog-bound checks AF-AI-024 records:
the structural four (EV.1 a claim with no support and no undeveloped
marker; AR.2 a decomposition whose sub-claims carry no reasoning step;
AR.1 a solution with children, a strategy that develops into nothing,
and support cycles) and the lexical eight (CL.5
unbounded qualifiers, CL.2 bundled properties, CL.6 chained
lifecycle steps, RD.1 reasoning inside claim text, RD.4 promotional
language, EV.7 uncontrolled evidence references, EV.8 mutable
sources cited unfixed, LF.3 absence offered as support). Every lexical
check is proven against the guideline's own bad and good examples, read
from the catalog. Lexical findings are advisory. Problem-severity findings
gate one thing only: an agent's submit_change_group, which refuses until
they are fixed or explicitly acknowledged (AF-AI-025). Nothing here ever
blocks acceptance — the reviewer is the authority on a safety argument.
Consent
Two gates (ADR 0007 and ADR 0014).
Gate 1 — the master flag. mcp.enabled in the settings file, off by
default, toggled in Preferences alongside a copyable client configuration. It
fails closed on every failure path — missing file, malformed document, absent
or non-boolean flag — and is re-read on every call, so revoking it takes
effect immediately rather than when the client happens to restart. Disabling
it also revokes every session grant below.
Gate 2 — the per-session grant. The master flag alone discloses nothing.
Each session's first project operation (or an explicit
request_project_access call) raises an access request inside the running
application — client label, project name, Allow while open / Deny — and
the operation is refused with project_access_pending until the user answers.
Grants are keyed by session id and project, live only in application memory,
survive a bridge reconnect to the same instance, and end on deny, revoke,
project close or switch, MCP disable, or application restart. A re-granted
session still owns the draft groups it authored earlier — they are keyed by
the same session id. The client label is attribution, never authentication;
the token and the pipe's ACL remain the security boundary.
Connected clients are named on their draft groups and shown with their access state. Something reading or contributing to a safety argument should never be invisible to the person responsible for it.
Discovery
The application publishes one instance record per running instance (ADR 0014), keyed by a runtime instance id, under the user's runtime directory. The listener outlives the open project: opening, closing or switching projects updates the record's project fingerprint but never recreates the listener or rotates its token. Liveness is the recorded pid — a record whose process is gone is ignored and pruned by whichever side meets it first.
The record carries a fingerprint of the open project root, never the path:
discovery metadata must not disclose where a user keeps their safety cases.
The project-bound adapter matches that fingerprint to find its application; a
session whose project is not the active one is refused with
project_not_active — a precise, recoverable refusal, not content from
whatever the user switched to. Service resumes on the same connection when the
project is reopened.
Two instances that open the same project trip an advisory warning in the
second instance: two writers on one .af/drafts is a real corruption risk,
but a crashed instance must never lock a user out of their own safety case.
Security of the bridge
Two gates. The operating system's access control on the pipe or socket (an explicit user-only DACL on Windows for both the pipe and the record directory; mode 0600 in a user-private directory on POSIX), and a 256-bit token published in the instance record. A local process that did not read that file is not the adapter this application published for.
The instance record is deliberately not in the project: a pipe name, a pid
and a token describe this machine at this moment, and in the project directory
they would be hash-tracked by af.proj and would reach a colleague through
version control.
Known limits
- Writes need the application running. The deliberate cost of ADR 0008.
- Project creation is a user action. An agent drafts into an argument file inside an already-open project.
- Standards traceability is text, not citation. The element model has no citation field, so "this goal answers ISO 26262-6:9.4.2" lives in the claim's description. A first-class citation is a separate decision.
- No atomic re-parent. Restructuring is expressed as remove-then-add
supported-by pairs, which works but loses the intent in the diff. A
MoveUnderoperation is designed and not yet built. - Concurrent clients share one draft. They see each other's submitted and building groups; optimistic revision checks replace private client sandboxes.
- Draft recovery is local to the project checkout.
.af/.gitignorekeeps it out of version control, so unaccepted work is not shared through tracked project files and requires an explicit future export to move elsewhere. - Applying collapses argument packages. The preview places each staged
addition on the package it attaches to;
ApplyProposalCommandwrites every element into the document's first argument package. Pre-existing, shared with the interactive proposal flow, and only visible in a document that has more than one. - SCCG binding is a named subset plus advisory prose, not "SCCG compliance".
- Terminology stops short of deletion and association. Terms and categories can be created and edited, including a term's categories, external reference and origin. Removing a category, and associating a term with an element as a visible context, stay in the application: a category deletion has cascade semantics that need a confirmation the MCP surface cannot raise. Created terms and categories land in the case's first terminology package (created on demand) — choosing among several packages is not expressible.
Verification
tests/test_bridge_protocol.cpp,tests/test_bridge_transport.cpp,tests/test_instance_registry.cpp— wire contract, instance records and discovery, stale-record pruning, transport, including a real loopback round trip and the shutdown path.tests/test_agent_bridge_controller.cpp— the online path end to end over a real pipe: handshake, token refusal, protocol mismatch, frame-thread execution, the listener surviving a project switch, and theproject_not_activerefusal.tests/test_agent_request_handler.cpp— connected reads use the integrated working model; MCP groups persist, support multi-call editing, and refuse a stale revision after another contributor changes the draft; terms stage through change groups and read back throughlist_termsin the working-draft view.tests/test_change_set_acceptance.cpp— accepting a stagedCreateTermcreates the terminology package when the case has none, matches the acceptance preflight by semantic hash, and term edits and removals land in the library document.tests/test_draft_workspace.cpp— staging changes no accepted SACM; stable ids, restart recovery, combined materialization and human promotion are verified.tests/test_sccg_staged_checks.cpp— one test per mechanical rule.tests/test_mcp_modes.cpp— offline is read-only, and every published tool leaves the project directory byte-identical.tests/test_mcp_server.cpp— every draft tool is consent-gated and the whole registry contains no apply, accept or promote operation.cmake/run_mcp_smoke_test.cmake— the consent gate through the real process.
Both platform branches of the transport are compiled: MSVC and MinGW g++ for
Windows, GCC under WSL for POSIX. Each toolchain proves which branch it took —
one resolves <windows.h>, the other <sys/socket.h>.