Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/nitro-plugins-convention.md
Original file line number Diff line number Diff line change
@@ -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[]
```
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/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
Expand Down
14 changes: 14 additions & 0 deletions apps/nitro-playground/server/mcp/plugins.ts
Original file line number Diff line number Diff line change
@@ -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[]
16 changes: 16 additions & 0 deletions packages/nitro-mcp-toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions packages/nitro-mcp-toolkit/src/module/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -52,6 +55,30 @@ export async function discoverDefinitions(dir: string): Promise<DiscoveredDefini
return perDir.flat()
}

/**
* The optional plugins file beside the three directories. Its default export is
* installed as the endpoint's `extensionPlugins`, which is how a plugin reaches
* a `mcp()` server: the module carries only this path, never the plugins.
*
* Sorted, and every match is returned β€” two of them is a configuration error,
* reported where it can name the route rather than swallowed here.
*/
export async function discoverPlugins(dir: string): Promise<string[]> {
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)
Expand Down
28 changes: 24 additions & 4 deletions packages/nitro-mcp-toolkit/src/module/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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({
Expand Down
16 changes: 12 additions & 4 deletions packages/nitro-mcp-toolkit/src/module/report.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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('|')

Expand All @@ -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) {
Expand Down
25 changes: 20 additions & 5 deletions packages/nitro-mcp-toolkit/src/module/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})
`
}

Expand Down
29 changes: 17 additions & 12 deletions packages/nitro-mcp-toolkit/src/module/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)$/
Expand All @@ -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)

Expand All @@ -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<string> {
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('|')
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/nitro-mcp-toolkit/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type {
CallToolResult,
ContentBlock,
Era,
ExtensionPlugin,
GetPromptResult,
Icon,
InputRequiredResult,
Expand Down
12 changes: 12 additions & 0 deletions packages/nitro-mcp-toolkit/test/e2e-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
@@ -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[]
23 changes: 20 additions & 3 deletions packages/nitro-mcp-toolkit/test/module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<fixture>/server/mcp-admin/plugins.ts"
import { prompts, resources, tools } from "#mcp/admin-mcp/registry"

export default createMcpHandler({
Expand All @@ -140,7 +141,7 @@ describe('the mcp() module', () => {
tools,
resources,
prompts,
})
}, { extensionPlugins: plugins })
"
`)
})
Expand Down Expand Up @@ -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 "<fixture>/server/mcp-admin/plugins.ts"
import { prompts, resources, tools } from "#mcp/oauth-mcp/registry"

export default createMcpHandler({
Expand All @@ -262,7 +264,7 @@ describe('the mcp() module', () => {
tools,
resources,
prompts,
})
}, { extensionPlugins: plugins })
"
`)

Expand Down Expand Up @@ -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",
]
`)

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading