diff --git a/package.json b/package.json index f9380916..c1ab08e2 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@unocss/reset": "catalog:frontend", "bumpp": "catalog:tooling", "crossws": "catalog:deps", + "esbuild": "catalog:build", "eslint": "catalog:tooling", "h3": "catalog:deps", "knip": "catalog:tooling", diff --git a/packages/devframe/src/adapters/cac.ts b/packages/devframe/src/adapters/cac.ts index c2487b61..2a029072 100644 --- a/packages/devframe/src/adapters/cac.ts +++ b/packages/devframe/src/adapters/cac.ts @@ -12,6 +12,7 @@ import type { DevframeDefinition } from '../types/devframe' import process from 'node:process' import cac from 'cac' import { colors as c } from 'devframe/utils/colors' +import { importRuntimeModule } from '../node/import-runtime-module' import { createBuild } from './build' import { createDevServer, resolveDevServerPort } from './dev' import { flagKeyToOption, isBooleanFlag, parseCliFlags } from './flags' @@ -124,7 +125,7 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) // MCP clients expect JSON-RPC on stdout — route welcome/logging // noise out of the way. Logs-SDK diagnostics land on stderr by // default, so nothing extra needed beyond not printing here. - const { createMcpServer } = await import('./mcp') + const { createMcpServer } = await importRuntimeModule('devframe/adapters/mcp') await createMcpServer(d, { transport: 'stdio', // Deliberately go to stderr: stdout is the MCP transport. diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index e1971775..6f2eb3c8 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -19,6 +19,7 @@ import { resolveClientAssets } from '../define' import { createHostContext } from '../node/context' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' +import { importRuntimeModule } from '../node/import-runtime-module' import { createInstanceShell, resolveInstanceRegister } from '../node/instance-shell' import { normalizeBasePath } from './_shared' import { resolveDevServerPort, resolveMcpConnectionMeta } from './dev' @@ -311,7 +312,7 @@ export function initDevframe( const mcpPath = joinURL(base, mcpMeta.path) let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp try { - ;({ mountMcpHttp } = await import('./mcp/http')) + ;({ mountMcpHttp } = await importRuntimeModule('devframe/adapters/mcp')) } catch (error) { const reason = error instanceof Error ? error.message : String(error) diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index bdcf5f5a..fc159c97 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -38,6 +38,7 @@ export type { AgentArgsFallback } from '../node/agent-args' export { diagnostics } from '../node/diagnostics' export { DevframeAgentHost } from '../node/host-agent' export * from '../node/host-h3' +export { importRuntimeModule } from '../node/import-runtime-module' export { listLiveDevframeInstances, registerDevframeInstance } from '../node/instance-registry' export type { DevframeInstanceRecord, DevframeInstanceRegistration } from '../node/instance-registry' export { createInstanceShell, resolveInstanceRegister, samePath } from '../node/instance-shell' diff --git a/packages/devframe/src/node/import-runtime-module.ts b/packages/devframe/src/node/import-runtime-module.ts new file mode 100644 index 00000000..daea7127 --- /dev/null +++ b/packages/devframe/src/node/import-runtime-module.ts @@ -0,0 +1,12 @@ +import { importServicePackage } from './services-install' + +/** + * Resolve and import a package at runtime without adding it to a consumer's + * bundle graph. First-party adapters use this for optional peers whose code + * is needed only when the matching feature is enabled. + * + * @internal + */ +export async function importRuntimeModule(specifier: string): Promise { + return await importServicePackage(specifier, [import.meta.url]) as T +} diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 6f6aa439..7268e3c3 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -12,7 +12,7 @@ import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import process from 'node:process' import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from 'devframe/constants' -import { createH3DevframeHost, createInstanceShell, resolveInstanceRegister } from 'devframe/internal' +import { createH3DevframeHost, createInstanceShell, importRuntimeModule, resolveInstanceRegister } from 'devframe/internal' import { mountStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' import { resolve } from 'pathe' @@ -571,7 +571,7 @@ export function initHub(options: InitHubOptions): HubInstance { return { context: ctx } const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE) - const { mountMcpHttp } = await import('devframe/adapters/mcp') + const { mountMcpHttp } = await importRuntimeModule('devframe/adapters/mcp') const mounted = mountMcpHttp(app, ctx, joinURL(base, mcpRoute), { serverName: options.name ?? 'devframes-hub', serverVersion: options.version ?? '0.0.0', diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index 82c5fe4b..b0e4fa3b 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -1,5 +1,6 @@ import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope } from 'devframe' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { importRuntimeModule } from 'devframe/internal' import { serveStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' @@ -163,7 +164,7 @@ export function createDevframeNextHost( connectionMeta = meta }, async mountMcp(ctx, path, mcpOptions = {}) { - const { createMcpFetchHandler } = await import('devframe/adapters/mcp') + const { createMcpFetchHandler } = await importRuntimeModule('devframe/adapters/mcp') const handler = createMcpFetchHandler(ctx, { serverName: mcpOptions.serverName ?? 'devframe (next)', serverVersion: mcpOptions.serverVersion ?? '0.0.0', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb06dc0a..49e255f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@vitejs/plugin-vue': specifier: ^6.0.8 version: 6.0.8 + esbuild: + specifier: ^0.28.0 + version: 0.28.0 lightningcss: specifier: ^1.33.0 version: 1.33.0 @@ -432,6 +435,9 @@ importers: crossws: specifier: ^0.4.10 version: 0.4.10(srvx@0.12.4) + esbuild: + specifier: catalog:build + version: 0.28.0 eslint: specifier: catalog:tooling version: 10.8.1(jiti@2.7.0)(supports-color@10.2.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b425924a..7b50158d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -90,6 +90,7 @@ catalogs: '@sveltejs/adapter-node': ^5.3.3 '@sveltejs/kit': ^2.46.4 '@vitejs/plugin-vue': ^6.0.8 + esbuild: ^0.28.0 lightningcss: ^1.33.0 magic-string: ^1.2.0 mlly: ^1.8.2 diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js index 6ad22c4c..bd0cf367 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.js @@ -1,8 +1,8 @@ /** * Generated by tsnapi — public API snapshot of `devframe/adapters/mcp` */ -// #region Other -export { createMcpFetchHandler } -export { createMcpServer } -export { mountMcpHttp } +// #region Functions +export function createMcpFetchHandler(_, _) {} +export async function createMcpServer(_, _) {} +export function mountMcpHttp(_, _, _, _) {} // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index c5217926..23179e31 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -50,6 +50,7 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { export declare function coerceAgentPositionalArgs(_: unknown, _: readonly unknown[] | undefined, _?: AgentArgsFallback): unknown[]; export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost; export declare function createRpcWireCodec(_?: ReadonlyMap>): RpcWireCodec; +export declare function importRuntimeModule(_: string): Promise; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; export declare function peekRpcWireFrame(_: string): { t?: string; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js index 861bcf1e..11d48e43 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js @@ -9,6 +9,7 @@ export { createInstanceShell } export { createRpcWireCodec } export { DevframeAgentHost } export { diagnostics } +export { importRuntimeModule } export { listLiveDevframeInstances } export { normalizeBasePath } export { normalizeHttpServerUrl } diff --git a/tests/optional-mcp-bundles.test.ts b/tests/optional-mcp-bundles.test.ts new file mode 100644 index 00000000..1a1cb771 --- /dev/null +++ b/tests/optional-mcp-bundles.test.ts @@ -0,0 +1,112 @@ +import type { Plugin } from 'esbuild' +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { build } from 'esbuild' +import { afterEach, describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const entries = [ + 'packages/devframe/dist/adapters/cac.mjs', + 'packages/devframe/dist/adapters/initiate.mjs', + 'packages/hub/dist/node/initiate.mjs', + 'packages/next/dist/hub.mjs', +] +const nextServerChunks = join(root, 'examples/hub-next/src/client/.next/server/chunks') +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) + rmSync(directory, { recursive: true, force: true }) +}) + +describe('optional MCP peers in consumer bundles', () => { + it.each(entries)('bundles %s without resolving the MCP SDK', async (entry) => { + const resolvedMcpImports: string[] = [] + const rejectMcpSdk: Plugin = { + name: 'reject-mcp-sdk', + setup(context) { + context.onResolve({ filter: /^@modelcontextprotocol\// }, (args) => { + resolvedMcpImports.push(args.path) + return { errors: [{ text: `Unexpected MCP SDK import: ${args.path}` }] } + }) + }, + } + + await build({ + entryPoints: [join(root, entry)], + bundle: true, + format: 'esm', + platform: 'node', + plugins: [rejectMcpSdk], + write: false, + }) + + expect(resolvedMcpImports).toEqual([]) + }) + + it('loads the MCP adapter when an MCP-enabled hub bundle starts', async () => { + const hubDist = join(root, 'packages/hub/dist') + const outputDirectory = mkdtempSync(join(hubDist, '.mcp-bundle-test-')) + temporaryDirectories.push(outputDirectory) + const outfile = join(outputDirectory, 'hub.mjs') + + await build({ + entryPoints: [join(hubDist, 'node/initiate.mjs')], + bundle: true, + format: 'esm', + outfile, + platform: 'node', + }) + + const bundled = await import(pathToFileURL(outfile).href) as typeof import('../packages/hub/src/node/initiate') + const hub = bundled.initHub({ + auth: false, + base: bundled.DEVFRAMES_HUB_BASE, + mcp: true, + ws: false, + }) + + try { + await hub.ready + expect(hub.connectionMeta().mcp).toEqual({ path: '__mcp' }) + + const origin = 'http://localhost:3000' + const response = await hub.handler(new Request(`${origin}/__devframes/__mcp`, { + method: 'POST', + headers: { + 'accept': 'application/json, text/event-stream', + 'content-type': 'application/json', + origin, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + capabilities: {}, + clientInfo: { name: 'bundle-test', version: '0' }, + protocolVersion: '2025-03-26', + }, + }), + })) + + expect(response.status).toBe(200) + expect(response.headers.get('mcp-session-id')).toBeTruthy() + await response.body?.cancel() + } + finally { + await hub.close() + } + }) + + it('preserves the runtime importer in Next production bundles', () => { + const runtimeImportChunks = readdirSync(nextServerChunks) + .filter(file => file.endsWith('.js')) + .map(file => readFileSync(join(nextServerChunks, file), 'utf8')) + .filter(source => source.includes('packages/devframe/src/node/import-runtime-module.ts')) + + expect(runtimeImportChunks).not.toHaveLength(0) + expect(runtimeImportChunks.join('\n')).not.toContain('Cannot find module as expression is too dynamic') + }) +})