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,
isDelegated,
getDelegatedTenantIds,
getHomeTenantId,
enforceDelegatedTenant,
enforceDelegatedTenantForPage,
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', () => {
it('keeps concurrent contexts from bleeding into each other', async () => {
// 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,
delegatedTenantIds: result.delegatedTenantIds,
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(),
);
@@ -58,6 +58,17 @@ interface CallerContext {
delegatedTenantIds?: string[];
/** Strongest delegated role ("DelegatedAdmin" | "DelegatedReader"); informational (read-only server). */
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>();
@@ -114,6 +125,15 @@ export function isDelegated(): boolean {
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
* 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
* (and for a delegated caller the backend rescue bounds it to the managed set); on `/api/*` it's
* 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;
}
@@ -147,16 +180,23 @@ export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string):
export function enforceDelegatedTenant(tenantId?: string): string | undefined {
if (!isDelegated()) return tenantId;
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();
if (!t) {
throw new Error(
'tenantId is required: as a delegated (MSP) user you must name one of your managed tenants. ' +
`Managed tenants: ${allowed.join(', ')}`,
'tenantId is required: as a delegated (MSP) user you must name the tenant to query. ' +
`Managed tenants: ${allowed.join(', ')}` +
(home ? `; or your own home tenant: ${home}.` : '.'),
);
}
if (!allowed.includes(t)) {
if (!allowed.includes(t) && t !== home) {
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;
@@ -185,7 +225,17 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
*/
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
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 { createOAuthRouter } from './oauth.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';
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
// instructions with no mention of cross-tenant capability at all — the surface
// 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
// one of their managed tenants — no platform aggregate. Spell that out once here (the host surfaces it
// per connection) so the model passes tenantId up front instead of discovering it via a tool error.
// a tenant — no platform aggregate. Spell that out once here (the host surfaces it per connection) so the
// 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
? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.'
: delegated
? 'Scope: you are a delegated (MSP) administrator. Every query MUST name one of your managed tenants via ' +
`tenantId — there is no cross-tenant aggregate. Your managed tenants: ${managedTenants.join(', ')}.`
? 'Scope: you are a delegated (MSP) administrator. Every query MUST name a tenant via tenantId — there ' +
`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.';
return [
'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
* 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(
{ name: 'Autopilot-Monitor', version: SERVER_VERSION },
{ instructions: buildInstructions(ga, delegated, managedTenants) },
{ instructions: buildInstructions(ga, delegated, managedTenants, homeTenantId) },
);
registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated);
registerResources(s);
@@ -277,7 +279,10 @@ app.all('/mcp', async (req, res) => {
const ga = hasGlobalScope();
const delegated = !ga && isDelegated();
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.
res.on('close', () => {
@@ -244,7 +244,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
const tenantId = enforceDelegatedTenant(rawTenantId);
const params: Record<string, string | number | undefined> = { ...rest };
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)}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
@@ -294,7 +294,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
...buildGeoLocationParams({ locationKey, country, region, city }),
};
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
{ 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
// (JWT-scoped; tenantId ignored). Routing by role unlocks the MemberRead tenant endpoint
// 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 })}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
@@ -540,7 +540,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
try {
const { tenantId: rawTenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize, continuation } = args;
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(
basePath,
{ 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 = enforceDelegatedTenantForPage(rawTenantId, continuation);
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(
basePath,
{ 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,
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize, continuation } = args;
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(
basePath,
{ tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
@@ -917,7 +917,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
ruleType: args.ruleType,
};
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)}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
@@ -953,7 +953,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
try {
const { tenantId: rawTenantId, days, topN } = args;
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 })}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
@@ -995,7 +995,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
const tenantId = enforceDelegatedTenant(rawTenantId);
// GA → /api/global/metrics/app (tenantId is a filter); Tenant-Admin → /api/metrics/app
// (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 })}`);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
} 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.
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 })}`);
return toolResultText(paginateInventory(data, pageSize, continuation), MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) {
@@ -426,7 +426,7 @@ async function fetchSessionEvents(
// is reserved for the genuine "no keyword maps to any event type" case below.)
const candidates = ordered.slice(0, MAX_EVENT_TYPE_CANDIDATES);
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);
if (anySucceeded) {
// 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 };
if (tenantId) searchParams.tenantId = tenantId;
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 {
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.
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
// 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
// we still need to layer in deviceProperties as `prop.<key>` query params,
// 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
// the type so a typo is a clear error rather than a silent empty result.
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(
basePath,
{ eventType, tenantId, pageSize },
@@ -620,7 +620,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const params: Record<string, string | number | undefined> = { ...rest };
if (tenantId) params.tenantId = tenantId;
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([
apiFetch(`${prefix}/summary${q}`),
apiFetch(`${prefix}/app${q}`),
@@ -684,7 +684,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
// Normalize to canonical upper-case form (schema accepts case-insensitive).
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(
basePath,
{ 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
// 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.
return 'REQUIRED: name one of YOUR managed tenants (delegated/MSP). There is no cross-tenant aggregate ' +
'and no home-tenant default — every query must target a managed tenant.';
return 'REQUIRED: name the tenant to query — one of YOUR managed tenants (delegated/MSP), or your own ' +
'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;
}