Multi-tenant / resource-scoped paths
A walkthrough of one mainstream scenario end-to-end: a SaaS API scoped by a path
segment — /tenants/:tenantId/widgets/... — where the same tenantId gates
access in auth and is injected into tool calls (one API key → many tenants).
Every piece below already exists; this recipe wires them into one flow. Each step links to its reference section.
1. The contract — tenant-agnostic
Endpoints know nothing about the tenant; the path segment lives on the group, not the endpoint:
export const widgets = defineContract(
{ prefix: 'widgets', scope: 'tenant' },
{
list: { method: 'GET', path: '/', desc: 'List widgets', output: WidgetList },
create: { method: 'POST', path: '/', desc: 'Create a widget', input: NewWidget, output: Widget },
},
)
2. The server — a param prefix
Mount the service under a group whose pathPrefix carries :tenantId. The
matched value is on the context root as ctx.tenantId
(details):
createServer({
groups: [
{ pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: authHook } },
],
})
// → /tenants/:tenantId/widgets
With more than one scope, let scopePrefixes map scope → prefix and mount
the flat services list — no hand-partitioning, the mapping lives in one place
(details, → ADR 0024):
createServer({
services, // mixed scopes, listed once
scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
hooks: { beforeHandle: authHook },
})
// `tenant`-scoped → /tenants/:tenantId/..., `project` → /projects/:projectId/..., the rest flat
Handlers read ctx.tenantId (a raw string — narrow it). To get it typed inside
ctx.params, add tenantId to the endpoint’s params schema (a z.strictObject
that omits it will reject the request). When each scope guarantees different
injected fields, call createImplement<TenantCtx>() / createImplement<BaseCtx>()
once per scope and implement each contract with the matching factory — every
handler is typed to its scope, with no superset context that lies about a
tenantId a public handler never has.
3. Auth — gate the tenant in the path
The tenant scope rule reads the prefix param and checks access; inject puts
the identity and derived facts on ctx
(details):
const authHook = createAuthHook<User>({
resolve: sessionResolver,
rules: {
public: 'public',
tenant: (user, ctx) => userCanAccessTenant(user.id, String(ctx.tenantId)),
} satisfies Record<Scope, AuthRule<User>>,
inject: (ctx, user) => { ctx.user = user },
})
4. The client — a per-tenant pathPrefix
createClient’s third argument prepends the tenant segment; stripPrefixKeys
keeps tenantId out of the body/query
(details):
const widgetsApi = createClient(widgets, http, {
pathPrefix: (args) => `tenants/${args.tenantId}/`,
stripPrefixKeys: ['tenantId'],
})
widgetsApi.list({ tenantId: 't_123' }) // GET /tenants/t_123/widgets
stripPrefixKeys (a const tuple) also makes the consumed keys typed,
required args on every method — tenantId above is type-checked, not a runtime
surprise — so no hand-written scoped-client wrapper is needed (→ ADR 0025).
5. The AI surface — extend injects the tenant
One API key serves every tenant: the model passes tenantId per call, resolve
validates it and puts it on ctx — so the same handler that reads ctx.tenantId
over HTTP serves the tool call
(details):
createMcpHandler({
serverInfo, auth,
services: [widgetsService],
lifecycle: { beforeHandle: authHook },
extend: {
schema: { tenantId: z.string().describe('Tenant to act on') },
resolve: async ({ tenantId }) => ({ tenantId: String(tenantId) }),
filter: (_s, m) => m.scope === 'tenant',
},
})
The through-line
ctx.tenantId is the single join point: the prefix param puts it there on
HTTP, extend’s resolve puts it there on a tool call. Handlers and the auth
rule read it the same way on both surfaces — one contract, every surface, no
per-transport tenant plumbing.