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/prompts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

<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>
31 changes: 31 additions & 0 deletions apps/website/content/docs/core-concepts/resources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

<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>
31 changes: 24 additions & 7 deletions packages/xmcp/src/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
},
});
Expand All @@ -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 });
}
},
});
Expand Down Expand Up @@ -430,9 +440,16 @@ async function buildClientBundles(): Promise<Map<string, string> | 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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,13 @@ export function setupCleanupHandlers(
*/
export async function initializeMcpServer(): Promise<McpServer> {
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);

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