|
| 1 | +/* |
| 2 | + This example demonstrates a tool loop using MCP sampling with locally defined tools. |
| 3 | +
|
| 4 | + It exposes a "localResearch" tool that uses an LLM with ripgrep and read capabilities |
| 5 | + to intelligently search and read files in the current directory. |
| 6 | +
|
| 7 | + Usage: |
| 8 | + npx -y @modelcontextprotocol/inspector \ |
| 9 | + npx -- -y --silent tsx src/examples/backfill/backfillSampling.ts \ |
| 10 | + npx -y --silent tsx src/examples/server/adventureGame.ts |
| 11 | +
|
| 12 | + claude mcp add game -- \ |
| 13 | + npx -y --silent tsx src/examples/backfill/backfillSampling.ts \ |
| 14 | + npx -y --silent tsx src/examples/server/adventureGame.ts |
| 15 | +
|
| 16 | + # Or dockerized: |
| 17 | + rm -fR node_modules |
| 18 | + docker run --rm -v $PWD:/src -w /src node:latest npm i |
| 19 | + npx -y @modelcontextprotocol/inspector -- \ |
| 20 | + docker run --rm -i -e ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \ |
| 21 | + -v $PWD:/src -w /src \ |
| 22 | + $( echo " |
| 23 | + FROM node:latest |
| 24 | + RUN apt update && apt install ripgrep |
| 25 | + " | docker build -q -f - . ) \ |
| 26 | + npm run --silent examples:adventure-game |
| 27 | +
|
| 28 | + Then connect with an MCP client and call the "localResearch" tool with a query like: |
| 29 | + "Find all TypeScript files that export a Server class" |
| 30 | +*/ |
| 31 | + |
| 32 | +import { McpError, ErrorCode } from "../../types.js"; |
| 33 | +import { McpServer } from "../../server/mcp.js"; |
| 34 | + |
| 35 | +import { StdioServerTransport } from "../../server/stdio.js"; |
| 36 | +import { RequestHandlerExtra } from "../../shared/protocol.js"; |
| 37 | +import { z } from "zod"; |
| 38 | +import { spawn } from "node:child_process"; |
| 39 | +import { readFile } from "node:fs/promises"; |
| 40 | +import { resolve, relative } from "node:path"; |
| 41 | +import type { |
| 42 | + SamplingMessage, |
| 43 | + Tool, |
| 44 | + ToolCallContent, |
| 45 | + CreateMessageResult, |
| 46 | + CreateMessageRequest, |
| 47 | + ToolResultContent, |
| 48 | + CallToolResult, |
| 49 | + RequestId, |
| 50 | + ServerRequest, |
| 51 | + ServerNotification, |
| 52 | +ElicitRequest, |
| 53 | +} from "../../types.js"; |
| 54 | +import { ElicitResultSchema } from "../../../dist/esm/types.js"; |
| 55 | +import { ToolRegistry } from "./toolRegistry.js"; |
| 56 | +import { runToolLoop, BreakToolLoopError } from "./toolLoop.js" |
| 57 | + |
| 58 | + |
| 59 | +function makeErrorCallToolResult(error: any): CallToolResult { |
| 60 | + return { |
| 61 | + content: [ |
| 62 | + { |
| 63 | + type: "text", |
| 64 | + text: error instanceof Error ? `${error.message}\n${error.stack}` : `${error}`, |
| 65 | + }, |
| 66 | + ], |
| 67 | + isError: true, |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +const registry = new ToolRegistry({ |
| 72 | + userLost: { |
| 73 | + description: "Called when the user loses", |
| 74 | + inputSchema: z.object({ |
| 75 | + storyUpdate: z.string(), |
| 76 | + }), |
| 77 | + callback: async ({storyUpdate}, extra) => { |
| 78 | + await extra.sendRequest(<ElicitRequest>{ |
| 79 | + method: 'elicitation/create', |
| 80 | + params: { |
| 81 | + message: 'You Lost!\n' + storyUpdate, |
| 82 | + requestedSchema: { |
| 83 | + type: 'object', |
| 84 | + properties: {}, |
| 85 | + }, |
| 86 | + }, |
| 87 | + }, ElicitResultSchema); |
| 88 | + throw new BreakToolLoopError('lost'); |
| 89 | + } |
| 90 | + }, |
| 91 | + userWon: { |
| 92 | + description: "Called when the user wins the game", |
| 93 | + inputSchema: z.object({ |
| 94 | + storyUpdate: z.string(), |
| 95 | + }), |
| 96 | + callback: async ({storyUpdate}, extra) => { |
| 97 | + await extra.sendRequest(<ElicitRequest>{ |
| 98 | + method: 'elicitation/create', |
| 99 | + params: { |
| 100 | + message: 'You Won!\n' + storyUpdate, |
| 101 | + requestedSchema: { |
| 102 | + type: 'object', |
| 103 | + properties: {}, |
| 104 | + }, |
| 105 | + }, |
| 106 | + }, ElicitResultSchema); |
| 107 | + throw new BreakToolLoopError('won'); |
| 108 | + } |
| 109 | + }, |
| 110 | + nextStep: { |
| 111 | + description: "Next step in the game.", |
| 112 | + inputSchema: z.object({ |
| 113 | + storyUpdate: z.string().describe("Description of the next step of the game. Acknowledges the last decision (if any) and describes what happened becaus of / since it was made, then continues the story up to the point where another decision is needed from the user (if/when appropriate)."), |
| 114 | + 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"), |
| 115 | + decisionTimeoutSeconds: z.number().optional().describe("Optional: timeout in seconds for decision to be made. Used when a timely decision is needed.") |
| 116 | + }), |
| 117 | + outputSchema: z.object({ |
| 118 | + userDecision: z.string().optional() |
| 119 | + .describe("The decision the user took, or undefined if the user let the decision time out. The game master may decide that failure to respond with in the time out means the user's character stayed still / failed to defend themselves, for instance."), |
| 120 | + }), |
| 121 | + callback: async ({storyUpdate, nextDecisions, decisionTimeoutSeconds}, extra) => { |
| 122 | + try { |
| 123 | + const result = await extra.sendRequest(<ElicitRequest>{ |
| 124 | + method: 'elicitation/create', |
| 125 | + params: { |
| 126 | + message: storyUpdate, |
| 127 | + requestedSchema: { |
| 128 | + type: 'object', |
| 129 | + properties: { |
| 130 | + nextDecision: { |
| 131 | + title: 'Next step', |
| 132 | + type: 'string', |
| 133 | + enum: nextDecisions, |
| 134 | + }, |
| 135 | + }, |
| 136 | + }, |
| 137 | + }, |
| 138 | + }, ElicitResultSchema, { |
| 139 | + timeout: decisionTimeoutSeconds === undefined ? undefined: decisionTimeoutSeconds * 1000, |
| 140 | + }); |
| 141 | + |
| 142 | + if (result.action === 'accept') { |
| 143 | + const structuredContent = { |
| 144 | + userDecision: result.content?.nextDecision as string, |
| 145 | + }; |
| 146 | + return { |
| 147 | + content: [{type: 'text', text: JSON.stringify(structuredContent)}], |
| 148 | + structuredContent, |
| 149 | + }; |
| 150 | + } else { |
| 151 | + return { |
| 152 | + content: [{type: 'text', text: result.action === 'decline' ? 'Game Over' : 'Game Cancelled'}], |
| 153 | + } |
| 154 | + } |
| 155 | + } catch (error) { |
| 156 | + if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { |
| 157 | + const structuredContent = { |
| 158 | + userDecision: undefined // Means "timeed out". |
| 159 | + }; |
| 160 | + return { |
| 161 | + content: [{type: 'text', text: JSON.stringify(structuredContent)}], |
| 162 | + structuredContent, |
| 163 | + }; |
| 164 | + } |
| 165 | + return makeErrorCallToolResult(error); |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | +}); |
| 170 | + |
| 171 | +// Create and configure MCP server |
| 172 | +const mcpServer = new McpServer({ |
| 173 | + name: "adventure-game", |
| 174 | + version: "1.0.0", |
| 175 | +}); |
| 176 | + |
| 177 | +// Register the localResearch tool that uses sampling with a tool loop |
| 178 | +mcpServer.registerTool( |
| 179 | + "choose_your_own_adventure_game", |
| 180 | + { |
| 181 | + description: "Play a game. The user will be asked for decisions along the way.", |
| 182 | + inputSchema: { |
| 183 | + gameSynopsisOrSubject: z |
| 184 | + .string() |
| 185 | + .describe( |
| 186 | + "Description of the game subject or possible synopsis." |
| 187 | + ), |
| 188 | + }, |
| 189 | + }, |
| 190 | + async ({ gameSynopsisOrSubject }, extra) => { |
| 191 | + try { |
| 192 | + const { answer, transcript, usage } = await runToolLoop({ |
| 193 | + initialMessages: [{ |
| 194 | + role: "user", |
| 195 | + content: { |
| 196 | + type: "text", |
| 197 | + text: gameSynopsisOrSubject, |
| 198 | + }, |
| 199 | + }], |
| 200 | + systemPrompt: |
| 201 | + "You are a 'choose your own adventure' game master. " + |
| 202 | + "Given an initial user request (subject and/or synopsis of the game, maybe description of their role in the game), " + |
| 203 | + "you will relentlessly walk the user forward in an imaginary story, " + |
| 204 | + "giving them regular choices as to what their character can do next can happen next. " + |
| 205 | + "If the user didn't choose a role for themselves, you can ask them to pick one of a few interesting options (first decision). " + |
| 206 | + "Then you will continually develop the story and call the nextStep too to give story updates and ask for pivotal decisions. " + |
| 207 | + "Updates should fit in a page (sometimes as short as a paragraph e.g. if doing a battle with very fast paced action). " + |
| 208 | + "Some decisions should have a timeout to create some thrills for the user, in tight action scenes. " + |
| 209 | + "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.", |
| 210 | + defaultToolChoice: {mode: 'required'}, |
| 211 | + server: mcpServer, |
| 212 | + registry, |
| 213 | + }, extra); |
| 214 | + |
| 215 | + return { |
| 216 | + content: [ |
| 217 | + { |
| 218 | + type: "text", |
| 219 | + text: answer, |
| 220 | + }, |
| 221 | + { |
| 222 | + type: "text", |
| 223 | + text: `\n\n--- Debug Transcript (${transcript.length} messages) ---\n${JSON.stringify(transcript, null, 2)}`, |
| 224 | + }, |
| 225 | + ], |
| 226 | + }; |
| 227 | + } catch (error) { |
| 228 | + return makeErrorCallToolResult(error); |
| 229 | + } |
| 230 | + } |
| 231 | +); |
| 232 | + |
| 233 | +async function main() { |
| 234 | + const transport = new StdioServerTransport(); |
| 235 | + await mcpServer.connect(transport); |
| 236 | + console.error("'MCP Choose Your Own Adventure Game' Server is running..."); |
| 237 | +} |
| 238 | + |
| 239 | +main().catch((error) => { |
| 240 | + console.error("Server error:", error); |
| 241 | + process.exit(1); |
| 242 | +}); |
0 commit comments