From ea8466086fecbdb010042d1944a250ae3306a9e4 Mon Sep 17 00:00:00 2001 From: Bradley Steinfeld Date: Wed, 23 Apr 2025 15:00:42 -0400 Subject: [PATCH 1/2] feat(transport): switch to streaming http transport and fix br bug --- package-lock.json | 290 +++++++++--------- package.json | 5 +- src/http.ts | 143 +++++++++ src/mcp/index.ts | 10 +- src/mcp/inmemoryeventstore.ts | 80 +++++ .../components/HtmlToMarkdownMiddleware.ts | 8 +- src/server.ts | 4 +- 7 files changed, 385 insertions(+), 155 deletions(-) create mode 100644 src/http.ts create mode 100644 src/mcp/inmemoryeventstore.ts diff --git a/package-lock.json b/package-lock.json index 5cc8c35..c3d49a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@arabold/docs-mcp-server", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@arabold/docs-mcp-server", - "version": "1.9.0", + "version": "1.10.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -16,7 +16,7 @@ "@langchain/google-genai": "^0.2.3", "@langchain/google-vertexai": "^0.2.4", "@langchain/openai": "^0.5.0", - "@modelcontextprotocol/sdk": "^1.6.1", + "@modelcontextprotocol/sdk": "^1.10.2", "axios": "^1.8.3", "axios-retry": "^4.5.0", "better-sqlite3": "^11.9.1", @@ -24,6 +24,7 @@ "commander": "^13.1.0", "dotenv": "^16.4.7", "env-paths": "^3.0.0", + "express": "^5.1.0", "fuse.js": "^7.1.0", "jsdom": "^26.0.0", "langchain": "0.3.19", @@ -46,11 +47,13 @@ "@biomejs/biome": "1.9.4", "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", + "@playwright/test": "^1.52.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^11.0.1", "@semantic-release/npm": "^12.0.1", "@types/better-sqlite3": "^7.6.12", + "@types/express": "^5.0.1", "@types/jsdom": "~21.1.7", "@types/lint-staged": "~13.3.0", "@types/node": "^20.17.23", @@ -3302,9 +3305,9 @@ "license": "BSD-2-Clause" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.8.0.tgz", - "integrity": "sha512-e06W7SwrontJDHwCawNO5SGxG+nU9AAx+jpHHZqGl/WrDBdWOpvirC+s58VpJTB5QemI4jTRcjWT4Pt3Q1NPQQ==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.2.tgz", + "integrity": "sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -3313,7 +3316,7 @@ "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", - "pkce-challenge": "^4.1.0", + "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" @@ -3528,49 +3531,16 @@ } }, "node_modules/@playwright/test": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", - "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "playwright": "1.51.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", - "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "playwright-core": "1.51.1" + "playwright": "1.52.0" }, "bin": { "playwright": "cli.js" }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", - "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "playwright-core": "cli.js" - }, "engines": { "node": ">=18" } @@ -5367,6 +5337,27 @@ "@types/node": "*" } }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz", @@ -5393,6 +5384,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", + "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -5402,6 +5418,13 @@ "@types/unist": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsdom": { "version": "21.1.7", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", @@ -5430,6 +5453,13 @@ "@types/unist": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -5481,6 +5511,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -5494,6 +5538,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -6042,21 +6109,6 @@ "node": ">=18" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -8206,46 +8258,45 @@ "license": "MIT" }, "node_modules/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", - "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.0.1", + "body-parser": "^2.2.0", "content-disposition": "^1.0.0", - "content-type": "~1.0.4", - "cookie": "0.7.1", + "content-type": "^1.0.5", + "cookie": "^0.7.1", "cookie-signature": "^1.2.1", - "debug": "4.3.6", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "^2.0.0", - "fresh": "2.0.0", - "http-errors": "2.0.0", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", - "methods": "~1.1.2", "mime-types": "^3.0.0", - "on-finished": "2.4.1", - "once": "1.4.0", - "parseurl": "~1.3.3", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "router": "^2.0.0", - "safe-buffer": "5.2.1", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", "send": "^1.1.0", - "serve-static": "^2.1.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "^2.0.0", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-rate-limit": { @@ -8263,29 +8314,6 @@ "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, - "node_modules/express/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "license": "MIT" - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11704,15 +11732,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -16307,9 +16326,9 @@ } }, "node_modules/pkce-challenge": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", - "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", "license": "MIT", "engines": { "node": ">=16.20.0" @@ -16618,12 +16637,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -17621,15 +17640,15 @@ } }, "node_modules/serve-static": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", - "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", - "send": "^1.0.0" + "send": "^1.2.0" }, "engines": { "node": ">= 18" @@ -19981,15 +20000,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", diff --git a/package.json b/package.json index 1d140a1..7050e3f 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@langchain/google-genai": "^0.2.3", "@langchain/google-vertexai": "^0.2.4", "@langchain/openai": "^0.5.0", - "@modelcontextprotocol/sdk": "^1.6.1", + "@modelcontextprotocol/sdk": "^1.10.2", "axios": "^1.8.3", "axios-retry": "^4.5.0", "better-sqlite3": "^11.9.1", @@ -48,6 +48,7 @@ "commander": "^13.1.0", "dotenv": "^16.4.7", "env-paths": "^3.0.0", + "express": "^5.1.0", "fuse.js": "^7.1.0", "jsdom": "^26.0.0", "langchain": "0.3.19", @@ -66,11 +67,13 @@ "@biomejs/biome": "1.9.4", "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", + "@playwright/test": "^1.52.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^11.0.1", "@semantic-release/npm": "^12.0.1", "@types/better-sqlite3": "^7.6.12", + "@types/express": "^5.0.1", "@types/jsdom": "~21.1.7", "@types/lint-staged": "~13.3.0", "@types/node": "^20.17.23", diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..1d77c30 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,143 @@ +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import express, { type NextFunction, type Request, type Response } from "express"; +import { InMemoryEventStore } from "./mcp/inmemoryeventstore.js"; + +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; + +import { setupServer } from "./mcp/index.js"; + +export async function startHttp() { + const DEFAULT_PORT = Number(process.env.PORT ?? 3000); + const app = express(); + app.use(express.json()); + + // ------------------------------------------------------------- + // Session bookkeeping – map sessionId => active transport + // ------------------------------------------------------------- + const transports: Record = {}; + + // ------------------------------------------------------------- + // POST /mcp ⟶ JSON-RPC request (init or follow-up) + // ------------------------------------------------------------- + app.post("/mcp", async (req: Request, res: Response) => { + try { + const sessionId = req.header("mcp-session-id") ?? undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Existing session – reuse the same transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + eventStore, + onsessioninitialized: (sid) => { + transports[sid] = transport; + }, + }); + + // Ensure we drop the transport on close + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) delete transports[sid]; + }; + + // Attach the transport to the server **before** handling + const server = await setupServer(); + + if (server === undefined) { + process.exit(1); + } + + await server.connect(transport); + } else { + // New session: only allowed for initialize request + const { jsonrpc, method } = req.body ?? {}; + if (!(jsonrpc === "2.0" && method === "mcp.initialize")) { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "First call must be mcp.initialize" }, + id: req.body?.id ?? null, + }); + return; + } + + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + eventStore, + onsessioninitialized: (sid) => { + transports[sid] = transport; + }, + }); + + // Ensure we drop the transport on close + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) delete transports[sid]; + }; + + // Attach the transport to the server **before** handling + const server = await setupServer(); + + if (server === undefined) { + process.exit(1); + } + + await server.connect(transport); + } + + await transport.handleRequest(req, res, req.body); + } catch (err) { + console.error("POST /mcp error:", err); + if (!res.headersSent) res.status(500).end(); + } + }); + + // ------------------------------------------------------------- + // GET /mcp ⟶ Server-Sent-Events stream (notifications / chunks) + // ------------------------------------------------------------- + app.get("/mcp", async (req: Request, res: Response) => { + const sessionId = req.header("mcp-session-id"); + const transport = sessionId ? transports[sessionId] : undefined; + if (!transport) { + res.status(400).send("Invalid session"); + } else { + await transport.handleRequest(req, res); // SDK handles SSE + resumability + } + }); + + // ------------------------------------------------------------- + // DELETE /mcp ⟶ graceful session close + // ------------------------------------------------------------- + app.delete("/mcp", async (req: Request, res: Response) => { + const sessionId = req.header("mcp-session-id"); + const transport = sessionId ? transports[sessionId] : undefined; + if (!transport) { + res.status(400).send("Invalid session"); + } else { + await transport.handleRequest(req, res); // calls server.endSession() + } + }); + + // ------------------------------------------------------------- + // Start / stop + // ------------------------------------------------------------- + const serverHandle = app.listen(DEFAULT_PORT, () => + console.log( + `🚀 MCP Streamable-HTTP listening on http://127.0.0.1:${DEFAULT_PORT}/mcp`, + ), + ); + + process.on("SIGINT", async () => { + console.log("\nShutting down…"); + + // Close active transports → flush event streams + await Promise.all(Object.values(transports).map((t) => t.close().catch(() => {}))); + + serverHandle.close(() => process.exit(0)); + }); +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 2832fd9..62b03aa 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import "dotenv/config"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { DEFAULT_MAX_DEPTH, DEFAULT_MAX_PAGES } from "../config"; import { PipelineManager } from "../pipeline/PipelineManager"; @@ -23,7 +22,7 @@ import { import { LogLevel, logger, setLogLevel } from "../utils/logger"; // Import LogLevel and setLogLevel import { createError, createResponse } from "./utils"; -export async function startServer() { +export async function setupServer(): Promise { // Set the default log level for the server to ERROR setLogLevel(LogLevel.ERROR); @@ -541,11 +540,6 @@ ${formattedResults.join("")}`, }, ); - // Start server - const transport = new StdioServerTransport(); - await server.connect(transport); - logger.info("Documentation MCP server running on stdio"); - // Handle cleanup process.on("SIGINT", async () => { await pipelineManager.stop(); // Stop the pipeline manager @@ -553,6 +547,8 @@ ${formattedResults.join("")}`, await server.close(); process.exit(0); }); + + return server; } catch (error) { await docService.shutdown(); // Ensure docService shutdown on error too logger.error(`❌ Fatal Error: ${error}`); diff --git a/src/mcp/inmemoryeventstore.ts b/src/mcp/inmemoryeventstore.ts new file mode 100644 index 0000000..c7d96e5 --- /dev/null +++ b/src/mcp/inmemoryeventstore.ts @@ -0,0 +1,80 @@ +import type { EventStore } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Simple in-memory implementation of the EventStore interface for resumability + * This is primarily intended for examples and testing, not for production use + * where a persistent storage solution would be more appropriate. + */ +export class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + private generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + /** + * Extracts the stream ID from an event ID + */ + private getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split("_"); + return parts.length > 0 ? parts[0] : ""; + } + + /** + * Stores an event with a generated event ID + * Implements EventStore.storeEvent + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter + */ + async replayEventsAfter( + lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise }, + ): Promise { + if (!lastEventId || !this.events.has(lastEventId)) { + return ""; + } + + // Extract the stream ID from the event ID + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + return ""; + } + + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + ); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start sending events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + await send(eventId, message); + } + } + return streamId; + } +} diff --git a/src/scraper/middleware/components/HtmlToMarkdownMiddleware.ts b/src/scraper/middleware/components/HtmlToMarkdownMiddleware.ts index 93c9f80..4306716 100644 --- a/src/scraper/middleware/components/HtmlToMarkdownMiddleware.ts +++ b/src/scraper/middleware/components/HtmlToMarkdownMiddleware.ts @@ -53,12 +53,10 @@ export class HtmlToMarkdownMiddleware implements ContentProcessorMiddleware { } } - const brElements = element.querySelectorAll("br"); - if (brElements.length > 0) { - for (const br of brElements) { - br.replaceWith("\n"); - } + for (const br of Array.from(element.querySelectorAll("br"))) { + br.replaceWith("\n"); } + const text = element.textContent || ""; return `\n\`\`\`${language}\n${text.replace(/^\n+|\n+$/g, "")}\n\`\`\`\n`; diff --git a/src/server.ts b/src/server.ts index 767c8e4..fc015eb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node -import { startServer } from "./mcp"; +import { startHttp } from "./http.js"; import { logger } from "./utils/logger"; -startServer().catch((error) => { +startHttp().catch((error) => { logger.error(`❌ Fatal Error: ${error}`); process.exit(1); }); From ec34411d5f7c6116ab666902906b5422541a28a8 Mon Sep 17 00:00:00 2001 From: Bradley Steinfeld Date: Wed, 23 Apr 2025 18:35:14 -0400 Subject: [PATCH 2/2] fix(http): apply some http fixes --- Dockerfile | 2 ++ docker-compose.yml | 18 ++++++++++++++++++ src/http.ts | 5 +++++ 3 files changed, 25 insertions(+) create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile index 1ff9eb7..e33b996 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,3 +40,5 @@ VOLUME /data # Set the command to run the application CMD ["node", "dist/server.js"] + +EXPOSE 3000 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..58205a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + docs-mcp-server: + image: icr.io/skills-network/docs-mcp-server:latest + build: + context: . + dockerfile: Dockerfile + platform: linux/amd64 + container_name: docs-mcp-server + ports: + - "3000:3000" + env_file: + - .env + volumes: + - docs-mcp-data:/data + +volumes: + docs-mcp-data: + name: docs-mcp-data diff --git a/src/http.ts b/src/http.ts index 1d77c30..27f7cca 100644 --- a/src/http.ts +++ b/src/http.ts @@ -18,6 +18,11 @@ export async function startHttp() { // ------------------------------------------------------------- const transports: Record = {}; + // Health route to verify server is running + app.get("/health", (_req: Request, res: Response) => { + res.status(200).json({ status: "ok" }); + }); + // ------------------------------------------------------------- // POST /mcp ⟶ JSON-RPC request (init or follow-up) // -------------------------------------------------------------