From 8b651d379ab8861521ab627ebf9f00b0eedbc9ed Mon Sep 17 00:00:00 2001 From: "Peter.Morton" Date: Thu, 30 Apr 2026 19:41:51 -0500 Subject: [PATCH] feat: add HTTP server transport for MCP Adds src/http-server.ts - a Node.js HTTP entry point that exposes the MCP implementation via Streamable HTTP transport (POST/GET /mcp) per the MCP 2025-11-25 specification. - Uses StreamableHTTPServerTransport (stateless mode) from the MCP SDK - Port configurable via MCP_PORT env var (default: 3000) - ObsidianMCPServer.connect() now accepts an optional Transport param so both stdio (existing) and HTTP (new) modes reuse the same server - Added 'npm run server' script to start the HTTP server Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 1 + src/http-server.ts | 114 +++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 11 +++-- 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 src/http-server.ts diff --git a/package.json b/package.json index 5af273f..4ed0e4a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "scripts": { "build": "tsc", + "server": "node dist/http-server.js", "validate-manifest": "mcpb validate manifest.json", "pack": "npm run build && mcpb pack", "test": "jest", diff --git a/src/http-server.ts b/src/http-server.ts new file mode 100644 index 0000000..eafc57b --- /dev/null +++ b/src/http-server.ts @@ -0,0 +1,114 @@ +/** + * HTTP Entry Point for Obsidian MCP Server + * Exposes the MCP implementation over Streamable HTTP transport + * per the MCP 2025-11-25 specification. + * + * Endpoint: POST/GET /mcp + * Port: MCP_PORT env var (default: 3000) + */ + +import http, { IncomingMessage } from 'node:http'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ObsidianMCPServer } from './server.js'; +import { registerAllTools } from './tools/index.js'; +import { logger } from './utils/logger.js'; + +const PORT = parseInt(process.env.MCP_PORT ?? '3000', 10); + +/** + * Read and parse the JSON body from an incoming HTTP request. + */ +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) { + resolve(undefined); + return; + } + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +async function main(): Promise { + const vaultName = process.env.OBSIDIAN_VAULT; + if (!vaultName) { + logger.error('OBSIDIAN_VAULT environment variable not set'); + process.exit(1); + } + + logger.info('Starting Obsidian MCP HTTP server', { vault: vaultName, port: PORT }); + + // Create and configure the MCP server + const mcpServer = new ObsidianMCPServer(); + await registerAllTools(mcpServer); + + // Stateless transport: no session management, each request is self-contained. + // Use sessionIdGenerator: () => randomUUID() for stateful/multi-client mode. + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + await mcpServer.connect(transport); + + const httpServer = http.createServer(async (req, res) => { + // Only serve the /mcp endpoint + if (req.url !== '/mcp') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not Found. Use POST /mcp' })); + return; + } + + try { + let body: unknown; + if (req.method === 'POST') { + body = await readBody(req); + } + await transport.handleRequest(req, res, body); + } catch (error) { + logger.error('Error handling MCP request', { + error: error instanceof Error ? error.message : String(error), + }); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } + } + }); + + httpServer.listen(PORT, () => { + logger.info(`MCP HTTP server ready`, { url: `http://localhost:${PORT}/mcp` }); + }); + + // Graceful shutdown + const shutdown = async (signal: string) => { + logger.info(`Received ${signal}, shutting down`); + httpServer.close(async () => { + try { + await mcpServer.close(); + logger.info('Server closed'); + process.exit(0); + } catch { + process.exit(1); + } + }); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +main().catch((error) => { + logger.error('Fatal error', { + error: error instanceof Error ? error.message : String(error), + }); + process.exit(1); +}); diff --git a/src/server.ts b/src/server.ts index 79f6bc7..edcf025 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -119,12 +120,12 @@ export class ObsidianMCPServer { } /** - * Connect to stdio transport + * Connect using the provided transport, or stdio if none is given */ - async connect(): Promise { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - logger.info('MCP server connected to stdio transport'); + async connect(transport?: Transport): Promise { + const t = transport ?? new StdioServerTransport(); + await this.server.connect(t); + logger.info('MCP server connected', { transport: transport ? 'custom' : 'stdio' }); } /**