From 916ae003ad5dd65a6eefcdebe5b167309bc992ae Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 3 Sep 2026 16:30:28 +0100 Subject: [PATCH] feat(nitro): install extension plugins from server/mcp/plugins.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An h3-mcp `ExtensionPlugin` is a live function, and `mcp()` options cross into generated code as JSON, so a plugin could not be passed as an option. A file beside `tools/`, `resources/` and `prompts/` can be: the generated handler imports its default export and hands it to `createMcpHandler` as `extensionPlugins`. `ExtensionPlugin` is now re-exported so the file can name the type it satisfies. Like a definition, the file belongs to whichever `mcp()` scans its directory. `discoverPlugins` returns every match rather than picking one, so the module can refuse an ambiguous pair while naming the route it belongs to. The handler virtual is async for the same reason the registry is — a file written after `setup` has to be seen at rebuild — and the watcher treats it like a definition, since nothing imports it until the handler is generated again. --- .changeset/nitro-plugins-convention.md | 13 ++ AGENTS.md | 2 + apps/nitro-playground/server/mcp/plugins.ts | 14 ++ packages/nitro-mcp-toolkit/README.md | 16 ++ .../nitro-mcp-toolkit/src/module/discover.ts | 27 +++ .../nitro-mcp-toolkit/src/module/index.ts | 28 ++- .../nitro-mcp-toolkit/src/module/report.ts | 16 +- .../nitro-mcp-toolkit/src/module/template.ts | 25 ++- .../nitro-mcp-toolkit/src/module/watch.ts | 29 +-- .../nitro-mcp-toolkit/src/runtime/index.ts | 1 + .../nitro-mcp-toolkit/test/e2e-module.test.ts | 12 ++ .../discovery/server/mcp-admin/plugins.ts | 10 + .../nitro-mcp-toolkit/test/module.test.ts | 23 ++- .../nitro-mcp-toolkit/test/plugins.test.ts | 192 ++++++++++++++++++ packages/nitro-mcp-toolkit/test/types.test.ts | 15 +- 15 files changed, 394 insertions(+), 29 deletions(-) create mode 100644 .changeset/nitro-plugins-convention.md create mode 100644 apps/nitro-playground/server/mcp/plugins.ts create mode 100644 packages/nitro-mcp-toolkit/test/fixtures/discovery/server/mcp-admin/plugins.ts create mode 100644 packages/nitro-mcp-toolkit/test/plugins.test.ts 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() + }) +})