Skip to content

Commit f86cf10

Browse files
committed
rename local researcher example
1 parent d372eff commit f86cf10

File tree

3 files changed

+164
-668
lines changed

3 files changed

+164
-668
lines changed

src/examples/server/toolLoopSampling.ts renamed to src/examples/server/simpleLocalResearcher.ts

Lines changed: 24 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
Usage:
88
npx -y @modelcontextprotocol/inspector \
99
npx -- -y --silent tsx src/examples/backfill/backfillSampling.ts \
10-
npx -y --silent tsx src/examples/server/toolLoopSampling.ts
10+
npx -y --silent tsx src/examples/server/simpleLocalResearcher.ts
1111
1212
claude mcp add sampling_with_tools -- \
1313
npx -y --silent tsx src/examples/backfill/backfillSampling.ts \
14-
npx -y --silent tsx src/examples/server/toolLoopSampling.ts
14+
npx -y --silent tsx src/examples/server/simpleLocalResearcher.ts
1515
1616
# Or dockerized:
1717
rm -fR node_modules
@@ -48,71 +48,11 @@ import type {
4848
ServerRequest,
4949
ServerNotification,
5050
} from "../../types.js";
51-
import { zodToJsonSchema } from "zod-to-json-schema";
52-
53-
54-
class ToolRegistry {
55-
readonly tools: Tool[]
56-
57-
constructor(private toolDefinitions: {[name: string]: Pick<RegisteredTool, 'title' | 'description' | 'inputSchema' | 'outputSchema' | 'annotations' | '_meta' | 'callback'> }) {
58-
this.tools = Object.entries(this.toolDefinitions).map(([name, tool]) => (<Tool>{
59-
name,
60-
title: tool.title,
61-
description: tool.description,
62-
inputSchema: tool.inputSchema ? zodToJsonSchema(tool.inputSchema) : undefined,
63-
outputSchema: tool.outputSchema ? zodToJsonSchema(tool.outputSchema) : undefined,
64-
annotations: tool.annotations,
65-
_meta: tool._meta,
66-
}));
67-
}
68-
69-
register(server: McpServer) {
70-
for (const [name, tool] of Object.entries(this.toolDefinitions)) {
71-
server.registerTool(name, {
72-
title: tool.title,
73-
description: tool.description,
74-
inputSchema: tool.inputSchema?.shape,
75-
outputSchema: tool.outputSchema?.shape,
76-
annotations: tool.annotations,
77-
_meta: tool._meta,
78-
}, tool.callback);
79-
}
80-
}
81-
82-
async callTools(toolCalls: ToolCallContent[], extra: RequestHandlerExtra<ServerRequest, ServerNotification>): Promise<ToolResultContent[]> {
83-
return Promise.all(toolCalls.map(async ({ name, id, input }) => {
84-
const tool = this.toolDefinitions[name];
85-
if (!tool) {
86-
throw new Error(`Tool ${name} not found`);
87-
}
88-
try {
89-
return <ToolResultContent>{
90-
type: "tool_result",
91-
toolUseId: id,
92-
// Copies fields: content, structuredContent?, isError?
93-
...await tool.callback(input as any, extra),
94-
};
95-
} catch (error) {
96-
throw new Error(`Tool ${name} failed: ${error instanceof Error ? `${error.message}\n${error.stack}` : error}`);
97-
}
98-
}));
99-
}
100-
}
101-
51+
import { ToolRegistry } from "./toolRegistry.js";
52+
import { runToolLoop } from './toolLoop.js';
10253

10354
const CWD = process.cwd();
10455

105-
/**
106-
* Interface for tracking aggregated token usage across API calls.
107-
*/
108-
interface AggregatedUsage {
109-
input_tokens: number;
110-
output_tokens: number;
111-
cache_creation_input_tokens: number;
112-
cache_read_input_tokens: number;
113-
api_calls: number;
114-
}
115-
11656
/**
11757
* Zod schemas for validating tool inputs
11858
*/
@@ -247,128 +187,6 @@ const registry = new ToolRegistry({
247187
}
248188
});
249189

250-
/**
251-
* Runs a tool loop using sampling.
252-
* Continues until the LLM provides a final answer.
253-
*/
254-
async function runToolLoop(
255-
server: McpServer,
256-
initialQuery: string,
257-
registry: ToolRegistry,
258-
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
259-
): Promise<{ answer: string; transcript: SamplingMessage[]; usage: AggregatedUsage }> {
260-
const messages: SamplingMessage[] = [
261-
{
262-
role: "user",
263-
content: {
264-
type: "text",
265-
text: initialQuery,
266-
},
267-
},
268-
];
269-
270-
// Initialize usage tracking
271-
const aggregatedUsage: AggregatedUsage = {
272-
input_tokens: 0,
273-
output_tokens: 0,
274-
cache_creation_input_tokens: 0,
275-
cache_read_input_tokens: 0,
276-
api_calls: 0,
277-
};
278-
279-
const MAX_ITERATIONS = 20;
280-
let iteration = 0;
281-
282-
const systemPrompt =
283-
"You are a helpful assistant that searches through files to answer questions. " +
284-
"You have access to ripgrep (for searching) and read (for reading file contents). " +
285-
"Use ripgrep to find relevant files, then read them to provide accurate answers. " +
286-
"All paths are relative to the current working directory. " +
287-
"Be concise and focus on providing the most relevant information." +
288-
"You will be allowed up to " + MAX_ITERATIONS + " iterations of tool use to find the information needed. When you have enough information or reach the last iteration, provide a final answer.";
289-
290-
let request: CreateMessageRequest["params"] | undefined
291-
let response: CreateMessageResult | undefined
292-
while (iteration < MAX_ITERATIONS) {
293-
iteration++;
294-
295-
// Request message from LLM with available tools
296-
response = await server.server.createMessage(request = {
297-
messages,
298-
systemPrompt,
299-
maxTokens: 4000,
300-
tools: iteration < MAX_ITERATIONS ? registry.tools : undefined,
301-
// Don't allow tool calls at the last iteration: finish with an answer no matter what!
302-
tool_choice: { mode: iteration < MAX_ITERATIONS ? "auto" : "none" },
303-
});
304-
305-
// Aggregate usage statistics from the response
306-
if (response._meta?.usage) {
307-
const usage = response._meta.usage as any;
308-
aggregatedUsage.input_tokens += usage.input_tokens || 0;
309-
aggregatedUsage.output_tokens += usage.output_tokens || 0;
310-
aggregatedUsage.cache_creation_input_tokens += usage.cache_creation_input_tokens || 0;
311-
aggregatedUsage.cache_read_input_tokens += usage.cache_read_input_tokens || 0;
312-
aggregatedUsage.api_calls += 1;
313-
}
314-
315-
// Add assistant's response to message history
316-
// SamplingMessage now supports arrays of content
317-
messages.push({
318-
role: "assistant",
319-
content: response.content,
320-
});
321-
322-
if (response.stopReason === "toolUse") {
323-
const contentArray = Array.isArray(response.content) ? response.content : [response.content];
324-
const toolCalls = contentArray.filter(
325-
(content): content is ToolCallContent => content.type === "tool_use"
326-
);
327-
328-
await server.sendLoggingMessage({
329-
level: "info",
330-
data: `Loop iteration ${iteration}: ${toolCalls.length} tool invocation(s) requested`,
331-
});
332-
333-
const toolResults = await registry.callTools(toolCalls, extra);
334-
335-
messages.push({
336-
role: "user",
337-
content: iteration < MAX_ITERATIONS ? toolResults : [
338-
...toolResults,
339-
{
340-
type: "text",
341-
text: "Using the information retrieved from the tools, please now provide a concise final answer to the original question (last iteration of the tool loop).",
342-
}
343-
],
344-
});
345-
} else if (response.stopReason === "endTurn") {
346-
const contentArray = Array.isArray(response.content) ? response.content : [response.content];
347-
const unexpectedBlocks = contentArray.filter(content => content.type !== "text");
348-
if (unexpectedBlocks.length > 0) {
349-
throw new Error(`Expected text content in final answer, but got: ${unexpectedBlocks.map(b => b.type).join(", ")}`);
350-
}
351-
352-
await server.sendLoggingMessage({
353-
level: "info",
354-
data: `Tool loop completed after ${iteration} iteration(s)`,
355-
});
356-
357-
return {
358-
answer: contentArray.map(block => block.text).join("\n\n"),
359-
transcript: messages,
360-
usage: aggregatedUsage
361-
};
362-
} else if (response?.stopReason === "maxTokens") {
363-
throw new Error("LLM response hit max tokens limit");
364-
} else {
365-
throw new Error(`Unsupported stop reason: ${response.stopReason}`);
366-
}
367-
}
368-
369-
throw new Error(`Tool loop exceeded maximum iterations (${MAX_ITERATIONS}); request: ${JSON.stringify(request)}\nresponse: ${JSON.stringify(response)}`);
370-
}
371-
372190
// Create and configure MCP server
373191
const mcpServer = new McpServer({
374192
name: "tool-loop-sampling-server",
@@ -394,7 +212,26 @@ mcpServer.registerTool(
394212
},
395213
async ({ query, maxIterations }, extra) => {
396214
try {
397-
const { answer, transcript, usage } = await runToolLoop(mcpServer, query, registry, extra);
215+
const MAX_ITERATIONS = 20;
216+
const { answer, transcript, usage } = await runToolLoop({
217+
initialMessages: [{
218+
role: "user",
219+
content: {
220+
type: "text",
221+
text: query,
222+
},
223+
}],
224+
systemPrompt:
225+
"You are a helpful assistant that searches through files to answer questions. " +
226+
"You have access to ripgrep (for searching) and read (for reading file contents). " +
227+
"Use ripgrep to find relevant files, then read them to provide accurate answers. " +
228+
"All paths are relative to the current working directory. " +
229+
"Be concise and focus on providing the most relevant information." +
230+
"You will be allowed up to " + MAX_ITERATIONS + " iterations of tool use to find the information needed. When you have enough information or reach the last iteration, provide a final answer.",
231+
maxIterations: MAX_ITERATIONS,
232+
server: mcpServer,
233+
registry,
234+
}, extra);
398235

399236
// Calculate total input tokens
400237
const totalInputTokens =

src/examples/server/toolLoop.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { z } from "zod";
2+
import { spawn } from "node:child_process";
3+
import { readFile } from "node:fs/promises";
4+
import { resolve, relative } from "node:path";
5+
6+
import { McpServer,RegisteredTool, } from "../../server/mcp.js";
7+
import { StdioServerTransport } from "../../server/stdio.js";
8+
import { RequestHandlerExtra } from "../../shared/protocol.js";
9+
import type {
10+
SamplingMessage,
11+
Tool,
12+
ToolCallContent,
13+
CreateMessageResult,
14+
CreateMessageRequest,
15+
RequestId,
16+
ServerRequest,
17+
ServerNotification,
18+
} from "../../types.js";
19+
import { ToolRegistry } from "./toolRegistry.js";
20+
21+
/**
22+
* Interface for tracking aggregated token usage across API calls.
23+
*/
24+
interface AggregatedUsage {
25+
input_tokens: number;
26+
output_tokens: number;
27+
cache_creation_input_tokens: number;
28+
cache_read_input_tokens: number;
29+
api_calls: number;
30+
}
31+
32+
/**
33+
* Runs a tool loop using sampling.
34+
* Continues until the LLM provides a final answer.
35+
*/
36+
export async function runToolLoop(
37+
options: {
38+
initialMessages: SamplingMessage[],
39+
server: McpServer,
40+
registry: ToolRegistry,
41+
maxIterations?: number,
42+
systemPrompt?: string,
43+
},
44+
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
45+
): Promise<{ answer: string; transcript: SamplingMessage[]; usage: AggregatedUsage }> {
46+
const messages: SamplingMessage[] = [...options.initialMessages];
47+
48+
// Initialize usage tracking
49+
const aggregatedUsage: AggregatedUsage = {
50+
input_tokens: 0,
51+
output_tokens: 0,
52+
cache_creation_input_tokens: 0,
53+
cache_read_input_tokens: 0,
54+
api_calls: 0,
55+
};
56+
57+
let iteration = 0;
58+
const maxIterations = options.maxIterations ?? Number.POSITIVE_INFINITY;
59+
60+
let request: CreateMessageRequest["params"] | undefined
61+
let response: CreateMessageResult | undefined
62+
while (iteration < maxIterations) {
63+
iteration++;
64+
65+
// Request message from LLM with available tools
66+
response = await options.server.server.createMessage(request = {
67+
messages,
68+
systemPrompt: options.systemPrompt,
69+
maxTokens: 4000,
70+
tools: iteration < maxIterations ? options.registry.tools : undefined,
71+
// Don't allow tool calls at the last iteration: finish with an answer no matter what!
72+
tool_choice: { mode: iteration < maxIterations ? "auto" : "none" },
73+
});
74+
75+
// Aggregate usage statistics from the response
76+
if (response._meta?.usage) {
77+
const usage = response._meta.usage as any;
78+
aggregatedUsage.input_tokens += usage.input_tokens || 0;
79+
aggregatedUsage.output_tokens += usage.output_tokens || 0;
80+
aggregatedUsage.cache_creation_input_tokens += usage.cache_creation_input_tokens || 0;
81+
aggregatedUsage.cache_read_input_tokens += usage.cache_read_input_tokens || 0;
82+
aggregatedUsage.api_calls += 1;
83+
}
84+
85+
// Add assistant's response to message history
86+
// SamplingMessage now supports arrays of content
87+
messages.push({
88+
role: "assistant",
89+
content: response.content,
90+
});
91+
92+
if (response.stopReason === "toolUse") {
93+
const contentArray = Array.isArray(response.content) ? response.content : [response.content];
94+
const toolCalls = contentArray.filter(
95+
(content): content is ToolCallContent => content.type === "tool_use"
96+
);
97+
98+
await options.server.sendLoggingMessage({
99+
level: "info",
100+
data: `Loop iteration ${iteration}: ${toolCalls.length} tool invocation(s) requested`,
101+
});
102+
103+
const toolResults = await options.registry.callTools(toolCalls, extra);
104+
105+
messages.push({
106+
role: "user",
107+
content: iteration < maxIterations ? toolResults : [
108+
...toolResults,
109+
{
110+
type: "text",
111+
text: "Using the information retrieved from the tools, please now provide a concise final answer to the original question (last iteration of the tool loop).",
112+
}
113+
],
114+
});
115+
} else if (response.stopReason === "endTurn") {
116+
const contentArray = Array.isArray(response.content) ? response.content : [response.content];
117+
const unexpectedBlocks = contentArray.filter(content => content.type !== "text");
118+
if (unexpectedBlocks.length > 0) {
119+
throw new Error(`Expected text content in final answer, but got: ${unexpectedBlocks.map(b => b.type).join(", ")}`);
120+
}
121+
122+
await options.server.sendLoggingMessage({
123+
level: "info",
124+
data: `Tool loop completed after ${iteration} iteration(s)`,
125+
});
126+
127+
return {
128+
answer: contentArray.map(block => block.text).join("\n\n"),
129+
transcript: messages,
130+
usage: aggregatedUsage
131+
};
132+
} else if (response?.stopReason === "maxTokens") {
133+
throw new Error("LLM response hit max tokens limit");
134+
} else {
135+
throw new Error(`Unsupported stop reason: ${response.stopReason}`);
136+
}
137+
}
138+
139+
throw new Error(`Tool loop exceeded maximum iterations (${maxIterations}); request: ${JSON.stringify(request)}\nresponse: ${JSON.stringify(response)}`);
140+
}

0 commit comments

Comments
 (0)