mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(mcp): MCP server adopts the generated wire types — the codegen now writes a second copy of wire-types.generated.ts into McpServer/src/generated (MCP_HEADER, buildMcpWireTypesSource, same freshness contract), pinned by a new MCP vitest freshness test (repo-root discovery like event-types-drift, byte-equal to a fresh codegen run, mutation-proven red/green) and enforced in the CI shared-manifests-in-sync diff check; the hand-declared response shapes and structural inline casts in tools/*.ts now import the generated types on the INPUT side only (tool output contracts unchanged): sessions.ts (GetSessionResponse/GetSessionEventsResponse/GetRuleResultsResponse/GetSessionAnnotationsResponse/DiagnosticsDownloadTicketResponse; get_session_summary events are Partial<EnrollmentEvent>, RuleResult drops the dead ruleTitle??title fallback), admin.ts (usage union GetMcpUserUsage/GetGlobalMcpUsage/GetGlobalMcpUsageDaily, GeographicLocationSessionsLeanResponse, GetAllTenantConfigurationsResponse, QueryRawTableResponse, GetUnmatchedSoftwareResponse, SoftwareInventoryResponse; the client-side pagers pin their wire-critical records/summaries/inventory key names via satisfies keyof so a backend rename goes tsc-red instead of silently paging nothing), search.ts (GetSessionEventsResponse on both event readers, SearchSessionsResponse on the legacy fallback, RawEventsPage = Partial<Pick<QueryRawEventsResponse,...>>), rules.ts (DryRunAnalyzeRuleResponse envelope; the inner RuleDryRun trace stays a local reader because the wire type is deliberately unknown); the raw-session fallback in get_session_diagnostics/get_session_summary is removed — { success, session } IS the pinned contract, and the diag test now asserts a non-enveloped object surfaces as a loud tool error instead of being silently absorbed; rule-stats, global/metrics/platform and verdict-calibration keep local readers (backend still serializes anonymous objects there — no DTO exists to import); MCP suite 617 green, tsc green, generator idempotent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -162,7 +162,7 @@ jobs:
|
||||
|
||||
- name: Fail if the generated files drifted
|
||||
run: |
|
||||
if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts; then
|
||||
if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts; then
|
||||
echo "::error::Generated wire types are out of date. Run 'npm run generate:manifests' in src/Web/autopilot-monitor-web and commit the result."
|
||||
git diff --stat
|
||||
exit 1
|
||||
|
||||
@@ -75,12 +75,16 @@ describe('get_session_diagnostics tool', () => {
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(2); // session lookup + ticket mint
|
||||
});
|
||||
|
||||
it('also accepts a raw (non-enveloped) session object', async () => {
|
||||
it('rejects a raw (non-enveloped) session object — the envelope IS the wire contract', async () => {
|
||||
// Since the typed API contract (GetSessionResponse), { success, session } is the only
|
||||
// shape the backend can send. A raw object is a contract violation and must surface
|
||||
// as a loud tool error, never be silently absorbed by a legacy fallback.
|
||||
apiFetchMock
|
||||
.mockResolvedValueOnce({ tenantId: TENANT, diagnosticsBlobName: 'AgentDiagnostics-x.zip' })
|
||||
.mockResolvedValueOnce({ url: '/api/diagnostics/download?t=ABC' });
|
||||
const res = await captureToolHandlers(false).get_session_diagnostics({ sessionId: SID });
|
||||
expect(parse(res).available).toBe(true);
|
||||
expect(res.isError).toBe(true);
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1); // fails before minting a ticket
|
||||
});
|
||||
|
||||
it('mints a ticket and returns an absolute downloadUrl + zipMap', async () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Freshness guard: src/generated/wire-types.generated.ts MUST be byte-identical to
|
||||
* a fresh run of the web codegen over shared-manifests.json (the C#-reflected wire
|
||||
* contract). The chain: SharedManifestParityTests pins JSON ↔ C#, the web suite pins
|
||||
* the web copy, this test pins the MCP copy — so a backend shape change that lands
|
||||
* without regenerating goes red here instead of silently mis-typing tool readers.
|
||||
*
|
||||
* Same-repo only (mirrors event-types-drift.test.ts): skipped gracefully when the
|
||||
* web project can't be located; CI additionally enforces via shared-manifests-in-sync.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/** Walk up from this test file to the repo root (marked by AutopilotMonitor.sln). */
|
||||
function findWebRoot(): string | null {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (existsSync(join(dir, 'AutopilotMonitor.sln'))) {
|
||||
const p = join(dir, 'src', 'Web', 'autopilot-monitor-web');
|
||||
return existsSync(p) ? p : null;
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('generated wire types freshness (MCP copy)', () => {
|
||||
const webRoot = findWebRoot();
|
||||
const itOrSkip = webRoot ? it : it.skip;
|
||||
|
||||
itOrSkip('src/generated/wire-types.generated.ts matches a fresh run of the codegen', () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildMcpWireTypesSource } = require(
|
||||
join(webRoot!, 'scripts', 'generate-shared-manifest-types.js'),
|
||||
);
|
||||
const json = readFileSync(join(webRoot!, 'utils', 'shared-manifests.json'), 'utf8');
|
||||
const committed = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), '..', 'generated', 'wire-types.generated.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(committed.replace(/\r\n/g, '\n')).toBe(buildMcpWireTypesSource(json));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,17 @@ import { getResourceContent, assertKnownEventType } from '../resource-catalog.js
|
||||
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { shapeVerdictCalibration } from '../verdict-calibration-shape.js';
|
||||
import type {
|
||||
GeographicLocationSessionsLeanResponse,
|
||||
GetAllTenantConfigurationsResponse,
|
||||
GetGlobalMcpUsageDailyResponse,
|
||||
GetGlobalMcpUsageResponse,
|
||||
GetMcpUserUsageResponse,
|
||||
GetMyMcpUsageResponse,
|
||||
GetUnmatchedSoftwareResponse,
|
||||
QueryRawTableResponse,
|
||||
SoftwareInventoryResponse,
|
||||
} from '../generated/wire-types.generated.js';
|
||||
|
||||
/**
|
||||
* Non-sensitive tenant fields safe to surface to the model. Keep-list (not
|
||||
@@ -195,7 +206,13 @@ export function paginateUsage(
|
||||
): Record<string, unknown> {
|
||||
if (!data || typeof data !== 'object') return { records: [], totalCount: 0, count: 0, offset: 0, nextLink: null };
|
||||
const obj = data as Record<string, unknown>;
|
||||
const arrayKey = Array.isArray(obj.records) ? 'records' : Array.isArray(obj.summaries) ? 'summaries' : null;
|
||||
// The `satisfies` pins tie the sliced key names to the generated wire types —
|
||||
// a backend rename of these wire-critical keys goes tsc-red here, not silent.
|
||||
const arrayKey = Array.isArray(obj.records)
|
||||
? ('records' satisfies keyof GetGlobalMcpUsageResponse & keyof GetMcpUserUsageResponse & keyof GetMyMcpUsageResponse)
|
||||
: Array.isArray(obj.summaries)
|
||||
? ('summaries' satisfies keyof GetGlobalMcpUsageDailyResponse)
|
||||
: null;
|
||||
if (!arrayKey) return obj; // unexpected shape — pass through untouched rather than mangle it
|
||||
const all = obj[arrayKey] as unknown[];
|
||||
const m = continuation ? /^usage-offset:(\d+)$/.exec(continuation) : null;
|
||||
@@ -231,8 +248,10 @@ export function paginateInventory(
|
||||
): Record<string, unknown> {
|
||||
if (!data || typeof data !== 'object') return { inventory: [], total: 0, count: 0, offset: 0, nextLink: null };
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (!Array.isArray(obj.inventory)) return obj; // unexpected shape — pass through untouched rather than mangle it
|
||||
const all = obj.inventory as unknown[];
|
||||
// Same wire-key pin as paginateUsage: renaming `inventory` on the wire goes tsc-red here.
|
||||
const inventoryKey = 'inventory' satisfies keyof SoftwareInventoryResponse;
|
||||
if (!Array.isArray(obj[inventoryKey])) return obj; // unexpected shape — pass through untouched rather than mangle it
|
||||
const all = obj[inventoryKey] as unknown[];
|
||||
const m = continuation ? /^inv-offset:(\d+)$/.exec(continuation) : null;
|
||||
const offset = m ? parseInt(m[1], 10) : 0;
|
||||
const slice = all.slice(offset, offset + pageSize);
|
||||
@@ -282,14 +301,14 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
async (args) => withToolTelemetry('get_api_usage', args, async () => {
|
||||
try {
|
||||
let data: unknown;
|
||||
let data: GetMcpUserUsageResponse | GetGlobalMcpUsageDailyResponse | GetGlobalMcpUsageResponse;
|
||||
const params: Record<string, string | undefined> = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo };
|
||||
if (args.userId) {
|
||||
data = await apiFetch(`/api/metrics/mcp-usage/user/${encodeURIComponent(args.userId)}${buildQuery(params)}`);
|
||||
data = await apiFetch(`/api/metrics/mcp-usage/user/${encodeURIComponent(args.userId)}${buildQuery(params)}`) as GetMcpUserUsageResponse;
|
||||
} else if (args.daily) {
|
||||
data = await apiFetch(`/api/global/metrics/mcp-usage/daily${buildQuery(params)}`);
|
||||
data = await apiFetch(`/api/global/metrics/mcp-usage/daily${buildQuery(params)}`) as GetGlobalMcpUsageDailyResponse;
|
||||
} else {
|
||||
data = await apiFetch(`/api/global/metrics/mcp-usage${buildQuery(params)}`);
|
||||
data = await apiFetch(`/api/global/metrics/mcp-usage${buildQuery(params)}`) as GetGlobalMcpUsageResponse;
|
||||
}
|
||||
return toolResultText(paginateUsage(data, args.pageSize, args.continuation), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
} catch (error: unknown) {
|
||||
@@ -379,8 +398,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
};
|
||||
if (tenantId) params.tenantId = tenantId;
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
|
||||
// Lean rows (the tool never passes ?full=1) — pinned by the wire contract.
|
||||
const data = await apiFetch(`${prefix}/geographic/sessions${buildQuery(params)}`) as
|
||||
{ success?: boolean; sessions?: unknown[]; totalCount?: number };
|
||||
GeographicLocationSessionsLeanResponse;
|
||||
|
||||
// The location-sessions endpoint filters in-memory and returns the full
|
||||
// set in one shot (no server-side cursor). A single busy location can
|
||||
@@ -694,7 +714,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
for (;;) {
|
||||
const path = followNextLink(
|
||||
'/api/config/all', { pageSize: TENANT_FILTER_PAGE_SIZE, fields: scanFields }, nextLink);
|
||||
const data = await apiFetch(path) as { tenants?: unknown[]; nextLink?: string | null };
|
||||
const data = await apiFetch(path) as GetAllTenantConfigurationsResponse;
|
||||
let pageTenants = extractTenantList({ tenants: data?.tenants ?? [] });
|
||||
if (delegated) {
|
||||
pageTenants = delegatedTenantListView(
|
||||
@@ -733,7 +753,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
// in nextLink. extractTenantList re-applies the keep-list as defense-in-depth (harmless
|
||||
// pass-through once the backend has projected).
|
||||
const path = followNextLink('/api/config/all', { pageSize, fields }, continuation);
|
||||
const data = await apiFetch(path) as { count?: number; tenants?: unknown[]; nextLink?: string | null };
|
||||
const data = await apiFetch(path) as GetAllTenantConfigurationsResponse;
|
||||
let tenants = extractTenantList({ tenants: data?.tenants ?? [] });
|
||||
if (delegated) {
|
||||
tenants = delegatedTenantListView(
|
||||
@@ -1214,7 +1234,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{ partitionKey, rowKeyPrefix, filter, pageSize },
|
||||
continuation,
|
||||
);
|
||||
const data = await apiFetch(path) as { table?: string; count?: number; entities?: Record<string, unknown>[]; nextLink?: string | null };
|
||||
const data = await apiFetch(path) as QueryRawTableResponse;
|
||||
|
||||
// Client-side projection — TableEntity columns are dynamic so the backend
|
||||
// can't help. Always retain PartitionKey + RowKey (cursor stability +
|
||||
@@ -1746,7 +1766,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
if (ga && scope === 'unmatched') {
|
||||
const m = continuation ? /^unmatched-offset:(\d+)$/.exec(continuation) : null;
|
||||
const skip = m ? parseInt(m[1], 10) : 0;
|
||||
const data = await apiFetch(`/api/vulnerability/unmatched-software${buildQuery({ skip, take: pageSize })}`) as { software?: unknown[]; total?: number };
|
||||
const data = await apiFetch(`/api/vulnerability/unmatched-software${buildQuery({ skip, take: pageSize })}`) as GetUnmatchedSoftwareResponse;
|
||||
const total = data?.total ?? 0;
|
||||
const nextOffset = skip + pageSize;
|
||||
const nextLink = nextOffset < total ? `unmatched-offset:${nextOffset}` : null;
|
||||
@@ -1767,7 +1787,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', tenantId);
|
||||
const data = await apiFetch(`${path}${buildQuery({ tenantId })}`);
|
||||
const data = await apiFetch(`${path}${buildQuery({ tenantId })}`) as SoftwareInventoryResponse;
|
||||
return toolResultText(paginateInventory(data, pageSize, continuation), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
} catch (error: unknown) {
|
||||
return toolError('get_software_inventory', args, error);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema } fro
|
||||
import { toolError } from './error-handler.js';
|
||||
import { validateRuleDraft } from '../rule-validation.js';
|
||||
import { interpolateRuleTemplate } from '../interpolate-rule-template.js';
|
||||
import type { DryRunAnalyzeRuleResponse } from '../generated/wire-types.generated.js';
|
||||
|
||||
/**
|
||||
* Rule-authoring tools: validate a draft gather/analyze rule locally, and dry-run a
|
||||
@@ -124,9 +125,11 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
|
||||
const data = await apiFetch('/api/rules/analyze/dryrun', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: args.sessionId, rule: args.rule }),
|
||||
}) as { result?: { verdict?: string; matchedConditions?: Record<string, unknown> } };
|
||||
}) as DryRunAnalyzeRuleResponse;
|
||||
|
||||
const result = data.result;
|
||||
// `result` is `unknown` on the wire by design (the RuleDryRun trace lives in the
|
||||
// Functions assembly) — read the two fields this tool needs via a local view.
|
||||
const result = data.result as { verdict?: string; matchedConditions?: Record<string, unknown> } | undefined;
|
||||
const rule = args.rule as Record<string, unknown>;
|
||||
const mc = result?.matchedConditions;
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import { withToolTelemetry, logSearchZeroHit } from '../telemetry.js';
|
||||
import type { SearchProvider, DocsSearchBundle } from '../search-provider.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import type {
|
||||
GetSessionEventsResponse,
|
||||
QueryRawEventsResponse,
|
||||
SearchSessionsResponse,
|
||||
} from '../generated/wire-types.generated.js';
|
||||
import { ALL_EVENT_TYPES } from '../resource-catalog.js';
|
||||
import { DOCS_BASE_URL } from '../config.js';
|
||||
|
||||
@@ -84,7 +89,8 @@ export function prioritizeFailureTypes(types: string[]): string[] {
|
||||
* enriched camelCase `EventEntry` shape — normalizeRawEvent() bridges that gap before scoring.
|
||||
*/
|
||||
type RawEventRow = Record<string, unknown>;
|
||||
type RawEventsPage = { events?: RawEventRow[]; nextLink?: string };
|
||||
// Wire-bound page shape (Partial: test fakes pass minimal pages; the wire always sends events).
|
||||
type RawEventsPage = Partial<Pick<QueryRawEventsResponse, 'events' | 'nextLink'>>;
|
||||
|
||||
/** Bounds the per-type page walk so a single tool call can't run unbounded. */
|
||||
type FetchBudget = { maxPagesPerType: number; wallClockMs: number };
|
||||
@@ -409,7 +415,7 @@ async function fetchSessionEvents(
|
||||
// No event-type pre-selection runs here, so ranking stays pure-keyword.
|
||||
if (sessionId) {
|
||||
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
|
||||
const data = await apiFetch(`/api/sessions/${sessionId}/events${q}`) as { events?: EventEntry[] };
|
||||
const data = await apiFetch(`/api/sessions/${sessionId}/events${q}`) as GetSessionEventsResponse;
|
||||
return { events: data?.events ?? [], sessionIds: [sessionId], searchMethod: 'direct-session', truncated: false, semanticTypeScores: noSemanticScores };
|
||||
}
|
||||
|
||||
@@ -446,9 +452,7 @@ async function fetchSessionEvents(
|
||||
if (tenantId) searchParams.tenantId = tenantId;
|
||||
const searchQ = buildQuery(searchParams);
|
||||
const searchBase = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions', tenantId);
|
||||
const sessions = await apiFetch(`${searchBase}${searchQ}`) as {
|
||||
sessions?: Array<{ sessionId?: string }>;
|
||||
};
|
||||
const sessions = await apiFetch(`${searchBase}${searchQ}`) as SearchSessionsResponse;
|
||||
// The backend ignores `limit`, so cap client-side to keep the N+1 fan-out bounded
|
||||
// and the recallNote honest.
|
||||
const ids = (sessions?.sessions ?? [])
|
||||
@@ -459,7 +463,7 @@ async function fetchSessionEvents(
|
||||
const allEvents = await Promise.all(
|
||||
ids.map(async (sid) => {
|
||||
try {
|
||||
const d = await apiFetch(`/api/sessions/${sid}/events${q}`) as { events?: EventEntry[] };
|
||||
const d = await apiFetch(`/api/sessions/${sid}/events${q}`) as GetSessionEventsResponse;
|
||||
return (d?.events ?? []).map((e) => ({ ...e, _sessionId: sid }));
|
||||
} catch { return [] as EventEntry[]; }
|
||||
}),
|
||||
|
||||
@@ -8,6 +8,14 @@ import { assertKnownEventType, assertKnownDevicePropertyKeys } from '../resource
|
||||
import { interpolateAnalysisResults } from '../interpolate-rule-template.js';
|
||||
import { API_BASE_URL } from '../config.js';
|
||||
import { DIAG_ZIP_MAP } from '../diag-zip-map.js';
|
||||
import type {
|
||||
DiagnosticsDownloadTicketResponse,
|
||||
EnrollmentEvent,
|
||||
GetRuleResultsResponse,
|
||||
GetSessionAnnotationsResponse,
|
||||
GetSessionEventsResponse,
|
||||
GetSessionResponse,
|
||||
} from '../generated/wire-types.generated.js';
|
||||
|
||||
// ── Session summary constants ───────────────────────────────────────────
|
||||
|
||||
@@ -324,15 +332,13 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const { sessionId, tenantId: rawTenantId } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
|
||||
// The backend wraps the session in a { success, session } envelope (GetSessionFunction);
|
||||
// fall back to the raw object so both shapes work (mirrors get_session_summary).
|
||||
const sessionResp = await apiFetch(`/api/sessions/${sessionId}${q}`) as Record<string, unknown>;
|
||||
const session = (sessionResp.session ?? sessionResp) as Record<string, unknown>;
|
||||
// The { success, session } envelope is the pinned wire contract (GetSessionResponse).
|
||||
const sessionResp = await apiFetch(`/api/sessions/${sessionId}${q}`) as GetSessionResponse;
|
||||
const session = sessionResp.session;
|
||||
|
||||
const blobName = typeof session.diagnosticsBlobName === 'string' ? session.diagnosticsBlobName : '';
|
||||
const blobName = session.diagnosticsBlobName || '';
|
||||
// tenantId for the ticket: explicit arg → session's tenantId → (tenant user) JWT default.
|
||||
const resolvedTenantId = tenantId
|
||||
?? (typeof session.tenantId === 'string' ? session.tenantId : undefined);
|
||||
const resolvedTenantId = tenantId ?? session.tenantId;
|
||||
|
||||
if (!blobName) {
|
||||
return toolResultText({
|
||||
@@ -351,7 +357,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const ticket = await apiFetch(ticketPath, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ blobName }),
|
||||
}) as { url?: string; expiresAt?: string; blobName?: string; destination?: string; sizeBytes?: number | null };
|
||||
}) as DiagnosticsDownloadTicketResponse;
|
||||
|
||||
if (!ticket?.url) {
|
||||
return toolError('get_session_diagnostics', args,
|
||||
@@ -469,15 +475,15 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const fetchOpts = { signal: AbortSignal.timeout(90_000) };
|
||||
|
||||
const [sessionData, eventsData, analysisData, annotationsData] = await Promise.all([
|
||||
apiFetch(`/api/sessions/${sessionId}${q}`, fetchOpts) as Promise<Record<string, unknown>>,
|
||||
apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<{ events?: Array<Record<string, unknown>>; count?: number }>,
|
||||
apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise<Record<string, unknown> | null>,
|
||||
apiFetch(`/api/sessions/${sessionId}${q}`, fetchOpts) as Promise<GetSessionResponse>,
|
||||
apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<GetSessionEventsResponse>,
|
||||
apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise<GetRuleResultsResponse | null>,
|
||||
// Human annotations (verdict + note per lane). Backend filters the platform-internal
|
||||
// globaladmin lane for non-global callers — pass-through, no re-shaping needed.
|
||||
apiFetch(`/api/sessions/${sessionId}/annotations${q}`, fetchOpts).catch(() => null) as Promise<{ annotations?: Array<Record<string, unknown>> } | null>,
|
||||
apiFetch(`/api/sessions/${sessionId}/annotations${q}`, fetchOpts).catch(() => null) as Promise<GetSessionAnnotationsResponse | null>,
|
||||
]);
|
||||
|
||||
const s = (sessionData.session ?? sessionData) as Record<string, unknown>;
|
||||
const s = sessionData.session;
|
||||
|
||||
const overview = {
|
||||
sessionId,
|
||||
@@ -524,8 +530,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
// would inflate errorCount; replayed app_install_* would inflate the appInstalls
|
||||
// stats; replayed events would pollute the keyEvents triage timeline. One filter
|
||||
// cleans all three. stats.totalEvents therefore counts non-replayed events.
|
||||
const allEvents = ((eventsData?.events ?? []) as Array<Record<string, unknown>>)
|
||||
.filter((e) => !isHistoricImeReplay(e));
|
||||
const allEvents = (eventsData.events ?? []).filter((e) => !isHistoricImeReplay(e));
|
||||
|
||||
let errorCount = 0;
|
||||
let warningCount = 0;
|
||||
@@ -537,8 +542,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const sev = String(e.severity ?? '');
|
||||
// A compliant health-script detection mis-stamped script_failed/Error is benign —
|
||||
// exclude it so a green session's errorCount isn't inflated by routine compliance reports.
|
||||
const benign = isBenignHealthDetectionReport(
|
||||
String(e.eventType ?? ''), e.data as Record<string, unknown> | undefined);
|
||||
const benign = isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data);
|
||||
if (!benign && (sev === 'Error' || sev === 'Critical')) errorCount++;
|
||||
if (sev === 'Warning') warningCount++;
|
||||
const et = String(e.eventType ?? '');
|
||||
@@ -561,10 +565,9 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
return (SEVERITY_RANK[String(e.severity ?? '')] ?? -1) >= 2;
|
||||
});
|
||||
|
||||
const relevanceScore = (e: Record<string, unknown>): number => {
|
||||
const relevanceScore = (e: Partial<EnrollmentEvent>): number => {
|
||||
// Benign compliant detection mis-stamped Error → rank as info-level, not top.
|
||||
if (isBenignHealthDetectionReport(
|
||||
String(e.eventType ?? ''), e.data as Record<string, unknown> | undefined)) return 10;
|
||||
if (isBenignHealthDetectionReport(String(e.eventType ?? ''), e.data)) return 10;
|
||||
const sev = SEVERITY_RANK[String(e.severity ?? '')] ?? -1;
|
||||
if (sev >= 3) return 100; // Error/Critical
|
||||
if (PHASE_EVENT_TYPES.has(String(e.eventType ?? ''))) return 60;
|
||||
@@ -598,21 +601,20 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
// Resolve {{token}} placeholders from each result's matchedConditions
|
||||
// before mapping, so issues carry readable text not raw {{...}} tokens.
|
||||
interpolateAnalysisResults(analysisData);
|
||||
const a = analysisData as Record<string, unknown>;
|
||||
const allResults = (a.results ?? []) as Array<Record<string, unknown>>;
|
||||
const allResults = analysisData.results ?? [];
|
||||
// Resolved findings (session healed after an interim fire) stay in the raw
|
||||
// /analysis response for audit but are not open issues; interim findings are
|
||||
// preliminary until the enrollment-end pass finalizes them.
|
||||
const results = allResults.filter((r) => !r.resolvedAt);
|
||||
const resolvedCount = allResults.length - results.length;
|
||||
analysis = {
|
||||
totalIssues: a.totalIssues ?? results.length,
|
||||
criticalCount: a.criticalCount ?? 0,
|
||||
highCount: a.highCount ?? 0,
|
||||
warningCount: a.warningCount ?? 0,
|
||||
totalIssues: analysisData.totalIssues ?? results.length,
|
||||
criticalCount: analysisData.criticalCount ?? 0,
|
||||
highCount: analysisData.highCount ?? 0,
|
||||
warningCount: analysisData.warningCount ?? 0,
|
||||
...(resolvedCount > 0 ? { resolvedCount } : {}),
|
||||
issues: results.map((r) => ({
|
||||
ruleTitle: r.ruleTitle ?? r.title,
|
||||
ruleTitle: r.ruleTitle,
|
||||
severity: r.severity,
|
||||
...(r.isInterim ? { isInterim: true } : {}),
|
||||
explanation: r.explanation,
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
* - wire-types.generated.ts: one interface per wire object, one string-union per
|
||||
* wire enum, with the C# <summary> texts as JSDoc. These are the authoritative
|
||||
* response types — hand-written mirrors re-export from here.
|
||||
* - a second copy of the wire types into the MCP server
|
||||
* (src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts),
|
||||
* whose tools read the same backend responses.
|
||||
*
|
||||
* Run: node scripts/generate-shared-manifest-types.js (npm run generate:manifests)
|
||||
* Freshness of BOTH outputs is pinned by utils/__tests__/sharedManifestParity.test.ts.
|
||||
* Freshness of the web outputs is pinned by utils/__tests__/sharedManifestParity.test.ts,
|
||||
* the MCP copy by the MCP suite's wire-types-freshness.test.ts.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
@@ -22,12 +26,20 @@ const WEB_ROOT = path.resolve(__dirname, "..");
|
||||
const SOURCE = path.join(WEB_ROOT, "utils", "shared-manifests.json");
|
||||
const DEST_MANIFEST = path.join(WEB_ROOT, "utils", "shared-manifests.generated.ts");
|
||||
const DEST_TYPES = path.join(WEB_ROOT, "utils", "wire-types.generated.ts");
|
||||
// Second copy for the MCP server (same repo) — its tools read the same backend wire.
|
||||
const MCP_ROOT = path.resolve(WEB_ROOT, "..", "..", "McpServer", "autopilot-monitor-mcp");
|
||||
const DEST_MCP_TYPES = path.join(MCP_ROOT, "src", "generated", "wire-types.generated.ts");
|
||||
|
||||
const HEADER =
|
||||
"// GENERATED from shared-manifests.json — do not edit by hand.\n" +
|
||||
"// Regenerate: node scripts/generate-shared-manifest-types.js\n" +
|
||||
"// (after AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests)\n";
|
||||
|
||||
const MCP_HEADER =
|
||||
"// GENERATED — do not edit by hand. Second copy for the MCP server.\n" +
|
||||
"// Source: src/Web/autopilot-monitor-web/utils/shared-manifests.json.\n" +
|
||||
"// Regenerate: npm run generate:manifests in src/Web/autopilot-monitor-web.\n";
|
||||
|
||||
/** Pure builder so the vitest freshness check can reuse it. */
|
||||
function buildGeneratedSource(manifestJsonText) {
|
||||
const manifest = JSON.parse(manifestJsonText);
|
||||
@@ -40,6 +52,15 @@ function buildGeneratedSource(manifestJsonText) {
|
||||
|
||||
/** Pure builder for utils/wire-types.generated.ts (same freshness contract). */
|
||||
function buildWireTypesSource(manifestJsonText) {
|
||||
return buildWireTypesSourceWithHeader(manifestJsonText, HEADER);
|
||||
}
|
||||
|
||||
/** Pure builder for the MCP server's src/generated/wire-types.generated.ts copy. */
|
||||
function buildMcpWireTypesSource(manifestJsonText) {
|
||||
return buildWireTypesSourceWithHeader(manifestJsonText, MCP_HEADER);
|
||||
}
|
||||
|
||||
function buildWireTypesSourceWithHeader(manifestJsonText, header) {
|
||||
const manifest = JSON.parse(manifestJsonText);
|
||||
const types = manifest.types;
|
||||
if (!types || manifest.schemaVersion !== 2) {
|
||||
@@ -47,7 +68,7 @@ function buildWireTypesSource(manifestJsonText) {
|
||||
}
|
||||
|
||||
const parts = [
|
||||
HEADER +
|
||||
header +
|
||||
"//\n" +
|
||||
"// Wire response types reflected from AutopilotMonitor.Shared (every IApiResponse\n" +
|
||||
"// implementer + [WireContract] type, transitively closed). Key ORDER, presence\n" +
|
||||
@@ -116,10 +137,13 @@ function main() {
|
||||
console.log(`[shared-manifests] Wrote ${path.relative(WEB_ROOT, DEST_MANIFEST)}`);
|
||||
fs.writeFileSync(DEST_TYPES, buildWireTypesSource(json), "utf8");
|
||||
console.log(`[shared-manifests] Wrote ${path.relative(WEB_ROOT, DEST_TYPES)}`);
|
||||
fs.mkdirSync(path.dirname(DEST_MCP_TYPES), { recursive: true });
|
||||
fs.writeFileSync(DEST_MCP_TYPES, buildMcpWireTypesSource(json), "utf8");
|
||||
console.log(`[shared-manifests] Wrote ${path.relative(WEB_ROOT, DEST_MCP_TYPES)}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { buildGeneratedSource, buildWireTypesSource };
|
||||
module.exports = { buildGeneratedSource, buildWireTypesSource, buildMcpWireTypesSource };
|
||||
|
||||
Reference in New Issue
Block a user