From ec45cf53ff350fcc8bd658c928c54e0390575ad4 Mon Sep 17 00:00:00 2001 From: bitloi Date: Thu, 4 Jun 2026 08:34:35 +0200 Subject: [PATCH] feat(mcp): add safe planning elicitation --- src/mcp/server.ts | 51 ++++- src/services/mcp-planning-elicitation.ts | 156 +++++++++++++++ test/unit/mcp-planning-elicitation.test.ts | 216 +++++++++++++++++++++ 3 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 src/services/mcp-planning-elicitation.ts create mode 100644 test/unit/mcp-planning-elicitation.test.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 09116f7c8d..ab90750d22 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,6 +1,8 @@ import { createMcpHandler } from "agents/mcp"; import type { Context } from "hono"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { ElicitResultSchema, type ServerNotification, type ServerRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { authenticatePrivateToken, extractBearerToken, type AuthIdentity } from "../auth/security"; import { loadControlPanelRoleSummary } from "../services/control-panel-roles"; @@ -44,6 +46,14 @@ import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; +import { + applyMcpPlanningChoices, + buildMcpPlanningElicitationAudit, + buildMcpPlanningElicitationRequest, + planningChoicesFromElicitationResult, + validateMcpPlanningElicitationRequest, + type McpPlanningChoices, +} from "../services/mcp-planning-elicitation"; import { buildBountyAdvisory, buildCollisionReport, @@ -70,6 +80,7 @@ type ToolPayload = { summary: string; data: Record; }; +type McpToolExtra = RequestHandlerExtra; function decisionPackSummary(login: string, freshness: string, rebuildEnqueued: boolean): string { if (freshness === "fresh") return `Gittensory decision pack for ${login}.`; @@ -663,7 +674,7 @@ export class GittensoryMcp { description: "Run the deterministic Gittensory base-agent planner and rank the next Gittensor OSS contribution actions.", inputSchema: agentPlanShape, }, - async (input) => this.toolResult(await this.agentPlanNextWork(input)), + async (input, extra) => this.toolResult(await this.agentPlanNextWork(input, extra, server)), ); server.registerTool( @@ -1104,15 +1115,47 @@ export class GittensoryMcp { }; } - private async agentPlanNextWork(input: z.infer>): Promise { + private async agentPlanNextWork( + input: z.infer>, + extra?: McpToolExtra, + mcpServer?: McpServer, + ): Promise { this.requireContributorAccess(input.login); - const bundle = await planNextWork(this.env, { ...input, surface: "mcp" }); + const elicitation = await this.collectPlanningChoices(input, extra, mcpServer); + const planInput = applyMcpPlanningChoices(input, elicitation.choices); + const bundle = await planNextWork(this.env, { ...planInput, surface: "mcp" }); return { summary: `Gittensory base-agent plan for ${input.login}.`, - data: bundle as unknown as Record, + data: { + ...bundle, + planningElicitation: buildMcpPlanningElicitationAudit(elicitation, elicitation.choices), + planningChoices: elicitation.choices, + } as unknown as Record, }; } + private async collectPlanningChoices( + input: z.infer>, + extra?: McpToolExtra, + mcpServer?: McpServer, + ): Promise<{ supported: boolean; requested: boolean; accepted: boolean; choices: McpPlanningChoices }> { + const elicitationCapabilities = mcpServer?.server.getClientCapabilities()?.elicitation; + const supportsFormElicitation = Boolean( + extra && elicitationCapabilities && (elicitationCapabilities.form || Object.keys(elicitationCapabilities).length === 0), + ); + if (!extra || !supportsFormElicitation) return { supported: false, requested: false, accepted: false, choices: {} }; + if (input.objective && input.repoFullName) return { supported: true, requested: false, accepted: false, choices: {} }; + const request = buildMcpPlanningElicitationRequest(); + validateMcpPlanningElicitationRequest(request); + try { + const result = await extra.sendRequest({ method: "elicitation/create", params: request }, ElicitResultSchema, { timeout: 1000 }); + const choices = planningChoicesFromElicitationResult(result); + return { supported: true, requested: true, accepted: result.action === "accept", choices }; + } catch { + return { supported: true, requested: true, accepted: false, choices: {} }; + } + } + private async agentStartRun(input: z.infer>): Promise { this.requireContributorAccess(input.actorLogin); const bundle = await startAgentRun(this.env, { diff --git a/src/services/mcp-planning-elicitation.ts b/src/services/mcp-planning-elicitation.ts new file mode 100644 index 0000000000..1e64a73fae --- /dev/null +++ b/src/services/mcp-planning-elicitation.ts @@ -0,0 +1,156 @@ +import type { ElicitRequestFormParams, ElicitResult } from "@modelcontextprotocol/sdk/types.js"; + +export const MCP_PLANNING_ELICITATION_FIELDS = [ + "repoFullName", + "contributionLane", + "timeHorizon", + "riskAppetite", + "cleanupFirst", +] as const; + +export type McpPlanningElicitationField = (typeof MCP_PLANNING_ELICITATION_FIELDS)[number]; + +export type McpPlanningChoices = Partial<{ + repoFullName: string; + contributionLane: "any" | "direct_pr" | "issue_discovery" | "cleanup"; + timeHorizon: "today" | "this_week" | "this_month"; + riskAppetite: "low" | "medium" | "high"; + cleanupFirst: boolean; +}>; + +export type McpPlanningElicitationAudit = { + supported: boolean; + requested: boolean; + accepted: boolean; + fields: McpPlanningElicitationField[]; +}; + +export type McpAgentPlanInput = { + login: string; + objective?: string | undefined; + repoFullName?: string | undefined; +}; + +const CONTRIBUTION_LANES = ["any", "direct_pr", "issue_discovery", "cleanup"] as const; +const TIME_HORIZONS = ["today", "this_week", "this_month"] as const; +const RISK_APPETITES = ["low", "medium", "high"] as const; +const REPO_FULL_NAME_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const SENSITIVE_FIELD_RE = + /\b(token|secret|wallet|hotkey|coldkey|private\s*keys?|pat|mnemonic|seed\s*phrase|private\s*maintainer\s*evidence)\b/i; + +function enumChoice(value: unknown, allowed: T): T[number] | undefined { + return typeof value === "string" && (allowed as readonly string[]).includes(value) ? (value as T[number]) : undefined; +} + +function stringChoice(value: unknown, maxLength: number): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim(); + if (!normalized || normalized.length > maxLength || SENSITIVE_FIELD_RE.test(normalized)) return undefined; + return normalized; +} + +export function buildMcpPlanningElicitationRequest(): ElicitRequestFormParams { + return { + mode: "form", + message: "Choose optional public planning preferences for ranking Gittensory contribution work.", + requestedSchema: { + type: "object", + properties: { + repoFullName: { + type: "string", + title: "Repository", + description: "Optional public GitHub repository in owner/name form.", + minLength: 3, + maxLength: 120, + }, + contributionLane: { + type: "string", + title: "Contribution lane", + description: "Preferred kind of public contribution work.", + enum: [...CONTRIBUTION_LANES], + default: "any", + }, + timeHorizon: { + type: "string", + title: "Time horizon", + description: "How soon the contribution should be practical.", + enum: [...TIME_HORIZONS], + default: "this_week", + }, + riskAppetite: { + type: "string", + title: "Risk appetite", + description: "Preferred review and implementation risk level.", + enum: [...RISK_APPETITES], + default: "medium", + }, + cleanupFirst: { + type: "boolean", + title: "Prefer cleanup first", + description: "Prefer small cleanup or stabilization work before larger features.", + default: false, + }, + }, + required: [], + }, + }; +} + +export function validateMcpPlanningElicitationRequest(request: ElicitRequestFormParams): void { + const fieldNames = Object.keys(request.requestedSchema.properties); + const expected = new Set(MCP_PLANNING_ELICITATION_FIELDS); + const unexpected = fieldNames.filter((field) => !expected.has(field)); + const missing = MCP_PLANNING_ELICITATION_FIELDS.filter((field) => !fieldNames.includes(field)); + const serialized = JSON.stringify(request); + if (unexpected.length > 0 || missing.length > 0 || SENSITIVE_FIELD_RE.test(serialized)) { + throw new Error("Unsafe MCP planning elicitation request."); + } +} + +export function planningChoicesFromElicitationResult(result: ElicitResult): McpPlanningChoices { + if (result.action !== "accept" || !result.content) return {}; + const content = result.content; + const choices: McpPlanningChoices = {}; + const repoFullName = stringChoice(content.repoFullName, 120); + if (repoFullName && REPO_FULL_NAME_RE.test(repoFullName)) choices.repoFullName = repoFullName; + const contributionLane = enumChoice(content.contributionLane, CONTRIBUTION_LANES); + if (contributionLane) choices.contributionLane = contributionLane; + const timeHorizon = enumChoice(content.timeHorizon, TIME_HORIZONS); + if (timeHorizon) choices.timeHorizon = timeHorizon; + const riskAppetite = enumChoice(content.riskAppetite, RISK_APPETITES); + if (riskAppetite) choices.riskAppetite = riskAppetite; + if (typeof content.cleanupFirst === "boolean") choices.cleanupFirst = content.cleanupFirst; + return choices; +} + +export function applyMcpPlanningChoices(input: McpAgentPlanInput, choices: McpPlanningChoices): McpAgentPlanInput { + const output: McpAgentPlanInput = { ...input }; + if (!output.repoFullName && choices.repoFullName) output.repoFullName = choices.repoFullName; + if (!output.objective && hasPlanningChoices(choices)) { + const parts = [ + choices.repoFullName ? `repo ${choices.repoFullName}` : undefined, + choices.contributionLane ? `lane ${choices.contributionLane}` : undefined, + choices.timeHorizon ? `time horizon ${choices.timeHorizon}` : undefined, + choices.riskAppetite ? `risk appetite ${choices.riskAppetite}` : undefined, + choices.cleanupFirst === true ? "prefer cleanup first" : choices.cleanupFirst === false ? "cleanup first optional" : undefined, + ].filter(Boolean); + output.objective = `Plan the next Gittensor OSS contribution action with ${parts.join(", ")}.`; + } + return output; +} + +export function buildMcpPlanningElicitationAudit( + input: { supported: boolean; requested: boolean; accepted: boolean }, + choices: McpPlanningChoices, +): McpPlanningElicitationAudit { + return { + supported: input.supported, + requested: input.requested, + accepted: input.accepted, + fields: MCP_PLANNING_ELICITATION_FIELDS.filter((field) => choices[field] !== undefined), + }; +} + +function hasPlanningChoices(choices: McpPlanningChoices): boolean { + return MCP_PLANNING_ELICITATION_FIELDS.some((field) => choices[field] !== undefined); +} diff --git a/test/unit/mcp-planning-elicitation.test.ts b/test/unit/mcp-planning-elicitation.test.ts new file mode 100644 index 0000000000..004616d566 --- /dev/null +++ b/test/unit/mcp-planning-elicitation.test.ts @@ -0,0 +1,216 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ElicitRequestSchema, type ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; +import { describe, expect, it } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { + applyMcpPlanningChoices, + buildMcpPlanningElicitationAudit, + buildMcpPlanningElicitationRequest, + MCP_PLANNING_ELICITATION_FIELDS, + planningChoicesFromElicitationResult, + validateMcpPlanningElicitationRequest, +} from "../../src/services/mcp-planning-elicitation"; +import { createTestEnv } from "../helpers/d1"; + +async function connectTestClient(capabilities: ClientCapabilities) { + const mcpServer = new GittensoryMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.connect(serverTransport); + const client = new Client({ name: "gittensory-planning-elicitation-test", version: "0.1.0" }, { capabilities }); + await client.connect(clientTransport); + return { client, mcpServer }; +} + +describe("MCP planning elicitation", () => { + it("builds an allowlisted form request without sensitive planning fields", () => { + const request = buildMcpPlanningElicitationRequest(); + validateMcpPlanningElicitationRequest(request); + expect(Object.keys(request.requestedSchema.properties)).toEqual([...MCP_PLANNING_ELICITATION_FIELDS]); + expect(JSON.stringify(request)).not.toMatch( + /token|secret|wallet|hotkey|coldkey|private keys?|pat|mnemonic|seed phrase|private maintainer evidence/i, + ); + }); + + it("blocks request fixtures that add sensitive fields", () => { + const request = buildMcpPlanningElicitationRequest(); + request.requestedSchema.properties.wallet = { + type: "string", + title: "Wallet", + description: "Wallet address", + }; + expect(() => validateMcpPlanningElicitationRequest(request)).toThrow("Unsafe MCP planning elicitation request."); + }); + + it("blocks request fixtures with missing fields or sensitive descriptions", () => { + const missing = buildMcpPlanningElicitationRequest(); + delete missing.requestedSchema.properties.cleanupFirst; + expect(() => validateMcpPlanningElicitationRequest(missing)).toThrow("Unsafe MCP planning elicitation request."); + + const sensitive = buildMcpPlanningElicitationRequest(); + sensitive.requestedSchema.properties.repoFullName = { + type: "string", + title: "Repository", + description: "Token-backed repository context", + minLength: 3, + maxLength: 120, + }; + expect(() => validateMcpPlanningElicitationRequest(sensitive)).toThrow("Unsafe MCP planning elicitation request."); + }); + + it("sanitizes accepted content down to safe planning choices", () => { + const choices = planningChoicesFromElicitationResult({ + action: "accept", + content: { + repoFullName: "JSONbored/gittensory", + contributionLane: "direct_pr", + timeHorizon: "this_week", + riskAppetite: "medium", + cleanupFirst: true, + token: "github_pat_private", + wallet: "coldkey", + }, + }); + expect(choices).toEqual({ + repoFullName: "JSONbored/gittensory", + contributionLane: "direct_pr", + timeHorizon: "this_week", + riskAppetite: "medium", + cleanupFirst: true, + }); + expect(JSON.stringify(choices)).not.toMatch(/token|wallet|hotkey|coldkey|github_pat_private/i); + }); + + it("drops declined, missing, invalid, and sensitive elicitation content", () => { + expect(planningChoicesFromElicitationResult({ action: "decline" })).toEqual({}); + expect(planningChoicesFromElicitationResult({ action: "accept" })).toEqual({}); + expect( + planningChoicesFromElicitationResult({ + action: "accept", + content: { + repoFullName: "not-a-repo", + contributionLane: "secret", + timeHorizon: "someday", + riskAppetite: "extreme", + cleanupFirst: "yes", + }, + }), + ).toEqual({}); + expect( + planningChoicesFromElicitationResult({ action: "accept", content: { repoFullName: "secret-owner/repo" } }), + ).toEqual({}); + }); + + it("keeps explicit planner input while applying missing safe choices", () => { + expect( + applyMcpPlanningChoices( + { login: "oktofeesh1", objective: "Use the explicit objective.", repoFullName: "explicit/repo" }, + { repoFullName: "ignored/repo", contributionLane: "cleanup" }, + ), + ).toEqual({ login: "oktofeesh1", objective: "Use the explicit objective.", repoFullName: "explicit/repo" }); + expect( + applyMcpPlanningChoices( + { login: "oktofeesh1" }, + { repoFullName: "JSONbored/gittensory" }, + ), + ).toMatchObject({ + login: "oktofeesh1", + repoFullName: "JSONbored/gittensory", + objective: expect.stringContaining("repo JSONbored/gittensory"), + }); + expect(applyMcpPlanningChoices({ login: "oktofeesh1" }, {})).toEqual({ login: "oktofeesh1" }); + expect(applyMcpPlanningChoices({ login: "oktofeesh1" }, { cleanupFirst: false })).toMatchObject({ + objective: expect.stringContaining("cleanup first optional"), + }); + }); + + it("summarizes accepted fields for public audit output", () => { + expect( + buildMcpPlanningElicitationAudit( + { supported: true, requested: true, accepted: true }, + { repoFullName: "JSONbored/gittensory", cleanupFirst: false }, + ), + ).toEqual({ + supported: true, + requested: true, + accepted: true, + fields: ["repoFullName", "cleanupFirst"], + }); + }); + + it("uses form elicitation when the MCP client supports it", async () => { + const { client, mcpServer } = await connectTestClient({ elicitation: { form: {} } }); + let requestPayload = ""; + client.setRequestHandler(ElicitRequestSchema, async (request) => { + requestPayload = JSON.stringify(request.params); + return { + action: "accept", + content: { + repoFullName: "JSONbored/gittensory", + contributionLane: "cleanup", + timeHorizon: "today", + riskAppetite: "low", + cleanupFirst: true, + hotkey: "should-be-ignored", + }, + }; + }); + const result = await client.callTool({ name: "gittensory_agent_plan_next_work", arguments: { login: "oktofeesh1" } }); + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + expect(requestPayload).not.toBe(""); + expect(requestPayload).not.toMatch(/token|secret|wallet|hotkey|coldkey|private keys?|pat|mnemonic|private maintainer evidence/i); + const data = result.structuredContent as Record; + expect(data.planningElicitation).toEqual({ + supported: true, + requested: true, + accepted: true, + fields: ["repoFullName", "contributionLane", "timeHorizon", "riskAppetite", "cleanupFirst"], + }); + expect(data.planningChoices).toMatchObject({ repoFullName: "JSONbored/gittensory", cleanupFirst: true }); + expect(JSON.stringify(data.planningChoices)).not.toMatch(/hotkey|should-be-ignored/i); + await mcpServer.close(); + }); + + it("treats empty elicitation capabilities as form-capable", async () => { + const { client, mcpServer } = await connectTestClient({ elicitation: {} }); + client.setRequestHandler(ElicitRequestSchema, async () => ({ + action: "accept", + content: { repoFullName: "JSONbored/gittensory" }, + })); + const result = await client.callTool({ name: "gittensory_agent_plan_next_work", arguments: { login: "oktofeesh1" } }); + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.planningElicitation).toMatchObject({ supported: true, requested: true, accepted: true }); + expect(data.planningChoices).toEqual({ repoFullName: "JSONbored/gittensory" }); + await mcpServer.close(); + }); + + it("does not elicit when explicit planner context is already supplied", async () => { + const { client, mcpServer } = await connectTestClient({ elicitation: { form: {} } }); + let requestCount = 0; + client.setRequestHandler(ElicitRequestSchema, async () => { + requestCount += 1; + return { action: "accept", content: { repoFullName: "ignored/repo" } }; + }); + const result = await client.callTool({ + name: "gittensory_agent_plan_next_work", + arguments: { login: "oktofeesh1", objective: "Use explicit context.", repoFullName: "JSONbored/gittensory" }, + }); + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + const data = result.structuredContent as Record; + expect(requestCount).toBe(0); + expect(data.planningElicitation).toEqual({ supported: true, requested: false, accepted: false, fields: [] }); + expect(data.planningChoices).toEqual({}); + await mcpServer.close(); + }); + + it("falls back without elicitation for unsupported MCP clients", async () => { + const { client, mcpServer } = await connectTestClient({}); + const result = await client.callTool({ name: "gittensory_agent_plan_next_work", arguments: { login: "oktofeesh1" } }); + expect(result.isError, JSON.stringify(result.content)).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.planningElicitation).toEqual({ supported: false, requested: false, accepted: false, fields: [] }); + expect(data.planningChoices).toEqual({}); + await mcpServer.close(); + }); +});