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:
olaservo
2026-01-10 08:57:40 -07:00
parent 0208e93f85
commit 9d863fb7e6
4 changed files with 484 additions and 1 deletions

View File

@@ -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

View File

@@ -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);
};

View 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 };
}
);
}
};

View 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)}`,
},
],
};
}
);
}
};