Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/website/content/docs/core-concepts/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

<Callout variant="info">
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.
</Callout>
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,16 @@ export function setupCleanupHandlers(
* Initializes and configures the MCP server with tools, prompts, and resources
*/
export async function initializeMcpServer(): Promise<McpServer> {
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);

Expand Down
22 changes: 8 additions & 14 deletions packages/xmcp/src/runtime/utils/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,16 +69,10 @@ export async function configureServer(
return server;
}

export function loadTools() {
const toolModules = new Map<string, ToolFile>();

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() {
Expand Down Expand Up @@ -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);
}
139 changes: 139 additions & 0 deletions packages/xmcp/src/runtime/utils/tool-loader.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, () => Promise<unknown>>
) {
const toolModules = new Map<string, ToolFile>();
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, "warn"> = 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();