Skip to content

Wire the MCP server into a coding agent

You will register GuardLink’s MCP server with a coding agent, verify over the wire that it exposes 24 tools and 3 resources, and confirm the answers it returns are about your repository rather than whatever directory it started in.

An agent editing an annotated file otherwise infers security context from the source. The model already records what a human decided: which risks were accepted, which controls are load-bearing, and which asset a function belongs to. None of that is derivable from the code.

  • GuardLink installed. See Install GuardLink.
  • A project with guardlink init run in it, and some annotations. The annotate guide builds one.
  • An MCP client. Claude Code discovers the config guardlink writes; others need a line of JSON.

guardlink init already wrote .mcp.json at the project root:

.mcp.json
{
"mcpServers": {
"guardlink": {
"command": "guardlink",
"args": ["mcp"]
}
}
}

Claude Code picks that up and asks before using it:

Terminal window
claude mcp list
guardlink: guardlink mcp - ⏸ Pending approval (run `claude` to approve)

guardlink-mcp and guardlink mcp are the same server, with the same startStdioServer(), the same tools. Use whichever your client’s config style prefers. For a client that does not auto-discover .mcp.json, the whole configuration is:

{"mcpServers": {"guardlink": {"command": "guardlink-mcp"}}}

Before involving an agent, check the server answers on its own. It speaks JSON-RPC on stdin and stdout, so you can drive it from a shell:

Terminal window
{
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
sleep 2
} | npx guardlink-mcp

The initialize response identifies the server:

{"serverInfo":{"name":"guardlink","version":"2.0.0"}}

and tools/list returns 24:

guardlink_parse guardlink_status guardlink_validate
guardlink_suggest guardlink_lookup guardlink_context
guardlink_graph guardlink_annotate_apply guardlink_reanchor
guardlink_threat_report guardlink_annotate guardlink_report
guardlink_dashboard guardlink_sarif guardlink_diff
guardlink_threat_reports guardlink_sync guardlink_clear
guardlink_unannotated guardlink_review_list guardlink_review_accept
guardlink_entitlement_propose guardlink_entitlement_list guardlink_workspace_info

plus three resources: guardlink://model, guardlink://definitions, and guardlink://unmitigated. Every one of them, with its input schema, is in the MCP tool reference.

guardlink-mcp --help and --version print and exit before any transport is connected, which is why they are safe on a server whose stdout is the protocol channel. With no flag it serves and blocks on stdin, so an apparently hung process is the server working.

This is the failure that wastes the most time, and it is invisible unless you look for it.

The server resolves root per tool call. Omit it, and the root is the process’s working directory, whatever your MCP client happened to launch the server in. Every tool result carries a second content block naming what it used:

{
"guardlink": {
"annotation_hash": "sha256-v2:102dc83796b48add30d28070c4aa654e36ea798f55df955fb339dd7d7a3473f5",
"git_sha": "14c2bbb9a99244888e861b82ea05679d4bbc662a",
"generated_at": "2026-08-13T08:12:00.768Z",
"mode": "external",
"root": "/path/to/legacy-api",
"guardlink_version": "2.0.0"
}
}

Started one directory too high, the same call still succeeds. It parses everything below that directory and reports a model assembled from unrelated projects:

{
"guardlink": {
"annotation_hash": "sha256-v2:c5152b0bfb6a6861dae830dc4ba161cc9ed57b292ffc8d1746372346644ab7a7",
"git_sha": null,
"mode": "mixed",
"root": "/path/to/the/parent/directory",
"guardlink_version": "2.0.0"
}
}

assets: 4 became assets: 14. Nothing errored. The three tells are root, git_sha: null where you expected a commit, and mode: "mixed" where the project has one annotation mode.

Ask for guardlink_status first in a new session and read the root line before trusting anything else.

Twenty-four is more than anyone memorises. These six carry most sessions.

Tool Ask it when
guardlink_context(file) You opened or are about to edit a file. Everything known about that one file.
guardlink_status() Cold start on an unfamiliar repository. Counts, plus the unmitigated list.
guardlink_graph(from, depth, direction) You are about to change a shared component and need blast radius.
guardlink_lookup(query) You have one specific question.
guardlink_validate() Before finishing a change.
guardlink_diff(ref) After a change: did this make the model worse?
Section titled “guardlink_context distinguishes kinds of empty”
{
"file": "test/users.test.js",
"status": "not_scanned",
"hint": "`test/users.test.js` exists but is not in the parser's scan set — it is under an excluded directory (node_modules, dist, build, test, tests, __tests__, vendor, target, .bravos, .bugb). Its annotations, if any, were never read."
}

annotated, scanned_without_annotations, not_scanned, not_found. The second means genuinely clean; the third means nobody looked. An agent that treats them alike will report a test directory as having no security concerns.

{
"traversal": {
"start": { "ref": "#api", "canonical": "api", "resolved": true, "matched_via": "exact" },
"nodes": [
{ "key": "api", "depth": 0, "declared": true },
{ "key": "tokens", "depth": 1, "declared": true },
{ "key": "users", "depth": 1, "declared": true }
],
"edges": [
{ "kind": "flow", "from": "api", "to": "users", "directed": true, "label": "GET./api/users", "at": "src/routes.js:4" },
{ "kind": "flow", "from": "api", "to": "tokens", "directed": true, "label": "POST./api/refresh", "at": "src/routes.js:8" }
],
"depth_reached": 1,
"depth_requested": 2,
"completeness": "complete"
}
}

depth_reached below depth_requested with completeness: "complete" means the graph ran out, not that the traversal was cut short. The response also carries the narrowed model beside the traversal.

It understands a fixed set of named forms and returns the whole list rather than inventing an answer for anything else:

{
"query": "what is broken",
"type": "no_match",
"count": 0,
"results": [
{
"hint": "Unrecognised query form: `what is broken`. This is not a supported form, so no result was guessed.",
"supported_forms": [
"unmitigated",
"confirmed",
"features",
"asset <id>",
"threats for <asset>",
"flows into <asset>",
"handles <classification>",
"cwe:CWE-89 | owasp:A03 | CWE-89"
]
}
]
}

Truncated. The real list runs to 26 forms. Send a deliberately bad query to see all of them.

Anything that resolves a reference reports matched_via: exact, alias, or substring. A substring match is a suggestion, not an identification, and ambiguous with a candidates list means several records tied.

guardlink_annotate_apply writes a validated @source block into the sidecar rather than having the agent edit .gal text. Pass the source file, the line the block anchors to, and the raw annotation lines:

{
"file": "src/db.js",
"line": 1,
"symbol": "query",
"dry_run": true,
"annotations": ["@audit #dbstore -- \"All SQL passes through here\""]
}
{
"ok": true,
"galPath": ".guardlink/annotations/src/db.js.gal",
"status": "written",
"diff": "--- .guardlink/annotations/src/db.js.gal\n+++ .guardlink/annotations/src/db.js.gal\n+@source file:src/db.js line:1 symbol:query\n+@audit #dbstore -- \"All SQL passes through here\"",
"errors": [],
"linesWritten": 2
}

Four things about that call.

  • Do not pass @source yourself. The header is synthesised from file, line and symbol. Passing one is an error.
  • Pass symbol. It is what lets guardlink reanchor find the block after a refactor moves the code. Omitting it also means “this is about the asset, not one function”.
  • dry_run: true returns the diff without writing. status: "written" in a dry run describes what would happen.
  • Every line is re-parsed before anything reaches disk, so a syntax error is rejected with its reason. An undefined #id is not, so run guardlink_validate afterwards.

guardlink_annotate_apply refuses @accepts and @entitles:

{
"ok": false,
"galPath": ".guardlink/annotations/src/db.js.gal",
"status": "rejected",
"diff": "",
"errors": [
"Line 1: refusing to write `@accepts`. Accepting a risk is a human governance decision. Record the risk with @exposes and flag it with @audit instead."
],
"linesWritten": 0
}

Both are human governance decisions: accepting a risk, and confirming an actor is legitimately entitled to a capability. There is deliberately no guardlink_entitlement_accept. An agent can file a proposal with guardlink_entitlement_propose, and only guardlink entitle at a terminal records the acceptance, under the name of the human who made it.

The MCP server is one of two channels. The other is the instruction files init wrote, namely CLAUDE.md, AGENTS.md, .cursor/rules/guardlink.mdc, .windsurfrules, .clinerules, .github/copilot-instructions.md, and .gemini/GEMINI.md, which carry a live block of model context an agent reads before it calls anything.

The two reach an agent differently, and that difference is the reason sync exists at all.

flowchart TB
    subgraph LIVE["over MCP, answered per call"]
        direction LR
        L1["the agent asks"] --> L2(["the server reads<br/>the model now"]) --> L3["always current"]
    end

    subgraph SNAP["in the instruction files, a copy"]
        direction LR
        S1["guardlink sync writes"] --> S2(["CLAUDE.md and<br/>seven more carry a block"]) --> S3["current only until<br/>the model changes"]
    end

    LIVE ~~~ SNAP

    class L3 emphasis

An agent reads the instruction files before it calls anything, so that copy is what shapes its first move in a session. The MCP tools then answer from the model as it is. A stale block is not corrected by the tools being right, because the agent has already read it.

That block is a snapshot, and it goes stale:

Terminal window
npx guardlink sync .
✓ Updated 8 agent instruction file(s):
.guardlink/README.md
CLAUDE.md
.cursor/rules/guardlink.mdc
AGENTS.md
.github/copilot-instructions.md
.windsurfrules
.clinerules
.gemini/GEMINI.md
✓ All agent instruction files now include live threat model context.
3 assets, 2 threats, 2 controls, 2 exposures.
Any coding agent (Cursor, Claude, Copilot, Windsurf, etc.) will see these IDs.

.guardlink/README.md gets the same treatment, and records the hash so a reader can tell whether it is current:

Current model: 14 annotations · 3 assets · 2 threats · 2 controls · 2 exposures · 2 flows
Content hash: `sha256-v2:41f8aa906bbb22512a8cfabd2f45b4373593b3ecfabfa8a4dc92748f32c8f585` — identical hash means identical model.

Run sync whenever the definitions change. validate --sync and status --sync do it as a side effect; without the flag, neither writes anything.

  • Add guardlink validate . to the agent’s finishing checklist. The instruction files already tell it to; a project-level hook makes it certain.
  • Commit .mcp.json. It is configuration every contributor needs, and it is three lines.
  • Give the agent guardlink_graph before a refactor, not after. It is the one tool whose answer is hard to reconstruct from reading files.