diff --git a/README.md b/README.md index 1a8a79d..031cc31 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,26 @@ -# mcp-framework +# MCP Framework -MCP is a framework for building Model Context Protocol (MCP) servers elegantly in TypeScript. +MCP-Framework is a framework for building Model Context Protocol (MCP) servers elegantly in TypeScript. MCP-Framework gives you architecture out of the box, with automatic directory-based discovery for tools, resources, and prompts. Use our powerful MCP abstractions to define tools, resources, or prompts in an elegant way. Our cli makes getting started with your own MCP server a breeze - -[Read the full docs here](https://mcp-framework.com) - -Get started fast with mcp-framework โšกโšกโšก - ## Features -- ๐Ÿ› ๏ธ Automatic directory-based discovery and loading for tools, prompts, and resources -- ๐Ÿ—๏ธ Powerful abstractions with full type safety -- ๐Ÿš€ Simple server setup and configuration -- ๐Ÿ“ฆ CLI for rapid development and project scaffolding +- Automatic discovery and loading of tools, resources, and prompts +- Multiple transport support (stdio, SSE) +- TypeScript-first development with full type safety +- Built on the official MCP SDK +- Easy-to-use base classes for tools, prompts, and resources +- Optional authentication for SSE endpoints -## Quick Start - -### Using the CLI (Recommended) - -```bash -# Install the framework globally -npm install -g mcp-framework - -# Create a new MCP server project -mcp create my-mcp-server - -# Navigate to your project -cd my-mcp-server - -# Your server is ready to use! -``` - -### Manual Installation +## Installation ```bash npm install mcp-framework ``` -## CLI Usage - -The framework provides a powerful CLI for managing your MCP server projects: - -### Project Creation - -```bash -# Create a new project -mcp create -``` - -### Adding a Tool - -```bash -# Add a new tool -mcp add tool price-fetcher -``` - -### Adding a Prompt - -```bash -# Add a new prompt -mcp add prompt price-analysis -``` - -### Adding a Resource - -```bash -# Add a new prompt -mcp add resource market-data -``` - -## Development Workflow - -1. Create your project: - -```bash - mcp create my-mcp-server - cd my-mcp-server -``` - -2. Add tools as needed: - - ```bash - mcp add tool data-fetcher - mcp add tool data-processor - mcp add tool report-generator - ``` - -3. Build: - - ```bash - npm run build - - ``` - -4. Add to MCP Client (Read below for Claude Desktop example) - -## Using with Claude Desktop - -### Local Development - -Add this configuration to your Claude Desktop config file: - -**MacOS**: \`~/Library/Application Support/Claude/claude_desktop_config.json\` -**Windows**: \`%APPDATA%/Claude/claude_desktop_config.json\` - -```json -{ -"mcpServers": { -"${projectName}": { - "command": "node", - "args":["/absolute/path/to/${projectName}/dist/index.js"] -} -} -} -``` - -### After Publishing - -Add this configuration to your Claude Desktop config file: - -**MacOS**: \`~/Library/Application Support/Claude/claude_desktop_config.json\` -**Windows**: \`%APPDATA%/Claude/claude_desktop_config.json\` - -```json -{ -"mcpServers": { -"${projectName}": { - "command": "npx", - "args": ["${projectName}"] -} -} -} -``` - -## Building and Testing - -1. Make changes to your tools -2. Run \`npm run build\` to compile -3. The server will automatically load your tools on startup - -## Components Overview - -### 1. Tools (Main Component) +## Quick Start -Tools are the primary way to extend an LLM's capabilities. Each tool should perform a specific function: +### Creating a Tool ```typescript import { MCPTool } from "mcp-framework"; @@ -173,149 +49,184 @@ class ExampleTool extends MCPTool { export default ExampleTool; ``` -### 2. Prompts (Optional) - -Prompts help structure conversations with Claude: +### Setting up the Server ```typescript -import { MCPPrompt } from "mcp-framework"; -import { z } from "zod"; - -interface GreetingInput { - name: string; - language?: string; -} - -class GreetingPrompt extends MCPPrompt { - name = "greeting"; - description = "Generate a greeting in different languages"; +import { MCPServer } from "mcp-framework"; - schema = { - name: { - type: z.string(), - description: "Name to greet", - required: true, - }, - language: { - type: z.string().optional(), - description: "Language for greeting", - required: false, - }, - }; +const server = new MCPServer(); - async generateMessages({ name, language = "English" }: GreetingInput) { - return [ - { - role: "user", - content: { - type: "text", - text: `Generate a greeting for ${name} in ${language}`, - }, - }, - ]; +// OR (mutually exclusive!) with SSE transport +const server = new MCPServer({ + transport: { + type: "sse", + options: { + port: 8080 // Optional (default: 8080) + } } -} +}); -export default GreetingPrompt; +// Start the server +await server.start(); ``` -### 3. Resources (Optional) - -Resources provide data access capabilities: +## Transport Configuration -```typescript -import { MCPResource, ResourceContent } from "mcp-framework"; - -class ConfigResource extends MCPResource { - uri = "config://app/settings"; - name = "Application Settings"; - description = "Current application configuration"; - mimeType = "application/json"; - - async read(): Promise { - const config = { - theme: "dark", - language: "en", - }; +### stdio Transport (Default) - return [ - { - uri: this.uri, - mimeType: this.mimeType, - text: JSON.stringify(config, null, 2), - }, - ]; - } -} +The stdio transport is used by default if no transport configuration is provided: -export default ConfigResource; +```typescript +const server = new MCPServer(); +// or explicitly: +const server = new MCPServer({ + transport: { type: "stdio" } +}); ``` -## Project Structure +### SSE Transport -``` -your-project/ -โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ tools/ # Tool implementations (Required) -โ”‚ โ”‚ โ””โ”€โ”€ ExampleTool.ts -โ”‚ โ”œโ”€โ”€ prompts/ # Prompt implementations (Optional) -โ”‚ โ”‚ โ””โ”€โ”€ GreetingPrompt.ts -โ”‚ โ”œโ”€โ”€ resources/ # Resource implementations (Optional) -โ”‚ โ”‚ โ””โ”€โ”€ ConfigResource.ts -โ”‚ โ””โ”€โ”€ index.ts -โ”œโ”€โ”€ package.json -โ””โ”€โ”€ tsconfig.json -``` +To use Server-Sent Events (SSE) transport: -## Automatic Feature Discovery +```typescript +const server = new MCPServer({ + transport: { + type: "sse", + options: { + port: 8080, // Optional (default: 8080) + endpoint: "/sse", // Optional (default: "/sse") + messageEndpoint: "/messages", // Optional (default: "/messages") + cors: { + allowOrigin: "*", // Optional (default: "*") + allowMethods: "GET, POST, OPTIONS", // Optional (default: "GET, POST, OPTIONS") + allowHeaders: "Content-Type, Authorization, x-api-key", // Optional (default: "Content-Type, Authorization, x-api-key") + exposeHeaders: "Content-Type, Authorization, x-api-key", // Optional (default: "Content-Type, Authorization, x-api-key") + maxAge: "86400" // Optional (default: "86400") + } + } + } +}); +``` -The framework automatically discovers and loads: +#### CORS Configuration -- Tools from the `src/tools` directory -- Prompts from the `src/prompts` directory (if present) -- Resources from the `src/resources` directory (if present) +The SSE transport supports flexible CORS configuration. By default, it uses permissive settings suitable for development. For production, you should configure CORS according to your security requirements: -Each feature should be in its own file and export a default class that extends the appropriate base class: +```typescript +const server = new MCPServer({ + transport: { + type: "sse", + options: { + // Restrict to specific origin + cors: { + allowOrigin: "https://myapp.com", + allowMethods: "GET, POST", + allowHeaders: "Content-Type, Authorization", + exposeHeaders: "Content-Type, Authorization", + maxAge: "3600" + } + } + } +}); + +// Or with multiple allowed origins +const server = new MCPServer({ + transport: { + type: "sse", + options: { + cors: { + allowOrigin: "https://app1.com, https://app2.com", + allowMethods: "GET, POST, OPTIONS", + allowHeaders: "Content-Type, Authorization, Custom-Header", + exposeHeaders: "Content-Type, Authorization", + maxAge: "86400" + } + } + } +}); +``` -- `MCPTool` for tools -- `MCPPrompt` for prompts -- `MCPResource` for resources +## Authentication -### Base Classes +MCP Framework provides optional authentication for SSE endpoints. You can choose between JWT and API Key authentication, or implement your own custom authentication provider. -#### MCPTool +### JWT Authentication -- Handles input validation using Zod -- Provides error handling and response formatting -- Includes fetch helper for HTTP requests +```typescript +import { MCPServer, JWTAuthProvider } from "mcp-framework"; +import { Algorithm } from "jsonwebtoken"; + +const server = new MCPServer({ + transport: { + type: "sse", + options: { + auth: { + provider: new JWTAuthProvider({ + secret: process.env.JWT_SECRET, + algorithms: ["HS256" as Algorithm], // Optional (default: ["HS256"]) + headerName: "Authorization" // Optional (default: "Authorization") + }), + endpoints: { + sse: true, // Protect SSE endpoint (default: false) + messages: true // Protect message endpoint (default: true) + } + } + } + } +}); +``` -#### MCPPrompt +Clients must include a valid JWT token in the Authorization header: +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIs... +``` -- Manages prompt arguments and validation -- Generates message sequences for LLM interactions -- Supports dynamic prompt templates +### API Key Authentication -#### MCPResource +```typescript +import { MCPServer, APIKeyAuthProvider } from "mcp-framework"; + +const server = new MCPServer({ + transport: { + type: "sse", + options: { + auth: { + provider: new APIKeyAuthProvider({ + keys: [process.env.API_KEY], + headerName: "X-API-Key" // Optional (default: "X-API-Key") + }) + } + } + } +}); +``` -- Exposes data through URI-based system -- Supports text and binary content -- Optional subscription capabilities for real-time updates +Clients must include a valid API key in the X-API-Key header: +``` +X-API-Key: your-api-key +``` -## Type Safety +### Custom Authentication -All features use Zod for runtime type validation and TypeScript for compile-time type checking. Define your input schemas using Zod types: +You can implement your own authentication provider by implementing the `AuthProvider` interface: ```typescript -schema = { - parameter: { - type: z.string().email(), - description: "User email address", - }, - count: { - type: z.number().min(1).max(100), - description: "Number of items", - }, -}; +import { AuthProvider, AuthResult } from "mcp-framework"; +import { IncomingMessage } from "node:http"; + +class CustomAuthProvider implements AuthProvider { + async authenticate(req: IncomingMessage): Promise { + // Implement your custom authentication logic + return true; + } + + getAuthError() { + return { + status: 401, + message: "Authentication failed" + }; + } +} ``` ## License diff --git a/package-lock.json b/package-lock.json index f295cad..eb6568f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "mcp-framework", - "version": "0.1.20", + "version": "0.1.21-beta.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcp-framework", - "version": "0.1.20", + "version": "0.1.21-beta.9", "dependencies": { "@types/prompts": "^2.4.9", "commander": "^12.1.0", "execa": "^9.5.2", "find-up": "^7.0.0", + "jsonwebtoken": "^9.0.2", "prompts": "^2.4.2", "typescript": "^5.3.3", "zod": "^3.23.8" @@ -22,7 +23,9 @@ }, "devDependencies": { "@modelcontextprotocol/sdk": "^0.6.0", + "@types/content-type": "^1.1.8", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.8", "@types/node": "^20.11.24", "jest": "^29.7.0", "ts-jest": "^29.1.2" @@ -1000,6 +1003,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/content-type": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@types/content-type/-/content-type-1.1.8.tgz", + "integrity": "sha512-1tBhmVUeso3+ahfyaKluXe38p+94lovUZdoVfQ3OnJo9uJC42JT7CBoN3k9HYhAae+GwiBYmHu+N9FZhOG+2Pg==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1043,6 +1052,22 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.8.tgz", + "integrity": "sha512-7fx54m60nLFUVYlxAB1xpe9CBWX2vSrk50Y6ogRJ1v5xxtba7qXTg5BgYDN5dq+yuQQ9HaVlHJyAAt1/mxryFg==", + "dev": true, + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true + }, "node_modules/@types/node": { "version": "20.17.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz", @@ -1339,6 +1364,11 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1616,6 +1646,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -1843,17 +1881,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-up/node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2944,6 +2971,57 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -2981,12 +3059,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3081,8 +3194,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -3137,6 +3249,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-run-path/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3529,6 +3652,25 @@ "node": ">=10" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3870,9 +4012,9 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "engines": { "node": ">=18" }, @@ -4058,9 +4200,9 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index e83a62f..892874b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "mcp-framework", - "version": "0.1.22", + "version": "0.1.23", + "description": "Framework for building Model Context Protocol (MCP) servers in Typescript", "type": "module", "author": "Alex Andru ", @@ -33,7 +34,11 @@ "anthropic", "ai", "framework", - "tools" + "tools", + "modelcontextprotocol", + "model", + "context", + "protocol" ], "peerDependencies": { "@modelcontextprotocol/sdk": "^0.6.0" @@ -43,13 +48,16 @@ "commander": "^12.1.0", "execa": "^9.5.2", "find-up": "^7.0.0", + "jsonwebtoken": "^9.0.2", "prompts": "^2.4.2", "typescript": "^5.3.3", "zod": "^3.23.8" }, "devDependencies": { "@modelcontextprotocol/sdk": "^0.6.0", + "@types/content-type": "^1.1.8", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.8", "@types/node": "^20.11.24", "jest": "^29.7.0", "ts-jest": "^29.1.2" diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..d489c42 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,7 @@ +export * from "./types.js"; +export * from "./providers/jwt.js"; +export * from "./providers/apikey.js"; + +export type { AuthProvider, AuthConfig, AuthResult } from "./types.js"; +export type { JWTConfig } from "./providers/jwt.js"; +export type { APIKeyConfig } from "./providers/apikey.js"; diff --git a/src/auth/providers/apikey.ts b/src/auth/providers/apikey.ts new file mode 100644 index 0000000..8a31902 --- /dev/null +++ b/src/auth/providers/apikey.ts @@ -0,0 +1,106 @@ +import { IncomingMessage } from "node:http"; +import { logger } from "../../core/Logger.js"; +import { AuthProvider, AuthResult, DEFAULT_AUTH_ERROR } from "../types.js"; + +export const DEFAULT_API_KEY_HEADER_NAME = "X-API-Key" +/** + * Configuration options for API key authentication + */ +export interface APIKeyConfig { + /** + * Valid API keys + */ + keys: string[]; + + /** + * Name of the header containing the API key + * @default "X-API-Key" + */ + headerName?: string; +} + +/** + * API key-based authentication provider + */ +export class APIKeyAuthProvider implements AuthProvider { + private config: Required; + + constructor(config: APIKeyConfig) { + this.config = { + headerName: DEFAULT_API_KEY_HEADER_NAME, + ...config + }; + + if (!this.config.keys?.length) { + throw new Error("At least one API key is required"); + } + } + + /** + * Get the number of configured API keys + */ + getKeyCount(): number { + return this.config.keys.length; + } + + /** + * Get the configured header name + */ + getHeaderName(): string { + return this.config.headerName; + } + + async authenticate(req: IncomingMessage): Promise { + logger.debug(`API Key auth attempt from ${req.socket.remoteAddress}`); + + logger.debug(`All request headers: ${JSON.stringify(req.headers, null, 2)}`); + + const headerVariations = [ + this.config.headerName, + this.config.headerName.toLowerCase(), + this.config.headerName.toUpperCase(), + 'x-api-key', + 'X-API-KEY', + 'X-Api-Key' + ]; + + logger.debug(`Looking for header variations: ${headerVariations.join(', ')}`); + + let apiKey: string | undefined; + let matchedHeader: string | undefined; + + for (const [key, value] of Object.entries(req.headers)) { + const lowerKey = key.toLowerCase(); + if (headerVariations.some(h => h.toLowerCase() === lowerKey)) { + apiKey = Array.isArray(value) ? value[0] : value; + matchedHeader = key; + break; + } + } + + if (!apiKey) { + logger.debug(`API Key header missing}`); + logger.debug(`Available headers: ${Object.keys(req.headers).join(', ')}`); + return false; + } + + logger.debug(`Found API key in header: ${matchedHeader}`); + + for (const validKey of this.config.keys) { + if (apiKey === validKey) { + logger.debug(`API Key authentication successful`); + return true; + } + } + + logger.debug(`Invalid API Key provided`); + return false; + } + + getAuthError() { + return { + ...DEFAULT_AUTH_ERROR, + message: "Invalid API key" + }; + } +} diff --git a/src/auth/providers/jwt.ts b/src/auth/providers/jwt.ts new file mode 100644 index 0000000..ecb8569 --- /dev/null +++ b/src/auth/providers/jwt.ts @@ -0,0 +1,86 @@ +import { IncomingMessage } from "node:http"; +import jwt, { Algorithm } from "jsonwebtoken"; +import { AuthProvider, AuthResult, DEFAULT_AUTH_ERROR } from "../types.js"; + +/** + * Configuration options for JWT authentication + */ +export interface JWTConfig { + /** + * Secret key for verifying JWT tokens + */ + secret: string; + + /** + * Allowed JWT algorithms + * @default ["HS256"] + */ + algorithms?: Algorithm[]; + + /** + * Name of the header containing the JWT token + * @default "Authorization" + */ + headerName?: string; + + /** + * Whether to require "Bearer" prefix in Authorization header + * @default true + */ + requireBearer?: boolean; +} + +/** + * JWT-based authentication provider + */ +export class JWTAuthProvider implements AuthProvider { + private config: Required; + + constructor(config: JWTConfig) { + this.config = { + algorithms: ["HS256"], + headerName: "Authorization", + requireBearer: true, + ...config + }; + + if (!this.config.secret) { + throw new Error("JWT secret is required"); + } + } + + async authenticate(req: IncomingMessage): Promise { + const authHeader = req.headers[this.config.headerName.toLowerCase()]; + + if (!authHeader || typeof authHeader !== "string") { + return false; + } + + let token = authHeader; + if (this.config.requireBearer) { + if (!authHeader.startsWith("Bearer ")) { + return false; + } + token = authHeader.split(" ")[1]; + } + + try { + const decoded = jwt.verify(token, this.config.secret, { + algorithms: this.config.algorithms + }); + + return { + data: typeof decoded === "object" ? decoded : { sub: decoded } + }; + } catch (err) { + return false; + } + } + + getAuthError() { + return { + ...DEFAULT_AUTH_ERROR, + message: "Invalid or expired JWT token" + }; + } +} diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..111a907 --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,63 @@ +import { IncomingMessage } from "node:http"; + +/** + * Result of successful authentication + */ +export interface AuthResult { + /** + * User or token data from authentication + */ + data?: Record; +} + +/** + * Base interface for authentication providers + */ +export interface AuthProvider { + /** + * Authenticate an incoming request + * @param req The incoming HTTP request + * @returns Promise resolving to boolean or AuthResult + */ + authenticate(req: IncomingMessage): Promise; + + /** + * Get error details for failed authentication + */ + getAuthError?(): { status: number; message: string }; +} + +/** + * Authentication configuration for transport + */ +export interface AuthConfig { + /** + * Authentication provider implementation + */ + provider: AuthProvider; + + /** + * Per-endpoint authentication configuration + */ + endpoints?: { + /** + * Whether to authenticate SSE connection endpoint + * @default false + */ + sse?: boolean; + + /** + * Whether to authenticate message endpoint + * @default true + */ + messages?: boolean; + }; +} + +/** + * Default authentication error + */ +export const DEFAULT_AUTH_ERROR = { + status: 401, + message: "Unauthorized" +}; diff --git a/src/cli/index.ts b/src/cli/index.ts index 75f7b0e..0893aa5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,7 +11,7 @@ const program = new Command(); program .name("mcp") .description("CLI for managing MCP server projects") - .version("0.1.21"); + .version("0.1.23"); program .command("build") diff --git a/src/cli/project/create.ts b/src/cli/project/create.ts index 1b51191..338cea0 100644 --- a/src/cli/project/create.ts +++ b/src/cli/project/create.ts @@ -58,9 +58,10 @@ export async function createProject(name?: string) { build: "mcp-build", prepare: "npm run build", watch: "tsc --watch", + start: "node dist/index.js" }, dependencies: { - "mcp-framework": "^0.1.8", + "mcp-framework": "^0.1.23", }, devDependencies: { "@types/node": "^20.11.24", @@ -88,9 +89,7 @@ export async function createProject(name?: string) { const server = new MCPServer(); -server.start().catch((error) => { - console.error("Server error:", error); - process.exit(1); +server.start(); });`; const exampleToolTs = `import { MCPTool } from "mcp-framework"; diff --git a/src/core/MCPServer.ts b/src/core/MCPServer.ts index 57d2391..9f8c9d2 100644 --- a/src/core/MCPServer.ts +++ b/src/core/MCPServer.ts @@ -1,5 +1,5 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StdioServerTransport } from "../transports/stdio/server.js"; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -14,16 +14,29 @@ import { ToolProtocol } from "../tools/BaseTool.js"; import { PromptProtocol } from "../prompts/BasePrompt.js"; import { ResourceProtocol } from "../resources/BaseResource.js"; import { readFileSync } from "fs"; -import { join, dirname } from "path"; +import { join, dirname, resolve } from "path"; import { logger } from "./Logger.js"; import { ToolLoader } from "../loaders/toolLoader.js"; import { PromptLoader } from "../loaders/promptLoader.js"; import { ResourceLoader } from "../loaders/resourceLoader.js"; +import { BaseTransport } from "../transports/base.js"; +import { SSEServerTransport } from "../transports/sse/server.js"; +import { SSETransportConfig, DEFAULT_SSE_CONFIG } from "../transports/sse/types.js"; + +export type TransportType = "stdio" | "sse"; + +export interface TransportConfig { + type: TransportType; + options?: SSETransportConfig & { + auth?: SSETransportConfig['auth']; + }; +} export interface MCPServerConfig { name?: string; version?: string; basePath?: string; + transport?: TransportConfig; } export type ServerCapabilities = { @@ -42,7 +55,7 @@ export type ServerCapabilities = { }; export class MCPServer { - private server: Server; + private server!: Server; private toolsMap: Map = new Map(); private promptsMap: Map = new Map(); private resourcesMap: Map = new Map(); @@ -52,54 +65,86 @@ export class MCPServer { private serverName: string; private serverVersion: string; private basePath: string; + private transportConfig: TransportConfig; + private capabilities: ServerCapabilities = { + tools: { enabled: true } + }; + private isRunning: boolean = false; + private transport?: BaseTransport; + private shutdownPromise?: Promise; + private shutdownResolve?: () => void; constructor(config: MCPServerConfig = {}) { - this.basePath = this.resolveBasePath(config.basePath); + this.basePath = config.basePath + ? resolve(config.basePath) + : join(process.cwd(), 'dist'); + this.serverName = config.name ?? this.getDefaultName(); this.serverVersion = config.version ?? this.getDefaultVersion(); + this.transportConfig = config.transport ?? { type: "stdio" }; logger.info( `Initializing MCP Server: ${this.serverName}@${this.serverVersion}` ); - this.toolLoader = new ToolLoader(this.basePath); - this.promptLoader = new PromptLoader(this.basePath); - this.resourceLoader = new ResourceLoader(this.basePath); - - this.server = new Server( - { - name: this.serverName, - version: this.serverVersion, - }, - { - capabilities: { - tools: { enabled: true }, - prompts: { enabled: false }, - resources: { enabled: false }, - }, - } - ); + this.toolLoader = new ToolLoader(join(this.basePath, 'tools')); + this.promptLoader = new PromptLoader(join(this.basePath, 'prompts')); + this.resourceLoader = new ResourceLoader(join(this.basePath, 'resources')); - this.setupHandlers(); + logger.debug(`Looking for tools in: ${join(this.basePath, 'tools')}`); + logger.debug(`Looking for prompts in: ${join(this.basePath, 'prompts')}`); + logger.debug(`Looking for resources in: ${join(this.basePath, 'resources')}`); } - private resolveBasePath(configPath?: string): string { - if (configPath) { - return configPath; - } - if (process.argv[1]) { - return process.argv[1]; + private createTransport(): BaseTransport { + logger.debug(`Creating transport: ${this.transportConfig.type}`); + + let transport: BaseTransport; + switch (this.transportConfig.type) { + case "sse": { + const sseConfig = this.transportConfig.options + ? { ...DEFAULT_SSE_CONFIG, ...this.transportConfig.options } + : DEFAULT_SSE_CONFIG; + transport = new SSEServerTransport(sseConfig); + break; + } + case "stdio": + logger.info("Starting stdio transport"); + transport = new StdioServerTransport(); + break; + default: + throw new Error(`Unsupported transport type: ${this.transportConfig.type}`); } - return process.cwd(); + + transport.onclose = () => { + logger.info("Transport connection closed"); + this.stop().catch(error => { + logger.error(`Error during shutdown: ${error}`); + process.exit(1); + }); + }; + + transport.onerror = (error) => { + logger.error(`Transport error: ${error}`); + }; + + return transport; } private readPackageJson(): any { try { - const packagePath = join(dirname(this.basePath), "package.json"); - const packageContent = readFileSync(packagePath, "utf-8"); - const packageJson = JSON.parse(packageContent); - logger.debug(`Successfully read package.json from: ${packagePath}`); - return packageJson; + const projectRoot = process.cwd(); + const packagePath = join(projectRoot, "package.json"); + + try { + const packageContent = readFileSync(packagePath, "utf-8"); + const packageJson = JSON.parse(packageContent); + logger.debug(`Successfully read package.json from project root: ${packagePath}`); + return packageJson; + } catch (error) { + logger.warn(`Could not read package.json from project root: ${error}`); + return null; + } } catch (error) { logger.warn(`Could not read package.json: ${error}`); return null; @@ -112,6 +157,7 @@ export class MCPServer { logger.info(`Using name from package.json: ${packageJson.name}`); return packageJson.name; } + logger.error("Couldn't find project name in package json"); return "unnamed-mcp-server"; } @@ -125,141 +171,164 @@ export class MCPServer { } private setupHandlers() { - this.server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: Array.from(this.toolsMap.values()).map( - (tool) => tool.toolDefinition - ), + this.server.setRequestHandler(ListToolsRequestSchema, async (request) => { + logger.debug(`Received ListTools request: ${JSON.stringify(request)}`); + + const tools = Array.from(this.toolsMap.values()).map( + (tool) => tool.toolDefinition + ); + + logger.debug(`Found ${tools.length} tools to return`); + logger.debug(`Tool definitions: ${JSON.stringify(tools)}`); + + const response = { + tools: tools, + nextCursor: undefined }; + + logger.debug(`Sending ListTools response: ${JSON.stringify(response)}`); + return response; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + logger.debug(`Tool call request received for: ${request.params.name}`); + logger.debug(`Tool call arguments: ${JSON.stringify(request.params.arguments)}`); + const tool = this.toolsMap.get(request.params.name); if (!tool) { - throw new Error( - `Unknown tool: ${request.params.name}. Available tools: ${Array.from( - this.toolsMap.keys() - ).join(", ")}` - ); + const availableTools = Array.from(this.toolsMap.keys()); + const errorMsg = `Unknown tool: ${request.params.name}. Available tools: ${availableTools.join(", ")}`; + logger.error(errorMsg); + throw new Error(errorMsg); } - const toolRequest = { - params: request.params, - method: "tools/call" as const, - }; - - return tool.toolCall(toolRequest); - }); - - this.server.setRequestHandler(ListPromptsRequestSchema, async () => { - return { - prompts: Array.from(this.promptsMap.values()).map( - (prompt) => prompt.promptDefinition - ), - }; - }); + try { + logger.debug(`Executing tool: ${tool.name}`); + const toolRequest = { + params: request.params, + method: "tools/call" as const, + }; - this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { - const prompt = this.promptsMap.get(request.params.name); - if (!prompt) { - throw new Error( - `Unknown prompt: ${ - request.params.name - }. Available prompts: ${Array.from(this.promptsMap.keys()).join( - ", " - )}` - ); + const result = await tool.toolCall(toolRequest); + logger.debug(`Tool execution successful: ${JSON.stringify(result)}`); + return result; + } catch (error) { + const errorMsg = `Tool execution failed: ${error}`; + logger.error(errorMsg); + throw new Error(errorMsg); } - - return { - messages: await prompt.getMessages(request.params.arguments), - }; }); - this.server.setRequestHandler(ListResourcesRequestSchema, async () => { - return { - resources: Array.from(this.resourcesMap.values()).map( - (resource) => resource.resourceDefinition - ), - }; - }); + if (this.capabilities.prompts) { + this.server.setRequestHandler(ListPromptsRequestSchema, async () => { + return { + prompts: Array.from(this.promptsMap.values()).map( + (prompt) => prompt.promptDefinition + ), + }; + }); - this.server.setRequestHandler( - ReadResourceRequestSchema, - async (request) => { - const resource = this.resourcesMap.get(request.params.uri); - if (!resource) { + this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const prompt = this.promptsMap.get(request.params.name); + if (!prompt) { throw new Error( - `Unknown resource: ${ - request.params.uri - }. Available resources: ${Array.from(this.resourcesMap.keys()).join( + `Unknown prompt: ${ + request.params.name + }. Available prompts: ${Array.from(this.promptsMap.keys()).join( ", " )}` ); } return { - contents: await resource.read(), + messages: await prompt.getMessages(request.params.arguments), }; - } - ); - - this.server.setRequestHandler(SubscribeRequestSchema, async (request) => { - const resource = this.resourcesMap.get(request.params.uri); - if (!resource) { - throw new Error(`Unknown resource: ${request.params.uri}`); - } + }); + } - if (!resource.subscribe) { - throw new Error( - `Resource ${request.params.uri} does not support subscriptions` - ); - } + if (this.capabilities.resources) { + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: Array.from(this.resourcesMap.values()).map( + (resource) => resource.resourceDefinition + ), + }; + }); + + this.server.setRequestHandler( + ReadResourceRequestSchema, + async (request) => { + const resource = this.resourcesMap.get(request.params.uri); + if (!resource) { + throw new Error( + `Unknown resource: ${ + request.params.uri + }. Available resources: ${Array.from(this.resourcesMap.keys()).join( + ", " + )}` + ); + } + + return { + contents: await resource.read(), + }; + } + ); - await resource.subscribe(); - return {}; - }); + this.server.setRequestHandler(SubscribeRequestSchema, async (request) => { + const resource = this.resourcesMap.get(request.params.uri); + if (!resource) { + throw new Error(`Unknown resource: ${request.params.uri}`); + } - this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { - const resource = this.resourcesMap.get(request.params.uri); - if (!resource) { - throw new Error(`Unknown resource: ${request.params.uri}`); - } + if (!resource.subscribe) { + throw new Error( + `Resource ${request.params.uri} does not support subscriptions` + ); + } - if (!resource.unsubscribe) { - throw new Error( - `Resource ${request.params.uri} does not support subscriptions` - ); - } + await resource.subscribe(); + return {}; + }); - await resource.unsubscribe(); - return {}; - }); - } + this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + const resource = this.resourcesMap.get(request.params.uri); + if (!resource) { + throw new Error(`Unknown resource: ${request.params.uri}`); + } - private async detectCapabilities(): Promise { - const capabilities: ServerCapabilities = {}; + if (!resource.unsubscribe) { + throw new Error( + `Resource ${request.params.uri} does not support subscriptions` + ); + } - if (await this.toolLoader.hasTools()) { - capabilities.tools = { enabled: true }; - logger.debug("Tools capability enabled"); + await resource.unsubscribe(); + return {}; + }); } + } + private async detectCapabilities(): Promise { if (await this.promptLoader.hasPrompts()) { - capabilities.prompts = { enabled: true }; + this.capabilities.prompts = { enabled: true }; logger.debug("Prompts capability enabled"); } if (await this.resourceLoader.hasResources()) { - capabilities.resources = { enabled: true }; + this.capabilities.resources = { enabled: true }; logger.debug("Resources capability enabled"); } - return capabilities; + return this.capabilities; } async start() { try { + if (this.isRunning) { + throw new Error("Server is already running"); + } + const tools = await this.toolLoader.loadTools(); this.toolsMap = new Map( tools.map((tool: ToolProtocol) => [tool.name, tool]) @@ -277,10 +346,98 @@ export class MCPServer { await this.detectCapabilities(); - const transport = new StdioServerTransport(); - await this.server.connect(transport); + logger.debug("Creating MCP Server instance"); + this.server = new Server( + { + name: this.serverName, + version: this.serverVersion, + }, + { + capabilities: this.capabilities + } + ); + + logger.debug(`Server created with capabilities: ${JSON.stringify(this.capabilities)}`); + this.setupHandlers(); + + logger.info("Starting transport..."); + this.transport = this.createTransport(); + + const originalTransportSend = this.transport.send.bind(this.transport); + this.transport.send = async (message) => { + logger.debug(`Transport sending message: ${JSON.stringify(message)}`); + return originalTransportSend(message); + }; + + this.transport.onmessage = async (message: any) => { + logger.debug(`Transport received message: ${JSON.stringify(message)}`); + + try { + if (message.method === 'initialize') { + logger.debug('Processing initialize request'); + + await this.transport?.send({ + jsonrpc: "2.0" as const, + id: message.id, + result: { + protocolVersion: "2024-11-05", + capabilities: this.capabilities, + serverInfo: { + name: this.serverName, + version: this.serverVersion + } + } + }); + + await this.transport?.send({ + jsonrpc: "2.0" as const, + method: "server/ready", + params: {} + }); + + logger.debug('Initialization sequence completed'); + return; + } + + if (message.method === 'tools/list') { + logger.debug('Processing tools/list request'); + const tools = Array.from(this.toolsMap.values()).map( + (tool) => tool.toolDefinition + ); + + await this.transport?.send({ + jsonrpc: "2.0" as const, + id: message.id, + result: { + tools, + nextCursor: undefined + } + }); + return; + } + + logger.debug(`Unhandled message method: ${message.method}`); + } catch (error) { + logger.error(`Error handling message: ${error}`); + if ('id' in message) { + await this.transport?.send({ + jsonrpc: "2.0" as const, + id: message.id, + error: { + code: -32000, + message: String(error), + data: { type: "handler_error" } + } + }); + } + } + }; + + await this.server.connect(this.transport); + logger.info("Transport connected successfully"); logger.info(`Started ${this.serverName}@${this.serverVersion}`); + logger.info(`Transport: ${this.transportConfig.type}`); if (tools.length > 0) { logger.info( @@ -303,9 +460,48 @@ export class MCPServer { ).join(", ")}` ); } + + this.isRunning = true; + + process.on('SIGINT', () => { + logger.info('Shutting down...'); + this.stop().catch(error => { + logger.error(`Error during shutdown: ${error}`); + process.exit(1); + }); + }); + + this.shutdownPromise = new Promise((resolve) => { + this.shutdownResolve = resolve; + }); + + logger.info("Server running and ready for connections"); + await this.shutdownPromise; + } catch (error) { logger.error(`Server initialization error: ${error}`); throw error; } } + + async stop() { + if (!this.isRunning) { + return; + } + + try { + logger.info("Stopping server..."); + await this.transport?.close(); + await this.server?.close(); + this.isRunning = false; + logger.info('Server stopped'); + + this.shutdownResolve?.(); + + process.exit(0); + } catch (error) { + logger.error(`Error stopping server: ${error}`); + throw error; + } + } } diff --git a/src/index.ts b/src/index.ts index 1e3446e..1f67cd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,10 @@ -export { MCPServer, type MCPServerConfig } from "./core/MCPServer.js"; -export { - MCPTool, - type ToolProtocol, - type ToolInputSchema, - type ToolInput, -} from "./tools/BaseTool.js"; -export { - MCPPrompt, - type PromptProtocol, - type PromptArgumentSchema, - type PromptArguments, -} from "./prompts/BasePrompt.js"; -export { - MCPResource, - type ResourceProtocol, - type ResourceContent, - type ResourceDefinition, - type ResourceTemplateDefinition, -} from "./resources/BaseResource.js"; -export { ToolLoader } from "./loaders/toolLoader.js"; -export { PromptLoader } from "./loaders/promptLoader.js"; -export { ResourceLoader } from "./loaders/resourceLoader.js"; +export * from "./core/MCPServer.js"; +export * from "./core/Logger.js"; + +export * from "./tools/BaseTool.js"; +export * from "./resources/BaseResource.js"; +export * from "./prompts/BasePrompt.js"; + +export * from "./auth/index.js"; + +export type { SSETransportConfig } from "./transports/sse/types.js"; diff --git a/src/transports/base.ts b/src/transports/base.ts new file mode 100644 index 0000000..0ba5c1f --- /dev/null +++ b/src/transports/base.ts @@ -0,0 +1,45 @@ +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Base transport interface + */ +export interface BaseTransport extends Transport { + /** + * The type of transport (e.g., "stdio", "sse") + */ + readonly type: string; + + /** + * Returns whether the transport is currently running + */ + isRunning(): boolean; +} + +/** + * Abstract base class for transports that implements common functionality + */ +export abstract class AbstractTransport implements BaseTransport { + abstract readonly type: string; + + protected _onclose?: () => void; + protected _onerror?: (error: Error) => void; + protected _onmessage?: (message: JSONRPCMessage) => void; + + set onclose(handler: (() => void) | undefined) { + this._onclose = handler; + } + + set onerror(handler: ((error: Error) => void) | undefined) { + this._onerror = handler; + } + + set onmessage(handler: ((message: JSONRPCMessage) => void) | undefined) { + this._onmessage = handler; + } + + abstract start(): Promise; + abstract send(message: JSONRPCMessage): Promise; + abstract close(): Promise; + abstract isRunning(): boolean; +} diff --git a/src/transports/sse/server.ts b/src/transports/sse/server.ts new file mode 100644 index 0000000..c0c8397 --- /dev/null +++ b/src/transports/sse/server.ts @@ -0,0 +1,399 @@ +import { randomUUID } from "node:crypto" +import { IncomingMessage, Server as HttpServer, ServerResponse, createServer } from "node:http" +import { JSONRPCMessage, ClientRequest } from "@modelcontextprotocol/sdk/types.js" +import contentType from "content-type" +import getRawBody from "raw-body" +import { APIKeyAuthProvider } from "../../auth/providers/apikey.js" +import { DEFAULT_AUTH_ERROR } from "../../auth/types.js" +import { AbstractTransport } from "../base.js" +import { DEFAULT_SSE_CONFIG, SSETransportConfig, SSETransportConfigInternal, DEFAULT_CORS_CONFIG, CORSConfig } from "./types.js" +import { logger } from "../../core/Logger.js" +import { getRequestHeader, setResponseHeaders } from "../../utils/headers.js" + +interface ExtendedIncomingMessage extends IncomingMessage { + body?: ClientRequest +} + +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" +} + +export class SSEServerTransport extends AbstractTransport { + readonly type = "sse" + + private _server?: HttpServer + private _sseResponse?: ServerResponse + private _sessionId: string + private _config: SSETransportConfigInternal + private _keepAliveInterval?: NodeJS.Timeout + + constructor(config: SSETransportConfig = {}) { + super() + this._sessionId = randomUUID() + this._config = { + ...DEFAULT_SSE_CONFIG, + ...config + } + logger.debug(`SSE transport configured with: ${JSON.stringify({ + ...this._config, + auth: this._config.auth ? { + provider: this._config.auth.provider.constructor.name, + endpoints: this._config.auth.endpoints + } : undefined + })}`) + } + + private getCorsHeaders(includeMaxAge: boolean = false): Record { + // Ensure all CORS properties are present by merging with defaults + const corsConfig = { + allowOrigin: DEFAULT_CORS_CONFIG.allowOrigin, + allowMethods: DEFAULT_CORS_CONFIG.allowMethods, + allowHeaders: DEFAULT_CORS_CONFIG.allowHeaders, + exposeHeaders: DEFAULT_CORS_CONFIG.exposeHeaders, + maxAge: DEFAULT_CORS_CONFIG.maxAge, + ...this._config.cors + } as Required + + const headers: Record = { + "Access-Control-Allow-Origin": corsConfig.allowOrigin, + "Access-Control-Allow-Methods": corsConfig.allowMethods, + "Access-Control-Allow-Headers": corsConfig.allowHeaders, + "Access-Control-Expose-Headers": corsConfig.exposeHeaders + } + + if (includeMaxAge) { + headers["Access-Control-Max-Age"] = corsConfig.maxAge + } + + return headers + } + + async start(): Promise { + if (this._server) { + throw new Error("SSE transport already started") + } + + return new Promise((resolve) => { + this._server = createServer(async (req, res) => { + try { + await this.handleRequest(req, res) + } catch (error) { + logger.error(`Error handling request: ${error}`) + res.writeHead(500).end("Internal Server Error") + } + }) + + this._server.listen(this._config.port, () => { + logger.info(`SSE transport listening on port ${this._config.port}`) + resolve() + }) + + this._server.on("error", (error) => { + logger.error(`SSE server error: ${error}`) + this._onerror?.(error) + }) + + this._server.on("close", () => { + logger.info("SSE server closed") + this._onclose?.() + }) + }) + } + + private async handleRequest(req: ExtendedIncomingMessage, res: ServerResponse): Promise { + logger.debug(`Incoming request: ${req.method} ${req.url}`) + + if (req.method === "OPTIONS") { + setResponseHeaders(res, this.getCorsHeaders(true)) + res.writeHead(204).end() + return + } + + setResponseHeaders(res, this.getCorsHeaders()) + + const url = new URL(req.url!, `http://${req.headers.host}`) + const sessionId = url.searchParams.get("sessionId") + + if (req.method === "GET" && url.pathname === this._config.endpoint) { + if (this._config.auth?.endpoints?.sse) { + const isAuthenticated = await this.handleAuthentication(req, res, "SSE connection") + if (!isAuthenticated) return + } + + if (this._sseResponse?.writableEnded) { + this._sseResponse = undefined + } + + if (this._sseResponse) { + logger.warn("SSE connection already established") + res.writeHead(409).end("SSE connection already established") + return + } + + this.setupSSEConnection(res) + return + } + + if (req.method === "POST" && url.pathname === this._config.messageEndpoint) { + if (sessionId !== this._sessionId) { + logger.warn(`Invalid session ID received: ${sessionId}, expected: ${this._sessionId}`) + res.writeHead(403).end("Invalid session ID") + return + } + + if (this._config.auth?.endpoints?.messages !== false) { + const isAuthenticated = await this.handleAuthentication(req, res, "message") + if (!isAuthenticated) return + } + + await this.handlePostMessage(req, res) + return + } + + res.writeHead(404).end("Not Found") + } + + private async handleAuthentication(req: ExtendedIncomingMessage, res: ServerResponse, context: string): Promise { + if (!this._config.auth?.provider) { + return true + } + + const isApiKey = this._config.auth.provider instanceof APIKeyAuthProvider + if (isApiKey) { + const provider = this._config.auth.provider as APIKeyAuthProvider + const headerValue = getRequestHeader(req.headers, provider.getHeaderName()) + + if (!headerValue) { + const error = provider.getAuthError?.() || DEFAULT_AUTH_ERROR + res.setHeader("WWW-Authenticate", `ApiKey realm="MCP Server", header="${provider.getHeaderName()}"`) + res.writeHead(error.status).end(JSON.stringify({ + error: error.message, + status: error.status, + type: "authentication_error" + })) + return false + } + } + + const authResult = await this._config.auth.provider.authenticate(req) + if (!authResult) { + const error = this._config.auth.provider.getAuthError?.() || DEFAULT_AUTH_ERROR + logger.warn(`Authentication failed for ${context}:`) + logger.warn(`- Client IP: ${req.socket.remoteAddress}`) + logger.warn(`- Error: ${error.message}`) + + if (isApiKey) { + const provider = this._config.auth.provider as APIKeyAuthProvider + res.setHeader("WWW-Authenticate", `ApiKey realm="MCP Server", header="${provider.getHeaderName()}"`) + } + + res.writeHead(error.status).end(JSON.stringify({ + error: error.message, + status: error.status, + type: "authentication_error" + })) + return false + } + + logger.info(`Authentication successful for ${context}:`) + logger.info(`- Client IP: ${req.socket.remoteAddress}`) + logger.info(`- Auth Type: ${this._config.auth.provider.constructor.name}`) + return true + } + + private setupSSEConnection(res: ServerResponse): void { + logger.debug(`Setting up SSE connection for session: ${this._sessionId}`); + + const headers = { + ...SSE_HEADERS, + ...this.getCorsHeaders(), + ...this._config.headers + } + setResponseHeaders(res, headers) + logger.debug(`SSE headers set: ${JSON.stringify(headers)}`); + + if (res.socket) { + res.socket.setNoDelay(true) + res.socket.setTimeout(0) + res.socket.setKeepAlive(true, 1000) + logger.debug('Socket optimized for SSE connection'); + } + + const endpointUrl = `${this._config.messageEndpoint}?sessionId=${this._sessionId}`; + logger.debug(`Sending endpoint URL: ${endpointUrl}`); + res.write(`event: endpoint\ndata: ${endpointUrl}\n\n`); + + logger.debug('Sending initial keep-alive'); + res.write(": keep-alive\n\n"); + + this._keepAliveInterval = setInterval(() => { + if (this._sseResponse && !this._sseResponse.writableEnded) { + try { + logger.debug('Sending keep-alive ping'); + this._sseResponse.write(": keep-alive\n\n"); + + const pingMessage = { + jsonrpc: "2.0", + method: "ping", + params: { timestamp: Date.now() } + }; + this._sseResponse.write(`data: ${JSON.stringify(pingMessage)}\n\n`); + } catch (error) { + logger.error(`Error sending keep-alive: ${error}`); + this.cleanupConnection(); + } + } + }, 15000) + + this._sseResponse = res + + const cleanup = () => this.cleanupConnection() + + res.on("close", () => { + logger.info(`SSE connection closed for session: ${this._sessionId}`) + cleanup() + }) + + res.on("error", (error) => { + logger.error(`SSE connection error for session ${this._sessionId}: ${error}`) + this._onerror?.(error) + cleanup() + }) + + res.on("end", () => { + logger.info(`SSE connection ended for session: ${this._sessionId}`) + cleanup() + }) + + logger.info(`SSE connection established successfully for session: ${this._sessionId}`) + } + + private async handlePostMessage(req: ExtendedIncomingMessage, res: ServerResponse): Promise { + if (!this._sseResponse || this._sseResponse.writableEnded) { + logger.warn(`Rejecting message: no active SSE connection for session ${this._sessionId}`) + res.writeHead(409).end("SSE connection not established") + return + } + + let currentMessage: { id?: string | number; method?: string } = {} + + try { + const rawMessage = req.body || await (async () => { + const ct = contentType.parse(req.headers["content-type"] ?? "") + if (ct.type !== "application/json") { + throw new Error(`Unsupported content-type: ${ct.type}`) + } + const rawBody = await getRawBody(req, { + limit: this._config.maxMessageSize, + encoding: ct.parameters.charset ?? "utf-8" + }) + const parsed = JSON.parse(rawBody.toString()) + logger.debug(`Received message: ${JSON.stringify(parsed)}`) + return parsed + })() + + const { id, method, params } = rawMessage as any; + logger.debug(`Parsed message - ID: ${id}, Method: ${method}`); + + const rpcMessage: JSONRPCMessage = { + jsonrpc: "2.0", + id: id, + method: method, + params: params + }; + + currentMessage = { + id: id, + method: method + }; + + logger.debug(`Processing RPC message: ${JSON.stringify({ + id: id, + method: method, + params: params + })}`); + + if (!this._onmessage) { + throw new Error("No message handler registered") + } + + await this._onmessage(rpcMessage) + + res.writeHead(202).end("Accepted") + + logger.debug(`Successfully processed message ${rpcMessage.id}`) + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Error handling message for session ${this._sessionId}:`) + logger.error(`- Error: ${errorMessage}`) + logger.error(`- Method: ${currentMessage.method || "unknown"}`) + logger.error(`- Message ID: ${currentMessage.id || "unknown"}`) + + const errorResponse = { + jsonrpc: "2.0", + id: currentMessage.id || null, + error: { + code: -32000, + message: errorMessage, + data: { + method: currentMessage.method || "unknown", + sessionId: this._sessionId, + connectionActive: Boolean(this._sseResponse), + type: "message_handler_error" + } + } + } + + res.writeHead(400).end(JSON.stringify(errorResponse)) + this._onerror?.(error as Error) + } + } + + async send(message: JSONRPCMessage): Promise { + if (!this._sseResponse || this._sseResponse.writableEnded) { + throw new Error("SSE connection not established") + } + + this._sseResponse.write(`data: ${JSON.stringify(message)}\n\n`) + } + + async close(): Promise { + if (this._sseResponse && !this._sseResponse.writableEnded) { + this._sseResponse.end() + } + + this.cleanupConnection() + + return new Promise((resolve) => { + if (!this._server) { + resolve() + return + } + + this._server.close(() => { + logger.info("SSE server stopped") + this._server = undefined + this._onclose?.() + resolve() + }) + }) + } + + /** + * Clean up SSE connection resources + */ + private cleanupConnection(): void { + if (this._keepAliveInterval) { + clearInterval(this._keepAliveInterval) + this._keepAliveInterval = undefined + } + this._sseResponse = undefined + } + + isRunning(): boolean { + return Boolean(this._server) + } +} diff --git a/src/transports/sse/types.ts b/src/transports/sse/types.ts new file mode 100644 index 0000000..b1da8e3 --- /dev/null +++ b/src/transports/sse/types.ts @@ -0,0 +1,109 @@ +import { AuthConfig } from "../../auth/types.js"; + +/** + * CORS configuration options for SSE transport + */ +export interface CORSConfig { + /** + * Access-Control-Allow-Origin header + * @default "*" + */ + allowOrigin?: string; + + /** + * Access-Control-Allow-Methods header + * @default "GET, POST, OPTIONS" + */ + allowMethods?: string; + + /** + * Access-Control-Allow-Headers header + * @default "Content-Type, Authorization, x-api-key" + */ + allowHeaders?: string; + + /** + * Access-Control-Expose-Headers header + * @default "Content-Type, Authorization, x-api-key" + */ + exposeHeaders?: string; + + /** + * Access-Control-Max-Age header for preflight requests + * @default "86400" + */ + maxAge?: string; +} + +/** + * Configuration options for SSE transport + */ +export interface SSETransportConfig { + /** + * Port to listen on + */ + port?: number; + + /** + * Endpoint for SSE events stream + * @default "/sse" + */ + endpoint?: string; + + /** + * Endpoint for receiving messages via POST + * @default "/messages" + */ + messageEndpoint?: string; + + /** + * Maximum allowed message size in bytes + * @default "4mb" + */ + maxMessageSize?: string; + + /** + * Custom headers to add to SSE responses + */ + headers?: Record; + + /** + * CORS configuration + */ + cors?: CORSConfig; + + /** + * Authentication configuration + */ + auth?: AuthConfig; +} + +/** + * Internal configuration type with required fields except headers + */ +export type SSETransportConfigInternal = Required> & { + headers?: Record; + auth?: AuthConfig; + cors?: CORSConfig; +}; + +/** + * Default CORS configuration + */ +export const DEFAULT_CORS_CONFIG: CORSConfig = { + allowOrigin: "*", + allowMethods: "GET, POST, OPTIONS", + allowHeaders: "Content-Type, Authorization, x-api-key", + exposeHeaders: "Content-Type, Authorization, x-api-key", + maxAge: "86400" +}; + +/** + * Default configuration values + */ +export const DEFAULT_SSE_CONFIG: SSETransportConfigInternal = { + port: 8080, + endpoint: "/sse", + messageEndpoint: "/messages", + maxMessageSize: "4mb" +}; diff --git a/src/transports/stdio/server.ts b/src/transports/stdio/server.ts new file mode 100644 index 0000000..2d9e0d7 --- /dev/null +++ b/src/transports/stdio/server.ts @@ -0,0 +1,46 @@ +import { StdioServerTransport as SDKStdioTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { BaseTransport } from "../base.js"; +import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; + +/** + * StdioServerTransport that implements BaseTransport + */ +export class StdioServerTransport implements BaseTransport { + readonly type = "stdio"; + private transport: SDKStdioTransport; + private running: boolean = false; + + constructor() { + this.transport = new SDKStdioTransport(); + } + + async start(): Promise { + await this.transport.start(); + this.running = true; + } + + async send(message: JSONRPCMessage): Promise { + await this.transport.send(message); + } + + async close(): Promise { + await this.transport.close(); + this.running = false; + } + + isRunning(): boolean { + return this.running; + } + + set onclose(handler: (() => void) | undefined) { + this.transport.onclose = handler; + } + + set onerror(handler: ((error: Error) => void) | undefined) { + this.transport.onerror = handler; + } + + set onmessage(handler: ((message: JSONRPCMessage) => void) | undefined) { + this.transport.onmessage = handler; + } +} diff --git a/src/utils/headers.ts b/src/utils/headers.ts new file mode 100644 index 0000000..24bb432 --- /dev/null +++ b/src/utils/headers.ts @@ -0,0 +1,14 @@ +import { ServerResponse } from "node:http" + +export function getRequestHeader(headers: NodeJS.Dict, headerName: string): string | undefined { + const headerLower = headerName.toLowerCase() + return Object.entries(headers).find( + ([key]) => key.toLowerCase() === headerLower + )?.[1] as string | undefined +} + +export function setResponseHeaders(res: ServerResponse, headers: Record): void { + Object.entries(headers).forEach(([key, value]) => { + res.setHeader(key, value) + }) +}