├── .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 |

9 |
10 |
11 | ## Connect All Bundled Servers in a Workspace to Your AI Apps with One Config
12 |
13 |
14 |

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 | [](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 |
--------------------------------------------------------------------------------