--- vcs: system: github remote: https://github.com/christianclaudio/mcp-server-taxcode owner: christianclaudio repo: mcp-server-taxcode default_branch: main branch_policy: pr_only merge_method: squash delete_branch_on_merge: true --- # AGENTS.md Instructions for AI coding agents working on this repository or developing MCP tools for tax codes and statutory regulations. --- ## 🎯 Project Overview This is `mcp-server-taxcode` β€” an enterprise Python Model Context Protocol (MCP) server for tax codes, statutory definitions, regulations, and tax reference guidance. **Primary Purpose**: Expose tax code navigation, statutory lookups, rate schedules, and cross-reference definitions to AI agents with strict enterprise safety gates, offline test isolation, and FastMCP 4 server composition. --- ## πŸ“š Canonical Documentation & Live Doc MCPs Before designing, implementing, or updating any MCP tool, always consult the official machine-readable documentation indexes ("the bibles") and live documentation MCP servers: | Resource | `llms.txt` index | Live doc MCP (SSE / Streamable HTTP) | | :--- | :--- | :--- | | **FastMCP 4 Framework** | [`https://gofastmcp.com/llms.txt`](https://gofastmcp.com/llms.txt) | `https://gofastmcp.com/mcp` (`search_fast_mcp`, `query_docs_filesystem_fast_mcp`) | | **Model Context Protocol (Official)** | [`https://modelcontextprotocol.io/llms.txt`](https://modelcontextprotocol.io/llms.txt) | `https://modelcontextprotocol.io/mcp` (`search_model_context_protocol`, `query_docs_filesystem_model_context_protocol`) | Host configuration JSON for these doc servers (`mcp_config.json` and other desktop or agent-host configs) lives in `README.md` (Canonical Documentation & Live Doc MCPs). --- ## πŸ—ΊοΈ Key Paths - `src/_mcp/server.py` β€” root gateway, `create_server` factory, parent middleware, `mount()`. Streamable HTTP and legacy SSE both pass `host_origin_protection=True` with `allowed_hosts` / optional `allowed_origins` (`COOKBOOK.md` Recipe 8). - `src/_mcp/auth.py` β€” `SharedTokenVerifier` (FastMCP `TokenVerifier`) for `_MCP_AUTH_TOKEN` and the HTTP bind policy; the verifier is attached at server build, so `fastmcp run` and `http_app()` enforce the token too (`COOKBOOK.md` Recipe 8). - `src/_mcp/tools/.py` β€” domain sub-servers (template ships `system.py`, `items.py`); re-exported from `tools/__init__.py`. - `src/_mcp/client.py` β€” async HTTP client; `ssrf.py` β€” SSRF / DNS-rebinding guard; `errors.py` β€” exceptions and secret redaction; `middleware.py` β€” parent and child middleware; `config.py` β€” settings. - `src/taxcode_mcp/data/` β€” the single source of truth for statute and publication rows: `jurisdictions.json` (catalog order), `statutes/.json`, `publications/.json`, `manifest/.json` (one source record per row), and `rates/.json` (the compliance tools' rate tables, one per state in `RATE_TABLE_MODELS`). `src/taxcode_mcp/catalog.py` loads and validates them; `db.py` seeds DuckDB from the rows, and `tools/compliance.py` reads every rate from the rate tables. Row layout, manifest fields and how to add a row: `CONTRIBUTING.md` (Adding or changing a row). - `scripts/build_catalog.py` β€” rewrites the data files in canonical form and generates `docs//*.md`, `docs//llms-full.txt` (one jurisdiction's full text), `docs/llms.txt` and `docs/llms-full.txt` (the index of the per-jurisdiction files) from them; `--check` (run in CI and by `tests/test_catalog.py`) fails on a schema or canonical-form error (rate tables included, and a missing or stray rate table), an incomplete or mismatched manifest entry, or docs that differ from the generated output. Never edit generated docs by hand. - `src/taxcode_mcp/paging.py` β€” hard response cap (32,000 characters of JSON) and paging for the statutes tools: `statutes_lookup_statute` pages long text (`page_cursor` / `next_cursor`), search and publications clamp `limit` to 1..25 items per call (`offset` / `next_offset`), and the `taxcode://statutes/{jurisdiction}/{section*}{?page_cursor}` resource returns the same 8,000-character pages, ending a non-final page with the full URI of the next one (`...?page_cursor=`). The template has no `statutes/` segment of its own: the `statutes` mount namespace supplies it. - `scripts/check_tool_contract.py` β€” source of truth for the expected tool set and annotations. Do not hard-code tool counts elsewhere. - `scripts/check_openapi_drift.py`, `scripts/check_conformance.sh` + `conformance-baseline.yml` β€” drift and protocol conformance. - `tests/` β€” offline unit, layered-composition, and protocol tests; `test_e2e_live.py` is opt-in (`-m e2e`). - `.github/workflows/` β€” `ci.yml`, `dependabot-automerge.yml`, and `release.yml` (on a `v*` tag: build with full history, verify the wheel version matches the tag, publish to PyPI via OIDC, create the GitHub Release from `scripts/release_notes.py`, build and push the container image to GHCR, attest build provenance for both wheel and container digests, and publish `server.json` to the MCP Registry). `ci.yml`'s `docker-build` job builds the image on every PR and checks its `io.modelcontextprotocol.server.name` label matches `server.json`. - `scripts/release_notes.py` β€” release body from squash commits since the previous `v*` tag. `scripts/check_version.py` β€” runs after `uv build` and reads the version from the single wheel in `dist/` (the file that ships, as release.yml's tag check does); fails on `0.0.0` (no git metadata) or `0.0.1.devN` (no reachable tag, a shallow checkout). - `server.json` (MCP Registry metadata), `Dockerfile`, `pyproject.toml`, `fastmcp.json`. - `COOKBOOK.md` β€” private maintainer runbook (release SOP, drift, testing, composition, payload hardening, naming recipes). Template-only. --- ## 🧩 FastMCP 4 Server Composition & Hierarchical Layering House tool naming is **hybrid A+B** (`COOKBOOK.md` Recipe 11). Structure a multi-domain catalog into domain sub-servers and mount them on a root gateway. A small single-vendor catalog stays on one server with bare tool names. ### Tool naming (hybrid A+B) * **A β€” bare names** for a small single-vendor catalog: `get_forecast`, `get_markets`, `place_order`. Omit `namespace`. * **B β€” domain layering** for a multi-domain or large in-server catalog: * `mount(..., namespace="weather")` so composed modules stay unique (`weather_get_forecast`, `calendar_list_events`, `docs_search`), or * flat domain prefixes on one server (`admin_*`, `example_domain_*`). * The prefix is the domain. A product-wide `_*` or `vendor_*` stamp on every tool is not the house default. * MCP tool-name uniqueness is per server ([Tool names](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool-names)). FastMCP `mount(..., namespace=...)` separates domain modules and avoids composition collisions ([Namespacing](https://gofastmcp.com/servers/composition#namespacing)). It does not brand the product. A host that aggregates many servers disambiguates on the client side. ### Composition rules Full code samples (sub-servers, root gateway, middleware classes, `ToolTransform`, profiles, in-memory testing, lifespan, `fastmcp.json`) live in **`COOKBOOK.md`** Recipe 7. * **Domain sub-servers:** each `tools/.py` owns one `FastMCP("-")` instance. Pattern A may keep a single module. * **Root gateway:** pattern B mounts each domain with its namespace, which prefixes tools (`system_*`, `items_*`), prompts, and resources (`data://system/*`): ```python root.mount(system_server, namespace="system") root.mount(items_server, namespace="items") ``` Pattern A mounts without `namespace`, or skips `mount` and registers bare names on the root server. * **Hierarchical middleware:** parent middleware runs on every request (`ParentAuditMiddleware` for timing, audit logging, secret redaction; `ReadOnlyGateMiddleware`). Child middleware runs on its sub-server (`ItemsDomainGuardMiddleware` for domain checks and bulk destructive gates). * **`ToolTransform`:** hide infrastructure arguments (e.g. `client_id`) from the LLM-visible schema and inject defaults at runtime. * **Profiles first, then discovery:** Agents pick `--profile` (or `_MCP_PROFILE`) before enabling discovery. Profiles live in `src/_mcp/profiles.py` and come in two kinds that coexist: **domain-mount profiles** (`full|system|items|readonly` β€” which domains mount) and **tool-name allowlist profiles** (`intake` β€” explicit client-visible names over the full catalog, for jobs that span domains). Shape allowlist profiles around a job a user does, one line per profile on that job; list tools in no job profile in `FULL_ONLY_TOOLS` (reachable in `full`). An unknown profile or an allowlisted name missing from the catalog raises `ValueError` at build time. Allowlists filter tools only (prompts and resources stay). Curated profiles keep a flat `tools/list` β€” never attach Tool Search or Code Mode there (log a warning and skip if requested). * **Read-only = `readOnlyHint`:** The MCP `readOnlyHint` annotation is the single source of truth for read-only classification. `--profile readonly` / `_MCP_READONLY=1` keep only tools annotated `readOnlyHint=True` (`ReadOnlyToolFilter`), and `ReadOnlyGateMiddleware` refuses any other real tool, including writes hidden by that filter and calls through the Tool Search `call_tool` proxy (unwrapped only when `call_tool` is a real tool, i.e. Tool Search is attached; otherwise `call_tool` itself is an unknown name). A missing annotation or `readOnlyHint` other than `True` is a write (fail closed). A name that is not a tool in the active profile keeps FastMCP's normal `Unknown tool` error, never the read-only refusal. No name or tag lists. * **Tool Search / Code Mode on `full` only:** Opt-in via `--enable-tool-search` / `_MCP_ENABLE_TOOL_SEARCH` (default backend regex; optional `bm25`) or experimental `--enable-code-mode` / `_MCP_ENABLE_CODE_MODE`. The two modes are mutually exclusive. Code Mode stays optional and needs the public `code-mode` extra (`code-mode = ["fastmcp[code-mode]>="]`, the `pydantic-monty` sandbox), so users install `mcp-server-[code-mode]`, not `dev`: check `pydantic_monty` directly, and when it is missing skip attach with a warning naming the extra and serve the flat catalog. `dev` repeats the same pin so tests run `execute`; tests that need the sandbox use `pytest.importorskip("pydantic_monty")`, CI installs `--extra code-mode`, and `tests/conftest.py` aborts a CI run without the sandbox so those tests never skip silently. Any doc or post quoting a Code Mode token figure must say it needs the `code-mode` extra. Default remains a flat unaugmented `tools/list` for standard MCP hosts. See `COOKBOOK.md` Recipe 7 Β§4. * **Hybrid A+B naming:** Domain `namespace` (`system_*`, `items_*`) is not a product-wide `_*` stamp. Pattern A (bare names) stays valid for small single-vendor catalogs (Recipe 11). --- ## ⚑ The Canonical Workflow: Building Tools from API / llm.txt When translating an API documentation page, `llm.txt`, or OpenAPI specification into MCP tools, repeat these 4 steps in exact order: ### 1. Client Method (`client.py`) - Implement a dedicated `async def` method on the client class. - Type all arguments strictly. Never use bare `dict` or `Any` when a concrete schema or literal is known. - URL path parameters **must** be quoted using `quote(str(param), safe="")` to eliminate path traversal vulnerabilities. - Call `await self._request("METHOD", path, params=..., json=...)`. ### 2. Tool Handler (Domain Sub-server: `tools/.py`) - Register the tool with `@_server.tool()` on its appropriate domain sub-server (e.g. `src/_mcp/tools/.py`). - Name the tool with hybrid A+B (Recipe 11): a bare name on a small single-vendor server, or a domain prefix (`weather_*`, `admin_*`) on a multi-domain server. Do not prefix every tool with the product or package name. - Provide an explicit, agent-friendly docstring describing: - What the tool does. - Required and optional arguments. - Return structure. - Destructive operations (`POST`, `PUT`, `PATCH`, `DELETE` that modify state) **must** include `confirm: bool = False`. - Report a failed call by raising, never by returning an error payload. Wrap upstream and execution failures with `tool_error(exc)` (a FastMCP `ToolError` carrying the redacted message; JSON inside the message that parses is redacted value by value and re-serialized, JSON that does not parse is regex-redacted whole and may lose its shape) and gate refusals as `SafetyViolationError`, so the client receives `isError: true` (`COOKBOOK.md` Recipe 7 Β§1). Assign `failure = tool_error(exc)` inside the `except` block and `raise failure from None` after the block ends, never inside it: `from None` inside the block still leaves the unredacted original on `__context__`. A returned `{"status": "error", ...}` dict reaches the client as a success (`isError: false`). Batch tools process every item; partial success returns a normal result listing each item's status, and when every item fails they raise the redacted `batch_failed` error. - Re-export the tool function in `src/_mcp/tools/__init__.py` and `server.py` to maintain 100% backward-compatible programmatic imports and direct test invocations. ### 3. Tool Annotations & Gating - Apply MCP `ToolAnnotations` either during registration or via the post-registration loop: - `readOnlyHint`: `True` for inspection/GET; `False` for mutations. - `destructiveHint`: `True` for delete/drop/archive/update actions; `False` otherwise. - `idempotentHint`: `True` for GET, PUT, idempotent operations; `False` for non-idempotent creations. - `openWorldHint`: `True` when interacting with external networks/APIs. - Gating: - Support `READONLY` mode (environment variable `_MCP_READONLY=1` or `--profile readonly`). It omits and blocks every tool not annotated `readOnlyHint=True`, so a correct `readOnlyHint` is required on every tool (Recipe 7 Β§4). - Support bulk protection (`_MCP_ALLOW_BULK_DESTRUCTIVE=1`) for mass-delete actions. ### 4. Pure Offline Testing & Contract Sync - Add unit tests in `tests/` mocking responses via `httpx.MockTransport` or `AsyncMock`. - **Zero live network calls during tests.** Tests must run 100% offline in CI. - Update expected tool count in `scripts/check_tool_contract.py`. - Place the new tool in a job (allowlist) profile in `profiles.py` or in `FULL_ONLY_TOOLS`. - Ensure test statement coverage remains at **100.0%**. --- ## πŸ›‘οΈ Non-Negotiable Safety & Security Rules 1. **Destructive Confirmation Gate**: - Every mutating tool must accept `confirm: bool = False`. If `False`, return a dry-run / confirmation preview without executing the side-effect. 2. **Secret Redaction**: - Error messages, logs, and tracebacks must pass through regex redaction (`redact_secrets` in `errors.py`) stripping Bearer tokens, passwords, API keys, client secrets, private keys, PEM blocks, and access/refresh/session tokens. Every secret becomes the fixed mask `[REDACTED]`, which never reveals its length; short values are redacted in keyed forms (`key=x`, `"key": "x"`, `Authorization: Bearer x`). A secret is redacted whole: a quoted value up to its closing unescaped quote (also backslash-escaped inside an already-serialized JSON string), an unquoted `key=value` of a credential key (`password`, `api_key`, `client_secret`, `private_key`) to the end of the line, spaces included, a `{...}` / `[...]` / `(...)` value after such a key or a token key, quoted or not (a Python `repr` such as `{'api_key': ['x']}` or `{'api_key': ('a', 'x')}`), to its balanced bracket, with brackets inside double- or single-quoted strings skipped (or the end of the line when it never balances), a bare number, `True` or word under a quoted key (`{'password': 12345}`; `None`/`null` stays), and a PEM block from `-----BEGIN