Skip to content

Commit 95b0aa2

Browse files
john-walkoeclaude
andcommitted
Release 2026-09-03: FastMCP 4, MCP 2026-07-28 protocol, verified examples, OCR docs
USPTO Final Petition Decisions MCP server: petition search by application, art unit and criteria, petition details, document content and downloads. Example application 17896175 (no petitions) replaced with 17414168; the accepted identifier forms now match the validator (digits and slash only). Updated to FastMCP 4.0.1 on the MCP Python SDK 2.x, speaking the 2026-07-28 MCP protocol revision (MCP Apps now import from fastmcp.apps; a compat shim restores defer_loading on the wire). Security and robustness pass from the 2026-09-03 review: INTERNAL_AUTH_SECRET rotation overlap window with per-purpose derived keys, bounded request bodies, archives and PDF buffers, USPTO-authored text escaped before it reaches the view HTML, response-size guard with paging markers on every tool, shared cross-process USPTO rate limiter, health checks that can actually fail. Documentation refresh: every example application, patent and proceeding number verified live against the USPTO API and rewritten in the form the identifier resolver expects; stale tool counts, env-var tables and response shapes fixed. Removed the cost and spend wording around OCR; the extraction tiers are now described by capability (native text layer first, OCR for scanned pages) and a self-hosted Docling backend can be configured via DOCLING_SERVE_URL. Secrets and prompt-injection baselines regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CeSWJpSaEHe9Cx66xzahVV
1 parent 3190a06 commit 95b0aa2

118 files changed

Lines changed: 10732 additions & 2353 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,41 @@
11

2+
# =============================================================================
3+
# Core configuration
4+
# =============================================================================
5+
# USPTO_API_KEY is the only REQUIRED value. Get one (free) from
6+
# https://data.uspto.gov/myodp/ . On Windows the setup script stores it with
7+
# DPAPI encryption instead of a plaintext .env; see API_KEY_GUIDE.md.
8+
# USPTO_API_KEY=
9+
10+
# Document extraction reads the PDF's native text layer with pypdf first.
11+
# Scanned pages need an OCR backend; either of the two below is sufficient,
12+
# and neither is required for text-layer PDFs.
13+
# - Mistral OCR (hosted, metered):
14+
# MISTRAL_API_KEY=
15+
# MISTRAL_OCR_MODEL=mistral-ocr-latest # pin a dated slug for determinism
16+
# - Docling (self-hosted, e.g. docling-serve): base URL of the instance
17+
# DOCLING_SERVE_URL=https://your-docling-host.example.com
18+
19+
# Transport. "stdio" (Claude Desktop) or "http" (Docker / claude.ai).
20+
# HTTP mode additionally requires INTERNAL_AUTH_SECRET.
21+
# FASTMCP_TRANSPORT=stdio
22+
# FASTMCP_HOST=127.0.0.1
23+
# FASTMCP_PORT=8000
24+
# INTERNAL_AUTH_SECRET= # openssl rand -hex 32; must match PFW's
25+
26+
# Download proxy. See README "Environment variables" for the full list.
27+
# FPD_PROXY_PORT=8081
28+
# CENTRALIZED_PROXY_URL= # full base URL of the PFW centralized proxy
29+
230
# =============================================================================
331
# OAuth sign-in (dual IdP: Google + Entra ID) — HTTP mode only
432
# =============================================================================
533
# "none" (default) keeps today's behavior. "oauth" turns the HTTP surface into
634
# an OAuth 2.1 authorization server: users sign in with Google/Microsoft and
735
# must have an active row in the mcp_users table (see
8-
# scripts/manage_mcp_users.py and the FPD_manage_users tool). In the suite
9-
# deployment FPD points FPD_AUTH_DB_PATH at the shared paid-tier file
10-
# hosted by PFW (shared bind mount, same host only).
36+
# scripts/manage_mcp_users.py and the FPD_manage_users tool). In a suite
37+
# deployment FPD points FPD_AUTH_DB_PATH at the shared user database
38+
# hosted alongside PFW (shared bind mount, same host only).
1139
# FPD_AUTH_MODE=oauth
1240
# FPD_AUTH_BASE_URL=https://your-public-hostname.example.com
1341
# FPD_AUTH_JWT_SECRET= # openssl rand -hex 32 — rotating logs everyone out
@@ -26,6 +54,15 @@
2654
# # this only to whichever internal caller
2755
# # actually needs to manage users; most
2856
# # deployments should leave it unset.
57+
# FPD_AUTH_ALLOWED_REDIRECT_HOSTS= # comma-separated EXTRA hosts allowed to
58+
# # dynamically register a redirect_uri. claude.ai,
59+
# # claude.com, anthropic.com, chatgpt.com, openai.com,
60+
# # localhost and 127.0.0.1 are always allowed
61+
# # (subdomains included). Clients already registered
62+
# # are unaffected.
63+
# FPD_AUTH_OPEN_REGISTRATION=false # true restores fully open Dynamic Client
64+
# # Registration. Leave false: an arbitrary redirect_uri
65+
# # is the first half of an identity-takeover chain.
2966
# FPD_AUTH_REGISTER_URL= # optional "Request access" link
3067
# FPD_AUTH_ACCESS_TTL=3600
3168
# FPD_AUTH_REFRESH_TTL=2592000
@@ -56,3 +93,73 @@
5693
# tool disappears. When enabled + FPD_AUTH_MODE=oauth it is additionally
5794
# gated behind the fpd:admin scope.
5895
# FPD_ENABLE_USER_MANAGEMENT=false
96+
97+
# Registration gate for the 10 MCP prompt templates. Default off: no prompts
98+
# are registered (absent from prompts/list on stdio and HTTP alike) unless
99+
# this is "true". When enabled, all 10 prompt templates register as before.
100+
# FPD_ENABLE_PROMPTS=false
101+
102+
# =============================================================================
103+
# Response-size guard (shared/response_bounds.py)
104+
# =============================================================================
105+
# claude.ai replaces an OVERSIZED tool result with a client-side truncation
106+
# error the server never sees: the model gets no data and no way to recover.
107+
# The cap applies to the serialized payload, so these budgets are CHARACTER
108+
# budgets (len(json.dumps(...))), never token estimates. Shared, identically
109+
# named, across the FPD/PTAB/PFW MCPs.
110+
#
111+
# Structured responses (searches, petition details) are slimmed then
112+
# truncated to fit, always with a `_bounds` marker carrying recovery
113+
# guidance. Document content (the OCR tool) gets the higher content budget
114+
# and a `_window` cursor instead — nothing is lost, it is paged via
115+
# char_offset/max_chars.
116+
# USPTO_MAX_RESPONSE_CHARS=40000
117+
# USPTO_MAX_CONTENT_CHARS=120000
118+
# USPTO_RESPONSE_BOUNDS_ENABLED=true # false disables the guard entirely
119+
120+
# Per-document page cap for the Mistral OCR tier (was a hard-coded 50).
121+
# Same variable name in the PFW and PTAB MCPs. A capped extraction is always
122+
# marked (truncated / truncation_note / _bounds).
123+
# MISTRAL_OCR_MAX_PAGES=50
124+
125+
# Daily (UTC) ceiling on metered Mistral OCR usage. UNSET NOW MEANS THE
126+
# BUILT-IN DEFAULT of 5.00, not unlimited: leaving the only bound on a metered
127+
# third-party API switched off by default was M-21. Set 0 to opt out
128+
# explicitly. The user-facing message when the ceiling is hit is neutral; the
129+
# accounting detail stays in the server log.
130+
# MISTRAL_OCR_DAILY_BUDGET_USD=5.00
131+
132+
# =============================================================================
133+
# Timeouts (F-R2: these existed but were documented only in main.py's
134+
# docstring and CLAUDE.md, not anywhere an operator tuning a deployment looks)
135+
# =============================================================================
136+
# USPTO_TIMEOUT=30.0 # per USPTO API request
137+
# USPTO_DOWNLOAD_TIMEOUT=60.0 # PDF download, extraction AND proxy stream
138+
# DOCLING_TIMEOUT=300.0 # docling-serve extraction (tier 3)
139+
# DOCLING_MAX_PAGES=25 # tier-3 page gate; a document whose page count
140+
# # cannot be determined is refused, not admitted
141+
# FPD_TOOL_DEADLINE_SECONDS=150 # overall budget for one content-extraction
142+
# # tool call, so a slow tier returns an envelope
143+
# # instead of a client-side transport timeout
144+
# FPD_MAX_MCP_BODY_BYTES=4194304 # request body cap on the /mcp surface
145+
# FPD_MAX_PDF_BYTES=26214400 # per-document extraction byte cap (25 MB)
146+
147+
# Error-detail mode. Unset means "production": error envelopes carry a
148+
# generic message for 401/403/429/5xx and never a `context` block. F-E8: this
149+
# variable appeared nowhere in .env.example, the Dockerfile or deploy/, so
150+
# the detailed branch was unreachable for developers too — a debug mode
151+
# nobody could turn on without reading the source. Values that enable detail:
152+
# development, dev, test.
153+
# ENVIRONMENT=production
154+
155+
# =============================================================================
156+
# Reverse-proxy trust (M-1)
157+
# =============================================================================
158+
# The download-proxy IP allowlist and every rate limiter key on the caller's
159+
# address. Behind an API gateway or reverse proxy the raw ASGI peer is ONE
160+
# value for the whole internet, so the allowlist admits everyone the proxy
161+
# admits and the limiters throttle all callers as a single client. Declare
162+
# the proxy's own address (IP or CIDR, comma-separated) and X-Forwarded-For
163+
# is honored FROM THAT PEER ONLY. Leave unset for a direct deployment: the
164+
# header is then never consulted, which is the historical behavior.
165+
# FPD_TRUSTED_PROXY_IPS=172.18.0.0/16

.github/dependabot.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# L-25: no dependency advisory gate existed. uv.lock pins a reproducible
2+
# resolved set, which is why this was a LOW rather than a HIGH, but nothing
3+
# told the operator when one of those pins gained an advisory.
4+
version: 2
5+
updates:
6+
- package-ecosystem: "uv"
7+
directory: "/"
8+
schedule:
9+
interval: "weekly"
10+
open-pull-requests-limit: 5
11+
labels:
12+
- "dependencies"
13+
14+
- package-ecosystem: "github-actions"
15+
directory: "/"
16+
schedule:
17+
interval: "monthly"
18+
open-pull-requests-limit: 3

.github/workflows/secret-scan.yaml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,29 @@ jobs:
3333
--exclude-files '\.md$' \
3434
--exclude-files 'package-lock\.json' \
3535
--exclude-files '\.lock$' \
36+
--exclude-files '\.baseline$' \
3637
--baseline .secrets.baseline
3738
3839
- name: Check for secrets in git history (last 100 commits)
3940
run: |
40-
# Scan recent git history for accidentally committed secrets
41+
# Scan recent git history for accidentally committed secrets.
42+
# detect-secrets exits 0 even when it finds something, so the results
43+
# are inspected explicitly instead of being discarded with `|| true`.
4144
git log --all --pretty=format: -p -100 | \
4245
detect-secrets scan --stdin \
4346
--exclude-files 'configs/.*\.json' \
44-
--exclude-files '\.md$' || true
47+
--exclude-files '\.md$' \
48+
--exclude-files '\.baseline$' > history-scan.json
49+
python - history-scan.json <<'PY'
50+
import json, sys
51+
results = json.load(open(sys.argv[1])).get("results", {})
52+
for path, findings in results.items():
53+
for finding in findings:
54+
print(f"{path}:{finding.get('line_number')} {finding.get('type')}")
55+
if results:
56+
sys.exit("Secrets detected in git history")
57+
print("No secrets detected in git history")
58+
PY
4559
4660
- name: Security scan summary
4761
if: always()

.github/workflows/security-scan.yaml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,29 @@ jobs:
3333
--exclude-files '\.md$' \
3434
--exclude-files 'package-lock\.json' \
3535
--exclude-files '\.lock$' \
36+
--exclude-files '\.baseline$' \
3637
--baseline .secrets.baseline
3738
3839
- name: Check for secrets in git history (last 100 commits)
3940
run: |
40-
# Scan recent git history for accidentally committed secrets
41+
# Scan recent git history for accidentally committed secrets.
42+
# detect-secrets exits 0 even when it finds something, so the results
43+
# are inspected explicitly instead of being discarded with `|| true`.
4144
git log --all --pretty=format: -p -100 | \
4245
detect-secrets scan --stdin \
4346
--exclude-files 'configs/.*\.json' \
44-
--exclude-files '\.md$' || true
47+
--exclude-files '\.md$' \
48+
--exclude-files '\.baseline$' > history-scan.json
49+
python - history-scan.json <<'PY'
50+
import json, sys
51+
results = json.load(open(sys.argv[1])).get("results", {})
52+
for path, findings in results.items():
53+
for finding in findings:
54+
print(f"{path}:{finding.get('line_number')} {finding.get('type')}")
55+
if results:
56+
sys.exit("Secrets detected in git history")
57+
print("No secrets detected in git history")
58+
PY
4559
4660
- name: Security scan summary
4761
if: always()

.github/workflows/tests.yaml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [ main, master, develop ]
6+
pull_request:
7+
branches: [ main, master, develop ]
8+
9+
jobs:
10+
test:
11+
name: Lint and unit tests
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: Checkout code
16+
uses: actions/checkout@v4
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: '3.11'
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v3
25+
26+
- name: Install dependencies
27+
run: uv sync
28+
29+
- name: Run ruff
30+
# Q-1: src/ is clean as of the 2026-09-03 fix wave, so this is a
31+
# blocking gate there. tests/ still carries pre-existing findings in
32+
# the two excluded key-management modules, so it stays advisory.
33+
run: uv run ruff check src
34+
35+
- name: Run ruff over tests (advisory)
36+
continue-on-error: true
37+
run: uv run ruff check tests
38+
39+
- name: Run mypy (advisory ratchet)
40+
# Q-7: 40 errors under the committed lenient config, down from 48.
41+
# Advisory so the count can only be driven down deliberately; the
42+
# step exists so a regression is visible in the run log.
43+
continue-on-error: true
44+
run: uv run mypy src/fpd_mcp
45+
46+
- name: Run pytest
47+
run: uv run pytest -q
48+
49+
- name: Audit dependencies for known advisories
50+
# L-25: the repo had no dependency advisory gate at all. Advisory
51+
# because an upstream CVE disclosure must not block an unrelated
52+
# merge; it does put the finding in the run log the same day.
53+
continue-on-error: true
54+
run: uv run --with pip-audit pip-audit

.pre-commit-config.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ repos:
1616
- '\.md$'
1717
- '--exclude-files'
1818
- 'package-lock\.json'
19+
- '--exclude-files'
20+
- '\.baseline$'
1921
exclude: ^\.secrets\.baseline$
2022

2123
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -33,8 +35,26 @@ repos:
3335
- id: check-merge-conflict
3436
- id: detect-private-key
3537

38+
# Q-1: the C901 complexity gate, the mypy config and a ~15-second test
39+
# suite were all committed and none of them ran automatically — the only
40+
# two workflows were security scanners. ruff runs per commit; pytest runs
41+
# on push, where 15 seconds is affordable.
42+
- repo: https://github.com/astral-sh/ruff-pre-commit
43+
rev: v0.15.21
44+
hooks:
45+
- id: ruff
46+
args: [--config, pyproject.toml]
47+
files: ^src/
48+
3649
- repo: local
3750
hooks:
51+
- id: pytest
52+
name: pytest (fast suite)
53+
entry: uv run pytest -q
54+
language: system
55+
pass_filenames: false
56+
stages: [pre-push]
57+
3858
- id: prompt-injection-check
3959
name: Check for prompt injection patterns (Unicode steganography & FPD attacks)
4060
entry: uv run python .security/check_prompt_injections.py

0 commit comments

Comments
 (0)