This is page 2 of 10. Use http://codebase.md/jakedismo/master-mcp-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .eslintignore
├── .eslintrc.cjs
├── .eslintrc.js
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .prettierrc.json
├── CHANGELOG.md
├── config
│ ├── default.json
│ ├── development.json
│ ├── production.json
│ └── schema.json
├── config.json
├── CONTRIBUTING.md
├── debug-stdio.cjs
├── debug-stdio.js
├── deploy
│ ├── cloudflare
│ │ ├── .gitkeep
│ │ ├── README.md
│ │ └── wrangler.toml
│ ├── docker
│ │ ├── .gitkeep
│ │ ├── docker-compose.yml
│ │ ├── Dockerfile
│ │ └── entrypoint.sh
│ ├── koyeb
│ │ ├── .gitkeep
│ │ └── koyeb.yaml
│ └── README.md
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── .DS_Store
│ ├── .vitepress
│ │ ├── cache
│ │ │ └── deps
│ │ │ ├── _metadata.json
│ │ │ ├── chunk-HVR2FF6M.js
│ │ │ ├── chunk-HVR2FF6M.js.map
│ │ │ ├── chunk-P2XGSYO7.js
│ │ │ ├── chunk-P2XGSYO7.js.map
│ │ │ ├── package.json
│ │ │ ├── vitepress___@vue_devtools-api.js
│ │ │ ├── vitepress___@vue_devtools-api.js.map
│ │ │ ├── vitepress___@vueuse_core.js
│ │ │ ├── vitepress___@vueuse_core.js.map
│ │ │ ├── vitepress___@vueuse_integrations_useFocusTrap.js
│ │ │ ├── vitepress___@vueuse_integrations_useFocusTrap.js.map
│ │ │ ├── vitepress___mark__js_src_vanilla__js.js
│ │ │ ├── vitepress___mark__js_src_vanilla__js.js.map
│ │ │ ├── vitepress___minisearch.js
│ │ │ ├── vitepress___minisearch.js.map
│ │ │ ├── vue.js
│ │ │ └── vue.js.map
│ │ ├── config.ts
│ │ ├── dist
│ │ │ ├── 404.html
│ │ │ ├── advanced
│ │ │ │ ├── extensibility.html
│ │ │ │ ├── index.html
│ │ │ │ ├── monitoring.html
│ │ │ │ ├── performance.html
│ │ │ │ └── security.html
│ │ │ ├── api
│ │ │ │ ├── index.html
│ │ │ │ └── README.html
│ │ │ ├── assets
│ │ │ │ ├── advanced_extensibility.md.TrXUn5w5.js
│ │ │ │ ├── advanced_extensibility.md.TrXUn5w5.lean.js
│ │ │ │ ├── advanced_index.md.CPcpUlw_.js
│ │ │ │ ├── advanced_index.md.CPcpUlw_.lean.js
│ │ │ │ ├── advanced_monitoring.md.DTybdNg-.js
│ │ │ │ ├── advanced_monitoring.md.DTybdNg-.lean.js
│ │ │ │ ├── advanced_performance.md.DKmzK0ia.js
│ │ │ │ ├── advanced_performance.md.DKmzK0ia.lean.js
│ │ │ │ ├── advanced_security.md.B-oBD7IB.js
│ │ │ │ ├── advanced_security.md.B-oBD7IB.lean.js
│ │ │ │ ├── api_index.md.Dl1JB08_.js
│ │ │ │ ├── api_index.md.Dl1JB08_.lean.js
│ │ │ │ ├── chunks
│ │ │ │ │ └── framework.CHl2ywxc.js
│ │ │ │ ├── configuration_environment-variables.md.Ddy3P_Wz.js
│ │ │ │ ├── configuration_environment-variables.md.Ddy3P_Wz.lean.js
│ │ │ │ ├── configuration_environment.md.DxcTQ623.js
│ │ │ │ ├── configuration_environment.md.DxcTQ623.lean.js
│ │ │ │ ├── configuration_overview.md.DIkVDv7V.js
│ │ │ │ ├── configuration_overview.md.DIkVDv7V.lean.js
│ │ │ │ ├── configuration_performance.md.DbJdmLrW.js
│ │ │ │ ├── configuration_performance.md.DbJdmLrW.lean.js
│ │ │ │ ├── configuration_reference.md.27IKWqtk.js
│ │ │ │ ├── configuration_reference.md.27IKWqtk.lean.js
│ │ │ │ ├── configuration_security.md.-OOlkzN4.js
│ │ │ │ ├── configuration_security.md.-OOlkzN4.lean.js
│ │ │ │ ├── contributing_dev-setup.md.Ceqh4w-R.js
│ │ │ │ ├── contributing_dev-setup.md.Ceqh4w-R.lean.js
│ │ │ │ ├── contributing_guidelines.md.ZEAX2yVh.js
│ │ │ │ ├── contributing_guidelines.md.ZEAX2yVh.lean.js
│ │ │ │ ├── contributing_index.md.DYq9R6wr.js
│ │ │ │ ├── contributing_index.md.DYq9R6wr.lean.js
│ │ │ │ ├── contributing_maintenance.md.k2bR0IaR.js
│ │ │ │ ├── contributing_maintenance.md.k2bR0IaR.lean.js
│ │ │ │ ├── deployment_cicd.md.Ci2T0UYC.js
│ │ │ │ ├── deployment_cicd.md.Ci2T0UYC.lean.js
│ │ │ │ ├── deployment_cloudflare-workers.md.D2WHsfep.js
│ │ │ │ ├── deployment_cloudflare-workers.md.D2WHsfep.lean.js
│ │ │ │ ├── deployment_docker.md.B8bQDQTo.js
│ │ │ │ ├── deployment_docker.md.B8bQDQTo.lean.js
│ │ │ │ ├── deployment_index.md.ClYeOkpy.js
│ │ │ │ ├── deployment_index.md.ClYeOkpy.lean.js
│ │ │ │ ├── deployment_koyeb.md.B_wJhvF7.js
│ │ │ │ ├── deployment_koyeb.md.B_wJhvF7.lean.js
│ │ │ │ ├── examples_advanced-routing.md.B3CqhLZ7.js
│ │ │ │ ├── examples_advanced-routing.md.B3CqhLZ7.lean.js
│ │ │ │ ├── examples_basic-node.md.CaDZzGlO.js
│ │ │ │ ├── examples_basic-node.md.CaDZzGlO.lean.js
│ │ │ │ ├── examples_cloudflare-worker.md.DwVSz-c7.js
│ │ │ │ ├── examples_cloudflare-worker.md.DwVSz-c7.lean.js
│ │ │ │ ├── examples_index.md.CBF_BLkl.js
│ │ │ │ ├── examples_index.md.CBF_BLkl.lean.js
│ │ │ │ ├── examples_oauth-delegation.md.1hZxoqDl.js
│ │ │ │ ├── examples_oauth-delegation.md.1hZxoqDl.lean.js
│ │ │ │ ├── examples_overview.md.CZN0JbZ7.js
│ │ │ │ ├── examples_overview.md.CZN0JbZ7.lean.js
│ │ │ │ ├── examples_testing.md.Dek4GpNs.js
│ │ │ │ ├── examples_testing.md.Dek4GpNs.lean.js
│ │ │ │ ├── getting-started_concepts.md.D7ON9iGB.js
│ │ │ │ ├── getting-started_concepts.md.D7ON9iGB.lean.js
│ │ │ │ ├── getting-started_installation.md.BKnVqAGg.js
│ │ │ │ ├── getting-started_installation.md.BKnVqAGg.lean.js
│ │ │ │ ├── getting-started_overview.md.DvJDFL2N.js
│ │ │ │ ├── getting-started_overview.md.DvJDFL2N.lean.js
│ │ │ │ ├── getting-started_quickstart-node.md.GOO4aGas.js
│ │ │ │ ├── getting-started_quickstart-node.md.GOO4aGas.lean.js
│ │ │ │ ├── getting-started_quickstart-workers.md.Cpofh8Mj.js
│ │ │ │ ├── getting-started_quickstart-workers.md.Cpofh8Mj.lean.js
│ │ │ │ ├── getting-started.md.DG9ndneo.js
│ │ │ │ ├── getting-started.md.DG9ndneo.lean.js
│ │ │ │ ├── guides_configuration-management.md.B-jwYMbA.js
│ │ │ │ ├── guides_configuration-management.md.B-jwYMbA.lean.js
│ │ │ │ ├── guides_configuration.md.Ci3zYDFA.js
│ │ │ │ ├── guides_configuration.md.Ci3zYDFA.lean.js
│ │ │ │ ├── guides_index.md.CIlq2fmx.js
│ │ │ │ ├── guides_index.md.CIlq2fmx.lean.js
│ │ │ │ ├── guides_module-loading.md.BkJvuRnQ.js
│ │ │ │ ├── guides_module-loading.md.BkJvuRnQ.lean.js
│ │ │ │ ├── guides_oauth-delegation.md.DEOZ-_G0.js
│ │ │ │ ├── guides_oauth-delegation.md.DEOZ-_G0.lean.js
│ │ │ │ ├── guides_request-routing.md.Bdzf0VLg.js
│ │ │ │ ├── guides_request-routing.md.Bdzf0VLg.lean.js
│ │ │ │ ├── guides_testing.md.kYfHqJLu.js
│ │ │ │ ├── guides_testing.md.kYfHqJLu.lean.js
│ │ │ │ ├── inter-italic-cyrillic-ext.r48I6akx.woff2
│ │ │ │ ├── inter-italic-cyrillic.By2_1cv3.woff2
│ │ │ │ ├── inter-italic-greek-ext.1u6EdAuj.woff2
│ │ │ │ ├── inter-italic-greek.DJ8dCoTZ.woff2
│ │ │ │ ├── inter-italic-latin-ext.CN1xVJS-.woff2
│ │ │ │ ├── inter-italic-latin.C2AdPX0b.woff2
│ │ │ │ ├── inter-italic-vietnamese.BSbpV94h.woff2
│ │ │ │ ├── inter-roman-cyrillic-ext.BBPuwvHQ.woff2
│ │ │ │ ├── inter-roman-cyrillic.C5lxZ8CY.woff2
│ │ │ │ ├── inter-roman-greek-ext.CqjqNYQ-.woff2
│ │ │ │ ├── inter-roman-greek.BBVDIX6e.woff2
│ │ │ │ ├── inter-roman-latin-ext.4ZJIpNVo.woff2
│ │ │ │ ├── inter-roman-latin.Di8DUHzh.woff2
│ │ │ │ ├── inter-roman-vietnamese.BjW4sHH5.woff2
│ │ │ │ ├── README.md.BO5r5M9u.js
│ │ │ │ ├── README.md.BO5r5M9u.lean.js
│ │ │ │ ├── style.BQrfSMzK.css
│ │ │ │ ├── troubleshooting_common-issues.md.CScvzWM1.js
│ │ │ │ ├── troubleshooting_common-issues.md.CScvzWM1.lean.js
│ │ │ │ ├── troubleshooting_deployment.md.DUhpqnLE.js
│ │ │ │ ├── troubleshooting_deployment.md.DUhpqnLE.lean.js
│ │ │ │ ├── troubleshooting_errors.md.BSCsEmGc.js
│ │ │ │ ├── troubleshooting_errors.md.BSCsEmGc.lean.js
│ │ │ │ ├── troubleshooting_oauth.md.Cw60Eka3.js
│ │ │ │ ├── troubleshooting_oauth.md.Cw60Eka3.lean.js
│ │ │ │ ├── troubleshooting_performance.md.DxY6LJcT.js
│ │ │ │ ├── troubleshooting_performance.md.DxY6LJcT.lean.js
│ │ │ │ ├── troubleshooting_routing.md.BHN-MDhs.js
│ │ │ │ ├── troubleshooting_routing.md.BHN-MDhs.lean.js
│ │ │ │ ├── troubleshooting_security-best-practices.md.Yiu8E-zt.js
│ │ │ │ ├── troubleshooting_security-best-practices.md.Yiu8E-zt.lean.js
│ │ │ │ ├── tutorials_beginner-getting-started.md.BXObgobW.js
│ │ │ │ ├── tutorials_beginner-getting-started.md.BXObgobW.lean.js
│ │ │ │ ├── tutorials_cloudflare-workers-tutorial.md.MPHsc0aT.js
│ │ │ │ ├── tutorials_cloudflare-workers-tutorial.md.MPHsc0aT.lean.js
│ │ │ │ ├── tutorials_load-balancing-and-resilience.md.Dv9r9jyW.js
│ │ │ │ ├── tutorials_load-balancing-and-resilience.md.Dv9r9jyW.lean.js
│ │ │ │ ├── tutorials_oauth-delegation-github.md.Nq4glqCe.js
│ │ │ │ └── tutorials_oauth-delegation-github.md.Nq4glqCe.lean.js
│ │ │ ├── configuration
│ │ │ │ ├── environment-variables.html
│ │ │ │ ├── environment.html
│ │ │ │ ├── examples.html
│ │ │ │ ├── overview.html
│ │ │ │ ├── performance.html
│ │ │ │ ├── reference.html
│ │ │ │ └── security.html
│ │ │ ├── contributing
│ │ │ │ ├── dev-setup.html
│ │ │ │ ├── guidelines.html
│ │ │ │ ├── index.html
│ │ │ │ └── maintenance.html
│ │ │ ├── deployment
│ │ │ │ ├── cicd.html
│ │ │ │ ├── cloudflare-workers.html
│ │ │ │ ├── docker.html
│ │ │ │ ├── index.html
│ │ │ │ └── koyeb.html
│ │ │ ├── diagrams
│ │ │ │ └── architecture.svg
│ │ │ ├── examples
│ │ │ │ ├── advanced-routing.html
│ │ │ │ ├── basic-node.html
│ │ │ │ ├── cloudflare-worker.html
│ │ │ │ ├── index.html
│ │ │ │ ├── oauth-delegation.html
│ │ │ │ ├── overview.html
│ │ │ │ └── testing.html
│ │ │ ├── getting-started
│ │ │ │ ├── concepts.html
│ │ │ │ ├── installation.html
│ │ │ │ ├── overview.html
│ │ │ │ ├── quick-start.html
│ │ │ │ ├── quickstart-node.html
│ │ │ │ └── quickstart-workers.html
│ │ │ ├── getting-started.html
│ │ │ ├── guides
│ │ │ │ ├── authentication.html
│ │ │ │ ├── client-integration.html
│ │ │ │ ├── configuration-management.html
│ │ │ │ ├── configuration.html
│ │ │ │ ├── index.html
│ │ │ │ ├── module-loading.html
│ │ │ │ ├── oauth-delegation.html
│ │ │ │ ├── request-routing.html
│ │ │ │ ├── server-management.html
│ │ │ │ ├── server-sharing.html
│ │ │ │ └── testing.html
│ │ │ ├── hashmap.json
│ │ │ ├── index.html
│ │ │ ├── logo.svg
│ │ │ ├── README.html
│ │ │ ├── reports
│ │ │ │ └── mcp-compliance-audit.html
│ │ │ ├── troubleshooting
│ │ │ │ ├── common-issues.html
│ │ │ │ ├── deployment.html
│ │ │ │ ├── errors.html
│ │ │ │ ├── index.html
│ │ │ │ ├── oauth.html
│ │ │ │ ├── performance.html
│ │ │ │ ├── routing.html
│ │ │ │ └── security-best-practices.html
│ │ │ ├── tutorials
│ │ │ │ ├── beginner-getting-started.html
│ │ │ │ ├── cloudflare-workers-tutorial.html
│ │ │ │ ├── load-balancing-and-resilience.html
│ │ │ │ └── oauth-delegation-github.html
│ │ │ └── vp-icons.css
│ │ └── theme
│ │ ├── components
│ │ │ ├── ApiPlayground.vue
│ │ │ ├── AuthFlowDemo.vue
│ │ │ ├── CodeTabs.vue
│ │ │ └── ConfigGenerator.vue
│ │ ├── index.ts
│ │ └── style.css
│ ├── advanced
│ │ ├── extensibility.md
│ │ ├── index.md
│ │ ├── monitoring.md
│ │ ├── performance.md
│ │ └── security.md
│ ├── api
│ │ ├── functions
│ │ │ └── createServer.md
│ │ ├── index.md
│ │ ├── interfaces
│ │ │ └── RunningServer.md
│ │ └── README.md
│ ├── architecture
│ │ └── images
│ │ └── mcp_master_architecture.svg
│ ├── configuration
│ │ ├── environment-variables.md
│ │ ├── environment.md
│ │ ├── examples.md
│ │ ├── overview.md
│ │ ├── performance.md
│ │ ├── reference.md
│ │ └── security.md
│ ├── contributing
│ │ ├── dev-setup.md
│ │ ├── guidelines.md
│ │ ├── index.md
│ │ └── maintenance.md
│ ├── deployment
│ │ ├── cicd.md
│ │ ├── cloudflare-workers.md
│ │ ├── docker.md
│ │ ├── docs-site.md
│ │ ├── index.md
│ │ └── koyeb.md
│ ├── examples
│ │ ├── advanced-routing.md
│ │ ├── basic-node.md
│ │ ├── cloudflare-worker.md
│ │ ├── index.md
│ │ ├── oauth-delegation.md
│ │ ├── overview.md
│ │ └── testing.md
│ ├── getting-started
│ │ ├── concepts.md
│ │ ├── installation.md
│ │ ├── overview.md
│ │ ├── quick-start.md
│ │ ├── quickstart-node.md
│ │ └── quickstart-workers.md
│ ├── getting-started.md
│ ├── guides
│ │ ├── authentication.md
│ │ ├── client-integration.md
│ │ ├── configuration-management.md
│ │ ├── configuration.md
│ │ ├── index.md
│ │ ├── module-loading.md
│ │ ├── oauth-delegation.md
│ │ ├── request-routing.md
│ │ ├── server-management.md
│ │ ├── server-sharing.md
│ │ └── testing.md
│ ├── index.html
│ ├── public
│ │ ├── diagrams
│ │ │ └── architecture.svg
│ │ ├── github-social.png
│ │ │ └── image.png
│ │ ├── logo.png
│ │ └── logo.svg
│ ├── README.md
│ ├── stdio-servers.md
│ ├── testing
│ │ └── phase-9-testing-architecture.md
│ ├── troubleshooting
│ │ ├── common-issues.md
│ │ ├── deployment.md
│ │ ├── errors.md
│ │ ├── index.md
│ │ ├── oauth.md
│ │ ├── performance.md
│ │ ├── routing.md
│ │ └── security-best-practices.md
│ └── tutorials
│ ├── beginner-getting-started.md
│ ├── cloudflare-workers-tutorial.md
│ ├── load-balancing-and-resilience.md
│ └── oauth-delegation-github.md
├── examples
│ ├── advanced-routing
│ │ ├── config.yaml
│ │ └── README.md
│ ├── basic-node
│ │ ├── config.yaml
│ │ ├── README.md
│ │ └── server.ts
│ ├── cloudflare-worker
│ │ ├── README.md
│ │ └── worker.ts
│ ├── custom-auth
│ │ ├── config.yaml
│ │ ├── index.ts
│ │ └── README.md
│ ├── multi-server
│ │ ├── config.yaml
│ │ └── README.md
│ ├── oauth-delegation
│ │ └── README.md
│ ├── oauth-node
│ │ ├── config.yaml
│ │ └── README.md
│ ├── performance
│ │ ├── config.yaml
│ │ └── README.md
│ ├── sample-configs
│ │ ├── basic.yaml
│ │ └── simple-setup.yaml
│ ├── security-hardening
│ │ └── README.md
│ ├── stdio-mcp-server.cjs
│ ├── test-mcp-server.js
│ └── test-stdio-server.js
├── LICENSE
├── master-mcp-definition.md
├── package-lock.json
├── package.json
├── README.md
├── reports
│ └── claude_report_20250815_222153.html
├── scripts
│ └── generate-config-docs.ts
├── src
│ ├── auth
│ │ ├── multi-auth-manager.ts
│ │ ├── oauth-providers.ts
│ │ └── token-manager.ts
│ ├── config
│ │ ├── config-loader.ts
│ │ ├── environment-manager.ts
│ │ ├── schema-validator.ts
│ │ └── secret-manager.ts
│ ├── index.ts
│ ├── mcp-server.ts
│ ├── modules
│ │ ├── capability-aggregator.ts
│ │ ├── module-loader.ts
│ │ ├── request-router.ts
│ │ ├── stdio-capability-discovery.ts
│ │ └── stdio-manager.ts
│ ├── oauth
│ │ ├── callback-handler.ts
│ │ ├── flow-controller.ts
│ │ ├── flow-validator.ts
│ │ ├── pkce-manager.ts
│ │ ├── state-manager.ts
│ │ └── web-interface.ts
│ ├── routing
│ │ ├── circuit-breaker.ts
│ │ ├── load-balancer.ts
│ │ ├── retry-handler.ts
│ │ └── route-registry.ts
│ ├── runtime
│ │ ├── node.ts
│ │ └── worker.ts
│ ├── server
│ │ ├── config-manager.ts
│ │ ├── dependency-container.ts
│ │ ├── master-server.ts
│ │ └── protocol-handler.ts
│ ├── types
│ │ ├── auth.ts
│ │ ├── config.ts
│ │ ├── jose-shim.d.ts
│ │ ├── mcp.ts
│ │ └── server.ts
│ └── utils
│ ├── cache.ts
│ ├── crypto.ts
│ ├── dev.ts
│ ├── errors.ts
│ ├── http.ts
│ ├── logger.ts
│ ├── monitoring.ts
│ ├── string.ts
│ ├── time.ts
│ ├── validation.ts
│ └── validators.ts
├── static
│ └── oauth
│ ├── consent.html
│ ├── error.html
│ ├── script.js
│ ├── style.css
│ └── success.html
├── tests
│ ├── _setup
│ │ ├── miniflare.setup.ts
│ │ └── vitest.setup.ts
│ ├── _utils
│ │ ├── log-capture.ts
│ │ ├── mock-fetch.ts
│ │ └── test-server.ts
│ ├── .gitkeep
│ ├── e2e
│ │ ├── flow-controller.express.test.ts
│ │ └── flow-controller.worker.test.ts
│ ├── factories
│ │ ├── configFactory.ts
│ │ ├── mcpFactory.ts
│ │ └── oauthFactory.ts
│ ├── fixtures
│ │ ├── capabilities.json
│ │ └── stdio-server.js
│ ├── integration
│ │ ├── modules.capability-aggregator.test.ts
│ │ ├── modules.module-loader-health.test.ts
│ │ ├── oauth.callback-handler.test.ts
│ │ └── request-router.test.ts
│ ├── mocks
│ │ ├── mcp
│ │ │ └── fake-backend.ts
│ │ └── oauth
│ │ └── mock-oidc-provider.ts
│ ├── perf
│ │ ├── artillery
│ │ │ └── auth-routing.yaml
│ │ └── perf.auth-and-routing.test.ts
│ ├── security
│ │ └── security.oauth-and-input.test.ts
│ ├── servers
│ │ ├── test-auth-simple.js
│ │ ├── test-debug.js
│ │ ├── test-master-mcp.js
│ │ ├── test-mcp-client.js
│ │ ├── test-streaming-both-complete.js
│ │ ├── test-streaming-both-full.js
│ │ ├── test-streaming-both-simple.js
│ │ ├── test-streaming-both.js
│ │ └── test-streaming.js
│ ├── setup
│ │ └── test-setup.ts
│ ├── unit
│ │ ├── auth.multi-auth-manager.test.ts
│ │ ├── auth.token-manager.test.ts
│ │ ├── config.environment-manager.test.ts
│ │ ├── config.schema-validator.test.ts
│ │ ├── config.secret-manager.test.ts
│ │ ├── modules
│ │ │ ├── stdio-capability-discovery.test.ts
│ │ │ └── stdio-manager.test.ts
│ │ ├── modules.route-registry.test.ts
│ │ ├── oauth.pkce-state.test.ts
│ │ ├── routing
│ │ │ └── circuit-breaker.test.ts
│ │ ├── routing.core.test.ts
│ │ ├── stdio-capability-discovery.test.ts
│ │ ├── utils.crypto.test.ts
│ │ ├── utils.logger.test.ts
│ │ └── utils.monitoring.test.ts
│ └── utils
│ ├── fake-express.ts
│ ├── mock-http.ts
│ ├── oauth-mocks.ts
│ └── token-storages.ts
├── tsconfig.base.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.worker.json
├── typedoc.json
├── vitest.config.ts
└── vitest.worker.config.ts
```
# Files
--------------------------------------------------------------------------------
/docs/guides/configuration-management.md:
--------------------------------------------------------------------------------
```markdown
1 | # Configuration Management
2 |
3 | Master MCP Server supports JSON or YAML configuration files, environment variable overrides, CLI overrides, schema validation, and secret resolution.
4 |
5 | ## Files
6 |
7 | - Default paths: `config/default.json`, `config/<env>.json`
8 | - Explicit path: set `MASTER_CONFIG_PATH=/path/to/config.yaml`
9 | - Schema: `config/schema.json` (also embedded in code as a fallback)
10 |
11 | ## Environment Overrides
12 |
13 | Environment variables map to config fields. Key ones:
14 |
15 | - `MASTER_HOSTING_PLATFORM`, `MASTER_HOSTING_PORT`, `MASTER_BASE_URL`
16 | - `MASTER_LOG_LEVEL`
17 | - `MASTER_OAUTH_*` (ISSUER, AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, SCOPES, AUDIENCE)
18 | - `MASTER_SERVERS` (JSON) or `MASTER_SERVERS_YAML` (YAML)
19 |
20 | See `docs/configuration/environment-variables.md` and `.env.example`.
21 |
22 | ## CLI Overrides
23 |
24 | You can override nested fields with dotted keys:
25 |
26 | ```
27 | node dist/node/index.js --hosting.port=4000 --routing.retry.maxRetries=3
28 | ```
29 |
30 | ## Secrets
31 |
32 | - `env:VARNAME` → replaced by `process.env.VARNAME` at load time
33 | - `enc:gcm:<base64>` → decrypted using `MASTER_CONFIG_KEY` (or `MASTER_SECRET_KEY`)
34 |
35 | Use `SecretManager` to encrypt/decrypt/rotate secrets safely.
36 |
37 | ## Hot Reload (Node)
38 |
39 | When `ConfigManager` is created with `{ watch: true }`, changes to `config/default.json`, `config/<env>.json`, or an explicit path will be validated and emitted. Some changes (e.g., hosting.port) still require a restart.
40 |
41 | ## Validation
42 |
43 | Configs are validated using a lightweight schema validator (`SchemaValidator`) with support for types, enums, required fields, arrays, and formats (`url`, `integer`). On failure, the error lists the exact path and reason.
44 |
45 |
```
--------------------------------------------------------------------------------
/src/utils/cache.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Simple in-memory TTL cache with optional memoization helpers.
3 | */
4 |
5 | export interface CacheEntry<V> {
6 | value: V
7 | expiresAt: number
8 | }
9 |
10 | export class TTLCache<K, V> {
11 | private store = new Map<K, CacheEntry<V>>()
12 | constructor(private defaultTtlMs = 60_000) {}
13 |
14 | set(key: K, value: V, ttlMs?: number): void {
15 | const ttl = ttlMs ?? this.defaultTtlMs
16 | const expiresAt = Date.now() + ttl
17 | this.store.set(key, { value, expiresAt })
18 | }
19 |
20 | get(key: K): V | undefined {
21 | const hit = this.store.get(key)
22 | if (!hit) return undefined
23 | if (hit.expiresAt < Date.now()) {
24 | this.store.delete(key)
25 | return undefined
26 | }
27 | return hit.value
28 | }
29 |
30 | has(key: K): boolean {
31 | return this.get(key) !== undefined
32 | }
33 |
34 | delete(key: K): void {
35 | this.store.delete(key)
36 | }
37 |
38 | clear(): void {
39 | this.store.clear()
40 | }
41 |
42 | size(): number {
43 | return this.store.size
44 | }
45 |
46 | sweep(): void {
47 | const now = Date.now()
48 | for (const [k, v] of this.store.entries()) {
49 | if (v.expiresAt < now) this.store.delete(k)
50 | }
51 | }
52 |
53 | async getOrSet(key: K, loader: () => Promise<V>, ttlMs?: number): Promise<V> {
54 | const existing = this.get(key)
55 | if (existing !== undefined) return existing
56 | const v = await loader()
57 | this.set(key, v, ttlMs)
58 | return v
59 | }
60 | }
61 |
62 | export function memoizeAsync<A extends unknown[], R>(fn: (...args: A) => Promise<R>, ttlMs = 60_000): (...args: A) => Promise<R> {
63 | const cache = new TTLCache<string, R>(ttlMs)
64 | return async (...args: A) => {
65 | const key = JSON.stringify(args)
66 | const hit = cache.get(key)
67 | if (hit !== undefined) return hit
68 | const res = await fn(...args)
69 | cache.set(key, res, ttlMs)
70 | return res
71 | }
72 | }
73 |
74 |
```
--------------------------------------------------------------------------------
/docs/.vitepress/theme/style.css:
--------------------------------------------------------------------------------
```css
1 | :root {
2 | --mcp-accent: #0ea5e9;
3 | --mcp-accent-600: #0284c7;
4 | --mcp-bg-soft: color-mix(in oklab, var(--vp-c-bg) 90%, var(--mcp-accent) 10%);
5 | }
6 |
7 | .mcp-kicker {
8 | font-size: .9rem;
9 | color: var(--vp-c-text-2);
10 | text-transform: uppercase;
11 | letter-spacing: .08em;
12 | }
13 |
14 | /* Tabs */
15 | .mcp-tabs {
16 | border: 1px solid var(--vp-c-divider);
17 | border-radius: 10px;
18 | overflow: hidden;
19 | background: var(--vp-c-bg-soft);
20 | }
21 | .mcp-tabs__nav {
22 | display: flex;
23 | gap: 6px;
24 | padding: 8px;
25 | background: var(--vp-c-bg);
26 | border-bottom: 1px solid var(--vp-c-divider);
27 | }
28 | .mcp-tabs__btn {
29 | appearance: none;
30 | border: 1px solid var(--vp-c-divider);
31 | padding: 6px 12px;
32 | border-radius: 8px;
33 | background: var(--vp-c-bg-soft);
34 | color: var(--vp-c-text-1);
35 | cursor: pointer;
36 | font-weight: 500;
37 | }
38 | .mcp-tabs__btn[aria-selected="true"] {
39 | background: var(--mcp-accent);
40 | border-color: var(--mcp-accent);
41 | color: white;
42 | }
43 | .mcp-tabs__panel {
44 | padding: 12px 14px;
45 | }
46 |
47 | /* Utility blocks */
48 | .mcp-callout {
49 | border-left: 3px solid var(--mcp-accent);
50 | padding: 10px 14px;
51 | margin: 10px 0;
52 | background: var(--vp-c-bg-soft);
53 | }
54 |
55 | .mcp-grid {
56 | display: grid;
57 | grid-template-columns: repeat(12, 1fr);
58 | gap: 16px;
59 | }
60 | .mcp-col-6 { grid-column: span 6; }
61 | .mcp-col-12 { grid-column: span 12; }
62 | @media (max-width: 960px) {
63 | .mcp-col-6 { grid-column: span 12; }
64 | }
65 |
66 | .mcp-diagram {
67 | width: 100%;
68 | border: 1px dashed var(--vp-c-divider);
69 | border-radius: 10px;
70 | padding: 12px;
71 | }
72 |
73 | .mcp-cta {
74 | display: inline-flex;
75 | align-items: center;
76 | gap: 8px;
77 | background: var(--mcp-accent);
78 | color: white;
79 | padding: 8px 12px;
80 | border-radius: 8px;
81 | text-decoration: none;
82 | }
83 | .mcp-cta:hover { background: var(--mcp-accent-600); }
84 |
85 |
```
--------------------------------------------------------------------------------
/tests/unit/routing.core.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import '../setup/test-setup.js'
2 | import test from 'node:test'
3 | import assert from 'node:assert/strict'
4 | import { CircuitBreaker, CircuitOpenError } from '../../src/routing/circuit-breaker.js'
5 | import { RetryHandler } from '../../src/routing/retry-handler.js'
6 | import { LoadBalancer } from '../../src/routing/load-balancer.js'
7 |
8 | test('CircuitBreaker opens and recovers', async () => {
9 | const cb = new CircuitBreaker({ failureThreshold: 2, successThreshold: 1, recoveryTimeoutMs: 10 })
10 | const key = 'svc::inst'
11 | await assert.rejects(cb.execute(key, async () => { throw new Error('fail') }))
12 | await assert.rejects(cb.execute(key, async () => { throw new Error('fail') }))
13 | // Now circuit open
14 | await assert.rejects(cb.execute(key, async () => 'ok'), (e: any) => e instanceof CircuitOpenError)
15 | // Wait for half-open
16 | await new Promise((r) => setTimeout(r, 12))
17 | const res = await cb.execute(key, async () => 'ok')
18 | assert.equal(res, 'ok')
19 | })
20 |
21 | test('RetryHandler retries on 5xx and succeeds', async () => {
22 | const rh = new RetryHandler({ maxRetries: 2, baseDelayMs: 1, maxDelayMs: 2, jitter: 'none' })
23 | let n = 0
24 | const res = await rh.execute(async () => {
25 | n++
26 | if (n < 3) { const err: any = new Error('HTTP 500'); err.status = 500; throw err }
27 | return 'ok'
28 | })
29 | assert.equal(res, 'ok')
30 | assert.equal(n, 3)
31 | })
32 |
33 | test('LoadBalancer round-robin selection', () => {
34 | const lb = new LoadBalancer({ strategy: 'round_robin' })
35 | const pool = [ { id: 'a' }, { id: 'b' }, { id: 'c' } ] as any
36 | const chosen = [
37 | lb.select('svc', pool)!.id,
38 | lb.select('svc', pool)!.id,
39 | lb.select('svc', pool)!.id,
40 | lb.select('svc', pool)!.id,
41 | ]
42 | assert.deepEqual(chosen, ['a','b','c','a'])
43 | })
44 |
45 |
```
--------------------------------------------------------------------------------
/src/utils/time.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Date/time utilities, duration parsing and timezone helpers.
3 | */
4 |
5 | export function now(): number {
6 | if (typeof performance !== 'undefined' && typeof performance.now === 'function') return performance.now()
7 | return Date.now()
8 | }
9 |
10 | export function sleep(ms: number): Promise<void> {
11 | return new Promise((resolve) => setTimeout(resolve, ms))
12 | }
13 |
14 | /** Parses durations like "500ms", "2s", "5m", "1h", "1d". */
15 | export function parseDuration(input: string): number {
16 | const m = String(input).trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/i)
17 | if (!m) throw new Error('Invalid duration')
18 | const n = parseFloat(m[1])
19 | const u = m[2].toLowerCase()
20 | switch (u) {
21 | case 'ms':
22 | return n
23 | case 's':
24 | return n * 1000
25 | case 'm':
26 | return n * 60_000
27 | case 'h':
28 | return n * 3_600_000
29 | case 'd':
30 | return n * 86_400_000
31 | default:
32 | throw new Error('Invalid duration unit')
33 | }
34 | }
35 |
36 | export function formatDuration(ms: number): string {
37 | if (ms < 1000) return `${ms}ms`
38 | if (ms < 60_000) return `${(ms / 1000).toFixed(ms % 1000 === 0 ? 0 : 2)}s`
39 | if (ms < 3_600_000) return `${(ms / 60_000).toFixed(ms % 60_000 === 0 ? 0 : 2)}m`
40 | if (ms < 86_400_000) return `${(ms / 3_600_000).toFixed(ms % 3_600_000 === 0 ? 0 : 2)}h`
41 | return `${(ms / 86_400_000).toFixed(ms % 86_400_000 === 0 ? 0 : 2)}d`
42 | }
43 |
44 | export function toUTC(date: Date): string {
45 | return date.toISOString()
46 | }
47 |
48 | export function fromUnix(seconds: number): Date {
49 | return new Date(seconds * 1000)
50 | }
51 |
52 | export function formatInTimeZone(date: Date, timeZone: string, opts?: Intl.DateTimeFormatOptions): string {
53 | const formatter = new Intl.DateTimeFormat('en-US', { timeZone, ...opts })
54 | return formatter.format(date)
55 | }
56 |
57 |
```
--------------------------------------------------------------------------------
/tests/perf/perf.auth-and-routing.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import '../setup/test-setup.js'
2 | import test from 'node:test'
3 | import { performance } from 'node:perf_hooks'
4 | import { MultiAuthManager } from '../../src/auth/multi-auth-manager.js'
5 | import { AuthStrategy } from '../../src/types/config.js'
6 | import { createMockServer } from '../utils/mock-http.js'
7 | import { RequestRouter } from '../../src/modules/request-router.js'
8 | import { CapabilityAggregator } from '../../src/modules/capability-aggregator.js'
9 |
10 | test('Perf: validateClientToken and routeCallTool throughput (smoke)', async (t) => {
11 | const mam = new MultiAuthManager({ authorization_endpoint: 'http://a', token_endpoint: 'http://t', client_id: 'x', redirect_uri: 'http://l', scopes: ['openid'] } as any)
12 | mam.registerServerAuth('s', AuthStrategy.BYPASS_AUTH)
13 | const N = 1000
14 | const t0 = performance.now()
15 | for (let i = 0; i < N; i++) await mam.validateClientToken('opaque-token')
16 | const dt = performance.now() - t0
17 | t.diagnostic(`validateClientToken x${N}: ${Math.round(dt)}ms (${Math.round((N/dt)*1000)} ops/sec)`) // eslint-disable-line
18 |
19 | const upstream = await createMockServer([
20 | { method: 'POST', path: '/mcp/tools/call', handler: () => ({ body: { content: { ok: true } } }) },
21 | ])
22 | const servers = new Map<string, any>([[ 's', { id: 's', type: 'node', endpoint: upstream.url, config: {} as any, status: 'running', lastHealthCheck: 0 } ]])
23 | const rr = new RequestRouter(servers as any, new CapabilityAggregator())
24 | const M = 200
25 | const t1 = performance.now()
26 | for (let i = 0; i < M; i++) await rr.routeCallTool({ name: 's.ping' })
27 | const dt2 = performance.now() - t1
28 | t.diagnostic(`routeCallTool x${M}: ${Math.round(dt2)}ms (${Math.round((M/dt2)*1000)} rps)`) // eslint-disable-line
29 | await upstream.close()
30 | })
31 |
32 |
```
--------------------------------------------------------------------------------
/docs/public/logo.svg:
--------------------------------------------------------------------------------
```
1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2 | <svg
3 | viewBox="0 0 120 120"
4 | version="1.1"
5 | id="svg4"
6 | sodipodi:docname="logo.svg"
7 | inkscape:export-filename="logo.png"
8 | inkscape:export-xdpi="96"
9 | inkscape:export-ydpi="96"
10 | inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
11 | xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
12 | xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
13 | xmlns="http://www.w3.org/2000/svg"
14 | xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
15 | id="namedview4"
16 | pagecolor="#ffffff"
17 | bordercolor="#000000"
18 | borderopacity="0.25"
19 | inkscape:showpageshadow="2"
20 | inkscape:pageopacity="0.0"
21 | inkscape:pagecheckerboard="0"
22 | inkscape:deskcolor="#d1d1d1"
23 | inkscape:zoom="2.9371694"
24 | inkscape:cx="38.131951"
25 | inkscape:cy="32.68453"
26 | inkscape:window-width="1512"
27 | inkscape:window-height="945"
28 | inkscape:window-x="0"
29 | inkscape:window-y="37"
30 | inkscape:window-maximized="0"
31 | inkscape:current-layer="svg4" />
32 | <defs
33 | id="defs2">
34 | <linearGradient
35 | id="g"
36 | x1="0"
37 | y1="0"
38 | x2="1"
39 | y2="1">
40 | <stop
41 | offset="0%"
42 | stop-color="#7C3AED"
43 | id="stop1" />
44 | <stop
45 | offset="100%"
46 | stop-color="#06B6D4"
47 | id="stop2" />
48 | </linearGradient>
49 | </defs>
50 | <circle
51 | cx="60"
52 | cy="60"
53 | r="56"
54 | fill="url(#g)"
55 | id="circle2" />
56 | <g
57 | fill="#fff"
58 | id="g4">
59 | <circle
60 | cx="60"
61 | cy="60"
62 | r="10"
63 | id="circle3" />
64 | <path
65 | d="M60 20 a40 40 0 0 1 0 80"
66 | fill="none"
67 | stroke="#fff"
68 | stroke-width="6"
69 | id="path3" />
70 | <path
71 | d="M20 60 a40 40 0 0 1 80 0"
72 | fill="none"
73 | stroke="#fff"
74 | stroke-width="6"
75 | id="path4" />
76 | </g>
77 |
78 | Sorry, your browser does not support inline SVG.
79 | </svg>
80 |
```
--------------------------------------------------------------------------------
/docs/configuration/examples.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | title: Configuration Examples
3 | ---
4 |
5 | # Configuration Examples
6 |
7 | Real-world scenarios to use as starting points.
8 |
9 | ## Minimal Local Aggregation
10 |
11 | ```yaml
12 | hosting:
13 | port: 3000
14 | servers:
15 | - id: search
16 | type: local
17 | auth_strategy: master_oauth
18 | config: { port: 4100 }
19 | ```
20 |
21 | ## Mixed Auth Strategies (GitHub Delegation)
22 |
23 | ```yaml
24 | hosting:
25 | port: 3000
26 | base_url: https://your.domain
27 | servers:
28 | - id: search
29 | type: local
30 | auth_strategy: master_oauth
31 | config: { port: 4100 }
32 | - id: github-tools
33 | type: local
34 | auth_strategy: delegate_oauth
35 | auth_config:
36 | provider: github
37 | authorization_endpoint: https://github.com/login/oauth/authorize
38 | token_endpoint: https://github.com/login/oauth/access_token
39 | client_id: ${GITHUB_CLIENT_ID}
40 | client_secret: env:GITHUB_CLIENT_SECRET
41 | scopes: [repo, read:user]
42 | config: { port: 4010 }
43 | routing:
44 | retry:
45 | maxRetries: 2
46 | baseDelayMs: 200
47 | circuitBreaker:
48 | failureThreshold: 5
49 | successThreshold: 2
50 | recoveryTimeoutMs: 10000
51 | ```
52 |
53 | ## Dockerized Production
54 |
55 | ```yaml
56 | hosting:
57 | port: 3000
58 | servers:
59 | - id: search
60 | type: local
61 | auth_strategy: bypass_auth
62 | config: { url: http://search:4100 }
63 | ```
64 |
65 | Run with env:
66 |
67 | ```bash
68 | TOKEN_ENC_KEY=... MASTER_BASE_URL=https://master.example.com docker compose up -d
69 | ```
70 |
71 | ## Multi-tenant (Advanced)
72 |
73 | In multi-tenant deployments, use separate configs per tenant and map them under different base URLs or headers. Keep secrets isolated and rotate regularly.
74 |
75 | ```yaml
76 | # tenant-a.yaml
77 | hosting: { port: 3001 }
78 | servers: [ { id: search, type: local, auth_strategy: master_oauth, config: { port: 4110 } } ]
79 |
80 | # tenant-b.yaml
81 | hosting: { port: 3002 }
82 | servers: [ { id: search, type: local, auth_strategy: master_oauth, config: { port: 4120 } } ]
83 | ```
84 |
85 |
```
--------------------------------------------------------------------------------
/tests/servers/test-auth-simple.js:
--------------------------------------------------------------------------------
```javascript
1 | import { MultiAuthManager } from '../../src/auth/multi-auth-manager.js'
2 | import { AuthStrategy } from '../../src/types/config.js'
3 | import '../setup/test-setup.js'
4 |
5 | const masterCfg = {
6 | authorization_endpoint: 'http://localhost/auth',
7 | token_endpoint: 'http://localhost/token',
8 | client_id: 'master',
9 | redirect_uri: 'http://localhost/cb',
10 | scopes: ['openid'],
11 | }
12 |
13 | async function testAuth() {
14 | console.log('Testing MultiAuthManager...')
15 |
16 | try {
17 | const mam = new MultiAuthManager(masterCfg)
18 | mam.registerServerAuth('srv1', AuthStrategy.MASTER_OAUTH)
19 | const h = await mam.prepareAuthForBackend('srv1', 'CLIENT')
20 |
21 | if (h.Authorization === 'Bearer CLIENT') {
22 | console.log('✅ Test 1 passed: Master OAuth pass-through')
23 | } else {
24 | console.log('❌ Test 1 failed:', h)
25 | }
26 |
27 | // Test delegation
28 | mam.registerServerAuth('srv2', AuthStrategy.DELEGATE_OAUTH, {
29 | provider: 'custom',
30 | authorization_endpoint: 'http://p/auth',
31 | token_endpoint: 'http://p/token',
32 | client_id: 'c'
33 | })
34 | const d = await mam.prepareAuthForBackend('srv2', 'CLIENT')
35 |
36 | if (d.type === 'oauth_delegation') {
37 | console.log('✅ Test 2 passed: OAuth delegation')
38 | } else {
39 | console.log('❌ Test 2 failed:', d)
40 | }
41 |
42 | // Test storage
43 | await mam.storeDelegatedToken('CLIENT', 'srv', { access_token: 'S', expires_at: Date.now() + 1000, scope: [] })
44 | const tok = await mam.getStoredServerToken('srv', 'CLIENT')
45 |
46 | if (tok === 'S') {
47 | console.log('✅ Test 3 passed: Token storage')
48 | } else {
49 | console.log('❌ Test 3 failed:', tok)
50 | }
51 |
52 | console.log('All tests completed successfully!')
53 |
54 | } catch (error) {
55 | console.error('Test failed:', error)
56 | console.error('Stack:', error.stack)
57 | }
58 | }
59 |
60 | testAuth()
```
--------------------------------------------------------------------------------
/tests/e2e/flow-controller.express.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import '../setup/test-setup.js'
2 | import test from 'node:test'
3 | import assert from 'node:assert/strict'
4 | import { OAuthFlowController } from '../../src/oauth/flow-controller.js'
5 | import { FakeExpressApp } from '../utils/fake-express.js'
6 | import { createMockServer } from '../utils/mock-http.js'
7 |
8 | test('OAuthFlowController Express flow: authorize -> token -> callback', async () => {
9 | const tokenSrv = await createMockServer([
10 | { method: 'POST', path: '/token', handler: (_req, _body) => ({ body: { access_token: 'AT', expires_in: 60, scope: 'openid' } }) },
11 | ])
12 | try {
13 | const cfg = {
14 | master_oauth: {
15 | authorization_endpoint: tokenSrv.url + '/authorize',
16 | token_endpoint: tokenSrv.url + '/token',
17 | client_id: 'cid',
18 | redirect_uri: 'http://localhost/oauth/callback',
19 | scopes: ['openid'],
20 | },
21 | hosting: { platform: 'node', base_url: 'http://localhost' },
22 | servers: [],
23 | }
24 | const ctrl = new OAuthFlowController({ getConfig: () => cfg as any })
25 | const app = new FakeExpressApp()
26 | ctrl.registerExpress(app as any)
27 |
28 | const auth = await app.invoke('GET', '/oauth/authorize', { query: { provider: 'master' } })
29 | assert.equal(auth.status, 200)
30 | assert.match(String(auth.body), /Redirecting/)
31 | const m = String(auth.body).match(/url=([^"\s]+)/)
32 | assert.ok(m && m[1])
33 | const urlStr = m[1].replace(/&/g, '&') // Decode HTML entities
34 | const url = new URL(urlStr)
35 | const state = url.searchParams.get('state')!
36 | assert.ok(state)
37 |
38 | const cb = await app.invoke('GET', '/oauth/callback', { query: { state, code: 'good', provider: 'master' } })
39 | assert.equal(cb.status, 200)
40 | assert.match(String(cb.body), /Authorization complete|You may close this window/)
41 | } finally {
42 | await tokenSrv.close()
43 | }
44 | })
45 |
46 |
```
--------------------------------------------------------------------------------
/deploy/docker/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # syntax=docker/dockerfile:1.7
2 |
3 | ARG NODE_VERSION=20
4 | FROM node:${NODE_VERSION}-slim AS base
5 | ENV PNPM_HOME=/usr/local/share/pnpm \
6 | NODE_ENV=production \
7 | APP_HOME=/app
8 | WORKDIR ${APP_HOME}
9 |
10 | # ---------- Builder ----------
11 | FROM base AS builder
12 | ENV NODE_ENV=development
13 | SHELL ["/bin/sh", "-lc"]
14 | RUN --mount=type=cache,target=/var/cache/apt \
15 | --mount=type=cache,target=/var/lib/apt/lists \
16 | apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
17 | COPY package.json package-lock.json ./
18 | RUN --mount=type=cache,target=/root/.npm npm ci
19 | COPY tsconfig*.json ./
20 | COPY src ./src
21 | COPY config ./config
22 | COPY static ./static
23 | RUN npm run build:node
24 |
25 | # ---------- Runtime ----------
26 | FROM base AS runtime
27 | SHELL ["/bin/sh", "-lc"]
28 | # Create non-root user
29 | RUN useradd -r -u 10001 -g root nodejs && mkdir -p /app && chown -R nodejs:root /app
30 |
31 | # Only production deps
32 | COPY package.json package-lock.json ./
33 | RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts && npm cache clean --force
34 |
35 | # Copy build artifacts and minimal runtime assets
36 | COPY --from=builder /app/dist/node ./dist/node
37 | COPY --from=builder /app/config ./config
38 | COPY --from=builder /app/static ./static
39 | COPY deploy/docker/entrypoint.sh /entrypoint.sh
40 | RUN chmod +x /entrypoint.sh
41 |
42 | ENV NODE_ENV=production \
43 | LOG_FORMAT=json \
44 | PORT=3000 \
45 | MASTER_HOSTING_PLATFORM=node
46 |
47 | EXPOSE 3000
48 | USER nodejs
49 | ENTRYPOINT ["/entrypoint.sh"]
50 | CMD ["node", "dist/node/index.js"]
51 |
52 | # Healthcheck without adding curl/wget: use Node's http module
53 | HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
54 | CMD node -e "const http=require('http');const p=process.env.PORT||3000;http.get({host:'127.0.0.1',port:p,path:'/health'},r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
55 |
56 |
```
--------------------------------------------------------------------------------
/src/utils/dev.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Development and debugging helpers.
3 | */
4 |
5 | export function isDev(): boolean {
6 | const env = (globalThis as any)?.process?.env
7 | return env?.NODE_ENV !== 'production'
8 | }
9 |
10 | export function debugLog(...args: unknown[]): void {
11 | if (!isDev()) return
12 | // eslint-disable-next-line no-console
13 | console.debug('[DEV]', ...args)
14 | }
15 |
16 | export function invariant(condition: unknown, message = 'Invariant failed'): asserts condition {
17 | if (!condition) throw new Error(message)
18 | }
19 |
20 | export function assertNever(x: never, message = 'Unexpected object'): never {
21 | throw new Error(`${message}: ${String(x)}`)
22 | }
23 |
24 | export function pretty(value: unknown): string {
25 | try {
26 | const util = (globalThis as any).require ? (globalThis as any).require('node:util') : undefined
27 | if (util?.inspect) return util.inspect(value, { depth: 4, colors: true })
28 | } catch {
29 | // ignore
30 | }
31 | try {
32 | return JSON.stringify(value, null, 2)
33 | } catch {
34 | return String(value)
35 | }
36 | }
37 |
38 | export function deprecate(fn: (...args: any[]) => any, message: string): (...args: any[]) => any {
39 | let warned = false
40 | return (...args: any[]) => {
41 | if (!warned) {
42 | warned = true
43 | // eslint-disable-next-line no-console
44 | console.warn(`[DEPRECATED] ${message}`)
45 | }
46 | return fn(...args)
47 | }
48 | }
49 |
50 | export function withTiming<T>(name: string, fn: () => T): { result: T; durationMs: number; name: string } {
51 | const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
52 | const result = fn()
53 | const durationMs = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start
54 | return { result, durationMs, name }
55 | }
56 |
57 | export function sleep(ms: number): Promise<void> {
58 | return new Promise((res) => setTimeout(res, ms))
59 | }
60 |
61 | export function mockRandom(fn: () => number): () => void {
62 | const original = Math.random
63 | ;(Math as any).random = fn
64 | return () => {
65 | ;(Math as any).random = original
66 | }
67 | }
68 |
```
--------------------------------------------------------------------------------
/tests/servers/test-master-mcp.js:
--------------------------------------------------------------------------------
```javascript
1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
3 |
4 | async function runTest() {
5 | try {
6 | console.log('Testing Master MCP Server...')
7 |
8 | // Create a streamable HTTP transport to connect to our MCP server
9 | const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3005/mcp'))
10 |
11 | // Create the MCP client
12 | const client = new Client({
13 | name: 'master-mcp-test-client',
14 | version: '1.0.0'
15 | })
16 |
17 | // Initialize the client
18 | await client.connect(transport)
19 | console.log('✅ Server initialized')
20 | console.log('Server info:', client.getServerVersion())
21 | console.log('Protocol version:', client.getServerCapabilities())
22 |
23 | // List tools
24 | console.log('\n--- Testing tools/list ---')
25 | const toolsResult = await client.listTools({})
26 | console.log('✅ tools/list successful')
27 | console.log('Number of tools:', toolsResult.tools.length)
28 | console.log('Tools:', toolsResult.tools.map(t => t.name))
29 |
30 | // List resources
31 | console.log('\n--- Testing resources/list ---')
32 | const resourcesResult = await client.listResources({})
33 | console.log('✅ resources/list successful')
34 | console.log('Number of resources:', resourcesResult.resources.length)
35 | console.log('Resources:', resourcesResult.resources.map(r => r.uri))
36 |
37 | // Test ping
38 | console.log('\n--- Testing ping ---')
39 | const pingResult = await client.ping()
40 | console.log('✅ ping successful')
41 | console.log('Ping result:', pingResult)
42 |
43 | // Close the connection
44 | await client.close()
45 | console.log('\n✅ Disconnected from MCP server')
46 | console.log('\n🎉 All tests completed successfully!')
47 |
48 | } catch (error) {
49 | console.error('❌ Test failed:', error)
50 | console.error('Error stack:', error.stack)
51 | }
52 | }
53 |
54 | // Run the test
55 | runTest()
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # syntax=docker/dockerfile:1.7
2 |
3 | # Multi-stage build for production and dev with multi-arch support.
4 | # Targets:
5 | # - base: common base image
6 | # - deps: install all dependencies (including dev)
7 | # - build: compile TypeScript to dist
8 | # - prod-deps: install only production deps
9 | # - runner: minimal runtime image
10 | # - dev: development image with hot-reload support
11 |
12 | ARG NODE_VERSION=20.14.0
13 | ARG ALPINE_VERSION=3.19
14 |
15 | FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS base
16 | ENV NODE_ENV=production \
17 | APP_HOME=/app \
18 | PNPM_HOME=/pnpm
19 | WORKDIR ${APP_HOME}
20 | RUN addgroup -g 1001 -S nodejs && adduser -S node -u 1001 -G nodejs
21 |
22 | FROM base AS deps
23 | ENV NODE_ENV=development
24 | COPY package*.json ./
25 | # Prefer npm ci for reproducible installs
26 | RUN --mount=type=cache,target=/root/.npm \
27 | npm ci
28 |
29 | FROM deps AS build
30 | COPY tsconfig*.json ./
31 | COPY src ./src
32 | COPY config ./config
33 | COPY static ./static
34 | RUN npm run build
35 |
36 | FROM deps AS prod-deps
37 | ENV NODE_ENV=production
38 | RUN --mount=type=cache,target=/root/.npm \
39 | npm prune --omit=dev
40 |
41 | FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS runner
42 | ENV NODE_ENV=production \
43 | APP_HOME=/app \
44 | PORT=3000
45 | WORKDIR ${APP_HOME}
46 | # Busybox wget is enough for healthcheck; curl can be used if preferred
47 | RUN apk add --no-cache wget
48 |
49 | # Copy built app and production deps
50 | COPY --from=prod-deps ${APP_HOME}/node_modules ./node_modules
51 | COPY --from=build ${APP_HOME}/dist ./dist
52 | COPY package*.json ./
53 | COPY config ./config
54 | COPY static ./static
55 |
56 | # Use non-root user
57 | USER 1001
58 |
59 | EXPOSE 3000
60 | HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
61 | CMD wget -qO- http://127.0.0.1:${PORT}/health || exit 1
62 |
63 | # Default start command
64 | CMD ["node", "dist/node/index.js"]
65 |
66 | # Development image with hot reloading (nodemon)
67 | FROM deps AS dev
68 | ENV NODE_ENV=development \
69 | PORT=3000
70 | RUN npm pkg set scripts.dev:watch="nodemon --watch src --ext ts,tsx,json --exec 'node --loader ts-node/esm src/index.ts'" && \
71 | npm i -D nodemon@^3
72 | CMD ["npm", "run", "dev:watch"]
73 |
74 |
```
--------------------------------------------------------------------------------
/src/oauth/web-interface.ts:
--------------------------------------------------------------------------------
```typescript
1 | export class WebInterface {
2 | // Minimal, accessible pages for success and error. CSS served from /static/oauth/style.css
3 | renderRedirectPage(providerName: string, redirectUrl: string): string {
4 | const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
5 | return `<!doctype html>
6 | <html lang="en">
7 | <head>
8 | <meta charset="utf-8" />
9 | <meta name="viewport" content="width=device-width, initial-scale=1" />
10 | <title>Continue to ${esc(providerName)}</title>
11 | <link rel="stylesheet" href="/static/oauth/style.css" />
12 | <meta http-equiv="refresh" content="0;url=${esc(redirectUrl)}" />
13 | <script>location.replace(${JSON.stringify(redirectUrl)})</script>
14 | </head>
15 | <body>
16 | <main class="container">
17 | <h1>Redirecting…</h1>
18 | <p>Taking you to ${esc(providerName)} to sign in.</p>
19 | <p><a class="button" href="${esc(redirectUrl)}">Continue</a></p>
20 | </main>
21 | </body>
22 | </html>`
23 | }
24 |
25 | renderSuccessPage(message = 'Authorization completed successfully.'): string {
26 | const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
27 | return `<!doctype html>
28 | <html lang="en">
29 | <head>
30 | <meta charset="utf-8" />
31 | <meta name="viewport" content="width=device-width, initial-scale=1" />
32 | <title>OAuth Success</title>
33 | <link rel="stylesheet" href="/static/oauth/style.css" />
34 | </head>
35 | <body>
36 | <main class="container">
37 | <h1>Success</h1>
38 | <p>${esc(message)}</p>
39 | </main>
40 | </body>
41 | </html>`
42 | }
43 |
44 | renderErrorPage(error = 'Authorization failed.'): string {
45 | const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
46 | return `<!doctype html>
47 | <html lang="en">
48 | <head>
49 | <meta charset="utf-8" />
50 | <meta name="viewport" content="width=device-width, initial-scale=1" />
51 | <title>OAuth Error</title>
52 | <link rel="stylesheet" href="/static/oauth/style.css" />
53 | </head>
54 | <body>
55 | <main class="container error">
56 | <h1>Authorization Error</h1>
57 | <p>${esc(error)}</p>
58 | </main>
59 | </body>
60 | </html>`
61 | }
62 | }
63 |
64 |
```
--------------------------------------------------------------------------------
/tests/servers/test-streaming.js:
--------------------------------------------------------------------------------
```javascript
1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
3 |
4 | async function runStreamingTest() {
5 | try {
6 | console.log('Testing Master MCP Server with HTTP Streaming...')
7 |
8 | // Create a streamable HTTP transport to connect to our MCP server
9 | const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3005/mcp'))
10 |
11 | // Create the MCP client
12 | const client = new Client({
13 | name: 'master-mcp-streaming-test-client',
14 | version: '1.0.0'
15 | })
16 |
17 | // Initialize the client
18 | await client.connect(transport)
19 | console.log('✅ Server initialized with streaming transport')
20 | console.log('Server info:', client.getServerVersion())
21 | console.log('Server capabilities:', client.getServerCapabilities())
22 |
23 | // List tools using streaming
24 | console.log('\n--- Testing tools/list with streaming ---')
25 | const toolsResult = await client.listTools({})
26 | console.log('✅ tools/list successful with streaming')
27 | console.log('Number of tools:', toolsResult.tools.length)
28 |
29 | // List resources using streaming
30 | console.log('\n--- Testing resources/list with streaming ---')
31 | const resourcesResult = await client.listResources({})
32 | console.log('✅ resources/list successful with streaming')
33 | console.log('Number of resources:', resourcesResult.resources.length)
34 |
35 | // Test ping
36 | console.log('\n--- Testing ping with streaming ---')
37 | const pingResult = await client.ping()
38 | console.log('✅ ping successful with streaming')
39 | console.log('Ping result:', pingResult)
40 |
41 | // Close the connection
42 | await client.close()
43 | console.log('\n✅ Disconnected from MCP server')
44 | console.log('\n🎉 All streaming tests completed successfully!')
45 |
46 | } catch (error) {
47 | console.error('❌ Streaming test failed:', error)
48 | console.error('Error stack:', error.stack)
49 | }
50 | }
51 |
52 | // Run the streaming test
53 | runStreamingTest()
```
--------------------------------------------------------------------------------
/docs/guides/server-management.md:
--------------------------------------------------------------------------------
```markdown
1 | # Server Management
2 |
3 | The `MasterServer` orchestrates backend servers and exposes convenience APIs.
4 |
5 | ## Key APIs
6 |
7 | - `startFromConfig(config, clientToken?)`: Load and health-check backends, discover capabilities
8 | - `performHealthChecks(clientToken?)`: Returns `{ [serverId]: boolean }`
9 | - `restartServer(id)`: Restarts a backend (when supported)
10 | - `unloadAll()`: Stops and clears all backends
11 | - `getRouter()`: Access to `RequestRouter`
12 | - `getAggregatedTools()/getAggregatedResources()`: Current aggregated definitions
13 | - `attachAuthManager(multiAuth)`: Injects a `MultiAuthManager`
14 | - `getOAuthFlowController()`: Provides an OAuth controller to mount in your runtime
15 |
16 | ## Node Runtime
17 |
18 | `src/index.ts` creates an Express app exposing health, metrics, OAuth endpoints, and MCP HTTP endpoints. Use `npm run dev` during development.
19 |
20 | ## Workers Runtime
21 |
22 | `src/runtime/worker.ts` exports a `fetch` handler integrating the protocol and OAuth flows. Configure via `deploy/cloudflare/wrangler.toml`.
23 |
24 | ## Adding Backends by Source
25 |
26 | > Note: Some origin types (git/npm/pypi/docker) are treated as config-driven endpoints in the current loader. Provide an explicit `url` or `port` for the running backend.
27 |
28 | <CodeTabs :options="[
29 | { label: 'Local', value: 'local' },
30 | { label: 'Git', value: 'git' },
31 | { label: 'NPM', value: 'npm' },
32 | { label: 'Docker', value: 'docker' }
33 | ]">
34 | <template #local>
35 |
36 | ```yaml
37 | servers:
38 | - id: search
39 | type: local
40 | auth_strategy: master_oauth
41 | config: { port: 4100 }
42 | ```
43 |
44 | </template>
45 | <template #git>
46 |
47 | ```yaml
48 | servers:
49 | - id: tools-from-git
50 | type: git
51 | auth_strategy: bypass_auth
52 | config:
53 | url: http://git-tools.internal:4010
54 | ```
55 |
56 | </template>
57 | <template #npm>
58 |
59 | ```yaml
60 | servers:
61 | - id: npm-tools
62 | type: npm
63 | auth_strategy: proxy_oauth
64 | config:
65 | url: http://npm-tools:4020
66 | ```
67 |
68 | </template>
69 | <template #docker>
70 |
71 | ```yaml
72 | servers:
73 | - id: containerized
74 | type: docker
75 | auth_strategy: master_oauth
76 | config:
77 | url: http://containerized:4030
78 | ```
79 |
80 | </template>
81 | </CodeTabs>
82 |
83 |
```
--------------------------------------------------------------------------------
/src/oauth/state-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | // State Manager for OAuth CSRF protection
2 | // Generates random opaque state tokens and tracks associated payload with TTL
3 |
4 | export interface OAuthStatePayload {
5 | provider?: string
6 | serverId?: string
7 | clientToken?: string
8 | returnTo?: string
9 | issuedAt: number
10 | }
11 |
12 | export interface StateRecord {
13 | payload: OAuthStatePayload
14 | expiresAt: number
15 | }
16 |
17 | export interface StateManagerOptions {
18 | ttlMs?: number
19 | }
20 |
21 | function getCrypto(): any {
22 | const g: any = globalThis as any
23 | if (g.crypto && g.crypto.subtle && g.crypto.getRandomValues) return g.crypto as any
24 | try {
25 | // eslint-disable-next-line @typescript-eslint/no-var-requires
26 | const nodeCrypto = require('node:crypto')
27 | return nodeCrypto.webcrypto as any
28 | } catch {
29 | throw new Error('Secure crypto not available in this environment')
30 | }
31 | }
32 |
33 | function randomId(bytes = 32): string {
34 | const crypto = getCrypto()
35 | const arr = new Uint8Array(bytes)
36 | crypto.getRandomValues(arr)
37 | let str = ''
38 | for (let i = 0; i < arr.length; i++) str += arr[i].toString(16).padStart(2, '0')
39 | return str
40 | }
41 |
42 | export class StateManager {
43 | private readonly store = new Map<string, StateRecord>()
44 | private readonly ttl: number
45 |
46 | constructor(options?: StateManagerOptions) {
47 | this.ttl = options?.ttlMs ?? 10 * 60_000
48 | }
49 |
50 | create(payload: Omit<OAuthStatePayload, 'issuedAt'>): string {
51 | const state = randomId(32)
52 | const now = Date.now()
53 | this.store.set(state, { payload: { ...payload, issuedAt: now }, expiresAt: now + this.ttl })
54 | return state
55 | }
56 |
57 | consume(state: string): OAuthStatePayload | null {
58 | const rec = this.store.get(state)
59 | if (!rec) return null
60 | this.store.delete(state)
61 | if (rec.expiresAt <= Date.now()) return null
62 | return rec.payload
63 | }
64 |
65 | peek(state: string): OAuthStatePayload | null {
66 | const rec = this.store.get(state)
67 | if (!rec || rec.expiresAt <= Date.now()) return null
68 | return rec.payload
69 | }
70 |
71 | cleanup(): void {
72 | const now = Date.now()
73 | for (const [k, v] of this.store) if (v.expiresAt <= now) this.store.delete(k)
74 | }
75 | }
76 |
```
--------------------------------------------------------------------------------
/tests/utils/mock-http.ts:
--------------------------------------------------------------------------------
```typescript
1 | import http from 'node:http'
2 |
3 | export type Handler = (req: http.IncomingMessage, body: any) => { status?: number; headers?: Record<string, string>; body?: any }
4 |
5 | export interface Route {
6 | method: string
7 | path: string | RegExp
8 | handler: Handler
9 | }
10 |
11 | export interface MockServer {
12 | url: string
13 | port: number
14 | close: () => Promise<void>
15 | }
16 |
17 | export function createMockServer(routes: Route[], opts?: { port?: number }): Promise<MockServer> {
18 | const server = http.createServer(async (req, res) => {
19 | const url = new URL(req.url || '/', `http://${req.headers.host}`)
20 | const chunks: Buffer[] = []
21 | for await (const c of req) chunks.push(c as Buffer)
22 | let body: any = Buffer.concat(chunks).toString('utf8')
23 | const ct = (req.headers['content-type'] || '').toString()
24 | try {
25 | if (ct.includes('application/json') && body) body = JSON.parse(body)
26 | else if (ct.includes('application/x-www-form-urlencoded') && body) body = Object.fromEntries(new URLSearchParams(body))
27 | } catch {
28 | // leave as raw string
29 | }
30 |
31 | const route = routes.find((r) => r.method.toUpperCase() === (req.method || '').toUpperCase() &&
32 | (typeof r.path === 'string' ? r.path === url.pathname : r.path.test(url.pathname)))
33 |
34 | const result = route ? route.handler(req, body) : { status: 404, body: { error: 'not found' } }
35 | const status = result.status ?? 200
36 | const headers = result.headers ?? { 'content-type': 'application/json' }
37 | const payload = result.body ?? { ok: true }
38 | res.statusCode = status
39 | for (const [k, v] of Object.entries(headers)) res.setHeader(k, v)
40 | res.end(typeof payload === 'string' || Buffer.isBuffer(payload) ? payload : JSON.stringify(payload))
41 | })
42 |
43 | return new Promise((resolve) => {
44 | server.listen(opts?.port ?? 0, () => {
45 | const addr = server.address()
46 | const port = typeof addr === 'object' && addr ? addr.port : (opts?.port ?? 0)
47 | resolve({
48 | url: `http://localhost:${port}`,
49 | port,
50 | close: () => new Promise((r) => server.close(() => r())),
51 | })
52 | })
53 | })
54 | }
55 |
56 |
```
--------------------------------------------------------------------------------
/src/routing/load-balancer.ts:
--------------------------------------------------------------------------------
```typescript
1 | export type LoadBalancingStrategy = 'round_robin' | 'weighted' | 'health'
2 |
3 | export interface LoadBalancingInstance {
4 | id: string
5 | weight?: number
6 | healthScore?: number // 0..100; higher is better
7 | }
8 |
9 | export interface LoadBalancerOptions {
10 | strategy: LoadBalancingStrategy
11 | }
12 |
13 | export class LoadBalancer {
14 | private readonly opts: Required<LoadBalancerOptions>
15 | private rrIndex: Map<string, number> = new Map()
16 |
17 | constructor(options?: Partial<LoadBalancerOptions>) {
18 | this.opts = { strategy: options?.strategy ?? 'round_robin' }
19 | }
20 |
21 | select<T extends LoadBalancingInstance>(key: string, instances: T[]): T | undefined {
22 | if (!instances.length) return undefined
23 | switch (this.opts.strategy) {
24 | case 'weighted':
25 | return this.selectWeighted(instances)
26 | case 'health':
27 | return this.selectHealth(instances, key)
28 | case 'round_robin':
29 | default:
30 | return this.selectRoundRobin(key, instances)
31 | }
32 | }
33 |
34 | private selectRoundRobin<T extends LoadBalancingInstance>(key: string, instances: T[]): T {
35 | const idx = this.rrIndex.get(key) ?? 0
36 | const chosen = instances[idx % instances.length]
37 | this.rrIndex.set(key, (idx + 1) % instances.length)
38 | return chosen
39 | }
40 |
41 | private selectWeighted<T extends LoadBalancingInstance>(instances: T[]): T {
42 | const weights = instances.map((i) => Math.max(1, Math.floor(i.weight ?? 1)))
43 | const total = weights.reduce((a, b) => a + b, 0)
44 | let r = Math.random() * total
45 | for (let i = 0; i < instances.length; i++) {
46 | if (r < weights[i]) return instances[i]
47 | r -= weights[i]
48 | }
49 | return instances[0]
50 | }
51 |
52 | private selectHealth<T extends LoadBalancingInstance>(instances: T[], key: string): T {
53 | // Choose highest health; tie-break with RR for stability
54 | const sorted = [...instances].sort((a, b) => (b.healthScore ?? 0) - (a.healthScore ?? 0))
55 | const topScore = sorted[0].healthScore ?? 0
56 | const top = sorted.filter((i) => (i.healthScore ?? 0) === topScore)
57 | if (top.length === 1) return top[0]
58 | return this.selectRoundRobin(key + '::health', top)
59 | }
60 | }
61 |
```
--------------------------------------------------------------------------------
/tests/_utils/test-server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import http from 'node:http'
2 |
3 | export interface RouteHandler {
4 | (req: http.IncomingMessage, body: string | undefined): { status?: number; headers?: Record<string, string>; body?: any }
5 | }
6 |
7 | export interface TestServer {
8 | url: string
9 | port: number
10 | close: () => Promise<void>
11 | register: (method: string, path: string, handler: RouteHandler) => void
12 | }
13 |
14 | export function createTestServer(): Promise<TestServer> {
15 | const routes = new Map<string, RouteHandler>()
16 | const server = http.createServer(async (req, res) => {
17 | try {
18 | const url = new URL(req.url || '/', 'http://localhost')
19 | const key = `${(req.method || 'GET').toUpperCase()} ${url.pathname}`
20 | let body: string | undefined
21 | if (req.method && ['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
22 | body = await new Promise<string>((resolve) => {
23 | const chunks: Buffer[] = []
24 | req.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))))
25 | req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
26 | })
27 | }
28 | const handler = routes.get(key)
29 | if (!handler) {
30 | res.statusCode = 404
31 | res.end('not found')
32 | return
33 | }
34 | const result = handler(req, body)
35 | const status = result.status ?? 200
36 | const headers = { 'content-type': 'application/json', ...(result.headers ?? {}) }
37 | const payload = typeof result.body === 'string' ? result.body : JSON.stringify(result.body ?? { ok: true })
38 | res.writeHead(status, headers)
39 | res.end(payload)
40 | } catch (err: any) {
41 | res.writeHead(500, { 'content-type': 'application/json' })
42 | res.end(JSON.stringify({ error: err?.message ?? 'internal error' }))
43 | }
44 | })
45 |
46 | return new Promise((resolve) => {
47 | server.listen(0, '127.0.0.1', () => {
48 | const addr = server.address()
49 | const port = typeof addr === 'object' && addr ? addr.port : 0
50 | resolve({
51 | port,
52 | url: `http://127.0.0.1:${port}`,
53 | close: () => new Promise<void>((r) => server.close(() => r())),
54 | register: (method: string, path: string, handler: RouteHandler) => {
55 | routes.set(`${method.toUpperCase()} ${path}`, handler)
56 | },
57 | })
58 | })
59 | })
60 | }
61 |
62 |
```
--------------------------------------------------------------------------------
/src/server/protocol-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type {
2 | CallToolRequest,
3 | CallToolResult,
4 | ListResourcesRequest,
5 | ListResourcesResult,
6 | ListToolsRequest,
7 | ListToolsResult,
8 | ReadResourceRequest,
9 | ReadResourceResult,
10 | SubscribeRequest,
11 | SubscribeResult,
12 | } from '../types/mcp.js'
13 | import type { CapabilityAggregator } from '../modules/capability-aggregator.js'
14 | import type { RequestRouter } from '../modules/request-router.js'
15 | import { Logger } from '../utils/logger.js'
16 |
17 | export interface ProtocolContext {
18 | aggregator: CapabilityAggregator
19 | router: RequestRouter
20 | // Optional client bearer token provided by gateway
21 | getClientToken?: () => string | undefined
22 | }
23 |
24 | export class ProtocolHandler {
25 | constructor(private readonly ctx: ProtocolContext) {}
26 |
27 | async handleListTools(_req: ListToolsRequest): Promise<ListToolsResult> {
28 | try {
29 | const tools = this.ctx.aggregator.getAllTools(this.ctx.router.getServers())
30 | return { tools }
31 | } catch (err) {
32 | Logger.error('handleListTools failed', err)
33 | return { tools: [] }
34 | }
35 | }
36 |
37 | async handleCallTool(req: CallToolRequest): Promise<CallToolResult> {
38 | try {
39 | const token = this.ctx.getClientToken?.()
40 | const res = await this.ctx.router.routeCallTool(req, token)
41 | return res
42 | } catch (err) {
43 | Logger.warn('handleCallTool error', err)
44 | return { content: { error: String(err) }, isError: true }
45 | }
46 | }
47 |
48 | async handleListResources(_req: ListResourcesRequest): Promise<ListResourcesResult> {
49 | try {
50 | const resources = this.ctx.aggregator.getAllResources(this.ctx.router.getServers())
51 | return { resources }
52 | } catch (err) {
53 | Logger.error('handleListResources failed', err)
54 | return { resources: [] }
55 | }
56 | }
57 |
58 | async handleReadResource(req: ReadResourceRequest): Promise<ReadResourceResult> {
59 | try {
60 | const token = this.ctx.getClientToken?.()
61 | const res = await this.ctx.router.routeReadResource(req, token)
62 | return res
63 | } catch (err) {
64 | Logger.warn('handleReadResource error', err)
65 | return { contents: String(err), mimeType: 'text/plain' }
66 | }
67 | }
68 |
69 | async handleSubscribe(_req: SubscribeRequest): Promise<SubscribeResult> {
70 | // Event subscriptions not yet surfaced; return OK for MCP compatibility
71 | return { ok: true }
72 | }
73 | }
74 |
```
--------------------------------------------------------------------------------
/tests/integration/oauth.callback-handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import '../setup/test-setup.js'
2 | import test from 'node:test'
3 | import assert from 'node:assert/strict'
4 |
5 | // Test imports one by one to isolate the issue
6 | console.log('Importing PKCEManager...')
7 | import { PKCEManager } from '../../src/oauth/pkce-manager.js'
8 | console.log('Importing StateManager...')
9 | import { StateManager } from '../../src/oauth/state-manager.js'
10 | console.log('Importing createMockServer...')
11 | import { createMockServer } from '../utils/mock-http.js'
12 | console.log('Importing CallbackHandler...')
13 | import { CallbackHandler } from '../../src/oauth/callback-handler.js'
14 | console.log('All imports successful')
15 |
16 | test.skip('CallbackHandler exchanges code and stores token', async () => {
17 | const tokenSrv = await createMockServer([
18 | { method: 'POST', path: '/token', handler: (_req, body) => {
19 | if (body.code === 'good') return { body: { access_token: 'AT', expires_in: 60, scope: 'openid' } }
20 | return { status: 400, body: { error: 'bad code' } }
21 | } },
22 | ])
23 | try {
24 | try {
25 | const pkce = new PKCEManager()
26 | const stateMgr = new StateManager()
27 | const state = stateMgr.create({ provider: 'prov', serverId: 'srv', clientToken: 'CT', returnTo: '/done' })
28 | const { verifier } = await pkce.generate(state)
29 | // pkce manager consumes verifier on getVerifier(), which CallbackHandler will do
30 | const cfg: any = {
31 | master_oauth: { authorization_endpoint: tokenSrv.url + '/auth', token_endpoint: tokenSrv.url + '/token', client_id: 'cid', redirect_uri: tokenSrv.url + '/cb', scopes: ['openid'] },
32 | hosting: { platform: 'node' },
33 | servers: [],
34 | }
35 | let stored: any
36 | const cb = new CallbackHandler({ config: cfg, stateManager: stateMgr, pkceManager: pkce, baseUrl: tokenSrv.url, storeDelegatedToken: async (ct, sid, tok) => { stored = { ct, sid, tok } } })
37 | const res = await cb.handleCallback(new URLSearchParams({ state, code: 'good' }), { provider: 'custom', authorization_endpoint: tokenSrv.url + '/auth', token_endpoint: tokenSrv.url + '/token', client_id: 'cid' })
38 | assert.ok(res.token)
39 | assert.equal(stored.ct, 'CT')
40 | assert.equal(stored.sid, 'srv')
41 | assert.equal(stored.tok.access_token, 'AT')
42 | } catch (error) {
43 | console.error('Test error:', error)
44 | throw error
45 | }
46 | } finally {
47 | await tokenSrv.close()
48 | }
49 | })
50 |
51 |
```
--------------------------------------------------------------------------------
/src/oauth/flow-validator.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { MasterConfig, ServerAuthConfig } from '../types/config.js'
2 |
3 | export interface ProviderResolution {
4 | providerId: string
5 | serverId?: string
6 | config: ServerAuthConfig
7 | }
8 |
9 | export class FlowValidator {
10 | constructor(private readonly getConfig: () => MasterConfig) {}
11 |
12 | resolveProvider(params: { provider?: string | null; serverId?: string | null }): ProviderResolution {
13 | const cfg = this.getConfig()
14 | const provider = params.provider ?? undefined
15 | const serverId = params.serverId ?? undefined
16 |
17 | if (!provider && !serverId) {
18 | return {
19 | providerId: 'master',
20 | config: {
21 | provider: 'custom',
22 | authorization_endpoint: cfg.master_oauth.authorization_endpoint,
23 | token_endpoint: cfg.master_oauth.token_endpoint,
24 | client_id: cfg.master_oauth.client_id,
25 | client_secret: cfg.master_oauth.client_secret,
26 | scopes: cfg.master_oauth.scopes,
27 | },
28 | }
29 | }
30 |
31 | if (provider === 'master') {
32 | return {
33 | providerId: 'master',
34 | config: {
35 | provider: 'custom',
36 | authorization_endpoint: cfg.master_oauth.authorization_endpoint,
37 | token_endpoint: cfg.master_oauth.token_endpoint,
38 | client_id: cfg.master_oauth.client_id,
39 | client_secret: cfg.master_oauth.client_secret,
40 | scopes: cfg.master_oauth.scopes,
41 | },
42 | }
43 | }
44 |
45 | if (serverId) {
46 | const server = cfg.servers.find((s) => s.id === serverId)
47 | if (!server || !server.auth_config) throw new Error('Unknown server or missing auth configuration')
48 | return { providerId: provider ?? serverId, serverId, config: server.auth_config }
49 | }
50 |
51 | const pre = cfg.oauth_delegation?.providers?.[String(provider)]
52 | if (!pre) throw new Error('Unknown provider')
53 | return { providerId: String(provider), config: pre }
54 | }
55 |
56 | validateReturnTo(returnTo: string | null | undefined, baseUrl?: string): string | undefined {
57 | if (!returnTo) return undefined
58 | try {
59 | // Allow relative paths only, or same-origin absolute if matches baseUrl
60 | if (returnTo.startsWith('/')) return returnTo
61 | if (baseUrl) {
62 | const origin = new URL(baseUrl).origin
63 | const u = new URL(returnTo)
64 | if (u.origin === origin) return u.pathname + u.search + u.hash
65 | }
66 | return undefined
67 | } catch {
68 | return undefined
69 | }
70 | }
71 | }
72 |
73 |
```
--------------------------------------------------------------------------------
/docs/guides/client-integration.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | title: Client Integration
3 | ---
4 |
5 | # Client Integration
6 |
7 | Connect your MCP clients to the Master MCP Server and verify end-to-end flows.
8 |
9 | > Note: The Master MCP Server exposes HTTP endpoints for tools and resources (e.g., `/mcp/tools/call`). Custom clients can integrate directly over HTTP. For GUI clients like Claude Desktop, support for HTTP/remote servers may vary by version. If direct HTTP is unsupported, consider a small bridge (stdio → HTTP) or use the Node runtime directly inside your app.
10 |
11 | ## Custom Clients (HTTP)
12 |
13 | Use any HTTP-capable client. Examples below:
14 |
15 | ```bash
16 | curl -s -H 'content-type: application/json' \
17 | -X POST http://localhost:3000/mcp/tools/list -d '{"type":"list_tools"}'
18 | ```
19 |
20 | Node (fetch):
21 |
22 | ```ts
23 | import fetch from 'node-fetch'
24 | const res = await fetch('http://localhost:3000/mcp/tools/call', {
25 | method: 'POST',
26 | headers: { 'content-type': 'application/json', authorization: 'Bearer YOUR_CLIENT_TOKEN' },
27 | body: JSON.stringify({ name: 'search.query', arguments: { q: 'hello' } })
28 | })
29 | console.log(await res.json())
30 | ```
31 |
32 | See also: Getting Started → Quick Start and the <ApiPlayground /> on the landing page.
33 |
34 | ## Claude Desktop (Guidance)
35 |
36 | Claude Desktop supports MCP servers via configuration. The exact configuration and supported transports can change; consult the latest Claude Desktop documentation.
37 |
38 | Two approaches:
39 |
40 | - If your Claude Desktop version supports remote/HTTP MCP servers, configure it to point at your master base URL (e.g., `http://localhost:3000`) and include a bearer token if required.
41 | - Otherwise, run a small stdio bridge that speaks MCP to the client and forwards requests to the master HTTP endpoints. The bridge should:
42 | - Respond to tool/resource listing using the master’s `/mcp/*/list` endpoints
43 | - Forward tool calls and resource reads to `/mcp/tools/call` and `/mcp/resources/read`
44 | - Map names like `serverId.toolName` consistently
45 |
46 | > Tip: Keep your bridge stateless. Let the master handle routing, retries, and auth strategies.
47 |
48 | ## Testing Connections
49 |
50 | - Health: `GET /health` → `{ ok: true }`
51 | - Capabilities: `GET /capabilities` → aggregated tools/resources
52 | - Tools/Resources: use the POST endpoints under `/mcp/*`
53 |
54 | ## Troubleshooting
55 |
56 | - 401/403: ensure your Authorization header is present and matches backend expectations.
57 | - Missing tools/resources: confirm the backend servers are healthy and listed in config.
58 | - Delegated OAuth required: follow the flow at `/oauth/authorize?server_id=<id>`.
59 |
60 |
```
--------------------------------------------------------------------------------
/tests/mocks/oauth/mock-oidc-provider.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createTestServer } from '../../_utils/test-server.js'
2 |
3 | export interface MockOidcOptions {
4 | issuer?: string
5 | clientId?: string
6 | clientSecret?: string
7 | scopes?: string[]
8 | }
9 |
10 | export async function startMockOidcProvider(opts?: MockOidcOptions): Promise<{
11 | issuer: string
12 | authorization_endpoint: string
13 | token_endpoint: string
14 | jwks_uri: string
15 | stop: () => Promise<void>
16 | }> {
17 | const srv = await createTestServer()
18 | const issuer = opts?.issuer ?? `${srv.url}`
19 | const clientId = opts?.clientId ?? 'test-client'
20 | const scopes = opts?.scopes ?? ['openid', 'profile']
21 |
22 | const codeStore = new Map<string, { scope: string[] }>()
23 |
24 | // OIDC Discovery
25 | srv.register('GET', '/.well-known/openid-configuration', () => ({
26 | body: {
27 | issuer,
28 | authorization_endpoint: `${issuer}/authorize`,
29 | token_endpoint: `${issuer}/token`,
30 | jwks_uri: `${issuer}/jwks.json`,
31 | response_types_supported: ['code'],
32 | grant_types_supported: ['authorization_code', 'refresh_token'],
33 | },
34 | }))
35 |
36 | // Simplified authorize: immediately redirects back with code + state
37 | srv.register('GET', '/authorize', (_req, _raw) => {
38 | const url = new URL(_req.url || '/', issuer)
39 | const redirectUri = url.searchParams.get('redirect_uri') || ''
40 | const state = url.searchParams.get('state') || ''
41 | const scopeStr = url.searchParams.get('scope') || scopes.join(' ')
42 | const code = `code_${Math.random().toString(36).slice(2)}`
43 | codeStore.set(code, { scope: scopeStr.split(/[ ,]+/).filter(Boolean) })
44 | return {
45 | status: 302,
46 | headers: { location: `${redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` },
47 | }
48 | })
49 |
50 | // Token endpoint
51 | srv.register('POST', '/token', (_req, raw) => {
52 | const params = new URLSearchParams(raw || '')
53 | const code = params.get('code') || ''
54 | const rec = codeStore.get(code)
55 | if (!rec) return { status: 400, body: { error: 'invalid_grant' } }
56 | // Minimal token response
57 | return {
58 | body: {
59 | access_token: `at_${code}`,
60 | token_type: 'bearer',
61 | scope: rec.scope.join(' '),
62 | expires_in: 3600,
63 | },
64 | }
65 | })
66 |
67 | // Static JWKS (not strictly needed for current code paths)
68 | srv.register('GET', '/jwks.json', () => ({ body: { keys: [] } }))
69 |
70 | return {
71 | issuer,
72 | authorization_endpoint: `${issuer}/authorize`,
73 | token_endpoint: `${issuer}/token`,
74 | jwks_uri: `${issuer}/jwks.json`,
75 | stop: srv.close,
76 | }
77 | }
78 |
79 |
```
--------------------------------------------------------------------------------
/examples/test-mcp-server.js:
--------------------------------------------------------------------------------
```javascript
1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2 | import express from 'express'
3 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
4 |
5 | // Create a simple test MCP server
6 | const server = new McpServer({
7 | name: 'test-mcp-server',
8 | version: '1.0.0'
9 | }, {
10 | capabilities: {
11 | tools: { listChanged: true },
12 | resources: { listChanged: true }
13 | }
14 | })
15 |
16 | // Register a simple tool
17 | server.tool('echo', 'Echoes back the input', { message: { type: 'string' } }, async (args) => {
18 | return {
19 | content: [{
20 | type: 'text',
21 | text: `Echo: ${args.message}`
22 | }]
23 | }
24 | })
25 |
26 | // Register a simple resource
27 | server.resource('test-resource', 'test://example', { description: 'A test resource' }, async () => {
28 | return {
29 | contents: [{
30 | uri: 'test://example',
31 | text: 'This is a test resource'
32 | }]
33 | }
34 | })
35 |
36 | // Create an Express app
37 | const app = express()
38 | app.use(express.json())
39 |
40 | // Create the HTTP streaming transport
41 | const transport = new StreamableHTTPServerTransport({
42 | sessionIdGenerator: undefined, // Stateless mode
43 | enableJsonResponse: false, // Use SSE by default
44 | enableDnsRebindingProtection: false
45 | })
46 |
47 | // Connect the server to the transport
48 | await server.connect(transport)
49 |
50 | // Handle MCP requests
51 | app.post('/mcp', async (req, res) => {
52 | try {
53 | await transport.handleRequest(req, res, req.body)
54 | } catch (error) {
55 | console.error('MCP request handling failed', { error })
56 | res.status(500).json({
57 | error: 'Internal server error'
58 | })
59 | }
60 | })
61 |
62 | app.get('/mcp', async (req, res) => {
63 | try {
64 | await transport.handleRequest(req, res)
65 | } catch (error) {
66 | console.error('MCP request handling failed', { error })
67 | res.status(500).json({
68 | error: 'Internal server error'
69 | })
70 | }
71 | })
72 |
73 | // Expose the capabilities endpoint
74 | app.get('/capabilities', (req, res) => {
75 | res.json({
76 | tools: [
77 | {
78 | name: 'echo',
79 | description: 'Echoes back the input',
80 | inputSchema: {
81 | type: 'object',
82 | properties: {
83 | message: {
84 | type: 'string'
85 | }
86 | },
87 | required: ['message']
88 | }
89 | }
90 | ],
91 | resources: [
92 | {
93 | uri: 'test://example',
94 | name: 'test-resource',
95 | description: 'A test resource'
96 | }
97 | ]
98 | })
99 | })
100 |
101 | // Start the server
102 | const port = process.env.PORT || 3006
103 | app.listen(port, () => {
104 | console.log(`Test MCP server listening on http://localhost:${port}`)
105 | })
```
--------------------------------------------------------------------------------
/tests/servers/test-mcp-client.js:
--------------------------------------------------------------------------------
```javascript
1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
3 |
4 | async function runTest() {
5 | try {
6 | console.log('Testing Master MCP Server...')
7 |
8 | // Create a streamable HTTP transport to connect to our MCP server
9 | const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3005/mcp'))
10 |
11 | // Create the MCP client
12 | const client = new Client({
13 | name: 'test-client',
14 | version: '1.0.0'
15 | })
16 |
17 | // Initialize the client
18 | await client.connect(transport)
19 | console.log('✅ Server initialized')
20 | console.log('Server info:', client.getServerVersion())
21 | console.log('Server capabilities:', client.getServerCapabilities())
22 |
23 | // List tools
24 | console.log('\n--- Testing tools/list ---')
25 | const toolsResult = await client.listTools({})
26 | console.log('✅ tools/list successful')
27 | console.log('Number of tools:', toolsResult.tools.length)
28 | console.log('Tools:', toolsResult.tools.map(t => t.name))
29 |
30 | // List resources
31 | console.log('\n--- Testing resources/list ---')
32 | const resourcesResult = await client.listResources({})
33 | console.log('✅ resources/list successful')
34 | console.log('Number of resources:', resourcesResult.resources.length)
35 | console.log('Resources:', resourcesResult.resources.map(r => r.uri))
36 |
37 | // Test the health endpoint
38 | console.log('\n--- Testing health endpoint ---')
39 | try {
40 | const response = await fetch('http://localhost:3005/health')
41 | const health = await response.json()
42 | console.log('✅ Health endpoint successful')
43 | console.log('Health status:', health)
44 | } catch (error) {
45 | console.log('⚠️ Health endpoint test failed:', error.message)
46 | }
47 |
48 | // Test the metrics endpoint
49 | console.log('\n--- Testing metrics endpoint ---')
50 | try {
51 | const response = await fetch('http://localhost:3005/metrics')
52 | const metrics = await response.json()
53 | console.log('✅ Metrics endpoint successful')
54 | console.log('Metrics:', metrics)
55 | } catch (error) {
56 | console.log('⚠️ Metrics endpoint test failed:', error.message)
57 | }
58 |
59 | // Close the connection
60 | await client.close()
61 | console.log('\n✅ Disconnected from MCP server')
62 | console.log('\n🎉 All tests completed successfully!')
63 |
64 | } catch (error) {
65 | console.error('❌ Test failed:', error)
66 | console.error('Error stack:', error.stack)
67 | }
68 | }
69 |
70 | // Run the test
71 | runTest()
```
--------------------------------------------------------------------------------
/config/schema.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://json-schema.org/draft/2020-12/schema",
3 | "$id": "https://example.com/master-mcp-config.schema.json",
4 | "type": "object",
5 | "required": ["master_oauth", "hosting", "servers"],
6 | "properties": {
7 | "master_oauth": {
8 | "type": "object",
9 | "required": ["authorization_endpoint", "token_endpoint", "client_id", "redirect_uri", "scopes"],
10 | "properties": {
11 | "issuer": { "type": "string" },
12 | "authorization_endpoint": { "type": "string" },
13 | "token_endpoint": { "type": "string" },
14 | "jwks_uri": { "type": "string" },
15 | "client_id": { "type": "string" },
16 | "client_secret": { "type": "string" },
17 | "redirect_uri": { "type": "string" },
18 | "scopes": { "type": "array", "items": { "type": "string" } },
19 | "audience": { "type": "string" }
20 | },
21 | "additionalProperties": true
22 | },
23 | "hosting": {
24 | "type": "object",
25 | "required": ["platform", "base_url"],
26 | "properties": {
27 | "platform": { "type": "string", "enum": ["node", "cloudflare-workers", "koyeb", "docker", "unknown"] },
28 | "port": { "type": "number" },
29 | "base_url": { "type": "string" },
30 | "storage_backend": { "type": "string" }
31 | },
32 | "additionalProperties": true
33 | },
34 | "logging": { "type": "object", "properties": { "level": { "type": "string", "enum": ["debug", "info", "warn", "error"] } } },
35 | "routing": { "type": "object", "additionalProperties": true },
36 | "security": { "type": "object", "additionalProperties": true },
37 | "servers": {
38 | "type": "array",
39 | "items": {
40 | "type": "object",
41 | "required": ["id", "type", "auth_strategy", "config"],
42 | "properties": {
43 | "id": { "type": "string" },
44 | "type": { "type": "string", "enum": ["git", "npm", "pypi", "docker", "local"] },
45 | "url": { "type": "string" },
46 | "package": { "type": "string" },
47 | "version": { "type": "string" },
48 | "branch": { "type": "string" },
49 | "auth_strategy": { "type": "string", "enum": ["master_oauth", "delegate_oauth", "bypass_auth", "proxy_oauth"] },
50 | "auth_config": { "type": "object", "additionalProperties": true },
51 | "config": {
52 | "type": "object",
53 | "properties": {
54 | "environment": { "type": "object", "additionalProperties": true },
55 | "args": { "type": "array", "items": { "type": "string" } },
56 | "port": { "type": "integer" }
57 | },
58 | "additionalProperties": true
59 | }
60 | },
61 | "additionalProperties": true
62 | }
63 | }
64 | },
65 | "additionalProperties": true
66 | }
67 |
68 |
```
--------------------------------------------------------------------------------
/src/utils/string.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * String manipulation and parsing utilities.
3 | */
4 |
5 | export function slugify(input: string): string {
6 | return input
7 | .normalize('NFKD')
8 | .replace(/[\u0300-\u036f]/g, '')
9 | .toLowerCase()
10 | .replace(/[^a-z0-9]+/g, '-')
11 | .replace(/(^-|-$)+/g, '')
12 | }
13 |
14 | export function toCamelCase(input: string): string {
15 | return input
16 | .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))
17 | .replace(/^(.)/, (m) => m.toLowerCase())
18 | }
19 |
20 | export function toKebabCase(input: string): string {
21 | return input
22 | .replace(/([a-z])([A-Z])/g, '$1-$2')
23 | .replace(/[\s_]+/g, '-')
24 | .toLowerCase()
25 | }
26 |
27 | export function toSnakeCase(input: string): string {
28 | return input
29 | .replace(/([a-z])([A-Z])/g, '$1_$2')
30 | .replace(/[\s-]+/g, '_')
31 | .toLowerCase()
32 | }
33 |
34 | export function escapeHTML(input: string): string {
35 | return input
36 | .replace(/&/g, '&')
37 | .replace(/</g, '<')
38 | .replace(/>/g, '>')
39 | .replace(/"/g, '"')
40 | .replace(/'/g, ''')
41 | }
42 |
43 | export function escapeRegExp(input: string): string {
44 | return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
45 | }
46 |
47 | export function joinUrlPaths(...parts: string[]): string {
48 | const sanitized = parts.filter(Boolean).map((p) => p.replace(/(^\/+|\/+?$)/g, ''))
49 | const joined = sanitized.join('/')
50 | return `/${joined}`.replace(/\/+$/g, '') || '/'
51 | }
52 |
53 | export function trimSafe(input: unknown): string {
54 | return String(input ?? '').trim()
55 | }
56 |
57 | export function truncateMiddle(input: string, maxLength: number): string {
58 | if (input.length <= maxLength) return input
59 | if (maxLength <= 3) return input.slice(0, maxLength)
60 | const keep = Math.floor((maxLength - 3) / 2)
61 | return `${input.slice(0, keep)}...${input.slice(-keep)}`
62 | }
63 |
64 | export function toBase64(input: string): string {
65 | if (typeof btoa === 'function') return btoa(input)
66 | return Buffer.from(input, 'utf8').toString('base64')
67 | }
68 |
69 | export function fromBase64(input: string): string {
70 | if (typeof atob === 'function') return atob(input)
71 | return Buffer.from(input, 'base64').toString('utf8')
72 | }
73 |
74 | export function stableJSONStringify(value: unknown): string {
75 | return JSON.stringify(sortKeys(value))
76 | }
77 |
78 | function sortKeys(value: any): any {
79 | if (Array.isArray(value)) return value.map(sortKeys)
80 | if (value && typeof value === 'object') {
81 | const out: Record<string, any> = {}
82 | for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key])
83 | return out
84 | }
85 | return value
86 | }
87 |
88 | export function parseBoolean(input: unknown, defaultValue = false): boolean {
89 | const s = String(input ?? '').trim().toLowerCase()
90 | if (s === 'true' || s === '1' || s === 'yes' || s === 'y') return true
91 | if (s === 'false' || s === '0' || s === 'no' || s === 'n') return false
92 | return defaultValue
93 | }
94 |
95 | export function normalizeUrl(input: string): string {
96 | try {
97 | const u = new URL(input)
98 | u.hash = ''
99 | return u.toString()
100 | } catch {
101 | return input
102 | }
103 | }
104 |
105 |
```
--------------------------------------------------------------------------------
/tests/integration/request-router.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import '../setup/test-setup.js'
2 | import test from 'node:test'
3 | import assert from 'node:assert/strict'
4 | import { RequestRouter } from '../../src/modules/request-router.js'
5 | import { CapabilityAggregator } from '../../src/modules/capability-aggregator.js'
6 | import { createMockServer } from '../utils/mock-http.js'
7 |
8 | test('RequestRouter routes tool/resource with pass-through auth', async () => {
9 | const sOk = await createMockServer([
10 | { method: 'POST', path: '/mcp/tools/call', handler: (_req, body) => ({ body: { content: { ok: true, args: body.arguments } } }) },
11 | { method: 'POST', path: '/mcp/resources/read', handler: (_req, body) => ({ body: { contents: `read:${body.uri}`, mimeType: 'text/plain' } }) },
12 | ])
13 | try {
14 | const servers = new Map<string, any>([[
15 | 's1', { id: 's1', type: 'node', endpoint: sOk.url, config: {} as any, status: 'running', lastHealthCheck: 0 }
16 | ]])
17 | const rr = new RequestRouter(servers as any, new CapabilityAggregator(), async (_sid, token) => token ? ({ Authorization: `Bearer ${token}` }) : undefined)
18 | const toolRes = await rr.routeCallTool({ name: 's1.echo', arguments: { a: 1 } }, 'CT')
19 | assert.equal((toolRes as any).content.ok, true)
20 | const readRes = await rr.routeReadResource({ uri: 's1.file' }, 'CT')
21 | assert.equal((readRes as any).contents, 'read:file')
22 | } finally {
23 | await sOk.close()
24 | }
25 | })
26 |
27 | test('RequestRouter returns delegation error when provider requires', async () => {
28 | const s = await createMockServer([
29 | { method: 'POST', path: '/mcp/tools/call', handler: (_req, _body) => ({ body: { content: { ok: true } } }) },
30 | ])
31 | try {
32 | const servers = new Map<string, any>([[ 's1', { id: 's1', type: 'node', endpoint: s.url, config: {} as any, status: 'running', lastHealthCheck: 0 } ]])
33 | const rr = new RequestRouter(servers as any, new CapabilityAggregator(), async () => ({ type: 'oauth_delegation', auth_endpoint: 'x', token_endpoint: 'y', client_info: {}, required_scopes: [], redirect_after_auth: true } as any))
34 | const res = await rr.routeCallTool({ name: 's1.x' }, 'CT')
35 | assert.equal((res as any).isError, true)
36 | } finally {
37 | await s.close()
38 | }
39 | })
40 |
41 | test('RequestRouter retries on transient failure and eventually succeeds', async () => {
42 | let n = 0
43 | const s = await createMockServer([
44 | { method: 'POST', path: '/mcp/tools/call', handler: () => {
45 | n++
46 | if (n < 3) return { status: 500, body: { error: 'boom' } }
47 | return { body: { content: { ok: true } } }
48 | } },
49 | ])
50 | try {
51 | const servers = new Map<string, any>([[ 's1', { id: 's1', type: 'node', endpoint: s.url, config: {} as any, status: 'running', lastHealthCheck: 0 } ]])
52 | const rr = new RequestRouter(servers as any, new CapabilityAggregator())
53 | const res = await rr.routeCallTool({ name: 's1.task' })
54 | // @ts-ignore
55 | assert.equal(res.content.ok, true)
56 | } finally {
57 | await s.close()
58 | }
59 | })
60 |
```
--------------------------------------------------------------------------------
/docs/.vitepress/theme/components/ApiPlayground.vue:
--------------------------------------------------------------------------------
```vue
1 | <template>
2 | <div class="mcp-callout" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
3 | <label>Endpoint
4 | <select v-model="endpoint" style="margin-left:8px">
5 | <option value="tools.list">POST /mcp/tools/list</option>
6 | <option value="tools.call">POST /mcp/tools/call</option>
7 | <option value="resources.list">POST /mcp/resources/list</option>
8 | <option value="resources.read">POST /mcp/resources/read</option>
9 | </select>
10 | </label>
11 | <label>Base URL <input v-model="baseUrl" placeholder="http://localhost:3000" /></label>
12 | <label>Token <input v-model="token" placeholder="optional bearer" /></label>
13 | </div>
14 |
15 | <div class="mcp-grid">
16 | <div class="mcp-col-6">
17 | <h4>Request Body</h4>
18 | <textarea v-model="body" rows="10" style="width:100%;font-family:var(--vp-font-family-mono)"></textarea>
19 | </div>
20 | <div class="mcp-col-6">
21 | <h4>curl</h4>
22 | <pre><code>{{ curl }}</code></pre>
23 | <h4 style="margin-top:10px">Node (fetch)</h4>
24 | <pre><code class="language-ts">{{ node }}</code></pre>
25 | </div>
26 | </div>
27 | <p style="color:var(--vp-c-text-2);font-size:.9rem;margin-top:6px">Note: This playground does not perform live requests in the docs site; copy commands to run locally.</p>
28 | </template>
29 |
30 | <script setup lang="ts">
31 | import { computed, ref, watch } from 'vue'
32 |
33 | const endpoint = ref<'tools.list'|'tools.call'|'resources.list'|'resources.read'>('tools.list')
34 | const baseUrl = ref('http://localhost:3000')
35 | const token = ref('')
36 | const body = ref('')
37 |
38 | const defaultBodies: Record<string, string> = {
39 | 'tools.list': JSON.stringify({ type: 'list_tools' }, null, 2),
40 | 'tools.call': JSON.stringify({ name: 'serverId.toolName', arguments: { query: 'hello' } }, null, 2),
41 | 'resources.list': JSON.stringify({ type: 'list_resources' }, null, 2),
42 | 'resources.read': JSON.stringify({ uri: 'serverId:resourceId' }, null, 2)
43 | }
44 |
45 | watch(endpoint, (v) => { body.value = defaultBodies[v] })
46 | body.value = defaultBodies[endpoint.value]
47 |
48 | const path = computed(() => {
49 | switch (endpoint.value) {
50 | case 'tools.list': return '/mcp/tools/list'
51 | case 'tools.call': return '/mcp/tools/call'
52 | case 'resources.list': return '/mcp/resources/list'
53 | case 'resources.read': return '/mcp/resources/read'
54 | }
55 | })
56 |
57 | const curl = computed(() => {
58 | const headers = ["-H 'content-type: application/json'"]
59 | if (token.value) headers.push(`-H 'authorization: Bearer ${token.value}'`)
60 | return `curl -s ${headers.join(' ')} -X POST ${baseUrl.value}${path.value} -d '${body.value.replace(/'/g, "'\\''")}'`
61 | })
62 |
63 | const node = computed(() => `import fetch from 'node-fetch'
64 |
65 | const res = await fetch('${baseUrl.value}${path.value}', {
66 | method: 'POST',
67 | headers: {
68 | 'content-type': 'application/json'${token.value ? ",\n authorization: 'Bearer " + token.value + "'" : ''}
69 | },
70 | body: JSON.stringify(${body.value})
71 | })
72 | const data = await res.json()
73 | console.log(data)
74 | `)
75 | </script>
76 |
77 |
```
--------------------------------------------------------------------------------
/src/oauth/pkce-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | // PKCE (Proof Key for Code Exchange) manager with cross-platform support
2 | // Generates code_verifier and S256 code_challenge and tracks them per state
3 |
4 | export interface PkceRecord {
5 | verifier: string
6 | method: 'S256' | 'plain'
7 | createdAt: number
8 | expiresAt: number
9 | }
10 |
11 | export interface PkceManagerOptions {
12 | ttlMs?: number
13 | }
14 |
15 | function getCrypto(): any {
16 | const g: any = globalThis as any
17 | if (g.crypto && g.crypto.subtle && g.crypto.getRandomValues) return g.crypto as any
18 | // Node fallback
19 | try {
20 | // eslint-disable-next-line @typescript-eslint/no-var-requires
21 | const nodeCrypto = require('node:crypto')
22 | return nodeCrypto.webcrypto as any
23 | } catch {
24 | throw new Error('Secure crypto not available in this environment')
25 | }
26 | }
27 |
28 | function base64UrlEncode(bytes: Uint8Array): string {
29 | let str = ''
30 | for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i])
31 | // btoa is available in browser/worker; Node 18 has global btoa via Buffer workaround
32 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
33 | // @ts-ignore
34 | const b64 = typeof btoa === 'function' ? btoa(str) : Buffer.from(bytes).toString('base64')
35 | return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
36 | }
37 |
38 | function randomString(length = 64): string {
39 | const crypto = getCrypto()
40 | const bytes = new Uint8Array(length)
41 | crypto.getRandomValues(bytes)
42 | // Allowed characters for verifier are [A-Z a-z 0-9 - . _ ~]
43 | const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'
44 | let out = ''
45 | for (let i = 0; i < length; i++) out += chars[bytes[i] % chars.length]
46 | return out
47 | }
48 |
49 | export class PKCEManager {
50 | private readonly store = new Map<string, PkceRecord>()
51 | private readonly ttl: number
52 |
53 | constructor(options?: PkceManagerOptions) {
54 | this.ttl = options?.ttlMs ?? 10 * 60_000 // 10 minutes
55 | }
56 |
57 | async generate(state: string): Promise<{ challenge: string; method: 'S256'; verifier: string }> {
58 | const verifier = randomString(64)
59 | const challenge = await this.computeS256(verifier)
60 | const now = Date.now()
61 | this.store.set(state, {
62 | verifier,
63 | method: 'S256',
64 | createdAt: now,
65 | expiresAt: now + this.ttl,
66 | })
67 | return { challenge, method: 'S256', verifier }
68 | }
69 |
70 | getVerifier(state: string, consume = true): string | undefined {
71 | const rec = this.store.get(state)
72 | if (!rec) return undefined
73 | if (rec.expiresAt <= Date.now()) {
74 | this.store.delete(state)
75 | return undefined
76 | }
77 | if (consume) this.store.delete(state)
78 | return rec.verifier
79 | }
80 |
81 | cleanup(): void {
82 | const now = Date.now()
83 | for (const [k, v] of this.store) if (v.expiresAt <= now) this.store.delete(k)
84 | }
85 |
86 | private async computeS256(verifier: string): Promise<string> {
87 | const crypto = getCrypto()
88 | const enc = new TextEncoder().encode(verifier)
89 | const digest = await crypto.subtle.digest('SHA-256', enc)
90 | return base64UrlEncode(new Uint8Array(digest))
91 | }
92 | }
93 |
```
--------------------------------------------------------------------------------
/scripts/generate-config-docs.ts:
--------------------------------------------------------------------------------
```typescript
1 | /*
2 | * Generates docs/configuration/reference.md from the built-in JSON schema
3 | * and enriches with examples from examples/sample-configs.
4 | */
5 | import { SchemaValidator } from '../src/config/schema-validator.js'
6 | import { promises as fs } from 'node:fs'
7 | import path from 'node:path'
8 |
9 | type JSONSchema = any
10 |
11 | async function main() {
12 | const schema: JSONSchema | undefined = await SchemaValidator.loadSchema()
13 | if (!schema) throw new Error('Unable to load configuration schema')
14 |
15 | const examplesDir = path.resolve('examples/sample-configs')
16 | const exampleFiles = await fs.readdir(examplesDir)
17 | const exampleSnippets: string[] = []
18 | for (const f of exampleFiles) {
19 | if (!f.endsWith('.yaml') && !f.endsWith('.yml')) continue
20 | const content = await fs.readFile(path.join(examplesDir, f), 'utf8')
21 | exampleSnippets.push(`## Example: ${f}\n\n\u0060\u0060\u0060yaml\n${content}\n\u0060\u0060\u0060\n`)
22 | }
23 |
24 | const lines: string[] = []
25 | lines.push('# Configuration Reference')
26 | lines.push('')
27 | lines.push('This reference is generated from the built-in JSON Schema used by the server to validate configuration.')
28 | lines.push('')
29 | lines.push('## Top-Level Fields')
30 | lines.push('')
31 | lines.push(renderObject(schema))
32 | lines.push('')
33 | lines.push('## Examples')
34 | lines.push('')
35 | lines.push(exampleSnippets.join('\n'))
36 |
37 | const target = path.resolve('docs/configuration/reference.md')
38 | const contents = await fs.readFile(target, 'utf8').catch(() => '')
39 | const start = '<!-- GENERATED:BEGIN -->'
40 | const end = '<!-- GENERATED:END -->'
41 | const prefix = contents.split(start)[0] ?? ''
42 | const suffix = contents.split(end)[1] ?? ''
43 | const generated = `${start}\n\n${lines.join('\n')}\n\n${end}`
44 | const next = `${prefix}${generated}${suffix}`
45 | await fs.writeFile(target, next, 'utf8')
46 | }
47 |
48 | function renderObject(schema: JSONSchema, indent = 0, name?: string): string {
49 | const pad = ' '.repeat(indent)
50 | if (!schema || typeof schema !== 'object') return ''
51 | let s = ''
52 | if (schema.type === 'object' || schema.properties) {
53 | const required = new Set<string>((schema.required || []) as string[])
54 | for (const [key, value] of Object.entries(schema.properties || {})) {
55 | const req = required.has(key) ? ' (required)' : ''
56 | s += `${pad}- \`${name ? name + '.' : ''}${key}\`${req}${renderType(value)}\n`
57 | if ((value as any).properties || (value as any).items) {
58 | s += renderObject(value, indent + 1, name ? `${name}.${key}` : key)
59 | }
60 | }
61 | }
62 | if (schema.items) {
63 | s += `${pad} - items:${renderType(schema.items)}\n`
64 | s += renderObject(schema.items, indent + 2, name ? `${name}[]` : '[]')
65 | }
66 | return s
67 | }
68 |
69 | function renderType(schema: JSONSchema): string {
70 | const t = Array.isArray(schema.type) ? schema.type.join('|') : schema.type
71 | const enumVals = schema.enum ? `, enum: ${schema.enum.join(', ')}` : ''
72 | const fmt = schema.format ? `, format: ${schema.format}` : ''
73 | return t ? ` — type: ${t}${enumVals}${fmt}` : `${enumVals}${fmt}`
74 | }
75 |
76 | main().catch((err) => {
77 | console.error(err)
78 | process.exitCode = 1
79 | })
80 |
81 |
```
--------------------------------------------------------------------------------
/src/server/dependency-container.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ConfigManager } from './config-manager.js'
2 | import { DefaultModuleLoader } from '../modules/module-loader.js'
3 | import { CapabilityAggregator } from '../modules/capability-aggregator.js'
4 | import { RequestRouter } from '../modules/request-router.js'
5 | import { ProtocolHandler } from './protocol-handler.js'
6 | import { MasterServer } from './master-server.js'
7 | import { MultiAuthManager } from '../auth/multi-auth-manager.js'
8 | import type { MasterConfig, ServerConfig } from '../types/config.js'
9 | import { Logger } from '../utils/logger.js'
10 |
11 | export class DependencyContainer {
12 | readonly configManager: ConfigManager
13 | readonly loader: DefaultModuleLoader
14 | readonly aggregator: CapabilityAggregator
15 | readonly master: MasterServer
16 | readonly authManager: MultiAuthManager
17 | readonly router: RequestRouter
18 | readonly handler: ProtocolHandler
19 |
20 | private config!: MasterConfig
21 |
22 | constructor() {
23 | this.configManager = new ConfigManager({ watch: true })
24 | this.loader = new DefaultModuleLoader()
25 | this.aggregator = new CapabilityAggregator()
26 | // Create a temporary router for early wiring; will be replaced after config load
27 | this.router = new RequestRouter(new Map(), this.aggregator)
28 | this.master = new MasterServer(undefined, undefined)
29 | // Temporarily construct auth manager with placeholder; will be replaced after config
30 | this.authManager = new MultiAuthManager({
31 | authorization_endpoint: 'about:blank',
32 | token_endpoint: 'about:blank',
33 | client_id: 'placeholder',
34 | redirect_uri: 'about:blank',
35 | scopes: ['openid'],
36 | })
37 | this.handler = this.master.handler
38 | }
39 |
40 | async initialize(clientToken?: string): Promise<void> {
41 | this.config = await this.configManager.load()
42 | // Recreate auth manager with real config
43 | const auth = new MultiAuthManager(this.config.master_oauth)
44 | this.registerServerAuth(auth, this.config.servers)
45 | this.master.attachAuthManager(auth)
46 | ;(this as any).authManager = auth
47 |
48 | // Load servers and discover capabilities
49 | await this.master.startFromConfig(this.config, clientToken)
50 | this.master.updateRouting(this.config.routing)
51 |
52 | // Recreate router/handler references for easy access
53 | ;(this as any).router = this.master.getRouter()
54 | ;(this as any).handler = this.master.handler
55 |
56 | // Watch for config changes and hot-reload
57 | this.configManager.onChange(async (cfg) => {
58 | try {
59 | Logger.info('Applying updated configuration to MasterServer')
60 | this.config = cfg
61 | this.registerServerAuth(this.authManager, cfg.servers)
62 | await this.master.loadServers(cfg.servers)
63 | await this.master.discoverAllCapabilities()
64 | this.master.updateRouting(cfg.routing)
65 | } catch (err) {
66 | Logger.warn('Failed to apply updated config', err)
67 | }
68 | })
69 | }
70 |
71 | getConfig(): MasterConfig {
72 | return this.config
73 | }
74 |
75 | private registerServerAuth(manager: MultiAuthManager, servers: ServerConfig[]): void {
76 | for (const s of servers) {
77 | try {
78 | manager.registerServerAuth(s.id, s.auth_strategy, s.auth_config)
79 | } catch (err) {
80 | Logger.warn(`Failed to register auth for server ${s.id}`, err)
81 | }
82 | }
83 | }
84 | }
85 |
86 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "master-mcp-server",
3 | "version": "0.1.0",
4 | "private": true,
5 | "description": "Master MCP Server that aggregates multiple MCP servers behind a single endpoint.",
6 | "license": "UNLICENSED",
7 | "type": "module",
8 | "engines": {
9 | "node": ">=18.17"
10 | },
11 | "scripts": {
12 | "prepare": "husky install || true",
13 | "clean": "rimraf dist .turbo tsconfig.tsbuildinfo",
14 | "typecheck": "tsc -p tsconfig.node.json --noEmit",
15 | "build": "npm run build:node && npm run build:worker",
16 | "build:node": "tsc -p tsconfig.node.json",
17 | "build:worker": "tsc -p tsconfig.worker.json",
18 | "dev": "node --loader ts-node/esm src/index.ts",
19 | "dev:watch": "nodemon --watch src --ext ts,tsx,json --exec 'node --loader ts-node/esm src/index.ts'",
20 | "start": "node dist/node/index.js",
21 | "start:prod": "NODE_ENV=production node dist/node/index.js",
22 | "lint": "eslint --config .eslintrc.cjs . --ext .ts,.tsx",
23 | "format": "prettier --write .",
24 | "test": "NODE_V8_COVERAGE=.coverage node --loader ts-node/esm --test",
25 | "test:unit": "node --loader ts-node/esm --test tests/unit/**/*.test.ts",
26 | "test:integration": "node --loader ts-node/esm --test tests/integration/**/*.test.ts",
27 | "test:e2e": "node --loader ts-node/esm --test tests/e2e/**/*.test.ts",
28 | "test:perf": "node --loader ts-node/esm --test tests/perf/**/*.test.ts",
29 | "test:security": "node --loader ts-node/esm --test tests/security/**/*.test.ts",
30 | "test:watch": "node --loader ts-node/esm --test --watch",
31 | "test:mcp": "node --loader ts-node/esm test-master-mcp.js",
32 | "test:streaming": "node --loader ts-node/esm tests/servers/test-streaming.js",
33 | "test:streaming-both": "node --loader ts-node/esm tests/servers/test-streaming-both.js",
34 | "test:streaming-both-full": "node --loader ts-node/esm tests/servers/test-streaming-both-full.js",
35 | "test:streaming-both-simple": "node --loader ts-node/esm tests/servers/test-streaming-both-simple.js",
36 | "test:streaming-both-complete": "node --loader ts-node/esm tests/servers/test-streaming-both-complete.js",
37 | "docs:api": "typedoc",
38 | "docs:config": "node --loader ts-node/esm scripts/generate-config-docs.ts",
39 | "docs:dev": "vitepress dev docs",
40 | "docs:build": "vitepress build docs",
41 | "docs:preview": "vitepress preview docs",
42 | "docs:pdf": "md-to-pdf docs/index.md --basedir docs",
43 | "docs:all": "npm run docs:api && npm run docs:config && npm run docs:build"
44 | },
45 | "dependencies": {
46 | "@modelcontextprotocol/sdk": "^1.17.3",
47 | "dotenv": "^17.2.1",
48 | "express": "5.1.0",
49 | "jose": "6.0.12",
50 | "node-fetch": "^3.3.2",
51 | "yaml": "^2.5.0"
52 | },
53 | "devDependencies": {
54 | "@types/express": "5.0.3",
55 | "@types/node": "24.3.0",
56 | "@typescript-eslint/eslint-plugin": "8.40.0",
57 | "@typescript-eslint/parser": "8.40.0",
58 | "eslint": "9.33.0",
59 | "eslint-config-prettier": "10.1.8",
60 | "eslint-plugin-import": "^2.29.1",
61 | "husky": "^9.1.6",
62 | "md-to-pdf": "^5.2.4",
63 | "nodemon": "^3.1.4",
64 | "prettier": "^3.3.2",
65 | "rimraf": "6.0.1",
66 | "ts-node": "^10.9.2",
67 | "typedoc": "^0.28.10",
68 | "typedoc-plugin-markdown": "^4.8.1",
69 | "typescript": "^5.5.4",
70 | "vitepress": "^1.6.4"
71 | },
72 | "main": "index.js",
73 | "keywords": [],
74 | "author": ""
75 | }
76 |
```
--------------------------------------------------------------------------------
/src/routing/route-registry.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { LoadedServer, ServerInstance } from '../types/server.js'
2 | import { Logger } from '../utils/logger.js'
3 | import { CircuitBreaker } from './circuit-breaker.js'
4 | import { LoadBalancer } from './load-balancer.js'
5 |
6 | export interface RouteRegistryOptions {
7 | // Mapping lifetime for cached routes (ms)
8 | cacheTtlMs?: number
9 | }
10 |
11 | export interface RouteResolution {
12 | serverId: string
13 | instance: ServerInstance
14 | }
15 |
16 | export class RouteRegistry {
17 | private readonly servers: Map<string, LoadedServer>
18 | private readonly circuit: CircuitBreaker
19 | private readonly lb: LoadBalancer
20 | private readonly cacheTtl: number
21 | private cache = new Map<string, { value: RouteResolution; expiresAt: number }>()
22 |
23 | constructor(
24 | servers: Map<string, LoadedServer>,
25 | circuit: CircuitBreaker,
26 | lb: LoadBalancer,
27 | options?: RouteRegistryOptions
28 | ) {
29 | this.servers = servers
30 | this.circuit = circuit
31 | this.lb = lb
32 | this.cacheTtl = options?.cacheTtlMs ?? 5_000
33 | }
34 |
35 | updateServers(servers: Map<string, LoadedServer>): void {
36 | // Shallow replace reference
37 | ;(this as any).servers = servers
38 | this.cache.clear()
39 | }
40 |
41 | getInstances(serverId: string): ServerInstance[] {
42 | const server = this.servers.get(serverId)
43 | if (!server) return []
44 | const instances = server.instances && server.instances.length
45 | ? server.instances
46 | : (server.endpoint && server.endpoint !== 'unknown'
47 | ? [{ id: `${serverId}-primary`, url: server.endpoint, weight: 1, healthScore: server.status === 'running' ? 100 : 0 }]
48 | : [])
49 | return instances
50 | }
51 |
52 | resolve(serverId: string): RouteResolution | undefined {
53 | const cached = this.cache.get(serverId)
54 | const now = Date.now()
55 | if (cached && cached.expiresAt > now) return cached.value
56 |
57 | const instances = this.getInstances(serverId)
58 | if (!instances.length) return undefined
59 |
60 | // Filter by circuit breaker allowance
61 | const allowed = instances.filter((i) => this.circuit.canExecute(this.key(serverId, i.id)).allowed)
62 | const pool = allowed.length ? allowed : instances
63 | const chosen = this.lb.select(serverId, pool)
64 | if (!chosen) return undefined
65 |
66 | const resolution: RouteResolution = { serverId, instance: chosen }
67 | this.cache.set(serverId, { value: resolution, expiresAt: now + this.cacheTtl })
68 | return resolution
69 | }
70 |
71 | markSuccess(serverId: string, instanceId: string): void {
72 | const key = this.key(serverId, instanceId)
73 | this.circuit.onSuccess(key)
74 | this.bumpHealth(serverId, instanceId, +5)
75 | }
76 |
77 | markFailure(serverId: string, instanceId: string): void {
78 | const key = this.key(serverId, instanceId)
79 | this.circuit.onFailure(key)
80 | this.bumpHealth(serverId, instanceId, -20)
81 | }
82 |
83 | private key(serverId: string, instanceId: string): string {
84 | return `${serverId}::${instanceId}`
85 | }
86 |
87 | private bumpHealth(serverId: string, instanceId: string, delta: number): void {
88 | const s = this.servers.get(serverId)
89 | if (!s) return
90 | const arr = s.instances
91 | if (!arr) return
92 | const inst = arr.find((i) => i.id === instanceId)
93 | if (!inst) return
94 | const prev = inst.healthScore ?? 50
95 | const next = Math.max(0, Math.min(100, prev + delta))
96 | inst.healthScore = next
97 | Logger.debug('Instance health updated', { serverId, instanceId, healthScore: next })
98 | }
99 | }
100 |
101 |
```
--------------------------------------------------------------------------------
/src/oauth/callback-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { MasterConfig, ServerAuthConfig } from '../types/config.js'
2 | import type { OAuthToken } from '../types/auth.js'
3 | import { StateManager, type OAuthStatePayload } from './state-manager.js'
4 | import { PKCEManager } from './pkce-manager.js'
5 |
6 | export interface CallbackContext {
7 | config: MasterConfig
8 | stateManager: StateManager
9 | pkceManager: PKCEManager
10 | baseUrl: string
11 | // Store token callback: serverId and clientToken must identify the storage key
12 | storeDelegatedToken?: (clientToken: string, serverId: string, token: OAuthToken) => Promise<void>
13 | }
14 |
15 | function toOAuthToken(json: any): OAuthToken {
16 | const expiresIn = 'expires_in' in json ? Number(json.expires_in) : 3600
17 | const scope = Array.isArray(json.scope)
18 | ? (json.scope as string[])
19 | : typeof json.scope === 'string'
20 | ? (json.scope as string).split(/[ ,]+/).filter(Boolean)
21 | : []
22 | return {
23 | access_token: String(json.access_token),
24 | refresh_token: json.refresh_token ? String(json.refresh_token) : undefined,
25 | expires_at: Date.now() + expiresIn * 1000,
26 | scope,
27 | }
28 | }
29 |
30 | async function exchangeAuthorizationCode(
31 | code: string,
32 | cfg: ServerAuthConfig,
33 | redirectUri: string,
34 | codeVerifier: string
35 | ): Promise<OAuthToken> {
36 | const body = new URLSearchParams({
37 | grant_type: 'authorization_code',
38 | code,
39 | client_id: cfg.client_id,
40 | redirect_uri: redirectUri,
41 | code_verifier: codeVerifier,
42 | })
43 | if (cfg.client_secret) body.set('client_secret', String(cfg.client_secret))
44 | const res = await fetch(cfg.token_endpoint, {
45 | method: 'POST',
46 | headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
47 | body,
48 | })
49 | const text = await res.text()
50 | if (!res.ok) throw new Error(`Token endpoint error ${res.status}: ${text}`)
51 | let json: any
52 | try {
53 | json = JSON.parse(text)
54 | } catch {
55 | json = Object.fromEntries(new URLSearchParams(text))
56 | }
57 | return toOAuthToken(json)
58 | }
59 |
60 | export class CallbackHandler {
61 | constructor(private readonly ctx: CallbackContext) {}
62 |
63 | async handleCallback(params: URLSearchParams, providerConfig: ServerAuthConfig): Promise<{ token?: OAuthToken; error?: string; state?: OAuthStatePayload }>
64 | {
65 | const error = params.get('error')
66 | if (error) {
67 | const desc = params.get('error_description') ?? 'OAuth authorization failed'
68 | return { error: `${error}: ${desc}` }
69 | }
70 | const stateStr = params.get('state')
71 | const code = params.get('code')
72 | if (!stateStr || !code) return { error: 'Missing state or code' }
73 |
74 | const state = this.ctx.stateManager.consume(stateStr)
75 | if (!state) return { error: 'Invalid or expired state' }
76 |
77 | const verifier = this.ctx.pkceManager.getVerifier(stateStr)
78 | if (!verifier) return { error: 'PKCE verification failed' }
79 |
80 | const redirectUri = new URL('/oauth/callback', this.ctx.baseUrl).toString()
81 | try {
82 | const token = await exchangeAuthorizationCode(code, providerConfig, redirectUri, verifier)
83 | // Store if we can identify a client + server context
84 | if (state.clientToken && state.serverId && this.ctx.storeDelegatedToken) {
85 | await this.ctx.storeDelegatedToken(state.clientToken, state.serverId, token)
86 | }
87 | return { token, state }
88 | } catch (err: any) {
89 | return { error: err?.message ?? 'Token exchange failed' }
90 | }
91 | }
92 | }
93 |
94 |
```
--------------------------------------------------------------------------------
/docs/stdio-servers.md:
--------------------------------------------------------------------------------
```markdown
1 | # STDIO Server Support
2 |
3 | The Master MCP Server now supports STDIO-based MCP servers in addition to HTTP-based servers. This allows you to aggregate both network-based and locally-running MCP servers through a single endpoint.
4 |
5 | ## Configuration
6 |
7 | To configure a STDIO server, use a `file://` URL in your server configuration:
8 |
9 | ```json
10 | {
11 | "servers": [
12 | {
13 | "id": "stdio-server",
14 | "type": "local",
15 | "url": "file://./path/to/your/stdio-mcp-server.cjs",
16 | "auth_strategy": "bypass_auth",
17 | "config": {
18 | "environment": {},
19 | "args": []
20 | }
21 | }
22 | ]
23 | }
24 | ```
25 |
26 | The Master MCP Server will automatically detect `file://` URLs and treat them as STDIO servers, starting them as child processes and communicating with them through stdin/stdout using JSON-RPC.
27 |
28 | ## How It Works
29 |
30 | 1. **Server Detection**: The Master MCP Server detects `file://` URLs and identifies them as STDIO servers
31 | 2. **Process Management**: STDIO servers are started as child processes
32 | 3. **Communication**: The Master MCP Server communicates with STDIO servers using JSON-RPC over stdin/stdout
33 | 4. **Capability Discovery**: The Master MCP Server discovers tools and resources from STDIO servers
34 | 5. **Request Routing**: Tool calls and resource reads are routed to the appropriate STDIO servers
35 |
36 | ## Benefits
37 |
38 | - **Unified Interface**: Access both HTTP and STDIO servers through a single MCP endpoint
39 | - **Process Isolation**: Each STDIO server runs in its own process for better isolation
40 | - **Automatic Management**: The Master MCP Server handles process lifecycle management
41 | - **Seamless Integration**: STDIO servers appear as regular MCP servers to clients
42 |
43 | ## Requirements
44 |
45 | - STDIO servers must implement the MCP protocol using JSON-RPC over stdin/stdout
46 | - STDIO servers should follow the MCP specification for initialization and capability discovery
47 | - STDIO servers must be executable Node.js scripts (`.js` or `.cjs` files)
48 |
49 | ## Example STDIO Server
50 |
51 | Here's a simple example of a STDIO server:
52 |
53 | ```javascript
54 | // Simple STDIO server that implements the MCP protocol
55 | // Save this as stdio-mcp-server.cjs and make it executable with chmod +x
56 | ```
57 | process.stdin.on('data', async (data) => {
58 | try {
59 | const lines = data.toString().split('\n').filter(line => line.trim() !== '');
60 | for (const line of lines) {
61 | const request = JSON.parse(line);
62 |
63 | // Handle initialize request
64 | if (request.method === 'initialize') {
65 | const response = {
66 | jsonrpc: '2.0',
67 | id: request.id,
68 | result: {
69 | protocolVersion: '2025-06-18',
70 | capabilities: {
71 | tools: { listChanged: true },
72 | resources: { listChanged: true }
73 | },
74 | serverInfo: {
75 | name: 'example-stdio-server',
76 | version: '1.0.0'
77 | }
78 | }
79 | };
80 | process.stdout.write(JSON.stringify(response) + '\n');
81 | }
82 | // Handle other requests...
83 | }
84 | } catch (err) {
85 | const errorResponse = {
86 | jsonrpc: '2.0',
87 | id: null,
88 | error: {
89 | code: -32700,
90 | message: 'Parse error',
91 | data: err.message
92 | }
93 | };
94 | process.stdout.write(JSON.stringify(errorResponse) + '\n');
95 | }
96 | });
97 | ```
98 |
99 | Make sure to make your STDIO server executable:
100 |
101 | ```bash
102 | chmod +x ./path/to/your/stdio-mcp-server.cjs
103 | ```
```
--------------------------------------------------------------------------------
/src/routing/retry-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Logger } from '../utils/logger.js'
2 |
3 | export type JitterMode = 'none' | 'full'
4 |
5 | export interface RetryPolicy {
6 | maxRetries: number
7 | baseDelayMs: number
8 | maxDelayMs: number
9 | backoffFactor: number // multiplier per attempt
10 | jitter: JitterMode
11 | timeoutMs?: number // overall timeout budget (optional)
12 | retryOn?: {
13 | networkErrors?: boolean
14 | httpStatuses?: number[]
15 | httpStatusClasses?: Array<4 | 5> // 4=4xx,5=5xx
16 | }
17 | }
18 |
19 | export interface RetryContext {
20 | attempt: number
21 | lastError?: unknown
22 | lastStatus?: number
23 | }
24 |
25 | function sleep(ms: number): Promise<void> {
26 | return new Promise((resolve) => setTimeout(resolve, ms))
27 | }
28 |
29 | function withJitter(base: number, mode: JitterMode): number {
30 | if (mode === 'none') return base
31 | // Full jitter: random between 0 and base
32 | return Math.floor(Math.random() * base)
33 | }
34 |
35 | function isRetryable(policy: RetryPolicy, _err: unknown, status?: number): boolean {
36 | if (status !== undefined) {
37 | if (policy.retryOn?.httpStatuses?.includes(status)) return true
38 | const klass = Math.floor(status / 100)
39 | if (klass === 5 && policy.retryOn?.httpStatusClasses?.includes(5)) return true
40 | if (klass === 4 && policy.retryOn?.httpStatusClasses?.includes(4)) return status === 408 || status === 429
41 | return false
42 | }
43 | // No status means a network error or thrown exception from fetch
44 | return Boolean(policy.retryOn?.networkErrors ?? true)
45 | }
46 |
47 | export class RetryHandler {
48 | private readonly policy: RetryPolicy
49 |
50 | constructor(policy?: Partial<RetryPolicy>) {
51 | this.policy = {
52 | maxRetries: policy?.maxRetries ?? 3,
53 | baseDelayMs: policy?.baseDelayMs ?? 200,
54 | maxDelayMs: policy?.maxDelayMs ?? 5_000,
55 | backoffFactor: policy?.backoffFactor ?? 2,
56 | jitter: policy?.jitter ?? 'full',
57 | timeoutMs: policy?.timeoutMs,
58 | retryOn: policy?.retryOn ?? { networkErrors: true, httpStatusClasses: [5], httpStatuses: [408, 429] },
59 | }
60 | }
61 |
62 | async execute<T>(op: () => Promise<T>, onRetry?: (ctx: RetryContext) => void): Promise<T> {
63 | const start = Date.now()
64 | let delay = this.policy.baseDelayMs
65 | let lastError: unknown
66 | for (let attempt = 0; attempt <= this.policy.maxRetries; attempt++) {
67 | try {
68 | const result = await op()
69 | return result
70 | } catch (err: any) {
71 | // If this looks like a fetch Response-like error, extract status
72 | let status: number | undefined
73 | if (err && typeof err === 'object' && 'status' in err && typeof (err as any).status === 'number') {
74 | status = (err as any).status
75 | }
76 |
77 | if (attempt >= this.policy.maxRetries || !isRetryable(this.policy, err, status)) {
78 | throw err
79 | }
80 |
81 | lastError = err
82 | const ctx: RetryContext = { attempt, lastError, lastStatus: status }
83 | try {
84 | onRetry?.(ctx)
85 | } catch { /* ignore */ }
86 |
87 | if (this.policy.timeoutMs && Date.now() - start + delay > this.policy.timeoutMs) {
88 | Logger.warn('Retry timeout budget exceeded')
89 | throw err
90 | }
91 |
92 | const wait = Math.min(this.policy.maxDelayMs, withJitter(delay, this.policy.jitter))
93 | await sleep(wait)
94 | delay = Math.min(this.policy.maxDelayMs, Math.floor(delay * this.policy.backoffFactor))
95 | }
96 | }
97 | // Should be unreachable
98 | throw lastError ?? new Error('RetryHandler failed without error')
99 | }
100 | }
101 |
```
--------------------------------------------------------------------------------
/src/types/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { OAuthDelegation } from './auth.js'
2 |
3 | export interface MasterConfig {
4 | master_oauth: MasterOAuthConfig
5 | servers: ServerConfig[]
6 | oauth_delegation?: OAuthDelegationConfig
7 | hosting: HostingConfig
8 | routing?: RoutingConfig
9 | logging?: LoggingConfig
10 | security?: SecurityConfig
11 | }
12 |
13 | export interface ServerConfig {
14 | id: string
15 | type: 'git' | 'npm' | 'pypi' | 'docker' | 'local'
16 | url?: string
17 | package?: string
18 | version?: string
19 | branch?: string
20 | auth_strategy: AuthStrategy
21 | auth_config?: ServerAuthConfig
22 | config: {
23 | environment?: Record<string, string>
24 | args?: string[]
25 | port?: number
26 | }
27 | }
28 |
29 | export enum AuthStrategy {
30 | MASTER_OAUTH = 'master_oauth',
31 | DELEGATE_OAUTH = 'delegate_oauth',
32 | BYPASS_AUTH = 'bypass_auth',
33 | PROXY_OAUTH = 'proxy_oauth'
34 | }
35 |
36 | export interface MasterOAuthConfig {
37 | issuer?: string
38 | authorization_endpoint: string
39 | token_endpoint: string
40 | jwks_uri?: string
41 | client_id: string
42 | client_secret?: string
43 | redirect_uri: string
44 | scopes: string[]
45 | audience?: string
46 | }
47 |
48 | // Alias used by MultiAuthManager constructor in later phases
49 | export type MasterAuthConfig = MasterOAuthConfig
50 |
51 | export interface OAuthDelegationConfig {
52 | enabled: boolean
53 | callback_base_url?: string
54 | // Optional pre-configured providers by ID
55 | providers?: Record<string, ServerAuthConfig>
56 | }
57 |
58 | export interface HostingConfig {
59 | platform: 'node' | 'cloudflare-workers' | 'koyeb' | 'docker' | 'unknown'
60 | port?: number
61 | base_url?: string
62 | // Optional platform-specific storage/backend hints
63 | storage_backend?: 'memory' | 'fs' | 'durable_object' | 'kv' | 's3'
64 | }
65 |
66 | export interface LoggingConfig {
67 | level?: 'debug' | 'info' | 'warn' | 'error'
68 | }
69 |
70 | export interface SecurityConfig {
71 | // Env var name containing encryption key for config secrets
72 | config_key_env?: string
73 | // Enable audit logging for config changes
74 | audit?: boolean
75 | // Optional secret rotation policy in days
76 | rotation_days?: number
77 | }
78 |
79 | export interface ServerAuthConfig {
80 | provider: 'github' | 'google' | 'custom'
81 | authorization_endpoint: string
82 | token_endpoint: string
83 | client_id: string
84 | client_secret?: string
85 | scopes?: string[]
86 | // Additional provider-specific fields
87 | [key: string]: unknown
88 | }
89 |
90 | // Re-export for convenience in consumers
91 | export type { OAuthDelegation }
92 |
93 | // ---- Routing configuration ----
94 | export type LoadBalancingStrategy = 'round_robin' | 'weighted' | 'health'
95 |
96 | export interface LoadBalancerConfig {
97 | strategy?: LoadBalancingStrategy
98 | }
99 |
100 | export interface CircuitBreakerConfig {
101 | failureThreshold?: number
102 | successThreshold?: number
103 | recoveryTimeoutMs?: number
104 | }
105 |
106 | export interface RetryPolicyConfig {
107 | maxRetries?: number
108 | baseDelayMs?: number
109 | maxDelayMs?: number
110 | backoffFactor?: number
111 | jitter?: 'none' | 'full'
112 | retryOn?: {
113 | networkErrors?: boolean
114 | httpStatuses?: number[]
115 | httpStatusClasses?: Array<4 | 5>
116 | }
117 | }
118 |
119 | export interface RoutingConfig {
120 | loadBalancer?: LoadBalancerConfig
121 | circuitBreaker?: CircuitBreakerConfig
122 | retry?: RetryPolicyConfig
123 | }
124 |
125 | // Defaults for consumers that want a baseline configuration
126 | export const DefaultRoutingConfig: RoutingConfig = {
127 | loadBalancer: { strategy: 'round_robin' },
128 | circuitBreaker: { failureThreshold: 5, successThreshold: 2, recoveryTimeoutMs: 30_000 },
129 | retry: { maxRetries: 2, baseDelayMs: 250, maxDelayMs: 4_000, backoffFactor: 2, jitter: 'full' },
130 | }
131 |
132 | export const DefaultHostingConfig: HostingConfig = {
133 | platform: 'node',
134 | port: 3000,
135 | }
136 |
```
--------------------------------------------------------------------------------
/docs/guides/authentication.md:
--------------------------------------------------------------------------------
```markdown
1 | # Authentication Guide
2 |
3 | Master MCP Server supports multiple authentication strategies between the client (master) and each backend server.
4 |
5 | ## Strategies
6 |
7 | - master_oauth: Pass the client token from the master directly to the backend.
8 | - delegate_oauth: Instruct the client to complete an OAuth flow against the backend provider, then store a backend token.
9 | - proxy_oauth: Use the master to refresh and proxy backend tokens, falling back to pass-through.
10 | - bypass_auth: No auth headers are sent to the backend.
11 |
12 | Configure per-server via `servers[].auth_strategy` and optional `servers[].auth_config`.
13 |
14 | <AuthFlowDemo />
15 |
16 | <CodeTabs :options="[
17 | { label: 'master_oauth', value: 'master' },
18 | { label: 'delegate_oauth', value: 'delegate' },
19 | { label: 'proxy_oauth', value: 'proxy' },
20 | { label: 'bypass_auth', value: 'bypass' }
21 | ]">
22 | <template #master>
23 |
24 | ```yaml
25 | servers:
26 | - id: search
27 | type: local
28 | auth_strategy: master_oauth
29 | config: { port: 4100 }
30 | ```
31 |
32 | </template>
33 | <template #delegate>
34 |
35 | ```yaml
36 | servers:
37 | - id: github-tools
38 | type: local
39 | auth_strategy: delegate_oauth
40 | auth_config:
41 | provider: github
42 | authorization_endpoint: https://github.com/login/oauth/authorize
43 | token_endpoint: https://github.com/login/oauth/access_token
44 | client_id: ${GITHUB_CLIENT_ID}
45 | client_secret: env:GITHUB_CLIENT_SECRET
46 | scopes: [repo, read:user]
47 | config: { port: 4010 }
48 | ```
49 |
50 | </template>
51 | <template #proxy>
52 |
53 | ```yaml
54 | servers:
55 | - id: internal
56 | type: local
57 | auth_strategy: proxy_oauth
58 | auth_config:
59 | token_source: env:INTERNAL_BACKEND_TOKEN
60 | config: { port: 4200 }
61 | ```
62 |
63 | </template>
64 | <template #bypass>
65 |
66 | ```yaml
67 | servers:
68 | - id: public
69 | type: local
70 | auth_strategy: bypass_auth
71 | config: { port: 4300 }
72 | ```
73 |
74 | </template>
75 | </CodeTabs>
76 |
77 | ```yaml
78 | servers:
79 | - id: github-tools
80 | type: local
81 | auth_strategy: delegate_oauth
82 | auth_config:
83 | provider: github
84 | authorization_endpoint: https://github.com/login/oauth/authorize
85 | token_endpoint: https://github.com/login/oauth/access_token
86 | client_id: ${GITHUB_CLIENT_ID}
87 | client_secret: env:GITHUB_CLIENT_SECRET
88 | scopes: [repo, read:user]
89 | config:
90 | port: 4010
91 | ```
92 |
93 | ## Flow Overview
94 |
95 | 1) Client calls a tool/resource via master with `Authorization: Bearer <client_token>`.
96 | 2) Master determines server strategy via `MultiAuthManager`.
97 | 3) If delegation is required, master responds with `{ type: 'oauth_delegation', ... }` metadata.
98 | 4) Client opens `GET /oauth/authorize?server_id=<id>` to initiate the auth code + PKCE flow.
99 | 5) Redirect back to `GET /oauth/callback` stores the backend token (associated with client token + server id).
100 | 6) Retries to the backend now include `Authorization: Bearer <server_token>` as needed.
101 |
102 | ## Endpoints
103 |
104 | - `GET /oauth/authorize` → Starts flow; query: `server_id`, optional `provider` if preconfigured.
105 | - `GET /oauth/callback` → Exchanges code for token and stores it.
106 | - `GET /oauth/success` + `GET /oauth/error` → Result pages.
107 |
108 | These are mounted automatically in the Node runtime (`src/index.ts`) and can be used in Workers via `OAuthFlowController.handleRequest()`.
109 |
110 | ## Customizing Auth
111 |
112 | Attach a custom `MultiAuthManager` instance to the `MasterServer`:
113 |
114 | ```ts
115 | import { MasterServer } from '../src/server/master-server'
116 | import { MultiAuthManager } from '../src/auth/multi-auth-manager'
117 |
118 | const master = new MasterServer()
119 | const auth = new MultiAuthManager(config.master_oauth)
120 | auth.registerServerAuth('github-tools', 'delegate_oauth', {/* provider config */})
121 | master.attachAuthManager(auth)
122 | ```
123 |
124 | See `examples/custom-auth` for a working example.
125 |
```
--------------------------------------------------------------------------------
/src/utils/errors.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Standardized error types and helpers.
3 | */
4 |
5 | export type ErrorSeverity = 'fatal' | 'error' | 'warn'
6 |
7 | export interface SerializedError {
8 | name: string
9 | message: string
10 | code?: string
11 | status?: number
12 | severity?: ErrorSeverity
13 | details?: unknown
14 | stack?: string
15 | cause?: SerializedError
16 | }
17 |
18 | export class AppError extends Error {
19 | code?: string
20 | status?: number
21 | severity: ErrorSeverity
22 | details?: unknown
23 | override cause?: unknown
24 |
25 | constructor(message: string, opts?: { code?: string; status?: number; severity?: ErrorSeverity; details?: unknown; cause?: unknown }) {
26 | super(message)
27 | this.name = this.constructor.name
28 | this.code = opts?.code
29 | this.status = opts?.status
30 | this.severity = opts?.severity ?? 'error'
31 | this.details = opts?.details
32 | this.cause = opts?.cause
33 | }
34 |
35 | toJSON(): SerializedError {
36 | return serializeError(this)
37 | }
38 | }
39 |
40 | export class ValidationError extends AppError {
41 | constructor(message: string, details?: unknown) {
42 | super(message, { code: 'VALIDATION_ERROR', status: 400, details, severity: 'warn' })
43 | }
44 | }
45 |
46 | export class AuthError extends AppError {
47 | constructor(message: string, details?: unknown) {
48 | super(message, { code: 'AUTH_ERROR', status: 401, details })
49 | }
50 | }
51 |
52 | export class PermissionError extends AppError {
53 | constructor(message: string, details?: unknown) {
54 | super(message, { code: 'FORBIDDEN', status: 403, details })
55 | }
56 | }
57 |
58 | export class NotFoundError extends AppError {
59 | constructor(message: string, details?: unknown) {
60 | super(message, { code: 'NOT_FOUND', status: 404, details, severity: 'warn' })
61 | }
62 | }
63 |
64 | export class RateLimitError extends AppError {
65 | constructor(message: string, details?: unknown) {
66 | super(message, { code: 'RATE_LIMITED', status: 429, details, severity: 'warn' })
67 | }
68 | }
69 |
70 | export class ExternalServiceError extends AppError {
71 | constructor(message: string, details?: unknown) {
72 | super(message, { code: 'EXTERNAL_SERVICE_ERROR', status: 502, details })
73 | }
74 | }
75 |
76 | export class CircuitBreakerError extends AppError {
77 | constructor(message: string, details?: unknown) {
78 | super(message, { code: 'CIRCUIT_OPEN', status: 503, details })
79 | }
80 | }
81 |
82 | export function serializeError(err: unknown): SerializedError {
83 | if (err instanceof AppError) {
84 | return {
85 | name: err.name,
86 | message: err.message,
87 | code: err.code,
88 | status: err.status,
89 | severity: err.severity,
90 | details: err.details,
91 | stack: err.stack,
92 | cause: err.cause ? serializeError(err.cause as any) : undefined,
93 | }
94 | }
95 | if (err instanceof Error) {
96 | return {
97 | name: err.name,
98 | message: err.message,
99 | stack: err.stack,
100 | }
101 | }
102 | return { name: 'Error', message: String(err) }
103 | }
104 |
105 | export function deserializeError(obj: SerializedError): AppError {
106 | const err = new AppError(obj.message, {
107 | code: obj.code,
108 | status: obj.status,
109 | severity: obj.severity,
110 | details: obj.details,
111 | cause: obj.cause ? deserializeError(obj.cause) : undefined,
112 | })
113 | err.name = obj.name
114 | err.stack = obj.stack
115 | return err
116 | }
117 |
118 | export function withErrorContext<T>(fn: () => Promise<T>, context: Record<string, unknown>): Promise<T> {
119 | return fn().catch((e) => {
120 | if (e instanceof AppError) throw new AppError(e.message, { ...e, details: { ...(e.details as any), ...context } })
121 | const err = new AppError('Unhandled error', { code: 'UNHANDLED', details: { cause: serializeError(e), ...context } })
122 | throw err
123 | })
124 | }
125 |
126 | export function stackTrace(e?: Error): string {
127 | const err = e ?? new Error('stack')
128 | return err.stack || ''
129 | }
130 |
131 | export function isAppError(e: unknown): e is AppError {
132 | return e instanceof AppError
133 | }
134 |
```
--------------------------------------------------------------------------------
/examples/custom-auth/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import express from 'express'
2 | import type { Request, Response } from 'express'
3 | import { ConfigLoader } from '../../src/config/config-loader.js'
4 | import { MasterServer } from '../../src/server/master-server.js'
5 | import { MultiAuthManager } from '../../src/auth/multi-auth-manager.js'
6 | import { CapabilityAggregator } from '../../src/modules/capability-aggregator.js'
7 | import { collectSystemMetrics } from '../../src/utils/monitoring.js'
8 |
9 | // A custom auth manager that adds an extra header for a specific backend
10 | class CustomAuthManager extends MultiAuthManager {
11 | override async handleMasterOAuth(serverId: string, clientToken: string) {
12 | const base = await super.handleMasterOAuth(serverId, clientToken)
13 | // Example: add a hint header for a particular backend
14 | if (serverId === 'custom-proxy') {
15 | return { ...base, 'X-Custom-Auth': 'enabled' }
16 | }
17 | return base
18 | }
19 | }
20 |
21 | async function main() {
22 | const configPath = process.env.MASTER_CONFIG_PATH || 'examples/custom-auth/config.yaml'
23 | const cfg = await ConfigLoader.load({ path: configPath })
24 |
25 | const master = new MasterServer()
26 | const customAuth = new CustomAuthManager(cfg.master_oauth)
27 | // Register per-server strategies from config
28 | for (const s of cfg.servers) customAuth.registerServerAuth(s.id, s.auth_strategy as any, s.auth_config as any)
29 | master.attachAuthManager(customAuth)
30 |
31 | await master.startFromConfig(cfg)
32 |
33 | const app = express()
34 | app.use(express.json())
35 |
36 | app.get('/health', (_req, res) => res.json({ ok: true }))
37 | app.get('/metrics', (_req, res) => res.json({ ok: true, system: collectSystemMetrics() }))
38 |
39 | // OAuth endpoints
40 | master.getOAuthFlowController().registerExpress(app)
41 |
42 | // Capabilities
43 | app.get('/capabilities', (_req: Request, res: Response) => {
44 | const agg = new CapabilityAggregator()
45 | const caps = agg.aggregate(Array.from(master.getRouter().getServers().values()))
46 | res.json(caps)
47 | })
48 |
49 | const getToken = (req: Request) => {
50 | const h = req.headers['authorization'] || req.headers['Authorization']
51 | return typeof h === 'string' && h.toLowerCase().startsWith('bearer ') ? h.slice(7) : undefined
52 | }
53 |
54 | // MCP endpoints
55 | app.post('/mcp/tools/list', async (_req: Request, res: Response) => {
56 | const handler = master.handler
57 | const result = await handler.handleListTools({ type: 'list_tools' } as any)
58 | res.json(result)
59 | })
60 |
61 | app.post('/mcp/tools/call', async (req: Request, res: Response) => {
62 | const token = getToken(req)
63 | const handler = new (master.handler.constructor as any)({
64 | aggregator: (master as any).aggregator,
65 | router: master.getRouter(),
66 | getClientToken: () => token,
67 | }) as typeof master.handler
68 | const result = await handler.handleCallTool({ name: req.body?.name, arguments: req.body?.arguments ?? {} } as any)
69 | res.json(result)
70 | })
71 |
72 | app.post('/mcp/resources/list', async (_req: Request, res: Response) => {
73 | const handler = master.handler
74 | const result = await handler.handleListResources({ type: 'list_resources' } as any)
75 | res.json(result)
76 | })
77 |
78 | app.post('/mcp/resources/read', async (req: Request, res: Response) => {
79 | const token = getToken(req)
80 | const handler = new (master.handler.constructor as any)({
81 | aggregator: (master as any).aggregator,
82 | router: master.getRouter(),
83 | getClientToken: () => token,
84 | }) as typeof master.handler
85 | const result = await handler.handleReadResource({ uri: req.body?.uri } as any)
86 | res.json(result)
87 | })
88 |
89 | const port = cfg.hosting.port || 3000
90 | app.listen(port, () => console.log(`Custom auth example on :${port}`))
91 | }
92 |
93 | main().catch((err) => {
94 | console.error(err)
95 | process.exit(1)
96 | })
97 |
98 |
```
--------------------------------------------------------------------------------
/tests/unit/routing/circuit-breaker.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { test } from 'node:test'
2 | import assert from 'node:assert'
3 | import { CircuitBreaker, InMemoryCircuitStorage } from '../../../src/routing/circuit-breaker.js'
4 |
5 | test('CircuitBreaker onSuccess in closed state should only reset failures', async () => {
6 | const storage = new InMemoryCircuitStorage()
7 | const breaker = new CircuitBreaker({
8 | failureThreshold: 2,
9 | successThreshold: 2,
10 | recoveryTimeoutMs: 100,
11 | }, storage)
12 | const key = 'test-key'
13 |
14 | // Simulate some failures, but not enough to open the circuit
15 | breaker.onFailure(key)
16 | let record = storage.get(key)
17 | assert.strictEqual(record?.failures, 1, 'should have 1 failure')
18 | assert.strictEqual(record?.state, 'closed', 'should be in closed state')
19 |
20 | // Simulate a success
21 | breaker.onSuccess(key)
22 | record = storage.get(key)
23 | assert.strictEqual(record?.failures, 0, 'failures should be reset to 0')
24 | assert.strictEqual(record?.successes, 0, 'successes should remain 0') // This is the key check for the bug
25 | assert.strictEqual(record?.state, 'closed', 'should remain in closed state')
26 | })
27 |
28 | test('CircuitBreaker should open after reaching failure threshold', async () => {
29 | const storage = new InMemoryCircuitStorage()
30 | const breaker = new CircuitBreaker({
31 | failureThreshold: 2,
32 | successThreshold: 2,
33 | recoveryTimeoutMs: 100,
34 | }, storage)
35 | const key = 'test-key'
36 |
37 | breaker.onFailure(key)
38 | breaker.onFailure(key)
39 |
40 | const record = storage.get(key)
41 | assert.strictEqual(record?.state, 'open', 'should be in open state')
42 | assert.strictEqual(record?.failures, 2, 'should have 2 failures')
43 | })
44 |
45 | test('CircuitBreaker should transition to half-open after recovery timeout', async () => {
46 | const storage = new InMemoryCircuitStorage()
47 | const breaker = new CircuitBreaker({
48 | failureThreshold: 2,
49 | successThreshold: 2,
50 | recoveryTimeoutMs: 50,
51 | }, storage)
52 | const key = 'test-key'
53 |
54 | // Open the circuit
55 | breaker.onFailure(key)
56 | breaker.onFailure(key)
57 |
58 | // Wait for recovery timeout
59 | await new Promise(resolve => setTimeout(resolve, 60))
60 |
61 | // First call should be allowed and move to half-open
62 | const gate = breaker.canExecute(key)
63 | assert.strictEqual(gate.allowed, true, 'execution should be allowed')
64 |
65 | const record = storage.get(key)
66 | assert.strictEqual(record?.state, 'half_open', 'should be in half-open state')
67 | })
68 |
69 | test('CircuitBreaker should close after successes in half-open state', async () => {
70 | const storage = new InMemoryCircuitStorage()
71 | const breaker = new CircuitBreaker({
72 | failureThreshold: 2,
73 | successThreshold: 2,
74 | recoveryTimeoutMs: 50,
75 | }, storage)
76 | const key = 'test-key'
77 |
78 | // Open the circuit
79 | breaker.onFailure(key)
80 | breaker.onFailure(key)
81 |
82 | // Wait for recovery timeout
83 | await new Promise(resolve => setTimeout(resolve, 60))
84 |
85 | // Transition to half-open
86 | breaker.canExecute(key)
87 |
88 | // Succeed twice
89 | breaker.onSuccess(key)
90 | breaker.onSuccess(key)
91 |
92 | const record = storage.get(key)
93 | assert.strictEqual(record?.state, 'closed', 'should be in closed state')
94 | assert.strictEqual(record?.failures, 0, 'failures should be reset')
95 | assert.strictEqual(record?.successes, 0, 'successes should be reset')
96 | })
97 |
98 | test('CircuitBreaker should re-open after failure in half-open state', async () => {
99 | const storage = new InMemoryCircuitStorage()
100 | const breaker = new CircuitBreaker({
101 | failureThreshold: 2,
102 | successThreshold: 2,
103 | recoveryTimeoutMs: 50,
104 | }, storage)
105 | const key = 'test-key'
106 |
107 | // Open the circuit
108 | breaker.onFailure(key)
109 | breaker.onFailure(key)
110 |
111 | // Wait for recovery timeout
112 | await new Promise(resolve => setTimeout(resolve, 60))
113 |
114 | // Transition to half-open
115 | breaker.canExecute(key)
116 |
117 | // Fail once
118 | breaker.onFailure(key)
119 |
120 | const record = storage.get(key)
121 | assert.strictEqual(record?.state, 'open', 'should be in open state again')
122 | })
```
--------------------------------------------------------------------------------
/examples/test-stdio-server.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 |
3 | // Simple test STDIO server for testing purposes
4 |
5 | // Simple JSON-RPC server that echoes back requests
6 |
7 | // Function to send a response
8 | function sendResponse(response) {
9 | process.stdout.write(JSON.stringify(response) + '\n');
10 | }
11 |
12 | // Function to send a notification
13 | function sendNotification(notification) {
14 | process.stdout.write(JSON.stringify(notification) + '\n');
15 | }
16 |
17 | // Handle incoming requests
18 | process.stdin.on('data', (data) => {
19 | try {
20 | const lines = data.toString().split('\n').filter(line => line.trim() !== '');
21 | for (const line of lines) {
22 | if (line.trim() === '') continue;
23 |
24 | const request = JSON.parse(line);
25 |
26 | // Handle initialize request
27 | if (request.method === 'initialize') {
28 | const response = {
29 | jsonrpc: '2.0',
30 | id: request.id,
31 | result: {
32 | protocolVersion: '2025-06-18',
33 | capabilities: {
34 | tools: {
35 | listChanged: true
36 | },
37 | resources: {
38 | listChanged: true
39 | }
40 | },
41 | serverInfo: {
42 | name: 'test-stdio-server',
43 | version: '1.0.0'
44 | }
45 | }
46 | };
47 | sendResponse(response);
48 | }
49 | // Handle tools/list request
50 | else if (request.method === 'tools/list') {
51 | const response = {
52 | jsonrpc: '2.0',
53 | id: request.id,
54 | result: {
55 | tools: [
56 | {
57 | name: 'stdio-echo',
58 | description: 'Echoes back the input',
59 | inputSchema: {
60 | type: 'object',
61 | properties: {
62 | message: {
63 | type: 'string'
64 | }
65 | },
66 | required: ['message']
67 | }
68 | }
69 | ]
70 | }
71 | };
72 | sendResponse(response);
73 | }
74 | // Handle resources/list request
75 | else if (request.method === 'resources/list') {
76 | const response = {
77 | jsonrpc: '2.0',
78 | id: request.id,
79 | result: {
80 | resources: [
81 | {
82 | uri: 'stdio://example/resource',
83 | name: 'stdio-resource',
84 | description: 'A test resource from STDIO server'
85 | }
86 | ]
87 | }
88 | };
89 | sendResponse(response);
90 | }
91 | // Handle tools/call request
92 | else if (request.method === 'tools/call') {
93 | const response = {
94 | jsonrpc: '2.0',
95 | id: request.id,
96 | result: {
97 | content: [
98 | {
99 | type: 'text',
100 | text: `STDIO Echo: ${request.params.arguments?.message || 'No message'}`
101 | }
102 | ]
103 | }
104 | };
105 | sendResponse(response);
106 | }
107 | // Handle resources/read request
108 | else if (request.method === 'resources/read') {
109 | const response = {
110 | jsonrpc: '2.0',
111 | id: request.id,
112 | result: {
113 | contents: [
114 | {
115 | uri: request.params.uri,
116 | text: 'This is content from a STDIO server resource',
117 | mimeType: 'text/plain'
118 | }
119 | ]
120 | }
121 | };
122 | sendResponse(response);
123 | }
124 | // Handle unknown methods
125 | else {
126 | const response = {
127 | jsonrpc: '2.0',
128 | id: request.id,
129 | error: {
130 | code: -32601,
131 | message: `Method not found: ${request.method}`
132 | }
133 | };
134 | sendResponse(response);
135 | }
136 | }
137 | } catch (err) {
138 | // Send error response
139 | const errorResponse = {
140 | jsonrpc: '2.0',
141 | id: null,
142 | error: {
143 | code: -32700,
144 | message: 'Parse error',
145 | data: err.message
146 | }
147 | };
148 | sendResponse(errorResponse);
149 | }
150 | });
151 |
152 | // Send a notification that the server is ready
153 | sendNotification({
154 | jsonrpc: '2.0',
155 | method: 'notifications/initialized',
156 | params: {}
157 | });
158 |
159 | console.error('Test STDIO server started');
```
--------------------------------------------------------------------------------
/src/utils/http.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * HTTP utilities for request/response handling, header manipulation, and content
3 | * type parsing. Minimal and cross-platform (Node 18+ and Workers).
4 | */
5 |
6 | export type HeadersLike = Headers | Record<string, string> | Array<[string, string]>
7 |
8 | export function normalizeHeaders(h: HeadersLike | undefined | null): Record<string, string> {
9 | const out: Record<string, string> = {}
10 | if (!h) return out
11 | if (typeof (h as any).forEach === 'function') {
12 | ;(h as Headers).forEach((v, k) => (out[k.toLowerCase()] = v))
13 | return out
14 | }
15 | if (Array.isArray(h)) {
16 | for (const [k, v] of h) out[k.toLowerCase()] = String(v)
17 | return out
18 | }
19 | for (const [k, v] of Object.entries(h)) out[k.toLowerCase()] = String(v)
20 | return out
21 | }
22 |
23 | export function getHeader(h: HeadersLike | undefined | null, name: string): string | undefined {
24 | const map = normalizeHeaders(h)
25 | return map[name.toLowerCase()]
26 | }
27 |
28 | export function setHeader(h: HeadersLike | undefined | null, name: string, value: string): HeadersLike {
29 | if (!h) return { [name]: value }
30 | if (typeof (h as any).set === 'function') {
31 | const copy = new Headers(h as Headers)
32 | copy.set(name, value)
33 | return copy
34 | }
35 | const map = normalizeHeaders(h)
36 | map[name.toLowerCase()] = value
37 | return map
38 | }
39 |
40 | export function getContentType(h: HeadersLike | undefined | null): string | undefined {
41 | return getHeader(h, 'content-type')
42 | }
43 |
44 | export function isJsonContentType(ct?: string): boolean {
45 | if (!ct) return false
46 | return /application\/json|\+json/i.test(ct)
47 | }
48 |
49 | export async function parseBody(req: Request, limitBytes = 1_000_000): Promise<any> {
50 | const ct = getContentType(req.headers)
51 | if (isJsonContentType(ct)) {
52 | const text = await readTextLimited(req, limitBytes)
53 | try {
54 | return JSON.parse(text)
55 | } catch (e) {
56 | throw new Error('Invalid JSON body')
57 | }
58 | }
59 | if (/text\//i.test(ct || '')) return readTextLimited(req, limitBytes)
60 | // default to arrayBuffer
61 | const buf = await req.arrayBuffer()
62 | if (buf.byteLength > limitBytes) throw new Error('Body too large')
63 | return buf
64 | }
65 |
66 | export async function readTextLimited(req: Request, limitBytes: number): Promise<string> {
67 | const buf = await req.arrayBuffer()
68 | if (buf.byteLength > limitBytes) throw new Error('Body too large')
69 | return new TextDecoder().decode(buf)
70 | }
71 |
72 | export function jsonResponse(body: unknown, init?: ResponseInit): Response {
73 | const headers = new Headers(init?.headers)
74 | headers.set('content-type', 'application/json; charset=utf-8')
75 | const payload = JSON.stringify(body)
76 | return new Response(payload, { ...init, headers })
77 | }
78 |
79 | export function buildQuery(params: Record<string, string | number | boolean | null | undefined>): string {
80 | const usp = new URLSearchParams()
81 | for (const [k, v] of Object.entries(params)) {
82 | if (v === undefined || v === null) continue
83 | usp.append(k, String(v))
84 | }
85 | const s = usp.toString()
86 | return s ? `?${s}` : ''
87 | }
88 |
89 | export function appendQuery(url: string, params: Record<string, string | number | boolean | null | undefined>): string {
90 | const u = new URL(url, 'http://localhost')
91 | for (const [k, v] of Object.entries(params)) {
92 | if (v === undefined || v === null) continue
93 | u.searchParams.set(k, String(v))
94 | }
95 | if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
96 | // Relative URL; return pathname+search only
97 | return `${u.pathname}${u.search}`
98 | }
99 | return u.toString()
100 | }
101 |
102 | export function ensureCorrelationId(headers?: HeadersLike | null): string {
103 | const existing = headers && getHeader(headers, 'x-correlation-id')
104 | if (existing) return existing
105 | return randomId()
106 | }
107 |
108 | export function randomId(): string {
109 | const g: any = globalThis as any
110 | try {
111 | if (g.crypto?.randomUUID) return g.crypto.randomUUID()
112 | } catch {
113 | // ignore
114 | }
115 | try {
116 | if (g.crypto?.getRandomValues) {
117 | const bytes = new Uint8Array(16)
118 | g.crypto.getRandomValues(bytes)
119 | return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
120 | }
121 | } catch {
122 | // ignore
123 | }
124 | return Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2)
125 | }
126 |
127 |
```
--------------------------------------------------------------------------------
/examples/stdio-mcp-server.cjs:
--------------------------------------------------------------------------------
```
1 | #!/usr/bin/env node
2 |
3 | // Simple test STDIO server for testing purposes
4 | const fs = require('fs')
5 |
6 | // Simple JSON-RPC server that echoes back requests
7 | let messageId = 0
8 |
9 | // Function to send a response
10 | function sendResponse(response) {
11 | process.stdout.write(JSON.stringify(response) + '\n')
12 | }
13 |
14 | // Function to send a notification
15 | function sendNotification(notification) {
16 | process.stdout.write(JSON.stringify(notification) + '\n')
17 | }
18 |
19 | // Handle incoming requests
20 | process.stdin.on('data', (data) => {
21 | try {
22 | const lines = data.toString().split('\n').filter(line => line.trim() !== '')
23 | for (const line of lines) {
24 | if (line.trim() === '') continue
25 |
26 | const request = JSON.parse(line)
27 |
28 | // Handle initialize request
29 | if (request.method === 'initialize') {
30 | const response = {
31 | jsonrpc: '2.0',
32 | id: request.id,
33 | result: {
34 | protocolVersion: '2025-06-18',
35 | capabilities: {
36 | tools: {
37 | listChanged: true
38 | },
39 | resources: {
40 | listChanged: true
41 | }
42 | },
43 | serverInfo: {
44 | name: 'test-stdio-server',
45 | version: '1.0.0'
46 | }
47 | }
48 | }
49 | sendResponse(response)
50 | }
51 | // Handle tools/list request
52 | else if (request.method === 'tools/list') {
53 | const response = {
54 | jsonrpc: '2.0',
55 | id: request.id,
56 | result: {
57 | tools: [
58 | {
59 | name: 'stdio-echo',
60 | description: 'Echoes back the input',
61 | inputSchema: {
62 | type: 'object',
63 | properties: {
64 | message: {
65 | type: 'string'
66 | }
67 | },
68 | required: ['message']
69 | }
70 | }
71 | ]
72 | }
73 | }
74 | sendResponse(response)
75 | }
76 | // Handle resources/list request
77 | else if (request.method === 'resources/list') {
78 | const response = {
79 | jsonrpc: '2.0',
80 | id: request.id,
81 | result: {
82 | resources: [
83 | {
84 | uri: 'stdio://example/resource',
85 | name: 'stdio-resource',
86 | description: 'A test resource from STDIO server'
87 | }
88 | ]
89 | }
90 | }
91 | sendResponse(response)
92 | }
93 | // Handle tools/call request
94 | else if (request.method === 'tools/call') {
95 | const response = {
96 | jsonrpc: '2.0',
97 | id: request.id,
98 | result: {
99 | content: [
100 | {
101 | type: 'text',
102 | text: `STDIO Echo: ${request.params.arguments?.message || 'No message'}`
103 | }
104 | ]
105 | }
106 | }
107 | sendResponse(response)
108 | }
109 | // Handle resources/read request
110 | else if (request.method === 'resources/read') {
111 | const response = {
112 | jsonrpc: '2.0',
113 | id: request.id,
114 | result: {
115 | contents: [
116 | {
117 | uri: request.params.uri,
118 | text: 'This is content from a STDIO server resource',
119 | mimeType: 'text/plain'
120 | }
121 | ]
122 | }
123 | }
124 | sendResponse(response)
125 | }
126 | // Handle unknown methods
127 | else {
128 | const response = {
129 | jsonrpc: '2.0',
130 | id: request.id,
131 | error: {
132 | code: -32601,
133 | message: `Method not found: ${request.method}`
134 | }
135 | }
136 | sendResponse(response)
137 | }
138 | }
139 | } catch (err) {
140 | // Send error response
141 | const errorResponse = {
142 | jsonrpc: '2.0',
143 | id: null,
144 | error: {
145 | code: -32700,
146 | message: 'Parse error',
147 | data: err.message
148 | }
149 | }
150 | sendResponse(errorResponse)
151 | }
152 | })
153 |
154 | // Send a notification that the server is ready
155 | sendNotification({
156 | jsonrpc: '2.0',
157 | method: 'notifications/initialized',
158 | params: {}
159 | })
160 |
161 | console.error('Test STDIO server started')
```