Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poor-otters-appear.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions examples/with-mcp-elicitation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 13 additions & 0 deletions examples/with-mcp-elicitation/go-server/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
14 changes: 14 additions & 0 deletions examples/with-mcp-elicitation/go-server/go.sum
Original file line number Diff line number Diff line change
@@ -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=
96 changes: 96 additions & 0 deletions examples/with-mcp-elicitation/go-server/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 3 additions & 3 deletions examples/with-mcp-elicitation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Dead code: startMcpServer(port, logger) will never be called because serverUrl is hardcoded to a truthy string. The || fallback is unreachable. Either remove the fallback or use a conditional that can actually be falsy (e.g., uncomment the env var access).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/with-mcp-elicitation/src/index.ts, line 13:

<comment>Dead code: `startMcpServer(port, logger)` will never be called because `serverUrl` is hardcoded to a truthy string. The `||` fallback is unreachable. Either remove the fallback or use a conditional that can actually be falsy (e.g., uncomment the env var access).</comment>

<file context>
@@ -9,14 +9,14 @@ const logger = createPinoLogger({
-
-const { url } = await startMcpServer(port, logger);
+const serverUrl = &quot;http://127.0.0.1:3142/mcp&quot;; // process.env.MCP_SERVER_URL?.trim();
+const resolvedUrl = serverUrl || (await startMcpServer(port, logger)).url;
 
 const mcpConfig = new MCPConfiguration({
</file context>
Fix with Cubic


const mcpConfig = new MCPConfiguration({
servers: {
demo: {
type: "http",
url,
url: resolvedUrl,
},
},
});
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/mcp/client/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
58 changes: 56 additions & 2 deletions packages/core/src/mcp/client/user-input-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown> | 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<string, unknown>)) {
if (!property || typeof property !== "object") {
continue;
}

const propertyDescription = (property as Record<string, unknown>).description;
if (typeof propertyDescription === "string" && propertyDescription.trim() !== "") {
return propertyDescription.trim();
}

const propertyTitle = (property as Record<string, unknown>).title;
if (typeof propertyTitle === "string" && propertyTitle.trim() !== "") {
return propertyTitle.trim();
}
}

return undefined;
}
}