MCP: list the rule dry-run tools only for roles that can run them

GET /api/auth/mcp reports the caller's client-capped member role in the token's tenant
(tenantRole; reported, never a grant). The MCP server registers test_analyze_rule and
test_log_pattern only for a platform role or a Tenant Admin: their routes are
TenantAdminOrGlobalReader, so Operators and Viewers only ever got a 403. validate_rule,
author-rule and the authoring guide hand a draft to a Tenant Admin otherwise, and
investigate-failed-session and debug-session end by drafting a rule for a cause that can
recur. Catalog ratchet lowered for tenant users (51,000) and delegated callers (55,500).

D-348

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-12 00:18:21 +02:00
parent d0f62a6aaf
commit 20ae3d8c09
22 changed files with 275 additions and 61 deletions
@@ -219,6 +219,32 @@ public class InfrastructureWireParityTests
});
}
[Fact]
public void CheckMcpAccessResponse_matches_the_member_shape()
{
// A tenant member reports the role of the token's tenant last; the MCP server gates the rule
// dry-run tools on it.
var payload = new Dictionary<string, object?>
{
["allowed"] = true,
["upn"] = "admin@contoso.com",
["accessGrant"] = "AllMembers",
["reason"] = "",
["tenantRole"] = "Admin",
};
AssertParity(
payload,
new CheckMcpAccessResponse
{
Allowed = true,
Upn = "admin@contoso.com",
AccessGrant = "AllMembers",
Reason = "",
TenantRole = "Admin",
});
}
[Fact]
public void CheckMcpAccessResponse_matches_the_denied_shape()
{
@@ -244,6 +270,7 @@ public class InfrastructureWireParityTests
GlobalRole = null,
DelegatedTenantIds = null,
DelegatedRole = null,
TenantRole = null,
});
}
@@ -76,6 +76,20 @@ public class McpUserServiceAllMembersTests
Assert.Equal("AllMembers", result.AccessGrant);
Assert.False(result.IsGlobalAdmin);
Assert.Null(result.GlobalRole);
Assert.Equal(role, result.TenantRole);
}
[Fact]
public async Task ClientCappedMember_ReportsViewer()
{
// The application cap applies to the reported role exactly as to every tenant endpoint, so a person
// reached through an admitted foreign client is never offered the Admin-only dry-run tools.
MemberOf(HomeTenant, Constants.TenantRoles.Admin);
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid, appRoles: null, clientCapped: true);
Assert.True(result.IsAllowed);
Assert.Equal(Constants.TenantRoles.Viewer, result.TenantRole);
}
[Fact]
@@ -144,13 +158,15 @@ public class McpUserServiceAllMembersTests
Assert.True(result.IsAllowed);
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, result.AccessGrant);
Assert.True(result.IsGlobalAdmin);
Assert.Null(result.TenantRole);
Assert.Empty(_memberRoles.Lookups);
}
[Fact]
public async Task DelegatedAdmin_WithoutHomeMembership_IsAllowed()
{
// An MSP whose own home tenant is not a customer: no home role, but a curated delegated scope.
// An MSP whose own home tenant is not a customer: no home role, but a curated delegated scope. The
// home role is looked up only to report it — it grants nothing here.
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
.ReturnsAsync(new DelegatedScope(new Dictionary<string, string>
{
@@ -162,7 +178,8 @@ public class McpUserServiceAllMembersTests
Assert.True(result.IsAllowed);
Assert.Equal("DelegatedAdmin", result.AccessGrant);
Assert.Equal(new[] { ManagedTenant }, result.DelegatedTenantIds);
Assert.Empty(_memberRoles.Lookups);
Assert.Null(result.TenantRole);
Assert.Equal(new[] { (HomeTenant, Upn) }, _memberRoles.Lookups);
}
[Fact]
@@ -175,7 +192,21 @@ public class McpUserServiceAllMembersTests
Assert.True(result.IsAllowed);
Assert.Equal("McpUser", result.AccessGrant);
Assert.Empty(_memberRoles.Lookups);
Assert.Null(result.TenantRole);
}
[Fact]
public async Task EnabledMcpUsersRow_ReportsTheHomeRole()
{
MemberOf(HomeTenant, Constants.TenantRoles.Admin);
_adminRepo.Setup(x => x.GetMcpUserAsync(Upn))
.ReturnsAsync(new McpUserEntry { Upn = Upn, IsEnabled = true });
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
Assert.True(result.IsAllowed);
Assert.Equal("McpUser", result.AccessGrant);
Assert.Equal(Constants.TenantRoles.Admin, result.TenantRole);
}
[Fact]
@@ -273,6 +273,7 @@ public class McpUserFunction
GlobalRole = string.IsNullOrEmpty(result.GlobalRole) ? null : result.GlobalRole,
DelegatedTenantIds = result.DelegatedTenantIds is { Count: > 0 } ? result.DelegatedTenantIds : null,
DelegatedRole = string.IsNullOrEmpty(result.DelegatedRole) ? null : result.DelegatedRole,
TenantRole = string.IsNullOrEmpty(result.TenantRole) ? null : result.TenantRole,
});
return response;
}
@@ -139,6 +139,14 @@ public class McpUserService
// The explicit grants below hold under BOTH remaining policies; AllMembers only ADDS the tenant-role
// path at the end. Same order everywhere so the reported AccessGrant is policy-independent.
// The caller's member role in the tenant the token was issued for. Reported, never used to grant:
// the MCP server registers tools whose backend route needs a role (the rule dry-runs are
// TenantAdminOrGlobalReader) only for callers who hold it. Platform roles skip it — their global
// scope already covers those routes.
async Task<string?> HomeTenantRoleAsync() => string.IsNullOrWhiteSpace(homeTenantId)
? null
: (await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles, clientCapped))?.Role;
// Any platform role (GlobalAdmin or read-only GlobalReader) always has MCP access
// (unless explicitly disabled via the kill-switch above).
if (globalRole != null)
@@ -165,11 +173,13 @@ public class McpUserService
if (!delegatedScope.IsEmpty)
return McpAccessCheckResult.Allowed(
upn, "DelegatedAdmin", isGlobalAdmin: false, globalRole: null,
delegatedTenantIds: delegatedTenantIds, delegatedRole: delegatedRole);
delegatedTenantIds: delegatedTenantIds, delegatedRole: delegatedRole,
tenantRole: await HomeTenantRoleAsync());
// An enabled McpUsers row is an explicit per-account grant under both policies.
if (whitelistState == McpWhitelistState.Enabled)
return McpAccessCheckResult.Allowed(upn, "McpUser", false, null, delegatedTenantIds, delegatedRole);
return McpAccessCheckResult.Allowed(
upn, "McpUser", false, null, delegatedTenantIds, delegatedRole, await HomeTenantRoleAsync());
if (policy != McpAccessPolicy.AllMembers)
// Surfaced verbatim to the end user by the MCP server's access guard. Keep it
@@ -183,11 +193,9 @@ public class McpUserService
// issued for — the same resolver the policy middleware uses on every tenant endpoint. A token from
// an organization that never onboarded, or from an employee without a role (Progress Portal
// end-users), resolves to no role and is denied here instead of failing on every tool call.
var memberRole = string.IsNullOrWhiteSpace(homeTenantId)
? null
: await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles, clientCapped);
var memberRole = await HomeTenantRoleAsync();
if (memberRole != null)
return McpAccessCheckResult.Allowed(upn, "AllMembers", false, null, delegatedTenantIds, delegatedRole);
return McpAccessCheckResult.Allowed(upn, "AllMembers", false, null, delegatedTenantIds, delegatedRole, memberRole);
return McpAccessCheckResult.Denied(Constants.PrincipalKeys.IsApplication(upn)
? "Service principal not enabled for MCP usage (the application is not a member of its tenant — ask a Tenant Admin to add it under Members as a service principal)"
: "User not enabled for MCP usage (account has no role in its organization's tenant — ask a Tenant Admin to add you)");
@@ -374,13 +382,20 @@ public class McpAccessCheckResult
/// </summary>
public string? DelegatedRole { get; init; }
/// <summary>
/// The caller's member role ("Admin" | "Operator" | "Viewer") in the tenant the token was issued for,
/// client-capped; null without a role and for platform roles. Never a grant by itself.
/// </summary>
public string? TenantRole { get; init; }
public static McpAccessCheckResult Allowed(
string upn,
string accessGrant,
bool isGlobalAdmin = false,
string? globalRole = null,
IReadOnlyCollection<string>? delegatedTenantIds = null,
string? delegatedRole = null) => new()
string? delegatedRole = null,
string? tenantRole = null) => new()
{
IsAllowed = true,
Upn = upn,
@@ -388,7 +403,8 @@ public class McpAccessCheckResult
IsGlobalAdmin = isGlobalAdmin,
GlobalRole = globalRole,
DelegatedTenantIds = delegatedTenantIds,
DelegatedRole = delegatedRole
DelegatedRole = delegatedRole,
TenantRole = tenantRole
};
public static McpAccessCheckResult Denied(string reason) => new()
@@ -26,10 +26,11 @@ const DEPS: ServerDeps = {
docs: { vector: stub(), sections: ['concepts', 'trust'] },
};
type Role = { label: string; isGlobalAdmin: boolean; isGlobalReader: boolean; delegatedTenantIds?: string[] };
type Role = { label: string; isGlobalAdmin: boolean; isGlobalReader: boolean; delegatedTenantIds?: string[]; tenantRole?: string };
const ROLES: Role[] = [
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false },
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true },
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin' },
{ label: 'tenant-user', isGlobalAdmin: false, isGlobalReader: false },
{ label: 'delegated', isGlobalAdmin: false, isGlobalReader: false, delegatedTenantIds: ['00000000-0000-0000-0000-000000000001'] },
];
@@ -46,7 +47,7 @@ async function main(): Promise<void> {
const handler = createMcpRequestHandler(() => createServerForCaller(DEPS), { onerror: (e) => console.error(e.message) });
app.post('/mcp', (req, res) =>
runWithCaller(
{ token: 't', isGlobalAdmin: role.isGlobalAdmin, isGlobalReader: role.isGlobalReader, upn: 'a@contoso.com', ...(role.delegatedTenantIds ? { delegatedTenantIds: role.delegatedTenantIds } : {}) } as never,
{ token: 't', isGlobalAdmin: role.isGlobalAdmin, isGlobalReader: role.isGlobalReader, upn: 'a@contoso.com', ...(role.delegatedTenantIds ? { delegatedTenantIds: role.delegatedTenantIds } : {}), ...(role.tenantRole ? { tenantRole: role.tenantRole } : {}) } as never,
() => handler(req, res),
),
);
@@ -22,7 +22,7 @@ process.env.MCP_RATE_LIMIT_PER_MINUTE = '2';
const { accessGuard } = await import('../access-guard.js');
// Same ESM singleton the guard uses internally — so the context set by
// runWithCaller inside next() is observable here.
const { isGlobalAdmin, hasGlobalScope, getCurrentToken, isDelegated, getDelegatedTenantIds } =
const { isGlobalAdmin, hasGlobalScope, getCurrentToken, isDelegated, getDelegatedTenantIds, canRunRuleDryRun } =
await import('../client.js');
/** Build an unsigned JWT-shaped token carrying the given claims. */
@@ -80,7 +80,7 @@ function mockRes(): CapturedRes {
interface GuardOutcome {
nextCalled: boolean;
/** caller context captured INSIDE next() (only meaningful when nextCalled) */
ctx?: { token: string | undefined; ga: boolean; scope?: boolean; delegated?: boolean; managed?: string[] };
ctx?: { token: string | undefined; ga: boolean; scope?: boolean; delegated?: boolean; managed?: string[]; dryRun?: boolean };
status: number | null;
body: unknown;
headers: Record<string, string>;
@@ -117,6 +117,7 @@ function runGuard(req: Request): Promise<GuardOutcome> {
scope: hasGlobalScope(),
delegated: isDelegated(),
managed: getDelegatedTenantIds(),
dryRun: canRunRuleDryRun(),
},
status: cap.statusCode,
body: cap.body,
@@ -290,7 +291,28 @@ describe('accessGuard — allow path', () => {
expect(out.nextCalled).toBe(true);
expect(out.status).toBeNull(); // no response sent on the allow path
expect(out.ctx).toEqual({ token, ga: true, scope: true, delegated: false, managed: undefined });
expect(out.ctx).toEqual({ token, ga: true, scope: true, delegated: false, managed: undefined, dryRun: true });
});
it('threads tenantRole=Admin into the context: the rule dry-runs are offered without platform scope', async () => {
stubBackend({ body: { allowed: true, accessGrant: 'AllMembers', tenantRole: 'Admin' } });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(true);
expect(out.ctx?.scope).toBe(false);
expect(out.ctx?.dryRun).toBe(true);
});
it('withholds the rule dry-runs from an Operator, a Viewer and a backend that sends no role', async () => {
for (const body of [
{ allowed: true, accessGrant: 'AllMembers', tenantRole: 'Operator' },
{ allowed: true, accessGrant: 'AllMembers', tenantRole: 'Viewer' },
{ allowed: true, accessGrant: 'AllMembers' },
]) {
stubBackend({ body });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(true);
expect(out.ctx?.dryRun, JSON.stringify(body)).toBe(false);
}
});
it('propagates isGlobalAdmin:false through the caller context', async () => {
@@ -34,14 +34,19 @@ interface Role {
isGlobalAdmin: boolean;
isGlobalReader: boolean;
delegatedTenantIds?: string[];
/** Member role in the token's tenant; "Admin" adds the rule dry-runs (client.ts canRunRuleDryRun). */
tenantRole?: string;
/** Ratchet: serialized `tools` array of tools/list, in characters (post-diet baseline 2026-09-22 + ~3 %). */
maxCatalogChars: number;
}
const ROLES: Role[] = [
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 92_000 },
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 77_000 },
{ label: 'tenant-user', isGlobalAdmin: false, isGlobalReader: false, maxCatalogChars: 53_000 },
{ label: 'delegated', isGlobalAdmin: false, isGlobalReader: false, delegatedTenantIds: ['11111111-1111-4111-8111-111111111111'], maxCatalogChars: 58_000 },
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_000 },
// Lowered 2026-10-11: Operators/Viewers and delegated callers without the home Admin role no longer
// list the two rule dry-runs (measured 49,385 and 53,645 chars).
{ label: 'tenant-user', isGlobalAdmin: false, isGlobalReader: false, maxCatalogChars: 51_000 },
{ label: 'delegated', isGlobalAdmin: false, isGlobalReader: false, delegatedTenantIds: ['11111111-1111-4111-8111-111111111111'], maxCatalogChars: 55_500 },
];
/**
@@ -79,6 +84,7 @@ beforeAll(async () => {
isGlobalReader: role.isGlobalReader,
upn: 'caller@contoso.com',
...(role.delegatedTenantIds ? { delegatedTenantIds: role.delegatedTenantIds } : {}),
...(role.tenantRole ? { tenantRole: role.tenantRole } : {}),
},
() => handler(req, res),
),
@@ -23,11 +23,11 @@ import { registerTools } from '../tools.js';
import { GA_STRICT_TOOL_NAMES } from '../tools/admin.js';
import type { SearchProvider } from '../search-provider.js';
function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] {
function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false, ruleDryRun: boolean = ga): string[] {
const server = new McpServer({ name: 'test', version: '0.0.0' });
// knowledgeBase / eventTypeIndex are optional — pass undefined so this stays a
// pure unit test with no search-provider or backend dependency.
registerTools(server, undefined, undefined, undefined, ga, strictGa, delegated);
registerTools(server, undefined, undefined, undefined, ga, strictGa, delegated, ruleDryRun);
const internal = server as unknown as { _registeredTools: Record<string, unknown> };
return Object.keys(internal._registeredTools);
}
@@ -177,10 +177,10 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'search_sessions',
'search_sessions_by_cve',
'search_sessions_by_event',
// Rule-authoring trio, available to every role: validate_rule is MCP-local
// (no backend call at all); test_analyze_rule's dryrun endpoint and
// test_log_pattern's test-pattern endpoint enforce TenantAdminOrGlobalReader
// server-side (a Viewer's call 403s there).
// Rule-authoring trio: validate_rule is MCP-local (no backend call) and listed
// for every role; test_analyze_rule and test_log_pattern call routes that are
// TenantAdminOrGlobalReader, so they are listed only where the backend would
// answer (RULE_DRY_RUN below).
'test_analyze_rule',
'test_log_pattern',
'update_tenant_config',
@@ -256,6 +256,10 @@ describe('role catalog snapshot — privilege-leak guard', () => {
// subset-tier routes it calls to the caller's managed set (never a client-side fan-out).
const DELEGATED_ADDED = ['get_fleet_overview', 'list_tenants'];
// Rule dry-runs: listed for platform roles and a Tenant Admin of the token's tenant only
// (ruleDryRun gate = client.ts canRunRuleDryRun); an Operator or Viewer would only collect a 403.
const RULE_DRY_RUN = ['test_analyze_rule', 'test_log_pattern'];
const without = (set: string[], remove: string[]) => set.filter((n) => !remove.includes(n));
it('Global Admin sees exactly the master set', () => {
@@ -267,16 +271,26 @@ describe('role catalog snapshot — privilege-leak guard', () => {
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT, ...RULE_SUBMISSION_WRITE_GA_STRICT]));
});
it('tenant user = GA minus all platform-only tools', () => {
expect(registeredToolNames(false, false, false)).toEqual(without(GA_FULL, PLATFORM_ONLY));
it('Tenant Admin = GA minus all platform-only tools', () => {
expect(registeredToolNames(false, false, false, true)).toEqual(without(GA_FULL, PLATFORM_ONLY));
});
it('delegated (MSP) caller = tenant user minus the global IME archive, plus list_tenants + get_fleet_overview', () => {
const tenant = without(GA_FULL, PLATFORM_ONLY);
it('tenant Operator / Viewer = Tenant Admin minus the rule dry-runs', () => {
expect(registeredToolNames(false, false, false, false)).toEqual(without(GA_FULL, [...PLATFORM_ONLY, ...RULE_DRY_RUN]));
});
it('delegated (MSP) caller = tenant member minus the global IME archive, plus list_tenants + get_fleet_overview', () => {
const tenant = without(GA_FULL, [...PLATFORM_ONLY, ...RULE_DRY_RUN]);
const expected = [...without(tenant, DELEGATED_HIDDEN), ...DELEGATED_ADDED].sort();
expect(registeredToolNames(false, false, true)).toEqual(expected);
});
it('a delegated caller who is Tenant Admin of the home tenant keeps the rule dry-runs', () => {
const tenant = without(GA_FULL, PLATFORM_ONLY);
const expected = [...without(tenant, DELEGATED_HIDDEN), ...DELEGATED_ADDED].sort();
expect(registeredToolNames(false, false, true, true)).toEqual(expected);
});
// ── Consistency of the difference lists themselves (catch stale entries). ──
it('every difference-list entry is a real GA tool', () => {
for (const [label, list] of [
@@ -287,6 +301,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
['PLATFORM_ONLY', PLATFORM_ONLY],
['DELEGATED_HIDDEN', DELEGATED_HIDDEN],
['DELEGATED_ADDED', DELEGATED_ADDED],
['RULE_DRY_RUN', RULE_DRY_RUN],
] as const) {
const stale = list.filter((n) => !GA_FULL.includes(n));
expect(stale, `${label} lists tools not present in the GA master set`).toEqual([]);
@@ -15,10 +15,11 @@ vi.mock('../client.js', async (importOriginal) => {
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
function captureToolHandlers(ga: boolean): Record<string, Handler> {
/** ruleDryRun defaults to true: the handler tests below act as a Tenant Admin, who gets all three tools. */
function captureToolHandlers(ga: boolean, ruleDryRun: boolean = true): Record<string, Handler> {
const handlers: Record<string, Handler> = {};
const fake = { registerTool: (name: string, _def: unknown, handler: Handler) => { handlers[name] = handler; } };
registerRuleTools(fake as never, ga);
registerRuleTools(fake as never, ga, ruleDryRun);
return handlers;
}
@@ -94,6 +95,23 @@ describe('test_analyze_rule', () => {
});
});
describe('registration by role', () => {
it('a Tenant Admin (or platform role) gets validate_rule plus both dry-runs', () => {
expect(Object.keys(captureToolHandlers(false, true)).sort()).toEqual(['test_analyze_rule', 'test_log_pattern', 'validate_rule']);
});
it('an Operator or Viewer gets validate_rule only, and its next step hands the draft to a Tenant Admin', async () => {
const handlers = captureToolHandlers(false, false);
expect(Object.keys(handlers)).toEqual(['validate_rule']);
const res = await handlers.validate_rule({ rule: ANALYZE_DRAFT });
const payload = JSON.parse(res.content[0].text);
expect(payload.valid).toBe(true);
expect(payload.nextStep).toContain('Tenant Admin');
expect(payload.nextStep).not.toContain('Dry-run it now');
});
});
describe('validate_rule', () => {
it('is fully MCP-local (never calls the backend) and reports findings', async () => {
const handlers = captureToolHandlers(false);
@@ -89,6 +89,8 @@ interface AccessCacheEntry {
// scope and downgrade the caller to home-tenant-only routing.
delegatedTenantIds?: string[];
delegatedRole?: string;
// Member role in the token's tenant (gates the rule dry-run tools); cached for the same reason.
tenantRole?: string;
// The backend refused the TOKEN itself (401) — cached like a deny so a replayed bad token costs
// one backend call per TTL, but answered 401 + challenge, never "not enabled".
unauthenticated?: boolean;
@@ -112,6 +114,9 @@ interface AccessCheckResult {
// routing bounded to this set; every delegated tool call must name one of these tenants.
delegatedTenantIds?: string[];
delegatedRole?: string;
// "Admin" | "Operator" | "Viewer" in the tenant the token was issued for (client-capped by the
// backend); undefined without a role, for platform roles, and from a backend that predates the field.
tenantRole?: string;
// The backend — the only party holding the JWKS — rejected the token (signature, issuer,
// audience, lifetime). That is "invalid token": the spec says it MUST be answered 401 with a
// WWW-Authenticate challenge so the client re-authenticates, not 403 ("insufficient
@@ -169,6 +174,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
isGlobalReader: cached.isGlobalReader,
delegatedTenantIds: cached.delegatedTenantIds,
delegatedRole: cached.delegatedRole,
tenantRole: cached.tenantRole,
unauthenticated: cached.unauthenticated,
// Only genuine backend verdicts are ever cached (infra errors return early
// below without caching), so a cache hit is never an infra error.
@@ -235,6 +241,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
isGlobalReader: data.globalRole === 'GlobalReader',
delegatedTenantIds: delegatedTenantIds && delegatedTenantIds.length > 0 ? delegatedTenantIds : undefined,
delegatedRole: data.delegatedRole,
tenantRole: typeof data.tenantRole === 'string' && data.tenantRole ? data.tenantRole : undefined,
// The backend reached a verdict — a deny here is a genuine authorization
// decision (e.g. not whitelisted), not an infrastructure problem.
infraError: false,
@@ -251,6 +258,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
isGlobalReader: result.isGlobalReader,
delegatedTenantIds: result.delegatedTenantIds,
delegatedRole: result.delegatedRole,
tenantRole: result.tenantRole,
expiresAt: Date.now() + ACCESS_CACHE_TTL_MS,
}, MAX_ACCESS_CACHE_ENTRIES);
return result;
@@ -539,6 +547,7 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
isGlobalReader: result.isGlobalReader,
delegatedTenantIds: result.delegatedTenantIds,
delegatedRole: result.delegatedRole,
tenantRole: result.tenantRole,
// 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.
@@ -89,6 +89,12 @@ interface CallerContext {
delegatedTenantIds?: string[];
/** Strongest delegated role ("DelegatedAdmin" | "DelegatedReader"); informational (read-only server). */
delegatedRole?: string;
/**
* The caller's member role ("Admin" | "Operator" | "Viewer") in the tenant the token was issued for, as the
* backend's access check reports it (client-capped). Gates the tools whose backend route needs the
* Tenant Admin role — see `canRunRuleDryRun`. Absent without a role and for platform roles.
*/
tenantRole?: 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
@@ -178,6 +184,15 @@ export function hasGlobalScope(): boolean {
return store?.isGlobalAdmin === true || store?.isGlobalReader === true;
}
/**
* True when the current caller may run the rule dry-runs (test_analyze_rule / test_log_pattern): their
* backend routes are TenantAdminOrGlobalReader, so a platform role or the Tenant Admin role in the token's
* tenant. Everyone else would only collect a 403, so those tools are not registered for them.
*/
export function canRunRuleDryRun(): boolean {
return hasGlobalScope() || callerStore.getStore()?.tenantRole === 'Admin';
}
/**
* Managed tenant IDs (lowercase) for the current delegated (MSP) caller, or undefined when the caller is
* not delegated / no context is active.
@@ -1043,6 +1043,8 @@ export interface CheckMcpAccessResponse {
delegatedTenantIds?: string[];
/** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */
delegatedRole?: string;
/** "Admin" | "Operator" | "Viewer": the caller's member role in the tenant the token was issued for (client-capped like every tenant endpoint); omitted without a role and for platform roles. The MCP server registers tools whose backend route needs a role (the rule dry-runs) only for callers holding it. */
tenantRole?: string;
}
/** Configuration for optional agent collectors */
@@ -10,7 +10,7 @@ import { registerTools } from './tools.js';
import { registerResources } from './resources.js';
import { registerPrompts } from './prompts.js';
import type { DocsSearchBundle, SearchProvider } from './search-provider.js';
import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds, getHomeTenantId } from './client.js';
import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds, getHomeTenantId, canRunRuleDryRun } from './client.js';
import { WEBSITE_BASE_URL } from './config.js';
/**
@@ -116,7 +116,7 @@ const RESOURCE_CACHE_TTL_MS = 60 * 60 * 1000;
* role: a non-Global-Admin never sees GA-only tools or any cross-tenant / GA
* wording — reducing both confusion and attack surface.
*/
export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string): McpServer {
export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string, ruleDryRun: boolean = ga): McpServer {
const s = new McpServer(
{ ...SERVER_INFO, version: deps.serverVersion },
{
@@ -148,14 +148,14 @@ export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean
},
},
);
registerTools(s, deps.knowledgeBase, deps.eventTypeIndex, deps.docs, ga, strictGa, delegated);
registerTools(s, deps.knowledgeBase, deps.eventTypeIndex, deps.docs, ga, strictGa, delegated, ruleDryRun);
stripSchemaUrls(s);
registerResources(s);
// A delegated caller has no platform scope, so prompts get the tenant-user surface (ga=false) —
// the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user. strictGa
// gates the prompt steps that name the strictGa-only raw tools (a Reader must not be told to
// call tools it cannot see).
registerPrompts(s, ga, strictGa);
// call tools it cannot see); ruleDryRun does the same for the dry-run steps.
registerPrompts(s, ga, strictGa, ruleDryRun);
return s;
}
@@ -178,6 +178,6 @@ export function createServerForCaller(deps: ServerDeps): McpServer {
// 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;
return createMcpServer(deps, ga, isGlobalAdmin(), delegated, managedTenants, homeTenantId);
return createMcpServer(deps, ga, isGlobalAdmin(), delegated, managedTenants, homeTenantId, canRunRuleDryRun());
}
@@ -14,7 +14,20 @@ import { z } from 'zod';
* with Zod but keep types as strings; numeric/optional semantics are described
* in the argument text for the model.
*/
export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolean): void {
/**
* The closing step of the two investigation prompts: a cause that can recur becomes an analyze rule. The
* dry-run sentence names test_analyze_rule only for a caller it is registered for (ruleDryRun).
*/
function ruleFromFindingStep(n: number, ruleDryRun: boolean): string {
return `${n}. If the root cause could happen again and no existing rule names it, draft an analyze rule that would ` +
'flag it next time: read get_resource(name="rule_authoring_guide") and check the draft with validate_rule. ' +
(ruleDryRun
? 'Then dry-run it with test_analyze_rule against this session (it should fire) and against a successful ' +
'session (it should stay silent), and hand over the final JSON.\n'
: 'Hand the validated JSON to a Tenant Admin, who can dry-run it and create it under Rules → Analyze Rules.\n');
}
export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolean, ruleDryRun: boolean = ga): void {
server.registerPrompt(
'investigate-failed-session',
{
@@ -43,7 +56,8 @@ export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolea
'3. Cross-check the rule analysis (it carries probable cause + remediation). If the summary ' +
'reported keyEventsTruncated, pull the raw events rather than trusting the capped list.\n\n' +
'Then report: (a) final outcome and phase reached, (b) the single most likely root cause with ' +
'the event evidence that supports it, (c) concrete remediation steps, (d) confidence level.',
'the event evidence that supports it, (c) concrete remediation steps, (d) confidence level.\n\n' +
ruleFromFindingStep(4, ruleDryRun),
},
},
],
@@ -100,7 +114,8 @@ export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolea
: '') +
'\nThen report: (a) final outcome + phase reached, (b) the single most likely root cause with ' +
'the specific evidence (agent-log line AND/OR backend event), (c) concrete remediation, ' +
'(d) confidence level. Cite both client and backend sources where they corroborate.',
'(d) confidence level. Cite both client and backend sources where they corroborate.\n\n' +
ruleFromFindingStep(strictGa ? 5 : 4, ruleDryRun),
},
},
],
@@ -174,12 +189,16 @@ export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolea
'(evaluate existing events), or both? If both, draft the gather rule first and reference ' +
'its outputEventType from the analyze rule.\n' +
'3. Draft the rule JSON and call validate_rule with it. Fix every error; explain warnings you keep.\n' +
(sessionId
? `4. Dry-run the analyze rule: test_analyze_rule(sessionId="${sessionId}", rule=<draft>). ` +
'Walk through the per-condition trace; iterate until the verdict matches the expectation, and check the interpolated explanation reads well.\n'
: '4. Ask me for a recent sessionId (one where the rule SHOULD fire and ideally one where it should not) and dry-run the analyze rule with test_analyze_rule.\n') +
(!ruleDryRun
? '4. The dry-run (test_analyze_rule) needs the Tenant Admin role and is not available to me. Point out the ' +
'session where the rule should fire, so a Tenant Admin can dry-run the draft before creating it.\n'
: sessionId
? `4. Dry-run the analyze rule: test_analyze_rule(sessionId="${sessionId}", rule=<draft>). ` +
'Walk through the per-condition trace; iterate until the verdict matches the expectation, and check the interpolated explanation reads well.\n'
: '4. Ask me for a recent sessionId (one where the rule SHOULD fire and ideally one where it should not) and dry-run the analyze rule with test_analyze_rule.\n') +
'5. Present the final rule JSON with a short explanation of when it fires, its confidence ' +
'model, and how to create it in the portal (Settings → Gather Rules / Analyze Rules).',
'model, and how to create it in the portal (Rules → Gather Rules / Analyze Rules, Create Custom Rule ' +
'in JSON mode).',
},
},
],
@@ -83,7 +83,7 @@ export function registerResources(server: McpServer): void {
'Complete guide for authoring gather and analyze rules: collector types, triggers, ' +
'condition sources, operators, confidence model, {{token}} interpolation, guardrail ' +
'semantics and common pitfalls. Read together with rule_schemas and rule_guardrails; ' +
'validate drafts with validate_rule and dry-run analyze rules with test_analyze_rule.',
'validate drafts with validate_rule and dry-run analyze rules with test_analyze_rule (Tenant Admin).',
},
async () => ({
contents: [
@@ -33,13 +33,17 @@ export const RULE_AUTHORING_GUIDE = {
'4. For analyze rules: call test_analyze_rule with the draft plus a sessionId of a real ' +
'session from your tenant (ideally one where the rule SHOULD fire and one where it should ' +
'NOT). The dry-run returns a per-condition trace with evidence — iterate until the verdict ' +
'matches your expectation. Nothing is persisted by a dry-run.',
'matches your expectation. Nothing is persisted by a dry-run. test_analyze_rule and ' +
'test_log_pattern are listed for Tenant Admins and platform roles only; other roles hand the ' +
'validated draft to a Tenant Admin.',
'5. Gather rules cannot be dry-run against a session (they execute on devices). For logparser ' +
'rules, test the regex with test_log_pattern against sample lines pasted from the real log ' +
'file — it uses the agent\'s exact .NET matching semantics. Then deploy to a test device/VM; ' +
'the agent debug log setting for gather rules shows per-rule execution detail on-device.',
'6. Create the finished rule in the portal (Settings → Gather Rules / Analyze Rules). Custom ' +
'rules are tenant-scoped. Well-tested rules can be proposed as community rules via GitHub.',
'6. Create the finished rule in the portal (Rules → Gather Rules / Analyze Rules; Create Custom ' +
'Rule accepts the JSON, and the analyze-rule editor can test it against a session). Custom rules ' +
'are tenant-scoped. A Tenant Admin can submit well-tested rules to the community pool from the ' +
'same page; accepted rules ship to every tenant after review.',
],
ids: {
@@ -636,7 +636,7 @@ function lintLogparserParameters(rule: Record<string, unknown>): ValidationFindi
level: 'info',
message:
'logparser matching is case-SENSITIVE and runs on the agent\'s .NET regex engine — a pattern verified in a JS/PHP/Python tester can behave differently. ' +
'Test it with test_log_pattern(pattern, sampleLines) against real lines from the log file before deploying.',
'Test it with test_log_pattern(pattern, sampleLines) against real lines from the log file before deploying (Tenant Admin).',
});
const format = parameters.format;
@@ -28,6 +28,10 @@ import type { DocsSearchBundle } from './search-provider.js';
* express on its own: hiding the one ungated platform-only tool (get_ime_version_history) and exposing a
* required `tenantId` selector on get_software_inventory. A caller that is BOTH platform AND delegated is
* treated as platform (ga=true ⇒ delegated=false at the call site), so this never strips a GA's tools.
*
* `ruleDryRun` gates the two rule dry-runs (test_analyze_rule / test_log_pattern): their backend routes are
* TenantAdminOrGlobalReader, so they are listed only for a platform role or a Tenant Admin of the token's
* tenant (client.ts canRunRuleDryRun) — an Operator or Viewer would only collect a 403.
*/
export function registerTools(
server: McpServer,
@@ -37,11 +41,12 @@ export function registerTools(
ga: boolean,
strictGa: boolean = ga,
delegated: boolean = false,
ruleDryRun: boolean = ga,
): void {
registerSessionTools(server, ga, delegated);
registerSearchTools(server, knowledgeBase, eventTypeIndex, docs, ga, delegated);
registerAdminTools(server, ga, strictGa, delegated);
registerRuleTools(server, ga);
registerRuleTools(server, ga, ruleDryRun);
sortToolCatalog(server);
}
@@ -15,13 +15,14 @@ import type {
} from '../generated/wire-types.generated.js';
/**
* Rule-authoring tools: validate a draft gather/analyze rule locally, and dry-run a
* draft analyze rule against a real session via the backend's side-effect-free
* dryrun endpoint. Registered for EVERY role — tenant admins are the primary
* audience (the backend still enforces its own authorization: the dryrun route is
* TenantAdmin / Global Admin / read-only Global Reader).
* Rule-authoring tools: validate a draft gather/analyze rule locally (every role), and
* dry-run a draft against the backend's side-effect-free test endpoints. The dry-runs are
* registered only when `ruleDryRun` is set — their routes are TenantAdmin / Global Admin /
* read-only Global Reader, so any other caller would only collect a 403 (see
* client.ts canRunRuleDryRun). The texts of validate_rule name them accordingly.
*/
export function registerRuleTools(server: McpServer, ga: boolean): void {
export function registerRuleTools(server: McpServer, ga: boolean, ruleDryRun: boolean = ga): void {
const dryRunNote = ruleDryRun ? '' : ' (a Tenant Admin runs it)';
// Tool: validate_rule (MCP-local, no backend call)
server.registerTool(
'validate_rule',
@@ -33,7 +34,7 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
'event-log allowlists with the agent\'s matching semantics) and semantic lint (unreachable confidence ' +
'threshold, non-evaluable confidenceFactor conditions, unknown event types, unanchored allow-list regex, ' +
'unresolvable {{token}} placeholders). Returns findings as error/warning/info. Fix all errors, then dry-run ' +
'analyze rules with test_analyze_rule against a real session. Read get_resource(name="rule_authoring_guide") ' +
`analyze rules with test_analyze_rule against a real session${dryRunNote}. Read get_resource(name="rule_authoring_guide") ` +
'first when authoring from scratch.',
inputSchema: {
rule: z.record(z.string(), z.unknown()).describe('The draft rule JSON object (gather or analyze — detected automatically)'),
@@ -51,9 +52,13 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
findings: result.findings,
nextStep: result.valid
? (result.ruleType === 'analyze'
? 'No errors. Dry-run it now: test_analyze_rule(sessionId=<recent session>, rule=<this draft>) — once against a session where it SHOULD fire, once where it should not.'
? (ruleDryRun
? 'No errors. Dry-run it now: test_analyze_rule(sessionId=<recent session>, rule=<this draft>) — once against a session where it SHOULD fire, once where it should not.'
: 'No errors. The dry-run (test_analyze_rule) needs the Tenant Admin role: hand this draft to a Tenant Admin, who can dry-run it in their AI client or under Rules → Analyze Rules (Test against a session) and create it there.')
: (args.rule as Record<string, unknown>).collectorType === 'logparser'
? 'No errors. Now test the regex with the agent\'s exact .NET engine: test_log_pattern(pattern=<parameters.pattern>, sampleLines=<raw lines from the log file>, format=<parameters.format>) — then deploy to a test device.'
? (ruleDryRun
? 'No errors. Now test the regex with the agent\'s exact .NET engine: test_log_pattern(pattern=<parameters.pattern>, sampleLines=<raw lines from the log file>, format=<parameters.format>) — then deploy to a test device.'
: 'No errors. Testing the regex with the agent\'s exact .NET engine (test_log_pattern) needs the Tenant Admin role: hand this draft and sample log lines to a Tenant Admin.')
: 'No errors. Gather rules run on-device and cannot be dry-run server-side — deploy to a test device and check the emitted events (the gather debug log setting shows per-rule detail).')
: 'Fix the error-level findings and validate again.',
},
@@ -66,7 +71,7 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
);
// Tool: test_log_pattern (backend .NET-regex test for logparser gather rules)
server.registerTool(
if (ruleDryRun) server.registerTool(
'test_log_pattern',
{
title: 'Test Logparser Pattern Against Sample Lines',
@@ -102,7 +107,7 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
);
// Tool: test_analyze_rule (backend dry-run, side-effect free)
server.registerTool(
if (ruleDryRun) server.registerTool(
'test_analyze_rule',
{
title: 'Test Analyze Rule Against Session (Dry-Run)',
@@ -194,6 +194,13 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>"DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope.</summary>
public string? DelegatedRole { get; set; }
/// <summary>
/// "Admin" | "Operator" | "Viewer": the caller's member role in the tenant the token was issued for
/// (client-capped like every tenant endpoint); omitted without a role and for platform roles. The MCP
/// server registers tools whose backend route needs a role (the rule dry-runs) only for callers holding it.
/// </summary>
public string? TenantRole { get; set; }
}
/// <summary>
@@ -5567,6 +5567,15 @@
},
"optional": true,
"doc": "\"DelegatedAdmin\" | \"DelegatedReader\"; omitted without delegated scope."
},
{
"name": "tenantRole",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "\"Admin\" | \"Operator\" | \"Viewer\": the caller's member role in the tenant the token was issued for (client-capped like every tenant endpoint); omitted without a role and for platform roles. The MCP server registers tools whose backend route needs a role (the rule dry-runs) only for callers holding it."
}
]
},
@@ -1043,6 +1043,8 @@ export interface CheckMcpAccessResponse {
delegatedTenantIds?: string[];
/** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */
delegatedRole?: string;
/** "Admin" | "Operator" | "Viewer": the caller's member role in the tenant the token was issued for (client-capped like every tenant endpoint); omitted without a role and for platform roles. The MCP server registers tools whose backend route needs a role (the rule dry-runs) only for callers holding it. */
tenantRole?: string;
}
/** Configuration for optional agent collectors */