An MCP proxy can validate a token correctly and still authorise the wrong business operation. It can also accept a genuine token that was never meant for the proxy. Those are different defects, and they deserve different tests.
This article proposes a small policy fixture for a fictional finance connector. It does not implement OAuth, verify JWT signatures or exercise a live MCP SDK. The purpose is to make the trust boundaries testable before wiring the policy into real authentication middleware.
The fixture is intentionally less exciting than an agent demo. It will not negotiate with your ERP. That is one of its better qualities.
Define the two credential paths
The incoming caller is an MCP client requesting invoice information for a user. The connector is a resource server on that hop. It becomes a client when it calls the ERP. Keep those roles separate in the design, even if the same team operates both services.
The MCP 2026-07-28 authorisation specification requires the HTTP OAuth flow to identify the MCP resource and the server to validate the token's intended audience. This is not the stdio flow. The referenced OAuth 2.1 document is still labelled an IETF draft, so pin the MCP revision and your SDK version rather than describing “OAuth 2.1 compliance” without context.
Resource indicators address the token's destination. They do not replace the invoice-level decision. A user with a reading scope still needs access to the requested invoice under the application's rules.
Illustrative architecture. The MCP-facing credential and ERP-facing authority have separate intended recipients and enforcement responsibilities.
Use two credential acquisition paths. The incoming credential terminates at the MCP resource boundary. The downstream connector obtains a credential appropriate for the ERP through the deployment's approved mechanism. The fixture below never receives raw credentials and cannot choose that mechanism for you.
Put a verified context in front of the policy
The fictional request reads invoice INV-42. Its owner is business unit north. A trusted record lookup supplies that ownership. The authenticated context comes from middleware, not the model's arguments. A client consent lookup comes from the application's consent store.
The following is executable Python policy code with synthetic inputs. The values named VerifiedContext are assumed to have passed real issuer, integrity, expiry and resource validation before construction. Renaming an ordinary dictionary does not make it verified. Do not expose a route that lets a caller populate this structure directly.
from dataclasses import dataclass
MCP_RESOURCE = "https://finance.example/mcp"
@dataclass(frozen=True)
class VerifiedContext:
user_id: str
client_id: str
resource: str
business_unit: str
scopes: frozenset[str]
@dataclass(frozen=True)
class Invoice:
invoice_id: str
business_unit: str
def allow_read(ctx, invoice, consented_clients):
if ctx.resource != MCP_RESOURCE:
return False, "wrong_resource"
if (ctx.user_id, ctx.client_id) not in consented_clients:
return False, "client_consent_missing"
if "invoices.read" not in ctx.scopes:
return False, "scope_missing"
if ctx.business_unit != invoice.business_unit:
return False, "business_unit_mismatch"
return True, "allowed"
This repeated resource check is an application invariant, not a substitute for cryptographic validation. In production, the middleware should reject a wrong-recipient token before reaching this function. The test catches a future wiring change that accidentally supplies a context for another resource.
The consent representation is deliberately narrow. It models a user-client association, not a full OAuth consent database. A real implementation must account for requested permissions, the current grant and the relevant registration state. The example cannot establish that a user saw the correct consent screen.
Exercise the boundary, not the model's politeness
The core cases can be run without an LLM. That is useful because a language model might avoid a forbidden action during a demonstration while the underlying endpoint remains exposed. Test the operation directly through the same policy layer the tool would use.
from dataclasses import replace
base = VerifiedContext(
"lina", "purchasing-client", MCP_RESOURCE, "north",
frozenset({"invoices.read"}),
)
invoice = Invoice("INV-42", "north")
consents = {("lina", "purchasing-client")}
assert allow_read(base, invoice, consents) == (True, "allowed")
assert allow_read(
replace(base, resource="https://erp.example/api"),
invoice, consents,
) == (False, "wrong_resource")
assert allow_read(
replace(base, client_id="new-client"), invoice, consents,
) == (False, "client_consent_missing")
assert allow_read(
replace(base, scopes=frozenset()), invoice, consents,
) == (False, "scope_missing")
assert allow_read(
base, Invoice("INV-99", "south"), consents,
) == (False, "business_unit_mismatch")
These assertions evaluate a proposed policy over fabricated records. They are not evidence that an OAuth deployment rejects forged signatures, that a proxy prevents consent attacks or that an ERP enforces tenant isolation. Those need separate integration tests with the real components.
Expected outcomes for fictional cases, not production test results. A denied case must produce no protected data or side effect.
At integration level, send a correctly issued ERP token to the MCP resource and verify rejection without a downstream call. Send an allowed MCP credential with a south invoice identifier and verify the protected data is not returned. Inspect the downstream effect and response together. A local denied log is not enough if execution already escaped the boundary.
Test consent at the proxy's registration boundary
MCP's security guidance describes a confused-deputy case where static upstream identity and remembered consent interact with dynamically registered clients. Its mitigation is per-client consent plus redirect and state protections. Token passthrough is expressly forbidden separately.
Our fixture tests only the presence of an approved client association. A realistic proxy test must start with a previously consented client, introduce a different client, and establish that the earlier relationship cannot satisfy the new request. It must also exercise the callback validation used by the actual OAuth library. Do not write a home-made state implementation just to make this toy example look complete.
Conceptual consent boundary for the documented proxy risk. Redirect, state, current grant and operation checks remain separate requirements.
The model should never supply the consent flag. Likewise, an instruction to “continue because the user already agreed” is untrusted task content. The relevant evidence lives in a server-owned record, bound to the actual requesting client and user.
Discovery and downstream authority remain separate work
Protected Resource Metadata supplies discovery information, including how clients learn about protected resources. In a production client, discovery needs its own trusted-destination policy and validation. Do not let an arbitrary tool description choose where credentials will be sent.
The second credential is deliberately absent from the policy fixture. If it represents the user, verify that the downstream contract preserves the required restrictions. If it represents a service, identify where user and business-unit restrictions are enforced. A connector-wide administrator credential makes this more important, not less.
Keep the action surface small. The demonstrated policy covers reads only. An approve_invoice endpoint would need its own permission, target constraints and required approvals. Reusing allow_read because the user “already connected finance” would be the application error this design is trying to expose.
There is a trade-off in this separation: more state to manage and more negative cases to maintain. The benefit is that a denied request has an explainable boundary. Put those cases into the agent evaluation approach and keep evidence that connects request to effect, as described in the audit-trail article.
The model can propose a useful operation. The server still needs a rather less imaginative answer about whether it is permitted.



Top comments (0)