From e9f8990872ebab08ca374b8a840eaa41be270044 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 25 Jun 2026 17:38:16 +0800 Subject: [PATCH 1/2] fix(controller): resolve MCP global middleware lazily on first request MCPControllerRegister.register() runs during the tegg load-unit init (postCreate), and eagerly called getGlobalMiddleware() which looks up the configured middleware names in app.middlewares. But app.middlewares is only populated later by loadMiddleware, so registering a controller whose config.mcp.middleware references any middleware throws 'TypeError: Middleware xxx not found' at boot. Defer the resolution: build the global middleware chain lazily on the first request (composeGlobalMiddleware wraps the base middleware), make getGlobalMiddleware idempotent, and drop the eager call in register(). The installed middleware is identical at request time; it just no longer requires app.middlewares to be ready during registration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/impl/mcp/MCPControllerRegister.ts | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) 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: From 49bc7150a508780002f51e58f34cf7ef8de9634d Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Thu, 25 Jun 2026 17:53:26 +0800 Subject: [PATCH 2/2] test(controller): cover lazy MCP global middleware resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests for the deferred resolution: - composeGlobalMiddleware does not read app.middlewares at registration time (no throw, globalMiddlewares stays unbuilt) — the robustness the fix adds. - the configured middleware is resolved and run on the first request, once app.middlewares has been populated by loadMiddleware. - getGlobalMiddleware is idempotent (builds once). - a genuinely unknown middleware name still throws at resolution time, so the deferral does not hide real misconfiguration. - koa-compose onion ordering is preserved for multiple middlewares. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/mcp/mcpGlobalMiddleware.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 plugin/controller/test/mcp/mcpGlobalMiddleware.test.ts 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'); + }); +});