diff --git a/apps/website/content/docs/core-concepts/prompts.mdx b/apps/website/content/docs/core-concepts/prompts.mdx index 6c24b5453..a20baba72 100644 --- a/apps/website/content/docs/core-concepts/prompts.mdx +++ b/apps/website/content/docs/core-concepts/prompts.mdx @@ -101,3 +101,34 @@ The default export function that performs the actual work. - **Parameters**: Automatically typed from your schema using the built-in `InferSchema`. - **Returns**: MCP-compatible response with content type. - **Async**: Supports async operations for API calls, file I/O, etc. + +## Troubleshooting + +### Prompt Loading Errors + +When `xmcp` starts, it loads every file under your prompts directory. + +- Empty prompt 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/prompts/draft.ts` is empty, startup will log: + +```txt +[xmcp] Failed to load prompt file: src/prompts/draft.ts + -> File is empty. +[xmcp] 1 prompt 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 prompt file: src/prompts/draft.ts + -> File does not export a default prompt 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/apps/website/content/docs/core-concepts/resources.mdx b/apps/website/content/docs/core-concepts/resources.mdx index 9de55bac0..1abf0d491 100644 --- a/apps/website/content/docs/core-concepts/resources.mdx +++ b/apps/website/content/docs/core-concepts/resources.mdx @@ -101,3 +101,34 @@ The default export function that performs the actual work. - **Parameters**: Automatically typed from your schema using the built-in `InferSchema`. - **Returns**: MCP-compatible response with content type. + +## Troubleshooting + +### Resource Loading Errors + +When `xmcp` starts, it loads every file under your resources directory. + +- Empty resource 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/resources/(drafts)/latest.ts` is empty, startup will log: + +```txt +[xmcp] Failed to load resource file: src/resources/(drafts)/latest.ts + -> File is empty. +[xmcp] 1 resource 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 resource file: src/resources/(drafts)/latest.ts + -> File does not export a default resource 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/compiler/index.ts b/packages/xmcp/src/compiler/index.ts index 750991bfa..4d1f7abce 100644 --- a/packages/xmcp/src/compiler/index.ts +++ b/packages/xmcp/src/compiler/index.ts @@ -130,13 +130,18 @@ export async function compile({ onBuild }: CompileOptions = {}) { onAdd: async (filePath) => { addWatchedPath(promptPaths, filePath); if (compilerStarted) { - await generateCode(); + await generateCode({ rebuildClientBundles: false }); + } + }, + onChange: async () => { + if (compilerStarted) { + await generateCode({ rebuildClientBundles: false }); } }, onUnlink: async (filePath) => { removeWatchedPath(promptPaths, filePath); if (compilerStarted) { - await generateCode(); + await generateCode({ rebuildClientBundles: false }); } }, }); @@ -154,13 +159,18 @@ export async function compile({ onBuild }: CompileOptions = {}) { onAdd: async (filePath) => { addWatchedPath(resourcePaths, filePath); if (compilerStarted) { - await generateCode(); + await generateCode({ rebuildClientBundles: false }); + } + }, + onChange: async () => { + if (compilerStarted) { + await generateCode({ rebuildClientBundles: false }); } }, onUnlink: async (filePath) => { removeWatchedPath(resourcePaths, filePath); if (compilerStarted) { - await generateCode(); + await generateCode({ rebuildClientBundles: false }); } }, }); @@ -430,9 +440,16 @@ async function buildClientBundles(): Promise | undefined> { * Generates all runtime code and builds client bundles if needed * This centralizes all code generation logic including client bundle building */ -async function generateCode() { - // Build client bundles first (if there are React components) - const clientBundles = await buildClientBundles(); +async function generateCode({ + rebuildClientBundles = true, +}: { + rebuildClientBundles?: boolean; +} = {}) { + const { clientBundles: currentClientBundles } = compilerContext.getContext(); + const clientBundles = + rebuildClientBundles || currentClientBundles === undefined + ? await buildClientBundles() + : currentClientBundles; // Store in context for import map generation compilerContext.setContext({ clientBundles }); 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 49db18c44..a62d2ceb7 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts @@ -38,15 +38,13 @@ export function setupCleanupHandlers( */ export async function initializeMcpServer(): Promise { const toolModulesPromise = loadTools(); - const [promptPromises, promptModules] = loadPrompts(); - const [resourcePromises, resourceModules] = loadResources(); - - await Promise.all([ + const promptModulesPromise = loadPrompts(); + const resourceModulesPromise = loadResources(); + const [toolModules, promptModules, resourceModules] = await Promise.all([ toolModulesPromise, - ...promptPromises, - ...resourcePromises, + promptModulesPromise, + resourceModulesPromise, ]); - const toolModules = await toolModulesPromise; const server = new McpServer(INJECTED_CONFIG); diff --git a/packages/xmcp/src/runtime/utils/prompt-loader.ts b/packages/xmcp/src/runtime/utils/prompt-loader.ts new file mode 100644 index 000000000..3425f3e96 --- /dev/null +++ b/packages/xmcp/src/runtime/utils/prompt-loader.ts @@ -0,0 +1,151 @@ +import type { PromptFile } from "./server"; + +const EMPTY_PROMPT_FILE_MESSAGE = "File is empty."; +const MISSING_DEFAULT_EXPORT_MESSAGE = + "File does not export a default prompt handler."; +const INVALID_DEFAULT_EXPORT_MESSAGE = + "Default export must be a prompt handler function."; + +type PromptLoadIssue = { + path: string; + message: string; +}; + +function createPromptLoadIssue(path: string): PromptLoadIssue { + return { + path, + message: EMPTY_PROMPT_FILE_MESSAGE, + }; +} + +function createInvalidPromptImplementationError(path: string): Error { + return new Error( + `[xmcp] Invalid prompt file: ${path}\n -> ${INVALID_DEFAULT_EXPORT_MESSAGE}` + ); +} + +function classifyPromptModule( + promptModule: unknown, + path: string +): "empty" | "missing-default" | "valid" { + if (typeof promptModule !== "object" || promptModule === null) { + throw createInvalidPromptImplementationError(path); + } + + const moduleRecord = promptModule as Record; + const keys = Object.keys(moduleRecord); + const defaultExport = moduleRecord.default; + const nonInteropKeys = keys.filter((key) => key !== "__esModule"); + + if ( + keys.length === 0 || + (nonInteropKeys.length === 1 && + nonInteropKeys[0] === "default" && + typeof defaultExport === "object" && + defaultExport !== null && + Object.keys(defaultExport as Record).length === 0) + ) { + return "empty"; + } + + if (!("default" in moduleRecord) || moduleRecord.default === undefined) { + return "missing-default"; + } + + if (typeof moduleRecord.default !== "function") { + throw createInvalidPromptImplementationError(path); + } + + return "valid"; +} + +function toPromptFile(promptModule: unknown): PromptFile { + return promptModule as PromptFile; +} + +export async function loadPromptModules( + loaders: Record Promise> +) { + const promptModules = new Map(); + const skippedPrompts: PromptLoadIssue[] = []; + + const results = await Promise.all( + Object.entries(loaders).map(async ([path, loadPrompt]) => { + const promptModule = await loadPrompt(); + const classification = classifyPromptModule(promptModule, path); + + if (classification === "empty") { + return { + skipped: true as const, + issue: createPromptLoadIssue(path), + }; + } + + if (classification === "missing-default") { + return { + skipped: true as const, + issue: { + path, + message: MISSING_DEFAULT_EXPORT_MESSAGE, + }, + }; + } + + return { + skipped: false as const, + path, + promptModule: toPromptFile(promptModule), + }; + }) + ); + + for (const result of results) { + if (result.skipped) { + skippedPrompts.push(result.issue); + continue; + } + + promptModules.set(result.path, result.promptModule); + } + + return { + promptModules, + skippedPrompts, + }; +} + +function createPromptLoadIssueReporter( + logger: Pick = console +) { + let lastReportedSummaryKey = ""; + + return (skippedPrompts: PromptLoadIssue[]) => { + const summaryKey = skippedPrompts + .map(({ path, message }) => `${path}:${message}`) + .sort() + .join("|"); + + if (summaryKey === lastReportedSummaryKey) { + return; + } + + lastReportedSummaryKey = summaryKey; + + if (skippedPrompts.length === 0) { + return; + } + + skippedPrompts.forEach(({ path, message }) => { + logger.warn( + `[xmcp] Failed to load prompt file: ${path}\n -> ${message}` + ); + }); + + const count = skippedPrompts.length; + logger.warn( + `[xmcp] ${count} prompt${count === 1 ? "" : "s"} skipped due to empty files or missing default exports` + ); + }; +} + +export const reportPromptLoadIssues = createPromptLoadIssueReporter(); diff --git a/packages/xmcp/src/runtime/utils/resource-loader.ts b/packages/xmcp/src/runtime/utils/resource-loader.ts new file mode 100644 index 000000000..6bedfbc75 --- /dev/null +++ b/packages/xmcp/src/runtime/utils/resource-loader.ts @@ -0,0 +1,151 @@ +import type { ResourceFile } from "./server"; + +const EMPTY_RESOURCE_FILE_MESSAGE = "File is empty."; +const MISSING_DEFAULT_EXPORT_MESSAGE = + "File does not export a default resource handler."; +const INVALID_DEFAULT_EXPORT_MESSAGE = + "Default export must be a resource handler function."; + +type ResourceLoadIssue = { + path: string; + message: string; +}; + +function createResourceLoadIssue(path: string): ResourceLoadIssue { + return { + path, + message: EMPTY_RESOURCE_FILE_MESSAGE, + }; +} + +function createInvalidResourceImplementationError(path: string): Error { + return new Error( + `[xmcp] Invalid resource file: ${path}\n -> ${INVALID_DEFAULT_EXPORT_MESSAGE}` + ); +} + +function classifyResourceModule( + resourceModule: unknown, + path: string +): "empty" | "missing-default" | "valid" { + if (typeof resourceModule !== "object" || resourceModule === null) { + throw createInvalidResourceImplementationError(path); + } + + const moduleRecord = resourceModule as Record; + const keys = Object.keys(moduleRecord); + const defaultExport = moduleRecord.default; + const nonInteropKeys = keys.filter((key) => key !== "__esModule"); + + if ( + keys.length === 0 || + (nonInteropKeys.length === 1 && + nonInteropKeys[0] === "default" && + typeof defaultExport === "object" && + defaultExport !== null && + Object.keys(defaultExport as Record).length === 0) + ) { + return "empty"; + } + + if (!("default" in moduleRecord) || moduleRecord.default === undefined) { + return "missing-default"; + } + + if (typeof moduleRecord.default !== "function") { + throw createInvalidResourceImplementationError(path); + } + + return "valid"; +} + +function toResourceFile(resourceModule: unknown): ResourceFile { + return resourceModule as ResourceFile; +} + +export async function loadResourceModules( + loaders: Record Promise> +) { + const resourceModules = new Map(); + const skippedResources: ResourceLoadIssue[] = []; + + const results = await Promise.all( + Object.entries(loaders).map(async ([path, loadResource]) => { + const resourceModule = await loadResource(); + const classification = classifyResourceModule(resourceModule, path); + + if (classification === "empty") { + return { + skipped: true as const, + issue: createResourceLoadIssue(path), + }; + } + + if (classification === "missing-default") { + return { + skipped: true as const, + issue: { + path, + message: MISSING_DEFAULT_EXPORT_MESSAGE, + }, + }; + } + + return { + skipped: false as const, + path, + resourceModule: toResourceFile(resourceModule), + }; + }) + ); + + for (const result of results) { + if (result.skipped) { + skippedResources.push(result.issue); + continue; + } + + resourceModules.set(result.path, result.resourceModule); + } + + return { + resourceModules, + skippedResources, + }; +} + +function createResourceLoadIssueReporter( + logger: Pick = console +) { + let lastReportedSummaryKey = ""; + + return (skippedResources: ResourceLoadIssue[]) => { + const summaryKey = skippedResources + .map(({ path, message }) => `${path}:${message}`) + .sort() + .join("|"); + + if (summaryKey === lastReportedSummaryKey) { + return; + } + + lastReportedSummaryKey = summaryKey; + + if (skippedResources.length === 0) { + return; + } + + skippedResources.forEach(({ path, message }) => { + logger.warn( + `[xmcp] Failed to load resource file: ${path}\n -> ${message}` + ); + }); + + const count = skippedResources.length; + logger.warn( + `[xmcp] ${count} resource${count === 1 ? "" : "s"} skipped due to empty files or missing default exports` + ); + }; +} + +export const reportResourceLoadIssues = createResourceLoadIssueReporter(); diff --git a/packages/xmcp/src/runtime/utils/server.ts b/packages/xmcp/src/runtime/utils/server.ts index 5ef7fd20f..54da0dfc4 100644 --- a/packages/xmcp/src/runtime/utils/server.ts +++ b/packages/xmcp/src/runtime/utils/server.ts @@ -11,6 +11,11 @@ import { ZodRawShape } from "zod/v3"; import { addResourcesToServer } from "./resources"; import { ResourceMetadata } from "@/types/resource"; import { uIResourceRegistry } from "./ext-apps-registry"; +import { loadPromptModules, reportPromptLoadIssues } from "./prompt-loader"; +import { + loadResourceModules, + reportResourceLoadIssues, +} from "./resource-loader"; import { loadToolModules, reportToolLoadIssues } from "./tool-loader"; export type ToolFile = { @@ -75,36 +80,31 @@ export async function loadTools() { return toolModules; } -export function loadPrompts() { - const promptModules = new Map(); - - const promptPromises = Object.keys(injectedPrompts).map((path) => - injectedPrompts[path]().then((promptModule) => { - promptModules.set(path, promptModule); - }) +export async function loadPrompts() { + const { promptModules, skippedPrompts } = await loadPromptModules( + injectedPrompts ); - - return [promptPromises, promptModules] as const; + reportPromptLoadIssues(skippedPrompts); + return promptModules; } -export function loadResources() { - const resourceModules = new Map(); - - const resourcePromises = Object.keys(injectedResources).map((path) => - injectedResources[path]().then((resourceModule) => { - resourceModules.set(path, resourceModule); - }) +export async function loadResources() { + const { resourceModules, skippedResources } = await loadResourceModules( + injectedResources ); - - return [resourcePromises, resourceModules] as const; + reportResourceLoadIssues(skippedResources); + return resourceModules; } export async function createServer() { const server = new McpServer(INJECTED_CONFIG); const toolModulesPromise = loadTools(); - const [promptPromises, promptModules] = loadPrompts(); - const [resourcePromises, resourceModules] = loadResources(); - await Promise.all([toolModulesPromise, ...promptPromises, ...resourcePromises]); - const toolModules = await toolModulesPromise; + const promptModulesPromise = loadPrompts(); + const resourceModulesPromise = loadResources(); + const [toolModules, promptModules, resourceModules] = await Promise.all([ + toolModulesPromise, + promptModulesPromise, + resourceModulesPromise, + ]); 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 index 7718fc624..4559e8c2d 100644 --- a/packages/xmcp/src/runtime/utils/tool-loader.ts +++ b/packages/xmcp/src/runtime/utils/tool-loader.ts @@ -34,7 +34,17 @@ function classifyToolModule( const moduleRecord = toolModule as Record; const keys = Object.keys(moduleRecord); - if (keys.length === 0) { + const defaultExport = moduleRecord.default; + const nonInteropKeys = keys.filter((key) => key !== "__esModule"); + + if ( + keys.length === 0 || + (nonInteropKeys.length === 1 && + nonInteropKeys[0] === "default" && + typeof defaultExport === "object" && + defaultExport !== null && + Object.keys(defaultExport as Record).length === 0) + ) { return "empty"; }