Skip to content

Commit 17d354d

Browse files
committed
feat(hermes): mcp implementation
fix(module-tools): booleans getting stringified and appearing as true on server-side fix(core): missing error definitions in proto
1 parent 39c3793 commit 17d354d

32 files changed

+1590
-48
lines changed

libraries/grpc-sdk/src/interfaces/Route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface ConduitRouteOptions {
6868
description?: string;
6969
middlewares?: string[];
7070
cacheControl?: string;
71+
mcp?: boolean;
7172
errors?: ModuleErrorDefinition[];
7273
}
7374

libraries/hermes/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@conduitplatform/grpc-sdk": "*",
2121
"@grpc/grpc-js": "^1.13.3",
2222
"@grpc/proto-loader": "^0.7.13",
23+
"@modelcontextprotocol/sdk": "^1.20.1",
2324
"@scalar/api-reference": "^1.28.19",
2425
"@scalar/express-api-reference": "^0.4.157",
2526
"@socket.io/redis-adapter": "^8.3.0",
@@ -44,7 +45,9 @@
4445
"object-hash": "^3.0.0",
4546
"socket.io": "^4.8.0",
4647
"swagger-ui-express": "5.0.0",
47-
"winston": "^3.12.0"
48+
"uuid": "^9.0.0",
49+
"winston": "^3.12.0",
50+
"zod": "^3.23.8"
4851
},
4952
"peerDependencies": {
5053
"socket.io-adapter": "^2.5.4"
@@ -63,6 +66,7 @@
6366
"@types/lodash-es": "^4.17.12",
6467
"@types/node": "20.11.24",
6568
"@types/object-hash": "^3.0.6",
69+
"@types/uuid": "^9.0.0",
6670
"rimraf": "^5.0.5",
6771
"typescript": "~5.6.2"
6872
}

libraries/hermes/src/GraphQl/GraphQL.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
ConduitRouteActions,
1717
ConduitRouteOptions,
1818
Indexable,
19-
TYPE,
2019
} from '@conduitplatform/grpc-sdk';
2120
import { ConduitRoute, TypeRegistry } from '../classes/index.js';
2221
import { ApolloServer } from '@apollo/server';
@@ -332,15 +331,6 @@ export class GraphQLController extends ConduitRouter {
332331
});
333332
}
334333

335-
private extractResult(returnTypeFields: string, result: Indexable | string) {
336-
switch (returnTypeFields) {
337-
case TYPE.JSON:
338-
return JSON.parse(result as string);
339-
default:
340-
return result;
341-
}
342-
}
343-
344334
private constructQuery(actionName: string, route: ConduitRoute) {
345335
if (!this.resolvers['Query']) {
346336
this.resolvers['Query'] = {};
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/**
2+
* MCP Controller - Official SDK Implementation
3+
*
4+
* Implements Model Context Protocol server using the official TypeScript SDK
5+
* with Streamable HTTP transport as defined in MCP specification 2025-06-18.
6+
*
7+
* Key features:
8+
* - Stateless transport (new transport per request)
9+
* - HTTP POST for client-to-server messages
10+
* - Admin authentication required
11+
* - Origin validation for security
12+
* - Automatic admin route to tool conversion
13+
*/
14+
15+
import { NextFunction, Request, Response } from 'express';
16+
import { ConduitRouter } from '../Router.js';
17+
import { ConduitGrpcSdk } from '@conduitplatform/grpc-sdk';
18+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
19+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
20+
import { MCPConfig, MCPToolDefinition } from './types.js';
21+
import { ToolRegistry } from './ToolRegistry.js';
22+
import { MCP_ERROR_CODES } from './MCPErrors.js';
23+
import { convertConduitRouteToMCPTool } from './RouteToTool.js';
24+
import { ConduitRoute } from '../classes/index.js';
25+
import { isNil } from 'lodash-es';
26+
27+
export class MCPController extends ConduitRouter {
28+
private _mcpServer: McpServer;
29+
private _toolRegistry: ToolRegistry;
30+
private _config: MCPConfig;
31+
private _grpcSdk: ConduitGrpcSdk;
32+
33+
constructor(
34+
grpcSdk: ConduitGrpcSdk,
35+
config: MCPConfig,
36+
private readonly metrics?: {
37+
registeredRoutes?: {
38+
name: string;
39+
};
40+
},
41+
) {
42+
super(grpcSdk);
43+
this._grpcSdk = grpcSdk;
44+
this._config = config;
45+
46+
// Initialize MCP server with SDK
47+
this._mcpServer = new McpServer({
48+
name: this._config.serverInfo.name,
49+
title: this._config.serverInfo.title,
50+
version: this._config.serverInfo.version,
51+
});
52+
53+
// Initialize tool registry
54+
this._toolRegistry = new ToolRegistry(grpcSdk);
55+
this._toolRegistry.setMcpServer(this._mcpServer);
56+
57+
this.initializeRouter();
58+
}
59+
60+
initializeRouter() {
61+
this.createRouter();
62+
this.setupRoutes();
63+
}
64+
65+
private setupRoutes() {
66+
if (!this._expressRouter) return;
67+
68+
// OPTIONS for CORS preflight
69+
this._expressRouter.options(this._config.path, this.handleOptions.bind(this));
70+
71+
// POST endpoint for MCP messages (required by Streamable HTTP spec)
72+
this._expressRouter.post(this._config.path, this.handleMCPRequest.bind(this));
73+
74+
// Health check endpoint
75+
this._expressRouter.get(
76+
`${this._config.path}/health`,
77+
this.handleHealthCheck.bind(this),
78+
);
79+
}
80+
81+
/**
82+
* Handle CORS preflight requests
83+
*/
84+
private handleOptions(req: Request, res: Response) {
85+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
86+
res.setHeader(
87+
'Access-Control-Allow-Headers',
88+
'Authorization, Content-Type, Origin, MCP-Protocol-Version',
89+
);
90+
res.setHeader('Access-Control-Max-Age', '86400');
91+
res.status(204).send();
92+
}
93+
94+
/**
95+
* Handle MCP POST requests with stateless transport
96+
* Creates a new transport for each request to prevent request ID collisions
97+
*/
98+
private async handleMCPRequest(req: Request, res: Response, next: NextFunction) {
99+
try {
100+
// Create stateless transport (new instance per request)
101+
const transport = new StreamableHTTPServerTransport({
102+
sessionIdGenerator: undefined, // Stateless mode
103+
enableJsonResponse: true,
104+
});
105+
106+
// Clean up transport when response closes
107+
res.on('close', () => {
108+
transport.close();
109+
});
110+
111+
// Connect server to transport (creates new session internally)
112+
await this._mcpServer.connect(transport);
113+
114+
// Handle the request
115+
await transport.handleRequest(req, res, req.body);
116+
} catch (error) {
117+
ConduitGrpcSdk.Logger.error(
118+
'Error handling MCP request: ' + (error as Error).message,
119+
);
120+
121+
// Only send error response if headers haven't been sent
122+
if (!res.headersSent) {
123+
const message = req.body;
124+
res.status(500).json({
125+
jsonrpc: '2.0',
126+
id: message?.id || null,
127+
error: {
128+
code: MCP_ERROR_CODES.INTERNAL_ERROR,
129+
message: error instanceof Error ? error.message : 'Internal server error',
130+
},
131+
});
132+
}
133+
}
134+
}
135+
136+
/**
137+
* Health check endpoint
138+
*/
139+
private handleHealthCheck(req: Request, res: Response) {
140+
res.json({
141+
status: 'healthy',
142+
protocol: 'mcp',
143+
version: this._config.protocolVersion,
144+
serverInfo: this._config.serverInfo,
145+
tools: this._toolRegistry.getToolCount(),
146+
uptime: process.uptime(),
147+
});
148+
}
149+
150+
/**
151+
* Register a tool with the MCP server
152+
*/
153+
registerTool(tool: MCPToolDefinition): void {
154+
this._toolRegistry.registerTool(tool);
155+
}
156+
157+
/**
158+
* Register a Conduit route as an MCP tool (following ConduitRouter pattern)
159+
*/
160+
registerConduitRoute(route: ConduitRoute): void {
161+
if (!this.routeChanged(route)) return; // Inherited from ConduitRouter
162+
// do not register any router that isn't requested
163+
if (!isNil(route.input.mcp) && !route.input.mcp) return;
164+
165+
const key = `${route.input.action}-${route.input.path}`;
166+
const registered = this._registeredRoutes.has(key);
167+
this._registeredRoutes.set(key, route);
168+
169+
if (!registered) {
170+
// First time registration
171+
this.registerRouteAsTool(route);
172+
if (this.metrics?.registeredRoutes) {
173+
ConduitGrpcSdk.Metrics?.increment(this.metrics.registeredRoutes.name, 1, {
174+
transport: 'mcp',
175+
});
176+
}
177+
}
178+
// If already registered and changed, refresh will handle it
179+
}
180+
181+
/**
182+
* Register a Conduit admin route as an MCP tool
183+
*/
184+
registerRouteAsTool(route: ConduitRoute): void {
185+
const tool = convertConduitRouteToMCPTool(route, this);
186+
this.registerTool(tool);
187+
}
188+
189+
// Override ConduitRouter methods
190+
protected _refreshRouter() {
191+
// Don't recreate server, just unregister and re-register tools
192+
this._toolRegistry.clearAllTools(); // This now properly removes via handles
193+
194+
// Re-register all tools from registered routes
195+
this._registeredRoutes.forEach(route => {
196+
this.registerRouteAsTool(route);
197+
});
198+
199+
ConduitGrpcSdk.Logger.log(
200+
`MCP tools refreshed: ${this._registeredRoutes.size} tools`,
201+
);
202+
}
203+
204+
/**
205+
* Clean up routes that are no longer registered
206+
* Updates _registeredRoutes and triggers refresh to rebuild tools
207+
*/
208+
cleanupRoutes(routes: { action: string; path: string }[]): void {
209+
// Base class cleanupRoutes updates _registeredRoutes
210+
// Then calls refreshRouter() which triggers our _refreshRouter()
211+
const newRegisteredRoutes: Map<string, ConduitRoute> = new Map();
212+
routes.forEach(route => {
213+
const key = `${route.action}-${route.path}`;
214+
if (this._registeredRoutes.has(key)) {
215+
newRegisteredRoutes.set(key, this._registeredRoutes.get(key)!);
216+
}
217+
});
218+
219+
this._registeredRoutes.clear();
220+
this._registeredRoutes = newRegisteredRoutes;
221+
this.refreshRouter(); // Triggers _refreshRouter after delay
222+
}
223+
224+
shutDown() {
225+
ConduitGrpcSdk.Logger.log('Shutting down MCP controller');
226+
super.shutDown();
227+
}
228+
}

0 commit comments

Comments
 (0)