diff --git a/apps/website/content/docs/core-concepts/tools.mdx b/apps/website/content/docs/core-concepts/tools.mdx index 07f70d27f..c08e2a5f0 100644 --- a/apps/website/content/docs/core-concepts/tools.mdx +++ b/apps/website/content/docs/core-concepts/tools.mdx @@ -503,6 +503,92 @@ export default async function getData() { } ``` +## Sampling From Tools + +Tool handlers can request MCP sampling through `sample(extra, ...)`. +Use it when a tool needs a model-generated follow-up, either as a simple +completion or as a tool-enabled flow that can call back into local xmcp tools. +If the connected client does not implement MCP sampling, +`sample()` throws an error instead of silently degrading. + +```typescript title="src/tools/summarize-with-sample.ts" +import { z } from "zod"; +import { + getSampleContext, + type InferSchema, + type ToolExtraArguments, + sample, +} from "xmcp"; + +export const schema = { + topic: z.string().describe("Topic to summarize"), +}; + +export const outputSchema = { + summary: z.string(), +}; + +export const metadata = { + name: "summarize_with_sample", + description: "Demonstrates xmcp sampling without tool use", +}; + +export default async function summarizeWithSample( + { topic }: InferSchema, + extra: ToolExtraArguments +) { + const result = await sample(extra, { + messages: [ + { + role: "user", + content: `Write a concise summary about ${topic}.`, + }, + ], + maxTokens: 300, + }); + + return getSampleContext(result) || "The client did not return text content."; +} +``` + +Because `outputSchema` has a single field, xmcp will also mirror that string +into structured output as `structuredContent.summary`. + +When you pass `tools`, xmcp resolves the named local tools, forwards their +definitions to the client, executes any returned `tool_use` blocks, and +continues the `tool_use -> tool_result -> continue` loop until the model +finishes or `maxSteps` is reached. + +```typescript title="src/tools/research-topic.ts" +const result = await sample(extra, { + messages: [ + { + role: "user", + content: `Research ${topic} using the local tools and give me a short answer.`, + }, + ], + tools: ["search_docs"], + toolChoice: { mode: "required" }, + maxTokens: 500, + maxSteps: 4, +}); +``` + +Sampling replies can come back as one block or several blocks. When tools are +enabled, those blocks can also include `tool_use` entries. Use +`getSampleContext(result)` when you just want the text to return from your +handler, or `getSampleContentBlocks(result)` when you need the raw blocks. +For text-only prompts, `messages[].content` also accepts a string shorthand and +xmcp will normalize it into a text block for the MCP request. + +A runnable HTTP example lives under +[`examples/http-transport/README.md`](https://github.com/basementstudio/xmcp/blob/canary/examples/http-transport/README.md), +with the sampling tools in +[`summarize-with-sample.ts`](https://github.com/basementstudio/xmcp/blob/canary/examples/http-transport/src/tools/summarize-with-sample.ts), +[`research-topic.ts`](https://github.com/basementstudio/xmcp/blob/canary/examples/http-transport/src/tools/research-topic.ts), +and +[`search-docs.ts`](https://github.com/basementstudio/xmcp/blob/canary/examples/http-transport/src/tools/search-docs.ts). + ## Troubleshooting ### Tool Loading Errors diff --git a/examples/http-transport/README.md b/examples/http-transport/README.md new file mode 100644 index 000000000..7241d4348 --- /dev/null +++ b/examples/http-transport/README.md @@ -0,0 +1,50 @@ +# HTTP Transport Example + +This example demonstrates xmcp sampling over Streamable HTTP. + +## Run the server + +From this directory: + +```bash +pnpm install +pnpm dev +``` + +Use the MCP URL printed in the terminal when the server starts. +If the default port is free, that URL will usually be `http://localhost:3000/mcp`. + +## Open it in your MCP client + +Connect a compatible MCP client to the HTTP server URL printed by `pnpm dev`. + +```txt +http://localhost:3000/mcp +``` + +## Try the sampling tools + +Once your client opens: + +1. Confirm the server is connected in the `MCP Servers` tab. +2. Open the `Tools` tab and run `summarize_with_sample` with: + +```json +{ + "topic": "Model Context Protocol" +} +``` + +This exercises the basic sampling flow and returns sampled text. + +3. Run `research_topic` with: + +```json +{ + "topic": "xmcp sampling" +} +``` + +This exercises the local tool loop. The model asks for `search_docs`, xmcp runs +that tool, and then sampling continues the `tool_use -> tool_result -> +continue` flow before returning the final text. diff --git a/examples/http-transport/src/tools/research-topic.ts b/examples/http-transport/src/tools/research-topic.ts new file mode 100644 index 000000000..faaac3a34 --- /dev/null +++ b/examples/http-transport/src/tools/research-topic.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { + getSampleTextContent, + type InferSchema, + type ToolExtraArguments, + sample, +} from "xmcp"; + +export const schema = { + topic: z.string().describe("Topic to research"), +}; + +export const metadata = { + name: "research_topic", + description: "Demonstrates xmcp sampling with local tool execution", +}; + +export default async function researchTopic( + { topic }: InferSchema, + extra: ToolExtraArguments +) { + const result = await sample(extra, { + messages: [ + { + role: "user", + content: `Research ${topic} using the local tools and give me a short answer.`, + }, + ], + tools: ["search_docs"], + toolChoice: { mode: "required" }, + maxTokens: 500, + maxSteps: 4, + }); + + return getSampleTextContent(result) || "No text returned."; +} diff --git a/examples/http-transport/src/tools/search-docs.ts b/examples/http-transport/src/tools/search-docs.ts new file mode 100644 index 000000000..33d15a96e --- /dev/null +++ b/examples/http-transport/src/tools/search-docs.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { type InferSchema } from "xmcp"; + +export const schema = { + query: z.string().describe("Query to look up in the canned docs"), +}; + +export const metadata = { + name: "search_docs", + description: "Returns canned xmcp notes for the sampling demo", +}; + +export default async function searchDocs({ + query, +}: InferSchema) { + return `Top hit for "${query}": xmcp lets tool handlers request client-side MCP sampling, optionally expose local tools, and continue the tool_use -> tool_result loop until the model finishes.`; +} diff --git a/examples/http-transport/src/tools/summarize-with-sample.ts b/examples/http-transport/src/tools/summarize-with-sample.ts new file mode 100644 index 000000000..7020c562f --- /dev/null +++ b/examples/http-transport/src/tools/summarize-with-sample.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { + getSampleContext, + type InferSchema, + type ToolExtraArguments, + sample, +} from "xmcp"; + +export const schema = { + topic: z.string().describe("Topic to summarize"), +}; + +export const outputSchema = { + summary: z.string(), +}; + +export const metadata = { + name: "summarize_with_sample", + description: "Demonstrates xmcp sampling without tool use", +}; + +export default async function summarizeWithSample( + { topic }: InferSchema, + extra: ToolExtraArguments +) { + const result = await sample(extra, { + messages: [ + { + role: "user", + content: `Write a concise summary about ${topic}.`, + }, + ], + maxTokens: 300, + }); + + return getSampleContext(result) || "The client did not return text content."; +} diff --git a/packages/xmcp/src/index.ts b/packages/xmcp/src/index.ts index 0828b39df..aae8e7750 100644 --- a/packages/xmcp/src/index.ts +++ b/packages/xmcp/src/index.ts @@ -7,6 +7,9 @@ export type { ToolSchema, ToolOutputSchema, ToolExtraArguments, + SampleMessageInput, + SampleRequest, + SampleResult, InferSchema, ElicitResult, } from "./types/tool"; @@ -20,6 +23,12 @@ export { apiKeyAuthMiddleware } from "./auth/api-key"; export { jwtAuthMiddleware } from "./auth/jwt"; export { createContext } from "./utils/context"; +export { sample } from "./runtime/utils/sampling"; +export { + getSampleContext, + getSampleContentBlocks, + getSampleTextContent, +} from "./utils/sample-result"; export { completable } from "@modelcontextprotocol/sdk/server/completable"; export { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types"; diff --git a/packages/xmcp/src/runtime/utils/sampling-tool-registry.ts b/packages/xmcp/src/runtime/utils/sampling-tool-registry.ts new file mode 100644 index 000000000..9166c5679 --- /dev/null +++ b/packages/xmcp/src/runtime/utils/sampling-tool-registry.ts @@ -0,0 +1,67 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types"; +import type { SampleToolSelection } from "@/types/tool"; +import type { McpToolHandler } from "./transformers/tool"; + +export interface SamplingToolRegistration { + definition: Tool; + validateInput: (input: unknown) => unknown; + execute: McpToolHandler; +} + +export type SamplingToolRegistry = Map; + +const defaultSamplingToolRegistry: SamplingToolRegistry = + createSamplingToolRegistry(); + +export function createSamplingToolRegistry(): SamplingToolRegistry { + return new Map(); +} + +export function clearSamplingToolRegistry( + registry: SamplingToolRegistry = defaultSamplingToolRegistry +): void { + registry.clear(); +} + +export function registerSamplingTool( + name: string, + registration: SamplingToolRegistration, + registry: SamplingToolRegistry = defaultSamplingToolRegistry +): void { + registry.set(name, registration); +} + +export function resolveSamplingTools( + selection: SampleToolSelection, + registry: SamplingToolRegistry = defaultSamplingToolRegistry +): SamplingToolRegistration[] { + if (selection === "all") { + return Array.from(registry.values()); + } + + const resolved: SamplingToolRegistration[] = []; + const missing: string[] = []; + const seen = new Set(); + + for (const name of selection) { + if (seen.has(name)) { + continue; + } + + seen.add(name); + const tool = registry.get(name); + + if (!tool) { + missing.push(name); + continue; + } + + resolved.push(tool); + } + + if (missing.length > 0) { + throw new Error(`Unknown sampling tool(s): ${missing.join(", ")}`); + } + + return resolved; +} diff --git a/packages/xmcp/src/runtime/utils/sampling.ts b/packages/xmcp/src/runtime/utils/sampling.ts new file mode 100644 index 000000000..bdcfe929b --- /dev/null +++ b/packages/xmcp/src/runtime/utils/sampling.ts @@ -0,0 +1,295 @@ +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol"; +import { + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + type CreateMessageResult, + type CreateMessageResultWithTools, + type ServerNotification, + type ServerRequest, + type ToolResultContent, + type ToolUseContent, +} from "@modelcontextprotocol/sdk/types"; +import type { + SampleContent, + SampleMessage, + SampleMessageContentInput, + SampleRequest, + SampleResult, + ToolRequestOptions, +} from "@/types/tool"; +import { + resolveSamplingTools, + type SamplingToolRegistration, + type SamplingToolRegistry, +} from "./sampling-tool-registry"; + +type SamplingExtra = RequestHandlerExtra; +type SamplingResponse = CreateMessageResult | CreateMessageResultWithTools; + +type SamplingContext = { + currentToolName?: string; + samplingToolRegistry?: SamplingToolRegistry; +}; + +const samplingContexts = new WeakMap(); + +function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + + return String(error); +} + +function isUnsupportedSamplingError(error: unknown): boolean { + const message = getErrorMessage(error); + + return ( + message.includes("sampling/createMessage") || + message.includes("Method not found") || + message.includes("does not support MCP sampling") + ); +} + +function normalizeSamplingError(error: unknown): Error { + if (isUnsupportedSamplingError(error)) { + return new Error( + "Connected MCP client does not support MCP sampling yet (missing sampling/createMessage)." + ); + } + + return error instanceof Error ? error : new Error(getErrorMessage(error)); +} + +function createToolErrorResult( + toolUseId: string, + message: string +): ToolResultContent { + return { + type: "tool_result", + toolUseId, + isError: true, + content: [ + { + type: "text", + text: message, + }, + ], + }; +} + +function toSamplingMessage(response: SamplingResponse) { + return { + role: response.role, + content: response.content, + _meta: response._meta, + }; +} + +function toTextBlock(text: string): Extract { + return { + type: "text", + text, + }; +} + +function normalizeMessageContent( + content: SampleMessageContentInput +): SampleMessage["content"] { + if (typeof content === "string") { + return toTextBlock(content); + } + + if (Array.isArray(content)) { + return [...content]; + } + + return content as SampleMessage["content"]; +} + +function normalizeMessages(messages: SampleRequest["messages"]): SampleMessage[] { + return messages.map((message) => ({ + ...message, + content: normalizeMessageContent(message.content), + })); +} + +function extractToolUses(response: CreateMessageResultWithTools): ToolUseContent[] { + return asArray(response.content).filter( + (content): content is ToolUseContent => content.type === "tool_use" + ); +} + +async function executeToolUse( + toolUse: ToolUseContent, + extra: SamplingExtra, + toolMap: Map +): Promise { + const tool = toolMap.get(toolUse.name); + + if (!tool) { + return createToolErrorResult( + toolUse.id, + `Tool "${toolUse.name}" is not available for sampling.` + ); + } + + try { + const validatedInput = tool.validateInput(toolUse.input); + const result = await tool.execute(validatedInput as any, extra); + + return { + type: "tool_result", + toolUseId: toolUse.id, + content: result.content ?? [], + ...(result.structuredContent + ? { structuredContent: result.structuredContent } + : {}), + ...(result.isError ? { isError: true } : {}), + ...(result._meta ? { _meta: result._meta } : {}), + }; + } catch (error) { + return createToolErrorResult( + toolUse.id, + `Tool "${toolUse.name}" failed: ${getErrorMessage(error)}` + ); + } +} + +async function sendSamplingRequest( + extra: SamplingExtra, + params: any, + resultSchema: any, + options?: ToolRequestOptions +) { + try { + return await extra.sendRequest( + { + method: "sampling/createMessage", + params, + } as any, + resultSchema, + options + ); + } catch (error) { + throw normalizeSamplingError(error); + } +} + +function getSamplingContext(extra: SamplingExtra): SamplingContext { + const context = samplingContexts.get(extra); + + if (!context) { + throw new Error( + "sample() can only be called from inside an xmcp tool handler." + ); + } + + return context; +} + +export function bindSamplingContext( + extra: SamplingExtra, + context: SamplingContext +): void { + samplingContexts.set(extra, context); +} + +export function clearSamplingContext(extra: SamplingExtra): void { + samplingContexts.delete(extra); +} + +export async function sample( + extra: SamplingExtra, + request: SampleRequest, + options?: ToolRequestOptions +): Promise { + const { currentToolName, samplingToolRegistry } = getSamplingContext(extra); + const { + tools: toolSelection, + maxSteps, + messages: inputMessages, + ...params + } = request; + const messages = normalizeMessages(inputMessages); + + if (!toolSelection) { + return sendSamplingRequest( + extra, + { + ...params, + messages, + }, + CreateMessageResultSchema, + options + ); + } + + const resolvedTools = resolveSamplingTools( + toolSelection, + samplingToolRegistry + ).filter( + (tool) => toolSelection !== "all" || tool.definition.name !== currentToolName + ); + + if (resolvedTools.length === 0) { + throw new Error( + toolSelection === "all" && currentToolName + ? `Sampling requested "all" tools, but "${currentToolName}" is the only available tool. Pass explicit tool names or omit "tools".` + : 'Sampling requested tools, but no tools were registered. Pass explicit tool names or omit "tools".' + ); + } + + const toolMap = new Map( + resolvedTools.map((tool) => [tool.definition.name, tool] as const) + ); + + let currentMessages = [...messages]; + let step = 0; + + while (true) { + const response = await sendSamplingRequest( + extra, + { + ...params, + messages: currentMessages, + tools: resolvedTools.map((tool) => tool.definition), + }, + CreateMessageResultWithToolsSchema, + options + ); + + const toolUses = extractToolUses(response); + + if (toolUses.length === 0) { + return response; + } + + if (typeof maxSteps === "number" && step >= maxSteps) { + throw new Error( + `Sampling exceeded the configured maxSteps (${maxSteps}).` + ); + } + + const toolResults: ToolResultContent[] = []; + + for (const toolUse of toolUses) { + toolResults.push(await executeToolUse(toolUse, extra, toolMap)); + } + + currentMessages = [ + ...currentMessages, + toSamplingMessage(response), + { + role: "user", + content: toolResults, + }, + ]; + + step += 1; + } +} diff --git a/packages/xmcp/src/runtime/utils/server.ts b/packages/xmcp/src/runtime/utils/server.ts index 0069954ff..980dbc038 100644 --- a/packages/xmcp/src/runtime/utils/server.ts +++ b/packages/xmcp/src/runtime/utils/server.ts @@ -11,6 +11,7 @@ import { ZodRawShape } from "zod/v3"; import { addResourcesToServer } from "./resources"; import { ResourceMetadata } from "@/types/resource"; import { uIResourceRegistry } from "./ext-apps-registry"; +import { createSamplingToolRegistry } from "./sampling-tool-registry"; import { loadPromptModules, reportPromptLoadIssues } from "./prompt-loader"; import { loadResourceModules, @@ -63,8 +64,9 @@ export async function configureServer( resourceModules: Map ): Promise { uIResourceRegistry.clear(); + const samplingToolRegistry = createSamplingToolRegistry(); - addToolsToServer(server, toolModules); + addToolsToServer(server, toolModules, samplingToolRegistry); addPromptsToServer(server, promptModules); addResourcesToServer(server, resourceModules); return server; diff --git a/packages/xmcp/src/runtime/utils/tools.ts b/packages/xmcp/src/runtime/utils/tools.ts index b61049b20..c3fba65a1 100644 --- a/packages/xmcp/src/runtime/utils/tools.ts +++ b/packages/xmcp/src/runtime/utils/tools.ts @@ -1,14 +1,19 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat"; import { z } from "zod"; import { ZodRawShape } from "zod/v3"; import { ToolFile } from "./server"; import { ToolMetadata } from "@/types/tool"; -import { transformToolHandler } from "./transformers/tool"; +import { McpToolHandler, transformToolHandler } from "./transformers/tool"; import { isReactFile } from "./react"; import { uIResourceRegistry } from "./ext-apps-registry"; import { flattenMeta, hasUIMeta } from "./ui/flatten-meta"; import { splitUIMetaNested } from "./ui/split-meta"; import { isPaidHandler, getX402Registry } from "@/plugins/x402"; +import { + registerSamplingTool, + type SamplingToolRegistry, +} from "./sampling-tool-registry"; /** Validates if a value is a valid Zod schema object */ export function isZodRawShape(value: unknown): value is ZodRawShape { @@ -41,7 +46,8 @@ export function ensureAnnotations(toolConfig: Pick + toolModules: Map, + samplingToolRegistry: SamplingToolRegistry ): McpServer { toolModules.forEach((toolModule, path) => { const defaultName = pathToName(path); @@ -139,11 +145,11 @@ export function addToolsToServer( const flattenedToolMeta = flattenMeta(toolSpecificMeta); const meta = uiWidget ? flattenedToolMeta : undefined; - let transformedHandler; + let transformedHandler: McpToolHandler; if (isReactFile(path) && uiWidget) { transformedHandler = async (args: any, extra: any) => ({ - content: [{ type: "text", text: "" }], + content: [{ type: "text" as const, text: "" }], _meta: meta, structuredContent: { args, @@ -154,7 +160,8 @@ export function addToolsToServer( handler, meta, toolOutputSchema, - toolConfig.name + toolConfig.name, + samplingToolRegistry ); } @@ -171,6 +178,41 @@ export function addToolsToServer( _meta: flattenedToolMeta, // Use flattened metadata for MCP protocol }; + registerSamplingTool(toolConfig.name, { + definition: { + name: toolConfig.name, + ...(toolConfigFormatted.title + ? { title: toolConfigFormatted.title } + : {}), + ...(toolConfigFormatted.description + ? { description: toolConfigFormatted.description } + : {}), + inputSchema: toJsonSchemaCompat(toolConfigFormatted.inputSchema, { + strictUnions: true, + pipeStrategy: "input", + }) as any, + ...(toolConfigFormatted.outputSchema + ? { + outputSchema: toJsonSchemaCompat( + toolConfigFormatted.outputSchema, + { + strictUnions: true, + pipeStrategy: "output", + } + ) as any, + } + : {}), + ...(toolConfigFormatted.annotations + ? { annotations: toolConfigFormatted.annotations } + : {}), + ...(Object.keys(flattenedToolMeta).length > 0 + ? { _meta: flattenedToolMeta } + : {}), + }, + validateInput: (input) => toolConfigFormatted.inputSchema.parse(input), + execute: transformedHandler, + }, samplingToolRegistry); + // server as any prevents infinite type recursion (server as any).registerTool( toolConfig.name, diff --git a/packages/xmcp/src/runtime/utils/transformers/tool.ts b/packages/xmcp/src/runtime/utils/transformers/tool.ts index 27973b5d4..37ffbe356 100644 --- a/packages/xmcp/src/runtime/utils/transformers/tool.ts +++ b/packages/xmcp/src/runtime/utils/transformers/tool.ts @@ -7,7 +7,9 @@ import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol"; import { ZodRawShape } from "zod/v3"; import type { ToolExtraArguments } from "@/types/tool"; import { elicitFromTool } from "../elicitation"; +import { bindSamplingContext, clearSamplingContext } from "../sampling"; import { validateContent } from "../validators"; +import type { SamplingToolRegistry } from "../sampling-tool-registry"; function validateAgainstOutputSchema( data: Record, @@ -102,18 +104,30 @@ export function transformToolHandler( handler: UserToolHandler, meta?: Record, outputSchema?: ZodRawShape, - toolName = "unknown-tool" + toolName = "unknown-tool", + samplingToolRegistry?: SamplingToolRegistry ): McpToolHandler { return async ( args: ZodRawShape, extra: RequestHandlerExtra ): Promise => { const toolExtra = createToolExtraArguments(extra); - let response: any = handler(args, toolExtra); + bindSamplingContext(toolExtra as any, { + currentToolName: toolName, + samplingToolRegistry, + }); - // only await if it's actually a promise - if (response instanceof Promise) { - response = await response; + let response: any; + + try { + response = handler(args, toolExtra); + + // only await if it's actually a promise + if (response instanceof Promise) { + response = await response; + } + } finally { + clearSamplingContext(toolExtra as any); } if (typeof response === "string" || typeof response === "number") { diff --git a/packages/xmcp/src/types/tool.ts b/packages/xmcp/src/types/tool.ts index 77f605858..df7f72c97 100644 --- a/packages/xmcp/src/types/tool.ts +++ b/packages/xmcp/src/types/tool.ts @@ -1,6 +1,15 @@ import { z } from "zod/v3"; import type { ZodType as ZodTypeV4, infer as inferV4 } from "zod"; -import type { ElicitResult as McpElicitResult } from "@modelcontextprotocol/sdk/types"; +import type { + CreateMessageRequestParams, + CreateMessageResult, + CreateMessageResultWithTools, + ElicitResult as McpElicitResult, + ModelPreferences, + SamplingMessage, + SamplingMessageContentBlock, + ToolChoice, +} from "@modelcontextprotocol/sdk/types"; import { UIMetadata } from "./ui-meta"; export interface ToolAnnotations { @@ -42,6 +51,45 @@ type InferCompatibleZodType = export type ToolSchema = Record; export type ToolOutputSchema = Record; export type ElicitResult = McpElicitResult; +export type SampleMessage = SamplingMessage; +export type SampleContent = SamplingMessageContentBlock; +export type SampleMessageContentInput = + | SampleContent + | readonly SampleContent[] + | string; +export type SampleMessageInput = Omit & { + content: SampleMessageContentInput; +}; +export type SampleModelPreferences = ModelPreferences; +export type SampleToolChoice = ToolChoice; +export type SampleToolSelection = "all" | readonly string[]; +export type SampleResult = CreateMessageResult | CreateMessageResultWithTools; + +type SampleRequestBase = Omit< + CreateMessageRequestParams, + "messages" | "tools" | "toolChoice" +> & { + messages: readonly SampleMessageInput[]; + /** + * Maximum number of tool-execution rounds to run before aborting. + * Only applies when `tools` are enabled. + */ + maxSteps?: number; +}; + +export type SampleRequest = + | (SampleRequestBase & { + tools?: never; + toolChoice?: never; + }) + | (SampleRequestBase & { + /** + * Local xmcp tool names to expose to the model, or `"all"` to expose + * every registered tool in the current server. + */ + tools: SampleToolSelection; + toolChoice?: ToolChoice; + }); export interface ToolRequestOptions { /** Progress notification callback */ diff --git a/packages/xmcp/src/utils/sample-result.ts b/packages/xmcp/src/utils/sample-result.ts new file mode 100644 index 000000000..52ef55a84 --- /dev/null +++ b/packages/xmcp/src/utils/sample-result.ts @@ -0,0 +1,21 @@ +import type { SampleContent, SampleResult } from "@/types/tool"; + +type TextSampleContent = Extract; + +export function getSampleContentBlocks(result: SampleResult): SampleContent[] { + return Array.isArray(result.content) ? result.content : [result.content]; +} + +export function getSampleContext(result: SampleResult): string { + return getSampleTextContent(result); +} + +export function getSampleTextContent(result: SampleResult): string { + return getSampleContentBlocks(result) + .filter( + (block): block is TextSampleContent => + block.type === "text" && typeof block.text === "string" + ) + .map((block) => block.text) + .join("\n"); +}