session' | 'agent-autonomous' | 'service-account';
discoveryVisibility: 'filtered' | 'public';
}
### Step 2: Map Authority Surface and Credential Lane
The authority surface restricts what external systems, paths, or resources the tool can affect. The credential lane binds the execution to a scoped principal, ensuring tenant isolation and revocable access.
```typescript
interface AuthorityBoundary {
allowedPrefixes: string[];
forbiddenNeighbors: string[];
credentialMode: 'scoped-token' | 'vault-ref' | 'managed-key';
credentialScope: string;
revocationPath: string;
}
Step 3: Enforce Budget and Quota Ownership
Financial and network-bound tools require explicit cost ceilings, retry limits, and idempotency enforcement. The budget owner tracks quota burn and partial success states.
interface BudgetConstraint {
costCeiling: number;
quotaBucket: string;
retryCeiling: number;
idempotencyKey: string;
billingOwner: string;
}
Step 4: Implement Denied Neighbor Fixtures
A tool is not production-ready until adjacent actions fail deterministically. Fixtures validate that sibling paths, private IPs, wrong tenants, or elevated amounts trigger typed denials.
interface DenialFixture {
target: string;
expectedError: 'AUTH_DENIED' | 'QUOTA_EXCEEDED' | 'NEIGHBOR_BLOCKED' | 'INVALID_SCOPE';
failClosed: boolean;
}
Step 5: Structure Receipt and Recovery Paths
Every invocation must emit a normalized audit receipt containing the policy decision, credential lane, cost, and recovery hint. This enables operator reconstruction without replaying agent conversations.
interface AuditReceipt {
policyDecision: 'ALLOWED' | 'DENIED' | 'PARTIAL';
normalizedInput: unknown;
denialReason?: string;
providerOutcome?: string;
retryState: number;
cost: number;
recoveryHint: string;
}
Step 6: Runtime Enforcement Wrapper
The following TypeScript class binds all seven fields into a single enforcement layer. It validates inputs against authority boundaries, tracks budget consumption, enforces neighbor denials, and emits structured receipts.
class McpToolBoundaryEnforcer {
private auditLog: AuditReceipt[] = [];
constructor(
private route: ToolRouteDefinition,
private authority: AuthorityBoundary,
private budget: BudgetConstraint,
private fixtures: DenialFixture[]
) {}
async execute(input: Record<string, unknown>, callerContext: { tenantId: string; sessionId: string }): Promise<AuditReceipt> {
// 1. Validate trust class and discovery visibility
if (this.route.discoveryVisibility === 'filtered' && callerContext.tenantId !== this.authority.credentialScope) {
return this.emitReceipt('DENIED', input, 'TRUST_CLASS_MISMATCH', 0, 'Revoke session and re-authenticate');
}
// 2. Normalize and validate authority surface
const targetResource = String(input.resource || '');
const isAllowed = this.authority.allowedPrefixes.some(prefix => targetResource.startsWith(prefix));
const isForbidden = this.authority.forbiddenNeighbors.some(neighbor => targetResource.includes(neighbor));
if (!isAllowed || isForbidden) {
return this.emitReceipt('DENIED', input, 'AUTHORITY_SURFACE_VIOLATION', 0, 'Sanitize input and retry with allowed prefix');
}
// 3. Enforce budget and idempotency
const currentCost = this.estimateCost(input);
if (currentCost > this.budget.costCeiling) {
return this.emitReceipt('DENIED', input, 'QUOTA_EXCEEDED', currentCost, 'Reduce scope or request quota increase');
}
// 4. Run negative fixtures in parallel
const fixtureResults = await Promise.all(
this.fixtures.map(f => this.validateFixture(f, input))
);
if (fixtureResults.some(r => !r.passed)) {
return this.emitReceipt('DENIED', input, 'FIXTURE_FAILURE', currentCost, 'Review neighbor boundary configuration');
}
// 5. Execute and emit success receipt
const result = await this.invokeBackend(input, callerContext);
return this.emitReceipt('ALLOWED', input, undefined, currentCost, 'Operation completed successfully');
}
private async validateFixture(fixture: DenialFixture, input: Record<string, unknown>): Promise<{ passed: boolean }> {
const testInput = { ...input, resource: fixture.target };
try {
await this.invokeBackend(testInput, { tenantId: 'test', sessionId: 'test' });
return { passed: false }; // Should have failed
} catch (err: any) {
return { passed: err.code === fixture.expectedError };
}
}
private emitReceipt(decision: AuditReceipt['policyDecision'], input: unknown, reason?: string, cost: number = 0, hint: string = ''): AuditReceipt {
const receipt: AuditReceipt = {
policyDecision: decision,
normalizedInput: input,
denialReason: reason,
retryState: 0,
cost,
recoveryHint: hint
};
this.auditLog.push(receipt);
return receipt;
}
private estimateCost(input: Record<string, unknown>): number {
return typeof input.amount === 'number' ? input.amount : 0;
}
private async invokeBackend(input: Record<string, unknown>, ctx: { tenantId: string; sessionId: string }): Promise<unknown> {
// Placeholder for actual MCP tool invocation
return { status: 'ok', tenant: ctx.tenantId };
}
}
Architecture Decisions and Rationale
- Boundary Enforcement at Registration: Tools are wrapped before exposure to the model. This prevents discovery-time leaks and ensures policy evaluation happens before execution.
- Typed Denials over Generic Errors: Returning specific error codes (
AUTH_DENIED, QUOTA_EXCEEDED) enables deterministic retry logic and prevents silent state corruption.
- Credential Scoping per Session: Backend principals are bound to tenant and session identifiers, eliminating cross-tenant bleed and enabling immediate revocation.
- Negative Fixtures as Gatekeepers: Adjacent path and neighbor validation runs synchronously during initialization. If fixtures fail, the tool is blocked from production routing.
- Receipt-First Design: Audit trails are emitted as structured objects, not string logs. This enables automated compliance reporting, cost attribution, and incident reconstruction.
Pitfall Guide
1. Over-Discovery Exposure
Explanation: Tools appear in the model's discovery list even when the caller lacks authority to use them. The model plans around impossible permissions, leading to repeated failures or fallback to unsafe alternatives.
Fix: Implement filtered discovery that evaluates trust class and tenant scope before returning tool metadata. Never expose tools that require elevated authority to low-trust sessions.
Explanation: Parameters accept arbitrary strings where the runtime requires normalized paths, hosts, tenants, or amounts. This enables path traversal, tenant bleed, and budget bypass.
Fix: Validate and normalize all inputs against explicit allowlists before policy evaluation. Reject raw strings that cannot be mapped to a known resource prefix or tenant boundary.
3. Shared Backend Principals
Explanation: Authentication proves identity, but the backend principal is shared across tenants or workflows. A single compromised session can access cross-tenant data or trigger unintended side effects.
Fix: Bind credentials to session-scoped tokens with explicit revocation paths. Use vault references or managed keys that rotate per tenant and expire on session termination.
4. Silent Failures on Denied Neighbors
Explanation: Adjacent paths, private IPs, or wrong tenants return generic 500 errors, trigger silent retries, or produce partial side effects instead of typed denials.
Fix: Enforce fail-closed behavior with explicit error codes. Configure the runtime to reject neighbor access immediately and log the denial reason. Never allow partial execution on boundary violations.
5. Incomplete Audit Receipts
Explanation: Logs capture the final provider response but omit the policy decision, credential lane, normalized input, and cost owner. Operators cannot reconstruct incidents without replaying conversations.
Fix: Emit structured receipts containing all seven threat model fields. Store receipts in an immutable audit store with tenant and session correlation IDs.
6. Happy-Path-Only Testing
Explanation: Test suites validate successful invocations but never exercise adjacent paths, private IPs, wrong tenants, larger spend, or unsafe write variants.
Fix: Implement negative fixture suites that run during CI/CD. Require explicit pass/fail results for neighbor denials, quota ceilings, and trust class mismatches before deployment.
Explanation: Retries and partial success states consume quota multiple times, leading to unexpected cost escalation and duplicate side effects.
Fix: Enforce idempotency keys at the tool boundary. Track retry state in the audit receipt and reject duplicate invocations that exceed the retry ceiling.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal development tools | Filtered discovery, scoped credentials, relaxed budget ceilings | Low risk of external exposure; prioritize developer velocity | Low monitoring overhead, moderate quota allocation |
| Customer-facing CRM updates | Strict tenant isolation, normalized input validation, typed denials | Prevents cross-tenant data leakage and unauthorized record mutation | High audit storage cost, low incident response cost |
| Payment/billing integrations | Idempotency enforcement, cost ceilings, retry limits, neighbor denial | Prevents duplicate charges, quota exhaustion, and financial side effects | High compliance overhead, near-zero fraud loss |
| CI/Deploy infrastructure | Read/build/status first, deny secret read and privilege escalation | Limits blast radius to non-destructive operations until review | Moderate pipeline latency, high production stability |
Configuration Template
tool_boundary:
route:
capability_id: "crm:update_record"
side_effect_class: "state-mutation"
trust_class: "user-session"
discovery_visibility: "filtered"
authority:
allowed_prefixes:
- "tenant-a/contacts/"
- "tenant-a/opportunities/"
forbidden_neighbors:
- "tenant-b/"
- "admin/"
- "archived/"
credential_mode: "vault-ref"
credential_scope: "tenant-a"
revocation_path: "/auth/revoke/session"
budget:
cost_ceiling: 50
quota_bucket: "crm-updates-tenant-a"
retry_ceiling: 2
idempotency_key: "req-{uuid}"
billing_owner: "ops-team"
fixtures:
- target: "tenant-b/contacts/123"
expected_error: "AUTH_DENIED"
fail_closed: true
- target: "admin/config"
expected_error: "NEIGHBOR_BLOCKED"
fail_closed: true
audit:
required_fields:
- "policy_decision"
- "normalized_input"
- "denial_reason"
- "cost"
- "recovery_hint"
Quick Start Guide
- Register Tool Boundaries: Wrap each MCP tool with the
McpToolBoundaryEnforcer class, passing route definition, authority boundaries, budget constraints, and negative fixtures.
- Configure Credential Scoping: Bind backend principals to tenant and session identifiers. Ensure credentials expire on session termination and support immediate revocation.
- Run Negative Fixtures: Execute fixture suites during initialization. Verify that adjacent paths, wrong tenants, and elevated amounts return typed denials before allowing production routing.
- Enable Structured Auditing: Configure the runtime to emit audit receipts for every invocation. Store receipts in an immutable log with tenant and session correlation for compliance and incident reconstruction.
- Deploy with CI/CD Gates: Block deployments if fixtures fail, receipts are incomplete, or authority boundaries are misconfigured. Treat boundary validation as a non-negotiable production requirement.