1- import { CONTROLLER_META_DATA , type MCPControllerMeta } from '@eggjs/controller-decorator' ;
1+ import { CONTROLLER_META_DATA , type MCPControllerMeta , type MCPToolMeta } from '@eggjs/controller-decorator' ;
22import {
33 MCPServerHelper ,
44 MCP_ROUTER_NAME ,
55 type McpRouter ,
66 type McpServerRegistration ,
7+ type ServerRegisterRecord ,
78} from '@eggjs/controller-runtime' ;
89import { Inject , InnerObjectProto } from '@eggjs/tegg' ;
910import { EggContainerFactory } from '@eggjs/tegg-runtime' ;
1011import { CONTROLLER_AOP_MIDDLEWARES } from '@eggjs/tegg-types' ;
1112import type { EggProtoImplClass , EggPrototype } from '@eggjs/tegg-types' ;
12- import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' ;
13+ import {
14+ type HandleRequestOptions ,
15+ WebStandardStreamableHTTPServerTransport ,
16+ } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' ;
1317
1418import type { FetchRouter } from '../http/FetchRouter.ts' ;
1519import type { ServiceWorkerFetchContext } from '../http/ServiceWorkerFetchContext.ts' ;
16- import type { MCPAuthHandler } from '../types.ts' ;
20+ import type { MCPAuthHandler , MCPTransportOptions } from '../types.ts' ;
1721import type { AbstractControllerAdvice } from './AbstractControllerAdvice.ts' ;
1822
1923type MCPMiddleware = ( ctx : ServiceWorkerFetchContext , next : ( ) => Promise < void > ) => Promise < void > ;
2024
25+ /** Minimal koa-style onion compose so selected middlewares wrap the dispatch. */
26+ function composeMiddlewares ( middlewares : MCPMiddleware [ ] ) : ( ctx : ServiceWorkerFetchContext ) => Promise < void > {
27+ return ( ctx ) => {
28+ let index = - 1 ;
29+ const dispatch = ( i : number ) : Promise < void > => {
30+ if ( i <= index ) {
31+ return Promise . reject ( new Error ( 'next() called multiple times' ) ) ;
32+ }
33+ index = i ;
34+ const fn = middlewares [ i ] ;
35+ if ( ! fn ) {
36+ return Promise . resolve ( ) ;
37+ }
38+ return Promise . resolve ( fn ( ctx , ( ) => dispatch ( i + 1 ) ) ) ;
39+ } ;
40+ return dispatch ( 0 ) ;
41+ } ;
42+ }
43+
2144/**
2245 * The fetch host's MCP transport boundary: stateless streamable HTTP under
2346 * `/mcp[/name]/stream` (POST only). The service worker speaks Fetch natively,
@@ -39,6 +62,9 @@ export class ServiceWorkerMcpRouter implements McpRouter {
3962 @Inject ( )
4063 private readonly mcpAuthHandler : MCPAuthHandler ;
4164
65+ @Inject ( )
66+ private readonly mcpTransportOptions : MCPTransportOptions ;
67+
4268 #registrations: McpServerRegistration [ ] = [ ] ;
4369 #mounted = false ;
4470
@@ -79,38 +105,107 @@ export class ServiceWorkerMcpRouter implements McpRouter {
79105 }
80106 const transport = new WebStandardStreamableHTTPServerTransport ( {
81107 sessionIdGenerator : undefined ,
108+ ...this . #dnsRebindingOptions( ) ,
82109 } ) ;
83110 await mcpServerHelper . server . connect ( transport ) ;
84111 return transport ;
85112 }
86113
87114 /**
88- * Resolve the per-server middlewares from every controller proto that
89- * contributed to this server: function-type middlewares from the controller
90- * metadata plus AOP advice classes declared on the proto .
115+ * Forward Host/Origin allow-lists to the SDK transport. Protection turns on
116+ * once either list is configured (default off for backward compatibility) —
117+ * the host should set it before exposing the endpoint beyond loopback .
91118 */
92- private buildMiddlewares ( reg : McpServerRegistration ) : MCPMiddleware [ ] {
119+ #dnsRebindingOptions( ) : {
120+ allowedHosts ?: string [ ] ;
121+ allowedOrigins ?: string [ ] ;
122+ enableDnsRebindingProtection ?: boolean ;
123+ } {
124+ const { allowedHosts, allowedOrigins, enableDnsRebindingProtection } = this . mcpTransportOptions ?? { } ;
125+ if ( ! allowedHosts ?. length && ! allowedOrigins ?. length ) {
126+ return { } ;
127+ }
128+ return {
129+ allowedHosts,
130+ allowedOrigins,
131+ enableDnsRebindingProtection : enableDnsRebindingProtection ?? true ,
132+ } ;
133+ }
134+
135+ /** Read the JSON-RPC body once via a clone; the original stream stays intact
136+ * for the SDK / a middleware, and the parsed value is handed to the SDK so it
137+ * never re-reads the one-shot body after auth or a middleware touched it. */
138+ async #readJsonRpcBody( request : Request ) : Promise < unknown > {
139+ try {
140+ return await request . clone ( ) . json ( ) ;
141+ } catch {
142+ // Empty / non-JSON: let the SDK read the original and raise its own error.
143+ return undefined ;
144+ }
145+ }
146+
147+ /** Find the tool/prompt/resource a JSON-RPC message targets, if any. */
148+ #findTargetRecord(
149+ reg : McpServerRegistration ,
150+ message : unknown ,
151+ ) : ServerRegisterRecord < { name ?: string ; mcpName ?: string ; uri ?: string } > | undefined {
152+ const method = ( message as { method ?: string } ) ?. method ;
153+ const params = ( ( message as { params ?: Record < string , unknown > } ) ?. params ?? { } ) as {
154+ name ?: string ;
155+ uri ?: string ;
156+ } ;
157+ switch ( method ) {
158+ case 'tools/call' :
159+ return reg . tools . find ( ( t ) => ( t . meta . mcpName ?? t . meta . name ) === params . name ) ;
160+ case 'prompts/get' :
161+ return reg . prompts . find ( ( p ) => ( p . meta . mcpName ?? p . meta . name ) === params . name ) ;
162+ case 'resources/read' :
163+ return reg . resources . find ( ( r ) => r . meta . uri !== undefined && r . meta . uri === params . uri ) ;
164+ default :
165+ // initialize / tools|prompts|resources/list / ping etc. target no record.
166+ return undefined ;
167+ }
168+ }
169+
170+ /** Controller-level (function + AOP advice) middlewares declared on a proto. */
171+ #controllerMiddlewares( proto : EggPrototype ) : MCPMiddleware [ ] {
172+ const out : MCPMiddleware [ ] = [ ] ;
173+ const metadata = proto . getMetaData ( CONTROLLER_META_DATA ) as MCPControllerMeta ;
174+ for ( const mw of metadata . middlewares ?? [ ] ) {
175+ out . push ( mw as unknown as MCPMiddleware ) ;
176+ }
177+ const aopMiddlewareClasses = ( proto . getMetaData ( CONTROLLER_AOP_MIDDLEWARES ) ??
178+ [ ] ) as EggProtoImplClass < AbstractControllerAdvice > [ ] ;
179+ for ( const clazz of aopMiddlewareClasses ) {
180+ out . push ( async ( ctx , next ) => {
181+ const eggObj = await EggContainerFactory . getOrCreateEggObjectFromClazz ( clazz ) ;
182+ await ( eggObj . obj as AbstractControllerAdvice ) . middleware ( ctx , next ) ;
183+ } ) ;
184+ }
185+ return out ;
186+ }
187+
188+ /**
189+ * Select only the middlewares of the targeted tool/prompt/resource:
190+ * controller-level (once per owning proto) plus that method's own
191+ * middlewares. So one controller's middleware never runs for another
192+ * controller's tool, nor for `initialize` / `tools/list`.
193+ */
194+ #selectMiddlewares( reg : McpServerRegistration , parsedBody : unknown ) : MCPMiddleware [ ] {
195+ const messages = Array . isArray ( parsedBody ) ? parsedBody : [ parsedBody ] ;
93196 const middlewares : MCPMiddleware [ ] = [ ] ;
94- const seen = new Set < EggPrototype > ( ) ;
95- for ( const record of [ ...reg . tools , ...reg . resources , ...reg . prompts ] ) {
96- if ( seen . has ( record . proto ) ) {
197+ const seenProtos = new Set < EggPrototype > ( ) ;
198+ for ( const message of messages ) {
199+ const record = this . #findTargetRecord( reg , message ) ;
200+ if ( ! record ) {
97201 continue ;
98202 }
99- seen . add ( record . proto ) ;
100- const metadata = record . proto . getMetaData ( CONTROLLER_META_DATA ) as MCPControllerMeta ;
101- // Function-type middlewares from MCPControllerMeta
102- const classMiddlewares = metadata . middlewares ?? [ ] ;
103- for ( const mw of classMiddlewares ) {
104- middlewares . push ( mw as unknown as MCPMiddleware ) ;
203+ if ( ! seenProtos . has ( record . proto ) ) {
204+ seenProtos . add ( record . proto ) ;
205+ middlewares . push ( ...this . #controllerMiddlewares( record . proto ) ) ;
105206 }
106- // AOP-type middlewares from class metadata
107- const aopMiddlewareClasses = ( record . proto . getMetaData ( CONTROLLER_AOP_MIDDLEWARES ) ??
108- [ ] ) as EggProtoImplClass < AbstractControllerAdvice > [ ] ;
109- for ( const clazz of aopMiddlewareClasses ) {
110- middlewares . push ( async ( ctx , next ) => {
111- const eggObj = await EggContainerFactory . getOrCreateEggObjectFromClazz ( clazz ) ;
112- await ( eggObj . obj as AbstractControllerAdvice ) . middleware ( ctx , next ) ;
113- } ) ;
207+ for ( const mw of ( record . meta as MCPToolMeta ) . middlewares ?? [ ] ) {
208+ middlewares . push ( mw as unknown as MCPMiddleware ) ;
114209 }
115210 }
116211 return middlewares ;
@@ -123,24 +218,32 @@ export class ServiceWorkerMcpRouter implements McpRouter {
123218 name : reg . controllerMeta . name ?? `mcp-${ reg . serverName } -server` ,
124219 version : reg . controllerMeta . version ?? '1.0.0' ,
125220 } ) ;
126- const middlewares = this . buildMiddlewares ( reg ) ;
127221
128222 const postRouterFunc = router . post ;
129223 const initHandler = async ( ctx : ServiceWorkerFetchContext ) => {
130- const denied = await this . mcpAuthHandler . authenticate ( ctx . event . request ) ;
224+ const request = ctx . event . request ;
225+ // Parse the body first (via a clone, no side effect) so target selection
226+ // and the SDK share one read; authentication remains the outermost gate
227+ // before any middleware or transport dispatch runs.
228+ const parsedBody = await this . #readJsonRpcBody( request ) ;
229+ const denied = await this . mcpAuthHandler . authenticate ( request ) ;
131230 if ( denied ) {
132231 ctx . response = denied ;
133232 return ;
134233 }
135- const transport = await this . createServerTransport ( reg , helperFactory ) ;
136- ctx . response = await transport . handleRequest ( ctx . event . request ) ;
234+ const options : HandleRequestOptions | undefined = parsedBody === undefined ? undefined : { parsedBody } ;
235+ const dispatch : MCPMiddleware = async ( ) => {
236+ const transport = await this . createServerTransport ( reg , helperFactory ) ;
237+ ctx . response = await transport . handleRequest ( request , options ) ;
238+ } ;
239+ await composeMiddlewares ( [ ...this . #selectMiddlewares( reg , parsedBody ) , dispatch ] ) ( ctx ) ;
137240 } ;
138241
139242 const streamPath = `/mcp${ name ? `/${ name } ` : '' } /stream` ;
140243 const basePath = `/mcp${ name ? `/${ name } ` : '' } ` ;
141244 const paths = [ streamPath , basePath ] ;
142245 for ( const path of paths ) {
143- Reflect . apply ( postRouterFunc , router , [ 'mcpStatelessStreamInit' , path , ... middlewares , initHandler ] ) ;
246+ Reflect . apply ( postRouterFunc , router , [ 'mcpStatelessStreamInit' , path , initHandler ] ) ;
144247 }
145248
146249 // Only POST is allowed for stateless streamable HTTP
0 commit comments