Tenant reporting

Checked against FrontMCP 1.10.10 on

Intermediate

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

  1. A production server verifies the caller's token. In-process tests supply trusted context directly; that is a fixture, not a token-verification test.
  2. get_report reads the tenant from verified claims. A tenant argument cannot grant access.
  3. The server selects a synthetic backing API credential; it is absent from the input schema and result.
  4. The backing API checks the credential's tenant against the record owner.
  5. 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.

Open
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 dev

Tests 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.

Report a docs issue · Report a security concern privately