Skip to content

feat(crm-agent): mcp_auth_proxy.py — let Claude Code call the auth-protected MCP#71

Merged
carvychen merged 1 commit into
mainfrom
feat/mcp-auth-proxy
May 10, 2026
Merged

feat(crm-agent): mcp_auth_proxy.py — let Claude Code call the auth-protected MCP#71
carvychen merged 1 commit into
mainfrom
feat/mcp-auth-proxy

Conversation

@carvychen
Copy link
Copy Markdown
Owner

Summary

Let Claude Code (and any other MCP client that doesn't natively manage Azure AD token refresh) call our auth-protected MCP server.

Why a proxy is needed: Claude Code's HTTP MCP support has three relevant limits:

  • `claude mcp add --header "Authorization: Bearer X"` registers a static token — expires in ~1h, no refresh
  • 401 responses are not auto-retried (per docs)
  • OAuth 2.0 support requires server-side metadata our MCP server does not publish

So a single `claude mcp add` would work for an hour, silently 401 forever after, and the user wouldn't know why their CRM tools stopped working.

Solution: thin local proxy on `127.0.0.1:8001`. Claude Code talks to the proxy (no auth needed for the localhost hop); proxy fetches a fresh `az` JWT (45-min eager refresh) and injects `Authorization: Bearer ...` on every forwarded request.

```
Claude Code ──► localhost:8001/mcp/ (proxy)
│ injects Authorization: Bearer

MCP_SERVER_URL[_REMOTE] (real MCP server)
```

Wire-up

```bash

terminal X (long-running)

python scripts/mcp_auth_proxy.py # → MCP_SERVER_URL (local)
python scripts/mcp_auth_proxy.py --remote # → MCP_SERVER_URL_REMOTE (deployed FA)

one-time, in Claude Code

claude mcp add --transport http crm http://127.0.0.1:8001/mcp/
```

Implementation

  • ~140 lines, single file
  • Reuses one `httpx.AsyncClient` for the proxy lifetime so MCP Streamable HTTP keep-alive sessions persist across multiple requests
  • Pass-through every header except (host, content-length, client-supplied auth)
  • Streaming response via `StreamingResponse` + `httpx.stream=True` — MCP returns SSE for some flows
  • 502 explicitly when upstream unreachable
  • Eager `_acquire_user_jwt()` at startup → fail fast if `az login` is missing
  • Token cached in process memory (not on disk), refreshed eagerly at 45 min

Test plan

  • Syntax (`python -c "import ast..."`)
  • Smoke: `run_mcp_server_local.py` (:8000) + `mcp_auth_proxy.py` (:8001) up; client sends raw POST to :8001 with NO auth:
    • `initialize` → 200, serverInfo.name=crm-mcp ✓
    • `notifications/initialized` → 202 Accepted ✓
    • `tools/list` → all 7 CRM tools (list_opportunities, get_opportunity, create_opportunity, update_opportunity, delete_opportunity, search_accounts, search_contacts) ✓
  • Manual: `claude mcp add` + ask Claude Code to list opportunities — verify tools fire

Doc

`docs/deployment/llm-foundry-zh.md` "本地 REPL" gets a new H3 (§4) "让 Claude Code 调我们的工具" explaining the gap, the workaround, and the wiring command. §5 (切换模型) numbering shifted from §4.

🤖 Generated with Claude Code

… our MCP

Claude Code's HTTP MCP support has two relevant limitations:
- `claude mcp add --header "Authorization: Bearer X"` registers a STATIC
  token — expires in ~1h, no refresh
- 401 responses are not retried (per docs)
- OAuth 2.0 support requires server-side metadata; our MCP server doesn't
  publish that

So a long-lived `claude mcp add` registration with a static token would
work for an hour, then 401 silently and stay broken until the user
manually re-registers. Painful and easy to miss.

Solution: thin local proxy. Claude Code talks to the proxy on localhost
(no auth needed for that hop); proxy fetches a fresh az JWT per request
window (45-min eager refresh) and injects `Authorization: Bearer <jwt>`
on every forwarded request. Token management lives in one place; Claude
Code's `.mcp.json` config stays simple.

Surface (~140 lines, single file):
  python scripts/mcp_auth_proxy.py                  # → MCP_SERVER_URL
  python scripts/mcp_auth_proxy.py --remote         # → MCP_SERVER_URL_REMOTE
  python scripts/mcp_auth_proxy.py --port 9001 ...
  → Listen: http://127.0.0.1:8001/
  → Wire Claude Code:
    claude mcp add --transport http crm http://127.0.0.1:8001/mcp/

Implementation notes:
- Reuses one httpx.AsyncClient for the proxy lifetime so MCP Streamable
  HTTP keep-alive sessions persist across requests.
- Pass-through everything except (host, content-length, our own auth).
- Streaming response via Starlette StreamingResponse + httpx stream=True
  — MCP returns SSE for some flows; can't buffer to memory.
- 502 if upstream unreachable; explicit error rather than silent fail.
- Eager `_acquire_user_jwt()` at startup so missing `az login` fails
  before Claude Code tries to connect.

Smoke (with both `run_mcp_server_local.py` on :8000 and proxy on :8001):
  - POST /mcp/ initialize → 200, serverInfo.name=crm-mcp
  - POST /mcp/ notifications/initialized → 202 Accepted
  - POST /mcp/ tools/list → 7 expected tools (list_opportunities,
    get_opportunity, create_opportunity, update_opportunity,
    delete_opportunity, search_accounts, search_contacts)

Doc:
- llm-foundry-zh.md "本地 REPL" gains a new H3 (§4) "让 Claude Code (或其他外部 MCP 客户端)调我们的工具"
  explaining the gap, the workaround, and the wiring command.
- §5 (切换模型) numbering shifted from §4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@carvychen carvychen merged commit 4d48379 into main May 10, 2026
3 checks passed
@carvychen carvychen deleted the feat/mcp-auth-proxy branch May 10, 2026 04:02
carvychen added a commit that referenced this pull request May 10, 2026
…#72)

Without this, Claude Code can't discover the CRM skill — its skill scanner
walks `.claude/skills/<name>/SKILL.md`, not `integrations/.../skills/`.
After PR #71 (mcp_auth_proxy.py), Claude Code can call our auth-protected
MCP, but it had no SOP context to know when to do so. This closes that gap.

Symlink (mode 120000) — single source of truth: edits to the host-portable
bundle at `integrations/crm-agent/skills/crm-opportunity/` are immediately
visible to Claude Code. No copy / no drift. Git stores symlinks
cross-platform on Unix; Windows users (rare for this repo) may need
`core.symlinks=true`.

Verified: starting Claude Code in this repo lists `crm-opportunity`
alongside the existing 5 SOP skills (domain-model, github-triage, tdd,
to-issues, to-prd). Description matches the bundle's frontmatter.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
carvychen added a commit that referenced this pull request May 10, 2026
* docs(crm-agent): SKILL.md compatibility — distinguish verified vs designed-for

The "Tested against" list claimed 4 hosts (Claude Desktop, VS Code Copilot
MCP, in-repo agent, Copilot Studio). In reality only the in-repo agent has
been verified end-to-end. The other 3 are aspirational MCP-spec compliance.
Today we also empirically verified Claude Code works — but only via the
auth-injecting proxy added in PR #71, not via `.mcp.json` directly, because
Claude Code's `--header` Bearer support is static and our tokens are short-lived.

Restructure to distinguish two categories:

- "Verified end-to-end": the in-repo agent + Claude Code (via local proxy).
  Names the proxy by reference for hosts with the same token-lifecycle gap.
- "Designed-for, not yet verified by us": Claude Desktop, VS Code Copilot MCP,
  Copilot Studio. All three accept static Bearer via `.mcp.json` directly;
  refresh discipline is on the customer.

Also explicitly states the implicit precondition that was always true but
never stated: hosts must be able to attach `Authorization: Bearer <user-jwt>`
to every request. The bundle's only interface with the host is `.mcp.json`;
how the host sources a JWT is host-specific (and is the gnarly part for
hosts whose token model isn't a great fit).

32 tests pass. No code/config change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(crm-agent): SKILL.md — strip host/project-specific accretion (#73 expanded)

The "Compatibility honest" change in this PR's first commit was the right
direction but too narrow. SKILL.md had been accreting more than just one
overclaim — multiple sections were host-specific or project-specific in a
way that breaks the "host-portable bundle" promise from ADR 0009.

Removed (project / host specific noise):
- "Single-source note" callout about in-repo agent + SkillsProvider —
  external customers don't care that OUR agent consumes this bundle.
- "Setup" 3-step section enumerating Claude Desktop / VS Code / Custom
  agent wiring — those belong in each host's own MCP docs. The 1-line
  "edit .mcp.json + wire per host docs" is enough.
- "Reference deployments: Bicep / func start / run_mcp_server_local.py"
  paragraph — pure project-specific (already documented in
  docs/deployment/walkthrough-zh.md and llm-foundry-zh.md).
- "Compatibility" section listing verified-by-us hosts (Claude Code via
  proxy, etc.) — the "in this repo" framing falls apart the moment the
  bundle is dropped into a non-this-repo context.
- Frontmatter `compatibility:` field's enumeration of named hosts.
- "Further reading" link to "main repo docs/" — bundle shouldn't reach
  back into specific project layout.

Kept (host-agnostic core):
- Frontmatter (with generalized `compatibility:` text).
- 2-line intro: what the skill does + how to wire (.mcp.json + host docs).
- Available tools table — the contract.
- Rating values / dates / FIELD_REFERENCE pointer — tool data the LLM uses.
- SOP § 1-4 (name resolution, destructive confirm, RLS, error fidelity).
- 3 Example dialogues.
- references/FIELD_REFERENCE.md link.

Test casualty: removed `test_skill_md_references_adr_0009` since we
deliberately removed the ADR 0009 reference (it was the project-specific
"single-source note"). The host-neutrality test (`test_skill_md_setup_is_host_neutral`)
remains — its docstring no longer mentions ADR 0009 either.

Net: 119 → 95 lines (-20%). 31 affected tests pass.

Project-specific content all moved (or already lives) in the right docs:
- Setup mechanics → docs/deployment/walkthrough-zh.md, llm-foundry-zh.md
- Claude Code via proxy → llm-foundry-zh.md "本地 REPL" §4
- ADR 0009 single-source rationale → docs/adr/0009-...

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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.

1 participant