Skip to content

Commit 9dcdc25

Browse files
added ElicitRequest to MCP
1 parent 8574760 commit 9dcdc25

1 file changed

Lines changed: 113 additions & 12 deletions

File tree

src/main/mcp/mcpServer.ts

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3-
import { CallToolRequestSchema, CallToolResult, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3+
import { CallToolRequestSchema, CallToolResult, ElicitRequestFormParams, ListToolsRequestSchema, PrimitiveSchemaDefinition } from "@modelcontextprotocol/sdk/types.js";
44
import { promises as fs } from "fs";
55
import { createServer, IncomingMessage, ServerResponse } from "http";
66
import os from "os";
@@ -17,6 +17,7 @@ import { ToolWindowManager } from "../managers/toolWindowManager";
1717
import { logInvocation } from "./agentInvocationLogger";
1818
import { AgentExecutionMode, AgentInvocationMode, AgentTool, getAgentInvokableTools, resolveToolId } from "./agentToolRegistry";
1919
import { createHeadlessLogger, invokeHeadlessTool } from "./headlessToolRuntime";
20+
import { JsonObjectSchema } from "./schemaConverter";
2021

2122
const MCP_AUTH_HEADER = "x-mcp-auth-token";
2223
const MCP_AUTH_HEADER_DISPLAY_NAME = "X-MCP-Auth-Token";
@@ -98,6 +99,54 @@ function stripInvocationMeta(args: Record<string, unknown>): Record<string, unkn
9899
return clone;
99100
}
100101

102+
/**
103+
* Parses validation errors to find top-level required-field-missing errors.
104+
* Only top-level fields ($.fieldName) are returned since elicitation supports flat schemas only.
105+
*/
106+
function extractMissingFieldNames(errors: string[]): string[] {
107+
const missing: string[] = [];
108+
for (const err of errors) {
109+
const match = /^\$\.([^.:]+): required field is missing$/.exec(err);
110+
if (match?.[1]) {
111+
missing.push(match[1]);
112+
}
113+
}
114+
return missing;
115+
}
116+
117+
/**
118+
* Builds a flat elicitation requestedSchema from missing required fields in the tool's input schema.
119+
* Returns null if any missing field cannot be represented as a primitive (e.g. object or array types),
120+
* since form-based elicitation only supports flat primitive values.
121+
*/
122+
function buildElicitationProperties(inputSchema: JsonObjectSchema, fieldNames: string[]): Record<string, PrimitiveSchemaDefinition> | null {
123+
const properties = isRecord(inputSchema.properties) ? inputSchema.properties : {};
124+
const result: Record<string, PrimitiveSchemaDefinition> = {};
125+
126+
for (const name of fieldNames) {
127+
const prop = isRecord(properties[name]) ? (properties[name] as Record<string, unknown>) : undefined;
128+
const type = typeof prop?.type === "string" ? prop.type : undefined;
129+
const description = typeof prop?.description === "string" ? prop.description : undefined;
130+
const title = typeof prop?.title === "string" ? prop.title : undefined;
131+
const enumValues = Array.isArray(prop?.enum) ? (prop.enum as unknown[]).filter((v): v is string => typeof v === "string") : undefined;
132+
133+
if (type === "boolean") {
134+
result[name] = { type: "boolean", ...(title ? { title } : {}), ...(description ? { description } : {}) };
135+
} else if (type === "number" || type === "integer") {
136+
result[name] = { type: "number", ...(title ? { title } : {}), ...(description ? { description } : {}) };
137+
} else if (enumValues && enumValues.length > 0) {
138+
result[name] = { type: "string", enum: enumValues, ...(title ? { title } : {}), ...(description ? { description } : {}) };
139+
} else if (type === "string" || type === undefined) {
140+
result[name] = { type: "string", ...(title ? { title } : {}), ...(description ? { description } : {}) };
141+
} else {
142+
// Object or array — cannot be entered via a flat elicitation form
143+
return null;
144+
}
145+
}
146+
147+
return Object.keys(result).length > 0 ? result : null;
148+
}
149+
101150
function createInvocationError(text: string): CallToolResult {
102151
return {
103152
content: [{ type: "text", text }],
@@ -643,7 +692,7 @@ export class McpServerManager {
643692
const toolIdFromName = resolveToolId(request.params.name);
644693
const toolArgs = isRecord(request.params.arguments) ? request.params.arguments : {};
645694
const invocationMeta = parseInvocationMeta(toolArgs);
646-
const prefillData = stripInvocationMeta(toolArgs);
695+
let prefillData = stripInvocationMeta(toolArgs);
647696

648697
const agentTools = await this.getAgentTools();
649698
const matchedTool = agentTools.find((tool) => tool.toolId === toolIdFromName);
@@ -664,16 +713,68 @@ export class McpServerManager {
664713
const displayName = matchedTool.displayName;
665714
const inputValidationErrors = validateAgainstSchema(prefillData, matchedTool.inputSchema);
666715
if (inputValidationErrors.length > 0) {
667-
const errorText = `Input validation failed: ${inputValidationErrors.join("; ")}`;
668-
logInvocationWithMeta({
669-
toolId,
670-
toolName: displayName,
671-
connectionId: null,
672-
prefillData,
673-
outcome: "rejected",
674-
error: errorText,
675-
});
676-
return createInvocationError(errorText);
716+
const missingFields = extractMissingFieldNames(inputValidationErrors);
717+
let elicitedSuccessfully = false;
718+
719+
// Attempt elicitation only when every validation error is a missing required field
720+
// and all of those fields can be represented as flat primitives in a form.
721+
if (missingFields.length > 0 && missingFields.length === inputValidationErrors.length) {
722+
const elicitProperties = buildElicitationProperties(matchedTool.inputSchema, missingFields);
723+
if (elicitProperties) {
724+
try {
725+
const clientCaps = server.server.getClientCapabilities();
726+
if (clientCaps?.elicitation?.form) {
727+
const elicitParams: ElicitRequestFormParams = {
728+
message: `The tool "${displayName}" requires the following parameters. Please provide values to continue.`,
729+
requestedSchema: {
730+
type: "object",
731+
properties: elicitProperties,
732+
required: missingFields,
733+
},
734+
};
735+
const elicitResult = await server.server.elicitInput(elicitParams);
736+
737+
if (elicitResult.action === "accept" && isRecord(elicitResult.content)) {
738+
const mergedPrefill = { ...prefillData, ...elicitResult.content };
739+
const revalidationErrors = validateAgainstSchema(mergedPrefill, matchedTool.inputSchema);
740+
if (revalidationErrors.length === 0) {
741+
prefillData = mergedPrefill;
742+
elicitedSuccessfully = true;
743+
} else {
744+
const errorText = `Input validation failed after elicitation: ${revalidationErrors.join("; ")}`;
745+
logInvocationWithMeta({ toolId, toolName: displayName, connectionId: null, prefillData, outcome: "rejected", error: errorText });
746+
return createInvocationError(errorText);
747+
}
748+
} else {
749+
logInvocationWithMeta({
750+
toolId,
751+
toolName: displayName,
752+
connectionId: null,
753+
prefillData,
754+
outcome: "rejected",
755+
error: "User declined or cancelled parameter elicitation",
756+
});
757+
return createInvocationError(`Tool invocation cancelled: required parameters were not provided for "${displayName}".`);
758+
}
759+
}
760+
} catch {
761+
// Elicitation not supported or failed; fall through to the validation error below
762+
}
763+
}
764+
}
765+
766+
if (!elicitedSuccessfully) {
767+
const errorText = `Input validation failed: ${inputValidationErrors.join("; ")}`;
768+
logInvocationWithMeta({
769+
toolId,
770+
toolName: displayName,
771+
connectionId: null,
772+
prefillData,
773+
outcome: "rejected",
774+
error: errorText,
775+
});
776+
return createInvocationError(errorText);
777+
}
677778
}
678779

679780
let executionMode: AgentExecutionMode;

0 commit comments

Comments
 (0)