# Tenant reporting

> A read-only reporting server with two tenants, server-side API credentials and allow/deny checks. Run the fixture and take it into a local project.

Source: https://frontmcp.dev/examples/tenant-reporting

[Checked against FrontMCP 1.10.10](https://frontmcp.dev/versions#how-pages-are-checked) on October 22, 2026

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.

<a id="follow-one-request"></a>

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

<a id="the-server-and-its-access-checks"></a>

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

Initial call configuration:

```js
{ tool: "dataset_info", arguments: {} }
```

```ts reports.ts active
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]);
  }
}
```

```ts reports.test.ts
import { create } from "@frontmcp/sdk";
import { test, expect } from "@frontmcp/testing";
import { GetReport, ReportApi } from "./reports";

test("an anonymous caller cannot read a report", async ({ mcp }) => {
  expect(await mcp.tools.call("get_report", { id: "R-1", tenant: "north" })).toBeError("AUTH_REQUIRED");
});

test("each tenant reads only its own record", async () => {
  const server = await create({ info: { name: "reports", version: "1.0.0" }, tools: [GetReport], providers: [ReportApi], logging: { enableConsole: false } });
  try {
    const north = { authContext: { user: { sub: "alice", tenant: "north" } } };
    const south = { authContext: { user: { sub: "bob", tenant: "south" } } };
    const a = await server.callTool("get_report", { id: "R-1" }, north);
    const b = await server.callTool("get_report", { id: "R-2" }, south);
    expect(a.structuredContent).toEqual({ id: "R-1", revenue: 1200 });
    expect(b.structuredContent).toEqual({ id: "R-2", revenue: 900 });
    for (const [args, caller] of [
      [{ id: "R-2" }, north], [{ id: "R-1" }, south],
      [{ id: "R-2", tenant: "south" }, north], [{ id: "missing" }, north],
    ] as const) {
      let rejected = false;
      try { await server.callTool("get_report", args, caller); }
      catch (error) { rejected = true; expect((error as Error).message).toBe("Report not found"); }
      expect(rejected).toBe(true);
    }
  } finally { await server.dispose(); }
});
```

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.

<a id="run-locally"></a>

## Run locally

[Download the version-pinned reporting project](https://frontmcp.dev/downloads/tenant-reporting.tgz). 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+:

```bash
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.

<a id="connect-real-identity-and-data"></a>

## Connect real identity and data

Replace the in-memory API and credential map with your service and secret store. Configure [transparent auth](https://frontmcp.dev/reference/auth/tokens) 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](https://frontmcp.dev/reference/auth/remote#what-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](https://frontmcp.dev/reference/deployment), 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.
