feat(mcp): concrete MCP servers become tools the agents can call during a run

Krav 3, and the operator chose the run path explicitly: the external service must
be reachable WHILE the run works, not only when documents are ingested. Until now
the run path had one in-process tool against a local folder — and on the bundle
path the agents had no tools at all.

MAF already ships the client (MCPStdioTool / MCPStreamableHTTPTool, verified in
the pinned 1.9.0 with allowed_tools and request_timeout), so `mcp_tools.py` owns
only what MAF cannot decide for us: which servers a run may contact, which of
their tools it may call, how long it waits, and where the credential comes from.
This is a DIFFERENT seam from ingest_mcp.py on purpose — that one pulls source
documents before a run and speaks to null-argument tools. Same protocol, different
job.

Every refusal is a live hazard, not tidiness. An empty allowlist would let the far
end decide what the agents may call, so naming the tools is mandatory. A
non-positive timeout is an unbounded wait against a third party. An unknown field
is refused rather than ignored, which is also what keeps a literal secret from
being parked in the config — there is no field for one, only the NAME of an env
var. A named-but-unset credential refuses instead of calling anonymously, because
an anonymous call can succeed with the wrong scope.

Egress is declared, always. Every server and permitted tool is named in the run
announcement before the first call — including when no --mandate is given, which
was a real hole: the announcement only printed with a commission, so configuring
servers without one would have contacted third parties with nothing printed at
all. --live-dry-run still opens nothing, because the tools are entered after the
dry-run cut: the promise to stop before the first call now covers egress too.

Threaded through BOTH modes. A flag accepted in one mode and silently dropped in
the other is the defect class this CLI refuses by name.

Load-bearing MEASURED against the whole 744-test suite, four mutations all red:
build the tools but never hand them to the agents (2) · never enter the
AsyncExitStack, so they are constructed and useless (1) · never declare the egress
(2) · drop the allowlist on the built client (1).

Two live docs claimed MCP was unwired in the run path; both corrected rather than
left to rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULCqjLF61rehj5cZmdUoR3
This commit is contained in:
Kjell Tore Guttormsen 2026-08-05 16:53:07 +02:00
commit 9668e17f2f
8 changed files with 706 additions and 4 deletions

View file

@ -184,3 +184,90 @@ def test_no_mandate_prints_neither_block(bundle, replies_file, capsys) -> None:
assert rc == 0, out
assert "Run mandate for" not in out
assert "Mandate outcome" not in out
# --- the egress declaration (Trekk B3): what a run will contact, before it contacts it ----------
_MCP = {
"servers": [
{
"name": "prisregister",
"transport": "http",
"url": "https://intern.example/mcp",
"allowed_tools": ["lookup_unit_price"],
"timeout_seconds": 15,
}
]
}
@pytest.fixture()
def mcp_file(tmp_path: Path) -> Path:
path = tmp_path / "mcp.json"
path.write_text(json.dumps(_MCP), encoding="utf-8")
return path
def test_configured_servers_are_named_before_the_run(
bundle, mandate_file, mcp_file, capsys
) -> None:
"""Every server and every permitted tool is named in the announcement — a run never reaches a
service it did not declare. ``--live-dry-run`` keeps the assertion offline."""
rc = run.main(
[
"BYGG-KONTOR-NORD",
"--docs-dir",
str(bundle),
"--bundle-dir",
str(bundle),
"--mandate",
str(mandate_file),
"--mcp-config",
str(mcp_file),
"--live-dry-run",
]
)
out = capsys.readouterr().out
assert rc == 0, out
assert "prisregister (lookup_unit_price)" in out
assert "no external services" not in out
def test_egress_is_declared_even_without_a_mandate(bundle, mcp_file, capsys) -> None:
"""The declaration cannot depend on a mandate being present. Without this, configuring servers
and omitting ``--mandate`` would contact third parties with nothing printed at all silent
egress, which is the one thing this repo's data rules forbid outright."""
rc = run.main(
[
"BYGG-KONTOR-NORD",
"--docs-dir",
str(bundle),
"--bundle-dir",
str(bundle),
"--mcp-config",
str(mcp_file),
"--live-dry-run",
]
)
out = capsys.readouterr().out
assert rc == 0, out
assert "prisregister (lookup_unit_price)" in out
def test_malformed_mcp_config_refuses_the_run(bundle, replies_file, tmp_path, capsys) -> None:
"""An egress config that cannot be read refuses — degrading it to 'no external services' would
make the announcement describe a run nobody configured."""
bad = tmp_path / "mcp.json"
bad.write_text('{"servers": [{"name": "x", "transport": "http"}]}', encoding="utf-8")
rc = run.main(_argv(bundle, replies_file, "--mcp-config", str(bad)))
assert rc == 1
assert "refused" in capsys.readouterr().err.lower()
def test_no_mcp_config_contacts_nothing(bundle, replies_file, mandate_file, capsys) -> None:
"""CONTROL: without ``--mcp-config`` the announcement says so explicitly. An omitted line would
read the same as an unchecked one."""
rc = run.main(_argv(bundle, replies_file, "--mandate", str(mandate_file)))
out = capsys.readouterr().out
assert rc == 0, out
assert "no external services" in out