Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 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();
151 changes: 151 additions & 0 deletions packages/xmcp/src/runtime/utils/resource-loader.ts
Original file line number Diff line number Diff line change
@@ -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<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 createInvalidResourceImplementationError(path);
}

return "valid";
}

function toResourceFile(resourceModule: unknown): ResourceFile {
return resourceModule as ResourceFile;
}

export async function loadResourceModules(
loaders: Record<string, () => Promise<unknown>>
) {
const resourceModules = new Map<string, ResourceFile>();
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, "warn"> = 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();
Loading