mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-04-17 21:54:05 +02:00
Add adventure game tool with tool loop and registry
- Add toolLoop.ts: Runs agentic sampling loop with tool calls - Add toolRegistry.ts: Manages tool definitions and execution - Update everything.ts: - Add ADVENTURE_GAME tool (requires sampling + elicitation) - Implement choose-your-own-adventure game using tool loop - Game tools: userLost, userWon, nextStep with elicitation - Fix type inference for Tool inputSchema/outputSchema - Add mode field to elicitation requests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,16 @@ import {
|
|||||||
CompleteRequestSchema,
|
CompleteRequestSchema,
|
||||||
CreateMessageRequest,
|
CreateMessageRequest,
|
||||||
CreateMessageResultSchema,
|
CreateMessageResultSchema,
|
||||||
|
ElicitRequest,
|
||||||
ElicitResultSchema,
|
ElicitResultSchema,
|
||||||
|
ErrorCode,
|
||||||
GetPromptRequestSchema,
|
GetPromptRequestSchema,
|
||||||
ListPromptsRequestSchema,
|
ListPromptsRequestSchema,
|
||||||
ListResourcesRequestSchema,
|
ListResourcesRequestSchema,
|
||||||
ListResourceTemplatesRequestSchema,
|
ListResourceTemplatesRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
LoggingLevel,
|
LoggingLevel,
|
||||||
|
McpError,
|
||||||
ReadResourceRequestSchema,
|
ReadResourceRequestSchema,
|
||||||
Resource,
|
Resource,
|
||||||
RootsListChangedNotificationSchema,
|
RootsListChangedNotificationSchema,
|
||||||
@@ -21,8 +24,11 @@ import {
|
|||||||
SubscribeRequestSchema,
|
SubscribeRequestSchema,
|
||||||
Tool,
|
Tool,
|
||||||
UnsubscribeRequestSchema,
|
UnsubscribeRequestSchema,
|
||||||
type Root
|
type CallToolResult,
|
||||||
|
type Root,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { ToolRegistry, BreakToolLoopError } from "./toolRegistry.js";
|
||||||
|
import { runToolLoop } from "./toolLoop.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
@@ -70,6 +76,12 @@ const SampleLLMSchema = z.object({
|
|||||||
.describe("Maximum number of tokens to generate"),
|
.describe("Maximum number of tokens to generate"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AdventureGameSchema = z.object({
|
||||||
|
gameSynopsisOrSubject: z
|
||||||
|
.string()
|
||||||
|
.describe("Description of the game subject or possible synopsis."),
|
||||||
|
});
|
||||||
|
|
||||||
const GetTinyImageSchema = z.object({});
|
const GetTinyImageSchema = z.object({});
|
||||||
|
|
||||||
const AnnotatedMessageSchema = z.object({
|
const AnnotatedMessageSchema = z.object({
|
||||||
@@ -137,6 +149,7 @@ enum ToolName {
|
|||||||
LONG_RUNNING_OPERATION = "longRunningOperation",
|
LONG_RUNNING_OPERATION = "longRunningOperation",
|
||||||
PRINT_ENV = "printEnv",
|
PRINT_ENV = "printEnv",
|
||||||
SAMPLE_LLM = "sampleLLM",
|
SAMPLE_LLM = "sampleLLM",
|
||||||
|
ADVENTURE_GAME = "adventureGame",
|
||||||
GET_TINY_IMAGE = "getTinyImage",
|
GET_TINY_IMAGE = "getTinyImage",
|
||||||
ANNOTATED_MESSAGE = "annotatedMessage",
|
ANNOTATED_MESSAGE = "annotatedMessage",
|
||||||
GET_RESOURCE_REFERENCE = "getResourceReference",
|
GET_RESOURCE_REFERENCE = "getResourceReference",
|
||||||
@@ -226,36 +239,6 @@ export const createServer = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper method to request sampling from client
|
|
||||||
const requestSampling = async (
|
|
||||||
context: string,
|
|
||||||
uri: string,
|
|
||||||
maxTokens: number = 100,
|
|
||||||
sendRequest: SendRequest
|
|
||||||
) => {
|
|
||||||
const request: CreateMessageRequest = {
|
|
||||||
method: "sampling/createMessage",
|
|
||||||
params: {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: {
|
|
||||||
type: "text",
|
|
||||||
text: `Resource ${uri} context: ${context}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
systemPrompt: "You are a helpful test server.",
|
|
||||||
maxTokens,
|
|
||||||
temperature: 0.7,
|
|
||||||
includeContext: "thisServer",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return await sendRequest(request, CreateMessageResultSchema);
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL_RESOURCES: Resource[] = Array.from({ length: 100 }, (_, i) => {
|
const ALL_RESOURCES: Resource[] = Array.from({ length: 100 }, (_, i) => {
|
||||||
const uri = `test://static/resource/${i + 1}`;
|
const uri = `test://static/resource/${i + 1}`;
|
||||||
if (i % 2 === 0) {
|
if (i % 2 === 0) {
|
||||||
@@ -536,6 +519,11 @@ export const createServer = () => {
|
|||||||
description: "Elicitation test tool that demonstrates how to request user input with various field types (string, boolean, email, uri, date, integer, number, enum)",
|
description: "Elicitation test tool that demonstrates how to request user input with various field types (string, boolean, email, uri, date, integer, number, enum)",
|
||||||
inputSchema: zodToJsonSchema(ElicitationSchema) as ToolInput,
|
inputSchema: zodToJsonSchema(ElicitationSchema) as ToolInput,
|
||||||
});
|
});
|
||||||
|
if (clientCapabilities!.sampling && clientCapabilities!.elicitation) tools.push ({
|
||||||
|
name: ToolName.ADVENTURE_GAME,
|
||||||
|
description: "Play a 'choose your own adventure' game. The user will be asked for decisions along the way via elicitation. Requires both sampling and elicitation capabilities.",
|
||||||
|
inputSchema: zodToJsonSchema(AdventureGameSchema) as ToolInput,
|
||||||
|
});
|
||||||
|
|
||||||
return { tools };
|
return { tools };
|
||||||
});
|
});
|
||||||
@@ -611,12 +599,25 @@ export const createServer = () => {
|
|||||||
const validatedArgs = SampleLLMSchema.parse(args);
|
const validatedArgs = SampleLLMSchema.parse(args);
|
||||||
const { prompt, maxTokens } = validatedArgs;
|
const { prompt, maxTokens } = validatedArgs;
|
||||||
|
|
||||||
const result = await requestSampling(
|
const result = await extra.sendRequest(<CreateMessageRequest>{
|
||||||
prompt,
|
method: "sampling/createMessage",
|
||||||
ToolName.SAMPLE_LLM,
|
params: {
|
||||||
maxTokens,
|
maxTokens,
|
||||||
extra.sendRequest
|
messages: [
|
||||||
);
|
{
|
||||||
|
role: "user",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: prompt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
systemPrompt: "You are a helpful test server.",
|
||||||
|
temperature: 0.7,
|
||||||
|
includeContext: "thisServer",
|
||||||
|
},
|
||||||
|
}, CreateMessageResultSchema);
|
||||||
|
|
||||||
const content = Array.isArray(result.content) ? result.content : [result.content];
|
const content = Array.isArray(result.content) ? result.content : [result.content];
|
||||||
const textResult = content.every((c) => c.type === "text")
|
const textResult = content.every((c) => c.type === "text")
|
||||||
? content.map(c => c.text).join("\n")
|
? content.map(c => c.text).join("\n")
|
||||||
@@ -628,6 +629,176 @@ export const createServer = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === ToolName.ADVENTURE_GAME) {
|
||||||
|
const { gameSynopsisOrSubject } = AdventureGameSchema.parse(args);
|
||||||
|
|
||||||
|
// Helper to create error result
|
||||||
|
const makeErrorCallToolResult = (error: unknown): CallToolResult => ({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: error instanceof Error ? `${error.message}\n${error.stack}` : `${error}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create registry with game tools
|
||||||
|
const gameRegistry = new ToolRegistry({
|
||||||
|
userLost: {
|
||||||
|
description: "Called when the user loses",
|
||||||
|
inputSchema: z.object({
|
||||||
|
storyUpdate: z.string(),
|
||||||
|
}),
|
||||||
|
callback: async (args, gameExtra) => {
|
||||||
|
const { storyUpdate } = args as { storyUpdate: string };
|
||||||
|
await gameExtra.sendRequest(<ElicitRequest>{
|
||||||
|
method: 'elicitation/create',
|
||||||
|
params: {
|
||||||
|
mode: 'form',
|
||||||
|
message: 'You Lost!\n' + storyUpdate,
|
||||||
|
requestedSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, ElicitResultSchema);
|
||||||
|
throw new BreakToolLoopError('lost');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userWon: {
|
||||||
|
description: "Called when the user wins the game",
|
||||||
|
inputSchema: z.object({
|
||||||
|
storyUpdate: z.string(),
|
||||||
|
}),
|
||||||
|
callback: async (args, gameExtra) => {
|
||||||
|
const { storyUpdate } = args as { storyUpdate: string };
|
||||||
|
await gameExtra.sendRequest(<ElicitRequest>{
|
||||||
|
method: 'elicitation/create',
|
||||||
|
params: {
|
||||||
|
mode: 'form',
|
||||||
|
message: 'You Won!\n' + storyUpdate,
|
||||||
|
requestedSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, ElicitResultSchema);
|
||||||
|
throw new BreakToolLoopError('won');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextStep: {
|
||||||
|
description: "Next step in the game.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
storyUpdate: z.string().describe("Description of the next step of the game. Acknowledges the last decision (if any) and describes what happened because of / since it was made, then continues the story up to the point where another decision is needed from the user (if/when appropriate)."),
|
||||||
|
nextDecisions: z.array(z.string()).describe("The list of possible decisions the user/player can make at this point of the story. Empty list if we've reached the end of the story"),
|
||||||
|
decisionTimeoutSeconds: z.number().optional().describe("Optional: timeout in seconds for decision to be made. Used when a timely decision is needed."),
|
||||||
|
}),
|
||||||
|
outputSchema: z.object({
|
||||||
|
userDecision: z.string().optional()
|
||||||
|
.describe("The decision the user took, or undefined if the user let the decision time out."),
|
||||||
|
}),
|
||||||
|
callback: async (args, gameExtra) => {
|
||||||
|
const { storyUpdate, nextDecisions, decisionTimeoutSeconds } = args as {
|
||||||
|
storyUpdate: string;
|
||||||
|
nextDecisions: string[];
|
||||||
|
decisionTimeoutSeconds?: number;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await gameExtra.sendRequest(<ElicitRequest>{
|
||||||
|
method: 'elicitation/create',
|
||||||
|
params: {
|
||||||
|
mode: 'form',
|
||||||
|
message: storyUpdate,
|
||||||
|
requestedSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
nextDecision: {
|
||||||
|
title: 'Next step',
|
||||||
|
type: 'string',
|
||||||
|
enum: nextDecisions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, ElicitResultSchema, {
|
||||||
|
timeout: decisionTimeoutSeconds == null ? undefined : decisionTimeoutSeconds * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.action === 'accept') {
|
||||||
|
const structuredContent = {
|
||||||
|
userDecision: result.content?.nextDecision as string,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }],
|
||||||
|
structuredContent,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: result.action === 'decline' ? 'Game Over' : 'Game Cancelled' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) {
|
||||||
|
const structuredContent = {
|
||||||
|
userDecision: undefined, // Means "timed out"
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent) }],
|
||||||
|
structuredContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return makeErrorCallToolResult(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { answer, transcript, usage } = await runToolLoop({
|
||||||
|
initialMessages: [{
|
||||||
|
role: "user",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: gameSynopsisOrSubject,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
systemPrompt:
|
||||||
|
"You are a 'choose your own adventure' game master. " +
|
||||||
|
"Given an initial user request (subject and/or synopsis of the game, maybe description of their role in the game), " +
|
||||||
|
"you will relentlessly walk the user forward in an imaginary story, " +
|
||||||
|
"giving them regular choices as to what their character can do next can happen next. " +
|
||||||
|
"If the user didn't choose a role for themselves, you can ask them to pick one of a few interesting options (first decision). " +
|
||||||
|
"Then you will continually develop the story and call the nextStep tool to give story updates and ask for pivotal decisions. " +
|
||||||
|
"Updates should fit in a page (sometimes as short as a paragraph e.g. if doing a battle with very fast paced action). " +
|
||||||
|
"Some decisions should have a timeout to create some thrills for the user, in tight action scenes. " +
|
||||||
|
"When / if the user loses (e.g. dies, or whatever the user expressed as a loss condition), the last call to nextStep should have zero options.",
|
||||||
|
defaultToolChoice: { mode: 'required' },
|
||||||
|
server,
|
||||||
|
registry: gameRegistry,
|
||||||
|
}, extra);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: answer,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `\n\n--- Usage: ${usage.api_calls} API calls, ${usage.input_tokens} input / ${usage.output_tokens} output tokens ---`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `\n\n--- Debug Transcript (${transcript.length} messages) ---\n${JSON.stringify(transcript, null, 2)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return makeErrorCallToolResult(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (name === ToolName.GET_TINY_IMAGE) {
|
if (name === ToolName.GET_TINY_IMAGE) {
|
||||||
GetTinyImageSchema.parse(args);
|
GetTinyImageSchema.parse(args);
|
||||||
return {
|
return {
|
||||||
|
|||||||
146
src/everything/toolLoop.ts
Normal file
146
src/everything/toolLoop.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||||
|
import type {
|
||||||
|
SamplingMessage,
|
||||||
|
ToolUseContent,
|
||||||
|
CreateMessageRequest,
|
||||||
|
CreateMessageResult,
|
||||||
|
ServerRequest,
|
||||||
|
ServerNotification,
|
||||||
|
ToolResultContent,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { ToolRegistry, BreakToolLoopError } from "./toolRegistry.js";
|
||||||
|
|
||||||
|
export { BreakToolLoopError };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for tracking aggregated token usage across API calls.
|
||||||
|
*/
|
||||||
|
interface AggregatedUsage {
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_creation_input_tokens: number;
|
||||||
|
cache_read_input_tokens: number;
|
||||||
|
api_calls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a tool loop using sampling.
|
||||||
|
* Continues until the LLM provides a final answer.
|
||||||
|
*/
|
||||||
|
export async function runToolLoop(
|
||||||
|
options: {
|
||||||
|
initialMessages: SamplingMessage[];
|
||||||
|
server: Server;
|
||||||
|
registry: ToolRegistry;
|
||||||
|
maxIterations?: number;
|
||||||
|
systemPrompt?: string;
|
||||||
|
defaultToolChoice?: CreateMessageRequest["params"]["toolChoice"];
|
||||||
|
},
|
||||||
|
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
|
||||||
|
): Promise<{ answer: string; transcript: SamplingMessage[]; usage: AggregatedUsage }> {
|
||||||
|
const messages: SamplingMessage[] = [...options.initialMessages];
|
||||||
|
|
||||||
|
// Initialize usage tracking
|
||||||
|
const usage: AggregatedUsage = {
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
api_calls: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let iteration = 0;
|
||||||
|
const maxIterations = options.maxIterations ?? Number.POSITIVE_INFINITY;
|
||||||
|
const defaultToolChoice = options.defaultToolChoice ?? { mode: "auto" };
|
||||||
|
|
||||||
|
let request: CreateMessageRequest["params"] | undefined;
|
||||||
|
let response: CreateMessageResult | undefined;
|
||||||
|
|
||||||
|
while (iteration < maxIterations) {
|
||||||
|
iteration++;
|
||||||
|
|
||||||
|
// Request message from LLM with available tools
|
||||||
|
response = await options.server.createMessage(request = {
|
||||||
|
messages,
|
||||||
|
systemPrompt: options.systemPrompt,
|
||||||
|
maxTokens: 4000,
|
||||||
|
tools: iteration < maxIterations ? options.registry.tools : undefined,
|
||||||
|
// Don't allow tool calls at the last iteration: finish with an answer no matter what!
|
||||||
|
toolChoice: iteration < maxIterations ? defaultToolChoice : { mode: "none" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregate usage statistics from the response
|
||||||
|
if (response._meta?.usage) {
|
||||||
|
const responseUsage = response._meta.usage as Record<string, number>;
|
||||||
|
usage.input_tokens += responseUsage.input_tokens || 0;
|
||||||
|
usage.output_tokens += responseUsage.output_tokens || 0;
|
||||||
|
usage.cache_creation_input_tokens += responseUsage.cache_creation_input_tokens || 0;
|
||||||
|
usage.cache_read_input_tokens += responseUsage.cache_read_input_tokens || 0;
|
||||||
|
usage.api_calls += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add assistant's response to message history
|
||||||
|
messages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: response.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.stopReason === "toolUse") {
|
||||||
|
const contentArray = Array.isArray(response.content) ? response.content : [response.content];
|
||||||
|
const toolCalls = contentArray.filter(
|
||||||
|
(content): content is ToolUseContent => content.type === "tool_use"
|
||||||
|
);
|
||||||
|
|
||||||
|
await options.server.sendLoggingMessage({
|
||||||
|
level: "info",
|
||||||
|
data: `Loop iteration ${iteration}: ${toolCalls.length} tool invocation(s) requested`,
|
||||||
|
});
|
||||||
|
|
||||||
|
let toolResults: ToolResultContent[];
|
||||||
|
try {
|
||||||
|
toolResults = await options.registry.callTools(toolCalls, extra);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BreakToolLoopError) {
|
||||||
|
return { answer: `${error.message}`, transcript: messages, usage };
|
||||||
|
}
|
||||||
|
console.error(error);
|
||||||
|
throw new Error(`Tool call failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: "user",
|
||||||
|
content: iteration < maxIterations ? toolResults : [
|
||||||
|
...toolResults,
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
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).",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else if (response.stopReason === "endTurn") {
|
||||||
|
const contentArray = Array.isArray(response.content) ? response.content : [response.content];
|
||||||
|
const unexpectedBlocks = contentArray.filter(content => content.type !== "text");
|
||||||
|
if (unexpectedBlocks.length > 0) {
|
||||||
|
throw new Error(`Expected text content in final answer, but got: ${unexpectedBlocks.map(b => b.type).join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await options.server.sendLoggingMessage({
|
||||||
|
level: "info",
|
||||||
|
data: `Tool loop completed after ${iteration} iteration(s)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
answer: contentArray.map(block => block.type === "text" ? block.text : "").join("\n\n"),
|
||||||
|
transcript: messages,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
} else if (response?.stopReason === "maxTokens") {
|
||||||
|
throw new Error("LLM response hit max tokens limit");
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported stop reason: ${response.stopReason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Tool loop exceeded maximum iterations (${maxIterations}); request: ${JSON.stringify(request)}\nresponse: ${JSON.stringify(response)}`);
|
||||||
|
}
|
||||||
75
src/everything/toolRegistry.ts
Normal file
75
src/everything/toolRegistry.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||||
|
import type {
|
||||||
|
Tool,
|
||||||
|
ToolAnnotations,
|
||||||
|
ToolUseContent,
|
||||||
|
ToolResultContent,
|
||||||
|
CallToolResult,
|
||||||
|
ServerRequest,
|
||||||
|
ServerNotification,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||||
|
|
||||||
|
export class BreakToolLoopError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCallback = (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
|
||||||
|
) => CallToolResult | Promise<CallToolResult>;
|
||||||
|
|
||||||
|
interface ToolDefinition {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: unknown;
|
||||||
|
outputSchema?: unknown;
|
||||||
|
annotations?: ToolAnnotations;
|
||||||
|
_meta?: Record<string, unknown>;
|
||||||
|
callback: ToolCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToolRegistry {
|
||||||
|
readonly tools: Tool[];
|
||||||
|
|
||||||
|
constructor(private toolDefinitions: { [name: string]: ToolDefinition }) {
|
||||||
|
this.tools = Object.entries(this.toolDefinitions).map(([name, tool]) => (<Tool>{
|
||||||
|
name,
|
||||||
|
title: tool.title,
|
||||||
|
description: tool.description,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
inputSchema: tool.inputSchema ? zodToJsonSchema(tool.inputSchema as any) : undefined,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
outputSchema: tool.outputSchema ? zodToJsonSchema(tool.outputSchema as any) : undefined,
|
||||||
|
annotations: tool.annotations,
|
||||||
|
_meta: tool._meta,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTools(
|
||||||
|
toolCalls: ToolUseContent[],
|
||||||
|
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
|
||||||
|
): Promise<ToolResultContent[]> {
|
||||||
|
return Promise.all(toolCalls.map(async ({ name, id, input }) => {
|
||||||
|
const tool = this.toolDefinitions[name];
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error(`Tool ${name} not found`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return <ToolResultContent>{
|
||||||
|
type: "tool_result",
|
||||||
|
toolUseId: id,
|
||||||
|
// Copies fields: content, structuredContent?, isError?
|
||||||
|
...await tool.callback(input as Record<string, unknown>, extra),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BreakToolLoopError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`Tool ${name} failed: ${error instanceof Error ? `${error.message}\n${error.stack}` : error}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user