Skip to content
SS
Sunny Sahsi

Building a Read-Only MCP Tool Server: Zero-Trust Enterprise Data Access for Claude, Cursor, and Beyond

How we added a second HTTP interface to our enterprise orchestrator: OAuth at the edge, opaque grants, 120-second delegated JWTs, and the same RBAC as the web app—with no prompt-based security.

Sunny Sahsi
•
•
16 min read
#mcp#oauth#security#ai#enterprise#architecture#templates
Building a Read-Only MCP Tool Server: Zero-Trust Enterprise Data Access for Claude, Cursor, and Beyond
Architecture Blueprint
Key Architecture Takeaways (AEO & Quick Facts)
  • •Zero-Trust Invariant: Security lives strictly in server-side authorization gates and session token validation—never in LLM prompt instructions.
  • •Deterministic Tool Execution: 600+ OpenAPI endpoints are dynamically pruned to domain-specific candidates, eliminating tool hallucinations.
  • •Delegated Grants: Scheduled automations run using revocable, scoped grants with tight TTLs without exposing root database credentials.

_Companion to: Inside an Enterprise AI Orchestrator: Multi-Service LLMs, Automation, and Zero-Trust Access_

TL;DR

The in-product chat path owns the model, prompts, and UX. The Model Context Protocol (MCP) path is deliberately different: the orchestrator becomes a tool server—no planner, no present_answer, no chip UX. External clients (e.g. Claude on the web, Claude Code, Cursor remote MCP) bring their own system prompts and reasoning. Security cannot live in instructions; it must live in OAuth consent, token exchange, tool allowlists, mandatory tenant injection, downstream RBAC, rate limits, and audit. v1 is read-only, parity-filtered with interactive chat, with JSON passthrough like the chat executor.

[!TIP] Blueprint & Template Notice: This architecture serves as a generic, production-ready template for exposing internal microservices safely to external MCP clients (Cursor, Claude Desktop, Windsurf) without prompt leakage or privilege escalation. Open-source starter templates are coming soon!

Why a second interface?

Teams already ask questions inside the web application via the in-app assistant. Product also wanted the same enterprise data available where power users already work: AI-native IDEs and assistants that speak MCP.

That sounds like “expose our APIs to Claude.” In a multi-service backend with heterogeneous auth header semantics and no audience on legacy session JWTs, the naive approach fails quickly:

Naive approachWhat breaks
Forward the user’s browser JWT to MCPLong-lived credential leaves the browser trust zone; external client holds it
Forward MCP bearer to downstream Service 1 / 2 APIsDownstream services interpret Authorization differently (M2M vs user)
Let the model pass tenantIdCross-tenant data exfiltration via tool arguments
Rely on “don’t call admin APIs” in the system promptPrompt controls do not apply on MCP—there is no in-house planner

The MCP design accepts a hard split:

Architecture Diagram
Rendering diagram...

Merit: One orchestrator binary, one tool registry filter, one RBAC story. Demerit: Operators must understand two security postures—chat has prompt-side guardrails; MCP does not.


Physical layout: one process, two HTTP servers

Chat and MCP share Postgres, the generated tool registry, and auth integration—but not the same http.Server tuning:

Architecture Diagram
Rendering diagram...

Illustrative startup gating (pseudocode):

go
if envBool("MCP_ENABLED") {
    go mcpServer.ListenAndServe() // separate from chat server
}
// MCP_ENABLED=false → port not bound (feature absent, not 403)

Why two servers? Chat streaming fits a bounded write timeout. MCP sessions (Streamable HTTP) can outlive that window; killing the connection mid-session is worse than isolating listeners.


Credential ladder: four layers, two rules

MCP introduces a credential ladder so each hop only means what it should:

Architecture Diagram
Rendering diagram...
CredentialHolderLifetimeAppears on
OAuth codeClient ↔ AuthSecondsToken endpoint only
MCP access tokenExternal AI client~1 hourAuthorization: Bearer on /mcp only
Service API keyOrchestrator ↔ AuthRotation-managedInternal exchange only
Delegated JWTOrchestrator memory~120 secondsX-Token on one downstream call

R1 — Opaque bearer: The MCP access token is meaningless to downstream APIs. Only the identity service’s grant table can interpret it.

R2 — Delegated JWT never egresses: Minted for one downstream call, dropped immediately—never returned to Anthropic, never logged, not cached like introspection.

Illustrative exchange contract (shape only):

json
{
  "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
  "subject_token": "<opaque>",
  "audience": "https://<canonical-mcp-resource-uri>",
  "requested_token_type": "urn:ietf:params:oauth:token-type:jwt"
}

Deliberate absence: No user_id, property_id, or act_as fields on the exchange request—the endpoint cannot become an impersonation oracle.

On success, the identity service returns active, scopes, property_id from the grant, and a short-lived delegated_token. On failure, { "active": false } without distinguishing _why_—mitigating token enumeration.


Architecture Diagram
Rendering diagram...

Product-locked v1 behavior:

RequirementBehavior
Read-onlyNo write tools; same GET filter as chat planner pool (~hundreds of tools, not exact count in public docs)
Coarse OAuth scopee.g. tools:read gates _whether_ MCP attempts reads; RBAC on each route still decides success
One active tenantDefault tenantId on grant; answers are for one tenant context like chat
Tenant switchMeta-tool to change active tenant after live permission re-check
Data egressPassthrough JSON like chat execTool—no MCP-only field stripping

RFC 8707-style resource binding ties tokens to the canonical MCP URI so audience confusion across environments is harder.


One tool call, end to end

Architecture Diagram
Rendering diagram...

Pseudocode for the critical control:

go
func executeReadTool(principal Principal, tool string, args map[string]any) {
    if !readAllowlist.Contains(tool) {
        return denied("tool_not_exposed")
    }
    args = stripAndInjectProperty(args, principal.PropertyID)
    jwt := principal.DelegatedToken // 120s, single use
    resp := httpGet(tool, jwt, args) // header: X-Token only
    audit(tool, principal, latency, outcome)
    return passthrough(resp)
}

Confused-deputy guard: Downstream calls use X-Token only. Setting Authorization on downstream clients would conflate M2M keys with user sessions across services.


Trust boundaries and prompt injection

Architecture Diagram
Rendering diagram...
BoundaryWhat crossesControl
User → AI cloudOAuth grant intentConsent UI names tenant + client
AI → your edgeOpaque bearer, tool argsTLS, optional egress IP allowlist, rate limits
Handler → internalsResolved user, client id, tenantIntrospection + audience check
Internals → identitySubject token onlyService API key, no user impersonation params
Internals → dataShort-lived user JWTRBAC per route, 20s read timeout (v1)
Data → AIFull JSON (passthrough)Same egress as chat-to-LLM—including PII

The uncomfortable truth (demerit): The model may read freeform text remarks, operational notes, or any field RBAC allows. An attacker who can influence text the model later reads can attempt indirect prompt injection. MCP mitigation is not “tell Claude to ignore remarks”—it is treat every tool argument as hostile, never trust client-supplied tenant scope, and monitor audit for anomalous tools.


Shared vs separated: architectural comparison

ConcernChat `/chat/stream`MCP `/mcp`Shared?
Process binaryYesYesShared
HTTP server instanceChat listenerMCP listenerSeparated
Caller authenticationUser session JWTOAuth opaque bearerSeparated
Permission authorityAuth serviceSame lookups via exchangeShared
Tool surfacePlanner picks subsetFull allowed read registryShared filter, different exposure
ModelIn-app configured LLMClient’s modelSeparated
Prompt policyPlanner, refuse rules, PCINoneSeparated
Downstream credentialUser JWT (~hours)Delegated JWT (~minutes)Separated
Turn auditChat tool executionsDedicated MCP audit tableSeparated schema
LLM calls inside orchestratorYesNoMCP is not an LLM proxy

Merit for security reviews: Anything that only existed in prompts does not protect MCP. The checklist shrinks to server-enforced controls.

Demerit for product: Behavior diverges—chat might refuse a phrasing that MCP still retrieves if RBAC allows the underlying GET.


Tool surface and capability registry

v1 registers one capability kind: read_tool.

Architecture Diagram
Rendering diagram...
Filter layerWhat it removes (examples)
MethodNon-GET operations
Path fragmentsAdmin, internal routes, export, impersonation, invites
Tenant scopeCross-tenant listings outside active workspace/org
Product v1All writes

Chat narrows which reads the planner mentions; MCP lists the full filtered set so the client model can call any read the user’s role already allows—aligned with “show me everything I’m permitted to see.”

Meta-tools (v1):

  • •Set active tenant (re-validates permissions).
  • •Get session context (active tenant, user attribution for client UX).

Input schemas on read tools are intentionally permissive (additionalProperties) so nested query objects (e.g. report PDF parameters) match chat’s parameter preparation—at the cost of weaker JSON Schema validation at the MCP edge.


Revocation and permission drift

Architecture Diagram
Rendering diagram...
EventWorst-case stalenessBehavior
Grant revokedUp to ~60s introspection cacheFail closed on next exchange
User deactivatedCascade revoke grantsactive: false
Permission removedImmediate on backend403 on specific tool, not global disconnect
Chat path permission cacheLonger TTL elsewhereNot reused for MCP—by design

Observability and compliance posture

MCP audit table (Orchestrator Postgres) — separate from chat tool_executions so connector activity is attributable:

Stored (v1)Not stored (v1)
User id/email, property idFull tool arguments
Client id, grant id, scopesResponse bodies
Tool names, HTTP path/methodDelegated JWT / JTI
Outcome, duration—

Exchange events also log in the identity service’s exchange log—pair grant id + timestamps for incidents.

Best-effort inserts: Audit failure does not block the tool result today—ops may require fail-closed audit before prod (checklist item).


Configuration and kill switches (names only)

Representative environment surface—values belong in secrets management, never in docs or git:

Orchestrator
MCP_ENABLED=false          # default off; false = listener not bound
MCP_PORT=8091
MCP_CANONICAL_URI=...      # PRM + audience
MCP_CAPABILITIES=read
MCP_INTROSPECTION_CACHE_TTL=60s
MCP_RATE_LIMIT_PER_MINUTE=30
MCP_RATE_LIMIT_PER_HOUR=500
AUTH_SERVICE_API_KEY=...   # secret
AUTH_SERVICE_INTERNAL_BASE_URL=...

# Identity service
MCP_OAUTH_ENABLED=false
MCP_DELEGATED_TOKEN_TTL=120s
MCP_ACCESS_TOKEN_TTL=1h

Official Go MCP SDK on modern Go; Streamable HTTP with stateless requests—identity from Authorization every call.


Merits of this MCP architecture

MeritExplanation
No second RBAC systemOAuth scopes are coarse; rbacRoutes.json + middleware remain authoritative
No service account readsEvery call traces to a staff user who consented
Parity with chat readsSame IsAllowedReadTool filter—security fixes propagate to both surfaces
Opaque tokensStolen bearer cannot be replayed on downstream APIs directly
Short delegated JWTBlast radius of a mishandled downstream credential is two minutes
Kill switch default offMCP_ENABLED absent/false means no listener—not a soft 403
Extensible capability registryFuture writes/events add kinds + scopes without new binaries
Dual server isolationStreaming/chat timeouts do not break MCP sessions
Audit attributiongrant id + client id distinguish Claude Code vs claude.ai vs Cursor
Exchange re-checks live permissionsGrants cannot outlive access removal (modulo cache)
Architecture Diagram
Rendering diagram...

Demerits, risks, and honest tradeoffs

DemeritImpactNotes / mitigations
Passthrough egressExternal models receive same PII/finance JSON as internal LLMRejected MCP-only redaction; compliance must treat AI vendor as subprocessors
No prompt layerNo PCI mask, refuse, or navigate cards unless client builds themServer still masks at chat; MCP relies on RBAC + read filter only
Wider tool exposure than plannerModel may chain more reads than typical chat UXRate limits; monitoring; user training
In-memory active propertyMulti-replica load balancing resets property switchDocument UX; shared session store is future work
60s verification cacheRevocation not instantaneousShorter than chat caller cache—intentional tradeoff
Permissive argument schemasLess validation of nested query objectsMatches chat prep; increases injection surface
v1 read-only onlyStaff still switch to UI for writesWrite capability needs new scopes + consent + handler rules
No v1 response byte capsLarge folio/report payloads can stress client contextEnv caps listed as future, not wired
Best-effort auditGap during DB outagesPre-launch hardening checklist
Optional IP allowlistMay block legitimate Claude Code from laptops if enforced globallySecurity vs usability tension
Heterogeneous legacy auth headersEngineering complexity concentrated in exchangeWhy blind forwarding was rejected
Two operational surfacesOncall must know chat vs MCP failure modesRunbooks, separate dashboards on mcp_tool_calls
Third-party model behaviorSame data → different summaries/hallucinationsNot the orchestrator’s presenter contract; support boundaries blur

Architectural tension: MCP is more capable per connection (full read catalog) but less guided (no presenter limits on stats/chart rows). Users may assume “The in-app agent said it in chat” equals “Claude read via MCP,” but grounding and formatting differ.


What we deliberately did not build (v1)

  • •Write tools (POST/PUT/PATCH/DELETE)—even if the chat agent can write with forms and confirmation.
  • •A parallel permission matrix or property allowlist beyond existing Auth permissions.
  • •MCP-only JSON field allowlists (rejected alternative documented internally).
  • •Dynamic Client Registration in some deployments—pre-registered clients with redirect URI tables.
  • •Prompt resources or server-owned prompt templates on the wire (future capability kind).

Long-running batch jobs and night-audit pipelines stay in domain services; MCP may later subscribe to events via notification/webhook capabilities—not replace internal schedulers.


Operations checklist (pre-production themes)

Themes from deployment/runbook docs—not a substitute for your org’s change control:

  • •TLS + canonical MCP_CANONICAL_URI matches grant Resource.
  • •Migrations for grant tables + orchestrator audit model (AutoMigrate vs formal migrate—confirm compliance).
  • •Secrets via envFrom, never baked in images.
  • •Redirect URIs registered per MCP client record.
  • •Retention policy on audit + exchange logs (~13 months proposed).
  • •Decide fail-closed vs best-effort audit.
  • •Document property-switch behavior under multiple replicas.
  • •Rate limits tuned per property size (large folio pulls).

How MCP fits the broader agent architecture

Architecture Diagram
Rendering diagram...
  • •Chat: product-owned experience, presenter contracts, agent flows, routines proposed in conversation.
  • •MCP: staff-owned AI environment, tool parity on reads, identity-bound egress.
  • •Routines: background automation with encrypted grant secrets—not MCP, but same exchange philosophy (user consent, short-lived tokens, no impersonation parameters).

Lessons for teams building “MCP on enterprise data”

  • •Split “AI app” from “AI tool server” early—shared filters, not shared prompts.
  • •Treat the model as untrusted input to your tool layer, especially with passthrough JSON.
  • •Never let the client name the tenant (workspace/org/account)—inject from grant server-side.
  • •Opaque + exchange beats “give the LLM a long-lived API key.”
  • •Measure connector activity separately from chat turns—different abuse patterns.
  • •Default off beats “enabled with 403” for regulated rollouts.
  • •Plan multi-replica state before shipping meta-tools that mutate session context.

Summary

The enterprise MCP layer is a read-only, OAuth-gated, RBAC-faithful tool server sitting beside—not inside—the conversational agent stack. It trades the safety of prompt engineering for the rigor of token exchange, allowlists, tenant injection, and audit attribution. The merits are alignment with existing permissions, no service accounts, and a clean path to future capability kinds; the demerits are passthrough data egress to third-party models, wider read exposure, operational dualism, and session-state quirks at scale.

Written by Sunny Sahsi

Associate Full Stack Developer building AI chatbot agents, Go microservices, and modern Next.js web applications.