diff --git a/.changeset/poor-otters-appear.md b/.changeset/poor-otters-appear.md new file mode 100644 index 000000000..28ff34d11 --- /dev/null +++ b/.changeset/poor-otters-appear.md @@ -0,0 +1,5 @@ +--- +"@voltagent/core": patch +--- + +fix: normalize MCP elicitation requests with empty `message` by falling back to the schema description so handlers receive a usable prompt. diff --git a/examples/with-mcp-elicitation/README.md b/examples/with-mcp-elicitation/README.md index 4e6af17bd..8c0fa80c8 100644 --- a/examples/with-mcp-elicitation/README.md +++ b/examples/with-mcp-elicitation/README.md @@ -37,6 +37,27 @@ From the repo root you can also run: pnpm --filter voltagent-example-with-mcp-elicitation dev ``` +## Optional: Go HTTP MCP Server + +Requires Go 1.23+ (the Go MCP SDK requires Go 1.23). + +Start the Go server: + +```bash +cd examples/with-mcp-elicitation/go-server +go run . +``` + +Then run the VoltAgent example pointing at it: + +```bash +MCP_SERVER_URL=http://localhost:3142/mcp pnpm dev +``` + +The Go server exposes the `customer_delete` tool and intentionally sends an empty +elicitation message with a schema description so you can verify the client-side +fallback. + ## What It Does - Starts a VoltAgent MCP server on `http://localhost:3142/mcp/mcp-elicitation-example/mcp`. diff --git a/examples/with-mcp-elicitation/go-server/go.mod b/examples/with-mcp-elicitation/go-server/go.mod new file mode 100644 index 000000000..3f8be370c --- /dev/null +++ b/examples/with-mcp-elicitation/go-server/go.mod @@ -0,0 +1,13 @@ +module voltagent-example-mcp-elicitation-go + +go 1.23.0 + +require ( + github.com/google/jsonschema-go v0.3.0 + github.com/modelcontextprotocol/go-sdk v1.2.0 +) + +require ( + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect +) diff --git a/examples/with-mcp-elicitation/go-server/go.sum b/examples/with-mcp-elicitation/go-server/go.sum new file mode 100644 index 000000000..3bc80f364 --- /dev/null +++ b/examples/with-mcp-elicitation/go-server/go.sum @@ -0,0 +1,14 @@ +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= +github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/with-mcp-elicitation/go-server/main.go b/examples/with-mcp-elicitation/go-server/main.go new file mode 100644 index 000000000..a2bbc8024 --- /dev/null +++ b/examples/with-mcp-elicitation/go-server/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "strings" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type DeleteCustomerParams struct { + CustomerID string `json:"customerId" jsonschema:"Customer ID to delete"` +} + +func deleteCustomer( + ctx context.Context, + req *mcp.CallToolRequest, + params DeleteCustomerParams, +) (*mcp.CallToolResult, any, error) { + schema := &jsonschema.Schema{ + Type: "object", + Description: "Confirm the deletion of the data.", + Properties: map[string]*jsonschema.Schema{ + "confirm": {Type: "boolean", Description: "Confirm the deletion of the data."}, + }, + Required: []string{"confirm"}, + } + + result, err := req.Session.Elicit(ctx, &mcp.ElicitParams{ + // Intentionally blank to exercise client-side fallback to schema description. + Message: "", + RequestedSchema: schema, + }) + if err != nil { + return nil, nil, fmt.Errorf("eliciting failed: %w", err) + } + + confirmed := false + if result != nil && result.Action == "accept" { + if value, ok := result.Content["confirm"]; ok { + switch typed := value.(type) { + case bool: + confirmed = typed + case string: + confirmed = strings.EqualFold(strings.TrimSpace(typed), "yes") + default: + confirmed = strings.EqualFold(strings.TrimSpace(fmt.Sprint(value)), "yes") + } + } + } + + message := fmt.Sprintf("Deletion cancelled for %s.", params.CustomerID) + if confirmed { + message = fmt.Sprintf("Customer %s deleted.", params.CustomerID) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: message}, + }, + }, nil, nil +} + +func main() { + host := flag.String("host", "127.0.0.1", "host to listen on") + port := flag.Int("port", 3142, "port to listen on") + flag.Parse() + + addr := fmt.Sprintf("%s:%d", *host, *port) + + server := mcp.NewServer(&mcp.Implementation{ + Name: "go-elicitation-server", + Version: "0.1.0", + }, nil) + + mcp.AddTool(server, &mcp.Tool{ + Name: "customer_delete", + Description: "Delete a customer after user confirmation.", + }, deleteCustomer) + + handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { + return server + }, nil) + + mux := http.NewServeMux() + mux.Handle("/mcp", handler) + + log.Printf("MCP server listening on http://%s/mcp", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("server failed: %v", err) + } +} diff --git a/examples/with-mcp-elicitation/src/index.ts b/examples/with-mcp-elicitation/src/index.ts index 8fe8b47e4..a06e6bef9 100644 --- a/examples/with-mcp-elicitation/src/index.ts +++ b/examples/with-mcp-elicitation/src/index.ts @@ -9,14 +9,14 @@ const logger = createPinoLogger({ }); const port = Number.parseInt(process.env.MCP_SERVER_PORT ?? "3142", 10); - -const { url } = await startMcpServer(port, logger); +const serverUrl = "http://127.0.0.1:3142/mcp"; // process.env.MCP_SERVER_URL?.trim(); +const resolvedUrl = serverUrl || (await startMcpServer(port, logger)).url; const mcpConfig = new MCPConfiguration({ servers: { demo: { type: "http", - url, + url: resolvedUrl, }, }, }); diff --git a/packages/core/src/mcp/client/index.spec.ts b/packages/core/src/mcp/client/index.spec.ts index eee87b649..90a6f1cfd 100644 --- a/packages/core/src/mcp/client/index.spec.ts +++ b/packages/core/src/mcp/client/index.spec.ts @@ -723,6 +723,46 @@ describe("MCPClient", () => { }); }); + it("should fill empty elicitation message from schema description", async () => { + const mockElicitationHandler = vi.fn().mockResolvedValue({ + action: "accept", + content: { confirmed: true }, + }); + + new MCPClient({ + clientInfo: mockClientInfo, + server: mockHttpServerConfig, + elicitation: { + onRequest: mockElicitationHandler, + }, + }); + + const mockRequest = { + params: { + message: "", + requestedSchema: { + type: "object", + properties: { + confirm: { + type: "string", + description: "Confirm the deletion of the data.", + }, + }, + required: ["confirm"], + }, + }, + }; + + expect(capturedRequestHandler).toBeDefined(); + await capturedRequestHandler?.(mockRequest); + + expect(mockElicitationHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Confirm the deletion of the data.", + }), + ); + }); + it("should use per-call elicitation handler from tool execution options", async () => { mockListTools.mockResolvedValue({ tools: [ diff --git a/packages/core/src/mcp/client/user-input-bridge.ts b/packages/core/src/mcp/client/user-input-bridge.ts index 5ccc1fd2e..6b1fd88bc 100644 --- a/packages/core/src/mcp/client/user-input-bridge.ts +++ b/packages/core/src/mcp/client/user-input-bridge.ts @@ -152,12 +152,14 @@ export class UserInputBridge { return { action: "cancel", content: undefined }; } + const normalizedRequest = this.normalizeRequest(request); + try { this.logger.debug("Processing user input request", { - message: request.message, + message: normalizedRequest.message, }); - const result = await this.handler(request); + const result = await this.handler(normalizedRequest); this.logger.debug("User input request processed", { action: result.action, @@ -169,4 +171,56 @@ export class UserInputBridge { return { action: "cancel", content: undefined }; } } + + private normalizeRequest(request: ElicitRequest["params"]): ElicitRequest["params"] { + if (typeof request.message === "string" && request.message.trim() !== "") { + return request; + } + + const fallbackMessage = this.getFallbackMessage(request); + if (!fallbackMessage) { + return request; + } + + return { ...request, message: fallbackMessage }; + } + + private getFallbackMessage(request: ElicitRequest["params"]): string | undefined { + if (!("requestedSchema" in request)) { + return undefined; + } + + const schema = request.requestedSchema as Record | undefined; + if (!schema || typeof schema !== "object") { + return undefined; + } + + const description = schema.description; + if (typeof description === "string" && description.trim() !== "") { + return description.trim(); + } + + const properties = schema.properties; + if (!properties || typeof properties !== "object") { + return undefined; + } + + for (const property of Object.values(properties as Record)) { + if (!property || typeof property !== "object") { + continue; + } + + const propertyDescription = (property as Record).description; + if (typeof propertyDescription === "string" && propertyDescription.trim() !== "") { + return propertyDescription.trim(); + } + + const propertyTitle = (property as Record).title; + if (typeof propertyTitle === "string" && propertyTitle.trim() !== "") { + return propertyTitle.trim(); + } + } + + return undefined; + } }