├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets └── demo │ ├── cursor.gif │ └── yamcp.gif ├── package.json ├── pnpm-lock.yaml ├── src ├── cli │ ├── common │ │ ├── prompts.ts │ │ └── utils.ts │ ├── log │ │ └── log.ts │ ├── run │ │ ├── actions │ │ │ └── runGateway.ts │ │ └── run.ts │ ├── server │ │ ├── actions │ │ │ ├── add.ts │ │ │ ├── import.ts │ │ │ ├── list.ts │ │ │ ├── remove.ts │ │ │ └── scan.ts │ │ └── server.ts │ ├── types.ts │ └── workspace │ │ ├── actions │ │ ├── create.ts │ │ ├── delete.ts │ │ ├── edit.ts │ │ ├── list.ts │ │ └── scan.ts │ │ └── workspace.ts ├── config.ts ├── example-servers.json ├── gateway.ts ├── gatewayRouter.ts ├── gatewayServer.ts ├── hooks │ └── type.ts ├── index.ts ├── providerClient.ts ├── providerScanner.ts ├── store │ ├── loader.ts │ ├── provider.ts │ ├── schema.ts │ └── workspace.ts └── utility │ ├── file.ts │ ├── logger.ts │ └── namespace.ts ├── tsconfig.base.json ├── tsconfig.json └── vitest.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | # turborepo 139 | .turbo 140 | 141 | #DS_Store 142 | .DS_Store 143 | 144 | # ignore .store folder 145 | .store/ 146 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍠 YAMCP - A Model Context Workspace Manager 2 | 3 | YAMCP (YAM-C-P) is a command-line tool for organizing and managing MCP servers as local workspaces. It seamlessly connects to multiple MCP servers, local or remote, grouping them into a unified workspace exposed as Yet Another MCP server (YAM) for AI applications. You can create dedicated workspaces based on specific functionality (e.g., a YAM workspace for coding, design, research, ...) or based on the AI apps that consume servers (e.g., a YAM for Cursor, Claude, Windsurf) or any other combination in between. In addition, it simplifies monitoring and debugging MCP servers by centralizing all server communication logs in a single store, eliminating the need to dig through each AI client app’s logs separately. 4 | 5 | ## Import and Create Workspaces 6 | 7 |
8 | yamcp demoo 9 |
10 | 11 | ## Connect All Bundled Servers in a Workspace to Your AI Apps with One Config 12 | 13 |
14 | cursor demo 15 |
16 | 17 | ## 🚀 Quick Start 18 | 19 | ```bash 20 | # Install YAMCP 21 | npm install -g yamcp # or use npx yamcp 22 | 23 | # Import servers (choose one) 24 | yamcp server import [config] # import servers from config file (see src/example-servers.json for format) 25 | yamcp server add # or add manually 26 | 27 | # create workspaces (e.g. a yam for coding, design, data, ...) 28 | yamcp yam create 29 | 30 | # Run workspace in your AI app 31 | yamcp run 32 | ``` 33 | 34 | ## 🔑 Key Concepts 35 | 36 | - **MCP Servers**: Remote or local servers that provide Model Context Protocol services 37 | - **Workspaces (YAMs)**: Collections of MCP servers grouped together to be shared with AI Apps (e.g. a workspace for coding, writing, design, magic making!) 38 | - **Gateway**: A local MCP server that manages connections to configured MCP servers in a workspace and exposes them through a unified server to AI App's MCP clients 39 | 40 | With YAMCP, you can: 41 | 42 | - Create workspaces to group MCP servers by AI application (e.g. Cursor, Claude, GitHub Copilot) 43 | - Group servers by workflow purpose (e.g. software development, data science, technical writing) 44 | - Connect AI apps to a single gateway that provides access to all workspace servers 45 | - Manage and monitor multiple MCP server connections through a unified interface 46 | - Track all server communications with detailed logging and debugging capabilities 47 | 48 | ## Top-Level Commands 49 | 50 | ```bash 51 | yamcp [command] [subcommand] [flags] 52 | ``` 53 | 54 | Available top-level commands: 55 | 56 | - `server` - Manage MCP providers 57 | - `yam` - Manage workspaces (yams) 58 | - `run` - Run the gateway with a workspace 59 | - `log` - View the server log location 60 | 61 | --- 62 | 63 | ## 🔧 **Mcp Server Management Commands** 64 | 65 | ### Server Commands 66 | 67 | ```bash 68 | yamcp server add # Add a new MCP server (interactive) 69 | yamcp server list # List all configured servers and their status 70 | yamcp server remove # Remove a server configuration 71 | yamcp server import # Import server configurations from a JSON file 72 | ``` 73 | 74 | --- 75 | 76 | ## 🍠 **Yam Workspace Management Commands** 77 | 78 | ### Workspace Commands 79 | 80 | ```bash 81 | yamcp yam create # Create a new workspace (interactive) 82 | yamcp yam list # List all workspaces or show specific workspace details 83 | yamcp yam edit # Modify an existing workspace configuration 84 | yamcp yam scan # Scan workspaces 85 | yamcp yam delete # Delete a workspace 86 | ``` 87 | 88 | ### Runtime Commands 89 | 90 | ```bash 91 | yamcp run # Start the gateway with specified workspace 92 | yamcp log # View server communication logs 93 | ``` 94 | 95 | --- 96 | 97 | ## ✅ Command Reference 98 | 99 | | Command | Description | Example | 100 | | ----------------- | ----------------------- | ------------------------------------ | 101 | | `server add` | Add a new MCP server | `yamcp server add` | 102 | | `server list` | List configured servers | `yamcp server list` | 103 | | `server remove` | Remove a server | `yamcp server remove [name]` | 104 | | `server import` | Import server config | `yamcp server import [config]` | 105 | | `yam create` | Create workspace | `yamcp yam create` | 106 | | `yam list` | List workspaces | `yamcp yam list` | 107 | | `yam list --name` | Show workspace details | `yamcp yam list --name my-workspace` | 108 | | `yam edit` | Edit workspace | `yamcp yam edit` | 109 | | `yam scan ` | Scan workspace | `yamcp yam scan [workspace-name]` | 110 | | `yam delete` | Delete workspace | `yamcp yam delete [workspace-name]` | 111 | | `run` | Start gateway | `yamcp run ` | 112 | | `log` | View logs | `yamcp log` | 113 | 114 | --- 115 | 116 | ## 🖥️ YAMCP UI (created by [@eladcandroid](https://github.com/eladcandroid)) 117 | [YAMCP UI](https://github.com/eladcandroid/yamcp-ui) provides an intuitive web interface to manage your MCP servers and workspaces through a universal dashboard. 118 | 119 | ```bash 120 | # Run directly with npx (recommended) 121 | npx yamcp-ui 122 | 123 | # Or install globally 124 | npm install -g yamcp-ui 125 | yamcp-ui 126 | ``` 127 | 128 | Refer to the project repo for full documentation: https://github.com/eladcandroid/yamcp-ui. 129 | 130 | ## 🏗️ System Architecture 131 | 132 | ```mermaid 133 | graph TB 134 | CLI[CLI Commands] 135 | GW[McpGateway] 136 | GS[GatewayServer] 137 | GR[GatewayRouter] 138 | LOG[Logger] 139 | STORE[(Store)] 140 | AI_APP[AI App] 141 | 142 | %% CLI Command Flow 143 | CLI -->|manages| STORE 144 | CLI -->|runs| GW 145 | 146 | %% Gateway Components 147 | GW -->|uses| GS 148 | GW -->|uses| GR 149 | GW -->|logs| LOG 150 | 151 | %% Server & Router 152 | GS -->|stdio transport| AI_APP 153 | GR -->|connects to| SERVERX 154 | GR -->|connects to| SERVERY 155 | 156 | %% Data Store 157 | STORE -->|loads config| GW 158 | 159 | %% External MCP Servers 160 | subgraph "Workspace Servers" 161 | SERVERX["Server x (Stdio)"] 162 | SERVERY["Server y (SSE)"] 163 | end 164 | %% Store Components 165 | subgraph "Configuration Store" 166 | PROVIDERS[(Provider Config)] 167 | WORKSPACES[(Workspace Config)] 168 | end 169 | STORE --- PROVIDERS 170 | STORE --- WORKSPACES 171 | 172 | classDef primary fill:#2374ab,stroke:#2374ab,color:#fff 173 | classDef secondary fill:#ff7e67,stroke:#ff7e67,color:#fff 174 | classDef store fill:#95b8d1,stroke:#95b8d1,color:#fff 175 | 176 | class GW,GS,GR primary 177 | class CLI,AI_APP secondary 178 | class STORE,PROVIDERS,WORKSPACES store 179 | ``` 180 | 181 | The diagram shows the main components of the YAMCP system: 182 | 183 | - **CLI Commands**: User interface for managing servers and workspaces 184 | - **McpGateway**: Core component that coordinates the Gateway Server and Router 185 | - **GatewayServer**: Handles communication with AI Apps via stdio transport 186 | - **GatewayRouter**: Manages connections to configured MCP servers 187 | - **Logger**: Provides consolidated logging for all components 188 | - **Store**: Manages configuration for providers and workspaces 189 | - **MCP Servers**: Both local (stdio) and remote (SSE) servers that provide MCP services 190 | 191 | ## 🪪 Security Audits 192 | 193 | [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/hamidra-yamcp-badge.png)](https://mseep.ai/app/hamidra-yamcp) 194 | -------------------------------------------------------------------------------- /assets/demo/cursor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamidra/yamcp/f74f5261bc0c7b1527fc0b29cb6607aad179ecb4/assets/demo/cursor.gif -------------------------------------------------------------------------------- /assets/demo/yamcp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamidra/yamcp/f74f5261bc0c7b1527fc0b29cb6607aad179ecb4/assets/demo/yamcp.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yamcp", 3 | "version": "0.2.2", 4 | "description": "Organize and manage your MCP servers in YAM (Yet Another MCP) workspaces - create dedicated workspaces for Research, Coding, Data Analysis, and more", 5 | "types": "dist/index.d.ts", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/hamidra/yamcp" 9 | }, 10 | "bin": { 11 | "yamcp": "dist/index.js" 12 | }, 13 | "scripts": { 14 | "dev": "tsx src/index.ts", 15 | "build": "pnpm run clean && tsc && pnpm run copy-example && chmod +x dist/index.js", 16 | "copy-example": "cp src/example-servers.json dist/example-servers.json", 17 | "inspect": "npx @modelcontextprotocol/inspector node dist/index.js", 18 | "clean": "rm -rf dist", 19 | "lint": "eslint src --ext .ts,.tsx", 20 | "test": "vitest run", 21 | "test:watch": "vitest", 22 | "start": "node dist/index.js", 23 | "prepublishOnly": "pnpm run build" 24 | }, 25 | "keywords": [], 26 | "author": "", 27 | "license": "MIT", 28 | "devDependencies": { 29 | "@modelcontextprotocol/inspector": "^0.10.2", 30 | "@types/node": "^20.17.30", 31 | "@types/prompts": "^2.4.9", 32 | "@types/treeify": "^1.0.3", 33 | "@types/uuid": "^9.0.8", 34 | "eslint": "^9", 35 | "typescript": "^5", 36 | "vitest": "^1.0.0" 37 | }, 38 | "dependencies": { 39 | "@modelcontextprotocol/sdk": "^1.9.0", 40 | "boxen": "^8.0.1", 41 | "chalk": "^5.4.1", 42 | "commander": "^13.1.0", 43 | "dotenv": "^16.4.7", 44 | "enquirer": "^2.4.1", 45 | "env-paths": "^3.0.0", 46 | "net": "^1.0.2", 47 | "open": "^10.1.0", 48 | "ora": "^8.2.0", 49 | "prompts": "^2.4.2", 50 | "treeify": "^1.1.0", 51 | "tsx": "^4.19.3", 52 | "uuid": "^9.0.1", 53 | "winston": "^3.17.0", 54 | "zod": "^3.24.2" 55 | }, 56 | "files": [ 57 | "dist" 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/cli/common/prompts.ts: -------------------------------------------------------------------------------- 1 | import { McpProvider } from "../../store/schema"; 2 | import prompts from "prompts"; 3 | import chalk from "chalk"; 4 | import { groupProvidersByType } from "./utils"; 5 | 6 | // select servers 7 | async function selectServersPrompt( 8 | providers: McpProvider[], 9 | onCancel: () => void 10 | ) { 11 | // Group servers by type for better organization 12 | const providersByType = groupProvidersByType(providers); 13 | 14 | // Create choices for the multiselect prompt 15 | const serverOptions = Object.entries(providersByType).flatMap( 16 | ([type, providers]) => [ 17 | { 18 | title: chalk.yellow(`---- ${type} MCP Servers ----`), 19 | value: `group_${type}`, 20 | group: type, 21 | description: `Select/deselect all ${type} servers`, 22 | }, 23 | ...providers.map((provider) => ({ 24 | title: `${provider.namespace}`, 25 | value: provider.namespace, 26 | description: `Select/deselect ${provider.namespace}`, 27 | group: type, 28 | })), 29 | ] 30 | ); 31 | 32 | // Select servers 33 | const serversResponse = await prompts( 34 | { 35 | type: "multiselect", 36 | name: "selectedServers", 37 | message: "Select servers to include:", 38 | choices: serverOptions, 39 | min: 1, 40 | instructions: false, 41 | hint: "- Space to select. Return to submit", 42 | }, 43 | { onCancel } 44 | ); 45 | 46 | // check if all groups are selected and 47 | const selections = serversResponse.selectedServers as string[]; 48 | const expandedSelections = selections.reduce((acc, selection) => { 49 | if (selection.startsWith("group_")) { 50 | const type = selection.replace("group_", ""); 51 | const providers = providersByType[type]?.map((p) => p.namespace) || []; 52 | return [...acc, ...providers]; 53 | } 54 | return [...acc, selection]; 55 | }, [] as string[]); 56 | // deduplicate selections 57 | const finalSelection = [...new Set(expandedSelections)]; 58 | return finalSelection; 59 | } 60 | 61 | export { selectServersPrompt }; 62 | -------------------------------------------------------------------------------- /src/cli/common/utils.ts: -------------------------------------------------------------------------------- 1 | import type { TreeNode } from "../types"; 2 | import type { Logger } from "../../utility/logger"; 3 | import { Namespace } from "../../utility/namespace"; 4 | import chalk from "chalk"; 5 | import { McpProvider, isStdioConfig, isSSEConfig } from "../../store/schema"; 6 | import { 7 | scanProvider, 8 | isScanSuccessful, 9 | getScanFailures, 10 | ScanResult, 11 | } from "../../providerScanner"; 12 | import prompts from "prompts"; 13 | import treeify from "treeify"; 14 | 15 | /** 16 | * Common CLI utilities and helpers for provider management, workspace handling, and user prompts. 17 | * 18 | * This module includes functions for parsing provider parameters, building provider trees, 19 | * displaying workspace choices, scanning providers, and printing scan results. 20 | * It is used throughout the CLI to facilitate user interaction and provider configuration. 21 | */ 22 | 23 | /** 24 | * Parses provider parameters and constructs a McpProvider object. 25 | * 26 | * @param name - The namespace or name of the provider. 27 | * @param options - Options containing either a command (with optional env) or a url. 28 | * @returns A McpProvider object configured with the given parameters. 29 | * @throws If neither command nor url is provided, or if command is missing. 30 | */ 31 | function parseProviderParameters( 32 | name: string, 33 | options: { command?: string; env?: string[]; url?: string } 34 | ): McpProvider { 35 | if (options.url) { 36 | return { 37 | type: "sse", 38 | namespace: name, 39 | providerParameters: { 40 | url: options.url, 41 | }, 42 | }; 43 | } 44 | 45 | if (options.command) { 46 | // Split command into command and args, handling quoted arguments 47 | const parts = options.command.match(/(?:[^\s"]+|"[^"]*")+/g) || []; 48 | const command = parts[0]; 49 | if (!command) { 50 | throw new Error("Command is missing"); 51 | } 52 | const args = parts.slice(1).map((arg) => arg.replace(/^"(.*)"$/, "$1")); 53 | 54 | const envVars: Record = {}; 55 | if (options.env) { 56 | for (const envPair of options.env) { 57 | const [key, value] = envPair.split("="); 58 | if (key && value) { 59 | envVars[key] = value; 60 | } 61 | } 62 | } 63 | 64 | return { 65 | type: "stdio", 66 | namespace: name, 67 | providerParameters: { 68 | command, 69 | args, 70 | env: envVars, 71 | }, 72 | }; 73 | } 74 | throw new Error("Either command or url must be provided"); 75 | } 76 | 77 | /** 78 | * Builds a tree representation of a provider for display purposes. 79 | * 80 | * @param provider - The McpProvider to represent as a tree. 81 | * @returns A TreeNode representing the provider's configuration. 82 | */ 83 | function buildProviderTree(provider: McpProvider): TreeNode { 84 | let subtree: TreeNode = {}; 85 | if (isStdioConfig(provider)) { 86 | subtree = { 87 | [`Command: ${provider.providerParameters.command}`]: {}, 88 | }; 89 | 90 | if (provider.providerParameters.args?.length) { 91 | subtree[`Args: ${provider.providerParameters.args.join(" ")}`] = {}; 92 | } 93 | 94 | const envVars = provider.providerParameters.env; 95 | if (Object.keys(envVars || {}).length > 0) { 96 | const envTree: TreeNode = {}; 97 | Object.entries(envVars || {}).forEach(([key, value]) => { 98 | envTree[chalk.dim(`${key}=${value}`)] = {}; 99 | }); 100 | subtree["Environment Variables"] = envTree; 101 | } 102 | } else if (isSSEConfig(provider)) { 103 | subtree = { 104 | [`URL: ${provider.providerParameters.url}`]: {}, 105 | }; 106 | } 107 | const tree: TreeNode = {}; 108 | tree[chalk.bold.cyan(provider.namespace)] = { 109 | [`Type: ${provider.type}`]: {}, 110 | ...subtree, 111 | }; 112 | return tree; 113 | } 114 | 115 | /** 116 | * Builds a list of provider options suitable for selection prompts. 117 | * 118 | * @param providers - Array of McpProvider objects. 119 | * @returns An array of prompt option objects with provider details in a tree format in the description. 120 | */ 121 | function buildDetailedProviderOptions(providers: McpProvider[]) { 122 | return providers.map((provider) => { 123 | const tree = buildProviderTree(provider); 124 | return { 125 | title: `- ${provider.namespace} (${provider.type})`, 126 | value: provider, 127 | description: `${treeify.asTree(tree, true, true)}`, 128 | }; 129 | }); 130 | } 131 | 132 | /** 133 | * Builds a list of provider options suitable for selection prompts. 134 | * 135 | * @param providers - Array of McpProvider objects. 136 | * @returns An array of prompt option objects with title, value, and description. 137 | */ 138 | function buildProviderOptions(providers: McpProvider[], description: string) { 139 | return providers.map((provider) => { 140 | return { 141 | title: `- ${provider.namespace} (${provider.type})`, 142 | value: provider, 143 | description, 144 | }; 145 | }); 146 | } 147 | 148 | /** 149 | * Retrieves providers from a record based on a workspace list, logging errors if not found. 150 | * 151 | * @param providers - Record of provider name to McpProvider. 152 | * @param workspace - Array of provider names (namespaces) in the workspace. 153 | * @param logger - Optional logger for error output. 154 | * @returns An array of found McpProvider objects (missing ones are filtered out). 155 | */ 156 | function getWorkspaceProviders( 157 | providers: Record, 158 | workspace: Namespace[], 159 | logger?: Logger 160 | ) { 161 | const workspaceProviders = workspace.map((wsProvider) => { 162 | const provider = providers[wsProvider]; 163 | if (!provider) { 164 | if (logger) { 165 | logger.error(`Provider ${wsProvider} not found`); 166 | } else { 167 | console.error(chalk.red(`✘Provider ${wsProvider} not found`)); 168 | } 169 | } 170 | return provider; 171 | }); 172 | return workspaceProviders.filter((provider) => provider); 173 | } 174 | 175 | /** 176 | * Exits the process with the given exit code. 177 | * 178 | * @param code - The exit code to use. 179 | */ 180 | function returnAndExit(code: number) { 181 | process.exit(code); 182 | } 183 | 184 | /** 185 | * Scans a provider and prompts the user to confirm adding it if the scan fails. 186 | * 187 | * @param mcpProvider - The provider to scan. 188 | * @param initial - Whether this is the initial scan (affects prompt default). 189 | * @returns True if the provider should be added, false otherwise. 190 | */ 191 | async function scanProviderAndConfirm( 192 | mcpProvider: McpProvider, 193 | initial: boolean = false 194 | ) { 195 | const scanResult = await scanProvider(mcpProvider); 196 | if (!isScanSuccessful(scanResult)) { 197 | console.log( 198 | chalk.red(`✘ Mcp server "${mcpProvider.namespace}" scan failed`) 199 | ); 200 | console.log(chalk.red(getScanFailures(scanResult).join("\n"))); 201 | const continueAdding = await prompts({ 202 | type: "confirm", 203 | name: "value", 204 | message: "Scan failed, Do you still want to add provider?", 205 | initial: initial, 206 | }); 207 | 208 | if (!continueAdding.value) { 209 | return false; 210 | } 211 | } 212 | console.log( 213 | chalk.green(`✔ Server "${mcpProvider.namespace}" imported successfully`) 214 | ); 215 | printScanResult(scanResult); 216 | return true; 217 | } 218 | 219 | /** 220 | * Prints the scan result in a tree format, showing tools, prompts, and resources. 221 | * 222 | * @param scanResult - The ScanResult object to display. 223 | */ 224 | function printScanResult(scanResult: ScanResult) { 225 | type CapabilityTree = { 226 | [key: string]: string | CapabilityTree; 227 | }; 228 | 229 | const tools = scanResult.capabilities.data?.tools?.list; 230 | const prompts = scanResult.capabilities.data?.prompts?.list; 231 | const resources = scanResult.capabilities.data?.resources?.list; 232 | 233 | const tree: CapabilityTree = {}; 234 | 235 | if (tools?.length && tools.length > 0) { 236 | tree["Tools"] = {}; 237 | const toolsNode: CapabilityTree = {}; 238 | tools.forEach((tool) => { 239 | toolsNode[tool.name] = {}; 240 | }); 241 | tree["Tools"] = toolsNode; 242 | } 243 | 244 | if (prompts?.length && prompts.length > 0) { 245 | tree["Prompts"] = {}; 246 | const promptsNode: CapabilityTree = {}; 247 | prompts.forEach((prompt) => { 248 | promptsNode[prompt.name] = {}; 249 | }); 250 | tree["Prompts"] = promptsNode; 251 | } 252 | 253 | if (resources?.length && resources.length > 0) { 254 | tree["Resources"] = {}; 255 | const resourcesNode: CapabilityTree = {}; 256 | resources.forEach((resource) => { 257 | resourcesNode[resource.name] = {}; 258 | }); 259 | tree["Resources"] = resourcesNode; 260 | } 261 | 262 | if (Object.keys(tree).length > 0) { 263 | console.log(chalk.bold("Server Capabilities:")); 264 | console.log(chalk.dim("---------------------")); 265 | console.log(treeify.asTree(tree, true, true)); 266 | } 267 | } 268 | 269 | /** 270 | * Builds a tree structure representing workspaces and their providers. 271 | * 272 | * @param workspaces - Record mapping workspace names to arrays of provider names. 273 | * @returns A nested object tree suitable for display. 274 | */ 275 | function buildWorkspaceTree( 276 | workspaces: Record 277 | ): Record> { 278 | // Create tree structure for workspaces 279 | const workspaceTree: Record> = {}; 280 | Object.entries(workspaces).forEach(([wsName, providers]) => { 281 | workspaceTree[wsName] = providers.reduce((acc, provider) => { 282 | acc[provider] = ""; 283 | return acc; 284 | }, {} as Record); 285 | }); 286 | return workspaceTree; 287 | } 288 | 289 | /** 290 | * Displays a prompt for the user to select a workspace, with an option to exit. 291 | * 292 | * @param workspaces - Record mapping workspace names to arrays of provider names. 293 | * @param promptMessage - Optional custom prompt message. 294 | * @returns The selected workspace name, or undefined if exited. 295 | */ 296 | async function displayWorkspacesChoice( 297 | workspaces: Record, 298 | promptMessage: string = "Select a workspace to view details (use arrow keys)" 299 | ) { 300 | // Create selection list 301 | const choices = [ 302 | ...Object.keys(workspaces).map((ws) => { 303 | const providerCount = workspaces[ws].length; 304 | const showCount = 4; 305 | const notDisplayedHint = 306 | providerCount > showCount ? `+ ${providerCount - showCount} more` : ""; 307 | const description = `${providerCount} servers (${workspaces[ws] 308 | .slice(0, showCount) 309 | .join(", ")} ${notDisplayedHint})`; 310 | return { 311 | title: ws, 312 | value: ws, 313 | description: description, 314 | }; 315 | }), 316 | { 317 | title: "Exit", 318 | value: "exit", 319 | description: "Return to main menu", 320 | }, 321 | ]; 322 | 323 | const response = await prompts({ 324 | type: "select", 325 | name: "workspace", 326 | message: promptMessage, 327 | choices, 328 | }); 329 | 330 | if (!response.workspace || response.workspace === "exit") { 331 | return; 332 | } else { 333 | return response.workspace; 334 | } 335 | } 336 | 337 | function groupProvidersByType(providers: McpProvider[]) { 338 | return providers.reduce((acc, provider) => { 339 | const key = provider.type.toUpperCase(); 340 | if (!acc[key]) acc[key] = []; 341 | acc[key].push(provider); 342 | return acc; 343 | }, {} as Record); 344 | } 345 | 346 | export { 347 | parseProviderParameters, 348 | buildProviderTree, 349 | buildDetailedProviderOptions, 350 | buildProviderOptions, 351 | getWorkspaceProviders, 352 | returnAndExit, 353 | scanProviderAndConfirm, 354 | printScanResult, 355 | buildWorkspaceTree, 356 | displayWorkspacesChoice, 357 | groupProvidersByType, 358 | }; 359 | -------------------------------------------------------------------------------- /src/cli/log/log.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { LOG_DIR } from "../../config"; 3 | import chalk from "chalk"; 4 | 5 | export function logCommand(program: Command) { 6 | program 7 | .command("log") 8 | .description("View logs") 9 | .action(() => { 10 | // print the LOG_DIR from config 11 | const logDir = LOG_DIR; 12 | if (logDir) { 13 | console.log( 14 | chalk.green(`\n\nYou can find logs in ${chalk.bold(logDir)}\n\n`) 15 | ); 16 | } else { 17 | console.log(chalk.red("Logs directory not set")); 18 | } 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/cli/run/actions/runGateway.ts: -------------------------------------------------------------------------------- 1 | import { GatewayServer } from "../../../gatewayServer"; 2 | import { GatewayRouter } from "../../../gatewayRouter"; 3 | import { McpGateway } from "../../../gateway"; 4 | import { Logger, getLogNamespace } from "../../../utility/logger"; 5 | import { loadProvidersMap, loadWorkspaceMap } from "../../../store/loader"; 6 | import { getWorkspaceProviders } from "../../common/utils"; 7 | 8 | export async function runGatewayAction(workspaceName: string) { 9 | let logger: Logger | undefined; 10 | // create a logger with a unique namespace for this run 11 | try { 12 | const logNamespace = getLogNamespace(workspaceName); 13 | logger = new Logger(logNamespace); 14 | } catch (error) { 15 | // TODO: consider if this should be fatal if logging is a requirement 16 | // logger failure does not stop the gateway 17 | } 18 | 19 | try { 20 | const providers = loadProvidersMap(); 21 | const workspaces = loadWorkspaceMap(); 22 | 23 | if (!workspaces[workspaceName]) { 24 | logger?.error(`Workspace ${workspaceName} not found`); 25 | throw new Error(`Workspace ${workspaceName} not found`); 26 | } 27 | 28 | const workspaceProviders = getWorkspaceProviders( 29 | providers, 30 | workspaces[workspaceName], 31 | logger 32 | ); 33 | 34 | logger?.info(JSON.stringify(workspaceProviders, null, 2)); 35 | const server = new GatewayServer(); 36 | const router = new GatewayRouter(logger); 37 | // Create gateway instance 38 | const gateway = new McpGateway(router, server, logger); 39 | 40 | // Handle SIGINT 41 | process.on("SIGINT", async () => { 42 | try { 43 | await gateway.stop(); 44 | logger?.info("Gateway stopped gracefully"); 45 | await logger?.flushLogsAndExit(0); 46 | } catch (error) { 47 | logger?.error("Error stopping gateway", { error }); 48 | await logger?.flushLogsAndExit(1); 49 | } 50 | }); 51 | 52 | // Create proxy instance with logging hooks 53 | await gateway.start(workspaceProviders); 54 | logger?.info( 55 | "Gateway started successfully with loaded server configurations" 56 | ); 57 | } catch (error) { 58 | logger?.error("Error starting gateway"); 59 | logger?.error(error); 60 | await logger?.flushLogs(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/cli/run/run.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { runGatewayAction } from "./actions/runGateway"; 3 | 4 | export function runCommand(program: Command) { 5 | program 6 | .command("run") 7 | .description("Run the gateway with a given workspace") 8 | .argument("", "name of the workspace to run") 9 | .action((name) => { 10 | runGatewayAction(name); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/cli/server/actions/add.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { addMcpProviders } from "../../../store/provider"; 3 | import { McpProvider, UrlSchema } from "../../../store/schema"; 4 | import prompts from "prompts"; 5 | import { 6 | parseProviderParameters, 7 | returnAndExit, 8 | scanProviderAndConfirm, 9 | } from "../../common/utils"; 10 | 11 | export async function addProviderAction() { 12 | const providerType = await prompts({ 13 | type: "select", 14 | name: "value", 15 | message: "Select provider type:", 16 | choices: [ 17 | { title: "Local Command (stdio)", value: "stdio" }, 18 | { title: "Remote Server (SSE)", value: "sse" }, 19 | ], 20 | }); 21 | 22 | if (!providerType.value) { 23 | console.log("Operation cancelled"); 24 | return; 25 | } 26 | 27 | const namePrompt = await prompts({ 28 | type: "text", 29 | name: "value", 30 | message: "Enter a name for the server (1-15 characters):", 31 | validate: (value) => { 32 | if (value.length === 0) { 33 | return "Name cannot be empty"; 34 | } else if (value.length > 15) { 35 | return "Name cannot be longer than 15 characters"; 36 | } else { 37 | return true; 38 | } 39 | }, 40 | }); 41 | 42 | if (!namePrompt.value) { 43 | console.log("Operation cancelled"); 44 | return; 45 | } 46 | 47 | const name = namePrompt.value; 48 | let mcpProvider: McpProvider | undefined; 49 | 50 | if (providerType.value === "sse") { 51 | const urlPrompt = await prompts({ 52 | type: "text", 53 | name: "value", 54 | message: "Enter server URL:", 55 | validate: (value) => { 56 | try { 57 | UrlSchema.parse(value); 58 | return true; 59 | } catch (error) { 60 | return `Please enter a valid URL e.g. https://example.com/`; 61 | } 62 | }, 63 | }); 64 | 65 | if (!urlPrompt.value) { 66 | console.log("Operation cancelled"); 67 | return; 68 | } 69 | 70 | mcpProvider = { 71 | type: "sse", 72 | namespace: name, 73 | providerParameters: { 74 | url: urlPrompt.value, 75 | }, 76 | }; 77 | } 78 | 79 | if (providerType.value === "stdio") { 80 | const commandPrompt = await prompts({ 81 | type: "text", 82 | name: "value", 83 | message: "Enter command:", 84 | validate: (value) => value.length > 0 || "Command cannot be empty", 85 | }); 86 | 87 | if (!commandPrompt.value) { 88 | console.log("Operation cancelled"); 89 | return; 90 | } 91 | 92 | const addEnvVars = await prompts({ 93 | type: "confirm", 94 | name: "value", 95 | message: "Add environment variables?", 96 | initial: false, 97 | }); 98 | 99 | let envVars: Record = {}; 100 | 101 | if (addEnvVars.value) { 102 | let addingEnv = true; 103 | while (addingEnv) { 104 | const envKey = await prompts({ 105 | type: "text", 106 | name: "value", 107 | message: "Enter environment variable name:", 108 | validate: (value) => value.length > 0 || "Name cannot be empty", 109 | }); 110 | 111 | if (!envKey.value) { 112 | addingEnv = false; 113 | continue; 114 | } 115 | 116 | const envValue = await prompts({ 117 | type: "text", 118 | name: "value", 119 | message: `Enter value for ${envKey.value}:`, 120 | }); 121 | 122 | if (envValue.value) { 123 | envVars[envKey.value] = envValue.value; 124 | } 125 | 126 | const continueAdding = await prompts({ 127 | type: "confirm", 128 | name: "value", 129 | message: "Add another environment variable?", 130 | initial: false, 131 | }); 132 | 133 | if (!continueAdding.value) { 134 | addingEnv = false; 135 | } 136 | } 137 | } 138 | 139 | mcpProvider = parseProviderParameters(name, { 140 | command: commandPrompt.value, 141 | env: Object.entries(envVars).map(([k, v]) => `${k}=${v}`), 142 | }); 143 | } 144 | 145 | // scan provider 146 | if (mcpProvider) { 147 | const confirmed = await scanProviderAndConfirm(mcpProvider); 148 | if (!confirmed) { 149 | returnAndExit(1); 150 | } 151 | addMcpProviders([mcpProvider]); 152 | console.log( 153 | chalk.green( 154 | `✔ ${mcpProvider.type.toUpperCase()} server "${name}" added successfully` 155 | ) 156 | ); 157 | returnAndExit(0); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/cli/server/actions/import.ts: -------------------------------------------------------------------------------- 1 | import prompts from "prompts"; 2 | import { existsSync } from "fs"; 3 | import { EXAMPLE_SERVERS_CONFIG_PATH } from "../../../config"; 4 | import chalk from "chalk"; 5 | import { returnAndExit } from "../../common/utils"; 6 | import { loadProviderConfigFile } from "../../../store/loader"; 7 | import { scanProviderAndConfirm } from "../../common/utils"; 8 | import { addMcpProviders } from "../../../store/provider"; 9 | 10 | export async function importProvidersAction(config: string) { 11 | let configPath = config; 12 | if (!configPath) { 13 | if (existsSync(EXAMPLE_SERVERS_CONFIG_PATH)) { 14 | // if example config file exists, ask user to use it 15 | const response = await prompts({ 16 | type: "confirm", 17 | name: "value", 18 | message: 19 | "No config file provided. Do you want to import the default servers?", 20 | initial: true, 21 | }); 22 | if (response.value) { 23 | configPath = EXAMPLE_SERVERS_CONFIG_PATH; 24 | } 25 | } 26 | } 27 | // if no config file provided, or example config file is not used, exit 28 | if (!configPath) { 29 | console.error(chalk.red("No config file provided")); 30 | returnAndExit(1); 31 | } 32 | 33 | const providers = loadProviderConfigFile(configPath); 34 | if (providers?.length === 0) { 35 | console.error( 36 | chalk.red( 37 | "Failed to load provider configuration. The file is not valid or does not exist" 38 | ) 39 | ); 40 | } 41 | for (const provider of providers) { 42 | const confirmed = await scanProviderAndConfirm(provider); 43 | if (!confirmed) { 44 | returnAndExit(1); 45 | } 46 | } 47 | addMcpProviders(providers); 48 | console.log(chalk.green("✔ Server configuration imported successfully")); 49 | returnAndExit(0); 50 | } 51 | -------------------------------------------------------------------------------- /src/cli/server/actions/list.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import prompts, { PromptType } from "prompts"; 3 | import treeify from "treeify"; 4 | import { getMcpProviders } from "../../../store/provider"; 5 | import { 6 | buildProviderOptions, 7 | buildProviderTree, 8 | printScanResult, 9 | returnAndExit, 10 | } from "../../common/utils"; 11 | 12 | import { 13 | getScanFailures, 14 | isScanSuccessful, 15 | scanProvider, 16 | } from "../../../providerScanner"; 17 | import { PROVIDERS_CONFIG_PATH } from "../../../config"; 18 | 19 | export async function listProvidersAction() { 20 | const providersMap = getMcpProviders(); 21 | const providers = Object.values(providersMap); 22 | if (providers.length === 0) { 23 | console.log(chalk.yellow("No MCP servers configured")); 24 | return; 25 | } 26 | 27 | console.log(chalk.bold("\nConfigured MCP Servers:")); 28 | console.log(chalk.dim("----------------------")); 29 | if (PROVIDERS_CONFIG_PATH) { 30 | console.log(chalk.dim("Servers are configured in the config file:")); 31 | console.log(chalk.dim(PROVIDERS_CONFIG_PATH)); 32 | } 33 | 34 | // Create provider selection options 35 | const description = "Select to list the server's capabilities"; 36 | const providerOptions = buildProviderOptions(providers, description); 37 | 38 | // Handle CTRL+C gracefully 39 | const onCancel = () => { 40 | console.log(chalk.yellow("\nServer viewing cancelled.")); 41 | returnAndExit(0); 42 | }; 43 | 44 | // Provider selection prompt 45 | const response = await prompts( 46 | { 47 | type: "select", 48 | name: "selectedProvider", 49 | message: "Select a server to see their details:", 50 | choices: providerOptions, 51 | hint: "Use arrow keys to navigate, Enter to select, Esc or Ctrl+C to exit", 52 | }, 53 | { onCancel } 54 | ); 55 | 56 | if (response.selectedProvider) { 57 | const mcpProvider = response.selectedProvider; 58 | // confirm the scan 59 | const providerDetails = buildProviderTree(mcpProvider); 60 | console.log(chalk.bold("\nProvider Details:")); 61 | console.log(chalk.dim("----------------------")); 62 | console.log(`${treeify.asTree(providerDetails, true, true)}`); 63 | 64 | const { scanConfirmed } = await prompts({ 65 | type: "confirm" as PromptType, 66 | name: "scanConfirmed", 67 | message: `Do you want to scan the capabilities of "${mcpProvider.namespace}"?`, 68 | initial: false, 69 | }); 70 | 71 | if (scanConfirmed) { 72 | const scanResult = await scanProvider(mcpProvider); 73 | printScanResult(scanResult); 74 | if (!isScanSuccessful(scanResult)) { 75 | console.log( 76 | chalk.red(`✘ Mcp server "${mcpProvider.namespace}" scan failed`) 77 | ); 78 | console.log(chalk.red(getScanFailures(scanResult).join("\n"))); 79 | returnAndExit(1); 80 | } 81 | console.log( 82 | chalk.green( 83 | `✔ Mcp server "${mcpProvider.namespace}" scan was sucessfull` 84 | ) 85 | ); 86 | } 87 | returnAndExit(0); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/cli/server/actions/remove.ts: -------------------------------------------------------------------------------- 1 | import { 2 | removeMcpProvider, 3 | getMcpProviders, 4 | removeAllProviders, 5 | } from "../../../store/provider"; 6 | import prompts, { type PromptType } from "prompts"; 7 | import chalk from "chalk"; 8 | import { buildProviderOptions, returnAndExit } from "../../common/utils"; 9 | 10 | function removeConfirmationPrompt(name: string) { 11 | return { 12 | type: "confirm" as PromptType, 13 | name: "removeConfirmed", 14 | message: `Are you sure you want to remove "${name}"?`, 15 | initial: false, 16 | }; 17 | } 18 | 19 | export async function removeProvidersAction(name?: string) { 20 | const providers = getMcpProviders(); 21 | 22 | if (name) { 23 | const provider = providers[name]; 24 | // Check if provider exists 25 | if (!provider) { 26 | console.error(`Provider "${name}" not found`); 27 | return; 28 | } 29 | // Confirm removal 30 | const { removeConfirmed } = await prompts(removeConfirmationPrompt(name)); 31 | if (!removeConfirmed) { 32 | console.log("Operation cancelled"); 33 | return; 34 | } 35 | // Remove provider 36 | removeMcpProvider(name); 37 | console.log(chalk.green(`✔ Server "${name}" removed successfully`)); 38 | returnAndExit(0); 39 | } 40 | 41 | // Create provider selection options 42 | const description = "Select a server to remove"; 43 | const providerOptions = buildProviderOptions( 44 | Object.values(providers), 45 | description 46 | ); 47 | const allProvidersOption = { 48 | title: "All servers", 49 | value: "all", 50 | }; 51 | // Handle CTRL+C gracefully 52 | const onCancel = () => { 53 | console.log(chalk.yellow("\nServer removal cancelled.")); 54 | returnAndExit(0); 55 | }; 56 | 57 | // Provider selection prompt 58 | const { selectedProvider } = await prompts( 59 | { 60 | type: "select", 61 | name: "selectedProvider", 62 | message: "Select a server to remove:", 63 | choices: [allProvidersOption, ...providerOptions], 64 | hint: "Use arrow keys to navigate, Enter to select, Esc or Ctrl+C to exit", 65 | }, 66 | { onCancel } 67 | ); 68 | 69 | if (selectedProvider === "all") { 70 | // Confirm removal all 71 | const { removeConfirmed } = await prompts( 72 | removeConfirmationPrompt("all servers.") 73 | ); 74 | if (!removeConfirmed) { 75 | console.log("server removal cancelled"); 76 | return; 77 | } 78 | 79 | removeAllProviders(); 80 | 81 | console.log(chalk.green("✔ All servers removed successfully")); 82 | returnAndExit(0); 83 | } else { 84 | // Confirm removal of the selected provider 85 | const { removeConfirmed } = await prompts( 86 | removeConfirmationPrompt(selectedProvider.namespace) 87 | ); 88 | if (!removeConfirmed) { 89 | console.log("Operation cancelled"); 90 | return; 91 | } 92 | 93 | // Remove the selected provider 94 | removeMcpProvider(selectedProvider.namespace); 95 | console.log( 96 | chalk.green( 97 | `✔ Server "${selectedProvider.namespace}" removed successfully` 98 | ) 99 | ); 100 | returnAndExit(0); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/cli/server/actions/scan.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { printScanResult } from "../../common/utils"; 3 | import { 4 | getScanFailures, 5 | scanProvider, 6 | isScanSuccessful, 7 | } from "../../../providerScanner"; 8 | import { getMcpProviders } from "../../../store/provider"; 9 | import { selectServersPrompt } from "../../common/prompts"; 10 | import { returnAndExit } from "../../common/utils"; 11 | 12 | export async function scanProvidersAction() { 13 | const providers = Object.values(getMcpProviders()); 14 | if (providers.length === 0) { 15 | console.log(chalk.yellow("No MCP providers configured")); 16 | return; 17 | } 18 | 19 | console.log(chalk.bold("\nConfigured MCP Providers:")); 20 | console.log(chalk.dim("----------------------")); 21 | 22 | // Handle CTRL+C gracefully 23 | const onCancel = () => { 24 | console.log(chalk.yellow("\nServer scanning cancelled.")); 25 | returnAndExit(0); 26 | }; 27 | 28 | // Show initial list of providers 29 | const selectedProviderNames = await selectServersPrompt(providers, onCancel); 30 | console.log(selectedProviderNames); 31 | // Show which servers were selected 32 | const selectedProviders = providers.filter((s) => 33 | selectedProviderNames.includes(s.namespace) 34 | ); 35 | const scanReport: { failure: string[]; sucess: string[] } = { 36 | failure: [], 37 | sucess: [], 38 | }; 39 | for (const mcpProvider of selectedProviders) { 40 | const scanResult = await scanProvider(mcpProvider); 41 | printScanResult(scanResult); 42 | if (!isScanSuccessful(scanResult)) { 43 | console.log( 44 | chalk.red(`✘ Mcp server "${mcpProvider.namespace}" scan failed`) 45 | ); 46 | console.log(chalk.red(getScanFailures(scanResult).join("\n"))); 47 | scanReport.failure.push(mcpProvider.namespace); 48 | } else { 49 | console.log( 50 | chalk.green( 51 | `✔ Mcp server "${mcpProvider.namespace}" scan was sucessfull` 52 | ) 53 | ); 54 | scanReport.sucess.push(mcpProvider.namespace); 55 | } 56 | } 57 | console.log(chalk.green(`✔ scan completed.`)); 58 | console.log(`${chalk.green(scanReport.sucess.length)} scan succeeded:`); 59 | console.log(chalk.green(` - ${scanReport.sucess.join(", ")}`)); 60 | console.log(`${chalk.red(scanReport.failure.length)} scan failed":`); 61 | console.log(chalk.red(` - ${scanReport.failure.join(", ")}`)); 62 | returnAndExit(0); 63 | } 64 | -------------------------------------------------------------------------------- /src/cli/server/server.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { addProviderAction } from "./actions/add"; 3 | import { listProvidersAction } from "./actions/list"; 4 | import { removeProvidersAction } from "./actions/remove"; 5 | import { importProvidersAction } from "./actions/import"; 6 | import { scanProvidersAction } from "./actions/scan"; 7 | export function serverCommands(program: Command) { 8 | const server = program.command("server").description("Manage MCP providers"); 9 | 10 | server 11 | .command("add") 12 | .description("Add a new MCP server (local or remote)") 13 | .action(async () => { 14 | console.clear(); 15 | addProviderAction(); 16 | }); 17 | 18 | server 19 | .command("list") 20 | .description("List all MCP servers") 21 | .action(async () => { 22 | console.clear(); 23 | listProvidersAction(); 24 | }); 25 | 26 | server 27 | .command("remove") 28 | .description("Remove a server") 29 | .argument("[name]", "name of the server to remove") 30 | .action(async (name) => { 31 | console.clear(); 32 | removeProvidersAction(name); 33 | }); 34 | 35 | server 36 | .command("import") 37 | .description("Import server configuration from a file") 38 | .argument("[config]", "path to the config file") 39 | .action(async (config) => { 40 | console.clear(); 41 | importProvidersAction(config); 42 | }); 43 | 44 | server 45 | .command("scan") 46 | .description("Scan the server's capabilities") 47 | .action(async () => { 48 | console.clear(); 49 | scanProvidersAction(); 50 | }); 51 | 52 | return server; 53 | } 54 | -------------------------------------------------------------------------------- /src/cli/types.ts: -------------------------------------------------------------------------------- 1 | export type TreeNode = { 2 | [key: string]: TreeNode; 3 | }; 4 | -------------------------------------------------------------------------------- /src/cli/workspace/actions/create.ts: -------------------------------------------------------------------------------- 1 | import prompts from "prompts"; 2 | import chalk from "chalk"; 3 | import ora from "ora"; 4 | import boxen from "boxen"; 5 | 6 | import { addWorkspace } from "../../../store/workspace"; 7 | import { loadProvidersMap } from "../../../store/loader"; 8 | import { selectServersPrompt } from "../../common/prompts"; 9 | export async function createWorkspaceAction() { 10 | const providers = Object.values(loadProvidersMap()); 11 | 12 | console.log( 13 | boxen( 14 | chalk.bold.cyan( 15 | providers.length > 0 16 | ? "YAMCP Workspace Creation Wizard" 17 | : "No servers found. \nUse `server add|import` to add some servers first." 18 | ), 19 | { 20 | padding: 1, 21 | margin: 1, 22 | borderStyle: "round", 23 | borderColor: "cyan", 24 | } 25 | ) 26 | ); 27 | 28 | if (providers.length === 0) { 29 | return; 30 | } 31 | 32 | // Handle CTRL+C gracefully 33 | const onCancel = () => { 34 | console.log(chalk.yellow("\nWorkspace creation cancelled.")); 35 | process.exit(0); 36 | }; 37 | 38 | // Get workspace name 39 | const nameResponse = await prompts( 40 | { 41 | type: "text", 42 | name: "name", 43 | message: "Workspace name:", 44 | validate: (value) => { 45 | const trimmed = value.trim(); 46 | if (!trimmed) return "Workspace name is required"; 47 | if (trimmed.length < 3) return "Name must be at least 3 characters"; 48 | return true; 49 | }, 50 | }, 51 | { onCancel } 52 | ); 53 | 54 | console.clear(); 55 | const finalSelection = await selectServersPrompt(providers, onCancel); 56 | 57 | // Confirmation prompt 58 | console.clear(); 59 | const confirmResponse = await prompts( 60 | { 61 | type: "confirm", 62 | name: "confirm", 63 | message: `Create workspace "${nameResponse.name}" with ${finalSelection.length} servers?`, 64 | initial: true, 65 | }, 66 | { onCancel } 67 | ); 68 | 69 | if (!confirmResponse.confirm) { 70 | console.log(chalk.yellow("\nWorkspace creation cancelled.\n")); 71 | return; 72 | } 73 | 74 | // Show which servers were selected 75 | const selectedServerObjects = providers.filter((s) => 76 | finalSelection.includes(s.namespace) 77 | ); 78 | 79 | // Show progress spinner 80 | const spinner = ora("Creating workspace...").start(); 81 | 82 | const selectedServerNames = selectedServerObjects.map((s) => s.namespace); 83 | 84 | addWorkspace(nameResponse.name, selectedServerNames); 85 | 86 | spinner.succeed( 87 | chalk.green(`Workspace "${nameResponse.name}" created successfully!`) 88 | ); 89 | 90 | console.log( 91 | boxen( 92 | chalk.bold(`Workspace: ${chalk.green(nameResponse.name)}\n\n`) + 93 | chalk.bold(`Servers (${selectedServerObjects.length}):\n`) + 94 | selectedServerObjects 95 | .map( 96 | (s) => 97 | ` ${chalk.green("✓")} ${s.namespace}) - ${chalk.cyan(s.type)}` 98 | ) 99 | .join("\n"), 100 | { padding: 1, borderColor: "green", borderStyle: "round" } 101 | ) 102 | ); 103 | 104 | console.log( 105 | chalk.dim( 106 | '\nTip: Use "mcp run ' + 107 | nameResponse.name + 108 | '" to start the gateway for this workspace.\n' 109 | ) 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/cli/workspace/actions/delete.ts: -------------------------------------------------------------------------------- 1 | import prompts, { type PromptType } from "prompts"; 2 | import { getWorkspaces, removeWorkspace } from "../../../store/workspace"; 3 | import { displayWorkspacesChoice } from "../../common/utils"; 4 | import { returnAndExit } from "../../common/utils"; 5 | import chalk from "chalk"; 6 | 7 | function deleteConfirmationPrompt(name: string) { 8 | return { 9 | type: "confirm" as PromptType, 10 | name: "removeConfirmed", 11 | message: `Are you sure you want to remove "${name}"?`, 12 | initial: false, 13 | }; 14 | } 15 | 16 | async function deleteWorkspaceWithConfirmation(name: string) { 17 | const response = await prompts(deleteConfirmationPrompt(name)); 18 | 19 | if (!response.removeConfirmed) { 20 | console.log("Operation cancelled"); 21 | return; 22 | } 23 | 24 | removeWorkspace(name); 25 | console.log(chalk.green(`✔ Workspace "${name}" deleted successfully`)); 26 | } 27 | 28 | export async function deleteWorkspaceAction(name: string) { 29 | const workspaces = getWorkspaces(); 30 | if (name) { 31 | if (!workspaces[name]) { 32 | console.error(`Workspace "${name}" not found`); 33 | return; 34 | } 35 | await deleteWorkspaceWithConfirmation(name); 36 | returnAndExit(0); 37 | } 38 | const selectedWorkspace = await displayWorkspacesChoice( 39 | workspaces, 40 | "Select a workspace to delete (use arrow keys)" 41 | ); 42 | if (!selectedWorkspace) { 43 | returnAndExit(0); 44 | } 45 | await deleteWorkspaceWithConfirmation(selectedWorkspace); 46 | returnAndExit(0); 47 | } 48 | -------------------------------------------------------------------------------- /src/cli/workspace/actions/edit.ts: -------------------------------------------------------------------------------- 1 | import prompts from "prompts"; 2 | import chalk from "chalk"; 3 | import ora from "ora"; 4 | import boxen from "boxen"; 5 | 6 | import { McpProvider } from "../../../store/schema"; 7 | import { addWorkspace, getWorkspaces } from "../../../store/workspace"; 8 | import { loadProvidersMap } from "../../../store/loader"; 9 | 10 | export async function editWorkspacesAction() { 11 | const workspaces = getWorkspaces(); 12 | const availableProviders = Object.values(loadProvidersMap()); 13 | // Welcome message 14 | console.log( 15 | boxen(chalk.bold.cyan("Edit Workspace"), { 16 | padding: 1, 17 | margin: 1, 18 | borderStyle: "round", 19 | borderColor: "cyan", 20 | }) 21 | ); 22 | 23 | // Handle CTRL+C gracefully 24 | const onCancel = () => { 25 | console.log(chalk.yellow("\nWorkspace editing cancelled.")); 26 | process.exit(0); 27 | }; 28 | 29 | const workspaceCount = Object.keys(workspaces).length; 30 | if (workspaceCount === 0) { 31 | console.log( 32 | boxen(chalk.yellow("No workspaces found to edit"), { 33 | padding: 1, 34 | margin: 1, 35 | borderStyle: "round", 36 | borderColor: "yellow", 37 | }) 38 | ); 39 | return; 40 | } 41 | 42 | // Create workspace selection options 43 | const workspaceOptions = Object.entries(workspaces).map( 44 | ([name, providers]) => ({ 45 | title: name, 46 | value: name, 47 | description: `${providers.length} server${ 48 | providers.length === 1 ? "" : "s" 49 | }`, 50 | }) 51 | ); 52 | 53 | // Select workspace to edit 54 | const workspaceResponse = await prompts( 55 | { 56 | type: "select", 57 | name: "workspace", 58 | message: "Select workspace to edit:", 59 | choices: workspaceOptions, 60 | hint: "- Use arrow-keys. Return to select", 61 | }, 62 | { onCancel } 63 | ); 64 | 65 | const selectedWorkspace = workspaceResponse.workspace; 66 | if (!selectedWorkspace) { 67 | console.log( 68 | chalk.yellow("\nNo workspace selected. Operation cancelled.\n") 69 | ); 70 | return; 71 | } 72 | 73 | // Call the existing editWorkspace function with the selected workspace 74 | await editWorkspace( 75 | selectedWorkspace, 76 | workspaces[selectedWorkspace], 77 | availableProviders 78 | ); 79 | } 80 | 81 | async function editWorkspace( 82 | workspaceName: string, 83 | currentProviders: string[], 84 | availableProviders: McpProvider[] 85 | ) { 86 | // Welcome message 87 | console.log( 88 | boxen(chalk.bold.cyan(`Edit Workspace: ${workspaceName}`), { 89 | padding: 1, 90 | margin: 1, 91 | borderStyle: "round", 92 | borderColor: "cyan", 93 | }) 94 | ); 95 | 96 | // Handle CTRL+C gracefully 97 | const onCancel = () => { 98 | console.log(chalk.yellow("\nWorkspace editing cancelled.")); 99 | process.exit(0); 100 | }; 101 | 102 | // Group providers by type for better organization 103 | const providersByType = availableProviders.reduce((acc, provider) => { 104 | const key = provider.type.toUpperCase(); 105 | if (!acc[key]) acc[key] = []; 106 | acc[key].push(provider); 107 | return acc; 108 | }, {} as Record); 109 | 110 | // Create options for the multiselect prompt 111 | const serverOptions = Object.entries(providersByType).flatMap( 112 | ([type, providers]) => [ 113 | { 114 | title: chalk.yellow(`---- ${type} MCP Servers ----`), 115 | value: `group_${type}`, 116 | group: type, 117 | description: `Select/deselect all ${type} servers`, 118 | }, 119 | ...providers.map((provider) => ({ 120 | title: `${provider.namespace}`, 121 | value: provider.namespace, 122 | description: `Select/deselect ${provider.namespace}`, 123 | group: type, 124 | selected: currentProviders.includes(provider.namespace), 125 | })), 126 | ] 127 | ); 128 | 129 | // Show current configuration 130 | console.log( 131 | chalk.dim("\nCurrent configuration:") + 132 | `\n${currentProviders 133 | .map((p) => ` ${chalk.green("•")} ${p}`) 134 | .join("\n")}\n` 135 | ); 136 | 137 | // Select servers 138 | const serversResponse = await prompts( 139 | { 140 | type: "multiselect", 141 | name: "selectedServers", 142 | message: "Update server selection:", 143 | choices: serverOptions, 144 | min: 1, 145 | instructions: false, 146 | hint: "- Space to select. Return to submit", 147 | }, 148 | { onCancel } 149 | ); 150 | 151 | // check if all groups are selected and expand selections 152 | const selections = serversResponse.selectedServers as string[]; 153 | const expandedSelections = selections.reduce((acc, selection) => { 154 | if (selection.startsWith("group_")) { 155 | const type = selection.replace("group_", ""); 156 | const providers = providersByType[type]?.map((p) => p.namespace) || []; 157 | return [...acc, ...providers]; 158 | } 159 | return [...acc, selection]; 160 | }, [] as string[]); 161 | 162 | // deduplicate selections 163 | const finalSelection = [...new Set(expandedSelections)]; 164 | 165 | // Show changes summary 166 | const added = finalSelection.filter((p) => !currentProviders.includes(p)); 167 | const removed = currentProviders.filter((p) => !finalSelection.includes(p)); 168 | 169 | console.log("\nChanges summary:"); 170 | if (added.length > 0) { 171 | console.log(chalk.green("\nAdded:")); 172 | added.forEach((p) => console.log(` ${chalk.green("+")} ${p}`)); 173 | } 174 | if (removed.length > 0) { 175 | console.log(chalk.red("\nRemoved:")); 176 | removed.forEach((p) => console.log(` ${chalk.red("-")} ${p}`)); 177 | } 178 | if (added.length === 0 && removed.length === 0) { 179 | console.log(chalk.dim(" No changes")); 180 | } 181 | 182 | // Confirmation prompt 183 | const confirmResponse = await prompts( 184 | { 185 | type: "confirm", 186 | name: "confirm", 187 | message: `Save changes to workspace "${workspaceName}"?`, 188 | initial: true, 189 | }, 190 | { onCancel } 191 | ); 192 | 193 | if (!confirmResponse.confirm) { 194 | console.log(chalk.yellow("\nWorkspace editing cancelled.\n")); 195 | return; 196 | } 197 | 198 | // Show progress spinner 199 | const spinner = ora("Updating workspace...").start(); 200 | 201 | // Save the changes 202 | addWorkspace(workspaceName, finalSelection); 203 | 204 | spinner.succeed( 205 | chalk.green(`Workspace "${workspaceName}" updated successfully!`) 206 | ); 207 | 208 | // Show final configuration 209 | console.log( 210 | boxen( 211 | chalk.bold(`Workspace: ${chalk.green(workspaceName)}\n\n`) + 212 | chalk.bold(`Servers (${finalSelection.length}):\n`) + 213 | finalSelection 214 | .map((provider) => ` ${chalk.green("•")} ${provider}`) 215 | .join("\n"), 216 | { 217 | padding: 1, 218 | margin: 1, 219 | borderStyle: "round", 220 | borderColor: "green", 221 | } 222 | ) 223 | ); 224 | } 225 | -------------------------------------------------------------------------------- /src/cli/workspace/actions/list.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import boxen from "boxen"; 3 | import prompts from "prompts"; 4 | import treeify from "treeify"; 5 | import { 6 | buildProviderTree, 7 | displayWorkspacesChoice, 8 | getWorkspaceProviders, 9 | returnAndExit, 10 | } from "../../common/utils"; 11 | import { McpProvider } from "../../../store/schema"; 12 | import { WORKSPACES_CONFIG_PATH } from "../../../config"; 13 | import { loadProvidersMap } from "../../../store/loader"; 14 | import { getWorkspaces } from "../../../store/workspace"; 15 | export async function listWorkspaceAction(name?: string) { 16 | const workspaces = getWorkspaces(); 17 | const availableProviders = loadProvidersMap(); 18 | 19 | const workspaceCount = Object.keys(workspaces).length; 20 | 21 | if (workspaceCount === 0) { 22 | console.log( 23 | boxen( 24 | chalk.yellow( 25 | "No workspaces found\nUse 'yam create' to create a workspace" 26 | ), 27 | { 28 | padding: 1, 29 | margin: 1, 30 | borderStyle: "round", 31 | borderColor: "yellow", 32 | } 33 | ) 34 | ); 35 | return; 36 | } 37 | 38 | // If a specific workspace is requested, show it directly 39 | if (name) { 40 | displayWorkspace(name, workspaces[name]); 41 | return; 42 | } 43 | 44 | while (true) { 45 | // Clear console for better visibility 46 | console.clear(); 47 | // Show config path if it exists 48 | if (WORKSPACES_CONFIG_PATH) { 49 | console.log(chalk.dim("Workspaces are configured in the config file:")); 50 | console.log(chalk.dim(WORKSPACES_CONFIG_PATH)); 51 | } 52 | // displace list of workspaces to let user selec 53 | const selectedWorkspace = await displayWorkspacesChoice(workspaces); 54 | if (!selectedWorkspace) { 55 | returnAndExit(0); 56 | } 57 | // Display selected workspace 58 | console.clear(); 59 | 60 | const wsProviders = getWorkspaceProviders( 61 | availableProviders, 62 | workspaces[selectedWorkspace] 63 | ); 64 | await displayWorkspaceInteractive(selectedWorkspace, wsProviders); 65 | } 66 | } 67 | 68 | async function displayWorkspaceInteractive( 69 | name: string, 70 | providers: McpProvider[] 71 | ) { 72 | while (true) { 73 | // Create provider selection 74 | const choices = [ 75 | ...providers.map((provider) => { 76 | const tree = buildProviderTree(provider); 77 | return { 78 | title: provider.namespace, 79 | value: provider.namespace, 80 | description: treeify.asTree(tree, true, true), 81 | }; 82 | }), 83 | { 84 | title: "Back", 85 | value: "back", 86 | description: "Return to workspace list", 87 | }, 88 | ]; 89 | 90 | // No matter what, we break out of the loop to return to the workspace list 91 | await prompts({ 92 | type: "select", 93 | name: "server", 94 | message: `See mcp server details in workspace "${name}" (use arrow keys)`, 95 | choices, 96 | }); 97 | 98 | // No matter what, we break out of the loop to return to the workspace list 99 | break; 100 | } 101 | } 102 | 103 | function displayWorkspace(name: string, providers: string[] | undefined) { 104 | if (!providers) { 105 | console.log( 106 | boxen(chalk.red(`Workspace "${name}" not found`), { 107 | padding: 1, 108 | margin: 1, 109 | borderStyle: "round", 110 | borderColor: "red", 111 | }) 112 | ); 113 | return; 114 | } 115 | 116 | // ToDo: Show as tree 117 | console.log( 118 | chalk.bold(`Workspace: ${chalk.green(name)}\n\n`) + 119 | chalk.bold(`Servers (${providers.length}):\n`) + 120 | providers 121 | .map((provider) => ` ${chalk.green("•")} ${provider}`) 122 | .join("\n"), 123 | { 124 | padding: 1, 125 | margin: 1, 126 | borderStyle: "round", 127 | borderColor: "green", 128 | } 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/cli/workspace/actions/scan.ts: -------------------------------------------------------------------------------- 1 | import { 2 | displayWorkspacesChoice, 3 | getWorkspaceProviders, 4 | printScanResult, 5 | returnAndExit, 6 | } from "../../common/utils"; 7 | import { 8 | scanProvider, 9 | getScanFailures, 10 | isScanSuccessful, 11 | } from "../../../providerScanner"; 12 | import chalk from "chalk"; 13 | import { loadProvidersMap } from "../../../store/loader"; 14 | import { getWorkspaces } from "../../../store/workspace"; 15 | 16 | export async function scanWorkspacesAction() { 17 | const workspaces = getWorkspaces(); 18 | const availableProviders = loadProvidersMap(); 19 | const selectedWorkspace = await displayWorkspacesChoice(workspaces); 20 | if (!selectedWorkspace) { 21 | return; 22 | } 23 | // Display selected workspace 24 | console.clear(); 25 | 26 | const wsProviders = getWorkspaceProviders( 27 | availableProviders, 28 | workspaces[selectedWorkspace] 29 | ); 30 | 31 | const scanReport: { failure: string[]; sucess: string[] } = { 32 | failure: [], 33 | sucess: [], 34 | }; 35 | for (const provider of wsProviders) { 36 | // scan provider 37 | const scanResult = await scanProvider(provider); 38 | if (!isScanSuccessful(scanResult)) { 39 | console.log( 40 | chalk.red(`✘ Mcp server "${provider.namespace}" scan failed`) 41 | ); 42 | console.log(chalk.red(getScanFailures(scanResult).join("\n"))); 43 | scanReport.failure.push(provider.namespace); 44 | } else { 45 | console.log( 46 | chalk.green(`✔ Server "${provider.namespace}" scanned successfully`) 47 | ); 48 | scanReport.sucess.push(provider.namespace); 49 | } 50 | printScanResult(scanResult); 51 | } 52 | console.log( 53 | chalk.green(`✔ Workspace "${selectedWorkspace}" scan completed.`) 54 | ); 55 | console.log(`${chalk.green(scanReport.sucess.length)} scan succeeded:`); 56 | console.log(chalk.green(` - ${scanReport.sucess.join(", ")}`)); 57 | console.log(`${chalk.red(scanReport.failure.length)} scan failed":`); 58 | console.log(chalk.red(` - ${scanReport.failure.join(", ")}`)); 59 | returnAndExit(0); 60 | } 61 | -------------------------------------------------------------------------------- /src/cli/workspace/workspace.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { createWorkspaceAction } from "./actions/create"; 3 | import { listWorkspaceAction } from "./actions/list"; 4 | import { editWorkspacesAction } from "./actions/edit"; 5 | import { scanWorkspacesAction } from "./actions/scan"; 6 | import { deleteWorkspaceAction } from "./actions/delete"; 7 | export function workspaceCommands(program: Command) { 8 | const workspace = program.command("yam").description("Manage yam workspaces"); 9 | 10 | workspace 11 | .command("create") 12 | .description("Create a new workspace") 13 | .action(async () => { 14 | console.clear(); 15 | createWorkspaceAction(); 16 | }); 17 | 18 | workspace 19 | .command("list") 20 | .option("-n, --name ", "name of the workspace to list") 21 | .description("List workspaces") 22 | .action(async (options) => { 23 | console.clear(); 24 | listWorkspaceAction(options.name); 25 | }); 26 | 27 | workspace 28 | .command("edit") 29 | .description("Edit a workspace") 30 | .action(async () => { 31 | console.clear(); 32 | editWorkspacesAction(); 33 | }); 34 | 35 | workspace 36 | .command("scan") 37 | .description("Scan workspaces") 38 | .action(async () => { 39 | console.clear(); 40 | scanWorkspacesAction(); 41 | }); 42 | 43 | workspace 44 | .command("delete") 45 | .description("Delete a workspace") 46 | .argument("[workspace-name]", "name of the workspace to delete") 47 | .action(async (name) => { 48 | console.clear(); 49 | deleteWorkspaceAction(name); 50 | }); 51 | 52 | return workspace; 53 | } 54 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import envPaths from "env-paths"; 3 | import packageJson from "../package.json"; 4 | 5 | const paths = envPaths("yamcp"); 6 | 7 | // version 8 | export const VERSION = packageJson.version; 9 | 10 | // Server 11 | export const SERVER_NAME = "yamcp_gateway"; 12 | export const SERVER_VERSION = VERSION; 13 | 14 | // Store 15 | const storeDir = paths.data; 16 | export const PROVIDERS_CONFIG_PATH = path.join(storeDir, `./providers.json`); 17 | export const WORKSPACES_CONFIG_PATH = path.join(storeDir, `./workspaces.json`); 18 | 19 | export const EXAMPLE_SERVERS_CONFIG_PATH = path.join( 20 | __dirname, 21 | `./example-servers.json` 22 | ); 23 | 24 | export const LOG_DIR = paths.log; 25 | -------------------------------------------------------------------------------- /src/example-servers.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcp-server-fetch": { 3 | "command": "pipx", 4 | "args": ["run", "mcp-server-fetch"] 5 | }, 6 | "filesystem": { 7 | "command": "npx", 8 | "args": [ 9 | "-y", 10 | "@modelcontextprotocol/server-filesystem", 11 | "~/Desktop", 12 | "~/Documents" 13 | ] 14 | }, 15 | "server-memory": { 16 | "command": "npx", 17 | "args": ["-y", "@modelcontextprotocol/server-memory"] 18 | }, 19 | "sequential-thinking": { 20 | "command": "npx", 21 | "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ListToolsRequestSchema, 3 | CallToolRequestSchema, 4 | ListPromptsRequestSchema, 5 | GetPromptRequestSchema, 6 | ListToolsResult, 7 | CallToolResult, 8 | ListPromptsResult, 9 | GetPromptResult, 10 | } from "@modelcontextprotocol/sdk/types.js"; 11 | import type { McpProvider } from "./store/schema"; 12 | import { GatewayServer } from "./gatewayServer.js"; 13 | import { GatewayRouter } from "./gatewayRouter.js"; 14 | import { Logger } from "./utility/logger.js"; 15 | 16 | class McpGateway { 17 | private server: GatewayServer; 18 | private router: GatewayRouter; 19 | private isStarted = false; 20 | private _logger?: Logger; 21 | 22 | constructor(router: GatewayRouter, server: GatewayServer, logger?: Logger) { 23 | this.server = server; 24 | this.router = router; 25 | this._logger = logger; 26 | } 27 | 28 | private _toolHandlersInitialized = false; 29 | private setToolRequestHandler() { 30 | const mcpServer = this.server.mcpServer; 31 | 32 | if (this._toolHandlersInitialized) { 33 | return; 34 | } 35 | 36 | if (!mcpServer) { 37 | throw new Error("GatewayServer not started"); 38 | } 39 | 40 | // assert the handlers are not already set 41 | mcpServer.assertCanSetRequestHandler( 42 | ListToolsRequestSchema.shape.method.value 43 | ); 44 | mcpServer.assertCanSetRequestHandler( 45 | CallToolRequestSchema.shape.method.value 46 | ); 47 | 48 | mcpServer.registerCapabilities({ 49 | tools: { 50 | listChanged: true, 51 | }, 52 | }); 53 | 54 | // set list tools handler 55 | mcpServer.setRequestHandler( 56 | ListToolsRequestSchema, 57 | async (request): Promise => { 58 | this._logger?.logMessage({ 59 | level: "info", 60 | data: request, 61 | }); 62 | const tools = await this.router.listTools(); 63 | return { 64 | tools, 65 | }; 66 | } 67 | ); 68 | 69 | // set call tool handler 70 | mcpServer.setRequestHandler( 71 | CallToolRequestSchema, 72 | async (request): Promise => { 73 | this._logger?.logMessage({ 74 | level: "info", 75 | data: request, 76 | }); 77 | return await this.router.routeToolRequest(request); 78 | } 79 | ); 80 | } 81 | 82 | private _promptHandlersInitialized = false; 83 | private setPromptRequestHandler() { 84 | const mcpServer = this.server.mcpServer; 85 | 86 | if (this._promptHandlersInitialized) { 87 | return; 88 | } 89 | 90 | if (!mcpServer) { 91 | throw new Error("GatewayServer not started"); 92 | } 93 | 94 | // assert the handlers are not already set 95 | mcpServer.assertCanSetRequestHandler( 96 | ListPromptsRequestSchema.shape.method.value 97 | ); 98 | 99 | mcpServer.registerCapabilities({ 100 | prompts: { 101 | listChanged: true, 102 | }, 103 | }); 104 | 105 | // set list prompts handler 106 | mcpServer.setRequestHandler( 107 | ListPromptsRequestSchema, 108 | async (request): Promise => { 109 | this._logger?.logMessage({ 110 | level: "info", 111 | data: request, 112 | }); 113 | return { 114 | prompts: await this.router.listPrompts(), 115 | }; 116 | } 117 | ); 118 | 119 | // set get prompt handler 120 | mcpServer.setRequestHandler( 121 | GetPromptRequestSchema, 122 | async (request): Promise => { 123 | this._logger?.logMessage({ 124 | level: "info", 125 | data: request, 126 | }); 127 | return await this.router.routePromptRequest(request); 128 | } 129 | ); 130 | } 131 | 132 | // TODO: implement support for resources 133 | /* 134 | private _resourceHandlersInitialized = false; 135 | private setResourceRequestHandler() {} // TODO: implement support for resources 136 | */ 137 | 138 | async start(providersConfig: McpProvider[]) { 139 | // first set the handlers. This should be done before starting the server and router 140 | this.setToolRequestHandler(); 141 | this.setPromptRequestHandler(); 142 | this._logger?.logMessage({ 143 | level: "info", 144 | data: "MCP Gateway started...", 145 | }); 146 | 147 | // start the gateway server and router 148 | await this.router.start(providersConfig); 149 | await this.server.start(); 150 | 151 | this._logger?.logMessage({ 152 | level: "info", 153 | data: "Listening for client connections on stdio", 154 | }); 155 | 156 | this.isStarted = true; 157 | } 158 | 159 | async stop() { 160 | if (!this.isStarted) { 161 | return; 162 | } 163 | // send a logging message to the client 164 | this._logger?.logMessage({ 165 | level: "info", 166 | data: "Shutting down gateway...", 167 | }); 168 | 169 | // stop the gateway server and router 170 | await Promise.all([this.router.stop(), this.server.stop()]); 171 | this.isStarted = false; 172 | } 173 | } 174 | 175 | export { McpGateway }; 176 | -------------------------------------------------------------------------------- /src/gatewayRouter.ts: -------------------------------------------------------------------------------- 1 | import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { 3 | CallToolRequest, 4 | GetPromptRequest, 5 | CallToolResultSchema, 6 | GetPromptResultSchema, 7 | McpError, 8 | ErrorCode, 9 | } from "@modelcontextprotocol/sdk/types.js"; 10 | import { 11 | getProviderClientTransport, 12 | connectProviderClient, 13 | } from "./providerClient.js"; 14 | 15 | import { 16 | addNamespace, 17 | isNamespacedName, 18 | parseNamespace, 19 | type Namespace, 20 | } from "./utility/namespace"; 21 | 22 | import type { McpProvider } from "./store/schema"; 23 | import { Logger } from "./utility/logger.js"; 24 | 25 | class GatewayRouter { 26 | providers?: Map; 27 | private _logger?: Logger; 28 | 29 | constructor(logger?: Logger) { 30 | this._logger = logger; 31 | } 32 | 33 | // Connect to MCP providers 34 | async connect(providersConfig: McpProvider[]) { 35 | this.providers = new Map(); 36 | // iterate and connect MCP providers 37 | const providerPromises = []; 38 | for (const providerConfig of providersConfig) { 39 | const transport = getProviderClientTransport(providerConfig); 40 | const { namespace } = providerConfig; 41 | const providerPromise = connectProviderClient(transport) 42 | .then((provider) => ({ 43 | namespace, 44 | provider, 45 | error: undefined, 46 | })) 47 | .catch((err) => ({ 48 | namespace, 49 | provider: undefined, 50 | error: err, 51 | })); 52 | providerPromises.push(providerPromise); 53 | } 54 | const providers = await Promise.all(providerPromises); 55 | // filter out providers that failed to connect 56 | const connectedProviders = providers.filter((provider) => { 57 | if (provider.error) { 58 | this._logger?.error( 59 | `Failed to connect to provider ${provider.namespace}`, 60 | provider.error 61 | ); 62 | return false; 63 | } else { 64 | return true; 65 | } 66 | }); 67 | 68 | for (const { namespace, provider } of connectedProviders) { 69 | provider && this.providers.set(namespace, provider); 70 | } 71 | } 72 | 73 | // List & Index MCP tools 74 | async listTools() { 75 | if (!this.providers) { 76 | throw new Error("Providers not connected"); 77 | } 78 | const toolList = []; 79 | // Iterate over all providers and index their tools 80 | for (const [namespace, provider] of this.providers.entries()) { 81 | const capabilities = await provider.getServerCapabilities(); 82 | if (capabilities?.tools) { 83 | const { tools } = await provider.listTools(); 84 | 85 | // transform tool names to include namespace 86 | const namespaceTools = tools.map((tool) => ({ 87 | ...tool, 88 | // Serialize tool name to include namespace 89 | name: addNamespace(namespace, tool.name), 90 | })); 91 | 92 | toolList.push(...namespaceTools); 93 | } 94 | } 95 | return toolList; 96 | } 97 | 98 | // List & Index MCP prompts 99 | async listPrompts() { 100 | if (!this.providers) { 101 | throw new Error("Providers not connected"); 102 | } 103 | const promptList = []; 104 | // Iterate over all providers and index their prompts 105 | for (const [namespace, provider] of this.providers.entries()) { 106 | const capabilities = await provider.getServerCapabilities(); 107 | if (capabilities?.prompts) { 108 | const { prompts } = await provider.listPrompts(); 109 | const namespacePrompts = prompts.map((prompt) => ({ 110 | ...prompt, 111 | // Serialize prompt name to include namespace 112 | name: addNamespace(namespace, prompt.name), 113 | })); 114 | 115 | promptList.push(...namespacePrompts); 116 | } 117 | } 118 | return promptList; 119 | } 120 | 121 | async routeToolRequest(request: CallToolRequest) { 122 | const { name } = request.params; 123 | if (!isNamespacedName(name)) { 124 | throw new McpError( 125 | ErrorCode.InvalidParams, 126 | "Invalid tool name. Missing namespace." 127 | ); 128 | } 129 | const { namespace, name: toolName } = parseNamespace(name); 130 | // get the provider for the namespace 131 | const provider = this.providers?.get(namespace); 132 | if (!provider) { 133 | throw new McpError( 134 | ErrorCode.InvalidParams, 135 | `Provider ${namespace} not found` 136 | ); 137 | } 138 | 139 | // set the tool name to the tool name without the namespace before routing the request 140 | request.params.name = toolName; 141 | 142 | // route the request to the provider 143 | return await provider.request(request, CallToolResultSchema); 144 | } 145 | 146 | async routePromptRequest(request: GetPromptRequest) { 147 | const { name } = request.params; 148 | if (!isNamespacedName(name)) { 149 | throw new McpError( 150 | ErrorCode.InvalidParams, 151 | "Invalid prompt name. Missing namespace." 152 | ); 153 | } 154 | const { namespace, name: promptName } = parseNamespace(name); 155 | // get the provider for the namespace 156 | const provider = this.providers?.get(namespace); 157 | if (!provider) { 158 | throw new McpError( 159 | ErrorCode.InvalidParams, 160 | `Provider ${namespace} not found` 161 | ); 162 | } 163 | 164 | // set the name to the prompt name without the namespace before routing the request 165 | request.params.name = promptName; 166 | 167 | // route the request to the provider 168 | return await provider.request(request, GetPromptResultSchema); 169 | } 170 | 171 | async start(providersConfig: McpProvider[]) { 172 | await this.connect(providersConfig); 173 | } 174 | 175 | async stop() { 176 | if (this.providers) { 177 | // close all providers 178 | await Promise.all( 179 | Array.from(this.providers.values()).map((provider) => provider.close()) 180 | ); 181 | this.providers = undefined; 182 | } 183 | } 184 | } 185 | 186 | export { GatewayRouter }; 187 | -------------------------------------------------------------------------------- /src/gatewayServer.ts: -------------------------------------------------------------------------------- 1 | import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { SERVER_VERSION, SERVER_NAME } from "./config"; 4 | 5 | export class GatewayServer { 6 | mcpServer: McpServer; 7 | transport: StdioServerTransport; 8 | constructor() { 9 | this.mcpServer = new McpServer({ 10 | name: SERVER_NAME, 11 | version: SERVER_VERSION, 12 | }); 13 | this.transport = new StdioServerTransport(); 14 | } 15 | async start() { 16 | this.mcpServer.connect(this.transport); 17 | } 18 | async stop() { 19 | if (this.mcpServer) { 20 | await this.mcpServer.close(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/type.ts: -------------------------------------------------------------------------------- 1 | export interface GatewayHook { 2 | onMessage?: ( 3 | message: any, 4 | direction: "client-to-server" | "server-to-client" 5 | ) => void; 6 | onError?: (error: Error) => void; 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from "commander"; 4 | import { serverCommands } from "./cli/server/server"; 5 | import { workspaceCommands } from "./cli/workspace/workspace"; 6 | import { runCommand } from "./cli/run/run"; 7 | import { logCommand } from "./cli/log/log"; 8 | import { VERSION } from "./config"; 9 | 10 | const program = new Command(); 11 | 12 | program.name("yamcp").description("YAMCP Gateway CLI").version(VERSION); 13 | program.showHelpAfterError("(add --help for additional information)"); 14 | // Add server commands 15 | serverCommands(program); 16 | 17 | // Add workspace commands 18 | workspaceCommands(program); 19 | 20 | // Add run command 21 | runCommand(program); 22 | 23 | // Add log command 24 | logCommand(program); 25 | 26 | program.parseAsync(process.argv); 27 | -------------------------------------------------------------------------------- /src/providerClient.ts: -------------------------------------------------------------------------------- 1 | import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 3 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 4 | import { SERVER_NAME, SERVER_VERSION } from "./config"; 5 | import { McpProvider } from "./store/schema"; 6 | import { isStdioConfig, isSSEConfig } from "./store/schema"; 7 | 8 | function getProviderClientTransport(mcpConfig: McpProvider) { 9 | if (isStdioConfig(mcpConfig)) { 10 | const { providerParameters } = mcpConfig; 11 | // if the args is an empty array, delete it 12 | if (providerParameters.args && providerParameters.args.length === 0) { 13 | delete providerParameters.args; 14 | } 15 | // if the env is an empty object, delete it 16 | if ( 17 | providerParameters.env && 18 | Object.keys(providerParameters.env).length === 0 19 | ) { 20 | delete providerParameters.env; 21 | } 22 | 23 | // inherit the process PATH to allow provider resolve commands with access to the process PATH 24 | const env = { 25 | ...providerParameters?.env, 26 | ...(process.env.PATH ? { PATH: process.env.PATH } : {}), // Explicitly set PATH to inherit the process PATH 27 | }; 28 | 29 | return new StdioClientTransport({ 30 | ...providerParameters, 31 | env, 32 | }); 33 | } 34 | if (isSSEConfig(mcpConfig)) { 35 | const { providerParameters } = mcpConfig; 36 | if (providerParameters.url) { 37 | return new SSEClientTransport(new URL(providerParameters.url)); 38 | } 39 | } 40 | throw new Error(`Unsupported provider type: ${mcpConfig.type}`); 41 | } 42 | 43 | async function connectProviderClient( 44 | transport: StdioClientTransport | SSEClientTransport 45 | ) { 46 | const client = new McpClient({ 47 | name: SERVER_NAME, 48 | version: SERVER_VERSION, 49 | }); 50 | await client.connect(transport); 51 | return client; 52 | } 53 | 54 | export { connectProviderClient, getProviderClientTransport }; 55 | -------------------------------------------------------------------------------- /src/providerScanner.ts: -------------------------------------------------------------------------------- 1 | import { McpProvider } from "./store/schema"; 2 | import { 3 | getProviderClientTransport, 4 | connectProviderClient, 5 | } from "./providerClient"; 6 | 7 | export interface ScanResult { 8 | connection: { 9 | success: boolean; 10 | error?: string; 11 | }; 12 | capabilities: { 13 | success: boolean; 14 | error?: string; 15 | data?: Capabilities; 16 | }; 17 | version: { 18 | success: boolean; 19 | error?: string; 20 | data?: { version: string; name: string }; 21 | }; 22 | } 23 | 24 | interface Capabilities { 25 | experimental?: Record; 26 | logging?: Record; 27 | completion?: Record; 28 | tools?: Record & { 29 | list?: { name: string; description: string | undefined }[]; 30 | }; 31 | prompts?: Record & { 32 | list?: { name: string; description: string | undefined }[]; 33 | }; 34 | resources?: Record & { 35 | list?: { name: string; description: string | undefined }[]; 36 | }; 37 | } 38 | 39 | function createInitialScanResult(): ScanResult { 40 | return { 41 | connection: { success: false }, 42 | capabilities: { success: false }, 43 | version: { success: false }, 44 | }; 45 | } 46 | 47 | function toBooleanFlags(obj: any): Record { 48 | if (!obj) return {}; 49 | return Object.fromEntries( 50 | Object.entries(obj).map(([key, value]) => [key, !!value]) 51 | ); 52 | } 53 | 54 | function transformCapabilities(capabilities: any): Capabilities { 55 | if (!capabilities) return {}; 56 | return { 57 | experimental: 58 | capabilities.experimental && toBooleanFlags(capabilities.experimental), 59 | logging: capabilities.logging && toBooleanFlags(capabilities.logging), 60 | completion: 61 | capabilities.completion && toBooleanFlags(capabilities.completion), 62 | tools: capabilities.tools && toBooleanFlags(capabilities.tools), 63 | prompts: capabilities.prompts && toBooleanFlags(capabilities.prompts), 64 | resources: capabilities.resources && toBooleanFlags(capabilities.resources), 65 | }; 66 | } 67 | 68 | export async function scanProvider(provider: McpProvider): Promise { 69 | const result = createInitialScanResult(); 70 | let client; 71 | try { 72 | // Step 1: Create transport 73 | let transport; 74 | try { 75 | transport = await getProviderClientTransport(provider); 76 | } catch (error) { 77 | result.connection.error = `Transport creation failed: ${ 78 | error instanceof Error ? error.message : String(error) 79 | }`; 80 | return result; 81 | } 82 | 83 | // Step 2: Test connection and get client 84 | 85 | try { 86 | client = await connectProviderClient(transport); 87 | // At this point, we know that the connection is successful since the transport was created and connected 88 | result.connection.success = true; 89 | } catch (error) { 90 | result.connection.error = `Connection failed: ${ 91 | error instanceof Error ? error.message : String(error) 92 | }`; 93 | return result; 94 | } 95 | 96 | // Step 3: Check version 97 | try { 98 | const version = await client.getServerVersion(); 99 | result.version.success = true; 100 | result.version.data = version; 101 | } catch (error) { 102 | result.version.error = `Version check failed: ${ 103 | error instanceof Error ? error.message : String(error) 104 | }`; 105 | } 106 | 107 | // Step 4: Check capabilities 108 | try { 109 | const caps = await client.getServerCapabilities(); 110 | result.capabilities.success = true; 111 | result.capabilities.data = transformCapabilities(caps); 112 | if (result.capabilities.data.prompts) { 113 | const prompts = await client.listPrompts(); 114 | result.capabilities.data.prompts.list = 115 | prompts.prompts?.map((prompt) => ({ 116 | name: prompt.name, 117 | description: prompt.description || "", 118 | })) || []; 119 | } 120 | if (result.capabilities.data.tools) { 121 | const tools = await client.listTools(); 122 | result.capabilities.data.tools.list = 123 | tools.tools?.map((tool) => ({ 124 | name: tool.name, 125 | description: tool.description || "", 126 | })) || []; 127 | } 128 | if (result.capabilities.data.resources) { 129 | const resources = await client.listResources(); 130 | result.capabilities.data.resources.list = 131 | resources.resources?.map((resource) => ({ 132 | name: resource.name, 133 | description: resource.description || "", 134 | })) || []; 135 | } 136 | } catch (error) { 137 | result.capabilities.error = `Capabilities check failed: ${ 138 | error instanceof Error ? error.message : String(error) 139 | }`; 140 | } 141 | } catch (error) { 142 | // Unexpected error 143 | result.connection.error = `Unexpected error: ${ 144 | error instanceof Error ? error.message : String(error) 145 | }`; 146 | } 147 | if (client) { 148 | client.close(); 149 | } 150 | return result; 151 | } 152 | 153 | // Helper to check if scan was fully successful 154 | export function isScanSuccessful(result: ScanResult): boolean { 155 | return ( 156 | result.connection.success && 157 | result.capabilities.success && 158 | result.version.success 159 | ); 160 | } 161 | 162 | // Helper to get a summary of failures 163 | export function getScanFailures(result: ScanResult): string[] { 164 | const failures: string[] = []; 165 | if (!result.connection.success && result.connection.error) { 166 | failures.push(result.connection.error); 167 | } 168 | if (!result.capabilities.success && result.capabilities.error) { 169 | failures.push(result.capabilities.error); 170 | } 171 | if (!result.version.success && result.version.error) { 172 | failures.push(result.version.error); 173 | } 174 | return failures; 175 | } 176 | -------------------------------------------------------------------------------- /src/store/loader.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import type { McpProvider, ProviderParameters, WorkspaceMap } from "./schema"; 4 | import { 5 | ProviderStoreSchema, 6 | ProviderConfigFileSchema, 7 | WorkspaceMapSchema, 8 | } from "./schema"; 9 | import { z } from "zod"; 10 | import { WORKSPACES_CONFIG_PATH, PROVIDERS_CONFIG_PATH } from "../config"; 11 | import { mkdirIfNotExists } from "../utility/file"; 12 | 13 | function providerType(providerParameters: ProviderParameters): "stdio" | "sse" { 14 | if ("command" in providerParameters) { 15 | return "stdio"; 16 | } else { 17 | return "sse"; 18 | } 19 | } 20 | 21 | /** 22 | * Loads a file and returns the parsed data according to the provided schema. 23 | * If the file does not exist, returns an empty object. 24 | * If a transform function is provided, applies it to the parsed data before returning. 25 | * @param configPath - Path to the config file 26 | * @param schema - Zod schema to validate the config data 27 | * @param transform - Optional function to transform the validated data 28 | * @throws {Error} If the file exists but is not accessible or contains invalid JSON 29 | */ 30 | function loadFile( 31 | configPath: string, 32 | schema: z.ZodType, 33 | transform?: (data: T) => U 34 | ): U { 35 | if (!fs.existsSync(configPath)) { 36 | return (transform ? transform({} as T) : {}) as U; 37 | } 38 | 39 | try { 40 | fs.accessSync(configPath, fs.constants.R_OK); 41 | } catch (error) { 42 | if (error instanceof Error) { 43 | throw new Error( 44 | `Config file not accessible at ${configPath}: ${error.message}` 45 | ); 46 | } 47 | throw new Error(`Config file not accessible at ${configPath}`); 48 | } 49 | 50 | const configContent = fs.readFileSync(configPath, "utf-8"); 51 | let jsonConfig: unknown; 52 | 53 | try { 54 | jsonConfig = JSON.parse(configContent); 55 | } catch (error) { 56 | if (error instanceof Error) { 57 | throw new Error( 58 | `Failed to parse config file: ${configPath} \n ${error.message}` 59 | ); 60 | } 61 | throw new Error(`Failed to parse config file: ${configPath}`); 62 | } 63 | 64 | const validatedConfig = schema.parse(jsonConfig); 65 | return transform 66 | ? transform(validatedConfig) 67 | : (validatedConfig as unknown as U); 68 | } 69 | 70 | /** 71 | * Loads the provider configs from the config file. 72 | * If the file does not exist, it returns an empty array. 73 | */ 74 | export function loadProviderConfigFile(configPath?: string) { 75 | const defaultConfigPath = PROVIDERS_CONFIG_PATH; 76 | const targetPath = configPath || defaultConfigPath; 77 | 78 | return loadFile(targetPath, ProviderConfigFileSchema, (config) => 79 | Object.entries(config || {}).map(([namespace, parameters]) => ({ 80 | namespace, 81 | type: providerType(parameters), 82 | providerParameters: parameters, 83 | })) 84 | ); 85 | } 86 | 87 | /** 88 | * Loads the provider configs from the config file. 89 | * If the file does not exist, it returns an empty array. 90 | */ 91 | export function loadProvidersMap(configPath?: string) { 92 | const defaultConfigPath = PROVIDERS_CONFIG_PATH; 93 | const targetPath = configPath || defaultConfigPath; 94 | 95 | return loadFile(targetPath, ProviderStoreSchema); 96 | } 97 | 98 | export function loadWorkspaceMap(configPath?: string) { 99 | const defaultConfigPath = WORKSPACES_CONFIG_PATH; 100 | const targetPath = configPath || defaultConfigPath; 101 | 102 | return loadFile(targetPath, WorkspaceMapSchema); 103 | } 104 | 105 | export function saveProviders( 106 | providers: Record, 107 | configPath?: string 108 | ) { 109 | const defaultConfigPath = PROVIDERS_CONFIG_PATH; 110 | const targetPath = configPath || defaultConfigPath; 111 | mkdirIfNotExists(targetPath); 112 | fs.writeFileSync(targetPath, JSON.stringify(providers, null, 2)); 113 | } 114 | 115 | export function deleteProviders(configPath?: string) { 116 | const defaultConfigPath = PROVIDERS_CONFIG_PATH; 117 | const targetPath = configPath || defaultConfigPath; 118 | if (fs.existsSync(targetPath)) { 119 | fs.unlinkSync(targetPath); 120 | } 121 | } 122 | 123 | export function saveWorkspaceMap( 124 | workspaceMap: WorkspaceMap, 125 | configPath?: string 126 | ) { 127 | const defaultConfigPath = WORKSPACES_CONFIG_PATH; 128 | const targetPath = configPath || defaultConfigPath; 129 | 130 | mkdirIfNotExists(targetPath); 131 | fs.writeFileSync(targetPath, JSON.stringify(workspaceMap, null, 2)); 132 | } 133 | -------------------------------------------------------------------------------- /src/store/provider.ts: -------------------------------------------------------------------------------- 1 | import { loadProvidersMap, saveProviders, deleteProviders } from "./loader"; 2 | import type { McpProvider } from "./schema"; 3 | 4 | export function addMcpProviders(providers: McpProvider[]) { 5 | // append the providers to the config file 6 | const config = loadProvidersMap(); 7 | 8 | // index the providers by namespace 9 | const newProviders = providers.reduce((acc, provider) => { 10 | acc[provider.namespace] = provider; 11 | return acc; 12 | }, {} as Record); 13 | 14 | // save the new config 15 | saveProviders({ ...config, ...newProviders }); 16 | } 17 | 18 | export function removeMcpProvider(name: string) { 19 | let config = loadProvidersMap(); 20 | delete config[name]; 21 | saveProviders(config); 22 | } 23 | 24 | export function removeAllProviders() { 25 | deleteProviders(); 26 | } 27 | 28 | export function getMcpProviders() { 29 | const config = loadProvidersMap(); 30 | return config; 31 | } 32 | -------------------------------------------------------------------------------- /src/store/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const UrlSchema = z.string().url("Invalid URL format"); 4 | 5 | // Define the Zod schema for provider parameters 6 | const StdioProviderParametersSchema = z.object({ 7 | command: z.string(), 8 | args: z.array(z.string()).optional(), 9 | env: z.record(z.string()).optional(), 10 | cwd: z.string().optional(), 11 | }); 12 | 13 | const SSEProviderParametersSchema = z.object({ 14 | url: UrlSchema, 15 | }); 16 | 17 | // Union type for provider parameters matching ProviderParameters type 18 | const ProviderParametersSchema = z.union([ 19 | StdioProviderParametersSchema, 20 | SSEProviderParametersSchema, 21 | ]); 22 | 23 | // Define the Zod schema for the provider config file that is used to load the providers 24 | const ProviderConfigFileSchema = z.record(z.string(), ProviderParametersSchema); 25 | 26 | // Define the schema for a single mcp provider 27 | const McpProviderSchema = z.object({ 28 | namespace: z.string().min(1, "Namespace cannot be empty"), 29 | type: z.enum(["stdio", "sse"]), 30 | providerParameters: ProviderParametersSchema, 31 | }); 32 | 33 | // Define the schema for the provider store that is used to store the providers 34 | const ProviderStoreSchema = z.record(z.string(), McpProviderSchema); 35 | 36 | const WorkspaceMapSchema = z.record(z.string(), z.array(z.string())); 37 | 38 | // Type inference from the Zod schema 39 | type ProviderParameters = z.infer; 40 | type McpProvider = z.infer; 41 | type ProviderStore = z.infer; 42 | type SseProviderParameters = z.infer; 43 | type StdioProviderParameters = z.infer; 44 | type WorkspaceMap = z.infer; 45 | function isStdioConfig(config: McpProvider): config is McpProvider & { 46 | type: "stdio"; 47 | providerParameters: StdioProviderParameters; 48 | } { 49 | return config.type === "stdio"; 50 | } 51 | 52 | function isSSEConfig(config: McpProvider): config is McpProvider & { 53 | type: "sse"; 54 | providerParameters: SseProviderParameters; 55 | } { 56 | return config.type === "sse"; 57 | } 58 | 59 | export { 60 | ProviderConfigFileSchema, 61 | McpProviderSchema, 62 | WorkspaceMapSchema, 63 | ProviderStoreSchema, 64 | UrlSchema, 65 | }; 66 | export type { 67 | McpProvider, 68 | ProviderParameters, 69 | ProviderStore, 70 | SseProviderParameters, 71 | StdioProviderParameters, 72 | WorkspaceMap, 73 | }; 74 | export { isStdioConfig, isSSEConfig }; 75 | -------------------------------------------------------------------------------- /src/store/workspace.ts: -------------------------------------------------------------------------------- 1 | import { loadWorkspaceMap, saveWorkspaceMap } from "./loader"; 2 | 3 | export function addWorkspace(name: string, providerNames: string[]) { 4 | let config = loadWorkspaceMap(); 5 | config = { ...config, [name]: providerNames }; 6 | saveWorkspaceMap(config); 7 | } 8 | 9 | export function removeWorkspace(name: string) { 10 | let config = loadWorkspaceMap(); 11 | delete config[name]; 12 | saveWorkspaceMap(config); 13 | } 14 | 15 | export function getWorkspaces() { 16 | const config = loadWorkspaceMap(); 17 | return config; 18 | } 19 | -------------------------------------------------------------------------------- /src/utility/file.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | 4 | export function mkdirIfNotExists(dirPath: string) { 5 | // Create the directory if it doesn't exist 6 | const dir = path.dirname(dirPath); 7 | if (!fs.existsSync(dir)) { 8 | fs.mkdirSync(dir, { recursive: true }); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utility/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from "winston"; 2 | import path, { resolve } from "path"; 3 | import fs from "fs"; 4 | import { LOG_DIR } from "../config"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | 7 | // Update the LogMessage interface to match MCP server's format 8 | interface LogMessage { 9 | level: 10 | | "error" 11 | | "debug" 12 | | "info" 13 | | "notice" 14 | | "warning" 15 | | "critical" 16 | | "alert" 17 | | "emergency"; 18 | data?: unknown; 19 | _meta?: Record; 20 | logger?: string; 21 | [key: string]: unknown; // Add index signature for additional properties 22 | } 23 | 24 | const createLogger = (namespace: string) => { 25 | // Create logs directory if it doesn't exist 26 | if (!fs.existsSync(path.join(LOG_DIR, namespace))) { 27 | fs.mkdirSync(path.join(LOG_DIR, namespace), { recursive: true }); 28 | } 29 | 30 | return winston.createLogger({ 31 | level: "info", 32 | format: winston.format.combine( 33 | winston.format.timestamp(), 34 | winston.format.json() 35 | ), 36 | transports: [ 37 | // Write all logs with importance level of 'error' or less to 'error.log' 38 | new winston.transports.File({ 39 | filename: path.join(LOG_DIR, namespace, `error.log`), 40 | level: "error", 41 | }), 42 | // Write all logs with importance level of 'info' or less to 'combined.log' 43 | new winston.transports.File({ 44 | filename: path.join(LOG_DIR, namespace, "combined.log"), 45 | }), 46 | ], 47 | }); 48 | }; 49 | 50 | // Helper function to format errors 51 | const formatError = ( 52 | error: unknown 53 | ): { errorMessage: string; metadata: Record } => { 54 | if (error instanceof Error) { 55 | return { 56 | errorMessage: error.message, 57 | metadata: { 58 | stack: error.stack, 59 | ...error, // Spread any additional properties 60 | }, 61 | }; 62 | } 63 | return { 64 | errorMessage: String(error), 65 | metadata: {}, 66 | }; 67 | }; 68 | 69 | class Logger { 70 | private ended = false; 71 | private logger: winston.Logger; 72 | constructor(namespace: string) { 73 | this.logger = createLogger(namespace); 74 | } 75 | info(message: string, metadata?: Record) { 76 | if (this.ended) { 77 | return; 78 | } 79 | this.logger.info(message, metadata); 80 | } 81 | error(error: unknown, additionalMetadata?: Record) { 82 | if (this.ended) { 83 | return; 84 | } 85 | const { errorMessage, metadata } = formatError(error); 86 | this.logger.error(errorMessage, { 87 | ...metadata, 88 | ...additionalMetadata, 89 | }); 90 | } 91 | logMessage(logMsg: LogMessage) { 92 | if (this.ended) { 93 | return; 94 | } 95 | const level = logMsg.level; 96 | const message = 97 | typeof logMsg.data === "string" 98 | ? logMsg.data 99 | : JSON.stringify(logMsg.data); 100 | this.logger.log(level, message, logMsg._meta); 101 | } 102 | // Function to flush logs and exit 103 | async flushLogs() { 104 | return new Promise(() => { 105 | this.logger.end(() => { 106 | this.ended = true; 107 | resolve(); 108 | }); // Signal Winston to finish writing logs 109 | }); 110 | } 111 | 112 | // Function to flush logs and exit 113 | async flushLogsAndExit(code: number) { 114 | return new Promise(() => { 115 | this.logger.end(() => { 116 | this.ended = true; 117 | process.exit(code); 118 | }); // Signal Winston to finish writing logs 119 | }); 120 | } 121 | } 122 | 123 | function getLogNamespace(workspaceName: string) { 124 | const randomString = uuidv4().replace(/-/g, "").substring(0, 8); 125 | return `${workspaceName}_${randomString}`; 126 | } 127 | 128 | export { Logger, getLogNamespace }; 129 | export type { LogMessage }; 130 | -------------------------------------------------------------------------------- /src/utility/namespace.ts: -------------------------------------------------------------------------------- 1 | // Namespace 2 | type Namespace = string; 3 | type NamespacedName = `${Namespace}_${string}`; 4 | function isNamespacedName(name: string): name is NamespacedName { 5 | return name.includes("_"); 6 | } 7 | function addNamespace(namespace: Namespace, name: string): NamespacedName { 8 | return `${namespace}_${name}`; 9 | } 10 | function parseNamespace(namespacedName: NamespacedName) { 11 | const [namespace, ...name] = namespacedName.split("_"); 12 | return { namespace, name: name.join("_") }; 13 | } 14 | 15 | export { isNamespacedName, addNamespace, parseNamespace }; 16 | export type { Namespace, NamespacedName }; 17 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "module": "commonjs", 6 | "lib": ["ES2020"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictBindCallApply": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "esModuleInterop": true, 24 | "experimentalDecorators": true, 25 | "emitDecoratorMetadata": true, 26 | "skipLibCheck": true, 27 | "forceConsistentCasingInFileNames": true 28 | }, 29 | "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "esModuleInterop": true, 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "types": ["node"], 9 | "jsx": "react" 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: "node", 7 | include: ["src/**/*.{test,spec}.{js,ts}"], 8 | coverage: { 9 | reporter: ["text", "json", "html"], 10 | exclude: ["node_modules/", "dist/"], 11 | }, 12 | }, 13 | }); 14 | --------------------------------------------------------------------------------