Skip to content

PACER-native MCP server (stacked on the spend-governance core) - #9

Merged
johnzfitch merged 4 commits into
claude/pacer-spend-governance-50JNsfrom
claude/pacer-mcp-server
Jun 10, 2026
Merged

PACER-native MCP server (stacked on the spend-governance core)#9
johnzfitch merged 4 commits into
claude/pacer-spend-governance-50JNsfrom
claude/pacer-mcp-server

Conversation

@johnzfitch

Copy link
Copy Markdown
Owner

Follow-up PR A from the plan on #6. Extracts the optional MCP server out of #6 so it reviews on its own. Stacked on #6 (base: claude/pacer-spend-governance-50JNs) — review/merge #6 first; the diff here is just the MCP layer.

What this adds

  • src/pacer_cli/mcp_server.py — a thin adapter over the governed client, not new business logic.
  • Tools: search_cases, search_parties, get_docket, get_document (all billable) + a read-only spend_status.
  • Optional [mcp] extra + pacer-mcp stdio entry point, so the core CLI keeps its dependency set.
  • tests/test_mcp_server.py + tests/test_failclosed_invariant_mcp.py (the network-tripwire invariant for the MCP surface).

Why it's safe

Every billable tool routes through the same security.check_spend() gate and writes the same audit line as the CLI — one cap, one ledger, for both surfaces. A breach maps to a structured MCP error mirroring the CLI's JSON (BUDGET_EXCEEDED / MATTER_REQUIRED / MATTER_INVALID / SCOPE_EMPTY / POLICY_INVALID). Credentials stay human-provisioned (env / config.env); the server never prompts, never auto-unlocks the vault, and never writes policy.csv / courts.csv. The mcp SDK is imported lazily, so the module (and tests) import without the extra installed.

Verification

  • 238 passed, 3 skipped; pyflakes + tools/audit.py clean.
  • The MCP fail-closed invariant suite drives all 4 tools under every refusal condition and asserts the network is never reached, with a positive control proving it's not vacuously green.

Depends on #6 (the shared check_spend() / spend_today() live there).

https://claude.ai/code/session_01NNvhsYRVWhjfcdgaSmU5bt


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional PACER-native MCP (Model Context Protocol) stdio server that exposes a governed, billable tool surface backed by the existing spend-governance layer (from stacked PR #6). This PR aims to provide an agent-friendly interface while preserving the same preventive cap enforcement and audit-ledger accounting as the CLI.

Changes:

  • Introduces pacer_cli.mcp_server implementing MCP tools (search_cases, search_parties, get_docket, get_document) plus a read-only spend_status.
  • Adds MCP-focused tests, including a “fail-closed network tripwire” invariant suite for the MCP surface.
  • Adds an optional [mcp] extra and a pacer-mcp console entry point; documents MCP usage in the README.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/pacer_cli/mcp_server.py Implements the MCP adapter and tool wiring over the governed client.
tests/test_mcp_server.py Unit tests for MCP adapter behavior without requiring the MCP SDK or network.
tests/test_failclosed_invariant_mcp.py Invariant tests ensuring refusals prevent any network client construction.
README.md Documents MCP installation, registration, and tool list.
pyproject.toml Adds the optional mcp extra and the pacer-mcp entry point.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +84 to +94
def spend_status() -> dict[str, Any]:
"""Today's spend vs. the active caps - a read-only budget view (no billing)."""
cfg = _load_config()
spent = spend_today()
return {
"spent_today": spent,
"per_op_cap": cfg.per_op_cap_usd,
"daily_cap": cfg.daily_cap_usd,
"remaining_today": round(max(0.0, cfg.daily_cap_usd - spent), 2),
"require_client_code": cfg.require_client_code,
}
Comment thread src/pacer_cli/mcp_server.py Outdated
Comment on lines +173 to +175
cost = float(result.cost or 0.0)
_audit(cfg, f"docket {court_normalized}/{case_number}", cost)
return {"path": str(result.filepath), "pages": int(result.pages or 0), "cost": cost}
Comment thread src/pacer_cli/mcp_server.py Outdated
Comment on lines +196 to +198
cost = float(result.cost or 0.0)
_audit(cfg, f"document {doc_number}", cost)
return {"path": str(result.filepath), "pages": int(result.pages or 0), "cost": cost}
@johnzfitch

Copy link
Copy Markdown
Owner Author

@copilot full bug test, check for race conditions

Copilot AI commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@copilot full bug test, check for race conditions

Full bug test run: 242 passed, 3 skipped (0 failures).

Two genuine race conditions fixed in commit Fix thread-safety races:

  1. get_audit_logger() — non-thread-safe singleton (security.py). The function had no lock; a parallel call could create two separate AuditLogger instances, causing split audit trails. Fixed by adding _audit_logger_lock = threading.Lock() and wrapping the singleton check, matching the existing _rate_limiter_lock pattern already in the same module.

  2. AuditLogger._ensure_logger() — duplicate-handler race (security.py). The if not self._logger.handlers check and addHandler call are not atomic: two threads could both see an empty handler list and install duplicate handlers, causing every audit line to be written twice. Fixed with per-instance double-checked locking (_init_lock).

A third condition — the check-then-act gap between _guard() (reads audit log) and _audit() (writes to it) — is inherent to the log-based design and the CLI has the same behaviour. It's documented inline in _guard()'s docstring. The MCP stdio server is single-threaded per connection so the gap isn't exploitable in practice, but it's acknowledged.

New TestConcurrency class covers all three: singleton thread-safety (20 threads racing), no-duplicate-handlers (10 threads on a barrier), and gap-is-documented assertion.

claude and others added 4 commits June 7, 2026 00:08
Optional MCP stdio server exposing the governed PACER client: search_cases,
search_parties, get_docket, get_document (all billable, all routed through the
shared check_spend() gate + audit ledger) plus a read-only spend_status. A cap
breach maps to a structured MCP error mirroring the CLI's JSON. Credentials are
human-provisioned; the server never prompts, auto-unlocks the vault, or writes
policy.csv/courts.csv. Ships as an optional [mcp] extra + pacer-mcp entry point,
so the core CLI keeps its dependency set. Includes the MCP fail-closed invariant
suite. 238 passed.
…parity

- spend_status() is read-only, so it must stay available when policy.csv is
  fat-fingered (only billable ops fail closed). Load the base config, apply the
  overlay best-effort, and on PolicyError report conservative built-in caps plus
  a policy_error field instead of raising. (Copilot review, mcp_server.py:94)
- get_docket()/get_document() now record via log_download (with page count),
  matching the CLI's download ledger format and always emitting the cost=$ token
  even at $0.00 — one consistent trail across surfaces. (Copilot, lines 175/198)
- Tests for read-only-survives-bad-policy added.
Copilot's pass fixed the audit-logger thread races but only documented the
check-then-act TOCTOU on the cap: two concurrent processes/agents could each
read the same 'spent today', both pass the cap, and both bill. Fix it at the
source with security.spend_lock() - a reentrant in-process RLock + an flock on
~/.pacer/logs/.spend.lock - held across the whole read-check-bill-record
section so concurrent billable ops serialize and the second sees the first's
spend. Acquire is bounded (fail-closed SpendLockTimeout on timeout); the OS
releases the flock if a process dies mid-op.

Wired in one place each: MCP tools wrap their critical section in spend_lock();
CLI billable commands via an @under_spend_lock decorator (one op per process).
Trade-off: concurrent billable ops serialize - correct for a hard shared cap.
Tests: reentrancy, thread serialization, and a 2-thread race proving exactly
one op bills while the other is refused. 244 passed.
@johnzfitch
johnzfitch force-pushed the claude/pacer-mcp-server branch from 5109645 to 17e474c Compare June 7, 2026 00:08
@johnzfitch
johnzfitch merged commit ca2e76d into claude/pacer-spend-governance-50JNs Jun 10, 2026
@johnzfitch
johnzfitch deleted the claude/pacer-mcp-server branch June 10, 2026 03:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants