Skip to content

Commit a207af5

Browse files
committed
fix(everything): use relatedTask option to enable elicitation over HTTP (#3228)
1 parent f424458 commit a207af5

2 files changed

Lines changed: 99 additions & 20 deletions

File tree

src/everything/__tests__/tools.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { registerTriggerSamplingRequestTool } from '../tools/trigger-sampling-re
1515
import { registerTriggerElicitationRequestTool } from '../tools/trigger-elicitation-request.js';
1616
import { registerGetRootsListTool } from '../tools/get-roots-list.js';
1717
import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js';
18+
import { registerSimulateResearchQueryTool } from '../tools/simulate-research-query.js';
1819

1920
// Helper to capture registered tool handlers
2021
function createMockServer() {
@@ -738,6 +739,90 @@ describe('Tools', () => {
738739
});
739740
});
740741

742+
describe('simulate-research-query', () => {
743+
function createMockServerWithTasks() {
744+
const taskHandlers: Record<string, any> = {};
745+
const mockServer = {
746+
experimental: {
747+
tasks: {
748+
registerToolTask: vi.fn((_name: string, _config: any, handler: any) => {
749+
Object.assign(taskHandlers, handler);
750+
}),
751+
},
752+
},
753+
server: { getClientCapabilities: vi.fn(() => ({ elicitation: {} })) },
754+
} as unknown as McpServer;
755+
return { mockServer, taskHandlers };
756+
}
757+
758+
function createMockTaskStore(taskId: string) {
759+
return {
760+
createTask: vi.fn().mockResolvedValue({
761+
taskId,
762+
status: 'working',
763+
createdAt: new Date().toISOString(),
764+
lastUpdatedAt: new Date().toISOString(),
765+
ttl: 300000,
766+
pollInterval: 1000,
767+
}),
768+
updateTaskStatus: vi.fn().mockResolvedValue(undefined),
769+
storeTaskResult: vi.fn().mockResolvedValue(undefined),
770+
getTask: vi.fn(),
771+
getTaskResult: vi.fn(),
772+
};
773+
}
774+
775+
it('should pass relatedTask to sendRequest when elicitation is triggered', async () => {
776+
vi.useFakeTimers();
777+
778+
const { mockServer, taskHandlers } = createMockServerWithTasks();
779+
registerSimulateResearchQueryTool(mockServer);
780+
781+
const mockTaskStore = createMockTaskStore('task-abc');
782+
const mockSendRequest = vi.fn().mockResolvedValue({
783+
action: 'accept',
784+
content: { interpretation: 'technical' },
785+
});
786+
787+
await taskHandlers.createTask(
788+
{ topic: 'python', ambiguous: true },
789+
{ taskStore: mockTaskStore, sendRequest: mockSendRequest }
790+
);
791+
792+
await vi.runAllTimersAsync();
793+
vi.useRealTimers();
794+
795+
expect(mockSendRequest).toHaveBeenCalledWith(
796+
expect.objectContaining({ method: 'elicitation/create' }),
797+
expect.anything(),
798+
expect.objectContaining({ relatedTask: { taskId: 'task-abc' } })
799+
);
800+
});
801+
802+
it('should complete without elicitation for non-ambiguous query', async () => {
803+
vi.useFakeTimers();
804+
805+
const { mockServer, taskHandlers } = createMockServerWithTasks();
806+
registerSimulateResearchQueryTool(mockServer);
807+
808+
const mockTaskStore = createMockTaskStore('task-def');
809+
const mockSendRequest = vi.fn();
810+
811+
await taskHandlers.createTask(
812+
{ topic: 'python', ambiguous: false },
813+
{ taskStore: mockTaskStore, sendRequest: mockSendRequest }
814+
);
815+
816+
await vi.runAllTimersAsync();
817+
vi.useRealTimers();
818+
819+
expect(mockSendRequest).not.toHaveBeenCalled();
820+
expect(mockTaskStore.storeTaskResult).toHaveBeenCalledWith(
821+
'task-def', 'completed', expect.anything()
822+
);
823+
});
824+
});
825+
741826
describe('gzip-file-as-resource', () => {
742827
it('should compress data URI and return resource link', async () => {
743828
const registeredResources: any[] = [];

src/everything/tools/simulate-research-query.ts

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,10 @@ const researchStates = new Map<string, ResearchState>();
4747
/**
4848
* Runs the background research process.
4949
* Updates task status as it progresses through stages.
50-
* If clarification is needed, attempts elicitation via sendRequest.
51-
*
52-
* Note: Elicitation only works on STDIO transport. On HTTP transport,
53-
* sendRequest will fail and the task will use a default interpretation.
54-
* Full HTTP support requires SDK PR #1210's elicitInputStream API.
50+
* If clarification is needed, sends elicitation via sendRequest with relatedTask,
51+
* which queues the request in the task message queue. The SDK delivers it through
52+
* the tasks/result stream when the client calls tasks/result (per spec input_required flow).
53+
* This works on all transports (STDIO, SSE, Streamable HTTP).
5554
*/
5655
async function runResearchProcess(
5756
taskId: string,
@@ -94,7 +93,7 @@ async function runResearchProcess(
9493
);
9594

9695
try {
97-
// Try elicitation via sendRequest (works on STDIO, fails on HTTP)
96+
// relatedTask queues elicitation via task message queue → delivered through tasks/result on all transports
9897
const elicitResult: ElicitResult = await sendRequest(
9998
{
10099
method: "elicitation/create",
@@ -115,7 +114,8 @@ async function runResearchProcess(
115114
},
116115
},
117116
},
118-
ElicitResultSchema
117+
ElicitResultSchema,
118+
{ relatedTask: { taskId } }
119119
);
120120

121121
// Process elicitation response
@@ -129,14 +129,12 @@ async function runResearchProcess(
129129
state.clarification = "User cancelled - using default interpretation";
130130
}
131131
} catch (error) {
132-
// Elicitation failed (likely HTTP transport without streaming support)
133-
// Use default interpretation and continue - task should still complete
132+
// Elicitation failed - use default interpretation and continue
134133
console.warn(
135-
`Elicitation failed for task ${taskId} (HTTP transport?):`,
134+
`Elicitation failed for task ${taskId}:`,
136135
error instanceof Error ? error.message : String(error)
137136
);
138-
state.clarification =
139-
"technical (default - elicitation unavailable on HTTP)";
137+
state.clarification = "technical (default - elicitation unavailable)";
140138
}
141139

142140
// Resume with working status (spec SHOULD)
@@ -199,12 +197,8 @@ ${
199197
When the query was ambiguous, the server sent an \`elicitation/create\` request
200198
to the client. The task status changed to \`input_required\` while awaiting user input.
201199
${
202-
state.clarification.includes("unavailable on HTTP")
203-
? `
204-
**Note:** Elicitation was skipped because this server is running over HTTP transport.
205-
The current SDK's \`sendRequest\` only works over STDIO. Full HTTP elicitation support
206-
requires SDK PR #1210's streaming \`elicitInputStream\` API.
207-
`
200+
state.clarification.includes("unavailable")
201+
? `**Note:** Elicitation failed and a default interpretation was used.`
208202
: `After receiving clarification ("${state.clarification}"), the task resumed processing and completed.`
209203
}
210204
`
@@ -215,7 +209,7 @@ requires SDK PR #1210's streaming \`elicitInputStream\` API.
215209
- \`statusMessage\` provides human-readable progress updates
216210
- Tasks have TTL (time-to-live) for automatic cleanup
217211
- \`pollInterval\` suggests how often to check status
218-
- Elicitation requests can be sent directly during task execution
212+
- Elicitation requests use \`relatedTask\` to queue via tasks/result (works on all transports)
219213
220214
*This is a simulated research report from the Everything MCP Server.*
221215
`;
@@ -279,7 +273,7 @@ export const registerSimulateResearchQueryTool = (server: McpServer) => {
279273
researchStates.set(task.taskId, state);
280274

281275
// Start background research (don't await - runs asynchronously)
282-
// Pass sendRequest for elicitation (works on STDIO, gracefully degrades on HTTP)
276+
// Pass sendRequest for elicitation (queued via task message queue, works on all transports)
283277
runResearchProcess(
284278
task.taskId,
285279
validatedArgs,

0 commit comments

Comments
 (0)