diff --git a/.changeset/nitro-plugins-convention.md b/.changeset/nitro-plugins-convention.md new file mode 100644 index 0000000..5d6b64b --- /dev/null +++ b/.changeset/nitro-plugins-convention.md @@ -0,0 +1,13 @@ +--- +'nitro-mcp-toolkit': minor +--- + +`server/mcp/plugins.ts`, beside `tools/`, `resources/` and `prompts/`, installs h3-mcp extension plugins on that endpoint. Its default export is the array, and `ExtensionPlugin` is now re-exported so the file can name the type it satisfies. A plugin is a live function, so this is how one reaches a generated handler — `mcp()` options cross into generated code as JSON. The file belongs to whichever `mcp()` scans its directory, `.js` / `.mts` / `.mjs` work too, creating it in development needs no restart, and each build names the file it installed. + +```ts +// server/mcp/plugins.ts +import { mcpTasks } from 'h3-mcp/tasks' +import type { ExtensionPlugin } from 'nitro-mcp-toolkit' + +export default [mcpTasks({ max: 100 })] satisfies ExtensionPlugin[] +``` diff --git a/AGENTS.md b/AGENTS.md index dd70adf..3e57b44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,8 @@ The runtime is written against a pinned `h3-mcp` (currently 0.2.0). That release **`X-MCP-Tools` is the same kind of gate** (`src/runtime/tools-header.ts`): allowlist of tool names, HTTP 400 on unknowns, applied before the engine runs. `handler.definitions` stays the full catalog. +**Plugins reach a `mcp()` server through `/plugins.ts`, not through an option** — an `ExtensionPlugin` is a live function and `mcp()` options cross into generated code as JSON. `discoverPlugins` returns *every* match so the caller can refuse an ambiguous pair; `onePluginsFile` in `src/module/index.ts` does that where it can name the route, which also keeps the watcher's `served()` from dying on a misconfiguration. The handler virtual is `async` for the same reason the registry is: a file written after `setup` must be seen at rebuild. Adding the convention means four places, not one — `discover.ts`, `template.ts` (`renderHandler`'s second argument), `watch.ts` (`couldChangeServed` plus `served`, or creating the file triggers no `rollup:reload`), and `report.ts`. + **OAuth** is a generic resource-server: `mcp({ oauth: { resource, authorizationServers, jwt } })` or `createMcpOAuth`. JWT verify, claims on `event.context.oauth`, RFC 9728 metadata mounted. Connectors (`nitro-mcp-toolkit/oauth/clerk`, `/okta`, `/workos`) return that same options object — Clerk infers issuer/JWKS from `CLERK_PUBLISHABLE_KEY` and skips `aud` (client is in `azp`). The package does not mint tokens. Opaque tokens still mean `createMcpOAuth({ verify })` in a route file. ### MCP Definitions diff --git a/apps/nitro-playground/server/mcp/plugins.ts b/apps/nitro-playground/server/mcp/plugins.ts new file mode 100644 index 0000000..d5622b3 --- /dev/null +++ b/apps/nitro-playground/server/mcp/plugins.ts @@ -0,0 +1,14 @@ +import type { ExtensionPlugin } from 'nitro-mcp-toolkit' + +/** + * Exercises the `plugins.ts` convention. A real app installs the engine's own + * kit here (`mcpTasks()` from `h3-mcp/tasks`); this one advertises a settings + * object instead, so the inspector shows it under `capabilities.extensions` on + * `initialize` without the playground taking a second dependency. + */ +export default [ + { + id: 'playground/stamp', + settings: () => ({ servedBy: 'nitro-mcp-playground' }), + }, +] satisfies ExtensionPlugin[] diff --git a/packages/nitro-mcp-toolkit/README.md b/packages/nitro-mcp-toolkit/README.md index 28324cd..9946ce3 100644 --- a/packages/nitro-mcp-toolkit/README.md +++ b/packages/nitro-mcp-toolkit/README.md @@ -79,6 +79,22 @@ export default defineMcpTool({ Both are advertised in the definition's `_meta`, so a client sees them in `tools/list` and can sort or filter on them. The group defaults to the subdirectory the file sits in, which is why most files only ever set `tags`. +### Plugins + +`server/mcp/plugins.ts`, beside the three directories, installs [h3-mcp](https://github.com/h3js/h3-mcp) extension plugins on that endpoint. Its default export is the array: + +```ts +// server/mcp/plugins.ts +import { mcpTasks } from 'h3-mcp/tasks' +import type { ExtensionPlugin } from 'nitro-mcp-toolkit' + +export default [mcpTasks({ max: 100 })] satisfies ExtensionPlugin[] +``` + +A plugin is a live function, so it cannot be an `mcp()` option — those cross into generated code and are data only. The file is how one reaches a generated handler. + +Like a definition, it belongs to whichever `mcp()` scans its directory: two servers get two plugin sets, and a server whose `dir` holds no such file installs none. `.js`, `.mts` and `.mjs` work too, one file per directory, and creating it in development is picked up without a restart. Every build names the file it installed alongside the counts it reports. + ### Options ```ts diff --git a/packages/nitro-mcp-toolkit/src/module/discover.ts b/packages/nitro-mcp-toolkit/src/module/discover.ts index ceed321..ae98488 100644 --- a/packages/nitro-mcp-toolkit/src/module/discover.ts +++ b/packages/nitro-mcp-toolkit/src/module/discover.ts @@ -12,6 +12,9 @@ export type DefinitionDir = (typeof DEFINITION_DIRS)[number] const PATTERN = '**/*.{ts,js,mts,mjs}' +const PLUGINS_PATTERN = 'plugins.{ts,js,mts,mjs}' +const PLUGINS_FILE_RE = /^plugins\.(?:ts|js|mts|mjs)$/ + /** A definition file, and everything its path says about the definition. */ export interface DiscoveredDefinition { /** Which of the three directories it was found in. */ @@ -52,6 +55,30 @@ export async function discoverDefinitions(dir: string): Promise { + const paths = await glob(PLUGINS_PATTERN, { + cwd: dir, + absolute: true, + onlyFiles: true, + expandDirectories: false, + }) + + return paths.sort((a, b) => a.localeCompare(b)) +} + +/** Whether `path` is the plugins file of the directory scanned at `dir`. */ +export function isPluginsFile(dir: string, path: string): boolean { + return dirname(path) === dir && PLUGINS_FILE_RE.test(basename(path)) +} + function describe(dir: DefinitionDir, root: string, path: string): DiscoveredDefinition { const inDir = relative(root, path) const group = dirname(inDir) diff --git a/packages/nitro-mcp-toolkit/src/module/index.ts b/packages/nitro-mcp-toolkit/src/module/index.ts index 6a28e74..6991e18 100644 --- a/packages/nitro-mcp-toolkit/src/module/index.ts +++ b/packages/nitro-mcp-toolkit/src/module/index.ts @@ -1,5 +1,5 @@ -import { resolve } from 'pathe' -import { discoverDefinitions } from './discover.ts' +import { basename, resolve } from 'pathe' +import { discoverDefinitions, discoverPlugins } from './discover.ts' import { resolveModuleOptions } from './options.ts' import { reportDefinitions } from './report.ts' import { registerServer, slugify } from './servers.ts' @@ -44,6 +44,18 @@ export type { */ const AS_METADATA = '/.well-known/oauth-authorization-server' +/** Which plugins file the handler installs, when the convention is unambiguous. */ +function onePluginsFile(route: string, paths: string[]): string | undefined { + if (paths.length > 1) { + throw new Error( + `[nitro-mcp-toolkit] ${route} has more than one plugins file ` + + `(${paths.map((path) => basename(path)).join(', ')}). Keep one.`, + ) + } + + return paths[0] +} + export default function mcp(options: McpModuleOptions = {}): NitroModule { const { route, dir, server, oauth } = resolveModuleOptions(options) const slug = slugify(route) @@ -68,8 +80,16 @@ export default function mcp(options: McpModuleOptions = {}): NitroModule { nitro.options.virtual[registryId] = async () => renderRegistry(await discoverDefinitions(definitionsDir)) - nitro.options.virtual[handlerId] = () => - renderHandler(registryId, server, oauth ? oauthId : undefined) + // Async like the registry: a plugins file written after setup is picked + // up by the rebuild the watcher triggers, rather than needing a restart. + nitro.options.virtual[handlerId] = async () => { + const pluginsPath = onePluginsFile(route, await discoverPlugins(definitionsDir)) + + return renderHandler(registryId, server, { + ...(oauth ? { oauthId } : {}), + ...(pluginsPath ? { pluginsPath } : {}), + }) + } registerServer(nitro, { route, slug, handlerId }) nitro.options.handlers.push({ diff --git a/packages/nitro-mcp-toolkit/src/module/report.ts b/packages/nitro-mcp-toolkit/src/module/report.ts index b7a3ed0..f5edc3e 100644 --- a/packages/nitro-mcp-toolkit/src/module/report.ts +++ b/packages/nitro-mcp-toolkit/src/module/report.ts @@ -1,6 +1,6 @@ -import { relative } from 'pathe' +import { basename, relative } from 'pathe' import { glob } from 'tinyglobby' -import { DEFINITION_DIRS, discoverDefinitions } from './discover.ts' +import { DEFINITION_DIRS, discoverDefinitions, discoverPlugins } from './discover.ts' import type { DiscoveredDefinition } from './discover.ts' import type { Nitro } from 'nitro/types' @@ -52,9 +52,14 @@ export function reportDefinitions(nitro: Nitro, route: string, dir: string): voi let reported: string | undefined nitro.hooks.hook('compiled', async () => { - const [definitions, misnamed] = await Promise.all([discoverDefinitions(dir), nearMisses(dir)]) + const [definitions, plugins, misnamed] = await Promise.all([ + discoverDefinitions(dir), + discoverPlugins(dir), + nearMisses(dir), + ]) const current = [ ...definitions.map((definition) => definition.file), + ...plugins.map((path) => basename(path)), ...misnamed.map((directory) => directory.found), ].join('|') @@ -70,7 +75,10 @@ export function reportDefinitions(nitro: Nitro, route: string, dir: string): voi 'The route is still mounted, and serves a server with nothing on it.', ) } else { - nitro.logger.info(`[mcp] ${route} serves ${counted(definitions)} from ${where}`) + const [pluginsFile] = plugins + const installed = pluginsFile ? `, with ${basename(pluginsFile)}` : '' + + nitro.logger.info(`[mcp] ${route} serves ${counted(definitions)} from ${where}${installed}`) } for (const { found, expected } of misnamed) { diff --git a/packages/nitro-mcp-toolkit/src/module/template.ts b/packages/nitro-mcp-toolkit/src/module/template.ts index 0571b08..caa1dbf 100644 --- a/packages/nitro-mcp-toolkit/src/module/template.ts +++ b/packages/nitro-mcp-toolkit/src/module/template.ts @@ -89,27 +89,42 @@ export default oauth.authorizationServerHandler ` } +/** What the handler wires in beyond the registry, when the app asked for it. */ +export interface HandlerWiring { + /** The `createMcpOAuth` instance to take `auth` from. */ + oauthId?: string + /** Absolute path of the plugins file, whose default export is installed. */ + pluginsPath?: string +} + /** The route handler: the discovered registry, served on the module's route. */ export function renderHandler( registryId: string, server: McpServerOptions, - oauthId?: string, + wiring: HandlerWiring = {}, ): string { + const { oauthId, pluginsPath } = wiring const options = Object.entries(server) .filter(([, value]) => value !== undefined) .map(([key, value]) => line(key, value)) if (oauthId) options.push(' auth: oauth.auth,') - const oauthImport = oauthId ? `import { oauth } from ${JSON.stringify(oauthId)}\n` : '' + const imports = [ + `import { createMcpHandler } from 'nitro-mcp-toolkit'`, + ...(oauthId ? [`import { oauth } from ${JSON.stringify(oauthId)}`] : []), + ...(pluginsPath ? [`import plugins from ${JSON.stringify(pluginsPath)}`] : []), + `import { prompts, resources, tools } from ${JSON.stringify(registryId)}`, + ] + + const setup = pluginsPath ? ', { extensionPlugins: plugins }' : '' return `${BANNER} -import { createMcpHandler } from 'nitro-mcp-toolkit' -${oauthImport}import { prompts, resources, tools } from ${JSON.stringify(registryId)} +${imports.join('\n')} export default createMcpHandler({ ${[...options, ' tools,', ' resources,', ' prompts,'].join('\n')} -}) +}${setup}) ` } diff --git a/packages/nitro-mcp-toolkit/src/module/watch.ts b/packages/nitro-mcp-toolkit/src/module/watch.ts index 4de80b2..27f8e06 100644 --- a/packages/nitro-mcp-toolkit/src/module/watch.ts +++ b/packages/nitro-mcp-toolkit/src/module/watch.ts @@ -6,8 +6,8 @@ import { watch } from 'node:fs/promises' // the whole dev server down. Everything else stays on `pathe`, since the paths // we compare against come from a glob. import { normalize as nativePath } from 'node:path' -import { dirname, extname, join, resolve, sep } from 'pathe' -import { DEFINITION_DIRS, discoverDefinitions } from './discover.ts' +import { basename, dirname, extname, join, resolve, sep } from 'pathe' +import { DEFINITION_DIRS, discoverDefinitions, discoverPlugins, isPluginsFile } from './discover.ts' import type { Nitro } from 'nitro/types' const DEFINITION_FILE_RE = /\.(?:ts|js|mts|mjs)$/ @@ -31,15 +31,17 @@ function watchableRoot(dir: string): string { } /** - * Whether a path could change what is served: a definition file in one of the - * three directories, or a directory on the way to one of them. A directory - * counts because it can arrive with its files already inside — a moved folder - * reports only itself, and on Linux a recursive watch attaches to a new - * subdirectory too late to report what was written into it. + * Whether a path could change what is served: the plugins file, a definition + * file in one of the three directories, or a directory on the way to one of + * them. A directory counts because it can arrive with its files already inside + * — a moved folder reports only itself, and on Linux a recursive watch attaches + * to a new subdirectory too late to report what was written into it. */ -function couldHoldDefinitions(dir: string, path: string): boolean { +function couldChangeServed(dir: string, path: string): boolean { if (extname(path) && !DEFINITION_FILE_RE.test(path)) return false + if (isPluginsFile(dir, path)) return true + return DEFINITION_DIRS.some((definitionDir) => { const scanned = join(dir, definitionDir) @@ -49,11 +51,14 @@ function couldHoldDefinitions(dir: string, path: string): boolean { }) } -/** What the registry would import, as one string to compare against. */ +/** What the generated modules would import, as one string to compare against. */ async function served(dir: string): Promise { - const definitions = await discoverDefinitions(dir) + const [definitions, plugins] = await Promise.all([discoverDefinitions(dir), discoverPlugins(dir)]) - return definitions.map((definition) => definition.file).join('|') + return [ + ...definitions.map((definition) => definition.file), + ...plugins.map((path) => basename(path)), + ].join('|') } /** @@ -113,7 +118,7 @@ export function watchDefinitions(nitro: Nitro, dir: string): void { continue } - if (couldHoldDefinitions(dir, resolve(root, filename))) await reloadIfChanged() + if (couldChangeServed(dir, resolve(root, filename))) await reloadIfChanged() } } } catch (error) { diff --git a/packages/nitro-mcp-toolkit/src/runtime/index.ts b/packages/nitro-mcp-toolkit/src/runtime/index.ts index 4fe515c..0f8f277 100644 --- a/packages/nitro-mcp-toolkit/src/runtime/index.ts +++ b/packages/nitro-mcp-toolkit/src/runtime/index.ts @@ -65,6 +65,7 @@ export type { CallToolResult, ContentBlock, Era, + ExtensionPlugin, GetPromptResult, Icon, InputRequiredResult, diff --git a/packages/nitro-mcp-toolkit/test/e2e-module.test.ts b/packages/nitro-mcp-toolkit/test/e2e-module.test.ts index 71762e2..c0f8ec6 100644 --- a/packages/nitro-mcp-toolkit/test/e2e-module.test.ts +++ b/packages/nitro-mcp-toolkit/test/e2e-module.test.ts @@ -89,4 +89,16 @@ describe('a built Nitro app using the module', () => { expect(adminClient.getServerVersion()?.name).toBe('admin-fixture') await expect(adminClient.callTool({ name: 'greet-visitor' })).rejects.toThrow(/Tool not found/) }) + + // The admin directory holds a plugins.ts; the endpoint advertising its + // extension is what proves the built app installed it, not merely imported it. + it('installs the plugins file of the directory it scanned', () => { + expect(adminClient.getServerCapabilities()?.extensions).toEqual({ + 'fixture/stamp': { stamped: true }, + }) + }) + + it('leaves a server without a plugins file advertising no extension', () => { + expect(client.getServerCapabilities()?.extensions).toBeUndefined() + }) }) diff --git a/packages/nitro-mcp-toolkit/test/fixtures/discovery/server/mcp-admin/plugins.ts b/packages/nitro-mcp-toolkit/test/fixtures/discovery/server/mcp-admin/plugins.ts new file mode 100644 index 0000000..2bd9648 --- /dev/null +++ b/packages/nitro-mcp-toolkit/test/fixtures/discovery/server/mcp-admin/plugins.ts @@ -0,0 +1,10 @@ +import type { ExtensionPlugin } from 'nitro-mcp-toolkit' + +// Advertised under `capabilities.extensions`, which is what lets the e2e run +// prove the built app installed the plugin rather than only importing it. +export default [ + { + id: 'fixture/stamp', + settings: () => ({ stamped: true }), + }, +] satisfies ExtensionPlugin[] diff --git a/packages/nitro-mcp-toolkit/test/module.test.ts b/packages/nitro-mcp-toolkit/test/module.test.ts index 99ec033..520def8 100644 --- a/packages/nitro-mcp-toolkit/test/module.test.ts +++ b/packages/nitro-mcp-toolkit/test/module.test.ts @@ -132,6 +132,7 @@ describe('the mcp() module', () => { await expect(render(nitro, '#mcp/admin-mcp/handler')).resolves.toMatchInlineSnapshot(` "// Generated by nitro-mcp-toolkit. Edit the files it lists instead. import { createMcpHandler } from 'nitro-mcp-toolkit' + import plugins from "/server/mcp-admin/plugins.ts" import { prompts, resources, tools } from "#mcp/admin-mcp/registry" export default createMcpHandler({ @@ -140,7 +141,7 @@ describe('the mcp() module', () => { tools, resources, prompts, - }) + }, { extensionPlugins: plugins }) " `) }) @@ -254,6 +255,7 @@ describe('the mcp() module', () => { "// Generated by nitro-mcp-toolkit. Edit the files it lists instead. import { createMcpHandler } from 'nitro-mcp-toolkit' import { oauth } from "#mcp/oauth-mcp/oauth" + import plugins from "/server/mcp-admin/plugins.ts" import { prompts, resources, tools } from "#mcp/oauth-mcp/registry" export default createMcpHandler({ @@ -262,7 +264,7 @@ describe('the mcp() module', () => { tools, resources, prompts, - }) + }, { extensionPlugins: plugins }) " `) @@ -382,7 +384,7 @@ describe('the report of what each endpoint serves', () => { expect(info()).toMatchInlineSnapshot(` [ "[mcp] /mcp serves 3 tools, 1 resource, 1 prompt from server/mcp", - "[mcp] /admin/mcp serves 1 tool from server/mcp-admin", + "[mcp] /admin/mcp serves 1 tool from server/mcp-admin, with plugins.ts", ] `) @@ -510,6 +512,21 @@ describe('watching for definitions in development', () => { await rm(late, { recursive: true, force: true }) }) + // Same reason as a definition file: nothing imports it until the handler is + // generated again, so only a rebuild can install it. + it('rebuilds when the plugins file appears, and again when it goes', async () => { + const file = join(root, 'server/mcp/plugins.ts') + const before = reloads + + await writeFile(file, 'export default []\n') + await vi.waitFor(() => expect(reloads).toBeGreaterThan(before), { timeout: 10_000 }) + + const afterAdd = reloads + + await rm(file) + await vi.waitFor(() => expect(reloads).toBeGreaterThan(afterAdd), { timeout: 10_000 }) + }) + it('ignores files that cannot hold a definition', async () => { const before = reloads diff --git a/packages/nitro-mcp-toolkit/test/plugins.test.ts b/packages/nitro-mcp-toolkit/test/plugins.test.ts new file mode 100644 index 0000000..11179cb --- /dev/null +++ b/packages/nitro-mcp-toolkit/test/plugins.test.ts @@ -0,0 +1,192 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createNitro } from 'nitro/builder' +import { basename } from 'pathe' +import { describe, expect, it } from 'vitest' +import { discoverPlugins, isPluginsFile } from '../src/module/discover.ts' +import mcp from '../src/module/index.ts' +import { renderHandler } from '../src/module/template.ts' + +async function appWith(files: Record): Promise { + const root = await mkdtemp(join(tmpdir(), 'nitro-mcp-plugins-')) + + await mkdir(join(root, 'server/mcp/tools'), { recursive: true }) + + for (const [file, contents] of Object.entries(files)) { + await writeFile(join(root, file), contents) + } + + return root +} + +const A_PLUGIN = `export default [{ id: 'fixture/one' }]\n` + +describe('discovering the plugins file', () => { + it('finds it under any extension the convention allows', async () => { + for (const extension of ['ts', 'js', 'mts', 'mjs']) { + const root = await appWith({ [`server/mcp/plugins.${extension}`]: A_PLUGIN }) + + const found = await discoverPlugins(join(root, 'server/mcp')) + + expect(found.map((path) => basename(path))).toEqual([`plugins.${extension}`]) + + await rm(root, { recursive: true, force: true }) + } + }) + + it('finds nothing when the app declared none', async () => { + const root = await appWith({}) + + await expect(discoverPlugins(join(root, 'server/mcp'))).resolves.toEqual([]) + + await rm(root, { recursive: true, force: true }) + }) + + // A definition directory holds definitions; the plugins file sits beside them. + it('ignores a plugins file inside one of the three directories', async () => { + const root = await appWith({ 'server/mcp/tools/plugins.ts': A_PLUGIN }) + + await expect(discoverPlugins(join(root, 'server/mcp'))).resolves.toEqual([]) + + await rm(root, { recursive: true, force: true }) + }) + + it('reports every match, so the caller can refuse an ambiguous pair', async () => { + const root = await appWith({ + 'server/mcp/plugins.ts': A_PLUGIN, + 'server/mcp/plugins.mjs': A_PLUGIN, + }) + + const found = await discoverPlugins(join(root, 'server/mcp')) + + expect(found.map((path) => basename(path))).toEqual(['plugins.mjs', 'plugins.ts']) + + await rm(root, { recursive: true, force: true }) + }) + + it('recognizes the file only at the root of the scanned directory', () => { + expect(isPluginsFile('/app/server/mcp', '/app/server/mcp/plugins.ts')).toBe(true) + expect(isPluginsFile('/app/server/mcp', '/app/server/mcp/plugins.mjs')).toBe(true) + expect(isPluginsFile('/app/server/mcp', '/app/server/mcp/plugins.json')).toBe(false) + expect(isPluginsFile('/app/server/mcp', '/app/server/mcp/tools/plugins.ts')).toBe(false) + expect(isPluginsFile('/app/server/mcp', '/app/server/mcp/setup.ts')).toBe(false) + }) +}) + +describe('the generated handler', () => { + it('installs the plugins file as extensionPlugins', () => { + const code = renderHandler( + '#mcp/mcp/registry', + { name: 'app' }, + { + pluginsPath: '/app/server/mcp/plugins.ts', + }, + ) + + expect(code).toMatchInlineSnapshot(` + "// Generated by nitro-mcp-toolkit. Edit the files it lists instead. + import { createMcpHandler } from 'nitro-mcp-toolkit' + import plugins from "/app/server/mcp/plugins.ts" + import { prompts, resources, tools } from "#mcp/mcp/registry" + + export default createMcpHandler({ + name: "app", + tools, + resources, + prompts, + }, { extensionPlugins: plugins }) + " + `) + }) + + it('takes no second argument when the app declared no plugins', () => { + const code = renderHandler('#mcp/mcp/registry', { name: 'app' }) + + expect(code).not.toContain('extensionPlugins') + expect(code).toContain('})\n') + }) + + it('installs them alongside oauth', () => { + const code = renderHandler( + '#mcp/mcp/registry', + {}, + { + oauthId: '#mcp/mcp/oauth', + pluginsPath: '/app/server/mcp/plugins.ts', + }, + ) + + expect(code).toContain(`import { oauth } from "#mcp/mcp/oauth"`) + expect(code).toContain('auth: oauth.auth,') + expect(code).toContain('}, { extensionPlugins: plugins })') + }) +}) + +describe('the mcp() module with a plugins file', () => { + it('picks it up from the scanned directory', async () => { + const root = await appWith({ 'server/mcp/plugins.ts': A_PLUGIN }) + const nitro = await createNitro({ + rootDir: root, + dev: false, + preset: 'standard', + modules: [mcp()], + }) + + const template = nitro.options.virtual['#mcp/mcp/handler'] + const code = typeof template === 'function' ? await template() : '' + + expect(code).toContain('plugins.ts"') + expect(code).toContain('}, { extensionPlugins: plugins })') + + await nitro.close() + await rm(root, { recursive: true, force: true }) + }) + + // Generated lazily, so a file written after `setup` is seen on the rebuild + // the watcher asks for rather than needing a restart. + it('sees a plugins file written after setup ran', async () => { + const root = await appWith({}) + const nitro = await createNitro({ + rootDir: root, + dev: false, + preset: 'standard', + modules: [mcp()], + }) + + const template = nitro.options.virtual['#mcp/mcp/handler'] + const render = async (): Promise => + typeof template === 'function' ? await template() : '' + + expect(await render()).not.toContain('extensionPlugins') + + await writeFile(join(root, 'server/mcp/plugins.ts'), A_PLUGIN) + + expect(await render()).toContain('}, { extensionPlugins: plugins })') + + await nitro.close() + await rm(root, { recursive: true, force: true }) + }) + + it('refuses two plugins files on one route', async () => { + const root = await appWith({ + 'server/mcp/plugins.ts': A_PLUGIN, + 'server/mcp/plugins.mjs': A_PLUGIN, + }) + const nitro = await createNitro({ + rootDir: root, + dev: false, + preset: 'standard', + modules: [mcp()], + }) + + const template = nitro.options.virtual['#mcp/mcp/handler'] + + await expect(typeof template === 'function' ? template() : undefined).rejects.toThrow( + /\/mcp has more than one plugins file \(plugins\.mjs, plugins\.ts\)/, + ) + + await nitro.close() + await rm(root, { recursive: true, force: true }) + }) +}) diff --git a/packages/nitro-mcp-toolkit/test/types.test.ts b/packages/nitro-mcp-toolkit/test/types.test.ts index de76e65..f8620ce 100644 --- a/packages/nitro-mcp-toolkit/test/types.test.ts +++ b/packages/nitro-mcp-toolkit/test/types.test.ts @@ -5,7 +5,7 @@ import type { CallToolResult } from '../src/runtime/index.ts' // Type-only: the module exists once a build generates it, never here. import type generated from '#mcp/admin-mcp/handler' import type { mcp } from 'nitro-mcp-toolkit/servers' -import type { McpEvent, McpHandler, McpToolReturn } from '../src/runtime/index.ts' +import type { ExtensionPlugin, McpEvent, McpHandler, McpToolReturn } from '../src/runtime/index.ts' const output = z.object({ bmi: z.number() }) @@ -62,3 +62,16 @@ describe('the generated handler modules', () => { expectTypeOf().toEqualTypeOf() }) }) + +// The convention is only usable if an app can name the type its plugins file +// must satisfy, without reaching into h3-mcp itself. +describe('the plugins convention', () => { + it('types the array server/mcp/plugins.ts exports', () => { + expectTypeOf<[{ id: 'acme/stamp'; settings: () => Record }]>().toExtend< + ExtensionPlugin[] + >() + + // The id is the key the extension is advertised under, so it is required. + expectTypeOf<[{ settings: () => Record }]>().not.toExtend() + }) +})