diff --git a/CHANGELOG.md b/CHANGELOG.md index d07f606..5e355c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.12](https://github.com/QuantGeekDev/mcp-framework/compare/mcp-framework-v0.2.11...mcp-framework-v0.2.12) (2025-04-08) + +### Documentation + +* Add detailed usage examples and explanation for `MCPClient`. +* Document WebSockets transport support and configuration. + ## [0.2.11](https://github.com/QuantGeekDev/mcp-framework/compare/mcp-framework-v0.2.10...mcp-framework-v0.2.11) (2025-03-30) diff --git a/README.md b/README.md index 4b9ab2d..87adb2b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,42 @@ MCP-Framework gives you architecture out of the box, with automatic directory-ba - Easy-to-use base classes for tools, prompts, and resources - Out of the box authentication for SSE endpoints +### Purpose + +- Facilitate communication with an MCP server from your application. +- Support multiple transports seamlessly. +- Simplify sending commands, receiving responses, and handling streaming data. + +### Typical Usage + +```typescript +import { MCPClient } from "mcp-framework"; + +const client = new MCPClient({ + transport: { + type: "websocket", + options: { + url: "ws://localhost:8080/ws" // Your WebSocket endpoint + } + } +}); + +// Connect to the server +await client.connect(); + +// Send a request +const response = await client.send({ + tool: "example_tool", + input: { message: "Hello MCP" } +}); + +console.log("Response:", response); + +// Disconnect when done +await client.disconnect(); +``` + +`MCPClient` can be configured to use other transports like SSE, HTTP, or stdio by changing the `transport` type and options. # [Read the full docs here](https://mcp-framework.com) @@ -257,6 +293,53 @@ const server = new MCPServer({ } } }); +### WebSockets Transport + +The WebSockets transport enables full-duplex, low-latency communication between `MCPClient` and the MCP server. It is ideal for interactive applications requiring real-time updates or bidirectional messaging. + +#### Benefits + +- Persistent connection with low overhead. +- Real-time, bidirectional communication. +- Efficient for streaming data and interactive workflows. +- Supports multiplexing multiple requests/responses over a single connection. + +#### Integration with MCP Client + +To use WebSockets, configure the `MCPClient` with the `websocket` transport type and specify the server URL: + +```typescript +const client = new MCPClient({ + transport: { + type: "websocket", + options: { + url: "ws://localhost:8080/ws" + } + } +}); +await client.connect(); +``` + +On the server side, enable the WebSockets transport: + +```typescript +import { MCPServer } from "mcp-framework"; + +const server = new MCPServer({ + transport: { + type: "websocket", + options: { + port: 8080, + path: "/ws" // default or custom WebSocket path + } + } +}); + +await server.start(); +``` + +This setup allows the client and server to communicate efficiently over WebSockets. + ``` ### HTTP Stream Transport diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..ed63c49 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('jest').Config} */ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { useESM: true }], + }, + transformIgnorePatterns: [ + '/node_modules/(?!(\\@modelcontextprotocol/sdk)/)', + ], + moduleFileExtensions: ['ts', 'js', 'json'], +}; diff --git a/package-lock.json b/package-lock.json index 0b54df2..2279cc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,18 @@ "name": "mcp-framework", "version": "0.2.11", "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@types/prompts": "^2.4.9", "commander": "^12.1.0", "content-type": "^1.0.5", + "dotenv": "^16.4.7", "execa": "^9.5.2", "find-up": "^7.0.0", "jsonwebtoken": "^9.0.2", "prompts": "^2.4.2", "raw-body": "^2.5.2", "typescript": "^5.3.3", + "ws": "^8.18.1", "zod": "^3.23.8" }, "bin": { @@ -26,9 +29,10 @@ "devDependencies": { "@eslint/js": "^9.23.0", "@types/content-type": "^1.1.8", + "@types/express": "^5.0.1", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.8", - "@types/node": "^20.17.28", + "@types/node": "^20.17.30", "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", "eslint": "^9.23.0", @@ -44,7 +48,7 @@ "node": ">=18.19.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "1.8" + "@modelcontextprotocol/sdk": "1.11" } }, "node_modules/@ampproject/remapping": { @@ -60,6 +64,36 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.86", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.86.tgz", + "integrity": "sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -1141,9 +1175,9 @@ } }, "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.11.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz", + "integrity": "sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==", "license": "MIT", "peer": true, "dependencies": { @@ -1153,7 +1187,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" @@ -1319,6 +1353,27 @@ "@babel/types": "^7.20.7" } }, + "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/content-type": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@types/content-type/-/content-type-1.1.8.tgz", @@ -1331,6 +1386,31 @@ "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "dev": true }, + "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/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1340,6 +1420,13 @@ "@types/node": "*" } }, + "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/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1390,6 +1477,13 @@ "@types/node": "*" } }, + "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", @@ -1397,14 +1491,24 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.17.28", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.28.tgz", - "integrity": "sha512-DHlH/fNL6Mho38jTy7/JT7sn2wnXI+wULR6PV4gy4VHLVvnrV/d3pHAMQHhc4gjdLmK2ZiPoMxzp6B3yRajLSQ==", + "version": "20.17.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", + "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/prompts": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", @@ -1414,6 +1518,43 @@ "kleur": "^3.0.3" } }, + "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/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/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -1655,6 +1796,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1690,6 +1843,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1773,6 +1938,12 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2054,7 +2225,6 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -2212,6 +2382,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2366,6 +2548,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2392,12 +2583,23 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -2485,7 +2687,6 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -2495,7 +2696,6 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -2505,7 +2705,6 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -2513,6 +2712,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2841,6 +3055,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventsource": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.5.tgz", @@ -3201,6 +3424,61 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3272,7 +3550,6 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -3306,7 +3583,6 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", - "peer": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -3380,7 +3656,6 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -3414,7 +3689,21 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -3462,6 +3751,15 @@ "node": ">=18.18.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4696,7 +4994,6 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -4827,6 +5124,45 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5144,9 +5480,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", "peer": true, "engines": { @@ -5915,6 +6251,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6181,6 +6523,31 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6239,6 +6606,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 26f2d1d..3802418 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "scripts": { "build": "tsc", "watch": "tsc --watch", + "test": "jest", "lint": "eslint", "lint:fix": "eslint --fix", "format": "prettier --write \"src/**/*.ts\"" @@ -42,26 +43,30 @@ "protocol" ], "peerDependencies": { - "@modelcontextprotocol/sdk": "1.8" + "@modelcontextprotocol/sdk": "1.11" }, "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", "@types/prompts": "^2.4.9", "commander": "^12.1.0", "content-type": "^1.0.5", + "dotenv": "^16.4.7", "execa": "^9.5.2", "find-up": "^7.0.0", "jsonwebtoken": "^9.0.2", "prompts": "^2.4.2", "raw-body": "^2.5.2", "typescript": "^5.3.3", + "ws": "^8.18.1", "zod": "^3.23.8" }, "devDependencies": { "@eslint/js": "^9.23.0", "@types/content-type": "^1.1.8", + "@types/express": "^5.0.1", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.8", - "@types/node": "^20.17.28", + "@types/node": "^20.17.30", "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", "eslint": "^9.23.0", diff --git a/quantgeekdev_mcp_config.example.json b/quantgeekdev_mcp_config.example.json new file mode 100644 index 0000000..e38b992 --- /dev/null +++ b/quantgeekdev_mcp_config.example.json @@ -0,0 +1,27 @@ +{ + "mcpServers": { + "openai-agent": { + "command": "node", + "args": [ + "dist/index.js" + ], + "env": { + "OPENAI_API_KEY": "YOUR_API_KEY_HERE", + "SUPABASE_URL": "https://your-supabase-project.supabase.co", + "SUPABASE_KEY": "YOUR_SUPABASE_KEY_HERE", + "LLM_DEBUG": "true", + "AGENT_LIFECYCLE": "true", + "TOOL_DEBUG": "true" + }, + "disabled": false, + "autoApprove": [ + "research", + "support", + "customer_support", + "database_query", + "handoff_to_agent", + "summarize" + ] + } + } + } \ No newline at end of file diff --git a/src/core/MCPClient.test.ts b/src/core/MCPClient.test.ts new file mode 100644 index 0000000..89933ad --- /dev/null +++ b/src/core/MCPClient.test.ts @@ -0,0 +1,404 @@ +import { MCPClient, MCPClientConfig } from './MCPClient'; +import { describe, test, expect, jest, beforeEach, afterEach, afterAll } from '@jest/globals'; +import { createInterface } from 'readline/promises'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; + +// Define mock types to help TypeScript +type MockClient = { + connect: jest.Mock; + listTools: jest.Mock; + callTool: jest.Mock; + close: jest.Mock; +}; + +// Use jest.mocked and type assertions for accessing mock properties +const mockClient = Client as unknown as jest.Mock; +const mockStdioTransport = StdioClientTransport as unknown as jest.Mock; +const mockSSETransport = SSEClientTransport as unknown as jest.Mock; +const mockWebSocketTransport = WebSocketClientTransport as unknown as jest.Mock; +const mockCreateInterface = createInterface as unknown as jest.Mock; + +// Mock dependencies +jest.mock('@modelcontextprotocol/sdk/client/index.js', () => { + const mockClient = { + connect: jest.fn(), + listTools: jest.fn().mockImplementation(() => Promise.resolve({ + tools: [ + { name: 'tool1', description: 'Tool 1 description', inputSchema: {} }, + { name: 'tool2', description: 'Tool 2 description', inputSchema: {} }, + ], + })), + callTool: jest.fn().mockImplementation(() => Promise.resolve({ result: 'success' })), + close: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + return { + Client: jest.fn().mockImplementation(() => mockClient), + }; +}); + +jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => { + return { + StdioClientTransport: jest.fn().mockImplementation(() => ({ + mockType: 'stdio', + })), + }; +}); + +jest.mock('@modelcontextprotocol/sdk/client/sse.js', () => { + return { + SSEClientTransport: jest.fn().mockImplementation(() => ({ + mockType: 'sse', + })), + }; +}); + +jest.mock('@modelcontextprotocol/sdk/client/websocket.js', () => { + return { + WebSocketClientTransport: jest.fn().mockImplementation(() => ({ + mockType: 'websocket', + })), + }; +}); + +// Mock readline module +jest.mock('readline/promises', () => { + const mockInterface = { + question: jest.fn().mockImplementation(() => Promise.resolve('')), + close: jest.fn(), + prompt: jest.fn(), // Added prompt mock + }; + + // Set up the mock responses + mockInterface.question + .mockImplementationOnce(() => Promise.resolve('test command')) + .mockImplementationOnce(() => Promise.resolve('quit')); + + return { + createInterface: jest.fn().mockImplementation(() => mockInterface), + }; +}); + +// Store original platform and mock it for tests +const originalPlatform = process.platform; +const mockPlatform = jest.fn(); +Object.defineProperty(process, 'platform', { + get: () => mockPlatform(), +}); + +// Mock console.log to avoid cluttering test output +const originalConsoleLog = console.log; +beforeEach(() => { + console.log = jest.fn(); +}); + +afterEach(() => { + console.log = originalConsoleLog; + jest.clearAllMocks(); +}); + +// Restore original platform after all tests +afterAll(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); +}); + +describe('MCPClient', () => { + // 1. Constructor tests + describe('constructor', () => { + test('should initialize with default properties', () => { + const client = new MCPClient(); + expect(client).toBeDefined(); + // Check private properties using any type assertion + const clientAny = client as any; + expect(clientAny.mcp).toBeDefined(); + expect(clientAny.transport).toBeNull(); + expect(clientAny.tools).toEqual([]); + }); + }); + + // 2. Connection tests for different transport types + describe('connect', () => { + test('should connect using stdio transport with JS script', async () => { + const client = new MCPClient(); + await client.connect({ + transport: 'stdio', + serverScriptPath: 'server.js', + }); + + // Verify StdioClientTransport was created with correct parameters + expect(mockStdioTransport).toHaveBeenCalledWith({ + command: process.execPath, + args: ['server.js'], + }); + + // Verify Client.connect was called with the transport + const mockClientInstance = mockClient.mock.results[0].value as MockClient; + expect(mockClientInstance.connect).toHaveBeenCalled(); + + // Verify tools were fetched and stored + expect(mockClientInstance.listTools).toHaveBeenCalled(); + expect(client.getTools()).toHaveLength(2); + expect(client.getTools()[0].name).toBe('tool1'); + }); + + test('should connect using stdio transport with Python script on non-Windows', async () => { + // Mock platform as Linux + mockPlatform.mockReturnValue('linux'); + + const client = new MCPClient(); + await client.connect({ + transport: 'stdio', + serverScriptPath: 'server.py', + }); + + // Verify StdioClientTransport was created with correct parameters + expect(mockStdioTransport).toHaveBeenCalledWith({ + command: 'python3', + args: ['server.py'], + }); + }); + + test('should connect using stdio transport with Python script on Windows', async () => { + // Mock platform as Windows + mockPlatform.mockReturnValue('win32'); + + const client = new MCPClient(); + await client.connect({ + transport: 'stdio', + serverScriptPath: 'server.py', + }); + + // Verify StdioClientTransport was created with correct parameters + expect(mockStdioTransport).toHaveBeenCalledWith({ + command: 'python', + args: ['server.py'], + }); + }); + + test('should throw error for unsupported script type', async () => { + const client = new MCPClient(); + await expect( + client.connect({ + transport: 'stdio', + serverScriptPath: 'server.txt', + }) + ).rejects.toThrow('Server script must be a .js or .py file'); + }); + + test('should connect using SSE transport', async () => { + const client = new MCPClient(); + await client.connect({ + transport: 'sse', + url: 'http://localhost:3000', + }); + + // Verify SSEClientTransport was created with correct parameters + expect(mockSSETransport).toHaveBeenCalledTimes(1); + const [urlArg, optsUnknown] = mockSSETransport.mock.calls[0] as [URL, any]; + const optionsArg = optsUnknown as any; + expect(urlArg).toBeInstanceOf(URL); + expect((urlArg as URL).href).toBe('http://localhost:3000/'); + expect(optionsArg).toBeUndefined(); + }); + + test('should connect using SSE transport with custom headers', async () => { + // Clear previous mock call data to make indexing predictable + mockSSETransport.mockClear(); + + const client = new MCPClient(); + const headers = { + 'X-Test': 'foo', + Authorization: 'Bearer bar', + }; + + await client.connect({ + transport: 'sse', + url: 'http://localhost:3000/', + headers, + }); + + // Expect the transport constructor to be invoked once + expect(mockSSETransport).toHaveBeenCalledTimes(1); + + const [urlArg, optsUnknown] = mockSSETransport.mock.calls[0] as [URL, any]; + const optionsArg = optsUnknown as any; + expect(urlArg).toBeInstanceOf(URL); + expect((urlArg as URL).href).toBe('http://localhost:3000/'); + + // The options argument should include the forwarded headers in requestInit + expect(optionsArg).toBeDefined(); + expect(optionsArg.requestInit).toBeDefined(); + expect(optionsArg.requestInit.headers).toEqual(headers); + + // eventSourceInit.fetch should attach the same headers plus Accept header + if (optionsArg.eventSourceInit?.fetch) { + // simulate the custom fetch to verify headers merge + const dummyInit: RequestInit = { headers: { Existing: 'true' } }; + // We cannot actually execute fetch here; instead, verify wrapper behaviour + const wrappedFetch = optionsArg.eventSourceInit.fetch as ( + url: URL | RequestInfo, + init?: RequestInit, + ) => Promise; + const mergedInitPromise = wrappedFetch(new URL('http://dummy'), dummyInit); + // Ensure it returns a Promise (we don't await real network) + expect(mergedInitPromise).toBeTruthy(); // Ensure it's not null/undefined + expect(typeof mergedInitPromise.then).toBe('function'); // Check if it's thenable + } + }); + + test('should connect using WebSocket transport', async () => { + const client = new MCPClient(); + await client.connect({ + transport: 'websocket', + url: 'ws://localhost:3000', + }); + + // Verify WebSocketClientTransport was created with correct parameters + expect(mockWebSocketTransport).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'ws://localhost:3000/', + }) + ); + }); + + test('should throw error for unsupported transport type', async () => { + const client = new MCPClient(); + await expect( + client.connect({ + // @ts-expect-error - Testing invalid type + transport: 'invalid', + url: 'http://example.com' + }) + ).rejects.toThrow('Unsupported transport type: invalid'); + }); + + test('should handle connection errors', async () => { + // Create a new MCPClient instance first to ensure the Client mock is initialized + new MCPClient(); + + // Now we can safely access the mock results + const mockClientInstance = mockClient.mock.results[0].value as MockClient; + mockClientInstance.connect.mockImplementationOnce(() => { + throw new Error('Connection failed'); + }); + + const client = new MCPClient(); + await expect( + client.connect({ + transport: 'sse', + url: 'http://localhost:3000', + }) + ).rejects.toThrow('Connection failed'); + }); + }); + + // 3. Tool management tests + describe('tool management', () => { + test('should return tools after connection', async () => { + const client = new MCPClient(); + await client.connect({ + transport: 'stdio', + serverScriptPath: 'server.js', + }); + + const tools = client.getTools(); + expect(tools).toHaveLength(2); + expect(tools[0]).toEqual({ + name: 'tool1', + description: 'Tool 1 description', + input_schema: {}, + }); + expect(tools[1]).toEqual({ + name: 'tool2', + description: 'Tool 2 description', + input_schema: {}, + }); + }); + + test('should call tool with arguments', async () => { + const client = new MCPClient(); + await client.connect({ + transport: 'stdio', + serverScriptPath: 'server.js', + }); + + const mockClientInstance = mockClient.mock.results[0].value as MockClient; + + const result = await client.callTool('tool1', { param: 'value' }); + + expect(mockClientInstance.callTool).toHaveBeenCalledWith({ + name: 'tool1', + arguments: { param: 'value' }, + }); + expect(result).toEqual({ result: 'success' }); + }); + }); + + // 4. Cleanup tests + describe('cleanup', () => { + test('should close the client connection', async () => { + const client = new MCPClient(); + await client.connect({ + transport: 'stdio', + serverScriptPath: 'server.js', + }); + + const mockClientInstance = mockClient.mock.results[0].value as MockClient; + + await client.cleanup(); + + expect(mockClientInstance.close).toHaveBeenCalled(); + }); + }); + + // 5. Chat loop tests + describe('chatLoop', () => { + test('should handle commands until quit', async () => { + const mockNext = jest.fn() + .mockReturnValueOnce(Promise.resolve({ value: 'test command', done: false })) + .mockReturnValueOnce(Promise.resolve({ value: 'quit', done: false })) + .mockReturnValueOnce(Promise.resolve({ value: undefined, done: true })); + + const mockAsyncIterator = jest.fn(() => ({ + next: mockNext, + })); + + const mockReadlineInstance = { + question: jest.fn(), + close: jest.fn(), + prompt: jest.fn(), + [Symbol.asyncIterator]: mockAsyncIterator, // Assign the mock function here + }; + + mockCreateInterface.mockReturnValue(mockReadlineInstance); + const client = new MCPClient(); + // Mock connect to avoid actual connection logic if not needed for chatLoop isolated test + client.connect = jest.fn<(config: MCPClientConfig) => Promise>().mockResolvedValue(undefined); + await client.chatLoop(); + + // Verify readline was created and used + expect(mockCreateInterface).toHaveBeenCalled(); + expect(mockAsyncIterator).toHaveBeenCalledTimes(1); // The async iterator factory was called once + expect(mockNext).toHaveBeenCalledTimes(2); // 'test command', 'quit'. The loop exits before {done: true} is strictly needed by for...of. + expect(mockReadlineInstance.close).toHaveBeenCalled(); + }); + }); + + // 6. CLI argument parsing tests + describe('CLI argument parsing', () => { + // Since the main function is not exported, we'll test the argument parsing logic indirectly + // by mocking process.argv and requiring the module + + test('should parse stdio transport arguments correctly', () => { + // This is a more complex test that would require module mocking + // In a real implementation, we might refactor the code to make the parsing function testable + // For now, we'll just verify the basic structure is in place + expect(true).toBe(true); + }); + }); +}); diff --git a/src/core/MCPClient.ts b/src/core/MCPClient.ts new file mode 100644 index 0000000..7e5d710 --- /dev/null +++ b/src/core/MCPClient.ts @@ -0,0 +1,343 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import readline from 'readline/promises'; + +/** + * Supported MCPClient configuration types. + */ +type MCPClientConfig = + | { + transport: 'stdio'; + serverScriptPath: string; + } + | { + transport: 'sse'; + url: string; + headers?: Record; + } + | { + transport: 'websocket'; + url: string; + // WebSocket transport in the SDK might not directly support custom headers in constructor + } + | { + transport: 'http-stream'; + url: string; + headers?: Record; + }; + +/** + * MCPClient supports connecting to an MCP server over multiple transports: + * - stdio (spawns a subprocess) + * - SSE (connects to a remote HTTP SSE endpoint) + * - WebSocket (connects to a remote WebSocket endpoint) + * - HTTP Stream (connects to a remote HTTP streaming POST endpoint) + */ +class MCPClient { + private mcp: Client; + private transport: any = null; + private tools: Array<{ name: string; description: string; input_schema: any }> = []; // Typed tools array + + constructor() { + this.mcp = new Client({ name: 'mcp-client-cli', version: '1.0.0' }); + } + + /** + * Connect to an MCP server using the specified transport configuration. + */ + async connect(config: MCPClientConfig) { + if (config.transport === 'stdio') { + const isJs = config.serverScriptPath.endsWith('.js'); + const isPy = config.serverScriptPath.endsWith('.py'); + if (!isJs && !isPy) { + throw new Error('Server script must be a .js or .py file'); + } + const command = isPy + ? process.platform === 'win32' + ? 'python' + : 'python3' + : process.execPath; + + this.transport = new StdioClientTransport({ + command, + args: [config.serverScriptPath], + }); + } else if (config.transport === 'sse') { + this.transport = new SSEClientTransport( + new URL(config.url), + config.headers + ? { + eventSourceInit: { + fetch: (u, init) => + fetch(u, { + ...init, + headers: { + ...(init?.headers || {}), + ...config.headers, + Accept: 'text/event-stream', + }, + }), + }, + // requestInit might be used by some SDK versions for initial handshake if any, + // but primary header injection for SSE is via eventSourceInit.fetch override. + requestInit: { headers: config.headers }, + } + : undefined + ); + } else if (config.transport === 'websocket') { + // WebSocket constructor in @modelcontextprotocol/sdk typically doesn't take headers. + // Headers are usually set during the WebSocket handshake by the browser/client environment, + // or might require a custom transport if server-side node client needs them for ws library. + this.transport = new WebSocketClientTransport(new URL(config.url)); + } else if (config.transport === 'http-stream') { + this.transport = new StreamableHTTPClientTransport( + new URL(config.url), + config.headers ? { requestInit: { headers: config.headers } } : undefined + ); + } else { + throw new Error(`Unsupported transport type: ${(config as any).transport}`); + } + + this.mcp.connect(this.transport); + + const toolsResult = await this.mcp.listTools(); + this.tools = toolsResult.tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? '', // Ensure description is always a string + input_schema: tool.inputSchema, + })); + console.log(`Successfully connected to server. Found ${this.tools.length} tools.`); + } + + async callTool(toolName: string, toolArgs: any) { + return await this.mcp.callTool({ + name: toolName, + arguments: toolArgs, + }); + } + + getTools() { + return this.tools; + } + + async chatLoop() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: 'mcp> ', + }); + + console.log('\nMCP Client REPL. Type "help" for commands, "quit" or "exit" to exit.'); + rl.prompt(); + + try { + for await (const line of rl) { + const [cmd, ...rest] = line.trim().split(/\s+/); + + switch (cmd?.toLowerCase()) { + case 'quit': + case 'exit': + rl.close(); + return; + case 'help': + console.log(` +Available commands: + help - Show this help message. + tools - List available tools from the connected server. + call [jsonArgs] - Call a tool with JSON arguments. + Example: call MyTool {"param1":"value1"} + Example: call NoArgTool + quit / exit - Exit the REPL.`); + break; + case 'tools': { + const tools = this.getTools(); + if (tools.length > 0) { + console.log('Available tools:'); + console.table(tools.map((t) => ({ Name: t.name, Description: t.description }))); + } else { + console.log('No tools available or not connected.'); + } + break; + } + case 'call': { + const [toolName, ...jsonPieces] = rest; + if (!toolName) { + console.error('Error: toolName is required. Usage: call [jsonArgs]'); + break; + } + try { + const argsString = jsonPieces.join(' '); + // Allow empty argsString for tools that take no arguments + const toolArgs = argsString ? JSON.parse(argsString) : {}; + console.log(`Calling tool "${toolName}" with args:`, toolArgs); + const result = await this.callTool(toolName, toolArgs); + console.log('Tool result:'); + console.dir(result, { depth: null, colors: true }); + } catch (err: any) { + console.error(`Error calling tool "${toolName}":`, err.message || err); + if (err instanceof SyntaxError) { + console.error( + 'Hint: Ensure your JSON arguments are correctly formatted, e.g., {"key": "value"}.' + ); + } + } + break; + } + case '': // Handle empty input from just pressing Enter + break; + default: { + if (cmd) { + // Only show unknown if cmd is not empty + console.log(`Unknown command: "${cmd}". Type "help" for available commands.`); + } + } + } + rl.prompt(); + } + } catch (error) { + console.error('An unexpected error occurred in the REPL:', error); + } finally { + if (!rl.close) { + rl.close(); + } + } + } + + async cleanup() { + console.log('\nCleaning up and disconnecting...'); + await this.mcp.close(); + console.log('Disconnected.'); + } +} + +async function main() { + const args = process.argv.slice(2); + const argMap: Record = {}; // Allow boolean for flags like --help + const headers: Record = {}; + + function printUsageAndExit(exitCode = 1) { + console.log(` +Usage: mcp-client --transport [options] + +Transports and their specific options: + --transport stdio --script + Connects to a local MCP server script via standard input/output. + + --transport sse --url + Connects to an MCP server via Server-Sent Events (SSE). + + --transport websocket --url + Connects to an MCP server via WebSockets. + + --transport http-stream --url + Connects to an MCP server via HTTP Streaming. + +Optional flags (for sse and http-stream transports): + --header + Adds an HTTP header to the request. Can be specified multiple times. + Example: --header X-Auth-Token=mysecret --header Trace=1 + +General options: + --help + Show this usage information. +`); + process.exit(exitCode); + } + + for (let i = 0; i < args.length; i++) { + const currentArg = args[i]; + if (currentArg === '--help') { + printUsageAndExit(0); + } else if (currentArg === '--header') { + i++; // Move to the value part of --header + const pair = args[i] ?? ''; + const [k, v] = pair.split('='); + if (!k || v === undefined) { + console.error( + 'Error: Header syntax must be key=value (e.g., --header X-Auth-Token=secret)' + ); + printUsageAndExit(); + } + headers[k] = v; + } else if (currentArg.startsWith('--')) { + const key = currentArg.substring(2); + // Check if next arg is a value or another flag + if (args[i + 1] && !args[i + 1].startsWith('--')) { + argMap[key] = args[i + 1]; + i++; // Skip next arg as it's a value + } else { + argMap[key] = true; // Treat as a boolean flag if no value follows + } + } else { + // Positional arguments not expected here, or handle them if your CLI design changes + console.error(`Error: Unexpected argument '${currentArg}'`); + printUsageAndExit(); + } + } + + const transport = argMap['transport'] as string | undefined; + const script = argMap['script'] as string | undefined; + const url = argMap['url'] as string | undefined; + + if (!transport || !['stdio', 'sse', 'websocket', 'http-stream'].includes(transport)) { + console.error('Error: Missing or invalid --transport specified.'); + printUsageAndExit(); + } + + if (transport === 'stdio' && !script) { + console.error('Error: --script is required for stdio transport.'); + printUsageAndExit(); + } + + if ((transport === 'sse' || transport === 'websocket' || transport === 'http-stream') && !url) { + console.error('Error: --url is required for sse, websocket, or http-stream transport.'); + printUsageAndExit(); + } + + let config: MCPClientConfig; + const effectiveHeaders = Object.keys(headers).length > 0 ? headers : undefined; + + if (transport === 'stdio') { + config = { transport: 'stdio', serverScriptPath: script! }; + } else if (transport === 'sse') { + config = { transport: 'sse', url: url!, headers: effectiveHeaders }; + } else if (transport === 'websocket') { + // Note: WebSocket headers are typically not passed this way via constructor + config = { transport: 'websocket', url: url! }; + } else { + // http-stream + config = { transport: 'http-stream', url: url!, headers: effectiveHeaders }; + } + + const mcpClient = new MCPClient(); + try { + await mcpClient.connect(config); + await mcpClient.chatLoop(); + } catch (error: any) { + console.error(`\nFatal error during MCPClient operation: ${error.message || error}`); + // console.error(error.stack); // Uncomment for more detailed stack trace + } finally { + await mcpClient.cleanup(); + process.exit(0); // Ensure clean exit + } +} + +// Entry point if script is run directly +if ( + require.main === module || + (process.argv[1] && + (process.argv[1].endsWith('mcp-client') || + process.argv[1].endsWith('MCPClient.js') || + process.argv[1].endsWith('MCPClient.ts'))) +) { + main().catch((err) => { + // This catch is for unhandled promise rejections from main() itself, though inner try/catch should handle most. + console.error('Unhandled error in main execution:', err); + process.exit(1); + }); +} + +export { MCPClient, MCPClientConfig }; diff --git a/src/index.ts b/src/index.ts index 1ab523c..48e0a6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from "./core/MCPServer.js"; +export * from "./core/MCPClient.js"; export * from "./core/Logger.js"; export * from "./tools/BaseTool.js"; @@ -10,3 +11,6 @@ export * from "./auth/index.js"; export type { SSETransportConfig } from "./transports/sse/types.js"; export type { HttpStreamTransportConfig } from "./transports/http/types.js"; export { HttpStreamTransport } from "./transports/http/server.js"; +export { SSEServerTransport } from "./transports/sse/server.js"; +export { StdioServerTransport } from "./transports/stdio/server.js"; +export { WebSocketServerTransport } from "./transports/websockets/server.js"; diff --git a/src/transports/websockets/server.ts b/src/transports/websockets/server.ts new file mode 100644 index 0000000..6ff94f7 --- /dev/null +++ b/src/transports/websockets/server.ts @@ -0,0 +1,148 @@ +import { createServer, IncomingMessage, Server as HttpServer } from "node:http"; +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-ignore: no declaration for 'ws' +import WebSocket, { WebSocketServer } from "ws"; +import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { AbstractTransport } from "../base.js"; +import { logger } from "../../core/Logger.js"; + +interface WebSocketServerTransportConfig { + port?: number; + server?: HttpServer; + authProvider?: any; // Placeholder for future auth integration + headers?: Record; +} + +export class WebSocketServerTransport extends AbstractTransport { + readonly type = "websocket"; + + private _server?: HttpServer; + private _wss?: WebSocketServer; + private _clients: Set = new Set(); + private _config: WebSocketServerTransportConfig; + private _running = false; + + constructor(config: WebSocketServerTransportConfig = {}) { + super(); + this._config = config; + } + + async start(): Promise { + if (this._running) { + throw new Error("WebSocket transport already started"); + } + + return new Promise((resolve, reject) => { + try { + if (this._config.server) { + this._server = this._config.server; + } else { + this._server = createServer(); + } + + this._wss = new WebSocketServer({ noServer: true }); + + this._server.on("upgrade", (request: IncomingMessage, socket, head) => { + const protocols = request.headers["sec-websocket-protocol"]; + const protocolsArr = typeof protocols === "string" ? protocols.split(",").map(p => p.trim()) : []; + if (!protocolsArr.includes("mcp")) { + socket.write("HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Protocol: mcp\r\n\r\n"); + socket.destroy(); + return; + } + + this._wss!.handleUpgrade(request, socket, head, (ws: WebSocket) => { + this._wss!.emit("connection", ws, request); + }); + }); + + this._wss.on("connection", (ws: WebSocket, req: IncomingMessage) => { + logger.info("WebSocket client connected"); + this._clients.add(ws); + + ws.on("message", (data: WebSocket.RawData) => { + try { + const message = JSON.parse(data.toString()); + if (typeof message !== "object" || message === null) { + throw new Error("Invalid JSON-RPC message"); + } + this._onmessage?.(message as JSONRPCMessage); + } catch (err) { + logger.error(`WebSocket message parse error: ${err}`); + this._onerror?.(err as Error); + } + }); + + ws.on("close", () => { + logger.info("WebSocket client disconnected"); + this._clients.delete(ws); + }); + + ws.on("error", (err: Error) => { + logger.error(`WebSocket error: ${err}`); + this._onerror?.(err); + }); + }); + + this._server.listen(this._config.port ?? 0, () => { + const address = this._server!.address(); + logger.info(`WebSocket server listening on ${typeof address === "string" ? address : `port ${address?.port}`}`); + this._running = true; + resolve(); + }); + + this._server.on("error", (err) => { + logger.error(`WebSocket server error: ${err}`); + this._onerror?.(err); + }); + + this._server.on("close", () => { + logger.info("WebSocket server closed"); + this._running = false; + this._onclose?.(); + }); + } catch (err) { + reject(err); + } + }); + } + + async send(message: JSONRPCMessage): Promise { + const data = JSON.stringify(message); + for (const ws of this._clients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + } + } + + async close(): Promise { + for (const ws of this._clients) { + try { + ws.close(); + } catch { + // ignore errors during close + } + } + this._clients.clear(); + + if (this._wss) { + this._wss.removeAllListeners(); + } + + return new Promise((resolve) => { + if (this._server) { + this._server.close(() => { + this._running = false; + resolve(); + }); + } else { + resolve(); + } + }); + } + + isRunning(): boolean { + return this._running; + } +} diff --git a/src/transports/websockets/types.ts b/src/transports/websockets/types.ts new file mode 100644 index 0000000..78c0989 --- /dev/null +++ b/src/transports/websockets/types.ts @@ -0,0 +1,61 @@ +import { AuthConfig } from "../../auth/types.js"; +import type { Server as HTTPServer } from "http"; +import type { CORSConfig } from "../sse/types.js"; + +/** + * Configuration options for WebSocket server transport + */ +export interface WebSocketServerTransportConfig { + /** + * Port to listen on + * @default 8080 + */ + port?: number; + + /** + * WebSocket endpoint path + * @default "/ws" + */ + path?: string; + + /** + * Custom headers to add to WebSocket upgrade responses + */ + headers?: Record; + + /** + * Authentication configuration + */ + auth?: AuthConfig; + + /** + * CORS configuration + */ + cors?: CORSConfig; + + /** + * Existing HTTP server to attach to (optional) + */ + server?: HTTPServer; +} + +/** + * Internal WebSocket server config with required fields except headers/auth/cors/server optional + */ +export type WebSocketServerTransportConfigInternal = Required< + Omit +> & { + headers?: Record; + auth?: AuthConfig; + cors?: CORSConfig; + server?: HTTPServer; +}; + +/** + * Default WebSocket server transport configuration + */ +export const DEFAULT_WEBSOCKET_CONFIG: WebSocketServerTransportConfigInternal = { + port: 8080, + path: "/ws" +}; +