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:
Oliver Kieselbach
2026-08-31 03:19:47 +02:00
parent d055eed074
commit c04280cf68
9 changed files with 2897 additions and 54 deletions
+1 -1
View File
@@ -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 };