mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-02-19 11:54:58 -05:00
feat: add async sampling and elicitation tools
Add tools that demonstrate bidirectional MCP tasks where the server sends requests to the client for async execution: - trigger-sampling-request-async: Send sampling request with task params, client creates task and executes LLM call in background, server polls for completion and retrieves result - trigger-elicitation-request-async: Same pattern for user input, useful when user may take time to fill out forms Both tools: - Check client capabilities (tasks.requests.sampling/elicitation) - Accept both CreateTaskResult and direct result responses - Poll tasks/get for status updates - Fetch final result via tasks/result Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@
|
||||
- `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts): Starts or stops simulated resource update notifications for URIs the invoking session has subscribed to.
|
||||
- `trigger-sampling-request` (tools/trigger-sampling-request.ts): Issues a `sampling/createMessage` request to the client/LLM using provided `prompt` and optional generation controls; returns the LLM's response payload.
|
||||
- `simulate-research-query` (tools/simulate-research-query.ts): Demonstrates MCP Tasks (SEP-1686) with a simulated multi-stage research operation. Accepts `topic` and `ambiguous` parameters. Returns a task that progresses through stages with status updates. If `ambiguous` is true and client supports elicitation, pauses with `input_required` status to gather clarification.
|
||||
- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts): Demonstrates bidirectional tasks where the server sends a sampling request that the client executes as a background task. Server polls for status and retrieves the LLM result when complete. Requires client to support `tasks.requests.sampling.createMessage`.
|
||||
- `trigger-elicitation-request-async` (tools/trigger-elicitation-request-async.ts): Demonstrates bidirectional tasks where the server sends an elicitation request that the client executes as a background task. Server polls while waiting for user input. Requires client to support `tasks.requests.elicitation.create`.
|
||||
|
||||
## Prompts
|
||||
|
||||
@@ -75,6 +77,26 @@ The server advertises support for MCP Tasks, enabling long-running operations wi
|
||||
- `failed`: Task encountered an error
|
||||
- `cancelled`: Task was cancelled by client
|
||||
|
||||
### Demo Tool
|
||||
### Demo Tools
|
||||
|
||||
**Server-side tasks (client calls server):**
|
||||
Use the `simulate-research-query` tool to exercise the full task lifecycle. Set `ambiguous: true` to trigger the `input_required` flow with elicitation.
|
||||
|
||||
**Client-side tasks (server calls client):**
|
||||
Use `trigger-sampling-request-async` or `trigger-elicitation-request-async` to demonstrate bidirectional tasks where the server sends requests that the client executes as background tasks. These require the client to advertise `tasks.requests.sampling.createMessage` or `tasks.requests.elicitation.create` capabilities respectively.
|
||||
|
||||
### Bidirectional Task Flow
|
||||
|
||||
MCP Tasks are bidirectional - both server and client can be task executors:
|
||||
|
||||
| Direction | Request Type | Task Executor | Demo Tool |
|
||||
|-----------|--------------|---------------|-----------|
|
||||
| Client -> Server | `tools/call` | Server | `simulate-research-query` |
|
||||
| Server -> Client | `sampling/createMessage` | Client | `trigger-sampling-request-async` |
|
||||
| Server -> Client | `elicitation/create` | Client | `trigger-elicitation-request-async` |
|
||||
|
||||
For client-side tasks:
|
||||
1. Server sends request with task metadata (e.g., `_meta.task.ttl`)
|
||||
2. Client creates task and returns `CreateTaskResult` with `taskId`
|
||||
3. Server polls `tasks/get` for status updates
|
||||
4. When complete, server calls `tasks/result` to retrieve the result
|
||||
|
||||
@@ -14,6 +14,8 @@ import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates
|
||||
import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js";
|
||||
import { registerTriggerLongRunningOperationTool } from "./trigger-long-running-operation.js";
|
||||
import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js";
|
||||
import { registerTriggerSamplingRequestAsyncTool } from "./trigger-sampling-request-async.js";
|
||||
import { registerTriggerElicitationRequestAsyncTool } from "./trigger-elicitation-request-async.js";
|
||||
import { registerSimulateResearchQueryTool } from "./simulate-research-query.js";
|
||||
|
||||
/**
|
||||
@@ -45,4 +47,7 @@ export const registerConditionalTools = (server: McpServer) => {
|
||||
registerTriggerSamplingRequestTool(server);
|
||||
// Task-based research tool (uses experimental tasks API)
|
||||
registerSimulateResearchQueryTool(server);
|
||||
// Bidirectional task tools - server sends requests that client executes as tasks
|
||||
registerTriggerSamplingRequestAsyncTool(server);
|
||||
registerTriggerElicitationRequestAsyncTool(server);
|
||||
};
|
||||
|
||||
242
src/everything/tools/trigger-elicitation-request-async.ts
Normal file
242
src/everything/tools/trigger-elicitation-request-async.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
|
||||
// Tool configuration
|
||||
const name = "trigger-elicitation-request-async";
|
||||
const config = {
|
||||
title: "Trigger Async Elicitation Request Tool",
|
||||
description:
|
||||
"Trigger an async elicitation request that the CLIENT executes as a background task. " +
|
||||
"Demonstrates bidirectional MCP tasks where the server sends an elicitation request and " +
|
||||
"the client handles user input asynchronously, allowing the server to poll for completion.",
|
||||
inputSchema: {},
|
||||
};
|
||||
|
||||
// Poll interval in milliseconds
|
||||
const POLL_INTERVAL = 1000;
|
||||
|
||||
// Maximum poll attempts before timeout (10 minutes for user input)
|
||||
const MAX_POLL_ATTEMPTS = 600;
|
||||
|
||||
/**
|
||||
* Registers the 'trigger-elicitation-request-async' tool.
|
||||
*
|
||||
* This tool demonstrates bidirectional MCP tasks for elicitation:
|
||||
* - Server sends elicitation request to client with task metadata
|
||||
* - Client creates a task and returns CreateTaskResult
|
||||
* - Client prompts user for input (task status: input_required)
|
||||
* - Server polls client's tasks/get endpoint for status
|
||||
* - Server fetches final result from client's tasks/result endpoint
|
||||
*
|
||||
* @param {McpServer} server - The McpServer instance where the tool will be registered.
|
||||
*/
|
||||
export const registerTriggerElicitationRequestAsyncTool = (server: McpServer) => {
|
||||
// Check client capabilities
|
||||
const clientCapabilities = server.server.getClientCapabilities() || {};
|
||||
|
||||
// Client must support elicitation AND tasks.requests.elicitation
|
||||
const clientSupportsElicitation = clientCapabilities.elicitation !== undefined;
|
||||
const clientTasksCapability = clientCapabilities.tasks as {
|
||||
requests?: { elicitation?: { create?: object } };
|
||||
} | undefined;
|
||||
const clientSupportsAsyncElicitation =
|
||||
clientTasksCapability?.requests?.elicitation?.create !== undefined;
|
||||
|
||||
if (clientSupportsElicitation && clientSupportsAsyncElicitation) {
|
||||
server.registerTool(
|
||||
name,
|
||||
config,
|
||||
async (args, extra): Promise<CallToolResult> => {
|
||||
// Create the elicitation request WITH task metadata
|
||||
// Using z.any() schema to avoid complex type matching with _meta
|
||||
const request = {
|
||||
method: "elicitation/create" as const,
|
||||
params: {
|
||||
message: "Please provide inputs for the following fields (async task demo):",
|
||||
requestedSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: {
|
||||
title: "Your Name",
|
||||
type: "string" as const,
|
||||
description: "Your full name",
|
||||
},
|
||||
favoriteColor: {
|
||||
title: "Favorite Color",
|
||||
type: "string" as const,
|
||||
description: "What is your favorite color?",
|
||||
enum: ["Red", "Blue", "Green", "Yellow", "Purple"],
|
||||
},
|
||||
agreeToTerms: {
|
||||
title: "Terms Agreement",
|
||||
type: "boolean" as const,
|
||||
description: "Do you agree to the terms and conditions?",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
_meta: {
|
||||
task: {
|
||||
ttl: 600000, // 10 minutes (user input may take a while)
|
||||
pollInterval: POLL_INTERVAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Send the elicitation request
|
||||
// Client may return either:
|
||||
// - ElicitResult (synchronous execution)
|
||||
// - CreateTaskResult (task-based execution with { task } object)
|
||||
const elicitResponse = await extra.sendRequest(
|
||||
request as Parameters<typeof extra.sendRequest>[0],
|
||||
z.union([
|
||||
// CreateTaskResult - client created a task
|
||||
z.object({
|
||||
task: z.object({
|
||||
taskId: z.string(),
|
||||
status: z.string(),
|
||||
pollInterval: z.number().optional(),
|
||||
statusMessage: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
// ElicitResult - synchronous execution
|
||||
z.object({
|
||||
action: z.string(),
|
||||
content: z.any().optional(),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// Check if client returned CreateTaskResult (has task object)
|
||||
const isTaskResult = 'task' in elicitResponse && elicitResponse.task;
|
||||
if (!isTaskResult) {
|
||||
// Client executed synchronously - return the direct response
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[SYNC] Client executed synchronously:\n${JSON.stringify(elicitResponse, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const taskId = elicitResponse.task.taskId;
|
||||
const statusMessages: string[] = [];
|
||||
statusMessages.push(`Task created: ${taskId}`);
|
||||
|
||||
// Poll for task completion
|
||||
let attempts = 0;
|
||||
let taskStatus = elicitResponse.task.status;
|
||||
let taskStatusMessage: string | undefined;
|
||||
|
||||
while (
|
||||
taskStatus !== "completed" &&
|
||||
taskStatus !== "failed" &&
|
||||
taskStatus !== "cancelled" &&
|
||||
attempts < MAX_POLL_ATTEMPTS
|
||||
) {
|
||||
// Wait before polling
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
||||
attempts++;
|
||||
|
||||
// Get task status from client
|
||||
const pollResult = await extra.sendRequest(
|
||||
{
|
||||
method: "tasks/get",
|
||||
params: { taskId },
|
||||
},
|
||||
z.object({
|
||||
status: z.string(),
|
||||
statusMessage: z.string().optional(),
|
||||
}).passthrough()
|
||||
);
|
||||
|
||||
taskStatus = pollResult.status;
|
||||
taskStatusMessage = pollResult.statusMessage;
|
||||
|
||||
// Only log status changes or every 10 polls to avoid spam
|
||||
if (attempts === 1 || attempts % 10 === 0 || taskStatus !== "input_required") {
|
||||
statusMessages.push(
|
||||
`Poll ${attempts}: ${taskStatus}${taskStatusMessage ? ` - ${taskStatusMessage}` : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (attempts >= MAX_POLL_ATTEMPTS) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[TIMEOUT] Task timed out after ${MAX_POLL_ATTEMPTS} poll attempts\n\nProgress:\n${statusMessages.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for failure/cancellation
|
||||
if (taskStatus === "failed" || taskStatus === "cancelled") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[${taskStatus.toUpperCase()}] ${taskStatusMessage || "No message"}\n\nProgress:\n${statusMessages.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch the final result
|
||||
const result = await extra.sendRequest(
|
||||
{
|
||||
method: "tasks/result",
|
||||
params: { taskId },
|
||||
},
|
||||
z.any()
|
||||
);
|
||||
|
||||
// Format the elicitation result
|
||||
const content: CallToolResult["content"] = [];
|
||||
|
||||
if (result.action === "accept" && result.content) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `[COMPLETED] User provided the requested information!`,
|
||||
});
|
||||
|
||||
const userData = result.content as Record<string, unknown>;
|
||||
const lines = [];
|
||||
if (userData.name) lines.push(`- Name: ${userData.name}`);
|
||||
if (userData.favoriteColor) lines.push(`- Favorite Color: ${userData.favoriteColor}`);
|
||||
if (userData.agreeToTerms !== undefined) lines.push(`- Agreed to terms: ${userData.agreeToTerms}`);
|
||||
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `User inputs:\n${lines.join("\n")}`,
|
||||
});
|
||||
} else if (result.action === "decline") {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `[DECLINED] User declined to provide the requested information.`,
|
||||
});
|
||||
} else if (result.action === "cancel") {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `[CANCELLED] User cancelled the elicitation dialog.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Include progress and raw result for debugging
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `\nProgress:\n${statusMessages.join("\n")}\n\nRaw result: ${JSON.stringify(result, null, 2)}`,
|
||||
});
|
||||
|
||||
return { content };
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
214
src/everything/tools/trigger-sampling-request-async.ts
Normal file
214
src/everything/tools/trigger-sampling-request-async.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import {
|
||||
CallToolResult,
|
||||
CreateMessageRequest,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
|
||||
// Tool input schema
|
||||
const TriggerSamplingRequestAsyncSchema = z.object({
|
||||
prompt: z.string().describe("The prompt to send to the LLM"),
|
||||
maxTokens: z
|
||||
.number()
|
||||
.default(100)
|
||||
.describe("Maximum number of tokens to generate"),
|
||||
});
|
||||
|
||||
// Tool configuration
|
||||
const name = "trigger-sampling-request-async";
|
||||
const config = {
|
||||
title: "Trigger Async Sampling Request Tool",
|
||||
description:
|
||||
"Trigger an async sampling request that the CLIENT executes as a background task. " +
|
||||
"Demonstrates bidirectional MCP tasks where the server sends a request and the client " +
|
||||
"executes it asynchronously, allowing the server to poll for progress and results.",
|
||||
inputSchema: TriggerSamplingRequestAsyncSchema,
|
||||
};
|
||||
|
||||
// Poll interval in milliseconds
|
||||
const POLL_INTERVAL = 1000;
|
||||
|
||||
// Maximum poll attempts before timeout
|
||||
const MAX_POLL_ATTEMPTS = 60;
|
||||
|
||||
/**
|
||||
* Registers the 'trigger-sampling-request-async' tool.
|
||||
*
|
||||
* This tool demonstrates bidirectional MCP tasks:
|
||||
* - Server sends sampling request to client with task metadata
|
||||
* - Client creates a task and returns CreateTaskResult
|
||||
* - Server polls client's tasks/get endpoint for status
|
||||
* - Server fetches final result from client's tasks/result endpoint
|
||||
*
|
||||
* @param {McpServer} server - The McpServer instance where the tool will be registered.
|
||||
*/
|
||||
export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => {
|
||||
// Check client capabilities
|
||||
const clientCapabilities = server.server.getClientCapabilities() || {};
|
||||
|
||||
// Client must support sampling AND tasks.requests.sampling
|
||||
const clientSupportsSampling = clientCapabilities.sampling !== undefined;
|
||||
const clientTasksCapability = clientCapabilities.tasks as {
|
||||
requests?: { sampling?: { createMessage?: object } };
|
||||
} | undefined;
|
||||
const clientSupportsAsyncSampling =
|
||||
clientTasksCapability?.requests?.sampling?.createMessage !== undefined;
|
||||
|
||||
if (clientSupportsSampling && clientSupportsAsyncSampling) {
|
||||
server.registerTool(
|
||||
name,
|
||||
config,
|
||||
async (args, extra): Promise<CallToolResult> => {
|
||||
const validatedArgs = TriggerSamplingRequestAsyncSchema.parse(args);
|
||||
const { prompt, maxTokens } = validatedArgs;
|
||||
|
||||
// Create the sampling request WITH task metadata
|
||||
// The _meta.task field signals to the client that this should be executed as a task
|
||||
const request: CreateMessageRequest & { params: { _meta?: { task: { ttl: number; pollInterval: number } } } } = {
|
||||
method: "sampling/createMessage",
|
||||
params: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Resource ${name} context: ${prompt}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
systemPrompt: "You are a helpful test server.",
|
||||
maxTokens,
|
||||
temperature: 0.7,
|
||||
_meta: {
|
||||
task: {
|
||||
ttl: 300000, // 5 minutes
|
||||
pollInterval: POLL_INTERVAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Send the sampling request
|
||||
// Client may return either:
|
||||
// - CreateMessageResult (synchronous execution)
|
||||
// - CreateTaskResult (task-based execution with { task } object)
|
||||
const samplingResponse = await extra.sendRequest(
|
||||
request,
|
||||
z.union([
|
||||
// CreateTaskResult - client created a task
|
||||
z.object({
|
||||
task: z.object({
|
||||
taskId: z.string(),
|
||||
status: z.string(),
|
||||
pollInterval: z.number().optional(),
|
||||
statusMessage: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
// CreateMessageResult - synchronous execution
|
||||
z.object({
|
||||
role: z.string(),
|
||||
content: z.any(),
|
||||
model: z.string(),
|
||||
stopReason: z.string().optional(),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// Check if client returned CreateTaskResult (has task object)
|
||||
const isTaskResult = 'task' in samplingResponse && samplingResponse.task;
|
||||
if (!isTaskResult) {
|
||||
// Client executed synchronously - return the direct response
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[SYNC] Client executed synchronously:\n${JSON.stringify(samplingResponse, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const taskId = samplingResponse.task.taskId;
|
||||
const statusMessages: string[] = [];
|
||||
statusMessages.push(`Task created: ${taskId}`);
|
||||
|
||||
// Poll for task completion
|
||||
let attempts = 0;
|
||||
let taskStatus = samplingResponse.task.status;
|
||||
let taskStatusMessage: string | undefined;
|
||||
|
||||
while (
|
||||
taskStatus !== "completed" &&
|
||||
taskStatus !== "failed" &&
|
||||
taskStatus !== "cancelled" &&
|
||||
attempts < MAX_POLL_ATTEMPTS
|
||||
) {
|
||||
// Wait before polling
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
||||
attempts++;
|
||||
|
||||
// Get task status from client
|
||||
const pollResult = await extra.sendRequest(
|
||||
{
|
||||
method: "tasks/get",
|
||||
params: { taskId },
|
||||
},
|
||||
z.object({
|
||||
status: z.string(),
|
||||
statusMessage: z.string().optional(),
|
||||
}).passthrough()
|
||||
);
|
||||
|
||||
taskStatus = pollResult.status;
|
||||
taskStatusMessage = pollResult.statusMessage;
|
||||
statusMessages.push(
|
||||
`Poll ${attempts}: ${taskStatus}${taskStatusMessage ? ` - ${taskStatusMessage}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (attempts >= MAX_POLL_ATTEMPTS) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[TIMEOUT] Task timed out after ${MAX_POLL_ATTEMPTS} poll attempts\n\nProgress:\n${statusMessages.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for failure/cancellation
|
||||
if (taskStatus === "failed" || taskStatus === "cancelled") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[${taskStatus.toUpperCase()}] ${taskStatusMessage || "No message"}\n\nProgress:\n${statusMessages.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch the final result
|
||||
const result = await extra.sendRequest(
|
||||
{
|
||||
method: "tasks/result",
|
||||
params: { taskId },
|
||||
},
|
||||
z.any()
|
||||
);
|
||||
|
||||
// Return the result with status history
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[COMPLETED] Async sampling completed!\n\n**Progress:**\n${statusMessages.join("\n")}\n\n**Result:**\n${JSON.stringify(result, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user