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();