diff --git a/plugin/controller/lib/impl/mcp/MCPControllerRegister.ts b/plugin/controller/lib/impl/mcp/MCPControllerRegister.ts index ea62564ea..2264b9e2d 100644 --- a/plugin/controller/lib/impl/mcp/MCPControllerRegister.ts +++ b/plugin/controller/lib/impl/mcp/MCPControllerRegister.ts @@ -174,9 +174,7 @@ export class MCPControllerRegister implements ControllerRegister { const postRouterFunc = this.router.post; const self = this; let mw = self.app.middleware.teggCtxLifecycleMiddleware(); - if (self.globalMiddlewares) { - mw = compose([ mw, self.globalMiddlewares ]); - } + mw = self.composeGlobalMiddleware(mw); const initHandler = async (ctx: Context) => { // Create fresh transport and server per request // MCP SDK >= 1.26 requires stateless transports to be single-use @@ -277,9 +275,7 @@ export class MCPControllerRegister implements ControllerRegister { const allRouterFunc = this.router.all; const self = this; let mw = self.app.middleware.teggCtxLifecycleMiddleware(); - if (self.globalMiddlewares) { - mw = compose([ mw, self.globalMiddlewares ]); - } + mw = self.composeGlobalMiddleware(mw); const initHandler = async (ctx: Context) => { ctx.respond = false; if (MCPControllerRegister.hooks.length > 0) { @@ -650,9 +646,7 @@ export class MCPControllerRegister implements ControllerRegister { sseCtxStorageRun(ctx: Context, transport: SSEServerTransport, name?: string) { const self = this; let mw = this.app.middleware.teggCtxLifecycleMiddleware(); - if (self.globalMiddlewares) { - mw = compose([ mw, self.globalMiddlewares ]); - } + mw = self.composeGlobalMiddleware(mw); const closeFunc = transport.onclose; transport.onclose = (...args) => { closeFunc?.(...args); @@ -734,9 +728,7 @@ export class MCPControllerRegister implements ControllerRegister { // } let mw = self.app.middleware.teggCtxLifecycleMiddleware(); - if (self.globalMiddlewares) { - mw = compose([ mw, self.globalMiddlewares ]); - } + mw = self.composeGlobalMiddleware(mw); const messageHander = async (ctx: Context) => { const sessionId = ctx.query.sessionId; @@ -808,6 +800,15 @@ export class MCPControllerRegister implements ControllerRegister { } getGlobalMiddleware() { + // Build once. Resolving the named middleware factories from `app.middlewares` + // must happen after `loadMiddleware` has populated them, so this is invoked + // lazily on the first request (see `composeGlobalMiddleware`) rather than at + // controller registration time (which runs during the tegg load-unit init, + // before `loadMiddleware`, where `app.middlewares[name]` is not yet ready and + // would throw `Middleware xxx not found`). + if (this.globalMiddlewares) { + return; + } const middlewareNames = this.app.config.mcp.middleware || []; const middlewares: compose.Middleware[] = []; for (const name of middlewareNames) { @@ -823,6 +824,18 @@ export class MCPControllerRegister implements ControllerRegister { this.globalMiddlewares = compose(middlewares); } + // Wrap a base middleware so the configured global middlewares are resolved and + // composed on first request (when `app.middlewares` is ready), not at register + // time. Returns a middleware that is safe to install during registration. + composeGlobalMiddleware(mw: compose.Middleware): compose.Middleware { + const self = this; + return async (ctx, next) => { + self.getGlobalMiddleware(); + const composed = self.globalMiddlewares ? compose([ mw, self.globalMiddlewares ]) : mw; + return composed(ctx, next); + }; + } + mcpServerPing(server: Server, sessionId: string, name?: string) { const duration = this.mcpConfig.getPingElapsed(name); const interval = this.mcpConfig.getPingInterval(name); @@ -866,7 +879,6 @@ export class MCPControllerRegister implements ControllerRegister { CONTROLLER_META_DATA, ) as MCPControllerMeta; if (!this.mcpServerHelperMap[metadata.name ?? 'default']) { - this.getGlobalMiddleware(); this.mcpServerHelperMap[metadata.name ?? 'default'] = () => { return new MCPServerHelper({ name: diff --git a/plugin/controller/test/mcp/mcpGlobalMiddleware.test.ts b/plugin/controller/test/mcp/mcpGlobalMiddleware.test.ts new file mode 100644 index 000000000..1c02c3aea --- /dev/null +++ b/plugin/controller/test/mcp/mcpGlobalMiddleware.test.ts @@ -0,0 +1,96 @@ +import assert from 'assert'; +import compose from 'koa-compose'; +import { MCPControllerRegister } from '../../lib/impl/mcp/MCPControllerRegister'; + +// Unit tests for the lazy global-middleware resolution. +// +// MCPControllerRegister.register() runs during the tegg load-unit init +// (postCreate), which happens before egg's `loadMiddleware` populates +// `app.middlewares`. The global middleware named in `config.mcp.middleware` +// must therefore be resolved lazily — on the first request — rather than at +// registration time, otherwise booting an app that configures `mcp.middleware` +// throws `Middleware not found`. +function createRegister(mcp: any, middlewares: any) { + const app: any = { + eggContainerFactory: {}, + router: {}, + config: { mcp }, + middlewares, + }; + const register = new (MCPControllerRegister as any)({}, {}, app); + return { register, app }; +} + +describe('plugin/controller/test/mcp/mcpGlobalMiddleware.test.ts', () => { + it('does not read app.middlewares at registration time', () => { + // app.middlewares is still empty here, mirroring registration time. + const { register } = createRegister({ middleware: [ 'trace' ] }, {}); + const base: any = async (_ctx: any, next: any) => next(); + + // Wrapping the base middleware must not throw even though `trace` is not + // yet available; resolution is deferred to the first request. + assert.doesNotThrow(() => register.composeGlobalMiddleware(base)); + assert.strictEqual(register.globalMiddlewares, undefined); + }); + + it('resolves and runs the configured global middleware on first request', async () => { + const { register, app } = createRegister({ middleware: [ 'trace' ] }, {}); + const order: string[] = []; + const base: any = async (_ctx: any, next: any) => { + order.push('base'); + return next(); + }; + const wrapped = register.composeGlobalMiddleware(base); + + // app.middlewares is populated later by loadMiddleware. + app.middlewares.trace = () => async (_ctx: any, next: any) => { + order.push('trace'); + return next(); + }; + + await wrapped({} as any, async () => { + order.push('handler'); + }); + + assert.deepStrictEqual(order, [ 'base', 'trace', 'handler' ]); + assert.ok(register.globalMiddlewares, 'built and cached after first request'); + }); + + it('getGlobalMiddleware is idempotent (builds once)', () => { + const { register } = createRegister({ middleware: [] }, {}); + register.getGlobalMiddleware(); + const first = register.globalMiddlewares; + register.getGlobalMiddleware(); + assert.strictEqual(register.globalMiddlewares, first); + }); + + it('still surfaces a genuinely missing middleware at resolution time', () => { + // The fix defers *when* middlewares are resolved; it must not hide a real + // misconfiguration — an unknown name still throws once resolved. + const { register } = createRegister({ middleware: [ 'nope' ] }, {}); + assert.throws(() => register.getGlobalMiddleware(), /Middleware nope not found/); + }); + + it('keeps koa-compose ordering for multiple global middlewares', async () => { + const { register, app } = createRegister({ middleware: [ 'a', 'b' ] }, {}); + const order: string[] = []; + const make = (name: string) => () => async (_ctx: any, next: any) => { + order.push(`${name}:before`); + await next(); + order.push(`${name}:after`); + }; + app.middlewares.a = make('a'); + app.middlewares.b = make('b'); + const base: any = async (_ctx: any, next: any) => { + order.push('base'); + return next(); + }; + const wrapped = register.composeGlobalMiddleware(base); + await wrapped({} as any, async () => order.push('handler')); + assert.deepStrictEqual(order, [ + 'base', 'a:before', 'b:before', 'handler', 'b:after', 'a:after', + ]); + // compose is imported to assert the helper relies on koa-compose semantics. + assert.strictEqual(typeof compose, 'function'); + }); +});