agent-planner-mcp 0.3.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ /**
2
+ * MCP HTTP/SSE Server
3
+ *
4
+ * Implements the MCP Streamable HTTP transport specification (2025-06-18)
5
+ * https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
6
+ *
7
+ * Features:
8
+ * - Single endpoint supporting POST and GET methods
9
+ * - Session management via Mcp-Session-Id header
10
+ * - Server-Sent Events (SSE) for streaming responses
11
+ * - JSON-RPC 2.0 protocol
12
+ * - Origin validation for security
13
+ */
14
+
15
+ const express = require('express');
16
+ const { SessionManager } = require('./session-manager');
17
+ const { setupTools } = require('./tools');
18
+ const { createApiClient } = require('./api-client');
19
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
20
+ const { version } = require('../package.json');
21
+ require('dotenv').config();
22
+
23
+ // MCP Protocol Version
24
+ const MCP_PROTOCOL_VERSION = '2025-03-26';
25
+
26
+ class MCPHTTPServer {
27
+ constructor(options = {}) {
28
+ this.port = options.port || process.env.PORT || 3100;
29
+ this.host = options.host || process.env.HOST || '127.0.0.1';
30
+
31
+ // Session manager
32
+ this.sessionManager = new SessionManager({
33
+ sessionTimeout: options.sessionTimeout || 30 * 60 * 1000,
34
+ cleanupInterval: options.cleanupInterval || 5 * 60 * 1000
35
+ });
36
+
37
+ // Store for pending SSE streams per session
38
+ this.sseStreams = new Map(); // sessionId -> { res, req }
39
+
40
+ // Create Express app
41
+ this.app = express();
42
+
43
+ // Setup middleware and routes
44
+ this.setupMiddleware();
45
+ this.setupRoutes();
46
+
47
+ console.error('MCPHTTPServer initialized');
48
+ }
49
+
50
+ /**
51
+ * Setup Express middleware
52
+ */
53
+ setupMiddleware() {
54
+ // Parse JSON bodies
55
+ this.app.use(express.json());
56
+
57
+ // Logging middleware
58
+ this.app.use((req, res, next) => {
59
+ console.error(`${req.method} ${req.path} - ${req.get('MCP-Protocol-Version') || 'no version'}`);
60
+ next();
61
+ });
62
+
63
+ // Protocol version validation
64
+ this.app.use((req, res, next) => {
65
+ // Skip version check for health and discovery endpoints
66
+ if (req.path === '/health' || req.path === '/.well-known/mcp.json') {
67
+ return next();
68
+ }
69
+
70
+ const version = req.get('MCP-Protocol-Version');
71
+
72
+ // Backwards compatibility: assume 2025-03-26 if not provided
73
+ if (!version) {
74
+ req.mcpVersion = '2025-03-26';
75
+ return next();
76
+ }
77
+
78
+ req.mcpVersion = version;
79
+ next();
80
+ });
81
+
82
+ // Authentication — require Authorization header on /mcp
83
+ this.app.use((req, res, next) => {
84
+ if (req.path === '/health' || req.path === '/.well-known/mcp.json') return next();
85
+
86
+ const authHeader = req.get('Authorization');
87
+ if (!authHeader) {
88
+ return res.status(401).json({
89
+ jsonrpc: '2.0',
90
+ error: { code: -32000, message: 'Authorization header required. Use "Authorization: Bearer <token>" or "Authorization: ApiKey <token>".' }
91
+ });
92
+ }
93
+
94
+ const parts = authHeader.split(' ');
95
+ if (parts.length !== 2 || !['Bearer', 'ApiKey'].includes(parts[0])) {
96
+ return res.status(401).json({
97
+ jsonrpc: '2.0',
98
+ error: { code: -32000, message: 'Invalid Authorization format. Use "Bearer <token>" or "ApiKey <token>".' }
99
+ });
100
+ }
101
+
102
+ // Store the raw token for per-session API client creation
103
+ req.userToken = parts[1];
104
+ next();
105
+ });
106
+
107
+ // Origin validation for security (DNS rebinding protection)
108
+ this.app.use((req, res, next) => {
109
+ // Skip origin check for health and discovery endpoints
110
+ if (req.path === '/health' || req.path === '/.well-known/mcp.json') {
111
+ return next();
112
+ }
113
+
114
+ // Skip origin check when running on 0.0.0.0 (production container behind nginx)
115
+ // Origin validation is DNS rebinding protection, not relevant for server-to-server MCP
116
+ if (this.host === '0.0.0.0') return next();
117
+
118
+ const origin = req.get('Origin');
119
+
120
+ // If Origin header is present, validate it
121
+ if (origin) {
122
+ // Accept localhost and production origins
123
+ const allowedOrigins = [
124
+ 'http://localhost',
125
+ 'http://127.0.0.1',
126
+ `http://localhost:${this.port}`,
127
+ `http://127.0.0.1:${this.port}`,
128
+ 'https://agentplanner.io'
129
+ ];
130
+
131
+ const isAllowed = allowedOrigins.some(allowed => origin.startsWith(allowed));
132
+
133
+ if (!isAllowed) {
134
+ console.error(`Rejected request from origin: ${origin}`);
135
+ return res.status(403).json({
136
+ jsonrpc: '2.0',
137
+ error: {
138
+ code: -32000,
139
+ message: 'Forbidden origin'
140
+ }
141
+ });
142
+ }
143
+ }
144
+
145
+ next();
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Setup Express routes
151
+ */
152
+ setupRoutes() {
153
+ // MCP discovery endpoint (no auth required)
154
+ this.app.get('/.well-known/mcp.json', (req, res) => {
155
+ res.json({
156
+ mcp_version: '2025-03-26',
157
+ server: {
158
+ name: 'agent-planner-mcp',
159
+ version,
160
+ description: 'AI agent orchestration with planning, dependencies, knowledge graphs, and human oversight'
161
+ },
162
+ endpoints: { mcp: '/mcp' },
163
+ authentication: {
164
+ type: 'api_key',
165
+ header: 'Authorization',
166
+ format: 'ApiKey <token>'
167
+ },
168
+ capabilities: { tools: true }
169
+ });
170
+ });
171
+
172
+ // Health check endpoint
173
+ this.app.get('/health', (req, res) => {
174
+ const stats = this.sessionManager.getStats();
175
+ res.json({
176
+ status: 'ok',
177
+ version: MCP_PROTOCOL_VERSION,
178
+ server: {
179
+ name: process.env.MCP_SERVER_NAME || 'planning-tools',
180
+ version: process.env.MCP_SERVER_VERSION || version
181
+ },
182
+ sessions: {
183
+ total: stats.total,
184
+ initialized: stats.initialized
185
+ }
186
+ });
187
+ });
188
+
189
+ // Main MCP endpoint - handles both POST and GET
190
+ this.app.post('/mcp', this.handleMCPPost.bind(this));
191
+ this.app.get('/mcp', this.handleMCPGet.bind(this));
192
+
193
+ // Session termination endpoint
194
+ this.app.delete('/mcp', this.handleMCPDelete.bind(this));
195
+
196
+ // 404 handler
197
+ this.app.use((req, res) => {
198
+ res.status(404).json({
199
+ jsonrpc: '2.0',
200
+ error: {
201
+ code: -32000,
202
+ message: 'Not found'
203
+ }
204
+ });
205
+ });
206
+
207
+ // Error handler
208
+ this.app.use((err, req, res, next) => {
209
+ console.error('Express error:', err);
210
+ res.status(500).json({
211
+ jsonrpc: '2.0',
212
+ error: {
213
+ code: -32603,
214
+ message: 'Internal server error',
215
+ data: err.message
216
+ }
217
+ });
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Handle POST requests (client-to-server messages)
223
+ */
224
+ async handleMCPPost(req, res) {
225
+ try {
226
+ // Get or create session
227
+ let sessionId = req.get('Mcp-Session-Id');
228
+ let session = sessionId ? this.sessionManager.getSession(sessionId) : null;
229
+
230
+ // Validate session exists if session ID provided
231
+ if (sessionId && !session) {
232
+ return res.status(404).json({
233
+ jsonrpc: '2.0',
234
+ error: {
235
+ code: -32000,
236
+ message: 'Session not found'
237
+ }
238
+ });
239
+ }
240
+
241
+ // Parse JSON-RPC message
242
+ const message = req.body;
243
+
244
+ if (!message || !message.jsonrpc || message.jsonrpc !== '2.0') {
245
+ return res.status(400).json({
246
+ jsonrpc: '2.0',
247
+ error: {
248
+ code: -32600,
249
+ message: 'Invalid JSON-RPC request'
250
+ }
251
+ });
252
+ }
253
+
254
+ // Handle different message types
255
+ const isRequest = message.method && message.id !== undefined;
256
+ const isNotification = message.method && message.id === undefined;
257
+ const isResponse = message.result !== undefined || message.error !== undefined;
258
+
259
+ if (isNotification || isResponse) {
260
+ // For notifications and responses, return 202 Accepted
261
+ return res.status(202).send();
262
+ }
263
+
264
+ if (isRequest) {
265
+ // Handle JSON-RPC request
266
+ const response = await this.handleRequest(message, session, sessionId, req.userToken);
267
+
268
+ // If this is an initialize request, create session and include session ID
269
+ if (message.method === 'initialize' && response.result) {
270
+ sessionId = this.sessionManager.createSession();
271
+ this.sessionManager.initializeSession(sessionId, message.params?.capabilities);
272
+
273
+ // Create a per-session API client bound to this user's token
274
+ const sessionApiClient = createApiClient(req.userToken);
275
+ this.sessionManager.setApiClient(sessionId, sessionApiClient);
276
+
277
+ // Set session ID header in response
278
+ res.setHeader('Mcp-Session-Id', sessionId);
279
+
280
+ console.error(`Session initialized: ${sessionId} (per-user token)`);
281
+ }
282
+
283
+ // Check if we should stream the response via SSE
284
+ const acceptHeader = req.get('Accept') || '';
285
+ const supportsSSE = acceptHeader.includes('text/event-stream');
286
+
287
+ // For now, we'll send simple JSON responses
288
+ // SSE streaming can be added later for long-running operations
289
+ if (supportsSSE && this.shouldStreamResponse(message)) {
290
+ // Send SSE stream
291
+ return this.streamResponse(req, res, response, sessionId);
292
+ } else {
293
+ // Send simple JSON response
294
+ res.setHeader('Content-Type', 'application/json');
295
+ return res.json(response);
296
+ }
297
+ }
298
+
299
+ // Unknown message type
300
+ return res.status(400).json({
301
+ jsonrpc: '2.0',
302
+ error: {
303
+ code: -32600,
304
+ message: 'Invalid request'
305
+ }
306
+ });
307
+ } catch (error) {
308
+ console.error('Error handling POST request:', error);
309
+ return res.status(500).json({
310
+ jsonrpc: '2.0',
311
+ error: {
312
+ code: -32603,
313
+ message: 'Internal error',
314
+ data: error.message
315
+ }
316
+ });
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Handle GET requests (SSE streams for server-to-client messages)
322
+ */
323
+ handleMCPGet(req, res) {
324
+ try {
325
+ // Validate Accept header
326
+ const acceptHeader = req.get('Accept') || '';
327
+ if (!acceptHeader.includes('text/event-stream')) {
328
+ return res.status(405).json({
329
+ jsonrpc: '2.0',
330
+ error: {
331
+ code: -32000,
332
+ message: 'Method not allowed. GET requires Accept: text/event-stream'
333
+ }
334
+ });
335
+ }
336
+
337
+ // Get session
338
+ const sessionId = req.get('Mcp-Session-Id');
339
+ if (!sessionId) {
340
+ return res.status(400).json({
341
+ jsonrpc: '2.0',
342
+ error: {
343
+ code: -32000,
344
+ message: 'Mcp-Session-Id header required'
345
+ }
346
+ });
347
+ }
348
+
349
+ const session = this.sessionManager.getSession(sessionId);
350
+ if (!session) {
351
+ return res.status(404).json({
352
+ jsonrpc: '2.0',
353
+ error: {
354
+ code: -32000,
355
+ message: 'Session not found'
356
+ }
357
+ });
358
+ }
359
+
360
+ // Setup SSE stream
361
+ res.setHeader('Content-Type', 'text/event-stream');
362
+ res.setHeader('Cache-Control', 'no-cache');
363
+ res.setHeader('Connection', 'keep-alive');
364
+ res.setHeader('X-Accel-Buffering', 'no'); // Disable buffering in nginx
365
+
366
+ // Store SSE stream for this session
367
+ this.sseStreams.set(sessionId, { res, req });
368
+
369
+ console.error(`SSE stream opened for session: ${sessionId}`);
370
+
371
+ // Send initial comment to establish connection
372
+ res.write(': connected\n\n');
373
+
374
+ // Handle client disconnect
375
+ req.on('close', () => {
376
+ console.error(`SSE stream closed for session: ${sessionId}`);
377
+ this.sseStreams.delete(sessionId);
378
+ });
379
+ } catch (error) {
380
+ console.error('Error handling GET request:', error);
381
+ res.status(500).json({
382
+ jsonrpc: '2.0',
383
+ error: {
384
+ code: -32603,
385
+ message: 'Internal error',
386
+ data: error.message
387
+ }
388
+ });
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Handle DELETE requests (session termination)
394
+ */
395
+ handleMCPDelete(req, res) {
396
+ const sessionId = req.get('Mcp-Session-Id');
397
+
398
+ if (!sessionId) {
399
+ return res.status(400).json({
400
+ jsonrpc: '2.0',
401
+ error: {
402
+ code: -32000,
403
+ message: 'Mcp-Session-Id header required'
404
+ }
405
+ });
406
+ }
407
+
408
+ // Close any SSE streams for this session
409
+ const stream = this.sseStreams.get(sessionId);
410
+ if (stream) {
411
+ stream.res.end();
412
+ this.sseStreams.delete(sessionId);
413
+ }
414
+
415
+ // Delete session
416
+ const deleted = this.sessionManager.deleteSession(sessionId);
417
+
418
+ if (deleted) {
419
+ return res.status(204).send();
420
+ } else {
421
+ return res.status(404).json({
422
+ jsonrpc: '2.0',
423
+ error: {
424
+ code: -32000,
425
+ message: 'Session not found'
426
+ }
427
+ });
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Handle JSON-RPC request
433
+ */
434
+ async handleRequest(message, session, sessionId, userToken) {
435
+ // Create MCP server instance for this request
436
+ const mcpServer = new Server({
437
+ name: process.env.MCP_SERVER_NAME || 'planning-tools',
438
+ version: process.env.MCP_SERVER_VERSION || version
439
+ }, {
440
+ capabilities: {
441
+ tools: {}
442
+ }
443
+ });
444
+
445
+ // Get per-session API client (bound to user's token), or create one for initialize requests
446
+ const sessionApiClient = session
447
+ ? this.sessionManager.getApiClient(sessionId)
448
+ : (userToken ? createApiClient(userToken) : null);
449
+
450
+ // Setup tools with the per-session API client
451
+ setupTools(mcpServer, sessionApiClient);
452
+
453
+ // Process the request through MCP server
454
+ try {
455
+ // Get the appropriate request handler
456
+ const handlers = mcpServer._requestHandlers;
457
+ const handler = handlers.get(message.method);
458
+
459
+ if (!handler) {
460
+ return {
461
+ jsonrpc: '2.0',
462
+ id: message.id,
463
+ error: {
464
+ code: -32601,
465
+ message: `Method not found: ${message.method}`
466
+ }
467
+ };
468
+ }
469
+
470
+ // Call the handler with the full request format expected by SDK
471
+ const result = await handler(message);
472
+
473
+ return {
474
+ jsonrpc: '2.0',
475
+ id: message.id,
476
+ result
477
+ };
478
+ } catch (error) {
479
+ console.error(`Error handling method ${message.method}:`, error);
480
+
481
+ return {
482
+ jsonrpc: '2.0',
483
+ id: message.id,
484
+ error: {
485
+ code: -32603,
486
+ message: 'Internal error',
487
+ data: error.message
488
+ }
489
+ };
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Determine if response should be streamed via SSE
495
+ */
496
+ shouldStreamResponse(message) {
497
+ // For now, we don't need streaming for planning tools
498
+ // All operations are relatively quick
499
+ // This can be enabled later for long-running operations
500
+ return false;
501
+ }
502
+
503
+ /**
504
+ * Stream response via SSE
505
+ */
506
+ streamResponse(req, res, response, sessionId) {
507
+ res.setHeader('Content-Type', 'text/event-stream');
508
+ res.setHeader('Cache-Control', 'no-cache');
509
+ res.setHeader('Connection', 'keep-alive');
510
+
511
+ // Send the response as an SSE event
512
+ res.write(`data: ${JSON.stringify(response)}\n\n`);
513
+
514
+ // Close the stream
515
+ res.end();
516
+ }
517
+
518
+ /**
519
+ * Start the HTTP server
520
+ */
521
+ async start() {
522
+ return new Promise((resolve, reject) => {
523
+ try {
524
+ this.server = this.app.listen(this.port, this.host, () => {
525
+ console.error(`MCP HTTP Server listening on ${this.host}:${this.port}`);
526
+ console.error(`MCP endpoint: http://${this.host}:${this.port}/mcp`);
527
+ console.error(`Health check: http://${this.host}:${this.port}/health`);
528
+ console.error(`Protocol version: ${MCP_PROTOCOL_VERSION}`);
529
+ resolve();
530
+ });
531
+
532
+ this.server.on('error', (error) => {
533
+ console.error('Server error:', error);
534
+ reject(error);
535
+ });
536
+ } catch (error) {
537
+ reject(error);
538
+ }
539
+ });
540
+ }
541
+
542
+ /**
543
+ * Stop the HTTP server
544
+ */
545
+ async stop() {
546
+ return new Promise((resolve) => {
547
+ // Close all SSE streams
548
+ for (const [sessionId, stream] of this.sseStreams.entries()) {
549
+ stream.res.end();
550
+ }
551
+ this.sseStreams.clear();
552
+
553
+ // Destroy session manager
554
+ this.sessionManager.destroy();
555
+
556
+ // Close HTTP server
557
+ if (this.server) {
558
+ this.server.close(() => {
559
+ console.error('MCP HTTP Server stopped');
560
+ resolve();
561
+ });
562
+ } else {
563
+ resolve();
564
+ }
565
+ });
566
+ }
567
+ }
568
+
569
+ module.exports = { MCPHTTPServer };