From 88a432dcd42e6b2564e7c24a2dc7f446f6c34f71 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 7 Jul 2026 16:49:50 +0200 Subject: [PATCH] fix(mcp): restore home-tenant access for delegated (MSP) admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/__tests__/client-routing.test.ts | 67 +++++++++++++++++++ .../autopilot-monitor-mcp/src/access-guard.ts | 4 ++ .../autopilot-monitor-mcp/src/client.ts | 62 +++++++++++++++-- .../autopilot-monitor-mcp/src/index.ts | 23 ++++--- .../autopilot-monitor-mcp/src/tools/admin.ts | 20 +++--- .../autopilot-monitor-mcp/src/tools/search.ts | 4 +- .../src/tools/sessions.ts | 8 +-- .../autopilot-monitor-mcp/src/tools/shared.ts | 5 +- 8 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/client-routing.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/client-routing.test.ts index 15479672..fe631476 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/client-routing.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/client-routing.test.ts @@ -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 diff --git a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts index b37cf3f4..dc0f5164 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts @@ -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(), ); diff --git a/src/McpServer/autopilot-monitor-mcp/src/client.ts b/src/McpServer/autopilot-monitor-mcp/src/client.ts index a65092a6..6d53c7c4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/client.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/client.ts @@ -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(); @@ -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); } /** diff --git a/src/McpServer/autopilot-monitor-mcp/src/index.ts b/src/McpServer/autopilot-monitor-mcp/src/index.ts index bb3cc535..b1db5269 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/index.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/index.ts @@ -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', () => { diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index c9b56d7b..e341cafe 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -244,7 +244,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo const tenantId = enforceDelegatedTenant(rawTenantId); const params: Record = { ...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) { diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts index e2e6ecef..6da575f8 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts @@ -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 = { 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 }>; }; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 5b8c113b..d8dd2d79 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -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.` 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 = { ...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 }, diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts index ee46650a..4a8ea269 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts @@ -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; }