diff --git a/apps/website/content/docs/core-concepts/tools.mdx b/apps/website/content/docs/core-concepts/tools.mdx index b4876d8c8..1522c94e9 100644 --- a/apps/website/content/docs/core-concepts/tools.mdx +++ b/apps/website/content/docs/core-concepts/tools.mdx @@ -455,3 +455,34 @@ export default async function getData() { }; } ``` + +## Troubleshooting + +### Tool Loading Errors + +When `xmcp` starts, it loads every file under your tools directory. + +- Empty tool files are skipped with a friendly warning +- Files without a `default` export are skipped with a friendly warning +- Real syntax or import errors still fail normally so you can see the full stack trace + +For example, if `src/tools/draft.ts` is empty, startup will log: + +```txt +[xmcp] Failed to load tool file: src/tools/draft.ts + -> File is empty. +[xmcp] 1 tool skipped due to empty files or missing default exports +``` + +If the file exists but does not export a default handler, startup will log: + +```txt +[xmcp] Failed to load tool file: src/tools/draft.ts + -> File does not export a default tool handler. +``` + + + Friendly handling is intentionally limited to empty files and missing default + exports. Invalid implementations and real import/syntax errors still surface + as normal runtime errors. + diff --git a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts index 9e4fc8468..49db18c44 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts @@ -37,11 +37,16 @@ export function setupCleanupHandlers( * Initializes and configures the MCP server with tools, prompts, and resources */ export async function initializeMcpServer(): Promise { - const [toolPromises, toolModules] = loadTools(); + const toolModulesPromise = loadTools(); const [promptPromises, promptModules] = loadPrompts(); const [resourcePromises, resourceModules] = loadResources(); - await Promise.all([...toolPromises, ...promptPromises, ...resourcePromises]); + await Promise.all([ + toolModulesPromise, + ...promptPromises, + ...resourcePromises, + ]); + const toolModules = await toolModulesPromise; const server = new McpServer(INJECTED_CONFIG); diff --git a/packages/xmcp/src/runtime/utils/server.ts b/packages/xmcp/src/runtime/utils/server.ts index fb54f0d99..5ef7fd20f 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 { loadToolModules, reportToolLoadIssues } from "./tool-loader"; export type ToolFile = { metadata: ToolMetadata; @@ -68,16 +69,10 @@ export async function configureServer( return server; } -export function loadTools() { - const toolModules = new Map(); - - const toolPromises = Object.keys(injectedTools).map((path) => - injectedTools[path]().then((toolModule) => { - toolModules.set(path, toolModule); - }) - ); - - return [toolPromises, toolModules] as const; +export async function loadTools() { + const { toolModules, skippedTools } = await loadToolModules(injectedTools); + reportToolLoadIssues(skippedTools); + return toolModules; } export function loadPrompts() { @@ -106,11 +101,10 @@ export function loadResources() { export async function createServer() { const server = new McpServer(INJECTED_CONFIG); - const [toolPromises, toolModules] = loadTools(); + const toolModulesPromise = loadTools(); const [promptPromises, promptModules] = loadPrompts(); const [resourcePromises, resourceModules] = loadResources(); - await Promise.all(toolPromises); - await Promise.all(promptPromises); - await Promise.all(resourcePromises); + await Promise.all([toolModulesPromise, ...promptPromises, ...resourcePromises]); + const toolModules = await toolModulesPromise; return configureServer(server, toolModules, promptModules, resourceModules); } diff --git a/packages/xmcp/src/runtime/utils/tool-loader.ts b/packages/xmcp/src/runtime/utils/tool-loader.ts new file mode 100644 index 000000000..7718fc624 --- /dev/null +++ b/packages/xmcp/src/runtime/utils/tool-loader.ts @@ -0,0 +1,139 @@ +import type { ToolFile } from "./server"; + +const EMPTY_TOOL_FILE_MESSAGE = "File is empty."; +const MISSING_DEFAULT_EXPORT_MESSAGE = + "File does not export a default tool handler."; +const INVALID_DEFAULT_EXPORT_MESSAGE = + "Default export must be a tool handler function."; + +type ToolLoadIssue = { + path: string; + message: string; +}; + +function createToolLoadIssue(path: string): ToolLoadIssue { + return { + path, + message: EMPTY_TOOL_FILE_MESSAGE, + }; +} + +function createInvalidToolImplementationError(path: string): Error { + return new Error( + `[xmcp] Invalid tool file: ${path}\n -> ${INVALID_DEFAULT_EXPORT_MESSAGE}` + ); +} + +function classifyToolModule( + toolModule: unknown, + path: string +): "empty" | "missing-default" | "valid" { + if (typeof toolModule !== "object" || toolModule === null) { + throw createInvalidToolImplementationError(path); + } + + const moduleRecord = toolModule as Record; + const keys = Object.keys(moduleRecord); + if (keys.length === 0) { + return "empty"; + } + + if (!("default" in moduleRecord) || moduleRecord.default === undefined) { + return "missing-default"; + } + + if (typeof moduleRecord.default !== "function") { + throw createInvalidToolImplementationError(path); + } + + return "valid"; +} + +function toToolFile(toolModule: unknown): ToolFile { + return toolModule as ToolFile; +} + +export async function loadToolModules( + loaders: Record Promise> +) { + const toolModules = new Map(); + const skippedTools: ToolLoadIssue[] = []; + + const results = await Promise.all( + Object.entries(loaders).map(async ([path, loadTool]) => { + const toolModule = await loadTool(); + const classification = classifyToolModule(toolModule, path); + + if (classification === "empty") { + return { + skipped: true as const, + issue: createToolLoadIssue(path), + }; + } + + if (classification === "missing-default") { + return { + skipped: true as const, + issue: { + path, + message: MISSING_DEFAULT_EXPORT_MESSAGE, + }, + }; + } + + return { + skipped: false as const, + path, + toolModule: toToolFile(toolModule), + }; + }) + ); + + for (const result of results) { + if (result.skipped) { + skippedTools.push(result.issue); + continue; + } + + toolModules.set(result.path, result.toolModule); + } + + return { + toolModules, + skippedTools, + }; +} + +function createToolLoadIssueReporter( + logger: Pick = console +) { + let lastReportedSummaryKey = ""; + + return (skippedTools: ToolLoadIssue[]) => { + const summaryKey = skippedTools + .map(({ path, message }) => `${path}:${message}`) + .sort() + .join("|"); + + if (summaryKey === lastReportedSummaryKey) { + return; + } + + lastReportedSummaryKey = summaryKey; + + if (skippedTools.length === 0) { + return; + } + + skippedTools.forEach(({ path, message }) => { + logger.warn(`[xmcp] Failed to load tool file: ${path}\n -> ${message}`); + }); + + const count = skippedTools.length; + logger.warn( + `[xmcp] ${count} tool${count === 1 ? "" : "s"} skipped due to empty files or missing default exports` + ); + }; +} + +export const reportToolLoadIssues = createToolLoadIssueReporter();