├── .cursor └── rules │ └── mcp-framework.mdc ├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc ├── .release-please-config.json ├── .release-please-manifest.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── auth │ ├── index.ts │ ├── providers │ │ ├── apikey.ts │ │ └── jwt.ts │ └── types.ts ├── cli │ ├── framework │ │ ├── build-cli.ts │ │ └── build.ts │ ├── index.ts │ ├── project │ │ ├── add-prompt.ts │ │ ├── add-resource.ts │ │ ├── add-tool.ts │ │ └── create.ts │ ├── templates │ │ └── readme.ts │ └── utils │ │ ├── string-utils.ts │ │ └── validate-project.ts ├── core │ ├── Logger.ts │ └── MCPServer.ts ├── index.ts ├── loaders │ ├── promptLoader.ts │ ├── resourceLoader.ts │ └── toolLoader.ts ├── prompts │ └── BasePrompt.ts ├── resources │ └── BaseResource.ts ├── tools │ └── BaseTool.ts ├── transports │ ├── base.ts │ ├── http │ │ ├── server.ts │ │ └── types.ts │ ├── sse │ │ ├── server.ts │ │ └── types.ts │ ├── stdio │ │ └── server.ts │ └── utils │ │ ├── image-handler.ts │ │ └── ping-message.ts └── utils │ └── headers.ts └── tsconfig.json /.cursor/rules/mcp-framework.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | We are in the mcp-framework , a typescript framework that allows construction of model context protocol (mcp) servers. 7 | We are implementing the new specification: 8 | Overview 9 | MCP provides a standardized way for applications to: 10 | 11 | Share contextual information with language models 12 | Expose tools and capabilities to AI systems 13 | Build composable integrations and workflows 14 | The protocol uses JSON-RPC 2.0 messages to establish communication between: 15 | 16 | Hosts: LLM applications that initiate connections 17 | Clients: Connectors within the host application 18 | Servers: Services that provide context and capabilities 19 | MCP takes some inspiration from the Language Server Protocol, which standardizes how to add support for programming languages across a whole ecosystem of development tools. In a similar way, MCP standardizes how to integrate additional context and tools into the ecosystem of AI applications. 20 | 21 | Key Details 22 | Base Protocol 23 | JSON-RPC message format 24 | Stateful connections 25 | Server and client capability negotiation 26 | Features 27 | Servers offer any of the following features to clients: 28 | 29 | Resources: Context and data, for the user or the AI model to use 30 | Prompts: Templated messages and workflows for users 31 | Tools: Functions for the AI model to execute 32 | 33 | Here is an example of how a user creats an mcp server using mcp-framework as the library: ## src/tools/ExampleTool.ts 34 | 35 | ```ts 36 | import { MCPTool } from "mcp-framework"; 37 | import { z } from "zod"; 38 | 39 | interface ExampleInput { 40 | message: string; 41 | } 42 | 43 | class ExampleTool extends MCPTool { 44 | name = "example_tool"; 45 | description = "An example tool that processes messages"; 46 | 47 | schema = { 48 | message: { 49 | type: z.string(), 50 | description: "Message to process", 51 | }, 52 | }; 53 | 54 | async execute(input: ExampleInput) { 55 | return `Processed: ${input.message}`; 56 | } 57 | } 58 | 59 | export default ExampleTool; 60 | ``` 61 | 62 | ## src/index.ts 63 | 64 | ```ts 65 | import { MCPServer } from "mcp-framework"; 66 | 67 | const server = new MCPServer({transport:{ 68 | type:"http-stream", 69 | options:{ 70 | port:1337, 71 | cors: { 72 | allowOrigin:"*" 73 | } 74 | } 75 | }}); 76 | 77 | server.start(); 78 | 79 | ``` 80 | 81 | ## package.json 82 | 83 | ```json 84 | { 85 | "name": "http2-hi", 86 | "version": "0.0.1", 87 | "description": "http2-hi MCP server", 88 | "type": "module", 89 | "bin": { 90 | "http2-hi": "./dist/index.js" 91 | }, 92 | "files": [ 93 | "dist" 94 | ], 95 | "scripts": { 96 | "build": "tsc && mcp-build", 97 | "watch": "tsc --watch", 98 | "start": "node dist/index.js" 99 | }, 100 | "dependencies": { 101 | "mcp-framework": "^0.2.12-beta.4", 102 | "zod": "^3.24.4" 103 | }, 104 | "devDependencies": { 105 | "@types/node": "^20.11.24", 106 | "typescript": "^5.3.3" 107 | }, 108 | "engines": { 109 | "node": ">=18.19.0" 110 | } 111 | } 112 | 113 | ``` 114 | 115 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | release-please: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: googleapis/release-please-action@v4 18 | with: 19 | config-file: '.release-please-config.json' 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | README 2 | .env 3 | .vscode 4 | .idea 5 | .DS_Store 6 | dist 7 | node_modules 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "endOfLine": "auto" 8 | } 9 | -------------------------------------------------------------------------------- /.release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "node", 5 | "bump-minor-pre-major": true, 6 | "bump-patch-for-minor-pre-major": true, 7 | "changelog-path": "CHANGELOG.md", 8 | "pull-request-title-pattern": "chore: release ${version}" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.2.13" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.2.13](https://github.com/QuantGeekDev/mcp-framework/compare/mcp-framework-v0.2.12...mcp-framework-v0.2.13) (2025-05-23) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * properly support required/optional tool input schema ([1583603](https://github.com/QuantGeekDev/mcp-framework/commit/1583603b2613b8c7c57ebbd52fb5c8159d0b8d13)) 14 | 15 | ## [0.2.12](https://github.com/QuantGeekDev/mcp-framework/compare/mcp-framework-v0.2.11...mcp-framework-v0.2.12) (2025-05-23) 16 | 17 | 18 | ### Features 19 | 20 | * bump mcp ts sdk version ([d9cc845](https://github.com/QuantGeekDev/mcp-framework/commit/d9cc8450d39e89f36e39e78bb4d1946cf5f858d3)) 21 | * enhanced cursor rule with example ([d3b54d4](https://github.com/QuantGeekDev/mcp-framework/commit/d3b54d4619e669ad322484b074a82aae27d61ae9)) 22 | * replace custom implementation with sdk delegation ([1b5b8e7](https://github.com/QuantGeekDev/mcp-framework/commit/1b5b8e7cbe354056856a565b21b8908eb93ac2ba)) 23 | 24 | ## [0.2.11](https://github.com/QuantGeekDev/mcp-framework/compare/mcp-framework-v0.2.10...mcp-framework-v0.2.11) (2025-03-30) 25 | 26 | 27 | ### Features 28 | 29 | * implement resources/templates/list handler ([ff73ff0](https://github.com/QuantGeekDev/mcp-framework/commit/ff73ff084860c12a0aae15757c39ab6eeef5a543)) 30 | * implement resources/templates/list handler ([0dabfc0](https://github.com/QuantGeekDev/mcp-framework/commit/0dabfc04370535ecbe9d31f4d2b54ac876032b93)) 31 | 32 | ## [0.2.10](https://github.com/QuantGeekDev/mcp-framework/compare/mcp-framework-v0.2.9...mcp-framework-v0.2.10) (2025-03-30) 33 | 34 | 35 | ### Features 36 | 37 | * add optional skip install param ([d77e6e9](https://github.com/QuantGeekDev/mcp-framework/commit/d77e6e9df5a6d989dc7fbfa25b2cbe3b56e50260)) 38 | * add optional skip install param ([318dbc7](https://github.com/QuantGeekDev/mcp-framework/commit/318dbc798e673678c38a468e6a898e9834cdfa7d)) 39 | * add skip example option to cli ([df733f9](https://github.com/QuantGeekDev/mcp-framework/commit/df733f999e9837012220a7696d993ef734eee393)) 40 | * add skip example option to cli ([c02809f](https://github.com/QuantGeekDev/mcp-framework/commit/c02809f185270bdc462f367b3a0e52a6e4d4300d)) 41 | 42 | 43 | ### Bug Fixes 44 | 45 | * Fixes ESLint 'no-case-declarations' error in HTTP transport by adding block scope to the default switch case. ([25ed8e6](https://github.com/QuantGeekDev/mcp-framework/commit/25ed8e6de845f72ea2967ff64b981e445ae48249)) 46 | * make ping conform with the spec ([aa46eb8](https://github.com/QuantGeekDev/mcp-framework/commit/aa46eb8199c95e4b1024e84d5a616e0cc420cd64)) 47 | * **transports:** Conform SSE/HTTP streams to MCP spec and improve logging ([9d9ef2a](https://github.com/QuantGeekDev/mcp-framework/commit/9d9ef2aa2ddea52c37133d4842d95d168ea5e190)) 48 | * **transports:** follow spec guideline ([208599d](https://github.com/QuantGeekDev/mcp-framework/commit/208599ddaafbf58eddaf4d5d6492a26e1effbbc6)) 49 | 50 | ## [0.2.9](https://github.com/QuantGeekDev/mcp-framework/compare/mcp-framework-v0.2.8...mcp-framework-v0.2.9) (2025-03-29) 51 | 52 | 53 | ### Features 54 | 55 | * add linting ([e71ebb5](https://github.com/QuantGeekDev/mcp-framework/commit/e71ebb5d538cb03510633bac0cf41bd318a0eab9)) 56 | * add linting ([181e634](https://github.com/QuantGeekDev/mcp-framework/commit/181e634da0cf97f2f40ae8b7e4fd4e74935a1c3c)) 57 | * add sse resumability ([4b47edb](https://github.com/QuantGeekDev/mcp-framework/commit/4b47edb243286a9f32bb81655bd0b51c2c4695e2)) 58 | * add sse resumability ([e20b7cc](https://github.com/QuantGeekDev/mcp-framework/commit/e20b7cc887dd4b080d38e01d21ac2ef3e63843d1)) 59 | * improve error handling for sse ([a5644af](https://github.com/QuantGeekDev/mcp-framework/commit/a5644af425563aca22fdf46ec24b1f547a5d9143)) 60 | * improve error handling for sse ([ba1646b](https://github.com/QuantGeekDev/mcp-framework/commit/ba1646be8da98c4d86b55313c515903c692d8f9f)) 61 | 62 | 63 | ### Bug Fixes 64 | 65 | * close sse stream after post ([eef96b4](https://github.com/QuantGeekDev/mcp-framework/commit/eef96b4c429af9c2f7083352c4bd45d645927352)) 66 | * close sse stream after post ([d6ea60d](https://github.com/QuantGeekDev/mcp-framework/commit/d6ea60deb5283551ce9730e2d20e38ffe8f6c711)) 67 | * detect tools capability using toolLoader ([c5d34a5](https://github.com/QuantGeekDev/mcp-framework/commit/c5d34a5be034ee0fb22e888d7696d64ac703e727)) 68 | * detect tools capability using toolLoader ([1e4c71f](https://github.com/QuantGeekDev/mcp-framework/commit/1e4c71f71634a72b7c146d84a21582e6e9d5fd3b)) 69 | * enforce that initialize request cannot be part of JSON-RPC batch ([452740c](https://github.com/QuantGeekDev/mcp-framework/commit/452740c9bdba8df014a4cd3d5149e78190c05058)) 70 | * enforce that initialize request cannot be part of JSON-RPC batch ([6cccf54](https://github.com/QuantGeekDev/mcp-framework/commit/6cccf54c18a6554c243d7dfdc286dcc3e92cb75f)) 71 | * import path utilities to resolve build errors ([5a7672c](https://github.com/QuantGeekDev/mcp-framework/commit/5a7672cca08dc21d527dc1d4b6a9cbdf809938bc)) 72 | * import path utilities to resolve build errors ([534d0de](https://github.com/QuantGeekDev/mcp-framework/commit/534d0de047e3d29f214b088c8fdad2d25444b344)) 73 | * project validation not working on windows ([fc506d3](https://github.com/QuantGeekDev/mcp-framework/commit/fc506d3b13a7c8c25647f6d2d8b278fa25e22ea5)) 74 | 75 | ## [0.2.8](https://github.com/QuantGeekDev/mcp-framework/compare/mcp-framework-v0.2.7...mcp-framework-v0.2.8) (2025-03-28) 76 | 77 | 78 | ### Features 79 | 80 | * add auth ([6338d14](https://github.com/QuantGeekDev/mcp-framework/commit/6338d14b6b8ad020ab41b2c2d3cf843d660db2fc)) 81 | * add Base Tool ([6b51594](https://github.com/QuantGeekDev/mcp-framework/commit/6b51594aaaedf0c13c418055d2b2039e919cc2d3)) 82 | * add basic sse support ([e8b808f](https://github.com/QuantGeekDev/mcp-framework/commit/e8b808f1967a9255a94cb9922169724e0a0bb145)) 83 | * add build before start. this one is for you, vibe coders ;) ([fa43fa8](https://github.com/QuantGeekDev/mcp-framework/commit/fa43fa81195981688adf30689d1cbd65fdb29892)) 84 | * add cli tool ([91101ee](https://github.com/QuantGeekDev/mcp-framework/commit/91101ee9671eb8489d7cb69d2b596d201a204cb1)) 85 | * add cors configuration ([5d3f27f](https://github.com/QuantGeekDev/mcp-framework/commit/5d3f27f0f2ce61551b1c66dbf9d3aa7f640606ff)) 86 | * add execa ([4d44864](https://github.com/QuantGeekDev/mcp-framework/commit/4d44864db3abbd41cbf02da380c597e92d2de39d)) 87 | * add execa ([d68cfb6](https://github.com/QuantGeekDev/mcp-framework/commit/d68cfb6b43034893a6de3018793164d5d334f5fe)) 88 | * add find-up ([09c767f](https://github.com/QuantGeekDev/mcp-framework/commit/09c767fe82dbf53403543905f8e056a9e2c9a3df)) 89 | * add find-up ([9f90df9](https://github.com/QuantGeekDev/mcp-framework/commit/9f90df97a5cf3777a5910c0a806a6fbc70dee61e)) 90 | * Add HTTP Stream transport with improved session handling and CORS support ([04ff8a5](https://github.com/QuantGeekDev/mcp-framework/commit/04ff8a5453b56e912d157356bb05a8cb6c987c41)) 91 | * add image support ([ab023ba](https://github.com/QuantGeekDev/mcp-framework/commit/ab023ba082af9d7e560a498e1e77b93e69331d74)) 92 | * add image support ([9c8ca20](https://github.com/QuantGeekDev/mcp-framework/commit/9c8ca201f792a2386b112e67d1e664ca78d2e2d1)) 93 | * add index ([62873b9](https://github.com/QuantGeekDev/mcp-framework/commit/62873b9fb67d41cff0fdca647b4d1a0ff4d35e6b)) 94 | * add keywords to package ([95ea9a7](https://github.com/QuantGeekDev/mcp-framework/commit/95ea9a7adb25fb5dd844a944ef1a5cc7ccfaeb25)) 95 | * Add LICENSE ([2d3612a](https://github.com/QuantGeekDev/mcp-framework/commit/2d3612a89dcd27b1567480378b318eeb8e838863)) 96 | * add logging ([d637a65](https://github.com/QuantGeekDev/mcp-framework/commit/d637a6560b17b09c5383b6b1ca7838f08c5fc780)) 97 | * add mcp-build cli ([27cb517](https://github.com/QuantGeekDev/mcp-framework/commit/27cb517306deaf2b34646d9b2ac617dc70c476f5)) 98 | * add MCPServer ([807f04d](https://github.com/QuantGeekDev/mcp-framework/commit/807f04ddbfe28598bb26c0dce640dcc0909d739a)) 99 | * add MCPTool abstraction ([4d5fb39](https://github.com/QuantGeekDev/mcp-framework/commit/4d5fb398efd4a253f1ea23e8307690ecb84f8009)) 100 | * add MCPTool abstraction ([3458e78](https://github.com/QuantGeekDev/mcp-framework/commit/3458e78233437c9afd71b9195424659e62aa08f3)) 101 | * add prompt capabilities ([9ca6a0f](https://github.com/QuantGeekDev/mcp-framework/commit/9ca6a0fc19638c2428c060bdb3996d49c5c31e55)) 102 | * add prompt capabilities ([019f409](https://github.com/QuantGeekDev/mcp-framework/commit/019f40949ea66fc4d1ca1969aaa57c8943e81036)) 103 | * add readme ([752024e](https://github.com/QuantGeekDev/mcp-framework/commit/752024ed92213c3c27a2b9a5a702f823e835e167)) 104 | * add README ([b0a371c](https://github.com/QuantGeekDev/mcp-framework/commit/b0a371c1b3a6275d249fdd89e67d7d791b573601)) 105 | * add release-please workflow ([eb6c121](https://github.com/QuantGeekDev/mcp-framework/commit/eb6c121a1b22659747c7edaecf05d3cd39b2d92e)) 106 | * add release-please workflow ([5a89670](https://github.com/QuantGeekDev/mcp-framework/commit/5a89670a1f797f8d91254d0b3f9f991fdc77148a)) 107 | * add resources support ([e8b03d4](https://github.com/QuantGeekDev/mcp-framework/commit/e8b03d432fd09e7e25e3f272f735cebeb571a31b)) 108 | * add sdk version logging ([8a05c48](https://github.com/QuantGeekDev/mcp-framework/commit/8a05c48431ff01b4fd68e178a055cfec41236b21)) 109 | * add sdk version logging ([bb716db](https://github.com/QuantGeekDev/mcp-framework/commit/bb716dba19d75214f7f23973f526f4ab107fe187)) 110 | * add toolLoader ([a44ffe7](https://github.com/QuantGeekDev/mcp-framework/commit/a44ffe7e7df099479ff516bc5fa0e9e2a5115bf0)) 111 | * add typescript dependencies ([0ade3dc](https://github.com/QuantGeekDev/mcp-framework/commit/0ade3dc3627934884b8908202fe71b06a3a1c660)) 112 | * bump up version ([85624c6](https://github.com/QuantGeekDev/mcp-framework/commit/85624c6bfe7dda5df0cfb6894b58bf8a6b39a99e)) 113 | * bump version ([0d88a0f](https://github.com/QuantGeekDev/mcp-framework/commit/0d88a0fbd6b571ff578a96c85361930616371355)) 114 | * bump version ([e227702](https://github.com/QuantGeekDev/mcp-framework/commit/e227702c87476bda82384c34efd871b13292b089)) 115 | * bump version ([33b1776](https://github.com/QuantGeekDev/mcp-framework/commit/33b17764330126ee79bb4c9510d8f067b339fda3)) 116 | * bump version ([a0e5a38](https://github.com/QuantGeekDev/mcp-framework/commit/a0e5a381ecc4b9fc6dee3a35609ead7297af905a)) 117 | * bump version ([ea3085d](https://github.com/QuantGeekDev/mcp-framework/commit/ea3085d1deae39e8dc737be499564d6a9487654c)) 118 | * bump version ([ddf74f6](https://github.com/QuantGeekDev/mcp-framework/commit/ddf74f6f142a69dcfe99af8def8b785b59c6d662)) 119 | * bump version ([4c04edb](https://github.com/QuantGeekDev/mcp-framework/commit/4c04edbf0037f34041948bd9596a1d3cab4f4b34)) 120 | * bump version ([0e6a21b](https://github.com/QuantGeekDev/mcp-framework/commit/0e6a21bbfc90ed9166692e4aea02d4e7d07a93d4)) 121 | * bump version ([1d1cfef](https://github.com/QuantGeekDev/mcp-framework/commit/1d1cfef4542d95d66fb52b8bee61d0eb6766674a)) 122 | * bump version number ([3a7f329](https://github.com/QuantGeekDev/mcp-framework/commit/3a7f329092c810d99d0cedd09ba11d902e941879)) 123 | * enforce node version 20 ([8f1466a](https://github.com/QuantGeekDev/mcp-framework/commit/8f1466adfa7f6adc69070ed1d7feb7c8ffbca224)) 124 | * enforce node version 20 ([bf4a4bb](https://github.com/QuantGeekDev/mcp-framework/commit/bf4a4bb427bedd948509eaf13b8a82222c98c006)) 125 | * fix directory validation issue ([bf0e0d4](https://github.com/QuantGeekDev/mcp-framework/commit/bf0e0d4d331cf64a132b27c52b85d0115fe6f280)) 126 | * fix directory validation issue ([bc6ab31](https://github.com/QuantGeekDev/mcp-framework/commit/bc6ab31396a05c898121ccc96a545e4a1ebc82ea)) 127 | * HTTP stream transport implementation (v0.2.0-beta.16) ([d29fb5f](https://github.com/QuantGeekDev/mcp-framework/commit/d29fb5fc36ce67c4494a2a47e79645a2559c4f80)) 128 | * lower node version to 18 ([77c8d1b](https://github.com/QuantGeekDev/mcp-framework/commit/77c8d1bcb4b26dbc0d8884269d9b5ddfe9b8864c)) 129 | * make the mcp server config optional ([9538626](https://github.com/QuantGeekDev/mcp-framework/commit/9538626ff03a9534a72913436c011616b61163e2)) 130 | * make toolLoader load tools automatically ([16f2793](https://github.com/QuantGeekDev/mcp-framework/commit/16f27930bf4d5251867f5a700fa1842d8f70ad07)) 131 | * read default name and version from package json ([b5789af](https://github.com/QuantGeekDev/mcp-framework/commit/b5789af47c112eddece3764505e8e942dc6f3810)) 132 | * remove vibe coder contingency ([36bbc88](https://github.com/QuantGeekDev/mcp-framework/commit/36bbc88d731f6aa2629ad85da3ab69e77508b74f)) 133 | * update readme ([ac3725a](https://github.com/QuantGeekDev/mcp-framework/commit/ac3725a4fda0b0b14a9a5d3cea44674e191999dc)) 134 | * update readme ([06af3b8](https://github.com/QuantGeekDev/mcp-framework/commit/06af3b8573e41360f528b21176fceeb4290f1197)) 135 | * update readme ([e739ff6](https://github.com/QuantGeekDev/mcp-framework/commit/e739ff6d2570b134289c3190476af4fe7ca61d1c)) 136 | * update README ([6c1efcd](https://github.com/QuantGeekDev/mcp-framework/commit/6c1efcde6bcef838aaacc52e6e41903025e5ecc5)) 137 | * upgrade tool loader ([2b066e6](https://github.com/QuantGeekDev/mcp-framework/commit/2b066e6a0af46f9720ee266e019345fd6022eb3a)) 138 | 139 | 140 | ### Bug Fixes 141 | 142 | * add release-please manifest file ([14bd878](https://github.com/QuantGeekDev/mcp-framework/commit/14bd878652ea3fdbf684749cb42752bccf675a6b)) 143 | * build error ([adc7e48](https://github.com/QuantGeekDev/mcp-framework/commit/adc7e48d51b6075ab7624901033050e87ef1f632)) 144 | * build errors with mcp-build ([27c255b](https://github.com/QuantGeekDev/mcp-framework/commit/27c255b119dbcc79575b4feae589c980972c8933)) 145 | * build errors with mcp-build ([dc5b56e](https://github.com/QuantGeekDev/mcp-framework/commit/dc5b56e6b1bf7937b02eb29a7c33a6ca7b771eb6)) 146 | * build issues ([2a3d12d](https://github.com/QuantGeekDev/mcp-framework/commit/2a3d12d1d5c0bd238cdd090e0e805cd4625ade32)) 147 | * cli build issues ([f84266a](https://github.com/QuantGeekDev/mcp-framework/commit/f84266a29214e5de73bd52bc0135727882cadbef)) 148 | * execa ([641614d](https://github.com/QuantGeekDev/mcp-framework/commit/641614d3b86450fbc850ee22898003438f5504cc)) 149 | * execa ([e520f27](https://github.com/QuantGeekDev/mcp-framework/commit/e520f2718fb8c082bdd76431e1b63b9cd72a301e)) 150 | * follow spec ([b9420c4](https://github.com/QuantGeekDev/mcp-framework/commit/b9420c45fbb44d65b4cf430dc69ee96294d7ad43)) 151 | * follow spec ([938a13b](https://github.com/QuantGeekDev/mcp-framework/commit/938a13b4c7ae08f88a5ac4078dbac94b1f89f31d)) 152 | * node spawning issue ([8b5d4fa](https://github.com/QuantGeekDev/mcp-framework/commit/8b5d4fafac022c15028abbf62a11fdf2a35207df)) 153 | * Prevent duplicate builds by removing prepare script ([3584959](https://github.com/QuantGeekDev/mcp-framework/commit/3584959f6c3260c3299beeb6cc09c284f6206d84)) 154 | * remove findup ([f281451](https://github.com/QuantGeekDev/mcp-framework/commit/f281451cb25264fcd80bf90ac7027fd1bbeaa1f4)) 155 | * remove findup ([be6f5b0](https://github.com/QuantGeekDev/mcp-framework/commit/be6f5b076c0c764b4447a87eb0b75ee5cc607e42)) 156 | * remove project name ([40789f3](https://github.com/QuantGeekDev/mcp-framework/commit/40789f3760f04a0dc117b6866b439e283ceec68a)) 157 | * remove project name ([6e89e31](https://github.com/QuantGeekDev/mcp-framework/commit/6e89e3128cca93d2657fc2dc382fa0f0d658aaf6)) 158 | * remove redundant prepare script from package.json and create.ts ([1ddff3f](https://github.com/QuantGeekDev/mcp-framework/commit/1ddff3f5ce4f8d7817a9c04db6432332957635a9)) 159 | * scope ([84b72f6](https://github.com/QuantGeekDev/mcp-framework/commit/84b72f6f2988121277bffcb5eb0474a47c2940a1)) 160 | * session id for initialization ([2743eaf](https://github.com/QuantGeekDev/mcp-framework/commit/2743eaf68994e890d319e9b2881ea47bb676d848)) 161 | * session id for initialization ([061d152](https://github.com/QuantGeekDev/mcp-framework/commit/061d1522729392a49e8565569931d96ef7c57693)) 162 | * sse reconnect issues ([fad62e3](https://github.com/QuantGeekDev/mcp-framework/commit/fad62e33b9fc3cc0df5bdfd96370dd6c2723dc57)) 163 | * tool loader base dir ([636807c](https://github.com/QuantGeekDev/mcp-framework/commit/636807cea8eb93227a75725b7359828e24bc8d26)) 164 | * tool loader base dir ([aa035a0](https://github.com/QuantGeekDev/mcp-framework/commit/aa035a0cd3da7d2187b2aae8d08da382b0a8d1f2)) 165 | * tsc during project creation bug ([eb3a7bf](https://github.com/QuantGeekDev/mcp-framework/commit/eb3a7bf0f94ca9809cfede795425f30a31480664)) 166 | * tsc during project creation bug ([2177d3e](https://github.com/QuantGeekDev/mcp-framework/commit/2177d3ebfee001242fb7fa6ac989cee9e3c05a1b)) 167 | * tsconfig not found ([4d2062f](https://github.com/QuantGeekDev/mcp-framework/commit/4d2062fe5e8f1984b770cd5389d4c2c8f84c7cff)) 168 | * tsconfig not found ([ec87841](https://github.com/QuantGeekDev/mcp-framework/commit/ec87841580cf9d4ea1ab9e5e3765b68f01a483be)) 169 | * wrong index created ([b6f186c](https://github.com/QuantGeekDev/mcp-framework/commit/b6f186cc21f79bef0acadd6bf904277ef2f13760)) 170 | * wrong index created ([1c6f9ef](https://github.com/QuantGeekDev/mcp-framework/commit/1c6f9ef587f14d77e346df7dbf1b59ad5fb5a3bc)) 171 | 172 | ## [Unreleased] 173 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2025] [Alexandr "Andru" Andrushevich] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Framework 2 | 3 | MCP-Framework is a framework for building Model Context Protocol (MCP) servers elegantly in TypeScript. 4 | 5 | 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 6 | 7 | ## Features 8 | 9 | - 🛠️ Automatic discovery and loading of tools, resources, and prompts 10 | - Multiple transport support (stdio, SSE, HTTP Stream) 11 | - TypeScript-first development with full type safety 12 | - Built on the official MCP SDK 13 | - Easy-to-use base classes for tools, prompts, and resources 14 | - Out of the box authentication for SSE endpoints 15 | 16 | ## Projects Built with MCP Framework 17 | 18 | The following projects and services are built using MCP Framework: 19 | 20 | - ### [tip.md](https://tip.md) 21 | A crypto tipping service that enables AI assistants to help users send cryptocurrency tips to content creators directly from their chat interface. The MCP service allows for: 22 | - Checking wallet types for users 23 | - Preparing cryptocurrency tips for users/agents to complete 24 | Setup instructions for various clients (Cursor, Sage, Claude Desktop) are available in their [MCP Server documentation](https://docs.tip.md/mcp-server/). 25 | 26 | ## Support our work 27 | 28 | [![Tip in Crypto](https://tip.md/badge.svg)](https://tip.md/QuantGeekDev) 29 | 30 | 31 | # [Read the full docs here](https://mcp-framework.com) 32 | 33 | 34 | 35 | 36 | 37 | ## Creating a repository with mcp-framework 38 | 39 | ### Using the CLI (Recommended) 40 | 41 | ```bash 42 | # Install the framework globally 43 | npm install -g mcp-framework 44 | 45 | # Create a new MCP server project 46 | mcp create my-mcp-server 47 | 48 | # Navigate to your project 49 | cd my-mcp-server 50 | 51 | # Your server is ready to use! 52 | ``` 53 | 54 | ## CLI Usage 55 | 56 | The framework provides a powerful CLI for managing your MCP server projects: 57 | 58 | ### Project Creation 59 | 60 | ```bash 61 | # Create a new project 62 | mcp create 63 | 64 | # Create a new project with the new EXPERIMENTAL HTTP transport 65 | Heads up: This will set cors allowed origin to "*", modify it in the index if you wish 66 | mcp create --http --port 1337 --cors 67 | ``` 68 | 69 | # Options: 70 | # --http: Use HTTP transport instead of default stdio 71 | # --port : Specify HTTP port (default: 8080) 72 | # --cors: Enable CORS with wildcard (*) access 73 | 74 | ### Adding a Tool 75 | 76 | ```bash 77 | # Add a new tool 78 | mcp add tool price-fetcher 79 | ``` 80 | 81 | ### Adding a Prompt 82 | 83 | ```bash 84 | # Add a new prompt 85 | mcp add prompt price-analysis 86 | ``` 87 | 88 | ### Adding a Resource 89 | 90 | ```bash 91 | # Add a new prompt 92 | mcp add resource market-data 93 | ``` 94 | 95 | ## Development Workflow 96 | 97 | 1. Create your project: 98 | 99 | ```bash 100 | mcp create my-mcp-server 101 | cd my-mcp-server 102 | ``` 103 | 104 | 2. Add tools as needed: 105 | 106 | ```bash 107 | mcp add tool data-fetcher 108 | mcp add tool data-processor 109 | mcp add tool report-generator 110 | ``` 111 | 112 | 3. Build: 113 | 114 | ```bash 115 | npm run build 116 | 117 | ``` 118 | 119 | 4. Add to MCP Client (Read below for Claude Desktop example) 120 | 121 | ## Using with Claude Desktop 122 | 123 | ### Local Development 124 | 125 | Add this configuration to your Claude Desktop config file: 126 | 127 | **MacOS**: \`~/Library/Application Support/Claude/claude_desktop_config.json\` 128 | **Windows**: \`%APPDATA%/Claude/claude_desktop_config.json\` 129 | 130 | ```json 131 | { 132 | "mcpServers": { 133 | "${projectName}": { 134 | "command": "node", 135 | "args":["/absolute/path/to/${projectName}/dist/index.js"] 136 | } 137 | } 138 | } 139 | ``` 140 | 141 | ### After Publishing 142 | 143 | Add this configuration to your Claude Desktop config file: 144 | 145 | **MacOS**: \`~/Library/Application Support/Claude/claude_desktop_config.json\` 146 | **Windows**: \`%APPDATA%/Claude/claude_desktop_config.json\` 147 | 148 | ```json 149 | { 150 | "mcpServers": { 151 | "${projectName}": { 152 | "command": "npx", 153 | "args": ["${projectName}"] 154 | } 155 | } 156 | } 157 | ``` 158 | 159 | ## Building and Testing 160 | 161 | 1. Make changes to your tools 162 | 2. Run \`npm run build\` to compile 163 | 3. The server will automatically load your tools on startup 164 | 165 | ## Environment Variables 166 | 167 | The framework supports the following environment variables for configuration: 168 | 169 | | Variable | Description | Default | 170 | |-----------------------|-------------------------------------------------------|-------------| 171 | | MCP_ENABLE_FILE_LOGGING | Enable logging to files (true/false) | false | 172 | | MCP_LOG_DIRECTORY | Directory where log files will be stored | logs | 173 | | MCP_DEBUG_CONSOLE | Display debug level messages in console (true/false) | false | 174 | 175 | Example usage: 176 | 177 | ```bash 178 | # Enable file logging 179 | MCP_ENABLE_FILE_LOGGING=true node dist/index.js 180 | 181 | # Specify a custom log directory 182 | MCP_ENABLE_FILE_LOGGING=true MCP_LOG_DIRECTORY=my-logs 183 | # Enable debug messages in console 184 | MCP_DEBUG_CONSOLE=true``` 185 | 186 | ## Quick Start 187 | 188 | ### Creating a Tool 189 | 190 | ```typescript 191 | import { MCPTool } from "mcp-framework"; 192 | import { z } from "zod"; 193 | 194 | interface ExampleInput { 195 | message: string; 196 | } 197 | 198 | class ExampleTool extends MCPTool { 199 | name = "example_tool"; 200 | description = "An example tool that processes messages"; 201 | 202 | schema = { 203 | message: { 204 | type: z.string(), 205 | description: "Message to process", 206 | }, 207 | }; 208 | 209 | async execute(input: ExampleInput) { 210 | return `Processed: ${input.message}`; 211 | } 212 | } 213 | 214 | export default ExampleTool; 215 | ``` 216 | 217 | ### Setting up the Server 218 | 219 | ```typescript 220 | import { MCPServer } from "mcp-framework"; 221 | 222 | const server = new MCPServer(); 223 | 224 | // OR (mutually exclusive!) with SSE transport 225 | const server = new MCPServer({ 226 | transport: { 227 | type: "sse", 228 | options: { 229 | port: 8080 // Optional (default: 8080) 230 | } 231 | } 232 | }); 233 | 234 | // Start the server 235 | await server.start(); 236 | ``` 237 | 238 | ## Transport Configuration 239 | 240 | ### stdio Transport (Default) 241 | 242 | The stdio transport is used by default if no transport configuration is provided: 243 | 244 | ```typescript 245 | const server = new MCPServer(); 246 | // or explicitly: 247 | const server = new MCPServer({ 248 | transport: { type: "stdio" } 249 | }); 250 | ``` 251 | 252 | ### SSE Transport 253 | 254 | To use Server-Sent Events (SSE) transport: 255 | 256 | ```typescript 257 | const server = new MCPServer({ 258 | transport: { 259 | type: "sse", 260 | options: { 261 | port: 8080, // Optional (default: 8080) 262 | endpoint: "/sse", // Optional (default: "/sse") 263 | messageEndpoint: "/messages", // Optional (default: "/messages") 264 | cors: { 265 | allowOrigin: "*", // Optional (default: "*") 266 | allowMethods: "GET, POST, OPTIONS", // Optional (default: "GET, POST, OPTIONS") 267 | allowHeaders: "Content-Type, Authorization, x-api-key", // Optional (default: "Content-Type, Authorization, x-api-key") 268 | exposeHeaders: "Content-Type, Authorization, x-api-key", // Optional (default: "Content-Type, Authorization, x-api-key") 269 | maxAge: "86400" // Optional (default: "86400") 270 | } 271 | } 272 | } 273 | }); 274 | ``` 275 | 276 | ### HTTP Stream Transport 277 | 278 | To use HTTP Stream transport: 279 | 280 | ```typescript 281 | const server = new MCPServer({ 282 | transport: { 283 | type: "http-stream", 284 | options: { 285 | port: 8080, // Optional (default: 8080) 286 | endpoint: "/mcp", // Optional (default: "/mcp") 287 | responseMode: "batch", // Optional (default: "batch"), can be "batch" or "stream" 288 | batchTimeout: 30000, // Optional (default: 30000ms) - timeout for batch responses 289 | maxMessageSize: "4mb", // Optional (default: "4mb") - maximum message size 290 | 291 | // Session configuration 292 | session: { 293 | enabled: true, // Optional (default: true) 294 | headerName: "Mcp-Session-Id", // Optional (default: "Mcp-Session-Id") 295 | allowClientTermination: true, // Optional (default: true) 296 | }, 297 | 298 | // Stream resumability (for missed messages) 299 | resumability: { 300 | enabled: false, // Optional (default: false) 301 | historyDuration: 300000, // Optional (default: 300000ms = 5min) - how long to keep message history 302 | }, 303 | 304 | // CORS configuration 305 | cors: { 306 | allowOrigin: "*" // Other CORS options use defaults 307 | } 308 | } 309 | } 310 | }); 311 | ``` 312 | 313 | #### Response Modes 314 | 315 | The HTTP Stream transport supports two response modes: 316 | 317 | 1. **Batch Mode** (Default): Responses are collected and sent as a single JSON-RPC response. This is suitable for typical request-response patterns and is more efficient for most use cases. 318 | 319 | 2. **Stream Mode**: All responses are sent over a persistent SSE connection opened for each request. This is ideal for long-running operations or when the server needs to send multiple messages in response to a single request. 320 | 321 | You can configure the response mode based on your specific needs: 322 | 323 | ```typescript 324 | // For batch mode (default): 325 | const server = new MCPServer({ 326 | transport: { 327 | type: "http-stream", 328 | options: { 329 | responseMode: "batch" 330 | } 331 | } 332 | }); 333 | 334 | // For stream mode: 335 | const server = new MCPServer({ 336 | transport: { 337 | type: "http-stream", 338 | options: { 339 | responseMode: "stream" 340 | } 341 | } 342 | }); 343 | ``` 344 | 345 | #### HTTP Stream Transport Features 346 | 347 | - **Session Management**: Automatic session tracking and management 348 | - **Stream Resumability**: Optional support for resuming streams after connection loss 349 | - **Batch Processing**: Support for JSON-RPC batch requests/responses 350 | - **Comprehensive Error Handling**: Detailed error responses with JSON-RPC error codes 351 | 352 | ## Authentication 353 | 354 | MCP Framework provides optional authentication for SSE endpoints. You can choose between JWT and API Key authentication, or implement your own custom authentication provider. 355 | 356 | ### JWT Authentication 357 | 358 | ```typescript 359 | import { MCPServer, JWTAuthProvider } from "mcp-framework"; 360 | import { Algorithm } from "jsonwebtoken"; 361 | 362 | const server = new MCPServer({ 363 | transport: { 364 | type: "sse", 365 | options: { 366 | auth: { 367 | provider: new JWTAuthProvider({ 368 | secret: process.env.JWT_SECRET, 369 | algorithms: ["HS256" as Algorithm], // Optional (default: ["HS256"]) 370 | headerName: "Authorization" // Optional (default: "Authorization") 371 | }), 372 | endpoints: { 373 | sse: true, // Protect SSE endpoint (default: false) 374 | messages: true // Protect message endpoint (default: true) 375 | } 376 | } 377 | } 378 | } 379 | }); 380 | ``` 381 | 382 | Clients must include a valid JWT token in the Authorization header: 383 | ``` 384 | Authorization: Bearer eyJhbGciOiJIUzI1NiIs... 385 | ``` 386 | 387 | ### API Key Authentication 388 | 389 | ```typescript 390 | import { MCPServer, APIKeyAuthProvider } from "mcp-framework"; 391 | 392 | const server = new MCPServer({ 393 | transport: { 394 | type: "sse", 395 | options: { 396 | auth: { 397 | provider: new APIKeyAuthProvider({ 398 | keys: [process.env.API_KEY], 399 | headerName: "X-API-Key" // Optional (default: "X-API-Key") 400 | }) 401 | } 402 | } 403 | } 404 | }); 405 | ``` 406 | 407 | Clients must include a valid API key in the X-API-Key header: 408 | ``` 409 | X-API-Key: your-api-key 410 | ``` 411 | 412 | ### Custom Authentication 413 | 414 | You can implement your own authentication provider by implementing the `AuthProvider` interface: 415 | 416 | ```typescript 417 | import { AuthProvider, AuthResult } from "mcp-framework"; 418 | import { IncomingMessage } from "node:http"; 419 | 420 | class CustomAuthProvider implements AuthProvider { 421 | async authenticate(req: IncomingMessage): Promise { 422 | // Implement your custom authentication logic 423 | return true; 424 | } 425 | 426 | getAuthError() { 427 | return { 428 | status: 401, 429 | message: "Authentication failed" 430 | }; 431 | } 432 | } 433 | ``` 434 | 435 | ## License 436 | 437 | MIT 438 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "eslint/config"; 2 | import globals from "globals"; 3 | import js from "@eslint/js"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | 7 | export default defineConfig([ 8 | { files: ["**/*.{js,mjs,cjs,ts}"] }, 9 | { files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.node } }, 10 | { files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] }, 11 | tseslint.configs.recommended, 12 | { 13 | files: ["**/*.{js,mjs,cjs,ts}"], 14 | rules: { 15 | "@typescript-eslint/no-unused-vars": "off", 16 | "@typescript-eslint/no-explicit-any": "off", 17 | "@typescript-eslint/no-empty-object-type": "off", 18 | }, 19 | }, 20 | ]); 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-framework", 3 | "version": "0.2.13", 4 | "description": "Framework for building Model Context Protocol (MCP) servers in Typescript", 5 | "type": "module", 6 | "author": "Alex Andru ", 7 | "main": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/index.d.ts", 12 | "import": "./dist/index.js" 13 | } 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "bin": { 19 | "mcp": "dist/cli/index.js", 20 | "mcp-build": "dist/cli/framework/build-cli.js" 21 | }, 22 | "scripts": { 23 | "build": "tsc", 24 | "watch": "tsc --watch", 25 | "lint": "eslint", 26 | "lint:fix": "eslint --fix", 27 | "format": "prettier --write \"src/**/*.ts\"", 28 | "prepare": "npm run build" 29 | "dev:pub": "rm -rf dist && npm run build && yalc publish --push" 30 | }, 31 | "engines": { 32 | "node": ">=18.19.0" 33 | }, 34 | "keywords": [ 35 | "mcp", 36 | "claude", 37 | "anthropic", 38 | "ai", 39 | "framework", 40 | "tools", 41 | "modelcontextprotocol", 42 | "model", 43 | "context", 44 | "protocol" 45 | ], 46 | "peerDependencies": { 47 | "@modelcontextprotocol/sdk": "^1.11.0" 48 | }, 49 | "dependencies": { 50 | "@types/prompts": "^2.4.9", 51 | "commander": "^12.1.0", 52 | "content-type": "^1.0.5", 53 | "execa": "^9.5.2", 54 | "find-up": "^7.0.0", 55 | "jsonwebtoken": "^9.0.2", 56 | "prompts": "^2.4.2", 57 | "raw-body": "^2.5.2", 58 | "typescript": "^5.3.3", 59 | "zod": "^3.23.8" 60 | }, 61 | "devDependencies": { 62 | "@eslint/js": "^9.23.0", 63 | "@types/content-type": "^1.1.8", 64 | "@types/jest": "^29.5.12", 65 | "@types/jsonwebtoken": "^9.0.8", 66 | "@types/node": "^20.17.28", 67 | "@typescript-eslint/eslint-plugin": "^8.28.0", 68 | "@typescript-eslint/parser": "^8.28.0", 69 | "eslint": "^9.23.0", 70 | "eslint-config-prettier": "^10.1.1", 71 | "eslint-plugin-prettier": "^5.2.5", 72 | "globals": "^16.0.0", 73 | "jest": "^29.7.0", 74 | "prettier": "^3.5.3", 75 | "ts-jest": "^29.1.2", 76 | "typescript-eslint": "^8.28.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types.js"; 2 | export * from "./providers/jwt.js"; 3 | export * from "./providers/apikey.js"; 4 | 5 | export type { AuthProvider, AuthConfig, AuthResult } from "./types.js"; 6 | export type { JWTConfig } from "./providers/jwt.js"; 7 | export type { APIKeyConfig } from "./providers/apikey.js"; 8 | -------------------------------------------------------------------------------- /src/auth/providers/apikey.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from "node:http"; 2 | import { logger } from "../../core/Logger.js"; 3 | import { AuthProvider, AuthResult, DEFAULT_AUTH_ERROR } from "../types.js"; 4 | 5 | export const DEFAULT_API_KEY_HEADER_NAME = "X-API-Key" 6 | /** 7 | * Configuration options for API key authentication 8 | */ 9 | export interface APIKeyConfig { 10 | /** 11 | * Valid API keys 12 | */ 13 | keys: string[]; 14 | 15 | /** 16 | * Name of the header containing the API key 17 | * @default "X-API-Key" 18 | */ 19 | headerName?: string; 20 | } 21 | 22 | /** 23 | * API key-based authentication provider 24 | */ 25 | export class APIKeyAuthProvider implements AuthProvider { 26 | private config: Required; 27 | 28 | constructor(config: APIKeyConfig) { 29 | this.config = { 30 | headerName: DEFAULT_API_KEY_HEADER_NAME, 31 | ...config 32 | }; 33 | 34 | if (!this.config.keys?.length) { 35 | throw new Error("At least one API key is required"); 36 | } 37 | } 38 | 39 | /** 40 | * Get the number of configured API keys 41 | */ 42 | getKeyCount(): number { 43 | return this.config.keys.length; 44 | } 45 | 46 | /** 47 | * Get the configured header name 48 | */ 49 | getHeaderName(): string { 50 | return this.config.headerName; 51 | } 52 | 53 | async authenticate(req: IncomingMessage): Promise { 54 | logger.debug(`API Key auth attempt from ${req.socket.remoteAddress}`); 55 | 56 | logger.debug(`All request headers: ${JSON.stringify(req.headers, null, 2)}`); 57 | 58 | const headerVariations = [ 59 | this.config.headerName, 60 | this.config.headerName.toLowerCase(), 61 | this.config.headerName.toUpperCase(), 62 | 'x-api-key', 63 | 'X-API-KEY', 64 | 'X-Api-Key' 65 | ]; 66 | 67 | logger.debug(`Looking for header variations: ${headerVariations.join(', ')}`); 68 | 69 | let apiKey: string | undefined; 70 | let matchedHeader: string | undefined; 71 | 72 | for (const [key, value] of Object.entries(req.headers)) { 73 | const lowerKey = key.toLowerCase(); 74 | if (headerVariations.some(h => h.toLowerCase() === lowerKey)) { 75 | apiKey = Array.isArray(value) ? value[0] : value; 76 | matchedHeader = key; 77 | break; 78 | } 79 | } 80 | 81 | if (!apiKey) { 82 | logger.debug(`API Key header missing}`); 83 | logger.debug(`Available headers: ${Object.keys(req.headers).join(', ')}`); 84 | return false; 85 | } 86 | 87 | logger.debug(`Found API key in header: ${matchedHeader}`); 88 | 89 | for (const validKey of this.config.keys) { 90 | if (apiKey === validKey) { 91 | logger.debug(`API Key authentication successful`); 92 | return true; 93 | } 94 | } 95 | 96 | logger.debug(`Invalid API Key provided`); 97 | return false; 98 | } 99 | 100 | getAuthError() { 101 | return { 102 | ...DEFAULT_AUTH_ERROR, 103 | message: "Invalid API key" 104 | }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/auth/providers/jwt.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from "node:http"; 2 | import jwt, { Algorithm } from "jsonwebtoken"; 3 | import { AuthProvider, AuthResult, DEFAULT_AUTH_ERROR } from "../types.js"; 4 | import { logger } from "../../core/Logger.js"; 5 | 6 | /** 7 | * Configuration options for JWT authentication 8 | */ 9 | export interface JWTConfig { 10 | /** 11 | * Secret key for verifying JWT tokens 12 | */ 13 | secret: string; 14 | 15 | /** 16 | * Allowed JWT algorithms 17 | * @default ["HS256"] 18 | */ 19 | algorithms?: Algorithm[]; 20 | 21 | /** 22 | * Name of the header containing the JWT token 23 | * @default "Authorization" 24 | */ 25 | headerName?: string; 26 | 27 | /** 28 | * Whether to require "Bearer" prefix in Authorization header 29 | * @default true 30 | */ 31 | requireBearer?: boolean; 32 | } 33 | 34 | /** 35 | * JWT-based authentication provider 36 | */ 37 | export class JWTAuthProvider implements AuthProvider { 38 | private config: Required; 39 | 40 | constructor(config: JWTConfig) { 41 | this.config = { 42 | algorithms: ["HS256"], 43 | headerName: "Authorization", 44 | requireBearer: true, 45 | ...config 46 | }; 47 | 48 | if (!this.config.secret) { 49 | throw new Error("JWT secret is required"); 50 | } 51 | } 52 | 53 | async authenticate(req: IncomingMessage): Promise { 54 | const authHeader = req.headers[this.config.headerName.toLowerCase()]; 55 | 56 | if (!authHeader || typeof authHeader !== "string") { 57 | return false; 58 | } 59 | 60 | let token = authHeader; 61 | if (this.config.requireBearer) { 62 | if (!authHeader.startsWith("Bearer ")) { 63 | return false; 64 | } 65 | token = authHeader.split(" ")[1]; 66 | } 67 | 68 | try { 69 | const decoded = jwt.verify(token, this.config.secret, { 70 | algorithms: this.config.algorithms 71 | }); 72 | 73 | return { 74 | data: typeof decoded === "object" ? decoded : { sub: decoded } 75 | }; 76 | } catch (error) { 77 | logger.debug(`JWT verification failed: ${(error as Error).message}`); 78 | return false; 79 | } 80 | } 81 | 82 | getAuthError() { 83 | return { 84 | ...DEFAULT_AUTH_ERROR, 85 | message: "Invalid or expired JWT token" 86 | }; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/auth/types.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from "node:http"; 2 | 3 | /** 4 | * Result of successful authentication 5 | */ 6 | export interface AuthResult { 7 | /** 8 | * User or token data from authentication 9 | */ 10 | data?: Record; 11 | } 12 | 13 | /** 14 | * Base interface for authentication providers 15 | */ 16 | export interface AuthProvider { 17 | /** 18 | * Authenticate an incoming request 19 | * @param req The incoming HTTP request 20 | * @returns Promise resolving to boolean or AuthResult 21 | */ 22 | authenticate(req: IncomingMessage): Promise; 23 | 24 | /** 25 | * Get error details for failed authentication 26 | */ 27 | getAuthError?(): { status: number; message: string }; 28 | } 29 | 30 | /** 31 | * Authentication configuration for transport 32 | */ 33 | export interface AuthConfig { 34 | /** 35 | * Authentication provider implementation 36 | */ 37 | provider: AuthProvider; 38 | 39 | /** 40 | * Per-endpoint authentication configuration 41 | */ 42 | endpoints?: { 43 | /** 44 | * Whether to authenticate SSE connection endpoint 45 | * @default false 46 | */ 47 | sse?: boolean; 48 | 49 | /** 50 | * Whether to authenticate message endpoint 51 | * @default true 52 | */ 53 | messages?: boolean; 54 | }; 55 | } 56 | 57 | /** 58 | * Default authentication error 59 | */ 60 | export const DEFAULT_AUTH_ERROR = { 61 | status: 401, 62 | message: "Unauthorized" 63 | }; 64 | -------------------------------------------------------------------------------- /src/cli/framework/build-cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { buildFramework } from './build.js'; 3 | 4 | if (process.argv[1].endsWith('mcp-build') || process.argv[1].endsWith('build-cli.js')) { 5 | buildFramework().catch(error => { 6 | process.stderr.write(`Fatal error: ${error instanceof Error ? error.message : String(error)}\n`); 7 | process.exit(1); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/cli/framework/build.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { execa } from "execa"; 3 | import { readFile, writeFile } from "fs/promises"; 4 | import { join, dirname } from "path"; 5 | import { findUp } from 'find-up'; 6 | 7 | export async function buildFramework() { 8 | process.stderr.write("MCP Build Script Starting...\n"); 9 | process.stderr.write("Finding project root...\n"); 10 | 11 | const startDir = process.cwd(); 12 | process.stderr.write(`Starting search from: ${startDir}\n`); 13 | 14 | const skipValidation = process.env.MCP_SKIP_VALIDATION === 'true'; 15 | if (skipValidation) { 16 | process.stderr.write(`Skipping dependency validation\n`); 17 | } 18 | 19 | try { 20 | const pkgPath = await findUp('package.json'); 21 | if (!pkgPath) { 22 | throw new Error("Could not find package.json in current directory or any parent directories"); 23 | } 24 | 25 | const projectRoot = dirname(pkgPath); 26 | 27 | if (!skipValidation) { 28 | const pkgContent = await readFile(pkgPath, 'utf8'); 29 | const pkg = JSON.parse(pkgContent); 30 | 31 | if (!pkg.dependencies?.["mcp-framework"]) { 32 | throw new Error("This directory is not an MCP project (mcp-framework not found in dependencies)"); 33 | } 34 | } 35 | 36 | process.stderr.write(`Running tsc in ${projectRoot}\n`); 37 | 38 | const tscCommand = process.platform === 'win32' ? ['npx.cmd', 'tsc'] : ['npx', 'tsc']; 39 | 40 | await execa(tscCommand[0], [tscCommand[1]], { 41 | cwd: projectRoot, 42 | stdio: "inherit", 43 | env: { 44 | ...process.env, 45 | ELECTRON_RUN_AS_NODE: "1", 46 | FORCE_COLOR: "1" 47 | } 48 | }); 49 | 50 | const distPath = join(projectRoot, "dist"); 51 | const projectIndexPath = join(distPath, "index.js"); 52 | const shebang = "#!/usr/bin/env node\n"; 53 | 54 | process.stderr.write("Adding shebang to index.js...\n"); 55 | try { 56 | const content = await readFile(projectIndexPath, "utf8"); 57 | if (!content.startsWith(shebang)) { 58 | await writeFile(projectIndexPath, shebang + content); 59 | } 60 | } catch (error) { 61 | process.stderr.write(`Error processing index.js: ${error instanceof Error ? error.message : String(error)}\n`); 62 | throw error; 63 | } 64 | 65 | process.stderr.write("Build completed successfully!\n"); 66 | } catch (error) { 67 | process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); 68 | process.exit(1); 69 | } 70 | } 71 | 72 | export default buildFramework; 73 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from "commander"; 3 | import { createProject } from "./project/create.js"; 4 | import { addTool } from "./project/add-tool.js"; 5 | import { addPrompt } from "./project/add-prompt.js"; 6 | import { addResource } from "./project/add-resource.js"; 7 | import { buildFramework } from "./framework/build.js"; 8 | 9 | const program = new Command(); 10 | 11 | program 12 | .name("mcp") 13 | .description("CLI for managing MCP server projects") 14 | .version("0.2.2"); 15 | 16 | program 17 | .command("build") 18 | .description("Build the MCP project") 19 | .action(buildFramework); 20 | 21 | program 22 | .command("create") 23 | .description("Create a new MCP server project") 24 | .argument("[name]", "project name") 25 | .option("--http", "use HTTP transport instead of default stdio") 26 | .option("--cors", "enable CORS with wildcard (*) access") 27 | .option("--port ", "specify HTTP port (only valid with --http)", (val) => parseInt(val, 10)) 28 | .option("--no-install", "skip npm install and build steps") 29 | .option("--no-example", "skip creating example tool") 30 | .action(createProject); 31 | 32 | program 33 | .command("add") 34 | .description("Add a new component to your MCP server") 35 | .addCommand( 36 | new Command("tool") 37 | .description("Add a new tool") 38 | .argument("[name]", "tool name") 39 | .action(addTool) 40 | ) 41 | .addCommand( 42 | new Command("prompt") 43 | .description("Add a new prompt") 44 | .argument("[name]", "prompt name") 45 | .action(addPrompt) 46 | ) 47 | .addCommand( 48 | new Command("resource") 49 | .description("Add a new resource") 50 | .argument("[name]", "resource name") 51 | .action(addResource) 52 | ); 53 | 54 | program.parse(); 55 | -------------------------------------------------------------------------------- /src/cli/project/add-prompt.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, mkdir } from "fs/promises"; 2 | import { join } from "path"; 3 | import prompts from "prompts"; 4 | import { toPascalCase } from "../utils/string-utils.js"; 5 | import { validateMCPProject } from "../utils/validate-project.js"; 6 | 7 | export async function addPrompt(name?: string) { 8 | await validateMCPProject(); 9 | 10 | let promptName = name; 11 | if (!promptName) { 12 | const response = await prompts([ 13 | { 14 | type: "text", 15 | name: "name", 16 | message: "What is the name of your prompt?", 17 | validate: (value: string) => 18 | /^[a-z0-9-]+$/.test(value) 19 | ? true 20 | : "Prompt name can only contain lowercase letters, numbers, and hyphens", 21 | }, 22 | ]); 23 | 24 | if (!response.name) { 25 | console.log("Prompt creation cancelled"); 26 | process.exit(1); 27 | } 28 | 29 | promptName = response.name; 30 | } 31 | 32 | if (!promptName) { 33 | throw new Error("Prompt name is required"); 34 | } 35 | 36 | const className = toPascalCase(promptName); 37 | const fileName = `${className}Prompt.ts`; 38 | const promptsDir = join(process.cwd(), "src/prompts"); 39 | 40 | try { 41 | await mkdir(promptsDir, { recursive: true }); 42 | 43 | const promptContent = `import { MCPPrompt } from "mcp-framework"; 44 | import { z } from "zod"; 45 | 46 | interface ${className}Input { 47 | message: string; 48 | } 49 | 50 | class ${className}Prompt extends MCPPrompt<${className}Input> { 51 | name = "${promptName}"; 52 | description = "${className} prompt description"; 53 | 54 | schema = { 55 | message: { 56 | type: z.string(), 57 | description: "Message to process", 58 | required: true, 59 | }, 60 | }; 61 | 62 | async generateMessages({ message }: ${className}Input) { 63 | return [ 64 | { 65 | role: "user", 66 | content: { 67 | type: "text", 68 | text: message, 69 | }, 70 | }, 71 | ]; 72 | } 73 | } 74 | 75 | export default ${className}Prompt;`; 76 | 77 | await writeFile(join(promptsDir, fileName), promptContent); 78 | 79 | console.log( 80 | `Prompt ${promptName} created successfully at src/prompts/${fileName}` 81 | ); 82 | } catch (error) { 83 | console.error("Error creating prompt:", error); 84 | process.exit(1); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/cli/project/add-resource.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, mkdir } from "fs/promises"; 2 | import { join } from "path"; 3 | import prompts from "prompts"; 4 | import { validateMCPProject } from "../utils/validate-project.js"; 5 | import { toPascalCase } from "../utils/string-utils.js"; 6 | 7 | export async function addResource(name?: string) { 8 | await validateMCPProject(); 9 | 10 | let resourceName = name; 11 | if (!resourceName) { 12 | const response = await prompts([ 13 | { 14 | type: "text", 15 | name: "name", 16 | message: "What is the name of your resource?", 17 | validate: (value: string) => 18 | /^[a-z0-9-]+$/.test(value) 19 | ? true 20 | : "Resource name can only contain lowercase letters, numbers, and hyphens", 21 | }, 22 | ]); 23 | 24 | if (!response.name) { 25 | console.log("Resource creation cancelled"); 26 | process.exit(1); 27 | } 28 | 29 | resourceName = response.name; 30 | } 31 | 32 | if (!resourceName) { 33 | throw new Error("Resource name is required"); 34 | } 35 | 36 | const className = toPascalCase(resourceName); 37 | const fileName = `${className}Resource.ts`; 38 | const resourcesDir = join(process.cwd(), "src/resources"); 39 | 40 | try { 41 | await mkdir(resourcesDir, { recursive: true }); 42 | 43 | const resourceContent = `import { MCPResource, ResourceContent } from "mcp-framework"; 44 | 45 | class ${className}Resource extends MCPResource { 46 | uri = "resource://${resourceName}"; 47 | name = "${className}"; 48 | description = "${className} resource description"; 49 | mimeType = "application/json"; 50 | 51 | async read(): Promise { 52 | return [ 53 | { 54 | uri: this.uri, 55 | mimeType: this.mimeType, 56 | text: JSON.stringify({ message: "Hello from ${className} resource" }), 57 | }, 58 | ]; 59 | } 60 | } 61 | 62 | export default ${className}Resource;`; 63 | 64 | await writeFile(join(resourcesDir, fileName), resourceContent); 65 | 66 | console.log( 67 | `Resource ${resourceName} created successfully at src/resources/${fileName}` 68 | ); 69 | } catch (error) { 70 | console.error("Error creating resource:", error); 71 | process.exit(1); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/cli/project/add-tool.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, mkdir } from "fs/promises"; 2 | import { join } from "path"; 3 | import prompts from "prompts"; 4 | import { validateMCPProject } from "../utils/validate-project.js"; 5 | import { toPascalCase } from "../utils/string-utils.js"; 6 | 7 | export async function addTool(name?: string) { 8 | await validateMCPProject(); 9 | 10 | let toolName = name; 11 | if (!toolName) { 12 | const response = await prompts([ 13 | { 14 | type: "text", 15 | name: "name", 16 | message: "What is the name of your tool?", 17 | validate: (value: string) => 18 | /^[a-z0-9-]+$/.test(value) 19 | ? true 20 | : "Tool name can only contain lowercase letters, numbers, and hyphens", 21 | }, 22 | ]); 23 | 24 | if (!response.name) { 25 | console.log("Tool creation cancelled"); 26 | process.exit(1); 27 | } 28 | 29 | toolName = response.name; 30 | } 31 | 32 | if (!toolName) { 33 | throw new Error("Tool name is required"); 34 | } 35 | 36 | const className = toPascalCase(toolName); 37 | const fileName = `${className}Tool.ts`; 38 | const toolsDir = join(process.cwd(), "src/tools"); 39 | 40 | try { 41 | await mkdir(toolsDir, { recursive: true }); 42 | 43 | const toolContent = `import { MCPTool } from "mcp-framework"; 44 | import { z } from "zod"; 45 | 46 | interface ${className}Input { 47 | message: string; 48 | } 49 | 50 | class ${className}Tool extends MCPTool<${className}Input> { 51 | name = "${toolName}"; 52 | description = "${className} tool description"; 53 | 54 | schema = { 55 | message: { 56 | type: z.string(), 57 | description: "Message to process", 58 | }, 59 | }; 60 | 61 | async execute(input: ${className}Input) { 62 | return \`Processed: \${input.message}\`; 63 | } 64 | } 65 | 66 | export default ${className}Tool;`; 67 | 68 | await writeFile(join(toolsDir, fileName), toolContent); 69 | 70 | console.log( 71 | `Tool ${toolName} created successfully at src/tools/${fileName}` 72 | ); 73 | } catch (error) { 74 | console.error("Error creating tool:", error); 75 | process.exit(1); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/cli/project/create.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from "child_process"; 2 | import { mkdir, writeFile } from "fs/promises"; 3 | import { join } from "path"; 4 | import prompts from "prompts"; 5 | import { generateReadme } from "../templates/readme.js"; 6 | import { execa } from "execa"; 7 | 8 | export async function createProject(name?: string, options?: { http?: boolean, cors?: boolean, port?: number, install?: boolean, example?: boolean }) { 9 | let projectName: string; 10 | // Default install and example to true if not specified 11 | const shouldInstall = options?.install !== false; 12 | const shouldCreateExample = options?.example !== false; 13 | 14 | if (!name) { 15 | const response = await prompts([ 16 | { 17 | type: "text", 18 | name: "projectName", 19 | message: "What is the name of your MCP server project?", 20 | validate: (value: string) => 21 | /^[a-z0-9-]+$/.test(value) 22 | ? true 23 | : "Project name can only contain lowercase letters, numbers, and hyphens", 24 | }, 25 | ]); 26 | 27 | if (!response.projectName) { 28 | console.log("Project creation cancelled"); 29 | process.exit(1); 30 | } 31 | 32 | projectName = response.projectName as string; 33 | } else { 34 | projectName = name; 35 | } 36 | 37 | if (!projectName) { 38 | throw new Error("Project name is required"); 39 | } 40 | 41 | const projectDir = join(process.cwd(), projectName); 42 | const srcDir = join(projectDir, "src"); 43 | const toolsDir = join(srcDir, "tools"); 44 | 45 | try { 46 | console.log("Creating project structure..."); 47 | await mkdir(projectDir); 48 | await mkdir(srcDir); 49 | await mkdir(toolsDir); 50 | 51 | const packageJson = { 52 | name: projectName, 53 | version: "0.0.1", 54 | description: `${projectName} MCP server`, 55 | type: "module", 56 | bin: { 57 | [projectName]: "./dist/index.js", 58 | }, 59 | files: ["dist"], 60 | scripts: { 61 | build: "tsc && mcp-build", 62 | watch: "tsc --watch", 63 | start: "node dist/index.js" 64 | }, 65 | dependencies: { 66 | "mcp-framework": "^0.2.2" 67 | }, 68 | devDependencies: { 69 | "@types/node": "^20.11.24", 70 | "typescript": "^5.3.3" 71 | }, 72 | engines: { 73 | "node": ">=18.19.0" 74 | } 75 | }; 76 | 77 | const tsconfig = { 78 | compilerOptions: { 79 | target: "ESNext", 80 | module: "ESNext", 81 | moduleResolution: "node", 82 | outDir: "./dist", 83 | rootDir: "./src", 84 | strict: true, 85 | esModuleInterop: true, 86 | skipLibCheck: true, 87 | forceConsistentCasingInFileNames: true, 88 | }, 89 | include: ["src/**/*"], 90 | exclude: ["node_modules"], 91 | }; 92 | 93 | let indexTs = ""; 94 | 95 | if (options?.http) { 96 | const port = options.port || 8080; 97 | let transportConfig = `\n transport: { 98 | type: "http-stream", 99 | options: { 100 | port: ${port}`; 101 | 102 | if (options.cors) { 103 | transportConfig += `, 104 | cors: { 105 | allowOrigin: "*" 106 | }`; 107 | } 108 | 109 | transportConfig += ` 110 | } 111 | }`; 112 | 113 | indexTs = `import { MCPServer } from "mcp-framework"; 114 | 115 | const server = new MCPServer({${transportConfig}}); 116 | 117 | server.start();`; 118 | } else { 119 | indexTs = `import { MCPServer } from "mcp-framework"; 120 | 121 | const server = new MCPServer(); 122 | 123 | server.start();`; 124 | } 125 | 126 | const exampleToolTs = `import { MCPTool } from "mcp-framework"; 127 | import { z } from "zod"; 128 | 129 | interface ExampleInput { 130 | message: string; 131 | } 132 | 133 | class ExampleTool extends MCPTool { 134 | name = "example_tool"; 135 | description = "An example tool that processes messages"; 136 | 137 | schema = { 138 | message: { 139 | type: z.string(), 140 | description: "Message to process", 141 | }, 142 | }; 143 | 144 | async execute(input: ExampleInput) { 145 | return \`Processed: \${input.message}\`; 146 | } 147 | } 148 | 149 | export default ExampleTool;`; 150 | 151 | // Prepare the files to write 152 | const filesToWrite = [ 153 | writeFile(join(projectDir, "package.json"), JSON.stringify(packageJson, null, 2)), 154 | writeFile(join(projectDir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2)), 155 | writeFile(join(projectDir, "README.md"), generateReadme(projectName)), 156 | writeFile(join(srcDir, "index.ts"), indexTs), 157 | ]; 158 | 159 | // Conditionally add the example tool 160 | if (shouldCreateExample) { 161 | filesToWrite.push(writeFile(join(toolsDir, "ExampleTool.ts"), exampleToolTs)); 162 | } 163 | 164 | console.log("Creating project files..."); 165 | await Promise.all(filesToWrite); 166 | 167 | process.chdir(projectDir); 168 | 169 | console.log("Initializing git repository..."); 170 | const gitInit = spawnSync("git", ["init"], { 171 | stdio: "inherit", 172 | shell: true, 173 | }); 174 | 175 | if (gitInit.status !== 0) { 176 | throw new Error("Failed to initialize git repository"); 177 | } 178 | 179 | if (shouldInstall) { 180 | console.log("Installing dependencies..."); 181 | const npmInstall = spawnSync("npm", ["install"], { 182 | stdio: "inherit", 183 | shell: true 184 | }); 185 | 186 | if (npmInstall.status !== 0) { 187 | throw new Error("Failed to install dependencies"); 188 | } 189 | 190 | console.log("Building project..."); 191 | const tscBuild = await execa('npx', ['tsc'], { 192 | cwd: projectDir, 193 | stdio: "inherit", 194 | }); 195 | 196 | if (tscBuild.exitCode !== 0) { 197 | throw new Error("Failed to build TypeScript"); 198 | } 199 | 200 | const mcpBuild = await execa('npx', ['mcp-build'], { 201 | cwd: projectDir, 202 | stdio: "inherit", 203 | env: { 204 | ...process.env, 205 | MCP_SKIP_VALIDATION: "true" 206 | } 207 | }); 208 | 209 | if (mcpBuild.exitCode !== 0) { 210 | throw new Error("Failed to run mcp-build"); 211 | } 212 | 213 | console.log(` 214 | Project ${projectName} created and built successfully! 215 | 216 | You can now: 217 | 1. cd ${projectName} 218 | 2. Add more tools using: 219 | mcp add tool 220 | `); 221 | } else { 222 | console.log(` 223 | Project ${projectName} created successfully (without dependencies)! 224 | 225 | You can now: 226 | 1. cd ${projectName} 227 | 2. Run 'npm install' to install dependencies 228 | 3. Run 'npm run build' to build the project 229 | 4. Add more tools using: 230 | mcp add tool 231 | `); 232 | } 233 | } catch (error) { 234 | console.error("Error creating project:", error); 235 | process.exit(1); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/cli/templates/readme.ts: -------------------------------------------------------------------------------- 1 | export function generateReadme(projectName: string): string { 2 | return `# ${projectName} 3 | 4 | A Model Context Protocol (MCP) server built with mcp-framework. 5 | 6 | ## Quick Start 7 | 8 | \`\`\`bash 9 | # Install dependencies 10 | npm install 11 | 12 | # Build the project 13 | npm run build 14 | 15 | \`\`\` 16 | 17 | ## Project Structure 18 | 19 | \`\`\` 20 | ${projectName}/ 21 | ├── src/ 22 | │ ├── tools/ # MCP Tools 23 | │ │ └── ExampleTool.ts 24 | │ └── index.ts # Server entry point 25 | ├── package.json 26 | └── tsconfig.json 27 | \`\`\` 28 | 29 | ## Adding Components 30 | 31 | The project comes with an example tool in \`src/tools/ExampleTool.ts\`. You can add more tools using the CLI: 32 | 33 | \`\`\`bash 34 | # Add a new tool 35 | mcp add tool my-tool 36 | 37 | # Example tools you might create: 38 | mcp add tool data-processor 39 | mcp add tool api-client 40 | mcp add tool file-handler 41 | \`\`\` 42 | 43 | ## Tool Development 44 | 45 | Example tool structure: 46 | 47 | \`\`\`typescript 48 | import { MCPTool } from "mcp-framework"; 49 | import { z } from "zod"; 50 | 51 | interface MyToolInput { 52 | message: string; 53 | } 54 | 55 | class MyTool extends MCPTool { 56 | name = "my_tool"; 57 | description = "Describes what your tool does"; 58 | 59 | schema = { 60 | message: { 61 | type: z.string(), 62 | description: "Description of this input parameter", 63 | }, 64 | }; 65 | 66 | async execute(input: MyToolInput) { 67 | // Your tool logic here 68 | return \`Processed: \${input.message}\`; 69 | } 70 | } 71 | 72 | export default MyTool; 73 | \`\`\` 74 | 75 | ## Publishing to npm 76 | 77 | 1. Update your package.json: 78 | - Ensure \`name\` is unique and follows npm naming conventions 79 | - Set appropriate \`version\` 80 | - Add \`description\`, \`author\`, \`license\`, etc. 81 | - Check \`bin\` points to the correct entry file 82 | 83 | 2. Build and test locally: 84 | \`\`\`bash 85 | npm run build 86 | npm link 87 | ${projectName} # Test your CLI locally 88 | \`\`\` 89 | 90 | 3. Login to npm (create account if necessary): 91 | \`\`\`bash 92 | npm login 93 | \`\`\` 94 | 95 | 4. Publish your package: 96 | \`\`\`bash 97 | npm publish 98 | \`\`\` 99 | 100 | After publishing, users can add it to their claude desktop client (read below) or run it with npx 101 | \`\`\` 102 | 103 | ## Using with Claude Desktop 104 | 105 | ### Local Development 106 | 107 | Add this configuration to your Claude Desktop config file: 108 | 109 | **MacOS**: \`~/Library/Application Support/Claude/claude_desktop_config.json\` 110 | **Windows**: \`%APPDATA%/Claude/claude_desktop_config.json\` 111 | 112 | \`\`\`json 113 | { 114 | "mcpServers": { 115 | "${projectName}": { 116 | "command": "node", 117 | "args":["/absolute/path/to/${projectName}/dist/index.js"] 118 | } 119 | } 120 | } 121 | \`\`\` 122 | 123 | ### After Publishing 124 | 125 | Add this configuration to your Claude Desktop config file: 126 | 127 | **MacOS**: \`~/Library/Application Support/Claude/claude_desktop_config.json\` 128 | **Windows**: \`%APPDATA%/Claude/claude_desktop_config.json\` 129 | 130 | \`\`\`json 131 | { 132 | "mcpServers": { 133 | "${projectName}": { 134 | "command": "npx", 135 | "args": ["${projectName}"] 136 | } 137 | } 138 | } 139 | \`\`\` 140 | 141 | ## Building and Testing 142 | 143 | 1. Make changes to your tools 144 | 2. Run \`npm run build\` to compile 145 | 3. The server will automatically load your tools on startup 146 | 147 | ## Learn More 148 | 149 | - [MCP Framework Github](https://github.com/QuantGeekDev/mcp-framework) 150 | - [MCP Framework Docs](https://mcp-framework.com) 151 | `; 152 | } 153 | -------------------------------------------------------------------------------- /src/cli/utils/string-utils.ts: -------------------------------------------------------------------------------- 1 | export function toPascalCase(str: string): string { 2 | return str 3 | .split(/[-_]/) 4 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) 5 | .join(""); 6 | } 7 | -------------------------------------------------------------------------------- /src/cli/utils/validate-project.ts: -------------------------------------------------------------------------------- 1 | 2 | import { readFile } from "fs/promises"; 3 | import { findUp } from 'find-up'; 4 | import { logger } from "../../core/Logger.js"; 5 | 6 | 7 | export async function validateMCPProject() { 8 | try { 9 | const packageJsonPath = await findUp('package.json'); 10 | 11 | if (!packageJsonPath) { 12 | throw new Error("Could not find package.json in current directory or any parent directories"); 13 | } 14 | const package_json = JSON.parse(await readFile(packageJsonPath, "utf-8")); 15 | if ( 16 | !package_json.dependencies?.["mcp-framework"] && 17 | !package_json.devDependencies?.["mcp-framework"] 18 | ) { 19 | throw new Error( 20 | "This directory is not an MCP project (mcp-framework not found in dependencies or devDependencies)" 21 | ); 22 | } 23 | } catch (error) { 24 | console.error("Error: Must be run from an MCP project directory"); 25 | logger.error(`Project validation failed: ${(error as Error).message}`); 26 | process.exit(1); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/Logger.ts: -------------------------------------------------------------------------------- 1 | import { createWriteStream, WriteStream } from "fs"; 2 | import { join } from "path"; 3 | import { mkdir } from "fs/promises"; 4 | 5 | export class Logger { 6 | private static instance: Logger; 7 | private logStream: WriteStream | null = null; 8 | private logFilePath: string = ''; 9 | private logToFile: boolean = false; 10 | 11 | private constructor() { 12 | this.logToFile = process.env.MCP_ENABLE_FILE_LOGGING === 'true'; 13 | 14 | if (this.logToFile) { 15 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 16 | const logDir = process.env.MCP_LOG_DIRECTORY || "logs"; 17 | 18 | this.initFileLogging(logDir, timestamp); 19 | } 20 | 21 | process.on("exit", () => this.close()); 22 | process.on("SIGINT", () => this.close()); 23 | process.on("SIGTERM", () => this.close()); 24 | } 25 | 26 | private async initFileLogging(logDir: string, timestamp: string): Promise { 27 | try { 28 | await mkdir(logDir, { recursive: true }); 29 | this.logFilePath = join(logDir, `mcp-server-${timestamp}.log`); 30 | this.logStream = createWriteStream(this.logFilePath, { flags: "a" }); 31 | this.info(`File logging enabled, writing to: ${this.logFilePath}`); 32 | } catch (err) { 33 | process.stderr.write(`Failed to initialize file logging: ${err}\n`); 34 | this.logToFile = false; 35 | } 36 | } 37 | 38 | public static getInstance(): Logger { 39 | if (!Logger.instance) { 40 | Logger.instance = new Logger(); 41 | } 42 | return Logger.instance; 43 | } 44 | 45 | private getTimestamp(): string { 46 | return new Date().toISOString(); 47 | } 48 | 49 | private formatMessage(level: string, message: string): string { 50 | return `[${this.getTimestamp()}] [${level}] ${message}\n`; 51 | } 52 | 53 | public info(message: string): void { 54 | const formattedMessage = this.formatMessage("INFO", message); 55 | if (this.logToFile && this.logStream) { 56 | this.logStream.write(formattedMessage); 57 | } 58 | process.stderr.write(formattedMessage); 59 | } 60 | 61 | public log(message: string): void { 62 | this.info(message); 63 | } 64 | 65 | public error(message: string): void { 66 | const formattedMessage = this.formatMessage("ERROR", message); 67 | if (this.logToFile && this.logStream) { 68 | this.logStream.write(formattedMessage); 69 | } 70 | process.stderr.write(formattedMessage); 71 | } 72 | 73 | public warn(message: string): void { 74 | const formattedMessage = this.formatMessage("WARN", message); 75 | if (this.logToFile && this.logStream) { 76 | this.logStream.write(formattedMessage); 77 | } 78 | process.stderr.write(formattedMessage); 79 | } 80 | 81 | public debug(message: string): void { 82 | const formattedMessage = this.formatMessage("DEBUG", message); 83 | if (this.logToFile && this.logStream) { 84 | this.logStream.write(formattedMessage); 85 | } 86 | if (process.env.MCP_DEBUG_CONSOLE === 'true') { 87 | process.stderr.write(formattedMessage); 88 | } 89 | } 90 | 91 | public close(): void { 92 | if (this.logStream) { 93 | this.logStream.end(); 94 | this.logStream = null; 95 | } 96 | } 97 | 98 | public getLogPath(): string { 99 | return this.logFilePath; 100 | } 101 | 102 | public isFileLoggingEnabled(): boolean { 103 | return this.logToFile; 104 | } 105 | } 106 | 107 | export const logger = Logger.getInstance(); 108 | -------------------------------------------------------------------------------- /src/core/MCPServer.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { 3 | CallToolRequestSchema, 4 | ListToolsRequestSchema, 5 | ListPromptsRequestSchema, 6 | GetPromptRequestSchema, 7 | ListResourcesRequestSchema, 8 | ListResourceTemplatesRequestSchema, 9 | ReadResourceRequestSchema, 10 | SubscribeRequestSchema, 11 | UnsubscribeRequestSchema, 12 | } from '@modelcontextprotocol/sdk/types.js'; 13 | import { ToolProtocol } from '../tools/BaseTool.js'; 14 | import { PromptProtocol } from '../prompts/BasePrompt.js'; 15 | import { ResourceProtocol } from '../resources/BaseResource.js'; 16 | import { readFileSync } from 'fs'; 17 | import { join, resolve, dirname } from 'path'; 18 | import { logger } from './Logger.js'; 19 | import { ToolLoader } from '../loaders/toolLoader.js'; 20 | import { PromptLoader } from '../loaders/promptLoader.js'; 21 | import { ResourceLoader } from '../loaders/resourceLoader.js'; 22 | import { BaseTransport } from '../transports/base.js'; 23 | import { StdioServerTransport } from '../transports/stdio/server.js'; 24 | import { SSEServerTransport } from '../transports/sse/server.js'; 25 | import { SSETransportConfig, DEFAULT_SSE_CONFIG } from '../transports/sse/types.js'; 26 | import { HttpStreamTransport } from '../transports/http/server.js'; 27 | import { HttpStreamTransportConfig, DEFAULT_HTTP_STREAM_CONFIG } from '../transports/http/types.js'; 28 | import { DEFAULT_CORS_CONFIG } from '../transports/sse/types.js'; 29 | import { AuthConfig } from '../auth/types.js'; 30 | import { createRequire } from 'module'; 31 | 32 | const require = createRequire(import.meta.url); 33 | export type TransportType = 'stdio' | 'sse' | 'http-stream'; 34 | 35 | export interface TransportConfig { 36 | type: TransportType; 37 | options?: SSETransportConfig | HttpStreamTransportConfig; 38 | auth?: AuthConfig; 39 | } 40 | 41 | export interface MCPServerConfig { 42 | name?: string; 43 | version?: string; 44 | basePath?: string; 45 | transport?: TransportConfig; 46 | } 47 | 48 | export type ServerCapabilities = { 49 | tools?: { 50 | listChanged?: true; // Optional: Indicates support for list change notifications 51 | }; 52 | prompts?: { 53 | listChanged?: true; // Optional: Indicates support for list change notifications 54 | }; 55 | resources?: { 56 | listChanged?: true; // Optional: Indicates support for list change notifications 57 | subscribe?: true; // Optional: Indicates support for resource subscriptions 58 | }; 59 | }; 60 | 61 | export class MCPServer { 62 | private server!: Server; 63 | private toolsMap: Map = new Map(); 64 | private promptsMap: Map = new Map(); 65 | private resourcesMap: Map = new Map(); 66 | private toolLoader: ToolLoader; 67 | private promptLoader: PromptLoader; 68 | private resourceLoader: ResourceLoader; 69 | private serverName: string; 70 | private serverVersion: string; 71 | private basePath: string; 72 | private transportConfig: TransportConfig; 73 | private capabilities: ServerCapabilities = {}; 74 | private isRunning: boolean = false; 75 | private transport?: BaseTransport; 76 | private shutdownPromise?: Promise; 77 | private shutdownResolve?: () => void; 78 | 79 | constructor(config: MCPServerConfig = {}) { 80 | this.basePath = this.resolveBasePath(config.basePath); 81 | this.serverName = config.name ?? this.getDefaultName(); 82 | this.serverVersion = config.version ?? this.getDefaultVersion(); 83 | this.transportConfig = config.transport ?? { type: 'stdio' }; 84 | 85 | if (this.transportConfig.auth && this.transportConfig.options) { 86 | (this.transportConfig.options as any).auth = this.transportConfig.auth; 87 | } else if (this.transportConfig.auth && !this.transportConfig.options) { 88 | this.transportConfig.options = { auth: this.transportConfig.auth } as any; 89 | } 90 | 91 | logger.info(`Initializing MCP Server: ${this.serverName}@${this.serverVersion}`); 92 | logger.debug(`Base path: ${this.basePath}`); 93 | logger.debug(`Transport config: ${JSON.stringify(this.transportConfig)}`); 94 | 95 | this.toolLoader = new ToolLoader(this.basePath); 96 | this.promptLoader = new PromptLoader(this.basePath); 97 | this.resourceLoader = new ResourceLoader(this.basePath); 98 | 99 | this.server = new Server( 100 | { name: this.serverName, version: this.serverVersion }, 101 | { capabilities: this.capabilities } 102 | ); 103 | logger.debug(`SDK Server instance created.`); 104 | } 105 | 106 | private resolveBasePath(configPath?: string): string { 107 | if (configPath) { 108 | return configPath; 109 | } 110 | if (process.argv[1]) { 111 | return process.argv[1]; 112 | } 113 | return process.cwd(); 114 | } 115 | 116 | private createTransport(): BaseTransport { 117 | logger.debug(`Creating transport: ${this.transportConfig.type}`); 118 | 119 | let transport: BaseTransport; 120 | const options = this.transportConfig.options || {}; 121 | const authConfig = this.transportConfig.auth ?? (options as any).auth; 122 | 123 | switch (this.transportConfig.type) { 124 | case 'sse': { 125 | const sseConfig: SSETransportConfig = { 126 | ...DEFAULT_SSE_CONFIG, 127 | ...(options as SSETransportConfig), 128 | cors: { ...DEFAULT_CORS_CONFIG, ...(options as SSETransportConfig).cors }, 129 | auth: authConfig, 130 | }; 131 | transport = new SSEServerTransport(sseConfig); 132 | break; 133 | } 134 | case 'http-stream': { 135 | const httpConfig: HttpStreamTransportConfig = { 136 | ...DEFAULT_HTTP_STREAM_CONFIG, 137 | ...(options as HttpStreamTransportConfig), 138 | cors: { 139 | ...DEFAULT_CORS_CONFIG, 140 | ...((options as HttpStreamTransportConfig).cors || {}), 141 | }, 142 | auth: authConfig, 143 | }; 144 | logger.debug(`Creating HttpStreamTransport. response mode: ${httpConfig.responseMode}`); 145 | transport = new HttpStreamTransport(httpConfig); 146 | break; 147 | } 148 | case 'stdio': 149 | default: 150 | if (this.transportConfig.type !== 'stdio') { 151 | logger.warn(`Unsupported type '${this.transportConfig.type}', defaulting to stdio.`); 152 | } 153 | transport = new StdioServerTransport(); 154 | break; 155 | } 156 | 157 | transport.onclose = () => { 158 | logger.info(`Transport (${transport.type}) closed.`); 159 | if (this.isRunning) { 160 | this.stop().catch((error) => { 161 | logger.error(`Shutdown error after transport close: ${error}`); 162 | process.exit(1); 163 | }); 164 | } 165 | }; 166 | 167 | transport.onerror = (error: Error) => { 168 | logger.error(`Transport (${transport.type}) error: ${error.message}\n${error.stack}`); 169 | }; 170 | return transport; 171 | } 172 | 173 | private readPackageJson(): any { 174 | try { 175 | const projectRoot = process.cwd(); 176 | const packagePath = join(projectRoot, 'package.json'); 177 | 178 | try { 179 | const packageContent = readFileSync(packagePath, 'utf-8'); 180 | const packageJson = JSON.parse(packageContent); 181 | logger.debug(`Successfully read package.json from project root: ${packagePath}`); 182 | return packageJson; 183 | } catch (error) { 184 | logger.warn(`Could not read package.json from project root: ${error}`); 185 | return null; 186 | } 187 | } catch (error) { 188 | logger.warn(`Could not read package.json: ${error}`); 189 | return null; 190 | } 191 | } 192 | 193 | private getDefaultName(): string { 194 | const packageJson = this.readPackageJson(); 195 | if (packageJson?.name) { 196 | return packageJson.name; 197 | } 198 | logger.error("Couldn't find project name in package json"); 199 | return 'unnamed-mcp-server'; 200 | } 201 | 202 | private getDefaultVersion(): string { 203 | const packageJson = this.readPackageJson(); 204 | if (packageJson?.version) { 205 | return packageJson.version; 206 | } 207 | return '0.0.0'; 208 | } 209 | 210 | private setupHandlers() { 211 | // TODO: Replace 'any' with the specific inferred request type from the SDK schema if available 212 | this.server.setRequestHandler(ListToolsRequestSchema, async (request: any) => { 213 | logger.debug(`Received ListTools request: ${JSON.stringify(request)}`); 214 | 215 | const tools = Array.from(this.toolsMap.values()).map((tool) => tool.toolDefinition); 216 | 217 | logger.debug(`Found ${tools.length} tools to return`); 218 | logger.debug(`Tool definitions: ${JSON.stringify(tools)}`); 219 | 220 | const response = { 221 | tools: tools, 222 | nextCursor: undefined, 223 | }; 224 | 225 | logger.debug(`Sending ListTools response: ${JSON.stringify(response)}`); 226 | return response; 227 | }); 228 | 229 | // TODO: Replace 'any' with the specific inferred request type from the SDK schema if available 230 | this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => { 231 | logger.debug(`Tool call request received for: ${request.params.name}`); 232 | logger.debug(`Tool call arguments: ${JSON.stringify(request.params.arguments)}`); 233 | 234 | const tool = this.toolsMap.get(request.params.name); 235 | if (!tool) { 236 | const availableTools = Array.from(this.toolsMap.keys()); 237 | const errorMsg = `Unknown tool: ${request.params.name}. Available tools: ${availableTools.join(', ')}`; 238 | logger.error(errorMsg); 239 | throw new Error(errorMsg); 240 | } 241 | 242 | try { 243 | logger.debug(`Executing tool: ${tool.name}`); 244 | const toolRequest = { 245 | params: request.params, 246 | method: 'tools/call' as const, 247 | }; 248 | 249 | const result = await tool.toolCall(toolRequest); 250 | logger.debug(`Tool execution successful: ${JSON.stringify(result)}`); 251 | return result; 252 | } catch (error) { 253 | const errorMsg = `Tool execution failed: ${error}`; 254 | logger.error(errorMsg); 255 | throw new Error(errorMsg); 256 | } 257 | }); 258 | 259 | if (this.capabilities.prompts) { 260 | this.server.setRequestHandler(ListPromptsRequestSchema, async () => { 261 | return { 262 | prompts: Array.from(this.promptsMap.values()).map((prompt) => prompt.promptDefinition), 263 | }; 264 | }); 265 | 266 | // TODO: Replace 'any' with the specific inferred request type from the SDK schema if available 267 | this.server.setRequestHandler(GetPromptRequestSchema, async (request: any) => { 268 | const prompt = this.promptsMap.get(request.params.name); 269 | if (!prompt) { 270 | throw new Error( 271 | `Unknown prompt: ${request.params.name}. Available prompts: ${Array.from( 272 | this.promptsMap.keys() 273 | ).join(', ')}` 274 | ); 275 | } 276 | 277 | return { 278 | messages: await prompt.getMessages(request.params.arguments), 279 | }; 280 | }); 281 | } 282 | 283 | if (this.capabilities.resources) { 284 | this.server.setRequestHandler(ListResourcesRequestSchema, async () => { 285 | return { 286 | resources: Array.from(this.resourcesMap.values()).map( 287 | (resource) => resource.resourceDefinition 288 | ), 289 | }; 290 | }); 291 | 292 | // TODO: Replace 'any' with the specific inferred request type from the SDK schema if available 293 | this.server.setRequestHandler(ReadResourceRequestSchema, async (request: any) => { 294 | const resource = this.resourcesMap.get(request.params.uri); 295 | if (!resource) { 296 | throw new Error( 297 | `Unknown resource: ${request.params.uri}. Available resources: ${Array.from( 298 | this.resourcesMap.keys() 299 | ).join(', ')}` 300 | ); 301 | } 302 | 303 | return { 304 | contents: await resource.read(), 305 | }; 306 | }); 307 | 308 | this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { 309 | logger.debug(`Received ListResourceTemplates request`); 310 | const response = { 311 | resourceTemplates: [], 312 | nextCursor: undefined, 313 | }; 314 | logger.debug(`Sending ListResourceTemplates response: ${JSON.stringify(response)}`); 315 | return response; 316 | }); 317 | 318 | // TODO: Replace 'any' with the specific inferred request type from the SDK schema if available 319 | this.server.setRequestHandler(SubscribeRequestSchema, async (request: any) => { 320 | const resource = this.resourcesMap.get(request.params.uri); 321 | if (!resource) { 322 | throw new Error(`Unknown resource: ${request.params.uri}`); 323 | } 324 | 325 | if (!resource.subscribe) { 326 | throw new Error(`Resource ${request.params.uri} does not support subscriptions`); 327 | } 328 | 329 | await resource.subscribe(); 330 | return {}; 331 | }); 332 | 333 | // TODO: Replace 'any' with the specific inferred request type from the SDK schema if available 334 | this.server.setRequestHandler(UnsubscribeRequestSchema, async (request: any) => { 335 | const resource = this.resourcesMap.get(request.params.uri); 336 | if (!resource) { 337 | throw new Error(`Unknown resource: ${request.params.uri}`); 338 | } 339 | 340 | if (!resource.unsubscribe) { 341 | throw new Error(`Resource ${request.params.uri} does not support subscriptions`); 342 | } 343 | 344 | await resource.unsubscribe(); 345 | return {}; 346 | }); 347 | } 348 | } 349 | 350 | private async detectCapabilities(): Promise { 351 | if (await this.toolLoader.hasTools()) { 352 | this.capabilities.tools = {}; 353 | logger.debug('Tools capability enabled'); 354 | } 355 | 356 | if (await this.promptLoader.hasPrompts()) { 357 | this.capabilities.prompts = {}; 358 | logger.debug('Prompts capability enabled'); 359 | } 360 | 361 | if (await this.resourceLoader.hasResources()) { 362 | this.capabilities.resources = {}; 363 | logger.debug('Resources capability enabled'); 364 | } 365 | 366 | (this.server as any).updateCapabilities?.(this.capabilities); 367 | logger.debug(`Capabilities updated: ${JSON.stringify(this.capabilities)}`); 368 | 369 | return this.capabilities; 370 | } 371 | 372 | private getSdkVersion(): string { 373 | try { 374 | const sdkSpecificFile = require.resolve('@modelcontextprotocol/sdk/server/index.js'); 375 | 376 | const sdkRootDir = resolve(dirname(sdkSpecificFile), '..', '..', '..'); 377 | 378 | const correctPackageJsonPath = join(sdkRootDir, 'package.json'); 379 | 380 | const packageContent = readFileSync(correctPackageJsonPath, 'utf-8'); 381 | 382 | const packageJson = JSON.parse(packageContent); 383 | 384 | if (packageJson?.version) { 385 | logger.debug(`Found SDK version: ${packageJson.version}`); 386 | return packageJson.version; 387 | } else { 388 | logger.warn('Could not determine SDK version from its package.json.'); 389 | return 'unknown'; 390 | } 391 | } catch (error: any) { 392 | logger.warn(`Failed to read SDK package.json: ${error.message}`); 393 | return 'unknown'; 394 | } 395 | } 396 | 397 | async start() { 398 | try { 399 | if (this.isRunning) { 400 | throw new Error('Server is already running'); 401 | } 402 | this.isRunning = true; 403 | 404 | const frameworkPackageJson = require('../../package.json'); 405 | const frameworkVersion = frameworkPackageJson.version || 'unknown'; 406 | const sdkVersion = this.getSdkVersion(); 407 | logger.info(`Starting MCP server: (Framework: ${frameworkVersion}, SDK: ${sdkVersion})...`); 408 | 409 | const tools = await this.toolLoader.loadTools(); 410 | this.toolsMap = new Map(tools.map((tool: ToolProtocol) => [tool.name, tool])); 411 | 412 | const prompts = await this.promptLoader.loadPrompts(); 413 | this.promptsMap = new Map(prompts.map((prompt: PromptProtocol) => [prompt.name, prompt])); 414 | 415 | const resources = await this.resourceLoader.loadResources(); 416 | this.resourcesMap = new Map( 417 | resources.map((resource: ResourceProtocol) => [resource.uri, resource]) 418 | ); 419 | 420 | await this.detectCapabilities(); 421 | logger.info(`Capabilities detected: ${JSON.stringify(this.capabilities)}`); 422 | 423 | this.setupHandlers(); 424 | 425 | this.transport = this.createTransport(); 426 | 427 | logger.info(`Connecting transport (${this.transport.type}) to SDK Server...`); 428 | await this.server.connect(this.transport); 429 | 430 | logger.info( 431 | `Started ${this.serverName}@${this.serverVersion} successfully on transport ${this.transport.type}` 432 | ); 433 | 434 | logger.info(`Tools (${tools.length}): ${tools.map((t) => t.name).join(', ') || 'None'}`); 435 | if (this.capabilities.prompts) { 436 | logger.info( 437 | `Prompts (${prompts.length}): ${prompts.map((p) => p.name).join(', ') || 'None'}` 438 | ); 439 | } 440 | if (this.capabilities.resources) { 441 | logger.info( 442 | `Resources (${resources.length}): ${resources.map((r) => r.uri).join(', ') || 'None'}` 443 | ); 444 | } 445 | 446 | const shutdownHandler = async (signal: string) => { 447 | if (!this.isRunning) return; 448 | logger.info(`Received ${signal}. Shutting down...`); 449 | try { 450 | await this.stop(); 451 | } catch (e: any) { 452 | logger.error(`Shutdown error via ${signal}: ${e.message}`); 453 | process.exit(1); 454 | } 455 | }; 456 | 457 | process.on('SIGINT', () => shutdownHandler('SIGINT')); 458 | process.on('SIGTERM', () => shutdownHandler('SIGTERM')); 459 | 460 | this.shutdownPromise = new Promise((resolve) => { 461 | this.shutdownResolve = resolve; 462 | }); 463 | 464 | logger.info('Server running and ready.'); 465 | await this.shutdownPromise; 466 | } catch (error: any) { 467 | logger.error(`Server failed to start: ${error.message}\n${error.stack}`); 468 | this.isRunning = false; 469 | throw error; 470 | } 471 | } 472 | 473 | async stop() { 474 | if (!this.isRunning) { 475 | logger.debug('Stop called, but server not running.'); 476 | return; 477 | } 478 | 479 | try { 480 | logger.info('Stopping server...'); 481 | 482 | let transportError: Error | null = null; 483 | let sdkServerError: Error | null = null; 484 | 485 | if (this.transport) { 486 | try { 487 | logger.debug(`Closing transport (${this.transport.type})...`); 488 | await this.transport.close(); 489 | logger.info(`Transport closed.`); 490 | } catch (e: any) { 491 | transportError = e; 492 | logger.error(`Error closing transport: ${e.message}`); 493 | } 494 | this.transport = undefined; 495 | } 496 | 497 | if (this.server) { 498 | try { 499 | logger.debug('Closing SDK Server...'); 500 | await this.server.close(); 501 | logger.info('SDK Server closed.'); 502 | } catch (e: any) { 503 | sdkServerError = e; 504 | logger.error(`Error closing SDK Server: ${e.message}`); 505 | } 506 | } 507 | 508 | this.isRunning = false; 509 | 510 | if (this.shutdownResolve) { 511 | this.shutdownResolve(); 512 | logger.debug('Shutdown promise resolved.'); 513 | } else { 514 | logger.warn('Shutdown resolve function not found.'); 515 | } 516 | 517 | if (transportError || sdkServerError) { 518 | logger.error('Errors occurred during server stop.'); 519 | throw new Error( 520 | `Server stop failed. TransportError: ${transportError?.message}, SDKServerError: ${sdkServerError?.message}` 521 | ); 522 | } 523 | 524 | logger.info('MCP server stopped successfully.'); 525 | } catch (error) { 526 | logger.error(`Error stopping server: ${error}`); 527 | throw error; 528 | } 529 | } 530 | 531 | get IsRunning(): boolean { 532 | return this.isRunning; 533 | } 534 | } 535 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core/MCPServer.js"; 2 | export * from "./core/Logger.js"; 3 | 4 | export * from "./tools/BaseTool.js"; 5 | export * from "./resources/BaseResource.js"; 6 | export * from "./prompts/BasePrompt.js"; 7 | 8 | export * from "./auth/index.js"; 9 | 10 | export type { SSETransportConfig } from "./transports/sse/types.js"; 11 | export type { HttpStreamTransportConfig } from "./transports/http/types.js"; 12 | export { HttpStreamTransport } from "./transports/http/server.js"; 13 | -------------------------------------------------------------------------------- /src/loaders/promptLoader.ts: -------------------------------------------------------------------------------- 1 | import { PromptProtocol } from "../prompts/BasePrompt.js"; 2 | import { join, dirname } from "path"; 3 | import { promises as fs } from "fs"; 4 | import { logger } from "../core/Logger.js"; 5 | 6 | export class PromptLoader { 7 | private readonly PROMPTS_DIR: string; 8 | private readonly EXCLUDED_FILES = ["BasePrompt.js", "*.test.js", "*.spec.js"]; 9 | 10 | constructor(basePath?: string) { 11 | const mainModulePath = basePath || process.argv[1]; 12 | this.PROMPTS_DIR = join(dirname(mainModulePath), "prompts"); 13 | logger.debug( 14 | `Initialized PromptLoader with directory: ${this.PROMPTS_DIR}` 15 | ); 16 | } 17 | 18 | async hasPrompts(): Promise { 19 | try { 20 | const stats = await fs.stat(this.PROMPTS_DIR); 21 | if (!stats.isDirectory()) { 22 | logger.debug("Prompts path exists but is not a directory"); 23 | return false; 24 | } 25 | 26 | const files = await fs.readdir(this.PROMPTS_DIR); 27 | const hasValidFiles = files.some((file) => this.isPromptFile(file)); 28 | logger.debug(`Prompts directory has valid files: ${hasValidFiles}`); 29 | return hasValidFiles; 30 | } catch (error) { 31 | logger.debug(`No prompts directory found: ${(error as Error).message}`); 32 | return false; 33 | } 34 | } 35 | 36 | private isPromptFile(file: string): boolean { 37 | if (!file.endsWith(".js")) return false; 38 | const isExcluded = this.EXCLUDED_FILES.some((pattern) => { 39 | if (pattern.includes("*")) { 40 | const regex = new RegExp(pattern.replace("*", ".*")); 41 | return regex.test(file); 42 | } 43 | return file === pattern; 44 | }); 45 | 46 | logger.debug( 47 | `Checking file ${file}: ${isExcluded ? "excluded" : "included"}` 48 | ); 49 | return !isExcluded; 50 | } 51 | 52 | private validatePrompt(prompt: any): prompt is PromptProtocol { 53 | const isValid = Boolean( 54 | prompt && 55 | typeof prompt.name === "string" && 56 | prompt.promptDefinition && 57 | typeof prompt.getMessages === "function" 58 | ); 59 | 60 | if (isValid) { 61 | logger.debug(`Validated prompt: ${prompt.name}`); 62 | } else { 63 | logger.warn(`Invalid prompt found: missing required properties`); 64 | } 65 | 66 | return isValid; 67 | } 68 | 69 | async loadPrompts(): Promise { 70 | try { 71 | logger.debug(`Attempting to load prompts from: ${this.PROMPTS_DIR}`); 72 | 73 | let stats; 74 | try { 75 | stats = await fs.stat(this.PROMPTS_DIR); 76 | } catch (error) { 77 | logger.debug(`No prompts directory found: ${(error as Error).message}`); 78 | return []; 79 | } 80 | 81 | if (!stats.isDirectory()) { 82 | logger.error(`Path is not a directory: ${this.PROMPTS_DIR}`); 83 | return []; 84 | } 85 | 86 | const files = await fs.readdir(this.PROMPTS_DIR); 87 | logger.debug(`Found files in directory: ${files.join(", ")}`); 88 | 89 | const prompts: PromptProtocol[] = []; 90 | 91 | for (const file of files) { 92 | if (!this.isPromptFile(file)) { 93 | continue; 94 | } 95 | 96 | try { 97 | const fullPath = join(this.PROMPTS_DIR, file); 98 | logger.debug(`Attempting to load prompt from: ${fullPath}`); 99 | 100 | const importPath = `file://${fullPath}`; 101 | const { default: PromptClass } = await import(importPath); 102 | 103 | if (!PromptClass) { 104 | logger.warn(`No default export found in ${file}`); 105 | continue; 106 | } 107 | 108 | const prompt = new PromptClass(); 109 | if (this.validatePrompt(prompt)) { 110 | prompts.push(prompt); 111 | } 112 | } catch (error) { 113 | logger.error(`Error loading prompt ${file}: ${(error as Error).message}`); 114 | } 115 | } 116 | 117 | logger.debug( 118 | `Successfully loaded ${prompts.length} prompts: ${prompts 119 | .map((p) => p.name) 120 | .join(", ")}` 121 | ); 122 | return prompts; 123 | } catch (error) { 124 | logger.error(`Failed to load prompts: ${(error as Error).message}`); 125 | return []; 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/loaders/resourceLoader.ts: -------------------------------------------------------------------------------- 1 | import { ResourceProtocol } from "../resources/BaseResource.js"; 2 | import { join, dirname } from "path"; 3 | import { promises as fs } from "fs"; 4 | import { logger } from "../core/Logger.js"; 5 | 6 | export class ResourceLoader { 7 | private readonly RESOURCES_DIR: string; 8 | private readonly EXCLUDED_FILES = [ 9 | "BaseResource.js", 10 | "*.test.js", 11 | "*.spec.js", 12 | ]; 13 | 14 | constructor(basePath?: string) { 15 | const mainModulePath = basePath || process.argv[1]; 16 | this.RESOURCES_DIR = join(dirname(mainModulePath), "resources"); 17 | logger.debug( 18 | `Initialized ResourceLoader with directory: ${this.RESOURCES_DIR}` 19 | ); 20 | } 21 | 22 | async hasResources(): Promise { 23 | try { 24 | const stats = await fs.stat(this.RESOURCES_DIR); 25 | if (!stats.isDirectory()) { 26 | logger.debug("Resources path exists but is not a directory"); 27 | return false; 28 | } 29 | 30 | const files = await fs.readdir(this.RESOURCES_DIR); 31 | const hasValidFiles = files.some((file) => this.isResourceFile(file)); 32 | logger.debug(`Resources directory has valid files: ${hasValidFiles}`); 33 | return hasValidFiles; 34 | } catch (error) { 35 | logger.debug(`No resources directory found: ${(error as Error).message}`); 36 | return false; 37 | } 38 | } 39 | 40 | private isResourceFile(file: string): boolean { 41 | if (!file.endsWith(".js")) return false; 42 | const isExcluded = this.EXCLUDED_FILES.some((pattern) => { 43 | if (pattern.includes("*")) { 44 | const regex = new RegExp(pattern.replace("*", ".*")); 45 | return regex.test(file); 46 | } 47 | return file === pattern; 48 | }); 49 | 50 | logger.debug( 51 | `Checking file ${file}: ${isExcluded ? "excluded" : "included"}` 52 | ); 53 | return !isExcluded; 54 | } 55 | 56 | private validateResource(resource: any): resource is ResourceProtocol { 57 | const isValid = Boolean( 58 | resource && 59 | typeof resource.uri === "string" && 60 | typeof resource.name === "string" && 61 | resource.resourceDefinition && 62 | typeof resource.read === "function" 63 | ); 64 | 65 | if (isValid) { 66 | logger.debug(`Validated resource: ${resource.name}`); 67 | } else { 68 | logger.warn(`Invalid resource found: missing required properties`); 69 | } 70 | 71 | return isValid; 72 | } 73 | 74 | async loadResources(): Promise { 75 | try { 76 | logger.debug(`Attempting to load resources from: ${this.RESOURCES_DIR}`); 77 | 78 | let stats; 79 | try { 80 | stats = await fs.stat(this.RESOURCES_DIR); 81 | } catch (error) { 82 | logger.debug(`No resources directory found: ${(error as Error).message}`); 83 | return []; 84 | } 85 | 86 | if (!stats.isDirectory()) { 87 | logger.error(`Path is not a directory: ${this.RESOURCES_DIR}`); 88 | return []; 89 | } 90 | 91 | const files = await fs.readdir(this.RESOURCES_DIR); 92 | logger.debug(`Found files in directory: ${files.join(", ")}`); 93 | 94 | const resources: ResourceProtocol[] = []; 95 | 96 | for (const file of files) { 97 | if (!this.isResourceFile(file)) { 98 | continue; 99 | } 100 | 101 | try { 102 | const fullPath = join(this.RESOURCES_DIR, file); 103 | logger.debug(`Attempting to load resource from: ${fullPath}`); 104 | 105 | const importPath = `file://${fullPath}`; 106 | const { default: ResourceClass } = await import(importPath); 107 | 108 | if (!ResourceClass) { 109 | logger.warn(`No default export found in ${file}`); 110 | continue; 111 | } 112 | 113 | const resource = new ResourceClass(); 114 | if (this.validateResource(resource)) { 115 | resources.push(resource); 116 | } 117 | } catch (error) { 118 | logger.error(`Error loading resource ${file}: ${(error as Error).message}`); 119 | } 120 | } 121 | 122 | logger.debug( 123 | `Successfully loaded ${resources.length} resources: ${resources 124 | .map((r) => r.name) 125 | .join(", ")}` 126 | ); 127 | return resources; 128 | } catch (error) { 129 | logger.error(`Failed to load resources: ${(error as Error).message}`); 130 | return []; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/loaders/toolLoader.ts: -------------------------------------------------------------------------------- 1 | import { ToolProtocol } from "../tools/BaseTool.js"; 2 | import { join, dirname } from "path"; 3 | import { promises as fs } from "fs"; 4 | import { logger } from "../core/Logger.js"; 5 | 6 | export class ToolLoader { 7 | private readonly TOOLS_DIR: string; 8 | private readonly EXCLUDED_FILES = ["BaseTool.js", "*.test.js", "*.spec.js"]; 9 | 10 | constructor(basePath?: string) { 11 | const mainModulePath = basePath || process.argv[1]; 12 | this.TOOLS_DIR = join(dirname(mainModulePath), "tools"); 13 | logger.debug(`Initialized ToolLoader with directory: ${this.TOOLS_DIR}`); 14 | } 15 | 16 | async hasTools(): Promise { 17 | try { 18 | const stats = await fs.stat(this.TOOLS_DIR); 19 | if (!stats.isDirectory()) { 20 | logger.debug("Tools path exists but is not a directory"); 21 | return false; 22 | } 23 | 24 | const files = await fs.readdir(this.TOOLS_DIR); 25 | const hasValidFiles = files.some((file) => this.isToolFile(file)); 26 | logger.debug(`Tools directory has valid files: ${hasValidFiles}`); 27 | return hasValidFiles; 28 | } catch (error) { 29 | logger.debug(`No tools directory found: ${(error as Error).message}`); 30 | return false; 31 | } 32 | } 33 | 34 | private isToolFile(file: string): boolean { 35 | if (!file.endsWith(".js")) return false; 36 | const isExcluded = this.EXCLUDED_FILES.some((pattern) => { 37 | if (pattern.includes("*")) { 38 | const regex = new RegExp(pattern.replace("*", ".*")); 39 | return regex.test(file); 40 | } 41 | return file === pattern; 42 | }); 43 | 44 | logger.debug( 45 | `Checking file ${file}: ${isExcluded ? "excluded" : "included"}` 46 | ); 47 | return !isExcluded; 48 | } 49 | 50 | private validateTool(tool: any): tool is ToolProtocol { 51 | const isValid = Boolean( 52 | tool && 53 | typeof tool.name === "string" && 54 | tool.toolDefinition && 55 | typeof tool.toolCall === "function" 56 | ); 57 | 58 | if (isValid) { 59 | logger.debug(`Validated tool: ${tool.name}`); 60 | } else { 61 | logger.warn(`Invalid tool found: missing required properties`); 62 | } 63 | 64 | return isValid; 65 | } 66 | 67 | async loadTools(): Promise { 68 | try { 69 | logger.debug(`Attempting to load tools from: ${this.TOOLS_DIR}`); 70 | 71 | let stats; 72 | try { 73 | stats = await fs.stat(this.TOOLS_DIR); 74 | } catch (error) { 75 | logger.debug(`No tools directory found: ${(error as Error).message}`); 76 | return []; 77 | } 78 | 79 | if (!stats.isDirectory()) { 80 | logger.error(`Path is not a directory: ${this.TOOLS_DIR}`); 81 | return []; 82 | } 83 | 84 | const files = await fs.readdir(this.TOOLS_DIR); 85 | logger.debug(`Found files in directory: ${files.join(", ")}`); 86 | 87 | const tools: ToolProtocol[] = []; 88 | 89 | for (const file of files) { 90 | if (!this.isToolFile(file)) { 91 | continue; 92 | } 93 | 94 | try { 95 | const fullPath = join(this.TOOLS_DIR, file); 96 | logger.debug(`Attempting to load tool from: ${fullPath}`); 97 | 98 | const importPath = `file://${fullPath}`; 99 | const { default: ToolClass } = await import(importPath); 100 | 101 | if (!ToolClass) { 102 | logger.warn(`No default export found in ${file}`); 103 | continue; 104 | } 105 | 106 | const tool = new ToolClass(); 107 | if (this.validateTool(tool)) { 108 | tools.push(tool); 109 | } 110 | } catch (error) { 111 | logger.error(`Error loading tool ${file}: ${(error as Error).message}`); 112 | } 113 | } 114 | 115 | logger.debug( 116 | `Successfully loaded ${tools.length} tools: ${tools 117 | .map((t) => t.name) 118 | .join(", ")}` 119 | ); 120 | return tools; 121 | } catch (error) { 122 | logger.error(`Failed to load tools: ${(error as Error).message}`); 123 | return []; 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/prompts/BasePrompt.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export type PromptArgumentSchema = { 4 | [K in keyof T]: { 5 | type: z.ZodType; 6 | description: string; 7 | required?: boolean; 8 | }; 9 | }; 10 | 11 | export type PromptArguments> = { 12 | [K in keyof T]: z.infer; 13 | }; 14 | 15 | export interface PromptProtocol { 16 | name: string; 17 | description: string; 18 | promptDefinition: { 19 | name: string; 20 | description: string; 21 | arguments?: Array<{ 22 | name: string; 23 | description: string; 24 | required?: boolean; 25 | }>; 26 | }; 27 | getMessages(args?: Record): Promise< 28 | Array<{ 29 | role: string; 30 | content: { 31 | type: string; 32 | text: string; 33 | resource?: { 34 | uri: string; 35 | text: string; 36 | mimeType: string; 37 | }; 38 | }; 39 | }> 40 | >; 41 | } 42 | 43 | export abstract class MCPPrompt = {}> 44 | implements PromptProtocol 45 | { 46 | abstract name: string; 47 | abstract description: string; 48 | protected abstract schema: PromptArgumentSchema; 49 | 50 | get promptDefinition() { 51 | return { 52 | name: this.name, 53 | description: this.description, 54 | arguments: Object.entries(this.schema).map(([name, schema]) => ({ 55 | name, 56 | description: schema.description, 57 | required: schema.required ?? false, 58 | })), 59 | }; 60 | } 61 | 62 | protected abstract generateMessages(args: TArgs): Promise< 63 | Array<{ 64 | role: string; 65 | content: { 66 | type: string; 67 | text: string; 68 | resource?: { 69 | uri: string; 70 | text: string; 71 | mimeType: string; 72 | }; 73 | }; 74 | }> 75 | >; 76 | 77 | async getMessages(args: Record = {}) { 78 | const zodSchema = z.object( 79 | Object.fromEntries( 80 | Object.entries(this.schema).map(([key, schema]) => [key, schema.type]) 81 | ) 82 | ); 83 | 84 | const validatedArgs = (await zodSchema.parse(args)) as TArgs; 85 | return this.generateMessages(validatedArgs); 86 | } 87 | 88 | protected async fetch(url: string, init?: RequestInit): Promise { 89 | const response = await fetch(url, init); 90 | if (!response.ok) { 91 | throw new Error(`HTTP error! status: ${response.status}`); 92 | } 93 | return response.json(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/resources/BaseResource.ts: -------------------------------------------------------------------------------- 1 | export type ResourceContent = { 2 | uri: string; 3 | mimeType?: string; 4 | text?: string; 5 | blob?: string; 6 | }; 7 | 8 | export type ResourceDefinition = { 9 | uri: string; 10 | name: string; 11 | description?: string; 12 | mimeType?: string; 13 | }; 14 | 15 | export type ResourceTemplateDefinition = { 16 | uriTemplate: string; 17 | name: string; 18 | description?: string; 19 | mimeType?: string; 20 | }; 21 | 22 | export interface ResourceProtocol { 23 | uri: string; 24 | name: string; 25 | description?: string; 26 | mimeType?: string; 27 | resourceDefinition: ResourceDefinition; 28 | read(): Promise; 29 | subscribe?(): Promise; 30 | unsubscribe?(): Promise; 31 | } 32 | 33 | export abstract class MCPResource implements ResourceProtocol { 34 | abstract uri: string; 35 | abstract name: string; 36 | description?: string; 37 | mimeType?: string; 38 | 39 | get resourceDefinition(): ResourceDefinition { 40 | return { 41 | uri: this.uri, 42 | name: this.name, 43 | description: this.description, 44 | mimeType: this.mimeType, 45 | }; 46 | } 47 | 48 | abstract read(): Promise; 49 | 50 | async subscribe?(): Promise { 51 | throw new Error("Subscription not implemented for this resource"); 52 | } 53 | 54 | async unsubscribe?(): Promise { 55 | throw new Error("Unsubscription not implemented for this resource"); 56 | } 57 | 58 | protected async fetch(url: string, init?: RequestInit): Promise { 59 | const response = await fetch(url, init); 60 | if (!response.ok) { 61 | throw new Error(`HTTP error! status: ${response.status}`); 62 | } 63 | return response.json(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/tools/BaseTool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Tool as SDKTool } from "@modelcontextprotocol/sdk/types.js"; 3 | import { ImageContent } from "../transports/utils/image-handler.js"; 4 | 5 | export type ToolInputSchema = { 6 | [K in keyof T]: { 7 | type: z.ZodType; 8 | description: string; 9 | }; 10 | }; 11 | 12 | export type ToolInput> = { 13 | [K in keyof T]: z.infer; 14 | }; 15 | 16 | export type TextContent = { 17 | type: "text"; 18 | text: string; 19 | }; 20 | 21 | export type ErrorContent = { 22 | type: "error"; 23 | text: string; 24 | }; 25 | 26 | export type ToolContent = TextContent | ErrorContent | ImageContent; 27 | 28 | export type ToolResponse = { 29 | content: ToolContent[]; 30 | }; 31 | 32 | export interface ToolProtocol extends SDKTool { 33 | name: string; 34 | description: string; 35 | toolDefinition: { 36 | name: string; 37 | description: string; 38 | inputSchema: { 39 | type: "object"; 40 | properties?: Record; 41 | required?: string[]; 42 | }; 43 | }; 44 | toolCall(request: { 45 | params: { name: string; arguments?: Record }; 46 | }): Promise; 47 | } 48 | 49 | export abstract class MCPTool = {}> 50 | implements ToolProtocol 51 | { 52 | abstract name: string; 53 | abstract description: string; 54 | protected abstract schema: ToolInputSchema; 55 | protected useStringify: boolean = true; 56 | [key: string]: unknown; 57 | 58 | get inputSchema(): { type: "object"; properties?: Record; required?: string[] } { 59 | const properties: Record = {}; 60 | const required: string[] = []; 61 | 62 | Object.entries(this.schema).forEach(([key, schema]) => { 63 | // Determine the correct JSON schema type (unwrapping optional if necessary) 64 | const jsonType = this.getJsonSchemaType(schema.type); 65 | properties[key] = { 66 | type: jsonType, 67 | description: schema.description, 68 | }; 69 | 70 | // If the field is not an optional, add it to the required array. 71 | if (!(schema.type instanceof z.ZodOptional)) { 72 | required.push(key); 73 | } 74 | }); 75 | 76 | const inputSchema: { type: "object"; properties: Record; required?: string[] } = { 77 | type: "object", 78 | properties, 79 | }; 80 | 81 | if (required.length > 0) { 82 | inputSchema.required = required; 83 | } 84 | 85 | return inputSchema; 86 | } 87 | 88 | get toolDefinition() { 89 | return { 90 | name: this.name, 91 | description: this.description, 92 | inputSchema: this.inputSchema, 93 | }; 94 | } 95 | 96 | protected abstract execute(input: TInput): Promise; 97 | 98 | async toolCall(request: { 99 | params: { name: string; arguments?: Record }; 100 | }): Promise { 101 | try { 102 | const args = request.params.arguments || {}; 103 | const validatedInput = await this.validateInput(args); 104 | const result = await this.execute(validatedInput); 105 | return this.createSuccessResponse(result); 106 | } catch (error) { 107 | return this.createErrorResponse(error as Error); 108 | } 109 | } 110 | 111 | private async validateInput(args: Record): Promise { 112 | const zodSchema = z.object( 113 | Object.fromEntries( 114 | Object.entries(this.schema).map(([key, schema]) => [key, schema.type]) 115 | ) 116 | ); 117 | 118 | return zodSchema.parse(args) as TInput; 119 | } 120 | 121 | private getJsonSchemaType(zodType: z.ZodType): string { 122 | // Unwrap optional types to correctly determine the JSON schema type. 123 | let currentType = zodType; 124 | if (currentType instanceof z.ZodOptional) { 125 | currentType = currentType.unwrap(); 126 | } 127 | 128 | if (currentType instanceof z.ZodString) return "string"; 129 | if (currentType instanceof z.ZodNumber) return "number"; 130 | if (currentType instanceof z.ZodBoolean) return "boolean"; 131 | if (currentType instanceof z.ZodArray) return "array"; 132 | if (currentType instanceof z.ZodObject) return "object"; 133 | return "string"; 134 | } 135 | 136 | protected createSuccessResponse(data: unknown): ToolResponse { 137 | if (this.isImageContent(data)) { 138 | return { 139 | content: [data], 140 | }; 141 | } 142 | 143 | if (Array.isArray(data)) { 144 | const validContent = data.filter(item => this.isValidContent(item)) as ToolContent[]; 145 | if (validContent.length > 0) { 146 | return { 147 | content: validContent, 148 | }; 149 | } 150 | } 151 | 152 | return { 153 | content: [{ 154 | type: "text", 155 | text: this.useStringify ? JSON.stringify(data) : String(data) 156 | }], 157 | }; 158 | } 159 | 160 | protected createErrorResponse(error: Error): ToolResponse { 161 | return { 162 | content: [{ type: "error", text: error.message }], 163 | }; 164 | } 165 | 166 | private isImageContent(data: unknown): data is ImageContent { 167 | return ( 168 | typeof data === "object" && 169 | data !== null && 170 | "type" in data && 171 | data.type === "image" && 172 | "data" in data && 173 | "mimeType" in data && 174 | typeof (data as ImageContent).data === "string" && 175 | typeof (data as ImageContent).mimeType === "string" 176 | ); 177 | } 178 | 179 | private isTextContent(data: unknown): data is TextContent { 180 | return ( 181 | typeof data === "object" && 182 | data !== null && 183 | "type" in data && 184 | data.type === "text" && 185 | "text" in data && 186 | typeof (data as TextContent).text === "string" 187 | ); 188 | } 189 | 190 | private isErrorContent(data: unknown): data is ErrorContent { 191 | return ( 192 | typeof data === "object" && 193 | data !== null && 194 | "type" in data && 195 | data.type === "error" && 196 | "text" in data && 197 | typeof (data as ErrorContent).text === "string" 198 | ); 199 | } 200 | 201 | private isValidContent(data: unknown): data is ToolContent { 202 | return ( 203 | this.isImageContent(data) || 204 | this.isTextContent(data) || 205 | this.isErrorContent(data) 206 | ); 207 | } 208 | 209 | protected async fetch(url: string, init?: RequestInit): Promise { 210 | const response = await fetch(url, init); 211 | if (!response.ok) { 212 | throw new Error(`HTTP error! status: ${response.status}`); 213 | } 214 | return response.json(); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/transports/base.ts: -------------------------------------------------------------------------------- 1 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 2 | import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; 3 | 4 | /** 5 | * Base transport interface 6 | */ 7 | export interface BaseTransport extends Transport { 8 | // Properties from SDK Transport (explicitly listed for clarity/safety) 9 | onclose?: (() => void) | undefined; 10 | onerror?: ((error: Error) => void) | undefined; 11 | onmessage?: ((message: JSONRPCMessage) => void) | undefined; 12 | 13 | // Methods from SDK Transport (explicitly listed for clarity/safety) 14 | send(message: JSONRPCMessage): Promise; 15 | close(): Promise; 16 | start(): Promise; // Assuming start is also needed, mirroring AbstractTransport 17 | 18 | /** 19 | * The type of transport (e.g., "stdio", "sse") 20 | */ 21 | readonly type: string; 22 | 23 | /** 24 | * Returns whether the transport is currently running 25 | */ 26 | isRunning(): boolean; 27 | } 28 | 29 | /** 30 | * Abstract base class for transports that implements common functionality 31 | */ 32 | export abstract class AbstractTransport implements BaseTransport { 33 | abstract readonly type: string; 34 | 35 | protected _onclose?: () => void; 36 | protected _onerror?: (error: Error) => void; 37 | protected _onmessage?: (message: JSONRPCMessage) => void; 38 | 39 | set onclose(handler: (() => void) | undefined) { 40 | this._onclose = handler; 41 | } 42 | 43 | set onerror(handler: ((error: Error) => void) | undefined) { 44 | this._onerror = handler; 45 | } 46 | 47 | set onmessage(handler: ((message: JSONRPCMessage) => void) | undefined) { 48 | this._onmessage = handler; 49 | } 50 | 51 | abstract start(): Promise; 52 | abstract send(message: JSONRPCMessage): Promise; 53 | abstract close(): Promise; 54 | abstract isRunning(): boolean; 55 | } 56 | -------------------------------------------------------------------------------- /src/transports/http/server.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import { IncomingMessage, ServerResponse, createServer, Server as HttpServer } from 'node:http'; 3 | import { AbstractTransport } from '../base.js'; 4 | import { JSONRPCMessage, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; 5 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 6 | import { HttpStreamTransportConfig } from './types.js'; 7 | import { logger } from '../../core/Logger.js'; 8 | 9 | export class HttpStreamTransport extends AbstractTransport { 10 | readonly type = 'http-stream'; 11 | private _sdkTransport: StreamableHTTPServerTransport; 12 | private _isRunning = false; 13 | private _port: number; 14 | private _server?: HttpServer; 15 | private _endpoint: string; 16 | private _enableJsonResponse: boolean = false; 17 | private _sessionInitialized: boolean = false; 18 | 19 | private _pingInterval?: NodeJS.Timeout; 20 | private _pingTimeouts: Map = new Map(); 21 | private _pingFrequency: number; 22 | private _pingTimeout: number; 23 | 24 | constructor(config: HttpStreamTransportConfig = {}) { 25 | super(); 26 | 27 | this._port = config.port || 8080; 28 | this._endpoint = config.endpoint || '/mcp'; 29 | this._enableJsonResponse = config.responseMode === 'batch'; 30 | 31 | this._pingFrequency = config.ping?.frequency ?? 30000; // Default 30 seconds 32 | this._pingTimeout = config.ping?.timeout ?? 10000; // Default 10 seconds 33 | 34 | this._sdkTransport = this.createSdkTransport(); 35 | 36 | logger.debug( 37 | `HttpStreamTransport configured with: ${JSON.stringify({ 38 | port: this._port, 39 | endpoint: this._endpoint, 40 | responseMode: config.responseMode, 41 | batchTimeout: config.batchTimeout, 42 | maxMessageSize: config.maxMessageSize, 43 | auth: config.auth ? true : false, 44 | cors: config.cors ? true : false, 45 | ping: { 46 | frequency: this._pingFrequency, 47 | timeout: this._pingTimeout, 48 | }, 49 | })}` 50 | ); 51 | } 52 | 53 | private createSdkTransport(): StreamableHTTPServerTransport { 54 | const transport = new StreamableHTTPServerTransport({ 55 | sessionIdGenerator: () => randomUUID(), 56 | onsessioninitialized: (sessionId: string) => { 57 | logger.info(`Session initialized: ${sessionId}`); 58 | this._sessionInitialized = true; 59 | }, 60 | enableJsonResponse: this._enableJsonResponse, 61 | }); 62 | 63 | transport.onmessage = (message: JSONRPCMessage) => { 64 | if (this.handlePingMessage(message)) { 65 | return; 66 | } 67 | 68 | this._onmessage?.(message); 69 | }; 70 | 71 | return transport; 72 | } 73 | 74 | async start(): Promise { 75 | if (this._isRunning) { 76 | throw new Error('HttpStreamTransport already started'); 77 | } 78 | 79 | return new Promise((resolve, reject) => { 80 | this._server = createServer(async (req, res) => { 81 | try { 82 | const url = new URL(req.url!, `http://${req.headers.host}`); 83 | 84 | if (url.pathname === this._endpoint) { 85 | // Special handling for POST requests to detect initialization requests 86 | if (req.method === 'POST') { 87 | const contentType = req.headers['content-type']; 88 | if (contentType?.includes('application/json')) { 89 | // Need to intercept body data to check for initialization 90 | let bodyData = ''; 91 | req.on('data', (chunk) => { 92 | bodyData += chunk.toString(); 93 | }); 94 | 95 | req.on('end', async () => { 96 | try { 97 | const jsonData = JSON.parse(bodyData); 98 | const messages = Array.isArray(jsonData) ? jsonData : [jsonData]; 99 | 100 | // Check if this is an initialization request AND we already have a session 101 | // Only recreate for subsequent initializations, not the first one 102 | if (messages.some(isInitializeRequest) && this._sessionInitialized) { 103 | logger.info( 104 | 'Received initialization request for existing session, recreating transport' 105 | ); 106 | 107 | // Reset session state first 108 | this._sessionInitialized = false; 109 | 110 | // Close the old transport 111 | try { 112 | await this._sdkTransport.close(); 113 | } catch (err) { 114 | logger.warn(`Error closing previous transport: ${err}`); 115 | } 116 | 117 | // Create a fresh transport for this new connection 118 | this._sdkTransport = this.createSdkTransport(); 119 | await this._sdkTransport.start(); 120 | } 121 | 122 | // Forward the original request to the SDK transport 123 | await this._sdkTransport.handleRequest(req, res, jsonData); 124 | } catch (error) { 125 | logger.error(`Error handling JSON data: ${error}`); 126 | if (!res.headersSent) { 127 | res.writeHead(400).end( 128 | JSON.stringify({ 129 | jsonrpc: '2.0', 130 | error: { 131 | code: -32700, 132 | message: 'Parse error', 133 | data: String(error), 134 | }, 135 | id: null, 136 | }) 137 | ); 138 | } 139 | } 140 | }); 141 | } else { 142 | await this._sdkTransport.handleRequest(req, res); 143 | } 144 | } else if (req.method === 'DELETE') { 145 | // For DELETE requests, reset the session state 146 | this._sessionInitialized = false; 147 | await this._sdkTransport.handleRequest(req, res); 148 | } else { 149 | // For GET requests, just forward to the SDK transport 150 | await this._sdkTransport.handleRequest(req, res); 151 | } 152 | } else { 153 | res.writeHead(404).end('Not Found'); 154 | } 155 | } catch (error) { 156 | logger.error(`Error handling request: ${error}`); 157 | if (!res.headersSent) { 158 | res.writeHead(500).end('Internal Server Error'); 159 | } 160 | } 161 | }); 162 | 163 | this._server.on('error', (error) => { 164 | logger.error(`HTTP server error: ${error}`); 165 | this._onerror?.(error); 166 | if (!this._isRunning) { 167 | reject(error); 168 | } 169 | }); 170 | 171 | this._server.on('close', () => { 172 | logger.info('HTTP server closed'); 173 | this._isRunning = false; 174 | this._onclose?.(); 175 | }); 176 | 177 | this._server.listen(this._port, () => { 178 | logger.info(`HTTP server listening on port ${this._port}, endpoint ${this._endpoint}`); 179 | 180 | this._sdkTransport 181 | .start() 182 | .then(() => { 183 | this._isRunning = true; 184 | logger.info(`HttpStreamTransport started successfully on port ${this._port}`); 185 | 186 | this.startPingInterval(); 187 | 188 | resolve(); 189 | }) 190 | .catch((error) => { 191 | logger.error(`Failed to start SDK transport: ${error}`); 192 | this._server?.close(); 193 | reject(error); 194 | }); 195 | }); 196 | }); 197 | } 198 | 199 | private startPingInterval(): void { 200 | if (this._pingFrequency > 0) { 201 | logger.debug( 202 | `Starting ping interval with frequency ${this._pingFrequency}ms and timeout ${this._pingTimeout}ms` 203 | ); 204 | this._pingInterval = setInterval(() => this.sendPing(), this._pingFrequency); 205 | } 206 | } 207 | 208 | private async sendPing(): Promise { 209 | if (!this._isRunning) { 210 | return; 211 | } 212 | 213 | try { 214 | const pingId = `ping-${Date.now()}`; 215 | const pingRequest: JSONRPCMessage = { 216 | jsonrpc: '2.0' as const, 217 | id: pingId, 218 | method: 'ping', 219 | }; 220 | 221 | logger.debug(`Sending ping request: ${JSON.stringify(pingRequest)}`); 222 | 223 | const timeoutId = setTimeout(() => { 224 | logger.warn( 225 | `Ping ${pingId} timed out after ${this._pingTimeout}ms - connection may be stale` 226 | ); 227 | this._pingTimeouts.delete(pingId); 228 | 229 | this._onerror?.(new Error(`Ping timeout (${pingId}) - connection may be stale`)); 230 | }, this._pingTimeout); 231 | 232 | this._pingTimeouts.set(pingId, timeoutId); 233 | 234 | await this.send(pingRequest); 235 | } catch (error) { 236 | logger.error(`Error sending ping: ${error}`); 237 | } 238 | } 239 | 240 | private handlePingMessage(message: JSONRPCMessage): boolean { 241 | if ('method' in message && message.method === 'ping') { 242 | const id = 'id' in message ? message.id : undefined; 243 | logger.debug(`Received ping request: ${JSON.stringify(message)}`); 244 | 245 | if (id !== undefined) { 246 | const response = { 247 | jsonrpc: '2.0' as const, 248 | id: id, 249 | result: {}, 250 | }; 251 | logger.debug(`Sending ping response: ${JSON.stringify(response)}`); 252 | 253 | this.send(response).catch((error) => logger.error(`Error responding to ping: ${error}`)); 254 | } 255 | 256 | return true; 257 | } 258 | 259 | if ( 260 | 'id' in message && 261 | message.id && 262 | typeof message.id === 'string' && 263 | message.id.startsWith('ping-') && 264 | 'result' in message 265 | ) { 266 | logger.debug(`Received ping response: ${JSON.stringify(message)}`); 267 | 268 | const timeoutId = this._pingTimeouts.get(message.id); 269 | if (timeoutId) { 270 | clearTimeout(timeoutId); 271 | this._pingTimeouts.delete(message.id); 272 | logger.debug(`Cleared timeout for ping response: ${message.id}`); 273 | } 274 | 275 | return true; 276 | } 277 | 278 | return false; 279 | } 280 | 281 | async handleRequest(req: IncomingMessage, res: ServerResponse, body?: any): Promise { 282 | return this._sdkTransport.handleRequest(req, res, body); 283 | } 284 | 285 | async send(message: JSONRPCMessage): Promise { 286 | await this._sdkTransport.send(message); 287 | } 288 | 289 | async close(): Promise { 290 | if (!this._isRunning) { 291 | return; 292 | } 293 | 294 | this._isRunning = false; 295 | this._sessionInitialized = false; 296 | 297 | if (this._pingInterval) { 298 | clearInterval(this._pingInterval); 299 | this._pingInterval = undefined; 300 | } 301 | 302 | for (const timeoutId of this._pingTimeouts.values()) { 303 | clearTimeout(timeoutId); 304 | } 305 | this._pingTimeouts.clear(); 306 | 307 | await this._sdkTransport.close(); 308 | 309 | return new Promise((resolve) => { 310 | if (!this._server) { 311 | resolve(); 312 | return; 313 | } 314 | 315 | this._server.close(() => { 316 | logger.info('HTTP server stopped'); 317 | this._server = undefined; 318 | resolve(); 319 | }); 320 | }); 321 | } 322 | 323 | isRunning(): boolean { 324 | return this._isRunning && !!this._server; 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/transports/http/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JSONRPCRequest, 3 | JSONRPCResponse, 4 | JSONRPCMessage, 5 | RequestId, 6 | } from '@modelcontextprotocol/sdk/types.js'; 7 | 8 | export { JSONRPCRequest, JSONRPCResponse, JSONRPCMessage, RequestId }; 9 | 10 | /** 11 | * Response mode enum 12 | */ 13 | export type ResponseMode = 'stream' | 'batch'; 14 | 15 | /** 16 | * Configuration interface for the HTTP Stream transport 17 | */ 18 | export interface HttpStreamTransportConfig { 19 | /** 20 | * Port to run the HTTP server on, defaults to 8080 21 | */ 22 | port?: number; 23 | 24 | /** 25 | * Endpoint path for MCP communication, defaults to "/mcp" 26 | */ 27 | endpoint?: string; 28 | 29 | /** 30 | * Configure ping mechanism for connection health verification 31 | */ 32 | ping?: { 33 | /** 34 | * Interval in milliseconds for sending ping requests 35 | * Set to 0 to disable pings 36 | * Default: 30000 (30 seconds) 37 | */ 38 | frequency?: number; 39 | 40 | /** 41 | * Timeout in milliseconds for waiting for a ping response 42 | * Default: 10000 (10 seconds) 43 | */ 44 | timeout?: number; 45 | }; 46 | 47 | /** 48 | * Response mode: stream (Server-Sent Events) or batch (JSON) 49 | * Defaults to 'stream' 50 | */ 51 | responseMode?: ResponseMode; 52 | 53 | /** 54 | * Timeout in milliseconds for batched messages 55 | * Only applies when responseMode is 'batch' 56 | */ 57 | batchTimeout?: number; 58 | 59 | /** 60 | * Maximum message size in bytes 61 | */ 62 | maxMessageSize?: number; 63 | 64 | /** 65 | * Authentication configuration 66 | */ 67 | auth?: any; 68 | 69 | /** 70 | * CORS configuration 71 | */ 72 | cors?: any; 73 | } 74 | 75 | export const DEFAULT_HTTP_STREAM_CONFIG: HttpStreamTransportConfig = { 76 | port: 8080, 77 | endpoint: '/mcp', 78 | responseMode: 'stream', 79 | batchTimeout: 30000, 80 | maxMessageSize: 4 * 1024 * 1024, // 4mb 81 | ping: { 82 | frequency: 30000, // 30 seconds 83 | timeout: 10000, // 10 seconds 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /src/transports/sse/server.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "node:crypto"; 2 | import { IncomingMessage, Server as HttpServer, ServerResponse, createServer } from "node:http"; 3 | import { JSONRPCMessage, ClientRequest } from "@modelcontextprotocol/sdk/types.js"; 4 | import contentType from "content-type"; 5 | import getRawBody from "raw-body"; 6 | import { APIKeyAuthProvider } from "../../auth/providers/apikey.js"; 7 | import { DEFAULT_AUTH_ERROR } from "../../auth/types.js"; 8 | import { AbstractTransport } from "../base.js"; 9 | import { DEFAULT_SSE_CONFIG, SSETransportConfig, SSETransportConfigInternal, DEFAULT_CORS_CONFIG, CORSConfig } from "./types.js"; 10 | import { logger } from "../../core/Logger.js"; 11 | import { getRequestHeader, setResponseHeaders } from "../../utils/headers.js"; 12 | import { PING_SSE_MESSAGE } from "../utils/ping-message.js"; 13 | 14 | 15 | const SSE_HEADERS = { 16 | "Content-Type": "text/event-stream", 17 | "Cache-Control": "no-cache", 18 | "Connection": "keep-alive" 19 | } 20 | 21 | export class SSEServerTransport extends AbstractTransport { 22 | readonly type = "sse" 23 | 24 | private _server?: HttpServer 25 | private _connections: Map // Map 26 | private _sessionId: string // Server instance ID 27 | private _config: SSETransportConfigInternal 28 | 29 | constructor(config: SSETransportConfig = {}) { 30 | super() 31 | this._connections = new Map() 32 | this._sessionId = randomUUID() // Used to validate POST messages belong to this server instance 33 | this._config = { 34 | ...DEFAULT_SSE_CONFIG, 35 | ...config 36 | } 37 | logger.debug(`SSE transport configured with: ${JSON.stringify({ 38 | ...this._config, 39 | auth: this._config.auth ? { 40 | provider: this._config.auth.provider.constructor.name, 41 | endpoints: this._config.auth.endpoints 42 | } : undefined 43 | })}`) 44 | } 45 | 46 | private getCorsHeaders(includeMaxAge: boolean = false): Record { 47 | const corsConfig = { 48 | allowOrigin: DEFAULT_CORS_CONFIG.allowOrigin, 49 | allowMethods: DEFAULT_CORS_CONFIG.allowMethods, 50 | allowHeaders: DEFAULT_CORS_CONFIG.allowHeaders, 51 | exposeHeaders: DEFAULT_CORS_CONFIG.exposeHeaders, 52 | maxAge: DEFAULT_CORS_CONFIG.maxAge, 53 | ...this._config.cors 54 | } as Required 55 | 56 | const headers: Record = { 57 | "Access-Control-Allow-Origin": corsConfig.allowOrigin, 58 | "Access-Control-Allow-Methods": corsConfig.allowMethods, 59 | "Access-Control-Allow-Headers": corsConfig.allowHeaders, 60 | "Access-Control-Expose-Headers": corsConfig.exposeHeaders 61 | } 62 | 63 | if (includeMaxAge) { 64 | headers["Access-Control-Max-Age"] = corsConfig.maxAge 65 | } 66 | 67 | return headers 68 | } 69 | 70 | async start(): Promise { 71 | if (this._server) { 72 | throw new Error("SSE transport already started") 73 | } 74 | 75 | return new Promise((resolve) => { 76 | this._server = createServer(async (req: IncomingMessage, res: ServerResponse) => { 77 | try { 78 | await this.handleRequest(req, res) 79 | } catch (error: any) { 80 | logger.error(`Error handling request: ${error instanceof Error ? error.message : String(error)}`) 81 | res.writeHead(500).end("Internal Server Error") 82 | } 83 | }) 84 | 85 | this._server.listen(this._config.port, () => { 86 | logger.info(`SSE transport listening on port ${this._config.port}`) 87 | resolve() 88 | }) 89 | 90 | this._server.on("error", (error: Error) => { 91 | logger.error(`SSE server error: ${error.message}`) 92 | this._onerror?.(error) 93 | }) 94 | 95 | this._server.on("close", () => { 96 | logger.info("SSE server closed") 97 | this._onclose?.() 98 | }) 99 | }) 100 | } 101 | 102 | private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { 103 | logger.debug(`Incoming request: ${req.method} ${req.url}`) 104 | 105 | if (req.method === "OPTIONS") { 106 | setResponseHeaders(res, this.getCorsHeaders(true)) 107 | res.writeHead(204).end() 108 | return 109 | } 110 | 111 | setResponseHeaders(res, this.getCorsHeaders()) 112 | 113 | const url = new URL(req.url!, `http://${req.headers.host}`) 114 | const sessionId = url.searchParams.get("sessionId") 115 | 116 | if (req.method === "GET" && url.pathname === this._config.endpoint) { 117 | if (this._config.auth?.endpoints?.sse) { 118 | const isAuthenticated = await this.handleAuthentication(req, res, "SSE connection") 119 | if (!isAuthenticated) return 120 | } 121 | 122 | // Check if a sessionId was provided in the request 123 | if (sessionId) { 124 | // If sessionId exists but is not in our connections map, it's invalid or inactive 125 | if (!this._connections.has(sessionId)) { 126 | logger.info(`Invalid or inactive session ID in GET request: ${sessionId}. Creating new connection.`); 127 | // Continue execution to create a new connection below 128 | } else { 129 | // If the connection exists and is still active, we could either: 130 | // 1. Return an error (409 Conflict) as a client shouldn't create duplicate connections 131 | // 2. Close the old connection and create a new one 132 | // 3. Keep the old connection and return its details 133 | 134 | // Option 2: Close old connection and create new one 135 | logger.info(`Replacing existing connection for session ID: ${sessionId}`); 136 | this.cleanupConnection(sessionId); 137 | // Continue execution to create a new connection below 138 | } 139 | } 140 | 141 | // Generate a unique ID for this specific connection 142 | const connectionId = randomUUID(); 143 | this.setupSSEConnection(res, connectionId); 144 | return; 145 | } 146 | 147 | if (req.method === "POST" && url.pathname === this._config.messageEndpoint) { 148 | // **Connection Validation (User Requested):** 149 | // Check if the 'sessionId' from the POST request URL query parameter 150 | // (which should contain a connectionId provided by the server via the 'endpoint' event) 151 | // corresponds to an active connection in the `_connections` map. 152 | if (!sessionId || !this._connections.has(sessionId)) { 153 | logger.warn(`Invalid or inactive connection ID in POST request URL: ${sessionId}`); 154 | // Use 403 Forbidden as the client is attempting an operation for an invalid/unknown connection 155 | res.writeHead(403).end("Invalid or inactive connection ID"); 156 | return; 157 | } 158 | 159 | if (this._config.auth?.endpoints?.messages !== false) { 160 | const isAuthenticated = await this.handleAuthentication(req, res, "message") 161 | if (!isAuthenticated) return 162 | } 163 | 164 | await this.handlePostMessage(req, res) 165 | return 166 | } 167 | 168 | res.writeHead(404).end("Not Found") 169 | } 170 | 171 | private async handleAuthentication(req: IncomingMessage, res: ServerResponse, context: string): Promise { 172 | if (!this._config.auth?.provider) { 173 | return true 174 | } 175 | 176 | const isApiKey = this._config.auth.provider instanceof APIKeyAuthProvider 177 | if (isApiKey) { 178 | const provider = this._config.auth.provider as APIKeyAuthProvider 179 | const headerValue = getRequestHeader(req.headers, provider.getHeaderName()) 180 | 181 | if (!headerValue) { 182 | const error = provider.getAuthError?.() || DEFAULT_AUTH_ERROR 183 | res.setHeader("WWW-Authenticate", `ApiKey realm="MCP Server", header="${provider.getHeaderName()}"`) 184 | res.writeHead(error.status).end(JSON.stringify({ 185 | error: error.message, 186 | status: error.status, 187 | type: "authentication_error" 188 | })) 189 | return false 190 | } 191 | } 192 | 193 | const authResult = await this._config.auth.provider.authenticate(req) 194 | if (!authResult) { 195 | const error = this._config.auth.provider.getAuthError?.() || DEFAULT_AUTH_ERROR 196 | logger.warn(`Authentication failed for ${context}:`) 197 | logger.warn(`- Client IP: ${req.socket.remoteAddress}`) 198 | logger.warn(`- Error: ${error.message}`) 199 | 200 | if (isApiKey) { 201 | const provider = this._config.auth.provider as APIKeyAuthProvider 202 | res.setHeader("WWW-Authenticate", `ApiKey realm="MCP Server", header="${provider.getHeaderName()}"`) 203 | } 204 | 205 | res.writeHead(error.status).end(JSON.stringify({ 206 | error: error.message, 207 | status: error.status, 208 | type: "authentication_error" 209 | })) 210 | return false 211 | } 212 | 213 | logger.info(`Authentication successful for ${context}:`) 214 | logger.info(`- Client IP: ${req.socket.remoteAddress}`) 215 | logger.info(`- Auth Type: ${this._config.auth.provider.constructor.name}`) 216 | return true 217 | } 218 | 219 | private setupSSEConnection(res: ServerResponse, connectionId: string): void { 220 | logger.debug(`Setting up SSE connection: ${connectionId} for server session: ${this._sessionId}`); 221 | const headers = { 222 | ...SSE_HEADERS, 223 | ...this.getCorsHeaders(), 224 | ...this._config.headers 225 | } 226 | setResponseHeaders(res, headers) 227 | logger.debug(`SSE headers set: ${JSON.stringify(headers)}`) 228 | 229 | if (res.socket) { 230 | res.socket.setNoDelay(true) 231 | res.socket.setTimeout(0) 232 | res.socket.setKeepAlive(true, 1000) 233 | logger.debug('Socket optimized for SSE connection'); 234 | } 235 | // **Important Change:** The endpoint URL now includes the specific connectionId 236 | // in the 'sessionId' query parameter, as requested by user feedback. 237 | // The client should use this exact URL for subsequent POST messages. 238 | const endpointUrl = `${this._config.messageEndpoint}?sessionId=${connectionId}`; 239 | logger.debug(`Sending endpoint URL for connection ${connectionId}: ${endpointUrl}`); 240 | res.write(`event: endpoint\ndata: ${endpointUrl}\n\n`); 241 | // Send the unique connection ID separately as well for potential client-side use 242 | res.write(`event: connectionId\ndata: ${connectionId}\n\n`); 243 | logger.debug(`Sending initial keep-alive for connection: ${connectionId}`); 244 | const intervalId = setInterval(() => { 245 | const connection = this._connections.get(connectionId); 246 | if (connection && !connection.res.writableEnded) { 247 | try { 248 | connection.res.write(PING_SSE_MESSAGE); 249 | } 250 | catch (error: any) { 251 | logger.error(`Error sending keep-alive for connection ${connectionId}: ${error instanceof Error ? error.message : String(error)}`); 252 | this.cleanupConnection(connectionId); 253 | } 254 | } 255 | else { 256 | // Should not happen if cleanup is working, but clear interval just in case 257 | logger.warn(`Keep-alive interval running for missing/ended connection: ${connectionId}`); 258 | this.cleanupConnection(connectionId); // Will clear interval 259 | } 260 | }, 15000); 261 | this._connections.set(connectionId, { res, intervalId }); 262 | const cleanup = () => this.cleanupConnection(connectionId); 263 | res.on("close", () => { 264 | logger.info(`SSE connection closed: ${connectionId}`); 265 | cleanup(); 266 | }); 267 | res.on("error", (error: Error) => { 268 | logger.error(`SSE connection error for ${connectionId}: ${error.message}`); 269 | this._onerror?.(error); 270 | cleanup(); 271 | }); 272 | res.on("end", () => { 273 | logger.info(`SSE connection ended: ${connectionId}`); 274 | cleanup(); 275 | }); 276 | logger.info(`SSE connection established successfully: ${connectionId}`); 277 | } 278 | 279 | private async handlePostMessage(req: IncomingMessage, res: ServerResponse): Promise { 280 | // Check if *any* connection is active, not just the old single _sseResponse 281 | if (this._connections.size === 0) { 282 | logger.warn(`Rejecting message: no active SSE connections for server session ${this._sessionId}`); 283 | // Use 409 Conflict as it indicates the server state prevents fulfilling the request 284 | res.writeHead(409).end("No active SSE connection established"); 285 | return; 286 | } 287 | 288 | let currentMessage: { id?: string | number; method?: string } = {} 289 | 290 | try { 291 | const rawMessage = (req as any).body || await (async () => { // Cast req to any to access potential body property 292 | const ct = contentType.parse(req.headers["content-type"] ?? "") 293 | if (ct.type !== "application/json") { 294 | throw new Error(`Unsupported content-type: ${ct.type}`) 295 | } 296 | const rawBody = await getRawBody(req, { 297 | limit: this._config.maxMessageSize, 298 | encoding: ct.parameters.charset ?? "utf-8" 299 | }) 300 | const parsed = JSON.parse(rawBody.toString()) 301 | logger.debug(`Received message: ${JSON.stringify(parsed)}`) 302 | return parsed 303 | })() 304 | 305 | const { id, method, params } = rawMessage 306 | logger.debug(`Parsed message - ID: ${id}, Method: ${method}`) 307 | 308 | const rpcMessage: JSONRPCMessage = { 309 | jsonrpc: "2.0", 310 | id: id, 311 | method: method, 312 | params: params 313 | } 314 | 315 | currentMessage = { 316 | id: id, 317 | method: method 318 | } 319 | 320 | logger.debug(`Processing RPC message: ${JSON.stringify({ 321 | id: id, 322 | method: method, 323 | params: params 324 | })}`) 325 | 326 | if (!this._onmessage) { 327 | throw new Error("No message handler registered") 328 | } 329 | 330 | await this._onmessage(rpcMessage) 331 | 332 | res.writeHead(202).end("Accepted") 333 | 334 | logger.debug(`Successfully processed message ${rpcMessage.id}`) 335 | 336 | } catch (error: any) { 337 | const errorMessage = error instanceof Error ? error.message : String(error) 338 | logger.error(`Error handling message for session ${this._sessionId}:`) 339 | logger.error(`- Error: ${errorMessage}`) 340 | logger.error(`- Method: ${currentMessage.method || "unknown"}`) 341 | logger.error(`- Message ID: ${currentMessage.id || "unknown"}`) 342 | 343 | const errorResponse = { 344 | jsonrpc: "2.0", 345 | id: currentMessage.id || null, 346 | error: { 347 | code: -32000, 348 | message: errorMessage, 349 | data: { 350 | method: currentMessage.method || "unknown", 351 | sessionId: this._sessionId, 352 | connectionActive: Boolean(this._connections.size > 0), 353 | type: "message_handler_error" 354 | } 355 | } 356 | } 357 | 358 | res.writeHead(400).end(JSON.stringify(errorResponse)) 359 | this._onerror?.(error as Error) 360 | } 361 | } 362 | 363 | // Broadcast message to all connected clients 364 | async send(message: JSONRPCMessage): Promise { 365 | if (this._connections.size === 0) { 366 | logger.warn("Attempted to send message, but no clients are connected."); 367 | // Optionally throw an error or just log 368 | // throw new Error("No SSE connections established"); 369 | return; 370 | } 371 | const messageString = `data: ${JSON.stringify(message)}\n\n`; 372 | logger.debug(`Broadcasting message to ${this._connections.size} clients: ${JSON.stringify(message)}`); 373 | let failedSends = 0; 374 | for (const [connectionId, connection] of this._connections.entries()) { 375 | if (connection.res && !connection.res.writableEnded) { 376 | try { 377 | connection.res.write(messageString); 378 | } 379 | catch (error: any) { 380 | failedSends++; 381 | logger.error(`Error sending message to connection ${connectionId}: ${error instanceof Error ? error.message : String(error)}`); 382 | // Clean up the problematic connection 383 | this.cleanupConnection(connectionId); 384 | } 385 | } 386 | else { 387 | // Should not happen if cleanup is working, but handle defensively 388 | logger.warn(`Attempted to send to ended connection: ${connectionId}`); 389 | this.cleanupConnection(connectionId); 390 | } 391 | } 392 | if (failedSends > 0) { 393 | logger.warn(`Failed to send message to ${failedSends} connections.`); 394 | } 395 | } 396 | 397 | async close(): Promise { 398 | logger.info(`Closing SSE transport and ${this._connections.size} connections.`); 399 | // Close all active client connections 400 | for (const connectionId of this._connections.keys()) { 401 | this.cleanupConnection(connectionId, true); // Pass true to end the response 402 | } 403 | this._connections.clear(); // Ensure map is empty 404 | // Close the main server 405 | return new Promise((resolve) => { 406 | if (!this._server) { 407 | logger.debug("Server already stopped."); 408 | resolve(); 409 | return; 410 | } 411 | this._server.close(() => { 412 | logger.info("SSE server stopped"); 413 | this._server = undefined; 414 | this._onclose?.(); 415 | resolve(); 416 | }); 417 | }); 418 | } 419 | 420 | // Clean up a specific connection by its ID 421 | private cleanupConnection(connectionId: string, endResponse = false): void { 422 | const connection = this._connections.get(connectionId); 423 | if (connection) { 424 | logger.debug(`Cleaning up connection: ${connectionId}`); 425 | if (connection.intervalId) { 426 | clearInterval(connection.intervalId); 427 | } 428 | if (endResponse && connection.res && !connection.res.writableEnded) { 429 | try { 430 | connection.res.end(); 431 | } 432 | catch (e: any) { 433 | logger.warn(`Error ending response for connection ${connectionId}: ${e instanceof Error ? e.message : String(e)}`); 434 | } 435 | } 436 | this._connections.delete(connectionId); 437 | logger.debug(`Connection removed: ${connectionId}. Remaining connections: ${this._connections.size}`); 438 | } 439 | else { 440 | logger.debug(`Attempted to clean up non-existent connection: ${connectionId}`); 441 | } 442 | } 443 | 444 | isRunning(): boolean { 445 | return Boolean(this._server) 446 | } 447 | } 448 | -------------------------------------------------------------------------------- /src/transports/sse/types.ts: -------------------------------------------------------------------------------- 1 | import { AuthConfig } from "../../auth/types.js"; 2 | 3 | /** 4 | * CORS configuration options for SSE transport 5 | */ 6 | export interface CORSConfig { 7 | /** 8 | * Access-Control-Allow-Origin header 9 | * @default "*" 10 | */ 11 | allowOrigin?: string; 12 | 13 | /** 14 | * Access-Control-Allow-Methods header 15 | * @default "GET, POST, OPTIONS" 16 | */ 17 | allowMethods?: string; 18 | 19 | /** 20 | * Access-Control-Allow-Headers header 21 | * @default "Content-Type, Authorization, x-api-key" 22 | */ 23 | allowHeaders?: string; 24 | 25 | /** 26 | * Access-Control-Expose-Headers header 27 | * @default "Content-Type, Authorization, x-api-key" 28 | */ 29 | exposeHeaders?: string; 30 | 31 | /** 32 | * Access-Control-Max-Age header for preflight requests 33 | * @default "86400" 34 | */ 35 | maxAge?: string; 36 | } 37 | 38 | /** 39 | * Configuration options for SSE transport 40 | */ 41 | export interface SSETransportConfig { 42 | /** 43 | * Port to listen on 44 | */ 45 | port?: number; 46 | 47 | /** 48 | * Endpoint for SSE events stream 49 | * @default "/sse" 50 | */ 51 | endpoint?: string; 52 | 53 | /** 54 | * Endpoint for receiving messages via POST 55 | * @default "/messages" 56 | */ 57 | messageEndpoint?: string; 58 | 59 | /** 60 | * Maximum allowed message size in bytes 61 | * @default "4mb" 62 | */ 63 | maxMessageSize?: string; 64 | 65 | /** 66 | * Custom headers to add to SSE responses 67 | */ 68 | headers?: Record; 69 | 70 | /** 71 | * CORS configuration 72 | */ 73 | cors?: CORSConfig; 74 | 75 | /** 76 | * Authentication configuration 77 | */ 78 | auth?: AuthConfig; 79 | } 80 | 81 | /** 82 | * Internal configuration type with required fields except headers 83 | */ 84 | export type SSETransportConfigInternal = Required> & { 85 | headers?: Record; 86 | auth?: AuthConfig; 87 | cors?: CORSConfig; 88 | }; 89 | 90 | /** 91 | * Default CORS configuration 92 | */ 93 | export const DEFAULT_CORS_CONFIG: CORSConfig = { 94 | allowOrigin: "*", 95 | allowMethods: "GET, POST, DELETE, OPTIONS", 96 | allowHeaders: "Content-Type, Accept, Authorization, x-api-key, Mcp-Session-Id, Last-Event-ID", 97 | exposeHeaders: "Content-Type, Authorization, x-api-key, Mcp-Session-Id", 98 | maxAge: "86400" 99 | }; 100 | 101 | /** 102 | * Default configuration values 103 | */ 104 | export const DEFAULT_SSE_CONFIG: SSETransportConfigInternal = { 105 | port: 8080, 106 | endpoint: "/sse", 107 | messageEndpoint: "/messages", 108 | maxMessageSize: "4mb" 109 | }; 110 | -------------------------------------------------------------------------------- /src/transports/stdio/server.ts: -------------------------------------------------------------------------------- 1 | import { StdioServerTransport as SDKStdioTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 2 | import { BaseTransport } from "../base.js"; 3 | import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; 4 | import { 5 | ImageTransportOptions, 6 | DEFAULT_IMAGE_OPTIONS, 7 | hasImageContent, 8 | prepareImageForTransport, 9 | ImageContent 10 | } from "../utils/image-handler.js"; 11 | import { logger } from "../../core/Logger.js"; 12 | 13 | type ExtendedJSONRPCMessage = JSONRPCMessage & { 14 | result?: { 15 | content?: Array; 16 | [key: string]: unknown; 17 | }; 18 | }; 19 | 20 | /** 21 | * StdioServerTransport 22 | */ 23 | export class StdioServerTransport implements BaseTransport { 24 | readonly type = "stdio"; 25 | private transport: SDKStdioTransport; 26 | private running: boolean = false; 27 | private imageOptions: ImageTransportOptions; 28 | 29 | constructor(imageOptions: Partial = {}) { 30 | this.transport = new SDKStdioTransport(); 31 | this.imageOptions = { 32 | ...DEFAULT_IMAGE_OPTIONS, 33 | ...imageOptions 34 | }; 35 | } 36 | 37 | async start(): Promise { 38 | await this.transport.start(); 39 | this.running = true; 40 | } 41 | 42 | async send(message: ExtendedJSONRPCMessage): Promise { 43 | try { 44 | if (hasImageContent(message)) { 45 | message = this.prepareMessageWithImage(message); 46 | } 47 | await this.transport.send(message); 48 | } catch (error) { 49 | logger.error(`Error sending message through stdio transport: ${error}`); 50 | throw error; 51 | } 52 | } 53 | 54 | private prepareMessageWithImage(message: ExtendedJSONRPCMessage): ExtendedJSONRPCMessage { 55 | if (!message.result?.content) { 56 | return message; 57 | } 58 | 59 | const processedContent = message.result.content.map((item: ImageContent | { type: string; [key: string]: unknown }) => { 60 | if (item.type === 'image') { 61 | return prepareImageForTransport(item as ImageContent, this.imageOptions); 62 | } 63 | return item; 64 | }); 65 | 66 | return { 67 | ...message, 68 | result: { 69 | ...message.result, 70 | content: processedContent 71 | } 72 | }; 73 | } 74 | 75 | async close(): Promise { 76 | await this.transport.close(); 77 | this.running = false; 78 | } 79 | 80 | isRunning(): boolean { 81 | return this.running; 82 | } 83 | 84 | set onclose(handler: (() => void) | undefined) { 85 | this.transport.onclose = handler; 86 | } 87 | 88 | set onerror(handler: ((error: Error) => void) | undefined) { 89 | this.transport.onerror = handler; 90 | } 91 | 92 | set onmessage(handler: ((message: JSONRPCMessage) => void) | undefined) { 93 | this.transport.onmessage = handler; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/transports/utils/image-handler.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * Configuration options for image transport 5 | */ 6 | export interface ImageTransportOptions { 7 | maxSize: number; 8 | allowedMimeTypes: string[]; 9 | compressionQuality?: number; 10 | } 11 | 12 | /** 13 | * Schema for image content validation 14 | */ 15 | export const ImageContentSchema = z.object({ 16 | type: z.literal("image"), 17 | data: z.string(), 18 | mimeType: z.string() 19 | }); 20 | 21 | export type ImageContent = z.infer; 22 | 23 | /** 24 | * Default configuration for image transport 25 | */ 26 | export const DEFAULT_IMAGE_OPTIONS: ImageTransportOptions = { 27 | maxSize: 5 * 1024 * 1024, // 5MB 28 | allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], 29 | compressionQuality: 0.8 30 | }; 31 | 32 | /** 33 | * Validates image content against transport options 34 | */ 35 | export function validateImageTransport(content: ImageContent, options: ImageTransportOptions = DEFAULT_IMAGE_OPTIONS): void { 36 | // Validate schema 37 | ImageContentSchema.parse(content); 38 | 39 | // Validate MIME type 40 | if (!options.allowedMimeTypes.includes(content.mimeType)) { 41 | throw new Error(`Unsupported image type: ${content.mimeType}. Allowed types: ${options.allowedMimeTypes.join(', ')}`); 42 | } 43 | 44 | // Validate base64 format 45 | if (!isBase64(content.data)) { 46 | throw new Error('Invalid base64 image data'); 47 | } 48 | 49 | // Validate size 50 | const sizeInBytes = Buffer.from(content.data, 'base64').length; 51 | if (sizeInBytes > options.maxSize) { 52 | throw new Error(`Image size ${sizeInBytes} bytes exceeds maximum allowed size of ${options.maxSize} bytes`); 53 | } 54 | } 55 | 56 | /** 57 | * Prepares image content for transport 58 | * This function can be extended to handle compression, format conversion, etc. 59 | */ 60 | export function prepareImageForTransport(content: ImageContent, options: ImageTransportOptions = DEFAULT_IMAGE_OPTIONS): ImageContent { 61 | validateImageTransport(content, options); 62 | 63 | // For now, we just return the validated content 64 | // Future: implement compression, format conversion, etc. 65 | return content; 66 | } 67 | 68 | /** 69 | * Checks if a string is valid base64 70 | */ 71 | function isBase64(str: string): boolean { 72 | if (str === '' || str.trim() === '') { 73 | return false; 74 | } 75 | try { 76 | return btoa(atob(str)) === str; 77 | } catch (_error) { 78 | return false; 79 | } 80 | } 81 | 82 | /** 83 | * Gets the size of a base64 image in bytes 84 | */ 85 | export function getBase64ImageSize(base64String: string): number { 86 | return Buffer.from(base64String, 'base64').length; 87 | } 88 | 89 | /** 90 | * Utility type for messages containing image content 91 | */ 92 | export type MessageWithImage = { 93 | result?: { 94 | content?: Array; 95 | }; 96 | [key: string]: unknown; 97 | }; 98 | 99 | /** 100 | * Checks if a message contains image content 101 | */ 102 | export function hasImageContent(message: unknown): message is MessageWithImage { 103 | if (typeof message !== 'object' || message === null) { 104 | return false; 105 | } 106 | 107 | const msg = message as MessageWithImage; 108 | return Array.isArray(msg.result?.content) && 109 | msg.result.content.some(item => item.type === 'image'); 110 | } 111 | -------------------------------------------------------------------------------- /src/transports/utils/ping-message.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pre-stringified JSON-RPC ping message formatted for Server-Sent Events (SSE). 3 | * Includes the 'data: ' prefix and trailing newlines. 4 | */ 5 | export const PING_SSE_MESSAGE = 'data: {"jsonrpc":"2.0","method":"ping"}\n\n'; -------------------------------------------------------------------------------- /src/utils/headers.ts: -------------------------------------------------------------------------------- 1 | import { ServerResponse } from "node:http" 2 | 3 | export function getRequestHeader(headers: NodeJS.Dict, headerName: string): string | undefined { 4 | const headerLower = headerName.toLowerCase() 5 | return Object.entries(headers).find( 6 | ([key]) => key.toLowerCase() === headerLower 7 | )?.[1] as string | undefined 8 | } 9 | 10 | export function setResponseHeaders(res: ServerResponse, headers: Record): void { 11 | Object.entries(headers).forEach(([key, value]) => { 12 | res.setHeader(key, value) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "declaration": true, 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "**/*.test.ts"] 17 | } 18 | --------------------------------------------------------------------------------