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()
+ })
+})