fix(mcp): restore home-tenant access for delegated (MSP) admins

A delegated admin who is also a member/operator of their own home tenant
lost MCP access to it: any non-empty delegated scope flipped the whole
session to delegated cross-tenant routing, and enforceDelegatedTenant
rejected every tenant outside the managed set — which never contains the
home tenant (GetScopeAsync uses the home tid only as the Enterprise
entitlement gate, never adds it to the scope).

Fix is MCP-only (no backend change; the backend already authorizes the
home tenant via MemberRead and bounds the /api/global/* drill away from
it, so the home tenant must route to the JWT-bound member path):

- access-guard.ts: thread homeTenantId (JWT tid, lowercased) into the
  caller context.
- client.ts: getHomeTenantId(); enforceDelegatedTenant accepts
  managed union {home}; enforceDelegatedTenantForPage resolves a
  non-global tenant-path nextLink (no embedded tenantId) to home so
  page-2 re-selects the member path; pickGlobalOrTenantPath gains an
  effectiveTenantId arg and routes delegated+home to the tenant path.
- 16 pattern-A tool call sites pass the effective tenantId (not
  devices/blocked, which is platform-only).
- shared.ts / index.ts: descriptions + instructions surface the home
  tenant as an addressable target.

The home tenant is accepted optimistically at the MCP boundary; the
backend MemberRead stays authoritative (a non-member is denied on the
member path, so no leak). Continuation defense-in-depth preserved.

Verify: tsc clean; vitest 352 passed (+7 home-tenant cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-07 16:49:50 +02:00
parent 9dfa3b9bb9
commit 88a432dcd4
8 changed files with 160 additions and 33 deletions
@@ -18,6 +18,7 @@ import {
hasCrossTenantRouting, hasCrossTenantRouting,
isDelegated, isDelegated,
getDelegatedTenantIds, getDelegatedTenantIds,
getHomeTenantId,
enforceDelegatedTenant, enforceDelegatedTenant,
enforceDelegatedTenantForPage, enforceDelegatedTenantForPage,
getCurrentToken, getCurrentToken,
@@ -152,6 +153,72 @@ describe('delegated (MSP) routing + tenant enforcement', () => {
}); });
}); });
describe('delegated (MSP) home-tenant access — member of own tenant + delegated elsewhere', () => {
const MANAGED = 'aaaa-1111';
const HOME = 'home-9999';
it('exposes the home tenant via getHomeTenantId (lowercased at the guard)', () => {
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
expect(getHomeTenantId()).toBe(HOME);
expect(getDelegatedTenantIds()).toEqual([MANAGED]); // home is NOT part of the managed set
});
});
it('enforceDelegatedTenant accepts the home tenant in addition to the managed set', () => {
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
expect(enforceDelegatedTenant(HOME)).toBe(HOME);
expect(enforceDelegatedTenant('HOME-9999')).toBe(HOME); // case-insensitive
expect(enforceDelegatedTenant(MANAGED)).toBe(MANAGED); // managed still works
});
});
it('routes a delegated caller reading their HOME tenant to the tenant (member) path', () => {
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
// Home tenant → member path (the /api/global/* drill is bounded to the managed set → empty).
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, HOME)).toBe(TENANT);
// Managed tenant → cross-tenant global path.
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, MANAGED)).toBe(GLOBAL);
// No effective tenant passed → default cross-tenant routing (unchanged).
expect(pickGlobalOrTenantPath(GLOBAL, TENANT)).toBe(GLOBAL);
});
});
it('still throws for a tenant that is neither managed nor the home tenant', () => {
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
expect(() => enforceDelegatedTenant('cccc-3333')).toThrow(/Not authorized for tenant/);
});
});
it('paginates a home-tenant search: a non-global tenant-path nextLink resolves to home (re-selects the tenant path)', () => {
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
// Page 2 of a home-tenant search: the backend nextLink is JWT-bound (no tenantId), path is /api/* not /api/global/*.
const homeNextLink = '/api/search/sessions?continuation=abc&pageSize=200';
const resolved = enforceDelegatedTenantForPage(undefined, homeNextLink);
expect(resolved).toBe(HOME);
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, resolved)).toBe(TENANT);
// A managed-tenant page-2 (global nextLink carrying tenantId) still routes cross-tenant.
const managedNextLink = `/api/global/search/sessions?continuation=abc&tenantId=${MANAGED}`;
const resolvedManaged = enforceDelegatedTenantForPage(undefined, managedNextLink);
expect(resolvedManaged).toBe(MANAGED);
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, resolvedManaged)).toBe(GLOBAL);
});
});
it('a hand-crafted non-global continuation embedding a foreign tenantId is still rejected (defense in depth)', () => {
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
expect(() => enforceDelegatedTenantForPage(undefined, '/api/raw/events?tenantId=cccc-3333')).toThrow(/Not authorized/);
});
});
it('without a home tenant in context, the old strict behavior holds (no phantom home access)', () => {
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED] }, () => {
expect(getHomeTenantId()).toBeUndefined();
expect(() => enforceDelegatedTenant(HOME)).toThrow(/Not authorized for tenant/);
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, HOME)).toBe(GLOBAL); // no home carve-out
});
});
});
describe('runWithCaller — context isolation', () => { describe('runWithCaller — context isolation', () => {
it('keeps concurrent contexts from bleeding into each other', async () => { it('keeps concurrent contexts from bleeding into each other', async () => {
// Interleave two async callers on the event loop; each must continue to see // Interleave two async callers on the event loop; each must continue to see
@@ -492,6 +492,10 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
isGlobalReader: result.isGlobalReader, isGlobalReader: result.isGlobalReader,
delegatedTenantIds: result.delegatedTenantIds, delegatedTenantIds: result.delegatedTenantIds,
delegatedRole: result.delegatedRole, delegatedRole: result.delegatedRole,
// Home tenant from the JWT `tid` (lowercased). Lets a delegated (MSP) admin who is also a member
// of their own home tenant keep reading it via the tenant-scoped member path — see client.ts
// enforceDelegatedTenant / pickGlobalOrTenantPath. Harmless for GA / plain tenant callers.
homeTenantId: claims.tid?.toLowerCase(),
}, },
() => next(), () => next(),
); );
@@ -58,6 +58,17 @@ interface CallerContext {
delegatedTenantIds?: string[]; delegatedTenantIds?: string[];
/** Strongest delegated role ("DelegatedAdmin" | "DelegatedReader"); informational (read-only server). */ /** Strongest delegated role ("DelegatedAdmin" | "DelegatedReader"); informational (read-only server). */
delegatedRole?: string; delegatedRole?: string;
/**
* The caller's home tenant (lowercase JWT `tid`). Set for every caller (harmless for GA / plain tenant
* users, who never consult it). Its purpose is the delegated (MSP) case: a delegated caller who is ALSO
* a member/operator of their OWN home tenant must keep reading it over MCP. That access is member-based
* (JWT-bound `/api/*`), NOT a delegated grant — the home tenant is not in `delegatedTenantIds` and the
* `/api/global/*` drill is bounded to the managed set (would return empty for it). So the home tenant is
* accepted by `enforceDelegatedTenant` and routed to the tenant-scoped member path by
* `pickGlobalOrTenantPath`. The backend is authoritative: a delegated caller who is NOT a member of their
* home tenant is simply denied there (MemberRead), so accepting the id here never leaks.
*/
homeTenantId?: string;
} }
const callerStore = new AsyncLocalStorage<CallerContext>(); const callerStore = new AsyncLocalStorage<CallerContext>();
@@ -114,6 +125,15 @@ export function isDelegated(): boolean {
return (callerStore.getStore()?.delegatedTenantIds?.length ?? 0) > 0; return (callerStore.getStore()?.delegatedTenantIds?.length ?? 0) > 0;
} }
/**
* The current caller's home tenant (lowercase JWT `tid`), or undefined when no context is active / the
* token carried no tid. Used by the delegated (MSP) routing helpers so a delegated admin who is also a
* member of their home tenant can still read it via the tenant-scoped member path.
*/
export function getHomeTenantId(): string | undefined {
return callerStore.getStore()?.homeTenantId;
}
/** /**
* True when the caller may address the cross-tenant `/api/global/*` paths. That is the union of platform * True when the caller may address the cross-tenant `/api/global/*` paths. That is the union of platform
* scope (GA / read-only Global Reader, who may omit tenantId for an aggregate) and delegated scope (MSP, * scope (GA / read-only Global Reader, who may omit tenantId for an aggregate) and delegated scope (MSP,
@@ -130,8 +150,21 @@ export function hasCrossTenantRouting(): boolean {
* variant. The same `tenantId` query param is meaningful in both worlds — on `/api/global/*` it filters * variant. The same `tenantId` query param is meaningful in both worlds — on `/api/global/*` it filters
* (and for a delegated caller the backend rescue bounds it to the managed set); on `/api/*` it's * (and for a delegated caller the backend rescue bounds it to the managed set); on `/api/*` it's
* informational (backend resolves from JWT). * informational (backend resolves from JWT).
*
* `effectiveTenantId` (the already-`enforceDelegatedTenant`-resolved target) carves out ONE exception: a
* delegated (MSP) caller reading their OWN home tenant is routed to the tenant-scoped MEMBER path, because
* their authorization there is member/operator (JWT-bound `/api/*`), NOT a delegated grant — and the
* `/api/global/*` drill is bounded to the managed set, so it would return an EMPTY page for the home
* tenant. Omitting the arg preserves the old behavior for every non-home / non-delegated call.
*/ */
export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string): string { export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string, effectiveTenantId?: string): string {
if (
isDelegated() &&
effectiveTenantId &&
effectiveTenantId.toLowerCase() === getHomeTenantId()
) {
return tenantPath;
}
return hasCrossTenantRouting() ? globalPath : tenantPath; return hasCrossTenantRouting() ? globalPath : tenantPath;
} }
@@ -147,16 +180,23 @@ export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string):
export function enforceDelegatedTenant(tenantId?: string): string | undefined { export function enforceDelegatedTenant(tenantId?: string): string | undefined {
if (!isDelegated()) return tenantId; if (!isDelegated()) return tenantId;
const allowed = getDelegatedTenantIds()!; // non-empty when isDelegated() const allowed = getDelegatedTenantIds()!; // non-empty when isDelegated()
// The caller's own home tenant is an ADDITIONAL valid target: a delegated admin who is also a member of
// their home tenant reads it via the member path (see pickGlobalOrTenantPath). It is deliberately NOT in
// the managed set (that is other tenants they administer). Accepting it here is safe even for a delegated
// caller who is NOT a member — the backend's MemberRead denies them on the tenant path, so no leak.
const home = getHomeTenantId();
const t = (tenantId ?? '').toLowerCase(); const t = (tenantId ?? '').toLowerCase();
if (!t) { if (!t) {
throw new Error( throw new Error(
'tenantId is required: as a delegated (MSP) user you must name one of your managed tenants. ' + 'tenantId is required: as a delegated (MSP) user you must name the tenant to query. ' +
`Managed tenants: ${allowed.join(', ')}`, `Managed tenants: ${allowed.join(', ')}` +
(home ? `; or your own home tenant: ${home}.` : '.'),
); );
} }
if (!allowed.includes(t)) { if (!allowed.includes(t) && t !== home) {
throw new Error( throw new Error(
`Not authorized for tenant ${tenantId}. Your managed tenants: ${allowed.join(', ')}`, `Not authorized for tenant ${tenantId}. Your managed tenants: ${allowed.join(', ')}` +
(home ? ` (or your own home tenant ${home}).` : '.'),
); );
} }
return t; return t;
@@ -185,7 +225,17 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
*/ */
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined { export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
if (!isDelegated()) return tenantId; if (!isDelegated()) return tenantId;
return enforceDelegatedTenant(tenantIdFromNextLink(continuation) ?? tenantId); const linkTenant = tenantIdFromNextLink(continuation);
// A follow-up page whose continuation is a full backend nextLink to a TENANT-scoped (non-global) path
// with NO embedded tenantId is inherently JWT-bound — it can only ever serve the caller's own home
// tenant (page 1 already routed there via pickGlobalOrTenantPath). Resolve it TO the home tenant so the
// follow-up page re-selects the same tenant path (matching page 1) without a managed-tenant check. A
// hand-crafted continuation that DOES embed a tenantId still flows through enforceDelegatedTenant below,
// so the defense-in-depth validation is preserved.
if (continuation?.startsWith('/api/') && !continuation.startsWith('/api/global/') && !linkTenant) {
return getHomeTenantId() ?? tenantId;
}
return enforceDelegatedTenant(linkTenant ?? tenantId);
} }
/** /**
@@ -16,7 +16,7 @@ import { validatePrecomputedIndex } from './precomputed-index.js';
import { buildEventTypeSearchDocs } from './resource-catalog.js'; import { buildEventTypeSearchDocs } from './resource-catalog.js';
import { createOAuthRouter } from './oauth.js'; import { createOAuthRouter } from './oauth.js';
import { accessGuard } from './access-guard.js'; import { accessGuard } from './access-guard.js';
import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds } from './client.js'; import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds, getHomeTenantId } from './client.js';
import { API_BASE_URL } from './config.js'; import { API_BASE_URL } from './config.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -137,15 +137,17 @@ console.error(`Event-type index ready: ${eventTypeIndex.name} — ${eventTypeInd
// Reader) sees the cross-tenant scope hint. A normal tenant user gets // Reader) sees the cross-tenant scope hint. A normal tenant user gets
// instructions with no mention of cross-tenant capability at all — the surface // instructions with no mention of cross-tenant capability at all — the surface
// is scoped to what they can actually do. // is scoped to what they can actually do.
function buildInstructions(ga: boolean, delegated: boolean, managedTenants: string[]): string { function buildInstructions(ga: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string): string {
// Delegated (MSP) callers get a tenant-bounded surface: cross-tenant ROUTING, but every query MUST name // Delegated (MSP) callers get a tenant-bounded surface: cross-tenant ROUTING, but every query MUST name
// one of their managed tenants — no platform aggregate. Spell that out once here (the host surfaces it // a tenant — no platform aggregate. Spell that out once here (the host surfaces it per connection) so the
// per connection) so the model passes tenantId up front instead of discovering it via a tool error. // model passes tenantId up front instead of discovering it via a tool error. A delegated admin who is
// also a member of their own home tenant may name it too (routed to the member path), so surface it.
const scopeLine = ga const scopeLine = ga
? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.' ? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.'
: delegated : delegated
? 'Scope: you are a delegated (MSP) administrator. Every query MUST name one of your managed tenants via ' + ? 'Scope: you are a delegated (MSP) administrator. Every query MUST name a tenant via tenantId — there ' +
`tenantId — there is no cross-tenant aggregate. Your managed tenants: ${managedTenants.join(', ')}.` `is no cross-tenant aggregate. Your managed tenants: ${managedTenants.join(', ')}.` +
(homeTenantId ? ` If you are a member of your own home tenant (${homeTenantId}), you may query it by naming it too.` : '')
: 'Scope: all queries are automatically limited to your tenant.'; : 'Scope: all queries are automatically limited to your tenant.';
return [ return [
'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.', 'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.',
@@ -165,10 +167,10 @@ function buildInstructions(ga: boolean, delegated: boolean, managedTenants: stri
* role: a non-Global-Admin never sees GA-only tools or any cross-tenant / GA * role: a non-Global-Admin never sees GA-only tools or any cross-tenant / GA
* wording — reducing both confusion and attack surface. * wording — reducing both confusion and attack surface.
*/ */
function createMcpServer(ga: boolean, strictGa: boolean, delegated: boolean, managedTenants: string[]): McpServer { function createMcpServer(ga: boolean, strictGa: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string): McpServer {
const s = new McpServer( const s = new McpServer(
{ name: 'Autopilot-Monitor', version: SERVER_VERSION }, { name: 'Autopilot-Monitor', version: SERVER_VERSION },
{ instructions: buildInstructions(ga, delegated, managedTenants) }, { instructions: buildInstructions(ga, delegated, managedTenants, homeTenantId) },
); );
registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated); registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated);
registerResources(s); registerResources(s);
@@ -277,7 +279,10 @@ app.all('/mcp', async (req, res) => {
const ga = hasGlobalScope(); const ga = hasGlobalScope();
const delegated = !ga && isDelegated(); const delegated = !ga && isDelegated();
const managedTenants = delegated ? (getDelegatedTenantIds() ?? []) : []; const managedTenants = delegated ? (getDelegatedTenantIds() ?? []) : [];
const server = createMcpServer(ga, isGlobalAdmin(), delegated, managedTenants); // Home tenant is only surfaced to a delegated caller (for the "you may also query your home tenant" hint);
// GA / plain tenant users don't need it in their instructions.
const homeTenantId = delegated ? getHomeTenantId() : undefined;
const server = createMcpServer(ga, isGlobalAdmin(), delegated, managedTenants, homeTenantId);
// Guarantee cleanup once the response is done, even on client disconnect. // Guarantee cleanup once the response is done, even on client disconnect.
res.on('close', () => { res.on('close', () => {
@@ -244,7 +244,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
const tenantId = enforceDelegatedTenant(rawTenantId); const tenantId = enforceDelegatedTenant(rawTenantId);
const params: Record<string, string | number | undefined> = { ...rest }; const params: Record<string, string | number | undefined> = { ...rest };
if (tenantId) params.tenantId = tenantId; if (tenantId) params.tenantId = tenantId;
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics'); const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
const data = await apiFetch(`${prefix}/geographic${buildQuery(params)}`); const data = await apiFetch(`${prefix}/geographic${buildQuery(params)}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) { } catch (error: unknown) {
@@ -294,7 +294,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
...buildGeoLocationParams({ locationKey, country, region, city }), ...buildGeoLocationParams({ locationKey, country, region, city }),
}; };
if (tenantId) params.tenantId = tenantId; if (tenantId) params.tenantId = tenantId;
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics'); const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
const data = await apiFetch(`${prefix}/geographic/sessions${buildQuery(params)}`) as const data = await apiFetch(`${prefix}/geographic/sessions${buildQuery(params)}`) as
{ success?: boolean; sessions?: unknown[]; totalCount?: number }; { success?: boolean; sessions?: unknown[]; totalCount?: number };
@@ -448,7 +448,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
// GA → /api/global/metrics/usage (tenantId is a filter); Tenant-Admin → /api/metrics/usage // GA → /api/global/metrics/usage (tenantId is a filter); Tenant-Admin → /api/metrics/usage
// (JWT-scoped; tenantId ignored). Routing by role unlocks the MemberRead tenant endpoint // (JWT-scoped; tenantId ignored). Routing by role unlocks the MemberRead tenant endpoint
// for non-GA callers instead of a blanket 403. // for non-GA callers instead of a blanket 403.
const path = pickGlobalOrTenantPath('/api/global/metrics/usage', '/api/metrics/usage'); const path = pickGlobalOrTenantPath('/api/global/metrics/usage', '/api/metrics/usage', tenantId);
const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`); const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) { } catch (error: unknown) {
@@ -540,7 +540,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
try { try {
const { tenantId: rawTenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize, continuation } = args; const { tenantId: rawTenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize, continuation } = args;
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation); const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
const basePath = pickGlobalOrTenantPath('/api/global/audit/logs', '/api/audit/logs'); const basePath = pickGlobalOrTenantPath('/api/global/audit/logs', '/api/audit/logs', tenantId);
const path = followNextLink( const path = followNextLink(
basePath, basePath,
{ tenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize }, { tenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize },
@@ -686,7 +686,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
const { tenantId: rawTenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize, continuation } = args; const { tenantId: rawTenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize, continuation } = args;
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation); const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
if (eventType) assertKnownEventType(eventType); if (eventType) assertKnownEventType(eventType);
const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events'); const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events', tenantId);
const path = followNextLink( const path = followNextLink(
basePath, basePath,
{ tenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize }, { tenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize },
@@ -755,7 +755,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild, imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild,
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize, continuation } = args; geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize, continuation } = args;
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation); const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions'); const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions', tenantId);
const path = followNextLink( const path = followNextLink(
basePath, basePath,
{ tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix, { tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
@@ -917,7 +917,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
ruleType: args.ruleType, ruleType: args.ruleType,
}; };
if (tenantId) params.tenantId = tenantId; if (tenantId) params.tenantId = tenantId;
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics'); const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`); const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) { } catch (error: unknown) {
@@ -953,7 +953,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
try { try {
const { tenantId: rawTenantId, days, topN } = args; const { tenantId: rawTenantId, days, topN } = args;
const tenantId = enforceDelegatedTenant(rawTenantId); const tenantId = enforceDelegatedTenant(rawTenantId);
const prefix = pickGlobalOrTenantPath('/api/global/metrics/vulnerability', '/api/metrics/vulnerability'); const prefix = pickGlobalOrTenantPath('/api/global/metrics/vulnerability', '/api/metrics/vulnerability', tenantId);
const data = await apiFetch(`${prefix}${buildQuery({ tenantId, days, topN })}`); const data = await apiFetch(`${prefix}${buildQuery({ tenantId, days, topN })}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) { } catch (error: unknown) {
@@ -995,7 +995,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
const tenantId = enforceDelegatedTenant(rawTenantId); const tenantId = enforceDelegatedTenant(rawTenantId);
// GA → /api/global/metrics/app (tenantId is a filter); Tenant-Admin → /api/metrics/app // GA → /api/global/metrics/app (tenantId is a filter); Tenant-Admin → /api/metrics/app
// (JWT-scoped; tenantId ignored). // (JWT-scoped; tenantId ignored).
const path = pickGlobalOrTenantPath('/api/global/metrics/app', '/api/metrics/app'); const path = pickGlobalOrTenantPath('/api/global/metrics/app', '/api/metrics/app', tenantId);
const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`); const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) { } catch (error: unknown) {
@@ -1093,7 +1093,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
)); ));
} }
// Endpoint returns the whole inventory in one shot, so page it client-side. // Endpoint returns the whole inventory in one shot, so page it client-side.
const path = pickGlobalOrTenantPath('/api/vulnerability/software-inventory', '/api/metrics/software-inventory'); const path = pickGlobalOrTenantPath('/api/vulnerability/software-inventory', '/api/metrics/software-inventory', tenantId);
const data = await apiFetch(`${path}${buildQuery({ tenantId })}`); const data = await apiFetch(`${path}${buildQuery({ tenantId })}`);
return toolResultText(paginateInventory(data, pageSize, continuation), MAX_RESULT_SIZE_CHARS.adminStream); return toolResultText(paginateInventory(data, pageSize, continuation), MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) { } catch (error: unknown) {
@@ -426,7 +426,7 @@ async function fetchSessionEvents(
// is reserved for the genuine "no keyword maps to any event type" case below.) // is reserved for the genuine "no keyword maps to any event type" case below.)
const candidates = ordered.slice(0, MAX_EVENT_TYPE_CANDIDATES); const candidates = ordered.slice(0, MAX_EVENT_TYPE_CANDIDATES);
const candidatesDropped = ordered.length > candidates.length; const candidatesDropped = ordered.length > candidates.length;
const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events'); const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events', tenantId);
const { events, truncated, anySucceeded } = await fetchEventsViaIndex(candidates, basePath, tenantId, budget); const { events, truncated, anySucceeded } = await fetchEventsViaIndex(candidates, basePath, tenantId, budget);
if (anySucceeded) { if (anySucceeded) {
// No event appears in more than one single-type walk, so the only // No event appears in more than one single-type walk, so the only
@@ -444,7 +444,7 @@ async function fetchSessionEvents(
const searchParams: Record<string, string | number | undefined> = { status: 'Failed', pageSize: LEGACY_FALLBACK_SESSION_CAP }; const searchParams: Record<string, string | number | undefined> = { status: 'Failed', pageSize: LEGACY_FALLBACK_SESSION_CAP };
if (tenantId) searchParams.tenantId = tenantId; if (tenantId) searchParams.tenantId = tenantId;
const searchQ = buildQuery(searchParams); const searchQ = buildQuery(searchParams);
const searchBase = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions'); const searchBase = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions', tenantId);
const sessions = await apiFetch(`${searchBase}${searchQ}`) as { const sessions = await apiFetch(`${searchBase}${searchQ}`) as {
sessions?: Array<{ sessionId?: string }>; sessions?: Array<{ sessionId?: string }>;
}; };
@@ -144,7 +144,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
// continuation nextLink. No-op for GA/Reader/tenant users. // continuation nextLink. No-op for GA/Reader/tenant users.
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation); const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
// GA → /api/global/search/sessions (tenantId is filter); Tenant-Admin → /api/search/sessions (JWT-bound). // GA → /api/global/search/sessions (tenantId is filter); Tenant-Admin → /api/search/sessions (JWT-bound).
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions'); const basePath = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions', tenantId);
// followNextLink handles full nextLink paths verbatim. For first-page calls // followNextLink handles full nextLink paths verbatim. For first-page calls
// we still need to layer in deviceProperties as `prop.<key>` query params, // we still need to layer in deviceProperties as `prop.<key>` query params,
// which followNextLink doesn't know about — so build the param record and // which followNextLink doesn't know about — so build the param record and
@@ -202,7 +202,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
// so an empty page never carries a nextLink — no auto-exhaust needed. But validate // so an empty page never carries a nextLink — no auto-exhaust needed. But validate
// the type so a typo is a clear error rather than a silent empty result. // the type so a typo is a clear error rather than a silent empty result.
assertKnownEventType(eventType); assertKnownEventType(eventType);
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-event', '/api/search/sessions-by-event'); const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-event', '/api/search/sessions-by-event', tenantId);
const path = followNextLink( const path = followNextLink(
basePath, basePath,
{ eventType, tenantId, pageSize }, { eventType, tenantId, pageSize },
@@ -620,7 +620,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const params: Record<string, string | number | undefined> = { ...rest }; const params: Record<string, string | number | undefined> = { ...rest };
if (tenantId) params.tenantId = tenantId; if (tenantId) params.tenantId = tenantId;
const q = buildQuery(params); const q = buildQuery(params);
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics'); const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
const [summaryRes, appsRes] = await Promise.allSettled([ const [summaryRes, appsRes] = await Promise.allSettled([
apiFetch(`${prefix}/summary${q}`), apiFetch(`${prefix}/summary${q}`),
apiFetch(`${prefix}/app${q}`), apiFetch(`${prefix}/app${q}`),
@@ -684,7 +684,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation); const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
// Normalize to canonical upper-case form (schema accepts case-insensitive). // Normalize to canonical upper-case form (schema accepts case-insensitive).
const normalizedCve = cveId.toUpperCase(); const normalizedCve = cveId.toUpperCase();
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-cve', '/api/search/sessions-by-cve'); const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-cve', '/api/search/sessions-by-cve', tenantId);
const path = followNextLink( const path = followNextLink(
basePath, basePath,
{ cveId: normalizedCve, tenantId, minCvssScore, overallRisk, pageSize }, { cveId: normalizedCve, tenantId, minCvssScore, overallRisk, pageSize },
@@ -58,8 +58,9 @@ export function tenantIdDescription(ga: boolean, delegated: boolean, gaText: str
// pages behave differently — backend-nextLink pagers re-send tenantId inside the continuation, but // pages behave differently — backend-nextLink pagers re-send tenantId inside the continuation, but
// offset-based client-side pagers (geo-offset:/inv-offset:) still need it re-passed every page. The // offset-based client-side pagers (geo-offset:/inv-offset:) still need it re-passed every page. The
// per-tool `continuation` arg description owns those mechanics; here we only state the invariant. // per-tool `continuation` arg description owns those mechanics; here we only state the invariant.
return 'REQUIRED: name one of YOUR managed tenants (delegated/MSP). There is no cross-tenant aggregate ' + return 'REQUIRED: name the tenant to query — one of YOUR managed tenants (delegated/MSP), or your own ' +
'and no home-tenant default — every query must target a managed tenant.'; 'home tenant if you are a member of it. There is no cross-tenant aggregate and no implicit default — ' +
'every query must name a specific tenant.';
} }
return ga ? gaText : tenantText; return ga ? gaText : tenantText;
} }