diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index b3060f1fdf..a1de0e6a82 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -70,6 +70,7 @@ import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast
import { buildMcpClientTelemetry } from "../services/client-telemetry";
import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns";
import { buildRepoOutcomeCalibration, outcomeCalibrationSummary } from "../services/outcome-calibration";
+import { computeFleetAnalytics } from "../orb/analytics";
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
import {
applyMcpPlanningChoices,
@@ -145,6 +146,18 @@ const ownerRepoWindowShape = {
windowDays: z.number().int().positive().optional(),
};
+const windowOnlyShape = {
+ windowDays: z.number().int().positive().optional(),
+};
+
+const fleetAnalyticsOutputSchema = {
+ windowDays: z.number().optional(),
+ instanceCount: z.number().optional(),
+ fleet: z.unknown().optional(),
+ instances: z.array(z.unknown()).optional(),
+ outliers: z.array(z.unknown()).optional(),
+};
+
const loginShape = {
login: z.string().min(1),
};
@@ -1055,6 +1068,17 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.getOutcomeCalibration(input)),
);
+ server.registerTool(
+ "gittensory_get_fleet_analytics",
+ {
+ description:
+ "Operator-only: aggregated gate-calibration analytics across the self-host fleet — median merge/close precision, false-positive + reversal rates, cycle-time percentiles, and per-instance outliers. Measurement only.",
+ inputSchema: windowOnlyShape,
+ outputSchema: fleetAnalyticsOutputSchema,
+ },
+ async (input) => this.toolResult(await this.getFleetAnalytics(input)),
+ );
+
server.registerTool(
"gittensory_get_contributor_profile",
{
@@ -1923,6 +1947,25 @@ export class GittensoryMcp {
};
}
+ // Operator-only gate: the fleet view aggregates ALL self-hosters' calibration, so a session must be an
+ // operator; private-token / static identities are trusted (the same model as the other measurement tools).
+ private async requireOperatorAccess(): Promise {
+ if (this.identity.kind !== "session") return;
+ const scope = await this.loadSessionAccessScope();
+ if (scope.operator) return;
+ throw new Error("Forbidden: operator authority is required for fleet analytics.");
+ }
+
+ private async getFleetAnalytics(input: { windowDays?: number | undefined }): Promise {
+ await this.requireOperatorAccess();
+ const report = await computeFleetAnalytics(this.env, input.windowDays !== undefined ? { windowDays: input.windowDays } : {});
+ const merge = report.fleet.mergePrecision !== null ? `${Math.round(report.fleet.mergePrecision * 100)}%` : "n/a";
+ return {
+ summary: `Fleet calibration over ${report.windowDays}d: ${report.instanceCount} instance(s), median merge precision ${merge}, ${report.outliers.length} outlier(s).`,
+ data: report as unknown as Record,
+ };
+ }
+
private async loadOpenQueueCounts(fullName: string): Promise<{ openIssues: number; openPullRequests: number }> {
const [totals, openIssues, openPullRequests] = await Promise.all([
getLatestRepoGithubTotalsSnapshot(this.env, fullName),
diff --git a/src/services/operator-dashboard.ts b/src/services/operator-dashboard.ts
index 10954d4fbb..134bc49a80 100644
--- a/src/services/operator-dashboard.ts
+++ b/src/services/operator-dashboard.ts
@@ -25,6 +25,7 @@ import type {
ScoringModelSnapshotRecord,
WeeklyValueReport,
} from "../types";
+import { computeFleetAnalytics, type FleetAnalytics } from "../orb/analytics";
import { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset";
import { nowIso } from "../utils/json";
import { buildRecommendationQualityReport, type RecommendationQualityReport } from "./recommendation-quality-report";
@@ -57,6 +58,7 @@ export type OperatorDashboardPayload = {
registry: RegistrySnapshot | null;
scoringModel: ScoringModelSnapshotRecord | null;
upstreamDrift: UpstreamStatus;
+ fleetMetrics: FleetAnalytics;
};
const USAGE_WINDOW_DAYS = 7;
@@ -79,6 +81,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise 0 ? `${fleetMetrics.outliers.length} outlier(s)` : "self-host fleet",
+ },
+ {
+ label: "Fleet merge precision",
+ value: fleetMetrics.fleet.mergePrecision !== null ? `${Math.round(fleetMetrics.fleet.mergePrecision * 100)}%` : "—",
+ delta: "median across the fleet",
+ },
],
noiseReduction: [
{
@@ -177,6 +191,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise {
+ const server = (identity ? new GittensoryMcp(env, identity) : new GittensoryMcp(env)).createServer();
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ await server.connect(serverTransport);
+ const client = new Client({ name: "fleet-test", version: "0.1.0" }, { capabilities: {} });
+ await client.connect(clientTransport);
+ return client;
+}
+
+let seq = 0;
+async function seedMergeSignals(env: Env, instance: string, n: number): Promise {
+ for (let i = 0; i < n; i++) {
+ await env.DB
+ .prepare(`INSERT INTO orb_signals (instance_id, repo_hash, pr_hash, gate_verdict, outcome, reversal_flag) VALUES (?, ?, ?, 'merge', 'merged', 'none')`)
+ .bind(instance, `repo${seq}`, `pr${seq++}`)
+ .run();
+ }
+}
+
+describe("gittensory_get_fleet_analytics MCP tool", () => {
+ it("returns fleet analytics for a trusted (non-session) identity, honoring windowDays", async () => {
+ const env = createTestEnv();
+ await seedMergeSignals(env, "inst1", 5); // ≥ MIN_DECIDED → counts toward the fleet
+ const result = await (await connect(env)).callTool({ name: "gittensory_get_fleet_analytics", arguments: { windowDays: 30 } });
+ expect(result.isError).toBeFalsy();
+ const data = result.structuredContent as { windowDays: number; instanceCount: number; fleet: { mergePrecision: number } };
+ expect(data.windowDays).toBe(30);
+ expect(data.instanceCount).toBe(1);
+ expect(data.fleet.mergePrecision).toBe(1);
+ });
+
+ it("returns an empty report (n/a summary) when there is no data and no windowDays", async () => {
+ const result = await (await connect(createTestEnv())).callTool({ name: "gittensory_get_fleet_analytics", arguments: {} });
+ expect(result.isError).toBeFalsy();
+ const data = result.structuredContent as { instanceCount: number };
+ expect(data.instanceCount).toBe(0);
+ });
+
+ it("allows an operator session", async () => {
+ const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "boss" });
+ const { session } = await createSessionForGitHubUser(env, { login: "boss", id: 1 });
+ const result = await (await connect(env, { kind: "session", actor: "boss", session })).callTool({ name: "gittensory_get_fleet_analytics", arguments: {} });
+ expect(result.isError).toBeFalsy();
+ });
+
+ it("forbids a non-operator session", async () => {
+ const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "boss" });
+ const { session } = await createSessionForGitHubUser(env, { login: "rando", id: 2 });
+ const result = await (await connect(env, { kind: "session", actor: "rando", session })).callTool({ name: "gittensory_get_fleet_analytics", arguments: {} });
+ expect(result.isError).toBeTruthy();
+ expect(JSON.stringify(result.content)).toMatch(/operator authority/i);
+ });
+});
diff --git a/test/unit/operator-dashboard.test.ts b/test/unit/operator-dashboard.test.ts
index 532e37cf61..70c40066e4 100644
--- a/test/unit/operator-dashboard.test.ts
+++ b/test/unit/operator-dashboard.test.ts
@@ -23,6 +23,39 @@ describe("operator dashboard payload", () => {
expect(payload.usageSummary).toMatchObject({ totalEvents: expect.any(Number), activeActors: expect.any(Number) });
expect(payload.commandUsefulness.totals).toMatchObject({ feedbackCount: expect.any(Number) });
expect(serialized).not.toMatch(FORBIDDEN_EXPORT_TERMS);
+ // Empty fleet → instanceCount 0, null precision card ("—"), no-outlier delta.
+ expect(payload.fleetMetrics.instanceCount).toBe(0);
+ expect(payload.metrics).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: "Fleet instances", value: "0", delta: "self-host fleet" }),
+ expect.objectContaining({ label: "Fleet merge precision", value: "—" }),
+ ]),
+ );
+ });
+
+ it("surfaces populated fleet metrics + outliers from orb_signals", async () => {
+ const env = createTestEnv();
+ let n = 0;
+ const seed = async (instance: string, count: number, outcome: string): Promise => {
+ for (let i = 0; i < count; i++) {
+ await env.DB
+ .prepare(`INSERT INTO orb_signals (instance_id, repo_hash, pr_hash, gate_verdict, outcome, reversal_flag) VALUES (?, ?, ?, 'merge', ?, 'none')`)
+ .bind(instance, `r${n}`, `p${n++}`, outcome)
+ .run();
+ }
+ };
+ await seed("good1", 5, "merged"); // precision 1.0
+ await seed("good2", 5, "merged"); // precision 1.0
+ await seed("bad", 5, "closed"); // precision 0.0 → outlier vs the median (1.0)
+ const payload = await buildOperatorDashboardPayload(env);
+ expect(payload.fleetMetrics.instanceCount).toBe(3);
+ expect(payload.fleetMetrics.outliers.map((o) => o.instanceId)).toContain("bad");
+ expect(payload.metrics).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: "Fleet instances", value: "3", delta: "1 outlier(s)" }),
+ expect.objectContaining({ label: "Fleet merge precision", value: "100%" }),
+ ]),
+ );
});
it("picks the newest rollup day for adoption insights", () => {