Skip to content

Commit b79d313

Browse files
authored
feat: add mcp stateless (#309)
<!-- Thank you for your pull request. Please review below requirements. Bug fixes and new features should include tests and possibly benchmarks. Contributors guide: https://github.com/eggjs/egg/blob/master/CONTRIBUTING.md 感谢您贡献代码。请确认下列 checklist 的完成情况。 Bug 修复和新功能必须包含测试,必要时请附上性能测试。 Contributors guide: https://github.com/eggjs/egg/blob/master/CONTRIBUTING.md --> ##### Checklist <!-- Remove items that do not apply. For completed items, change [ ] to [x]. --> - [ ] `npm test` passes - [ ] tests and/or benchmarks are included - [ ] documentation is changed or added - [ ] commit message follows commit guidelines ##### Affected core subsystem(s) <!-- Provide affected core subsystem(s). --> ##### Description of change <!-- Provide a description of the change below this comment. --> <!-- - any feature? - close https://github.com/eggjs/egg/ISSUE_URL --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced stateless stream transport support for MCP controllers, accessible via a new endpoint for streamable HTTP interactions. - Added centralized configuration for MCP controller endpoints and stream transport options. - **Documentation** - Expanded documentation to include detailed guidance and examples on MCP annotations, controller setup, and usage of new stream endpoints. - **Bug Fixes** - Middleware updated to properly handle request body parsing for the new stateless stream endpoint. - **Tests** - Added comprehensive tests to verify stateless streamable MCP controller functionality. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 355924d commit b79d313

File tree

12 files changed

+584
-180
lines changed

12 files changed

+584
-180
lines changed

plugin/controller/README.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,126 @@ export class FooController {
211211
}
212212
}
213213
```
214+
215+
### MCP 注解
216+
217+
#### MCPController/MCPPrompt/MCPTool/MCPResource
218+
219+
`@MCPController` 注解用来声明当前类是一个 MCP controller。
220+
通过使用装饰器 `@MCPPrompt` `@MCPTool` `@MCPResource` 来声明对应的 MCP 类型。
221+
使用 `@ToolArgsSchema` `@PromptArgsSchema` `@Extra` 来 schema 和 extra。
222+
223+
```ts
224+
import {
225+
MCPController,
226+
PromptArgs,
227+
ToolArgs,
228+
MCPPromptResponse,
229+
MCPToolResponse,
230+
MCPResourceResponse,
231+
MCPPrompt,
232+
MCPTool,
233+
MCPResource,
234+
PromptArgsSchema,
235+
Logger,
236+
Inject,
237+
ToolArgsSchema,
238+
} from '@eggjs/tegg';
239+
import z from 'zod';
240+
241+
export const PromptType = {
242+
name: z.string(),
243+
};
244+
245+
export const ToolType = {
246+
name: z.string({
247+
description: 'npm package name',
248+
}),
249+
};
250+
251+
252+
@MCPController()
253+
export class McpController {
254+
255+
@Inject()
256+
logger: Logger;
257+
258+
@MCPPrompt()
259+
async foo(@PromptArgsSchema(PromptType) args: PromptArgs<typeof PromptType>): Promise<MCPPromptResponse> {
260+
this.logger.info('hello world');
261+
return {
262+
messages: [
263+
{
264+
role: 'user',
265+
content: {
266+
type: 'text',
267+
text: `Generate a concise but descriptive commit message for these changes:\n\n${args.name}`,
268+
},
269+
},
270+
],
271+
};
272+
}
273+
274+
@MCPTool()
275+
async bar(@ToolArgsSchema(ToolType) args: ToolArgs<typeof ToolType>): Promise<MCPToolResponse> {
276+
return {
277+
content: [
278+
{
279+
type: 'text',
280+
text: `npm package: ${args.name} not found`,
281+
},
282+
],
283+
};
284+
}
285+
286+
287+
@MCPResource({
288+
template: [
289+
'mcp://npm/{name}{?version}',
290+
{
291+
list: () => {
292+
return {
293+
resources: [
294+
{ name: 'egg', uri: 'mcp://npm/egg?version=4.10.0' },
295+
{ name: 'mcp', uri: 'mcp://npm/mcp?version=0.10.0' },
296+
],
297+
};
298+
},
299+
},
300+
],
301+
})
302+
async car(uri: URL): Promise<MCPResourceResponse> {
303+
return {
304+
contents: [{
305+
uri: uri.toString(),
306+
text: 'MOCK TEXT',
307+
}],
308+
};
309+
}
310+
}
311+
312+
```
313+
314+
#### MCP 地址
315+
MCP controller 完整的实现了 SSE / streamable HTTP / streamable stateless HTTP 三种模式,默认情况下,他们的路径分别是 `/mcp/init` `/mcp/stream` `/mcp/stateless/stream`, 你可以根据你所需要的场景,灵活使用对应的接口。
316+
317+
``` ts
318+
// config.{env}.ts
319+
import { randomUUID } from 'node:crypto';
320+
321+
export default () => {
322+
323+
const config = {
324+
mcp: {
325+
sseInitPath: '/mcp/init', // SSE path
326+
sseMessagePath: '/mcp/message', // SSE message path
327+
streamPath: '/mcp/stream', // streamable path
328+
statelessStreamPath: '/mcp/stateless/stream', // stateless streamable path
329+
sessionIdGenerator: randomUUID,
330+
},
331+
};
332+
333+
return config;
334+
};
335+
336+
```

plugin/controller/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default class ControllerAppBootHook {
7373
this.app.config.mcp.sseInitPath,
7474
this.app.config.mcp.sseMessagePath,
7575
this.app.config.mcp.streamPath,
76+
this.app.config.mcp.statelessStreamPath,
7677
...(Array.isArray(this.app.config.security.csrf.ignore)
7778
? this.app.config.security.csrf.ignore
7879
: [ this.app.config.security.csrf.ignore ]),
@@ -83,6 +84,7 @@ export default class ControllerAppBootHook {
8384
this.app.config.mcp.sseInitPath,
8485
this.app.config.mcp.sseMessagePath,
8586
this.app.config.mcp.streamPath,
87+
this.app.config.mcp.statelessStreamPath,
8688
];
8789
}
8890
}
@@ -108,6 +110,9 @@ export default class ControllerAppBootHook {
108110
// The HTTPControllerRegister will collect all the methods
109111
// and register methods after collect is done.
110112
HTTPControllerRegister.instance?.doRegister(this.app.rootProtoManager);
113+
if (this.app.mcpProxy) {
114+
MCPControllerRegister.connectStatelessStreamTransport();
115+
}
111116
}
112117

113118
async beforeClose() {

plugin/controller/app/middleware/mcp_body_middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import pathToRegexp from 'path-to-regexp';
33

44
export default () => {
55
return async function mcpBodyMiddleware(ctx: EggContext, next: Next) {
6-
const arr = [ ctx.app.config.mcp.sseInitPath, ctx.app.config.mcp.sseMessagePath, ctx.app.config.mcp.streamPath ];
6+
const arr = [ ctx.app.config.mcp.sseInitPath, ctx.app.config.mcp.sseMessagePath, ctx.app.config.mcp.streamPath, ctx.app.config.mcp.statelessStreamPath ];
77
const res = arr.some(igPath => {
88
const match = pathToRegexp(igPath, [], {
99
end: false,

plugin/controller/config/config.default.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default () => {
77
sseInitPath: '/mcp/init',
88
sseMessagePath: '/mcp/message',
99
streamPath: '/mcp/stream',
10+
statelessStreamPath: '/mcp/stateless/stream',
1011
sessionIdGenerator: randomUUID,
1112
},
1213
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { randomUUID } from 'node:crypto';
2+
import { EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3+
import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js';
4+
5+
export interface MCPConfigOptions {
6+
sseInitPath: string;
7+
sseMessagePath: string;
8+
streamPath: string;
9+
statelessStreamPath: string;
10+
sessionIdGenerator?: () => string;
11+
eventStore?: EventStore;
12+
sseHeartTime?: number;
13+
}
14+
15+
export class MCPConfig {
16+
private _sseInitPath: string;
17+
private _sseMessagePath: string;
18+
private _streamPath: string;
19+
private _statelessStreamPath: string;
20+
private _sessionIdGenerator: () => string;
21+
private _eventStore: EventStore;
22+
private _sseHeartTime: number;
23+
24+
constructor(options: MCPConfigOptions) {
25+
this._sessionIdGenerator = options.sessionIdGenerator ?? randomUUID;
26+
this._sseInitPath = options.sseInitPath;
27+
this._sseMessagePath = options.sseMessagePath;
28+
this._streamPath = options.streamPath;
29+
this._statelessStreamPath = options.statelessStreamPath;
30+
this._eventStore = options.eventStore ?? new InMemoryEventStore();
31+
this._sseHeartTime = options.sseHeartTime ?? 25000;
32+
}
33+
34+
get sseInitPath() {
35+
return this._sseInitPath;
36+
}
37+
38+
get sseMessagePath() {
39+
return this._sseMessagePath;
40+
}
41+
42+
get streamPath() {
43+
return this._streamPath;
44+
}
45+
46+
get statelessStreamPath() {
47+
return this._statelessStreamPath;
48+
}
49+
50+
get sessionIdGenerator() {
51+
return this._sessionIdGenerator;
52+
}
53+
54+
get eventStore() {
55+
return this._eventStore;
56+
}
57+
58+
get sseHeartTime() {
59+
return this._sseHeartTime;
60+
}
61+
}

0 commit comments

Comments
 (0)