mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
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:
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user