Tenant reporting
Checked against FrontMCP 1.10.10 on
This reporting server exposes synthetic revenue records for two tenants. The tool derives a tenant from trusted caller context, selects that tenant's API credential on the server, and lets the backing service enforce record access. The tests include an anonymous caller, a guessed record ID, a forged tenant argument and guessed record IDs. The local download also checks concurrent calls. No live identity provider, financial data or external API is used.
Follow one request
- A production server verifies the caller's token. In-process tests supply trusted context directly; that is a fixture, not a token-verification test.
get_reportreads the tenant from verified claims. A tenant argument cannot grant access.- The server selects a synthetic backing API credential; it is absent from the input schema and result.
- The backing API checks the credential's tenant against the record owner.
- Only the permitted record reaches the client/model. Cross-tenant and missing records have the same error.
The server and its access checks
The browser's ordinary caller is anonymous. dataset_info is public; calling get_report from the Call tab is expected to fail. Open Tests for the permitted and refused paths.
import { Provider, PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
const credentials = { north: "fixture-north", south: "fixture-south" } as const;
const records = [
{ id: "R-1", tenant: "north", revenue: 1200 },
{ id: "R-2", tenant: "south", revenue: 900 },
];
@Provider({ name: "ReportApi" })
export class ReportApi {
read(id: string, credential: string) {
const tenant = Object.entries(credentials).find(([, value]) => value === credential)?.[0];
const record = records.find((item) => item.id === id && item.tenant === tenant);
if (!record) throw new PublicMcpError("Report not found", "NOT_FOUND");
return { id: record.id, revenue: record.revenue };
}
}
@Tool({ name: "dataset_info", description: "Describe the synthetic reporting dataset", inputSchema: {}, annotations: { readOnlyHint: true } })
export class DatasetInfo extends ToolContext {
async execute() { return { fixture: true, tenants: 2, records: 2 }; }
}
@Tool({
name: "get_report",
description: "Read one report owned by the authenticated tenant",
inputSchema: { id: z.string(), tenant: z.string().optional().describe("Untrusted hint; cannot grant access") },
annotations: { readOnlyHint: true },
})
export class GetReport extends ToolContext {
async execute({ id, tenant: hint }: { id: string; tenant?: string }) {
const tenant = this.auth.claims.tenant;
if (this.auth.isAnonymous || (tenant !== "north" && tenant !== "south")) {
this.fail(new PublicMcpError("A verified tenant is required", "AUTH_REQUIRED"));
}
if (hint && hint !== tenant) this.fail(new PublicMcpError("Report not found", "NOT_FOUND"));
return this.get(ReportApi).read(id, credentials[tenant as keyof typeof credentials]);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The browser runtime has a simplified request-context shim: these browser checks use sequential calls. The local project adds a Node concurrency check using real AsyncLocalStorage. Do not use the browser fixture as proof of concurrent principal isolation.
Run locally
Download the version-pinned reporting project. The archive contains the same files, app/server registration, dependency lockfile, local tests and instructions. It does not include browser edits or credentials. Use Node.js 24+:
tar -xzf tenant-reporting.tgz
cd tenant-reporting
npm ci
npm test
npm run devTests adapt the Playground checks to e2e/*.e2e.spec.ts with an explicit server entry. The default local endpoint is /mcp; dataset_info is public and get_report refuses callers without a verified tenant. The archive's README includes a protocol request and build commands.
Connect real identity and data
Replace the in-memory API and credential map with your service and secret store. Configure transparent auth for trusted issuer/audience/keys, then map the provider's verified tenant claim. Remote auth does not copy every upstream claim; read what remote tools see before relying on custom claims.
Add HTTP tests for missing/expired/wrong-audience tokens, two real tenant identities, backing API authorization and log redaction. Background jobs need their own explicit principal and credential lifecycle. This fixture demonstrates application checks; it does not certify tenant isolation for a deployment.
For production, choose a deployment target, configure secrets and persistence, then run permitted and refused requests against the deployed endpoint. Record the target, configuration and date; local builds do not prove a hosted deployment.