Skip to content

Commit f2a3ba8

Browse files
authored
fix: normalize MCP elicitation requests with empty message (#895)
1 parent 5a756e4 commit f2a3ba8

File tree

8 files changed

+248
-5
lines changed

8 files changed

+248
-5
lines changed

.changeset/poor-otters-appear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@voltagent/core": patch
3+
---
4+
5+
fix: normalize MCP elicitation requests with empty `message` by falling back to the schema description so handlers receive a usable prompt.

examples/with-mcp-elicitation/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,27 @@ From the repo root you can also run:
3737
pnpm --filter voltagent-example-with-mcp-elicitation dev
3838
```
3939

40+
## Optional: Go HTTP MCP Server
41+
42+
Requires Go 1.23+ (the Go MCP SDK requires Go 1.23).
43+
44+
Start the Go server:
45+
46+
```bash
47+
cd examples/with-mcp-elicitation/go-server
48+
go run .
49+
```
50+
51+
Then run the VoltAgent example pointing at it:
52+
53+
```bash
54+
MCP_SERVER_URL=http://localhost:3142/mcp pnpm dev
55+
```
56+
57+
The Go server exposes the `customer_delete` tool and intentionally sends an empty
58+
elicitation message with a schema description so you can verify the client-side
59+
fallback.
60+
4061
## What It Does
4162

4263
- Starts a VoltAgent MCP server on `http://localhost:3142/mcp/mcp-elicitation-example/mcp`.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module voltagent-example-mcp-elicitation-go
2+
3+
go 1.23.0
4+
5+
require (
6+
github.com/google/jsonschema-go v0.3.0
7+
github.com/modelcontextprotocol/go-sdk v1.2.0
8+
)
9+
10+
require (
11+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
12+
golang.org/x/oauth2 v0.30.0 // indirect
13+
)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
2+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
3+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5+
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
6+
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
7+
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
8+
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
9+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
10+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
11+
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
12+
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
13+
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
14+
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"strings"
10+
11+
"github.com/google/jsonschema-go/jsonschema"
12+
"github.com/modelcontextprotocol/go-sdk/mcp"
13+
)
14+
15+
type DeleteCustomerParams struct {
16+
CustomerID string `json:"customerId" jsonschema:"Customer ID to delete"`
17+
}
18+
19+
func deleteCustomer(
20+
ctx context.Context,
21+
req *mcp.CallToolRequest,
22+
params DeleteCustomerParams,
23+
) (*mcp.CallToolResult, any, error) {
24+
schema := &jsonschema.Schema{
25+
Type: "object",
26+
Description: "Confirm the deletion of the data.",
27+
Properties: map[string]*jsonschema.Schema{
28+
"confirm": {Type: "boolean", Description: "Confirm the deletion of the data."},
29+
},
30+
Required: []string{"confirm"},
31+
}
32+
33+
result, err := req.Session.Elicit(ctx, &mcp.ElicitParams{
34+
// Intentionally blank to exercise client-side fallback to schema description.
35+
Message: "",
36+
RequestedSchema: schema,
37+
})
38+
if err != nil {
39+
return nil, nil, fmt.Errorf("eliciting failed: %w", err)
40+
}
41+
42+
confirmed := false
43+
if result != nil && result.Action == "accept" {
44+
if value, ok := result.Content["confirm"]; ok {
45+
switch typed := value.(type) {
46+
case bool:
47+
confirmed = typed
48+
case string:
49+
confirmed = strings.EqualFold(strings.TrimSpace(typed), "yes")
50+
default:
51+
confirmed = strings.EqualFold(strings.TrimSpace(fmt.Sprint(value)), "yes")
52+
}
53+
}
54+
}
55+
56+
message := fmt.Sprintf("Deletion cancelled for %s.", params.CustomerID)
57+
if confirmed {
58+
message = fmt.Sprintf("Customer %s deleted.", params.CustomerID)
59+
}
60+
61+
return &mcp.CallToolResult{
62+
Content: []mcp.Content{
63+
&mcp.TextContent{Text: message},
64+
},
65+
}, nil, nil
66+
}
67+
68+
func main() {
69+
host := flag.String("host", "127.0.0.1", "host to listen on")
70+
port := flag.Int("port", 3142, "port to listen on")
71+
flag.Parse()
72+
73+
addr := fmt.Sprintf("%s:%d", *host, *port)
74+
75+
server := mcp.NewServer(&mcp.Implementation{
76+
Name: "go-elicitation-server",
77+
Version: "0.1.0",
78+
}, nil)
79+
80+
mcp.AddTool(server, &mcp.Tool{
81+
Name: "customer_delete",
82+
Description: "Delete a customer after user confirmation.",
83+
}, deleteCustomer)
84+
85+
handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
86+
return server
87+
}, nil)
88+
89+
mux := http.NewServeMux()
90+
mux.Handle("/mcp", handler)
91+
92+
log.Printf("MCP server listening on http://%s/mcp", addr)
93+
if err := http.ListenAndServe(addr, mux); err != nil {
94+
log.Fatalf("server failed: %v", err)
95+
}
96+
}

examples/with-mcp-elicitation/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ const logger = createPinoLogger({
99
});
1010

1111
const port = Number.parseInt(process.env.MCP_SERVER_PORT ?? "3142", 10);
12-
13-
const { url } = await startMcpServer(port, logger);
12+
const serverUrl = "http://127.0.0.1:3142/mcp"; // process.env.MCP_SERVER_URL?.trim();
13+
const resolvedUrl = serverUrl || (await startMcpServer(port, logger)).url;
1414

1515
const mcpConfig = new MCPConfiguration({
1616
servers: {
1717
demo: {
1818
type: "http",
19-
url,
19+
url: resolvedUrl,
2020
},
2121
},
2222
});

packages/core/src/mcp/client/index.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,46 @@ describe("MCPClient", () => {
723723
});
724724
});
725725

726+
it("should fill empty elicitation message from schema description", async () => {
727+
const mockElicitationHandler = vi.fn().mockResolvedValue({
728+
action: "accept",
729+
content: { confirmed: true },
730+
});
731+
732+
new MCPClient({
733+
clientInfo: mockClientInfo,
734+
server: mockHttpServerConfig,
735+
elicitation: {
736+
onRequest: mockElicitationHandler,
737+
},
738+
});
739+
740+
const mockRequest = {
741+
params: {
742+
message: "",
743+
requestedSchema: {
744+
type: "object",
745+
properties: {
746+
confirm: {
747+
type: "string",
748+
description: "Confirm the deletion of the data.",
749+
},
750+
},
751+
required: ["confirm"],
752+
},
753+
},
754+
};
755+
756+
expect(capturedRequestHandler).toBeDefined();
757+
await capturedRequestHandler?.(mockRequest);
758+
759+
expect(mockElicitationHandler).toHaveBeenCalledWith(
760+
expect.objectContaining({
761+
message: "Confirm the deletion of the data.",
762+
}),
763+
);
764+
});
765+
726766
it("should use per-call elicitation handler from tool execution options", async () => {
727767
mockListTools.mockResolvedValue({
728768
tools: [

packages/core/src/mcp/client/user-input-bridge.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,14 @@ export class UserInputBridge {
152152
return { action: "cancel", content: undefined };
153153
}
154154

155+
const normalizedRequest = this.normalizeRequest(request);
156+
155157
try {
156158
this.logger.debug("Processing user input request", {
157-
message: request.message,
159+
message: normalizedRequest.message,
158160
});
159161

160-
const result = await this.handler(request);
162+
const result = await this.handler(normalizedRequest);
161163

162164
this.logger.debug("User input request processed", {
163165
action: result.action,
@@ -169,4 +171,56 @@ export class UserInputBridge {
169171
return { action: "cancel", content: undefined };
170172
}
171173
}
174+
175+
private normalizeRequest(request: ElicitRequest["params"]): ElicitRequest["params"] {
176+
if (typeof request.message === "string" && request.message.trim() !== "") {
177+
return request;
178+
}
179+
180+
const fallbackMessage = this.getFallbackMessage(request);
181+
if (!fallbackMessage) {
182+
return request;
183+
}
184+
185+
return { ...request, message: fallbackMessage };
186+
}
187+
188+
private getFallbackMessage(request: ElicitRequest["params"]): string | undefined {
189+
if (!("requestedSchema" in request)) {
190+
return undefined;
191+
}
192+
193+
const schema = request.requestedSchema as Record<string, unknown> | undefined;
194+
if (!schema || typeof schema !== "object") {
195+
return undefined;
196+
}
197+
198+
const description = schema.description;
199+
if (typeof description === "string" && description.trim() !== "") {
200+
return description.trim();
201+
}
202+
203+
const properties = schema.properties;
204+
if (!properties || typeof properties !== "object") {
205+
return undefined;
206+
}
207+
208+
for (const property of Object.values(properties as Record<string, unknown>)) {
209+
if (!property || typeof property !== "object") {
210+
continue;
211+
}
212+
213+
const propertyDescription = (property as Record<string, unknown>).description;
214+
if (typeof propertyDescription === "string" && propertyDescription.trim() !== "") {
215+
return propertyDescription.trim();
216+
}
217+
218+
const propertyTitle = (property as Record<string, unknown>).title;
219+
if (typeof propertyTitle === "string" && propertyTitle.trim() !== "") {
220+
return propertyTitle.trim();
221+
}
222+
}
223+
224+
return undefined;
225+
}
172226
}

0 commit comments

Comments
 (0)