├── .changeset ├── README.md └── config.json ├── .eslintignore ├── .github ├── actions │ ├── branch-info │ │ └── action.yml │ ├── install │ │ └── action.yml │ ├── local-payloads │ │ ├── README.md │ │ └── example-pr.json │ └── setup │ │ └── action.yml └── workflows │ ├── ci-checks.yml │ ├── ci-setup.yml │ ├── main.yml │ └── pr.yml ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── .yarn ├── patches │ └── @changesets-cli-npm-2.29.3-b3f38d424c.patch ├── plugins │ └── @yarnpkg │ │ └── plugin-engines.cjs └── releases │ └── yarn-4.9.1.cjs ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apps └── web │ ├── app.config.ts │ ├── eslint.config.js │ ├── package.json │ ├── public │ └── favicon.ico │ ├── src │ ├── assets │ │ ├── app.css │ │ └── fonts │ │ │ └── ml │ │ │ ├── 0-normal.woff2 │ │ │ └── 1-italic.woff2 │ ├── client.tsx │ ├── components │ │ └── CommandExplorer.tsx │ ├── routeTree.gen.ts │ ├── router.tsx │ ├── routes │ │ ├── __root.tsx │ │ └── index.tsx │ └── ssr.tsx │ ├── tsconfig.json │ └── wrangler.jsonc ├── nx.json ├── package.json ├── packages ├── cli │ ├── CHANGELOG.md │ ├── DEVELOPMENT.md │ ├── README.md │ ├── bin │ │ └── index.js │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── api │ │ │ └── index.ts │ │ ├── commands │ │ │ ├── install │ │ │ │ ├── handler.ts │ │ │ │ ├── index.ts │ │ │ │ └── openapi │ │ │ │ │ ├── create-remix.ts │ │ │ │ │ ├── generate-remix.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── negotiate-filepath.ts │ │ │ │ │ └── types.ts │ │ │ ├── login │ │ │ │ └── index.ts │ │ │ ├── logout │ │ │ │ └── index.ts │ │ │ ├── run │ │ │ │ ├── handler.ts │ │ │ │ └── index.ts │ │ │ ├── uninstall │ │ │ │ ├── handler.ts │ │ │ │ └── index.ts │ │ │ ├── upload │ │ │ │ ├── handler.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mcp-utils │ │ │ │ │ ├── create-client.ts │ │ │ │ │ └── get-tools.ts │ │ │ │ ├── transport-definitions │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── openapi.ts │ │ │ │ │ ├── sse.ts │ │ │ │ │ ├── stdio.ts │ │ │ │ │ ├── streamable-http.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils │ │ │ │ │ ├── __tests__ │ │ │ │ │ ├── mask-headers.spec.ts │ │ │ │ │ └── mask-url.spec.ts │ │ │ │ │ ├── config-schema.ts │ │ │ │ │ ├── mask-headers.ts │ │ │ │ │ ├── mask-url.ts │ │ │ │ │ ├── parse-cmd │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ │ ├── parse-docker-run.spec.ts │ │ │ │ │ │ ├── parse-env-variables.spec.ts │ │ │ │ │ │ ├── parse-generic.spec.ts │ │ │ │ │ │ └── parse-npx.spec.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── parse-docker-run.ts │ │ │ │ │ ├── parse-env-variables.ts │ │ │ │ │ ├── parse-generic.ts │ │ │ │ │ ├── parse-npx-command.ts │ │ │ │ │ ├── parsed-command.ts │ │ │ │ │ └── types.ts │ │ │ │ │ └── string.ts │ │ │ └── whoami │ │ │ │ └── index.ts │ │ ├── env.ts │ │ ├── errors │ │ │ ├── guards.ts │ │ │ ├── handler-error.ts │ │ │ ├── index.ts │ │ │ ├── printable-error.ts │ │ │ └── ui-errors.ts │ │ ├── index.ts │ │ ├── libs │ │ │ ├── cli-utils │ │ │ │ ├── create-handler.ts │ │ │ │ └── index.ts │ │ │ ├── console │ │ │ │ ├── index.ts │ │ │ │ ├── prompts │ │ │ │ │ ├── confirm.ts │ │ │ │ │ ├── guards.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── select.ts │ │ │ │ │ ├── state.ts │ │ │ │ │ ├── text.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── url.ts │ │ │ │ │ └── user.ts │ │ │ │ ├── reporters │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── log-file.spec.ts │ │ │ │ │ ├── console.ts │ │ │ │ │ ├── fancy │ │ │ │ │ │ ├── fancy.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── log-file.ts │ │ │ │ └── types.ts │ │ │ ├── datanaut │ │ │ │ ├── agent.ts │ │ │ │ ├── auth-cli.ts │ │ │ │ ├── auth │ │ │ │ │ ├── api.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── server.ts │ │ │ │ │ └── storage │ │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── mutex.spec.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── mutex.ts │ │ │ │ ├── fs-config.ts │ │ │ │ └── sdk │ │ │ │ │ └── sdk.ts │ │ │ ├── mcp-clients │ │ │ │ ├── config │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ ├── read.spec.ts │ │ │ │ │ │ ├── resolve-path.spec.ts │ │ │ │ │ │ └── write.spec.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── read.ts │ │ │ │ │ ├── resolve-path.ts │ │ │ │ │ └── write.ts │ │ │ │ ├── errors │ │ │ │ │ ├── guards.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── install-method-unavailable.ts │ │ │ │ │ ├── server-conflict.ts │ │ │ │ │ └── server-not-installed.ts │ │ │ │ ├── get-install-filepath.ts │ │ │ │ ├── get-install-hints.ts │ │ │ │ ├── index.isomorphic.ts │ │ │ │ ├── index.ts │ │ │ │ ├── install.ts │ │ │ │ ├── integrations │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ ├── fixtures │ │ │ │ │ │ │ ├── generic.ts │ │ │ │ │ │ │ ├── goose.ts │ │ │ │ │ │ │ └── vscode.ts │ │ │ │ │ │ ├── generic.spec.ts │ │ │ │ │ │ ├── goose.spec.ts │ │ │ │ │ │ └── vscode.spec.ts │ │ │ │ │ ├── cursor.ts │ │ │ │ │ ├── generic.ts │ │ │ │ │ ├── goose.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── utils │ │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ │ ├── find-matching-install-method.spec.ts │ │ │ │ │ │ │ ├── generate-transport.spec.ts │ │ │ │ │ │ │ └── uwrap-matching-install-method.spec.ts │ │ │ │ │ │ ├── find-existing-server.ts │ │ │ │ │ │ ├── find-matching-install-method.ts │ │ │ │ │ │ ├── generate-definition-workspace-path.ts │ │ │ │ │ │ ├── generate-server-name.ts │ │ │ │ │ │ ├── generate-transport.ts │ │ │ │ │ │ ├── guards.ts │ │ │ │ │ │ ├── parse-generic-server.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── uwrap-matching-install-method.ts │ │ │ │ │ └── vscode.ts │ │ │ │ ├── types.ts │ │ │ │ └── uninstall.ts │ │ │ ├── mcp-utils │ │ │ │ ├── index.ts │ │ │ │ └── infer-target-type.ts │ │ │ ├── observable │ │ │ │ ├── __tests__ │ │ │ │ │ ├── array.spec.ts │ │ │ │ │ └── ref.spec.ts │ │ │ │ ├── array.ts │ │ │ │ ├── hooks │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── ref.ts │ │ │ │ └── types.ts │ │ │ ├── openapi │ │ │ │ ├── __tests__ │ │ │ │ │ ├── fixtures │ │ │ │ │ │ ├── petstore.json │ │ │ │ │ │ ├── slack.json │ │ │ │ │ │ └── weather-gov.json │ │ │ │ │ └── list-tools.spec.ts │ │ │ │ ├── cli.ts │ │ │ │ ├── cli │ │ │ │ │ ├── security.ts │ │ │ │ │ └── server-url.ts │ │ │ │ ├── http-spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-tools.ts │ │ │ │ ├── load-document-as-service.ts │ │ │ │ ├── security │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ ├── fixtures │ │ │ │ │ │ │ ├── api-key-security-fragment.ts │ │ │ │ │ │ │ ├── empty-security-array-fragment.ts │ │ │ │ │ │ │ ├── empty-security-fragment.ts │ │ │ │ │ │ │ ├── http-basic-security-fragment.ts │ │ │ │ │ │ │ ├── http-bearer-security-fragment.ts │ │ │ │ │ │ │ ├── http-bearer-with-format-security-fragment.ts │ │ │ │ │ │ │ ├── http-unsupported-scheme-fragment.ts │ │ │ │ │ │ │ ├── multiple-and-security-fragment.ts │ │ │ │ │ │ │ ├── multiple-or-security-fragment.ts │ │ │ │ │ │ │ ├── mutual-tls-security-fragment.ts │ │ │ │ │ │ │ ├── no-global-security-fragment.ts │ │ │ │ │ │ │ ├── oauth2-security-fragment.ts │ │ │ │ │ │ │ └── openid-connect-security-fragment.ts │ │ │ │ │ │ └── resolve-security-schemes.spec.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── resolve-security-schemes.ts │ │ │ │ └── server │ │ │ │ │ ├── __tests__ │ │ │ │ │ ├── apply-url-variables.spec.ts │ │ │ │ │ ├── fixtures │ │ │ │ │ │ ├── empty-servers-array-fragment.ts │ │ │ │ │ │ ├── empty-servers-fragment.ts │ │ │ │ │ │ ├── invalid-templated-server-fragment.ts │ │ │ │ │ │ ├── invalid-url-server-fragment.ts │ │ │ │ │ │ ├── mixed-servers-fragment.ts │ │ │ │ │ │ ├── multiple-variables-fragment.ts │ │ │ │ │ │ ├── non-templated-servers-fragment.ts │ │ │ │ │ │ ├── openapi-document.ts │ │ │ │ │ │ ├── server-with-name-fragment.ts │ │ │ │ │ │ ├── templated-server-with-default-fragment.ts │ │ │ │ │ │ └── templated-server-with-enum-fragment.ts │ │ │ │ │ └── resolve-servers.spec.ts │ │ │ │ │ ├── apply-url-variables.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── resolve-servers.ts │ │ │ │ │ └── resolve-templated-server.ts │ │ │ ├── platform │ │ │ │ ├── constants.ts │ │ │ │ ├── get-platform.ts │ │ │ │ └── index.ts │ │ │ ├── remix │ │ │ │ ├── config │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ ├── fixtures │ │ │ │ │ │ │ ├── config-with-absolute-path.json │ │ │ │ │ │ │ ├── config-with-env.json │ │ │ │ │ │ │ ├── config-with-file-url.json │ │ │ │ │ │ │ ├── config-with-undefined-env.json │ │ │ │ │ │ │ ├── config-with-url.json │ │ │ │ │ │ │ ├── remote-config.json │ │ │ │ │ │ │ └── valid-config.json │ │ │ │ │ │ ├── load.spec.ts │ │ │ │ │ │ └── schemas.spec.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── load.ts │ │ │ │ │ ├── parse.ts │ │ │ │ │ └── schemas.ts │ │ │ │ ├── errors.ts │ │ │ │ ├── index.ts │ │ │ │ ├── manager │ │ │ │ │ ├── client-servers │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── register-client-servers.ts │ │ │ │ │ │ └── scoped-client-server.ts │ │ │ │ │ ├── remix-manager.ts │ │ │ │ │ └── servers │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── interpolate-openapi-client-config.ts │ │ │ │ │ │ ├── register-servers.ts │ │ │ │ │ │ └── to-transport-config.ts │ │ │ │ ├── server.ts │ │ │ │ └── utils │ │ │ │ │ ├── __tests__ │ │ │ │ │ └── strict-replace-variables.spec.ts │ │ │ │ │ ├── strict-replace-variables.ts │ │ │ │ │ └── tools.ts │ │ │ └── string-utils │ │ │ │ └── index.ts │ │ ├── register.ts │ │ ├── rpc │ │ │ ├── agents.ts │ │ │ ├── base.ts │ │ │ ├── index.ts │ │ │ ├── mcp-servers.ts │ │ │ └── schemas.ts │ │ └── ui │ │ │ ├── app.tsx │ │ │ ├── components │ │ │ ├── input-label.tsx │ │ │ └── text-row.tsx │ │ │ ├── index.ts │ │ │ ├── input │ │ │ ├── confirm.tsx │ │ │ ├── index.ts │ │ │ ├── selects │ │ │ │ ├── components │ │ │ │ │ ├── option-item.tsx │ │ │ │ │ ├── options-list.tsx │ │ │ │ │ └── search-display.tsx │ │ │ │ ├── hooks │ │ │ │ │ ├── useFilteredOptions.ts │ │ │ │ │ ├── useHighlightedIndex.ts │ │ │ │ │ ├── useMultiSelectInput.ts │ │ │ │ │ ├── useSearchInput.ts │ │ │ │ │ ├── useSelectInput.ts │ │ │ │ │ └── useSelectInputBase.ts │ │ │ │ ├── index.ts │ │ │ │ ├── multi-select.tsx │ │ │ │ ├── select.tsx │ │ │ │ └── types.ts │ │ │ └── text.tsx │ │ │ ├── logs.tsx │ │ │ └── prompt.tsx │ ├── tsconfig.json │ ├── tsup.config.ts │ └── vitest.config.ts ├── manager │ ├── CHANGELOG.md │ ├── README.md │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── client-servers.ts │ │ ├── index.ts │ │ ├── manager.ts │ │ ├── servers.ts │ │ ├── storage │ │ │ ├── index.ts │ │ │ └── memory.ts │ │ ├── transport.ts │ │ └── types.ts │ ├── tests │ │ ├── client-servers.spec.ts │ │ ├── manager.test.ts │ │ └── servers.spec.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── openapi │ ├── .turbo │ │ ├── turbo-build.log │ │ ├── turbo-lint.log │ │ ├── turbo-test.log │ │ └── turbo-typecheck.log │ ├── CHANGELOG.md │ ├── __tests__ │ │ ├── __fixtures__ │ │ │ └── openapi │ │ │ │ ├── petstore.json │ │ │ │ ├── slack.json │ │ │ │ └── weather-gov.json │ │ ├── __snapshots__ │ │ │ └── openapi │ │ │ │ ├── petstore.json.snap │ │ │ │ ├── slack.json.snap │ │ │ │ └── weather-gov.json.snap │ │ ├── client.test.ts │ │ └── index.test.ts │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── client.ts │ │ ├── index.ts │ │ └── openapi-to-mcp.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── schemas │ ├── CHANGELOG.md │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ └── mcp.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── server │ ├── CHANGELOG.md │ ├── eslint.config.js │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── resources.ts │ │ ├── server.ts │ │ └── tools.ts │ ├── tsconfig.json │ └── tsup.config.ts └── utils │ ├── CHANGELOG.md │ ├── eslint.config.js │ ├── package.json │ ├── src │ ├── auto-trim.ts │ ├── documents │ │ ├── __tests__ │ │ │ ├── fixtures │ │ │ │ ├── invalid-document.json │ │ │ │ ├── invalid-document.yaml │ │ │ │ ├── non-object-document.json │ │ │ │ ├── unsupported-document.txt │ │ │ │ ├── valid-document.json │ │ │ │ ├── valid-document.jsonc │ │ │ │ ├── valid-document.yaml │ │ │ │ └── valid-document.yml │ │ │ ├── parse.spec.ts │ │ │ └── read.spec.ts │ │ ├── index.ts │ │ ├── load.ts │ │ ├── parse.ts │ │ ├── read.ts │ │ └── serialize.ts │ ├── errors.ts │ ├── index.ts │ └── replace-variables.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── scripts └── openmcp-dev.sh ├── yarn.config.cjs └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@changesets/config/schema.json", 3 | "access": "public", 4 | "baseBranch": "main", 5 | "changelog": ["@changesets/changelog-github", { "repo": "getdatanaut/openmcp" }], 6 | "commit": false, 7 | "fixed": [], 8 | "ignore": ["web"], 9 | "linked": [], 10 | "updateInternalDependencies": "patch" 11 | } 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/worker-configuration.d.ts 2 | **/routeTree.gen.ts 3 | **/schema.gen.ts 4 | .yalc 5 | -------------------------------------------------------------------------------- /.github/actions/branch-info/action.yml: -------------------------------------------------------------------------------- 1 | name: Branch Info 2 | 3 | description: 'Retrieves and normalizes information about the current branch' 4 | 5 | # Export the branch names as output to be able to use it in other jobs 6 | outputs: 7 | base-branch-name: 8 | description: The base branch name. 9 | value: ${{ steps.get-base-branch-name.outputs.branch }} 10 | branch-name: 11 | description: The current branch name. 12 | value: ${{ steps.branch-name.outputs.current_branch }} 13 | 14 | runs: 15 | using: composite 16 | steps: 17 | - name: Get branch name 18 | id: branch-name 19 | uses: tj-actions/branch-names@v8.2.1 20 | 21 | - name: Get base branch name 22 | id: get-base-branch-name 23 | shell: bash 24 | run: | 25 | if [[ "${{steps.branch-name.outputs.base_ref_branch}}" != "" ]]; then 26 | echo "branch=${{steps.branch-name.outputs.base_ref_branch}}" >> $GITHUB_OUTPUT 27 | else 28 | echo "branch=${{steps.branch-name.outputs.current_branch}}" >> $GITHUB_OUTPUT 29 | fi 30 | -------------------------------------------------------------------------------- /.github/actions/local-payloads/README.md: -------------------------------------------------------------------------------- 1 | For use with the VSCode GitHub Local Actions extension. 2 | -------------------------------------------------------------------------------- /.github/actions/local-payloads/example-pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "pull_request": { 3 | "head": { 4 | "ref": "mbm/ci-setup" 5 | }, 6 | "base": { 7 | "ref": "main" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | 3 | description: 'Sets up the environment for a job during CI workflow' 4 | 5 | runs: 6 | using: composite 7 | steps: 8 | - name: Setup node 9 | uses: actions/setup-node@v4 10 | with: 11 | node-version: 22 12 | 13 | - name: ♻️ Restore node_modules 14 | id: yarn-nm-cache 15 | uses: actions/cache/restore@v4 16 | with: 17 | path: ./**/node_modules 18 | key: yarn-nm-cache-${{ runner.os }}-${{ hashFiles('./yarn.lock', './.yarnrc.yml') }} 19 | fail-on-cache-miss: true 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - action-testing 8 | 9 | permissions: 10 | contents: read 11 | actions: read 12 | pull-requests: read 13 | 14 | # Only run one deploy workflow at a time 15 | concurrency: production 16 | 17 | env: 18 | NX_CLOUD_ACCESS_TOKEN: ${{secrets.NX_CLOUD_ACCESS_TOKEN}} 19 | 20 | defaults: 21 | run: 22 | shell: bash 23 | 24 | jobs: 25 | ci-setup: 26 | uses: ./.github/workflows/ci-setup.yml 27 | secrets: inherit 28 | 29 | ci-checks: 30 | uses: ./.github/workflows/ci-checks.yml 31 | needs: [ci-setup] 32 | secrets: inherit 33 | with: 34 | typecheck-projects: ${{ needs.ci-setup.outputs.typecheck-projects }} 35 | lint-projects: ${{ needs.ci-setup.outputs.lint-projects }} 36 | test-projects: ${{ needs.ci-setup.outputs.test-projects }} 37 | build-projects: ${{ needs.ci-setup.outputs.build-projects }} 38 | 39 | release: 40 | runs-on: ubuntu-latest 41 | needs: [ci-setup, ci-checks] 42 | # if: needs.ci-setup.outputs.build-projects 43 | permissions: 44 | pull-requests: write 45 | contents: write 46 | steps: 47 | - name: Checkout 48 | uses: actions/checkout@v4 49 | 50 | - name: Setup 51 | uses: './.github/actions/setup' 52 | 53 | - name: Build 54 | run: yarn build -p openmcp "@openmcp/*" 55 | 56 | - name: Set NPM Auth Token 57 | run: yarn config set npmAuthToken "$NPM_TOKEN" 58 | env: 59 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 60 | 61 | - name: Create Release Pull Request or Publish to NPM 62 | id: changesets 63 | uses: changesets/action@v1 64 | with: 65 | publish: yarn changeset publish 66 | commit: 'ci: release packages' 67 | title: 'ci: release packages' 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | actions: read 9 | pull-requests: read 10 | 11 | concurrency: 12 | group: ${{ github.ref_name }} 13 | # Here we specify that we'll cancel any "in progress" workflow of the same group. Thus if we push, ammend a commit and push 14 | # again the previous workflow will be cancelled. This saves us github action build minutes and avoid any conflicts 15 | cancel-in-progress: true 16 | 17 | env: 18 | NX_CLOUD_ACCESS_TOKEN: ${{secrets.NX_CLOUD_ACCESS_TOKEN}} 19 | 20 | defaults: 21 | run: 22 | shell: bash 23 | 24 | jobs: 25 | ci-setup: 26 | uses: ./.github/workflows/ci-setup.yml 27 | secrets: inherit 28 | 29 | ci-checks: 30 | uses: ./.github/workflows/ci-checks.yml 31 | needs: [ci-setup] 32 | secrets: inherit 33 | with: 34 | typecheck-projects: ${{ needs.ci-setup.outputs.typecheck-projects }} 35 | lint-projects: ${{ needs.ci-setup.outputs.lint-projects }} 36 | test-projects: ${{ needs.ci-setup.outputs.test-projects }} 37 | build-projects: ${{ needs.ci-setup.outputs.build-projects }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | build 6 | # do not ignore built dist files in yalc'd packages 7 | !.yalc/**/dist 8 | tmp 9 | out-tsc 10 | .cache 11 | .wrangler 12 | storybook-static 13 | .nx 14 | stats.html 15 | .vinxi 16 | .vercel 17 | .output 18 | 19 | # typescript 20 | *.tsbuildinfo 21 | 22 | # dependencies 23 | node_modules 24 | 25 | # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 26 | .pnp.* 27 | .yarn/* 28 | !.yarn/patches 29 | !.yarn/plugins 30 | !.yarn/releases 31 | !.yarn/sdks 32 | !.yarn/versions 33 | 34 | # IDEs and editors 35 | /.idea 36 | .project 37 | .classpath 38 | .c9/ 39 | *.launch 40 | .settings/ 41 | *.sublime-workspace 42 | 43 | # IDE - VSCode 44 | .vscode/* 45 | !.vscode/settings.json 46 | !.vscode/tasks.json 47 | !.vscode/launch.json 48 | !.vscode/extensions.json 49 | 50 | # misc 51 | /coverage 52 | yarn-error.log 53 | .no-checkin 54 | .ignore 55 | 56 | # System Files 57 | .DS_Store 58 | Thumbs.db 59 | 60 | # env 61 | .env* 62 | !.env.example 63 | .dev.vars 64 | 65 | *storybook.log 66 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "typescript.preferences.importModuleSpecifierEnding": "js", 5 | "eslint.runtime": "node", 6 | "files.associations": { 7 | "wrangler.json": "jsonc", 8 | "*.css": "tailwindcss" 9 | }, 10 | "yaml.schemas": { 11 | "https://json.schemastore.org/github-workflow.json": ".github/workflows/*.yml" 12 | }, 13 | "editor.quickSuggestions": { 14 | "strings": true 15 | }, 16 | "tailwindCSS.rootFontSize": 14, 17 | "tailwindCSS.experimental.classRegex": [ 18 | "tw`([^`]*)`", 19 | ["(?:tx|cn|twMerge|tn)\\(([^\\);]*)[\\);]", "[`'\"`]([^'\"`,;]*)[`'\"`]"] 20 | ], 21 | "vitest.disableWorkspaceWarning": true, 22 | "[typescript]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | "[typescriptreact]": { 26 | "editor.defaultFormatter": "esbenp.prettier-vscode" 27 | }, 28 | "[jsonc]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | }, 31 | "editor.codeActionsOnSave": { 32 | "source.fixAll": "explicit" 33 | }, 34 | "files.readonlyInclude": { 35 | "**/routeTree.gen.ts": true 36 | }, 37 | "files.watcherExclude": { 38 | "**/routeTree.gen.ts": true 39 | }, 40 | "search.exclude": { 41 | "**/routeTree.gen.ts": true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableConstraintsChecks: true 2 | 3 | nodeLinker: node-modules 4 | 5 | plugins: 6 | - checksum: 8f13acff9aef76f5fbfb5474b6d23b14ed3f49258cf4e344229e3515e42f4d6990e3c51b9ab176aa46c407fb9ca97fc0902c6400db5a44e9994d0b53512f3aed 7 | path: .yarn/plugins/@yarnpkg/plugin-engines.cjs 8 | spec: "https://raw.githubusercontent.com/devoto13/yarn-plugin-engines/main/bundles/%40yarnpkg/plugin-engines.js" 9 | 10 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Clone the repo 4 | 2. `yarn` 5 | 3. Make changes 6 | 4. Make sure `yarn test`, `yarn lint`, `yarn typecheck`, and `yarn build` pass 7 | 5. If your changes should result in a new published version of one or more packages, run `yarn changeset` 8 | 6. Open a PR 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025-present Datanaut Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the “Software”), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/cli/README.md -------------------------------------------------------------------------------- /apps/web/app.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | import { defineConfig } from '@tanstack/react-start/config'; 3 | import { cloudflare } from 'unenv'; 4 | import { analyzer } from 'vite-bundle-analyzer'; 5 | import tsConfigPaths from 'vite-tsconfig-paths'; 6 | 7 | // flip to true if you want to analyze the bundle.. doesn't work well w vinxi though 8 | const ANALYZE = process.argv.includes('--analyze') || false; 9 | 10 | export default defineConfig({ 11 | server: { 12 | compatibilityDate: '2025-04-30', 13 | preset: 'cloudflare_module', 14 | unenv: cloudflare, 15 | }, 16 | tsr: { 17 | routeToken: 'layout', 18 | appDirectory: 'src', 19 | }, 20 | vite: { 21 | plugins: [tsConfigPaths(), tailwindcss(), ANALYZE ? analyzer() : null], 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /apps/web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { reactConfig } from '@datanaut/eslint-config/react'; 2 | import pluginRouter from '@tanstack/eslint-plugin-router'; 3 | 4 | export default [...reactConfig, ...pluginRouter.configs['flat/recommended']]; 5 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "sideEffects": false, 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vinxi dev", 9 | "build": "vinxi build", 10 | "build.analyze": "vinxi build -- --analyze", 11 | "preview": "wrangler --cwd .output dev", 12 | "deploy": "wrangler --cwd .output deploy" 13 | }, 14 | "dependencies": { 15 | "@datanaut/ui-primitives": "0.1.4", 16 | "@fortawesome/free-brands-svg-icons": "6.7.2", 17 | "@fortawesome/free-regular-svg-icons": "6.7.2", 18 | "@fortawesome/free-solid-svg-icons": "6.7.2", 19 | "@tanstack/react-router": "1.119.0", 20 | "@tanstack/react-start": "1.119.2", 21 | "react": "19.1.0", 22 | "react-dom": "19.1.0", 23 | "vinxi": "0.5.6", 24 | "zod": "^3.24.3" 25 | }, 26 | "devDependencies": { 27 | "@datanaut/eslint-config": "0.1.1", 28 | "@datanaut/tsconfig": "0.1.3", 29 | "@tailwindcss/vite": "4.1.5", 30 | "@tanstack/eslint-plugin-router": "1.115.0", 31 | "@types/react": "19.1.3", 32 | "@types/react-dom": "19.1.3", 33 | "tailwindcss": "4.1.5", 34 | "unenv": "1.10.0", 35 | "vite": "6.3.5", 36 | "vite-bundle-analyzer": "0.20.1", 37 | "vite-tsconfig-paths": "5.1.4", 38 | "wrangler": "4.14.2" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getdatanaut/openmcp/fa8927ba55489bf73ab5ab0114444234cccfac48/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /apps/web/src/assets/app.css: -------------------------------------------------------------------------------- 1 | @import '@datanaut/ui-primitives/styles'; 2 | 3 | /* https://tailwindcss.com/docs/detecting-classes-in-source-files#explicitly-registering-sources */ 4 | @source "../../../../node_modules/@datanaut/ui-primitives/src"; 5 | 6 | @font-face { 7 | src: url(./fonts/ml/0-normal.woff2) format('woff2'); 8 | font-family: ml; 9 | font-weight: 100 900; 10 | font-style: normal; 11 | unicode-range: U+0020-007F; 12 | } 13 | 14 | @font-face { 15 | src: url(./fonts/ml/1-italic.woff2) format('woff2'); 16 | font-family: ml; 17 | font-weight: 100 900; 18 | font-style: italic; 19 | unicode-range: U+0020-007F; 20 | } 21 | 22 | @theme { 23 | --font-mono: ml, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; 24 | } 25 | 26 | :root { 27 | --root-font-size: 14px; 28 | } 29 | 30 | @layer base { 31 | html, 32 | body { 33 | @apply min-h-dvh; 34 | 35 | font-family: var(--font-mono); 36 | letter-spacing: -0.02em; 37 | } 38 | 39 | html { 40 | color-scheme: light dark; 41 | font-size: var(--root-font-size); 42 | } 43 | 44 | body { 45 | @apply ak-layer-canvas; 46 | } 47 | 48 | * { 49 | @apply edge-d selection:ak-layer-mix-primary/40; 50 | 51 | /* to prevent grid and flex items from spilling out of their container */ 52 | min-width: 0; 53 | } 54 | 55 | a { 56 | @apply focus-visible:ak-outline focus-visible:outline-[1.5px] focus-visible:outline-offset-[1.5px]; 57 | } 58 | 59 | h1, 60 | h2, 61 | h3, 62 | h4 { 63 | /* balance headings across multiple lines into an even block */ 64 | text-wrap: balance; 65 | } 66 | 67 | p { 68 | /* prevent text orphans (single words on last line) */ 69 | text-wrap: pretty; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /apps/web/src/assets/fonts/ml/0-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getdatanaut/openmcp/fa8927ba55489bf73ab5ab0114444234cccfac48/apps/web/src/assets/fonts/ml/0-normal.woff2 -------------------------------------------------------------------------------- /apps/web/src/assets/fonts/ml/1-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getdatanaut/openmcp/fa8927ba55489bf73ab5ab0114444234cccfac48/apps/web/src/assets/fonts/ml/1-italic.woff2 -------------------------------------------------------------------------------- /apps/web/src/client.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import { StartClient } from '@tanstack/react-start'; 3 | import { hydrateRoot } from 'react-dom/client'; 4 | 5 | import { createRouter } from './router.tsx'; 6 | 7 | const router = createRouter(); 8 | 9 | hydrateRoot(document, ); 10 | -------------------------------------------------------------------------------- /apps/web/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createRouter as createTanStackRouter } from '@tanstack/react-router'; 2 | 3 | import { routeTree } from './routeTree.gen.ts'; 4 | 5 | export function createRouter() { 6 | return createTanStackRouter({ 7 | scrollRestoration: true, 8 | scrollRestorationBehavior: 'auto', 9 | routeTree, 10 | defaultPreload: 'intent', 11 | }); 12 | } 13 | 14 | declare module '@tanstack/react-router' { 15 | interface Register { 16 | router: ReturnType; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/web/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRouteWithContext, HeadContent, Outlet, Scripts } from '@tanstack/react-router'; 2 | 3 | import appCss from '~/assets/app.css?url'; 4 | 5 | export const Route = createRootRouteWithContext<{}>()({ 6 | component: RootComponent, 7 | head: () => ({ 8 | meta: [ 9 | { 10 | charSet: 'utf-8', 11 | }, 12 | { 13 | name: 'viewport', 14 | content: 'width=device-width, initial-scale=1', 15 | }, 16 | { 17 | title: 'OpenMCP', 18 | }, 19 | { 20 | name: 'description', 21 | content: 'Instant MCP servers from any OpenAPI file.', 22 | }, 23 | ], 24 | 25 | links: [ 26 | { rel: 'stylesheet', href: appCss }, 27 | { rel: 'icon', href: '/favicon.ico' }, 28 | ], 29 | }), 30 | }); 31 | 32 | function RootComponent() { 33 | return ( 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | function RootDocument({ children }: { children: React.ReactNode }) { 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | {children} 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/web/src/ssr.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import { getRouterManifest } from '@tanstack/react-start/router-manifest'; 3 | import { createStartHandler, defaultStreamHandler } from '@tanstack/react-start/server'; 4 | 5 | import { createRouter } from './router.tsx'; 6 | 7 | // eslint-disable-next-line react-refresh/only-export-components 8 | export default createStartHandler({ 9 | createRouter, 10 | getRouterManifest, 11 | })(defaultStreamHandler); 12 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@datanaut/tsconfig/tsconfig.react.json", 3 | "include": ["src", "app.config.ts"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "types": ["vite/client"], 7 | "paths": { 8 | "~/*": ["./src/*"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "../../node_modules/wrangler/config-schema.json", 7 | "name": "openmcp-web", 8 | "main": "./.output/server/index.mjs", 9 | "compatibility_date": "2025-04-08", 10 | "compatibility_flags": ["nodejs_compat"], 11 | "assets": { 12 | "html_handling": "drop-trailing-slash", 13 | "directory": "./.output/public/", 14 | "binding": "ASSETS", 15 | }, 16 | "routes": [ 17 | { 18 | "pattern": "openmcp.datanaut.ai", 19 | "custom_domain": true, 20 | }, 21 | ], 22 | } 23 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "defaultBase": "main", 4 | "parallel": 7, 5 | "nxCloudId": "68191ad677166d4da489ea72", 6 | "cli": { 7 | "packageManager": "yarn" 8 | }, 9 | "namedInputs": { 10 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 11 | "production": [ 12 | "default", 13 | "!{projectRoot}/**/__tests__/**", 14 | "!{projectRoot}/eslint.config.js", 15 | "!{projectRoot}/**/README.md" 16 | ], 17 | "sharedGlobals": [] 18 | }, 19 | "targetDefaults": { 20 | "build": { 21 | "dependsOn": ["^build"], 22 | "inputs": ["production", "^production"], 23 | "cache": true 24 | }, 25 | "test": { 26 | "inputs": ["default", "^production"], 27 | "cache": true 28 | }, 29 | "typecheck": { 30 | "cache": true 31 | }, 32 | "lint": { 33 | "cache": true 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/cli/bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --no-warnings 2 | import process from 'node:process'; 3 | 4 | import register from '#register'; 5 | 6 | await register(process.argv.slice(2)); 7 | -------------------------------------------------------------------------------- /packages/cli/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { baseConfig } from '@datanaut/eslint-config/base'; 2 | 3 | export default [ 4 | ...baseConfig, 5 | 6 | { 7 | files: ['src/**/*.{ts,tsx}'], 8 | rules: { 9 | 'no-console': 'error', 10 | 'import/extensions': 'off', // not needed if moduleResolution is set to node16 11 | 'import/no-extraneous-dependencies': 'error', 12 | }, 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /packages/cli/src/api/index.ts: -------------------------------------------------------------------------------- 1 | // API exposed by CLI 2 | // must be environment independent - the members exported here must not use Node.js specific APIs 3 | export { getInstallHints, type IntegrationName } from '../libs/mcp-clients/index.isomorphic.ts'; 4 | -------------------------------------------------------------------------------- /packages/cli/src/commands/install/handler.ts: -------------------------------------------------------------------------------- 1 | import console from '#libs/console'; 2 | import { install, type IntegrationName } from '#libs/mcp-clients'; 3 | 4 | import { getAgentById } from '../../libs/datanaut/agent.ts'; 5 | import { inferTargetType } from '../../libs/mcp-utils/index.ts'; 6 | import createOpenAPIRemix from './openapi/index.ts'; 7 | 8 | type Flags = { 9 | client: IntegrationName; 10 | type: 'agent-id' | 'openapi' | undefined; 11 | scope: 'global' | 'local' | 'prefer-local'; 12 | }; 13 | 14 | export default async function handler(target: string, { type, client, scope }: Flags) { 15 | const server = await getServer(target, type ?? (await inferTargetType(target)), client, scope); 16 | const ctx = { 17 | cwd: process.cwd(), 18 | logger: console, 19 | } as const; 20 | await install(ctx, client, server, scope); 21 | } 22 | 23 | async function getServer( 24 | target: string, 25 | type: NonNullable, 26 | client: IntegrationName, 27 | scope: Flags['scope'], 28 | ) { 29 | switch (type) { 30 | case 'agent-id': { 31 | const agent = await getAgentById(target); 32 | return { 33 | id: agent.id, 34 | name: agent.name, 35 | target: agent.id, 36 | } as const; 37 | } 38 | case 'openapi': 39 | return createOpenAPIRemix(target, client, scope); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/cli/src/commands/install/openapi/create-remix.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | import * as path from 'node:path'; 3 | 4 | import console from '#libs/console'; 5 | import type { Config as RemixDefinition } from '#libs/remix'; 6 | 7 | export default async function createRemix(cwd: string, filepath: string, remix: RemixDefinition): Promise { 8 | const dirname = path.dirname(filepath); 9 | if (dirname !== cwd) { 10 | try { 11 | await fs.mkdir(dirname, { recursive: true }); 12 | } catch (error) { 13 | throw new Error(formatErrorMessage(`Failed to create directory at ${JSON.stringify(dirname)}`, error)); 14 | } 15 | } 16 | 17 | const strFilepath = JSON.stringify(filepath); 18 | 19 | try { 20 | await fs.writeFile(filepath, JSON.stringify(remix, null, 2)); 21 | console.success(`Created OpenAPI openmcp definition at ${strFilepath}`); 22 | } catch (error) { 23 | throw new Error(formatErrorMessage(`Failed to write file at ${strFilepath}`, error)); 24 | } 25 | } 26 | 27 | function formatErrorMessage(message: string, error: unknown): string { 28 | const errorMessage = error instanceof Error ? error.message : null; 29 | return `${errorMessage ? `${message}: ${errorMessage}` : message}`; 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/commands/install/openapi/index.ts: -------------------------------------------------------------------------------- 1 | import { OperationCanceledError } from '#errors'; 2 | import console from '#libs/console'; 3 | import type { InstallLocation, IntegrationName, Remix } from '#libs/mcp-clients'; 4 | 5 | import createRemix from './create-remix.ts'; 6 | import generateRemixDefinition from './generate-remix.ts'; 7 | import negotiateFilepath from './negotiate-filepath.ts'; 8 | 9 | export default async function createOpenAPIRemix( 10 | openapiLocation: string, 11 | client: IntegrationName, 12 | installLocation: InstallLocation, 13 | ): Promise { 14 | const cwd = process.cwd(); 15 | console.start('Generating OpenAPI openmcp definition...'); 16 | try { 17 | const remix = await negotiateFilepath(cwd, client, installLocation); 18 | const definition = await generateRemixDefinition(remix, openapiLocation); 19 | await createRemix(cwd, remix.filepath, definition); 20 | 21 | return { 22 | name: 'openmcp', 23 | target: remix.filepath, 24 | }; 25 | } catch (error) { 26 | if (error instanceof OperationCanceledError) { 27 | throw error; 28 | } else { 29 | throw new Error( 30 | `Failed to generate OpenAPI openmcp definition: ${error instanceof Error ? error.message : error}`, 31 | ); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/src/commands/install/openapi/types.ts: -------------------------------------------------------------------------------- 1 | export type Context = { 2 | cwd: string; 3 | console: Console; 4 | }; 5 | -------------------------------------------------------------------------------- /packages/cli/src/commands/login/index.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs'; 2 | 3 | import { createHandler } from '#libs/cli-utils'; 4 | import { login } from '#libs/datanaut-auth-cli'; 5 | 6 | export default { 7 | command: 'login', 8 | describe: process.env['NODE_ENV'] === 'development' ? 'Login to the CLI' : false, 9 | handler: createHandler(login, true), 10 | } satisfies CommandModule; 11 | -------------------------------------------------------------------------------- /packages/cli/src/commands/logout/index.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs'; 2 | 3 | import { createHandler } from '#libs/cli-utils'; 4 | import { logout } from '#libs/datanaut-auth-cli'; 5 | 6 | export default { 7 | command: 'logout', 8 | describe: process.env['NODE_ENV'] === 'development' ? 'Logout from the CLI' : false, 9 | handler: createHandler(logout, false), 10 | } satisfies CommandModule; 11 | -------------------------------------------------------------------------------- /packages/cli/src/commands/run/index.ts: -------------------------------------------------------------------------------- 1 | import { type Argv } from 'yargs'; 2 | 3 | import { createHandler } from '#libs/cli-utils'; 4 | 5 | const builder = (yargs: Argv) => 6 | yargs.strict().options({ 7 | server: { 8 | type: 'string', 9 | describe: 'The name or id of the server to start', 10 | conflicts: 'config', 11 | }, 12 | secret: { 13 | type: 'string', 14 | describe: 'The secret to use for authentication', 15 | implies: 'server', 16 | }, 17 | config: { 18 | type: 'string', 19 | describe: 'The filepath to the local config file', 20 | conflicts: 'server', 21 | }, 22 | } as const); 23 | 24 | export default { 25 | describe: 'Start a new server', 26 | command: 'run', 27 | builder, 28 | handler: createHandler(async args => { 29 | const { default: handler } = await import('./handler.ts'); 30 | const { server, secret, config } = args as Awaited['argv']>; 31 | if (typeof config === 'string') { 32 | await handler({ 33 | configFile: config, 34 | }); 35 | } else { 36 | await handler({ 37 | server: String(server), 38 | secret, 39 | }); 40 | } 41 | }, false), 42 | }; 43 | -------------------------------------------------------------------------------- /packages/cli/src/commands/uninstall/handler.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | 3 | import { loadDocument } from '@openmcp/utils/documents'; 4 | 5 | import console from '#libs/console'; 6 | import { type IntegrationName, uninstall } from '#libs/mcp-clients'; 7 | 8 | import { getAgentById } from '../../libs/datanaut/agent.ts'; 9 | import { inferTargetType } from '../../libs/mcp-utils/index.ts'; 10 | 11 | type Flags = { 12 | client: IntegrationName; 13 | type: 'agent-id' | 'openapi' | undefined; 14 | scope: 'global' | 'local' | 'prefer-local'; 15 | }; 16 | 17 | export default async function handler(target: string, { type, client, scope }: Flags) { 18 | const server = await getServer(target, type ?? (await inferTargetType(target))); 19 | const ctx = { 20 | cwd: process.cwd(), 21 | logger: console, 22 | } as const; 23 | await uninstall(ctx, client, server, scope); 24 | } 25 | 26 | async function getServer(target: string, type: NonNullable) { 27 | switch (type) { 28 | case 'agent-id': { 29 | const agent = await getAgentById(target); 30 | return { 31 | id: agent.id, 32 | name: agent.name, 33 | target: agent.id, 34 | } as const; 35 | } 36 | case 'openapi': { 37 | const name = ( 38 | await loadDocument( 39 | { 40 | fs, 41 | fetch, 42 | }, 43 | target, 44 | ) 45 | )['info']?.['title']; 46 | return { 47 | id: target, 48 | name: String(name ?? target), 49 | target: target, 50 | }; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/cli/src/commands/uninstall/index.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs'; 2 | 3 | import { createHandler } from '#libs/cli-utils'; 4 | 5 | import type { BuilderArgv } from '../install/index.ts'; 6 | import { builder } from '../install/index.ts'; 7 | import handler from './handler.ts'; 8 | 9 | export default { 10 | describe: process.env['NODE_ENV'] === 'development' ? 'Uninstall the target' : false, 11 | command: 'uninstall ', 12 | builder, 13 | handler: createHandler(async args => { 14 | const { target, client, type, scope } = args as BuilderArgv & { 15 | target: string; 16 | }; 17 | 18 | await handler(target, { 19 | client, 20 | type, 21 | scope, 22 | }); 23 | }, true), 24 | } satisfies CommandModule; 25 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/handler.ts: -------------------------------------------------------------------------------- 1 | import console from '#libs/console'; 2 | 3 | import { rpcClient } from '../../libs/datanaut/sdk/sdk.ts'; 4 | import createConnectedClient from './mcp-utils/create-client.ts'; 5 | import listMcpTools from './mcp-utils/get-tools.ts'; 6 | import createTransportDefinition from './transport-definitions/index.ts'; 7 | import type { ServerDefinition } from './types.ts'; 8 | 9 | export default async function handler(definition: ServerDefinition): Promise { 10 | const { transport, transportConfig, configSchema, externalId, metadata } = 11 | await createTransportDefinition(definition); 12 | const defaultAnnotations = { 13 | iconUrl: definition.iconUrl, 14 | developer: definition.developer, 15 | }; 16 | 17 | await using client = await createConnectedClient(transport, defaultAnnotations); 18 | const serverVersion = client.getServerVersion(); 19 | if (!serverVersion) { 20 | throw new Error('Failed to get server version'); 21 | } 22 | const serverAnnotations = client.getServerAnnotations(); 23 | 24 | const mcpServer = { 25 | name: serverAnnotations?.serverName ?? metadata?.name ?? serverVersion.name, 26 | externalId: externalId ?? serverVersion.name, 27 | description: metadata?.description, 28 | developer: definition.developer ?? metadata?.developer, 29 | developerUrl: definition.developerUrl || metadata?.developerUrl, 30 | iconUrl: definition.iconUrl ?? serverAnnotations?.iconUrl ?? metadata?.iconUrl, 31 | sourceUrl: definition.sourceUrl, 32 | configSchema, 33 | transport: transportConfig, 34 | tools: await listMcpTools(client), 35 | }; 36 | 37 | console.start(`Uploading server "${mcpServer.name}"...`); 38 | await rpcClient.cli.mcpServers.upload(mcpServer); 39 | console.success(`Server "${mcpServer.name}" successfully uploaded`); 40 | } 41 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/mcp-utils/create-client.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; 3 | 4 | import packageJson from '../../../../package.json' with { type: 'json' }; 5 | 6 | interface ServerAnnotations { 7 | // https://github.com/modelcontextprotocol/modelcontextprotocol/blob/dfd270157380c645d0731f9f2ffbc89f75fba47d/schema/2025-03-26/schema.ts#L177 8 | serverName?: string; 9 | iconUrl?: string; 10 | [key: string]: unknown; 11 | } 12 | 13 | export type ConnectedClient = Pick< 14 | Client, 15 | 'listTools' | 'listPrompts' | 'listResources' | 'listResourceTemplates' | 'getServerVersion' | 'getServerCapabilities' 16 | > & { 17 | getServerAnnotations(): ServerAnnotations | undefined; 18 | } & AsyncDisposable; 19 | 20 | export default async function createConnectedClient( 21 | transport: Transport, 22 | annotations: Record, 23 | ): Promise { 24 | const client = new Client({ 25 | name: packageJson.name, 26 | version: packageJson.version, 27 | }); 28 | 29 | try { 30 | await client.connect(transport); 31 | } catch (error) { 32 | throw new Error(`Failed to connect to server: ${error}`); 33 | } 34 | 35 | return { 36 | getServerAnnotations(): ServerAnnotations | undefined { 37 | return { 38 | // will work once SDK adds support for server annotations this should work 39 | ...client['getServerAnnotations']?.(), 40 | ...annotations, 41 | }; 42 | }, 43 | getServerCapabilities: client.getServerCapabilities.bind(client), 44 | getServerVersion: client.getServerVersion.bind(client), 45 | listTools: client.listTools.bind(client), 46 | listPrompts: client.listPrompts.bind(client), 47 | listResources: client.listResources.bind(client), 48 | listResourceTemplates: client.listResourceTemplates.bind(client), 49 | async [Symbol.asyncDispose]() { 50 | await client.close(); 51 | }, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/mcp-utils/get-tools.ts: -------------------------------------------------------------------------------- 1 | import console from '#libs/console'; 2 | 3 | import type { rpcClient } from '../../../libs/datanaut/sdk/sdk.ts'; 4 | import { getSummary } from '../utils/string.ts'; 5 | import type { ConnectedClient } from './create-client.ts'; 6 | 7 | type Tool = NonNullable[0]['tools']>[number]; 8 | 9 | function unwrapBooleanOrUndefined(value: unknown) { 10 | return typeof value === 'boolean' ? value : undefined; 11 | } 12 | 13 | export default async function getTools(client: ConnectedClient): Promise { 14 | const capabilities = client.getServerCapabilities(); 15 | if (!capabilities?.tools) { 16 | console.warn(`No "tools" capability found`); 17 | return []; 18 | } 19 | 20 | try { 21 | return (await client.listTools()).tools.map(tool => { 22 | const annotations = tool['annotations'] as Record | undefined; 23 | const title = annotations?.['title']; 24 | return { 25 | name: tool.name, 26 | description: tool.description, 27 | summary: tool.description ? getSummary(tool.description) : undefined, 28 | displayName: typeof title === 'string' ? title : undefined, 29 | inputSchema: tool.inputSchema, 30 | outputSchema: tool['outputSchema'] as Tool['outputSchema'] | undefined, 31 | isReadonly: unwrapBooleanOrUndefined(annotations?.['readOnlyHint']), 32 | isDestructive: unwrapBooleanOrUndefined(annotations?.['destructiveHint']), 33 | isIdempotent: unwrapBooleanOrUndefined(annotations?.['idempotentHint']), 34 | isOpenWorld: unwrapBooleanOrUndefined(annotations?.['openWorldHint']), 35 | }; 36 | }); 37 | } catch (error) { 38 | throw new Error(`Failed to list tools: ${error}`); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/transport-definitions/index.ts: -------------------------------------------------------------------------------- 1 | import type { ServerDefinition } from '../types.ts'; 2 | import parseCmd from '../utils/parse-cmd/index.ts'; 3 | import createOpenAPITransportDefinition from './openapi.ts'; 4 | import createSSETransportDefinition from './sse.ts'; 5 | import createStdioTransportDefinition from './stdio.ts'; 6 | import type { TransportDefinition, TransportType } from './types.ts'; 7 | 8 | export default async function createTransportDefinition( 9 | definition: ServerDefinition, 10 | ): Promise> { 11 | switch (definition.type) { 12 | case 'stdio': 13 | return createStdioTransportDefinition(parseCmd(definition.input), process.cwd()); 14 | case 'sse': 15 | return createSSETransportDefinition(definition.url, { 16 | requestInit: { 17 | headers: new Headers(definition.headers), 18 | }, 19 | }); 20 | case 'streamable-http': { 21 | const { default: createStreamableHTTPTransportDefinition } = await import('./streamable-http.ts'); 22 | return createStreamableHTTPTransportDefinition(definition.url, { 23 | requestInit: { 24 | headers: new Headers(definition.headers), 25 | }, 26 | }); 27 | } 28 | case 'openapi': 29 | return createOpenAPITransportDefinition(definition.uri, definition.serverUrl); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/transport-definitions/sse.ts: -------------------------------------------------------------------------------- 1 | import { SSEClientTransport, type SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'; 2 | 3 | import ConfigSchema from '../utils/config-schema.ts'; 4 | import maskHeaders from '../utils/mask-headers.ts'; 5 | import maskUrl from '../utils/mask-url.ts'; 6 | import type { TransportDefinition } from './types.ts'; 7 | 8 | export type SSETransportDefinition = TransportDefinition<'sse'>; 9 | 10 | export default function createSSETransportDefinition( 11 | url: URL, 12 | opts: SSEClientTransportOptions, 13 | ): SSETransportDefinition { 14 | const configSchema = new ConfigSchema(); 15 | const maskedUrl = maskUrl(configSchema, url); 16 | const maskedHeaders = opts.requestInit?.headers 17 | ? maskHeaders(configSchema, new Headers(opts.requestInit.headers)) 18 | : undefined; 19 | 20 | return { 21 | transport: new SSEClientTransport(url, opts), 22 | transportConfig: { 23 | type: 'sse', 24 | url: maskedUrl, 25 | headers: maskedHeaders, 26 | }, 27 | configSchema: configSchema.serialize(), 28 | externalId: undefined, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/transport-definitions/stdio.ts: -------------------------------------------------------------------------------- 1 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 2 | 3 | import type ParsedCommand from '../utils/parse-cmd/parsed-command.ts'; 4 | import type { TransportDefinition } from './types.ts'; 5 | 6 | export type StdioTransportDefinition = TransportDefinition<'stdio'>; 7 | 8 | export default function getStdioTransportDefinition(command: ParsedCommand, cwd: string): StdioTransportDefinition { 9 | return { 10 | transport: new StdioClientTransport(command.getStdioServerParameters(cwd)), 11 | transportConfig: command.getTransportConfig(), 12 | configSchema: command.configSchema.serialize(), 13 | externalId: command.externalId, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/transport-definitions/streamable-http.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StreamableHTTPClientTransport, 3 | type StreamableHTTPClientTransportOptions, 4 | } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 5 | 6 | import ConfigSchema from '../utils/config-schema.ts'; 7 | import maskHeaders from '../utils/mask-headers.ts'; 8 | import maskUrl from '../utils/mask-url.ts'; 9 | import type { TransportDefinition } from './types.ts'; 10 | 11 | export type StreamableHTTPTransportDefinition = TransportDefinition<'streamableHttp'>; 12 | 13 | export default function createStreamableHTTPTransportDefinition( 14 | url: URL, 15 | opts: StreamableHTTPClientTransportOptions, 16 | ): StreamableHTTPTransportDefinition { 17 | const configSchema = new ConfigSchema(); 18 | const maskedUrl = maskUrl(configSchema, url); 19 | const maskedHeaders = opts.requestInit?.headers 20 | ? maskHeaders(configSchema, new Headers(opts.requestInit.headers)) 21 | : undefined; 22 | 23 | return { 24 | transport: new StreamableHTTPClientTransport(url, opts), 25 | transportConfig: { 26 | type: 'streamable-http', 27 | url: maskedUrl, 28 | headers: maskedHeaders, 29 | }, 30 | configSchema: configSchema.serialize(), 31 | externalId: undefined, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/transport-definitions/types.ts: -------------------------------------------------------------------------------- 1 | import type { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; 2 | import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 3 | import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 4 | import type { OpenAPITransport, SSETransport, StdIOTransport, StreamableHTTPTransport } from '@openmcp/schemas/mcp'; 5 | 6 | import type { OpenAPIClientTransport } from './openapi.ts'; 7 | 8 | type Clients = { 9 | openapi: OpenAPIClientTransport; 10 | sse: SSEClientTransport; 11 | streamableHttp: StreamableHTTPClientTransport; 12 | stdio: StdioClientTransport; 13 | }; 14 | 15 | export type TransportDefinition = { 16 | readonly transport: Clients[T]; 17 | readonly transportConfig: TransportConfig; 18 | readonly configSchema: 19 | | { 20 | type: 'object'; 21 | properties: { 22 | [key: string]: { 23 | type: 'string' | 'boolean' | 'number'; 24 | }; 25 | }; 26 | required: string[]; 27 | } 28 | | undefined; 29 | readonly externalId: string | undefined; 30 | // some of that won't be needed once we MCP supports more robust server annotations 31 | readonly metadata?: { 32 | readonly name?: string; 33 | readonly summary?: string; 34 | readonly description?: string; 35 | readonly developer?: string; 36 | readonly developerUrl?: string; 37 | readonly iconUrl?: string; 38 | }; 39 | }; 40 | 41 | export type TransportConfigs = { 42 | openapi: OpenAPITransport; 43 | sse: SSETransport; 44 | streamableHttp: StreamableHTTPTransport; 45 | stdio: StdIOTransport; 46 | }; 47 | 48 | export type TransportType = keyof TransportConfigs; 49 | 50 | export type TransportConfig = TransportConfigs[T]; 51 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/types.ts: -------------------------------------------------------------------------------- 1 | export type ServerDefinition = { 2 | iconUrl?: string; 3 | developer?: string; 4 | developerUrl?: string; 5 | sourceUrl?: string; 6 | } & ( 7 | | { 8 | type: 'sse' | 'streamable-http'; 9 | headers?: [string, string][]; 10 | url: URL; 11 | } 12 | | { 13 | type: 'openapi'; 14 | serverUrl?: string; 15 | uri: string; 16 | } 17 | | { 18 | type: 'stdio'; 19 | input: string; 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/utils/config-schema.ts: -------------------------------------------------------------------------------- 1 | export type InferredType = 'string' | 'number' | 'boolean'; 2 | 3 | export type SerializableConfigSchema = { 4 | type: 'object'; 5 | properties: Record< 6 | string, 7 | { 8 | type: InferredType; 9 | } 10 | >; 11 | required: string[]; 12 | }; 13 | 14 | export default class ConfigSchema { 15 | readonly #type = 'object'; 16 | readonly #properties: Record = {}; 17 | #size = 0; 18 | 19 | public add(name: string, type: InferredType): string { 20 | if (!Object.hasOwn(this.#properties, name)) { 21 | this.#size++; 22 | this.#properties[name] = { type }; 23 | } 24 | 25 | return name; 26 | } 27 | 28 | public get size() { 29 | return this.#size; 30 | } 31 | 32 | public inferType(value: string): InferredType { 33 | if (value === 'true' || value === 'false') { 34 | return 'boolean'; 35 | } 36 | 37 | if (value === '0' || value === '1') { 38 | return 'boolean'; 39 | } 40 | 41 | if (!Number.isNaN(Number.parseInt(value))) { 42 | return 'number'; 43 | } 44 | 45 | return 'string'; 46 | } 47 | 48 | public serialize(): SerializableConfigSchema | undefined { 49 | if (this.#size === 0) { 50 | return; 51 | } 52 | 53 | return { 54 | type: this.#type, 55 | properties: this.#properties, 56 | required: Object.keys(this.#properties), 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/utils/parse-cmd/index.ts: -------------------------------------------------------------------------------- 1 | import ConfigSchema from '../config-schema.ts'; 2 | import parseDockerRun from './parse-docker-run.ts'; 3 | import parseEnvVariables from './parse-env-variables.ts'; 4 | import parseGeneric from './parse-generic.ts'; 5 | import parseNpx from './parse-npx-command.ts'; 6 | import ParsedCommand from './parsed-command.ts'; 7 | 8 | function parseCommand(input: string): string { 9 | if (input.length === 0) { 10 | throw new Error('No command name found'); 11 | } 12 | 13 | const index = input.indexOf(' '); 14 | return index === -1 ? input : input.slice(0, index); 15 | } 16 | 17 | const parsers = { 18 | npx: parseNpx, 19 | docker: parseDockerRun, 20 | generic: parseGeneric, 21 | } as const; 22 | 23 | export default function parseCmd(input: string): ParsedCommand { 24 | const configSchema = new ConfigSchema(); 25 | const { vars, lastIndex: offset } = parseEnvVariables(input); 26 | const env: Record = {}; 27 | for (const [key, value] of vars) { 28 | configSchema.add(key, configSchema.inferType(value)); 29 | env[key] = value; 30 | } 31 | 32 | const actualInput = input.slice(offset).trim(); 33 | const commandName = parseCommand(actualInput); 34 | const parse = parsers[commandName] ?? parsers.generic; 35 | return new ParsedCommand({ 36 | ...parse(configSchema, commandName, actualInput.slice(commandName.length + 1)), 37 | configSchema, 38 | env, 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/utils/parse-cmd/parse-generic.ts: -------------------------------------------------------------------------------- 1 | import type ConfigSchema from '../config-schema.ts'; 2 | import type { Result } from './types.ts'; 3 | 4 | function splitByWhitespacePreservingQuotes(input: string): string[] { 5 | // Regular expression to match: 6 | // 1. Double-quoted strings: "[^"]*" 7 | // 2. Single-quoted strings: '[^']*' 8 | // 3. Sequences of non-whitespace characters: \S+ 9 | const regex = /"[^"]*"|'[^']*'|\S+/g; 10 | const matches = input.match(regex); 11 | 12 | // Return the array of matches, or an empty array if no matches are found 13 | return matches || []; 14 | } 15 | 16 | function isChar(code: number): boolean { 17 | return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); 18 | } 19 | 20 | export default function parseGeneric( 21 | _configSchema: ConfigSchema, 22 | command: string, 23 | input: string, 24 | ): Omit { 25 | const args = splitByWhitespacePreservingQuotes(input); 26 | const externalId = [command]; 27 | for (const arg of args) { 28 | if (isChar(arg.charCodeAt(0))) { 29 | externalId.push(arg); 30 | } else { 31 | break; 32 | } 33 | } 34 | 35 | return { 36 | command, 37 | args: args.map(arg => ({ 38 | type: 'positional', 39 | dataType: 'string', 40 | value: arg, 41 | masked: null, 42 | })), 43 | externalId: externalId.join('-'), 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/utils/parse-cmd/types.ts: -------------------------------------------------------------------------------- 1 | import type ConfigSchema from '../config-schema.ts'; 2 | 3 | export type ResultArg = 4 | | { 5 | type: 'command'; 6 | value: string; 7 | } 8 | | { 9 | type: 'positional'; 10 | dataType: 'string' | 'number'; 11 | value: string; 12 | masked: string | null; 13 | } 14 | | ResultArgFlag; 15 | 16 | export type ResultArgFlag = { 17 | type: 'flag'; 18 | name: string; 19 | } & ( 20 | | { 21 | dataType: 'boolean'; 22 | value: true | false; 23 | } 24 | | { 25 | dataType: 'string' | 'number'; 26 | value: string; 27 | masked: string | null; 28 | } 29 | ); 30 | 31 | export type Result = { 32 | externalId: string; 33 | command: string; 34 | args: ResultArg[]; 35 | env: Record; 36 | configSchema: ConfigSchema; 37 | }; 38 | -------------------------------------------------------------------------------- /packages/cli/src/commands/upload/utils/string.ts: -------------------------------------------------------------------------------- 1 | const SENTENCE_REGEX = /[A-Za-z][^.!?]+[.!?]/; 2 | 3 | export function getSummary(value: string): string | undefined { 4 | return SENTENCE_REGEX.exec(value)?.[0]; 5 | } 6 | -------------------------------------------------------------------------------- /packages/cli/src/commands/whoami/index.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs'; 2 | 3 | import { createHandler } from '#libs/cli-utils'; 4 | import { whoami } from '#libs/datanaut-auth-cli'; 5 | 6 | export default { 7 | command: 'whoami', 8 | describe: process.env['NODE_ENV'] === 'development' ? 'Display Datanaut email' : false, 9 | handler: createHandler(whoami, true), 10 | } satisfies CommandModule; 11 | -------------------------------------------------------------------------------- /packages/cli/src/env.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import envalid from 'envalid'; 4 | 5 | import { constants } from '#libs/platform'; 6 | 7 | export default envalid.cleanEnv(process.env, { 8 | DN_API_URL: envalid.url({ 9 | default: 'https://datanaut.ai/', 10 | devDefault: 'http://localhost:3001/', 11 | desc: 'The URL of the datanaut API', 12 | }), 13 | DN_CLIENT_ID: envalid.str({ 14 | default: 'openmcp-cli', 15 | }), 16 | DN_CONFIGDIR: envalid.str({ 17 | default: path.join(constants.CONFIGDIR, 'datanaut'), 18 | }), 19 | }); 20 | -------------------------------------------------------------------------------- /packages/cli/src/errors/guards.ts: -------------------------------------------------------------------------------- 1 | export function isEnoentError(error: unknown): error is NodeJS.ErrnoException { 2 | return ( 3 | typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'ENOENT' 4 | ); 5 | } 6 | -------------------------------------------------------------------------------- /packages/cli/src/errors/handler-error.ts: -------------------------------------------------------------------------------- 1 | export default class HandlerError extends Error { 2 | override readonly message: string; 3 | 4 | constructor(error: unknown) { 5 | const message = error instanceof Error ? error.message : String(error); 6 | super(message, { cause: error }); 7 | this.message = message; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/src/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { isEnoentError } from './guards.ts'; 2 | export { default as HandlerError } from './handler-error.ts'; 3 | export { default as PrintableError } from './printable-error.ts'; 4 | export { OperationCanceledError, OperationTimedOutError } from './ui-errors.ts'; 5 | -------------------------------------------------------------------------------- /packages/cli/src/errors/printable-error.ts: -------------------------------------------------------------------------------- 1 | export default class PrintableError extends Error { 2 | override readonly message: string; 3 | 4 | constructor(message: string, error: unknown) { 5 | super(message, { cause: error }); 6 | this.message = `${message}: ${error instanceof Error ? error.message : String(error)}`; 7 | } 8 | 9 | override toString() { 10 | return this.message; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/errors/ui-errors.ts: -------------------------------------------------------------------------------- 1 | export class OperationCanceledError extends Error { 2 | constructor() { 3 | super('Operation was canceled'); 4 | } 5 | } 6 | 7 | export class OperationTimedOutError extends Error { 8 | constructor(timeout: number) { 9 | super(`Operation timed out after ${timeout}ms`); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as install } from './commands/install/index.ts'; 2 | export { default as run } from './commands/run/index.ts'; 3 | export { default as uninstall } from './commands/uninstall/index.ts'; 4 | export { default as upload } from './commands/upload/index.ts'; 5 | -------------------------------------------------------------------------------- /packages/cli/src/libs/cli-utils/create-handler.ts: -------------------------------------------------------------------------------- 1 | import type { ArgumentsCamelCase } from 'yargs'; 2 | 3 | import { HandlerError } from '#errors'; 4 | import console from '#libs/console'; 5 | 6 | import { render } from '../../ui/index.ts'; 7 | 8 | type Handler = (args: ArgumentsCamelCase) => void | Promise; 9 | 10 | export default function createHandler = Handler>(fn: F, ui: boolean): F { 11 | const wrappedFn: Handler = async args => { 12 | console.wrapConsole(); 13 | const inkInstance = ui ? render() : null; 14 | try { 15 | await fn(args); 16 | } catch (error) { 17 | if (inkInstance !== null) { 18 | console.error(error instanceof Error ? error.message : String(error)); 19 | } 20 | 21 | throw new HandlerError(error); 22 | } finally { 23 | console.restoreConsole(); 24 | inkInstance?.rerender(); 25 | inkInstance?.unmount(); 26 | } 27 | }; 28 | 29 | return wrappedFn as F; 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/libs/cli-utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createHandler } from './create-handler.ts'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/prompts/confirm.ts: -------------------------------------------------------------------------------- 1 | import { setAndWaitForPrompt } from './state.ts'; 2 | import type { ConfirmPrompt } from './types.ts'; 3 | 4 | export default function confirm(config: ConfirmPrompt['config']): Promise { 5 | return setAndWaitForPrompt({ 6 | type: 'confirm', 7 | config, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/prompts/guards.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject } from '@stoplight/json'; 2 | 3 | import type { MultiSelectPrompt, Prompt, SelectPrompt, TextPrompt } from './types.ts'; 4 | 5 | export function isTextPrompt(prompt: unknown): prompt is TextPrompt { 6 | return isPlainObject(prompt) && prompt['type'] === 'text'; 7 | } 8 | 9 | export function isConfirmPrompt(prompt: unknown): prompt is Prompt { 10 | return isPlainObject(prompt) && prompt['type'] === 'confirm'; 11 | } 12 | 13 | export function isSelectPrompt(prompt: unknown): prompt is SelectPrompt { 14 | return isPlainObject(prompt) && prompt['type'] === 'select'; 15 | } 16 | 17 | export function isMultiSelectPrompt(prompt: unknown): prompt is MultiSelectPrompt { 18 | return isPlainObject(prompt) && prompt['type'] === 'multi-select'; 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/prompts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as confirm } from './confirm.ts'; 2 | export { multiselect, select } from './select.ts'; 3 | export * as state from './state.ts'; 4 | export { maskedText, text } from './text.ts'; 5 | export type { 6 | ConfirmPrompt, 7 | MultiSelectPrompt, 8 | Prompt, 9 | SelectPrompt, 10 | SelectPromptOption, 11 | TextPrompt, 12 | } from './types.ts'; 13 | export { default as url } from './url.ts'; 14 | export { password, username } from './user.ts'; 15 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/prompts/select.ts: -------------------------------------------------------------------------------- 1 | import { setAndWaitForPrompt } from './state.ts'; 2 | import type { MultiSelectPrompt, SelectPrompt } from './types.ts'; 3 | 4 | export function select(config: SelectPrompt['config']): Promise { 5 | return setAndWaitForPrompt({ 6 | type: 'select', 7 | config, 8 | }); 9 | } 10 | 11 | export function multiselect(config: MultiSelectPrompt['config']): Promise { 12 | return setAndWaitForPrompt({ 13 | type: 'multi-select', 14 | config, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/prompts/text.ts: -------------------------------------------------------------------------------- 1 | import { setAndWaitForPrompt } from './state.ts'; 2 | import type { TextPrompt } from './types.ts'; 3 | 4 | export function text(config: TextPrompt['config']): Promise { 5 | return setAndWaitForPrompt({ 6 | type: 'text', 7 | config, 8 | }); 9 | } 10 | 11 | export function maskedText(config: Omit) { 12 | return text({ 13 | ...config, 14 | mask: '*', 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/prompts/types.ts: -------------------------------------------------------------------------------- 1 | export type TextPrompt = { 2 | type: 'text'; 3 | config: { 4 | message: string; 5 | placeholder?: string; 6 | mask?: string; 7 | help?: string; 8 | defaultValue?: string; 9 | validate?(value: string): string | void; 10 | }; 11 | }; 12 | 13 | export type ConfirmPrompt = { 14 | type: 'confirm'; 15 | config: { 16 | message: string; 17 | defaultValue?: boolean; 18 | }; 19 | }; 20 | 21 | export type SelectPromptOption = { 22 | label: string; 23 | hint?: string; 24 | value: string; 25 | }; 26 | 27 | export type SelectPrompt = { 28 | type: 'select'; 29 | config: { 30 | message: string; 31 | initialValue?: string; 32 | options: SelectPromptOption[]; 33 | }; 34 | }; 35 | 36 | export type MultiSelectPrompt = { 37 | type: 'multi-select'; 38 | config: { 39 | message: string; 40 | initialValues?: string[]; 41 | options: SelectPromptOption[]; 42 | optional?: boolean; 43 | }; 44 | }; 45 | 46 | export type Prompt = TextPrompt | ConfirmPrompt | SelectPrompt | MultiSelectPrompt; 47 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/prompts/url.ts: -------------------------------------------------------------------------------- 1 | import { text } from './text.ts'; 2 | 3 | export default function url(message = 'Please provide URL:'): Promise { 4 | return text({ 5 | message, 6 | validate: url => { 7 | if (!URL.canParse(url)) { 8 | return 'Inserted invalid URL. Please provide a valid URL.'; 9 | } 10 | }, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/prompts/user.ts: -------------------------------------------------------------------------------- 1 | import { maskedText, text } from './text.ts'; 2 | 3 | export const username = (message = 'Please insert username:') => 4 | text({ 5 | message, 6 | validate: value => { 7 | if (value.trim().length === 0) { 8 | return 'Username cannot be empty'; 9 | } 10 | }, 11 | }); 12 | 13 | export const password = (message = 'Please insert password:') => 14 | maskedText({ 15 | message, 16 | }); 17 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/reporters/__tests__/log-file.spec.ts: -------------------------------------------------------------------------------- 1 | import { createConsola } from 'consola/core'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | 4 | import logFileReporter from '../log-file.ts'; 5 | 6 | describe('logFileReporter', () => { 7 | it('should write log message to stdout for relevant log types', () => { 8 | const stdoutMock = { write: vi.fn() }; 9 | const consola = createConsola({ 10 | stdout: stdoutMock as any, 11 | reporters: [logFileReporter], 12 | }); 13 | 14 | consola.info('info message', 0); 15 | consola.log('log message', 2); 16 | 17 | expect(stdoutMock.write).toHaveBeenCalledTimes(2); 18 | expect(stdoutMock.write.mock.calls).toEqual([ 19 | [expect.stringMatching(/\[INFO]\sinfo message\s0\n/)], 20 | [expect.stringMatching(/\[LOG]\slog message\s2\n/)], 21 | ]); 22 | }); 23 | 24 | it('should write log message to stderr for error log types', () => { 25 | const stderrMock = { write: vi.fn() }; 26 | const consola = createConsola({ 27 | stderr: stderrMock as any, 28 | reporters: [logFileReporter], 29 | }); 30 | 31 | consola.error('error message', new Error('some error')); 32 | 33 | expect(stderrMock.write.mock.calls).toEqual([ 34 | [expect.stringMatching(/\[ERROR]\serror message\sError: some error\n/)], 35 | ]); 36 | }); 37 | 38 | it('should handle missing stdout and stderr gracefully', () => { 39 | const consola = createConsola({ 40 | reporters: [logFileReporter], 41 | }); 42 | 43 | expect(() => consola.error('error')).not.toThrow(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/reporters/console.ts: -------------------------------------------------------------------------------- 1 | import { type ConsolaReporter } from 'consola/core'; 2 | 3 | const LOG_LEVELS = { 4 | 0: 'error', 5 | 1: 'warn', 6 | 2: 'log', 7 | 3: 'info', 8 | 4: 'debug', 9 | 5: 'trace', 10 | } as const; 11 | 12 | export default { 13 | log(logObject) { 14 | const args = logObject.message === undefined ? logObject.args : [logObject.message, ...logObject.args]; 15 | // eslint-disable-next-line no-console 16 | console[LOG_LEVELS[logObject.level]](...args); 17 | }, 18 | } as const satisfies ConsolaReporter; 19 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/reporters/fancy/fancy.ts: -------------------------------------------------------------------------------- 1 | import type { ConsolaReporter, LogObject, LogType as ConsolaLogType } from 'consola/core'; 2 | 3 | import { createShallowObservableArray } from '#libs/observable'; 4 | 5 | export type LogType = 'error' | 'warn' | 'info' | 'verbose' | 'start' | 'success'; 6 | 7 | export type Log = { 8 | readonly id: string; 9 | readonly type: LogType; 10 | readonly message: string; 11 | readonly timestamp: Date; 12 | }; 13 | 14 | export const logs = createShallowObservableArray([]); 15 | 16 | export function addLog(type: LogType, message: string, timestamp: Date) { 17 | logs.push({ 18 | id: String(logs.length + 1), 19 | type, 20 | message, 21 | timestamp, 22 | }); 23 | } 24 | 25 | const map = { 26 | error: 'error', 27 | fatal: 'error', 28 | fail: 'error', 29 | 30 | warn: 'warn', 31 | 32 | info: 'info', 33 | log: 'info', 34 | 35 | box: null, 36 | start: 'start', 37 | 38 | success: 'success', 39 | ready: 'success', 40 | 41 | debug: 'verbose', 42 | trace: 'verbose', 43 | verbose: 'verbose', 44 | 45 | silent: null, 46 | } as const satisfies Record; 47 | 48 | // Format the message from logObj 49 | function formatMessage(logObj: LogObject): string { 50 | const serializedArgs = [logObj.message, ...logObj.args].filter(v => v !== undefined).map(v => String(v)); 51 | 52 | return serializedArgs.join(' '); 53 | } 54 | 55 | const fancyReporter: ConsolaReporter = { 56 | log(logObj) { 57 | const type = map[logObj.type]; 58 | if (type === null) return; 59 | const message = formatMessage(logObj); 60 | addLog(type, message, new Date(logObj.date)); 61 | }, 62 | }; 63 | 64 | export default fancyReporter; 65 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/reporters/fancy/index.ts: -------------------------------------------------------------------------------- 1 | export { default, logs, type LogType } from './fancy.ts'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/reporters/log-file.ts: -------------------------------------------------------------------------------- 1 | import { type ConsolaReporter, LogLevels, type LogObject } from 'consola/core'; 2 | 3 | function formatDate(date: Date): string { 4 | const year = date.getFullYear(); 5 | const month = String(date.getMonth() + 1).padStart(2, '0'); 6 | const day = String(date.getDate()).padStart(2, '0'); 7 | const hours = String(date.getHours()).padStart(2, '0'); 8 | const minutes = String(date.getMinutes()).padStart(2, '0'); 9 | const seconds = String(date.getSeconds()).padStart(2, '0'); 10 | 11 | const timezoneOffset = -date.getTimezoneOffset(); 12 | const timezoneHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0'); 13 | const timezoneMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0'); 14 | const timezoneSign = timezoneOffset >= 0 ? '+' : '-'; 15 | 16 | const timezone = `${timezoneSign}${timezoneHours}:${timezoneMinutes}`; 17 | 18 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`; 19 | } 20 | 21 | function formatMessage(logObj: LogObject) { 22 | const date = formatDate(logObj.date); 23 | const serializedArgs = [logObj.message, ...logObj.args].filter(v => v !== undefined).map(v => String(v)); 24 | return `[${date}] [${logObj.type.toUpperCase()}] ${serializedArgs.join(' ')}\n`; 25 | } 26 | 27 | export default { 28 | log(logObj, { options }) { 29 | if (logObj.level <= LogLevels.error) { 30 | options.stderr?.write(formatMessage(logObj)); 31 | } else { 32 | options.stdout?.write(formatMessage(logObj)); 33 | } 34 | }, 35 | } as const satisfies ConsolaReporter; 36 | -------------------------------------------------------------------------------- /packages/cli/src/libs/console/types.ts: -------------------------------------------------------------------------------- 1 | import type { ConsolaInstance } from 'consola/core'; 2 | 3 | export type PromptlessConsola = Omit; 4 | -------------------------------------------------------------------------------- /packages/cli/src/libs/datanaut/agent.ts: -------------------------------------------------------------------------------- 1 | import { isDefinedError, ORPCError } from '@orpc/client'; 2 | 3 | import { confirm } from '#libs/console/prompts'; 4 | import { login } from '#libs/datanaut-auth-cli'; 5 | 6 | import type { Agent } from '../../rpc/agents.ts'; 7 | import { rpcClient } from './sdk/sdk.ts'; 8 | 9 | type Opts = { 10 | /** 11 | * @defaultValue true 12 | */ 13 | login?: boolean; 14 | }; 15 | 16 | export async function getAgentById(id: string, { login: shouldLogin = true }: Opts = {}): Promise { 17 | let agent: Agent; 18 | try { 19 | agent = await rpcClient.cli.agents.getAgent({ agentId: id }); 20 | } catch (error) { 21 | if (!(error instanceof ORPCError) || !isDefinedError(error)) { 22 | throw error; 23 | } 24 | 25 | if (error.status !== 401 || !shouldLogin) { 26 | throw new Error(error.message, { cause: error }); 27 | } 28 | 29 | const res = await confirm({ 30 | message: `You do not seem to be logged in. Would you like to log in?`, 31 | }); 32 | 33 | if (res) { 34 | await login(); 35 | // let's retry once logged in 36 | return getAgentById(id, { login: false }); 37 | } else { 38 | throw new Error('Login cancelled. Please execute `login` to log in.'); 39 | } 40 | } 41 | 42 | return agent; 43 | } 44 | -------------------------------------------------------------------------------- /packages/cli/src/libs/datanaut/auth-cli.ts: -------------------------------------------------------------------------------- 1 | // a set of wrappers around auth 2 | import console from '#libs/console'; 3 | import { confirm } from '#libs/console/prompts'; 4 | 5 | import { login as _login, logout as _logout, whoami as _whoami } from './auth/index.ts'; 6 | 7 | export async function login(): Promise { 8 | console.start('Logging in...'); 9 | const { email } = await _login({ 10 | async openPage(url: URL): Promise { 11 | const res = await confirm({ 12 | message: `Do you want to open the login page in your browser?`, 13 | }); 14 | 15 | if (!res) { 16 | console.info(['Copy the URL from the terminal and open it in the browser', url.toString()].join('\n')); 17 | } else { 18 | console.info( 19 | [ 20 | 'Opening the login page in your browser...', 21 | 'If the browser does not open, copy the URL from the terminal and open it in the browser', 22 | url.toString(), 23 | ].join('\n'), 24 | ); 25 | } 26 | 27 | return res; 28 | }, 29 | }); 30 | console.success(`Logged in successfully as ${email}`); 31 | } 32 | 33 | export async function logout(): Promise { 34 | console.start('Logging out...'); 35 | await _logout(); 36 | console.success('Logged out successfully'); 37 | } 38 | 39 | export async function whoami(): Promise { 40 | const { email } = _whoami(); 41 | console.success(`You are logged in as ${email}`); 42 | } 43 | -------------------------------------------------------------------------------- /packages/cli/src/libs/datanaut/auth/api.ts: -------------------------------------------------------------------------------- 1 | import { clearTimeout, setTimeout } from 'node:timers'; 2 | 3 | import open from 'open'; 4 | 5 | import env from '../../../env.ts'; 6 | import { createAuthClient } from './client.ts'; 7 | import { startServer, waitForAuthorizationCallback } from './server.ts'; 8 | import Storage from './storage/index.ts'; 9 | 10 | export const client = await createAuthClient({ 11 | hostURL: env.DN_API_URL, 12 | clientId: env.DN_CLIENT_ID, 13 | storage: await Storage.create('auth'), 14 | }); 15 | 16 | interface Prompts { 17 | openPage(url: URL): Promise; 18 | } 19 | 20 | export function whoami() { 21 | const decoded = client.getDecodedIdToken(); 22 | return { 23 | email: String(decoded['email']), 24 | } as const; 25 | } 26 | 27 | export async function login(prompts: Prompts) { 28 | const LOGIN_TIMEOUT = 1000 * 60 * 5; // 5 minutes 29 | 30 | const controller = new AbortController(); 31 | const tId = setTimeout(() => controller.abort(), LOGIN_TIMEOUT); 32 | using server = await startServer(controller.signal); 33 | try { 34 | const authorizeUrl = await client.initiateAuthFlow(server.address); 35 | 36 | await Promise.all([ 37 | waitForAuthorizationCallback(server, client).then(code => client.exchangeCodeForTokens(code)), 38 | prompts.openPage(authorizeUrl).then(consent => { 39 | if (consent) { 40 | return open(authorizeUrl.toString()); 41 | } 42 | }), 43 | ]); 44 | 45 | return whoami(); 46 | } finally { 47 | clearTimeout(tId); 48 | } 49 | } 50 | 51 | export async function logout() { 52 | await client.clearTokens(); 53 | } 54 | -------------------------------------------------------------------------------- /packages/cli/src/libs/datanaut/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { client, login, logout, whoami } from './api.ts'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/libs/datanaut/auth/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from '../../fs-config.ts'; 2 | import type { OAuth2Tokens, Storage } from '../client.ts'; 3 | import Mutex from './mutex.ts'; 4 | 5 | export default class StorageImpl implements Storage { 6 | readonly #filename: string; 7 | readonly #data: OAuth2Tokens; 8 | readonly #mutex = new Mutex(); 9 | 10 | constructor(filename: string, data: Record) { 11 | this.#filename = filename; 12 | this.#data = data; 13 | } 14 | 15 | /** 16 | * Creates a new storage instance 17 | * 18 | * @param namespace 19 | */ 20 | static async create(namespace: string) { 21 | const filename = `storage-${namespace}.json`; 22 | const data = await readFile(filename); 23 | try { 24 | return new StorageImpl(filename, data === null ? {} : JSON.parse(data)); 25 | } catch { 26 | return new StorageImpl(filename, {}); 27 | } 28 | } 29 | 30 | async #flush() { 31 | await writeFile(this.#filename, JSON.stringify(this.#data)); 32 | } 33 | 34 | async setItem(key: string, value: unknown): Promise { 35 | await this.#mutex.lock(async () => { 36 | this.#data[key as string] = value as string; 37 | await this.#flush(); 38 | }); 39 | } 40 | 41 | async getItem(key: K): Promise { 42 | return this.#data[key] ?? null; 43 | } 44 | 45 | async removeItem(key: string): Promise { 46 | await this.#mutex.lock(async () => { 47 | delete this.#data[key as string]; 48 | await this.#flush(); 49 | }); 50 | } 51 | 52 | async clear() { 53 | await this.#mutex.lock(async () => { 54 | for (const key of Object.keys(this.#data)) { 55 | delete this.#data[key]; 56 | } 57 | await this.#flush(); 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/cli/src/libs/datanaut/auth/storage/mutex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A simple mutex implementation for asynchronous functions 3 | * Note: this is not a general purpose mutex implementation, it is only 4 | * intended to be used for synchronizing access to a single resource. 5 | */ 6 | export default class Mutex { 7 | private mutex = Promise.resolve(); 8 | 9 | /** 10 | * Locks the mutex and executes the given function 11 | * @param fn The function to execute while the mutex is locked 12 | * @returns The result of the function 13 | */ 14 | async lock(fn: () => Promise): Promise { 15 | const { promise: newMutex, resolve: unlock } = Promise.withResolvers(); 16 | 17 | // Wait for the previous operation to complete 18 | const previousMutex = this.mutex; 19 | this.mutex = previousMutex.then(() => newMutex); 20 | 21 | try { 22 | await previousMutex; 23 | return await fn(); 24 | } finally { 25 | unlock(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/cli/src/libs/datanaut/fs-config.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, readFile as fsReadFile, writeFile as fsWriteFile } from 'node:fs/promises'; 2 | import { join } from 'node:path'; 3 | 4 | import env from '../../env.ts'; 5 | 6 | /** 7 | * Gets the path to a file in the configuration directory 8 | * @param filename The name of the file 9 | * @returns The full path to the file 10 | */ 11 | export function getFilePath(filename: string): string { 12 | return join(env.DN_CONFIGDIR, filename); 13 | } 14 | 15 | /** 16 | * Reads data from a file in the configuration directory 17 | * @param filename The name of the file to read 18 | * @returns The file contents as a string, or null if the file doesn't exist 19 | */ 20 | export async function readFile(filename: string): Promise { 21 | try { 22 | return await fsReadFile(getFilePath(filename), 'utf-8'); 23 | } catch { 24 | return null; 25 | } 26 | } 27 | 28 | /** 29 | * Writes data to a file in the configuration directory 30 | * @param filename The name of the file to write 31 | * @param data The data to write to the file 32 | */ 33 | export async function writeFile(filename: string, data: string): Promise { 34 | await ensureConfigDirExists(); 35 | await fsWriteFile(getFilePath(filename), data, 'utf8'); 36 | } 37 | 38 | /** 39 | * Ensures that the configuration directory exists 40 | */ 41 | async function ensureConfigDirExists(): Promise { 42 | await mkdir(env.DN_CONFIGDIR, { recursive: true }); 43 | } 44 | -------------------------------------------------------------------------------- /packages/cli/src/libs/datanaut/sdk/sdk.ts: -------------------------------------------------------------------------------- 1 | import { createORPCClient } from '@orpc/client'; 2 | import { RPCLink } from '@orpc/client/fetch'; 3 | import { SimpleCsrfProtectionLinkPlugin } from '@orpc/client/plugins'; 4 | 5 | import env from '../../../env.ts'; 6 | import type { ContractRouterClient } from '../../../rpc/index.ts'; 7 | import { client } from '../auth/index.ts'; 8 | 9 | const link = new RPCLink({ 10 | url: new URL('__rpc', env.DN_API_URL), 11 | plugins: [new SimpleCsrfProtectionLinkPlugin()], 12 | async headers() { 13 | try { 14 | const bearer = await client.generateAccessToken(); 15 | return { 16 | Authorization: `Bearer ${bearer}`, 17 | 'X-Bearer-Format': 'opaque', 18 | }; 19 | } catch { 20 | return {}; 21 | } 22 | }, 23 | }); 24 | 25 | export const rpcClient: ContractRouterClient = createORPCClient(link); 26 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/config/index.ts: -------------------------------------------------------------------------------- 1 | export { default as readConfig } from './read.ts'; 2 | export { type ResolvableConfigPath, default as resolveConfigPath } from './resolve-path.ts'; 3 | export { default as writeConfig } from './write.ts'; 4 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/config/read.ts: -------------------------------------------------------------------------------- 1 | import { parseDocument } from '@openmcp/utils/documents'; 2 | import type { z } from 'zod'; 3 | 4 | import type { Context, FsInstallMethod } from '../types.ts'; 5 | import resolveConfigPath from './resolve-path.ts'; 6 | 7 | export default async function readConfig( 8 | { constants, fs, logger }: Context, 9 | installMethod: I, 10 | ): Promise> { 11 | const resolvedConfigPath = resolveConfigPath(constants, installMethod.filepath); 12 | logger.start(`Loading config from "${resolvedConfigPath}"`); 13 | const content = await fs.readFile(resolvedConfigPath, 'utf8'); 14 | const fileExtension = resolvedConfigPath.split('.').pop() || ''; 15 | let document; 16 | try { 17 | document = parseDocument(content, fileExtension); 18 | } catch (error) { 19 | throw new Error(`Error parsing config file: ${error instanceof Error ? error.message : String(error)}`); 20 | } 21 | 22 | const result = installMethod.schema.safeParse(document); 23 | if (result.error) { 24 | throw new Error(`Error validating config file: ${result.error.issues.map(issue => issue.message).join(', ')}`); 25 | } 26 | 27 | logger.success('Config was loaded successfully'); 28 | 29 | return result.data; 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/config/resolve-path.ts: -------------------------------------------------------------------------------- 1 | import { normalize } from 'node:path'; 2 | import { join } from 'node:path/posix'; 3 | 4 | import type { Context } from '../types.ts'; 5 | 6 | export type ResolvableConfigPath = `${'$CWD' | '$HOME' | '$CONFIG' | '$VSCODE' | ''}/${string}`; 7 | 8 | export default function resolveConfigPath( 9 | { HOMEDIR, CONFIGDIR, CWD }: Context['constants'], 10 | value: ResolvableConfigPath, 11 | ) { 12 | return normalize( 13 | value.replace(/^\$[A-Za-z_]+/g, v => { 14 | switch (v) { 15 | case '$HOME': 16 | return HOMEDIR; 17 | case '$CONFIG': 18 | return CONFIGDIR; 19 | case '$CWD': 20 | return CWD; 21 | case '$VSCODE': 22 | return join(CONFIGDIR, 'Code', 'User'); 23 | default: 24 | throw new Error(`Unknown path variable: ${v}`); 25 | } 26 | }), 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/config/write.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path'; 2 | 3 | import { serializeDocument } from '@openmcp/utils/documents'; 4 | import type { z } from 'zod'; 5 | 6 | import { isEnoentError } from '../../../errors/guards.ts'; 7 | import type { Context, FsInstallMethod } from '../types.ts'; 8 | import readConfig from './read.ts'; 9 | import resolveConfigPath from './resolve-path.ts'; 10 | 11 | export default async function writeConfig( 12 | ctx: Context, 13 | installMethod: I, 14 | applyConfig: (config: z.infer, filepath: string) => Promise, 15 | ): Promise { 16 | const resolvedConfigPath = resolveConfigPath(ctx.constants, installMethod.filepath); 17 | let config: z.infer; 18 | try { 19 | config = await readConfig(ctx, installMethod); 20 | } catch (error) { 21 | if (!isEnoentError(error)) { 22 | throw error; 23 | } 24 | 25 | ctx.logger.info('Config does not exist yet'); 26 | await ctx.fs.mkdir(dirname(resolvedConfigPath), { recursive: true }); 27 | config = {}; 28 | } 29 | 30 | await applyConfig(config, resolvedConfigPath); 31 | const ext = resolvedConfigPath.slice(resolvedConfigPath.lastIndexOf('.') + 1); 32 | if (ext === 'json') { 33 | await ctx.fs.writeFile(resolvedConfigPath, serializeDocument(config, 'json')); 34 | } else { 35 | await ctx.fs.writeFile(resolvedConfigPath, serializeDocument(config, 'yaml')); 36 | } 37 | 38 | ctx.logger.success('Config was saved successfully'); 39 | } 40 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/errors/guards.ts: -------------------------------------------------------------------------------- 1 | export function isEnoentError(error: unknown): error is NodeJS.ErrnoException { 2 | return ( 3 | typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'ENOENT' 4 | ); 5 | } 6 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { isEnoentError } from './guards.ts'; 2 | export { default as InstallLocationUnavailable } from './install-method-unavailable.ts'; 3 | export { default as ServerConflict } from './server-conflict.ts'; 4 | export { default as ServerNotInstalled } from './server-not-installed.ts'; 5 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/errors/install-method-unavailable.ts: -------------------------------------------------------------------------------- 1 | import type { InstallMethodLocation, Server } from '../types.ts'; 2 | 3 | export default class InstallLocationUnavailable extends Error { 4 | constructor(server: Server, location: InstallMethodLocation) { 5 | super(`Install location "${location}" is not available for server "${server.name}"`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/errors/server-conflict.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from '../types.ts'; 2 | 3 | export default class ServerConflict extends Error { 4 | constructor(server: Server) { 5 | super(`Server "${server.name}" is already installed.`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/errors/server-not-installed.ts: -------------------------------------------------------------------------------- 1 | import type { Remix } from '../types.ts'; 2 | 3 | export default class RemixNotInstalled extends Error { 4 | constructor(remix: Remix) { 5 | super(`Server "${remix.name}" is not installed`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/get-install-filepath.ts: -------------------------------------------------------------------------------- 1 | import { constants as osConstants } from '#libs/platform'; 2 | 3 | import { resolveConfigPath } from './config/index.ts'; 4 | import { type IntegrationName, integrations } from './integrations/index.ts'; 5 | import findMatchingInstallMethod from './integrations/utils/find-matching-install-method.ts'; 6 | import type { InstallLocation } from './types.ts'; 7 | 8 | export default function getInstallFilepath( 9 | cwd: string, 10 | integrationName: IntegrationName, 11 | location: InstallLocation, 12 | ): string | null { 13 | const integration = integrations[integrationName]; 14 | const installMethod = findMatchingInstallMethod(integration.installMethods, location); 15 | if (installMethod === null) return null; 16 | 17 | const constants = { 18 | ...osConstants, 19 | CWD: cwd, 20 | } as const; 21 | return resolveConfigPath(constants, installMethod.filepath); 22 | } 23 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/get-install-hints.ts: -------------------------------------------------------------------------------- 1 | import type { IntegrationName } from './integrations/index.ts'; 2 | import generateServerName from './integrations/utils/generate-server-name.ts'; 3 | import type { Server } from './types.ts'; 4 | 5 | type ShellCommandHint = { 6 | readonly type: 'command'; 7 | readonly value: string; 8 | }; 9 | 10 | type LinkCommandHint = { 11 | readonly type: 'url'; 12 | readonly value: string; 13 | }; 14 | 15 | type Hint = ShellCommandHint | LinkCommandHint; 16 | 17 | function generateVsCodeTransport(server: Server) { 18 | return serializeTransport({ 19 | name: generateServerName([], server), 20 | type: 'stdio', 21 | args: ['-y', 'openmcp@latest', 'run', '--server', server.target], 22 | }); 23 | } 24 | 25 | function serializeTransport(transport: Record) { 26 | return encodeURIComponent(JSON.stringify(transport)); 27 | } 28 | 29 | export default function getInstallHints( 30 | server: Server, 31 | integrationName: IntegrationName, 32 | ): [ShellCommandHint] | [ShellCommandHint, LinkCommandHint] { 33 | const hints: [ShellCommandHint, ...Hint[]] = [ 34 | { 35 | type: 'command', 36 | value: `npx openmcp@latest install ${server.target} --client ${integrationName}`, 37 | }, 38 | ]; 39 | 40 | if (integrationName === 'vscode') { 41 | hints.push({ 42 | type: 'url', 43 | value: `vscode:mcp/install?${generateVsCodeTransport(server)}`, 44 | }); 45 | } else if (integrationName === 'vscode-insiders') { 46 | hints.push({ 47 | type: 'url', 48 | value: `vscode-insiders:mcp/install?${generateVsCodeTransport(server)}`, 49 | }); 50 | } 51 | 52 | return hints as [ShellCommandHint] | [ShellCommandHint, LinkCommandHint]; 53 | } 54 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/index.isomorphic.ts: -------------------------------------------------------------------------------- 1 | export { default as getInstallHints } from './get-install-hints.ts'; 2 | export type { IntegrationName } from './integrations/index.ts'; 3 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/index.ts: -------------------------------------------------------------------------------- 1 | export { default as getInstallFilepath } from './get-install-filepath.ts'; 2 | export { default as getInstallHints } from './get-install-hints.ts'; 3 | export { default as install } from './install.ts'; 4 | export { type IntegrationName, integrations } from './integrations/index.ts'; 5 | export type { InstallLocation, Logger, McpHostClient, Remix } from './types.ts'; 6 | export { default as uninstall } from './uninstall.ts'; 7 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/install.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | 3 | import { constants as osConstants, getPlatform as platform } from '#libs/platform'; 4 | 5 | import resolveConfigPath from './config/resolve-path.ts'; 6 | import { ServerConflict } from './errors/index.ts'; 7 | import { type IntegrationName, integrations } from './integrations/index.ts'; 8 | import type { InstallLocation, Logger, Server } from './types.ts'; 9 | 10 | export default async function install( 11 | { 12 | cwd, 13 | logger, 14 | }: { 15 | readonly cwd: string; 16 | readonly logger: Logger; 17 | }, 18 | integrationName: IntegrationName, 19 | server: Server, 20 | location: InstallLocation, 21 | ): Promise { 22 | const integration = integrations[integrationName]; 23 | const serverName = JSON.stringify(server.name); 24 | logger.start(`Installing ${serverName}`); 25 | try { 26 | const constants = { 27 | ...osConstants, 28 | CWD: cwd, 29 | } as const; 30 | const { filepath } = await integration.install( 31 | { 32 | platform: platform(), 33 | constants, 34 | fs, 35 | logger, 36 | }, 37 | server, 38 | location, 39 | ); 40 | 41 | const resolvedConfigPath = resolveConfigPath(constants, filepath); 42 | logger.success(`${serverName} was successfully installed to ${JSON.stringify(resolvedConfigPath)}`); 43 | } catch (error) { 44 | if (error instanceof ServerConflict) { 45 | logger.info( 46 | `${serverName} is already installed. You may need to restart your target client for changes to take affect.`, 47 | ); 48 | return; 49 | } 50 | 51 | logger.error(new Error(`Failed to install ${serverName}`, { cause: error })); 52 | return; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/__tests__/fixtures/generic.ts: -------------------------------------------------------------------------------- 1 | import { serializeDocument } from '@openmcp/utils/documents'; 2 | 3 | export const configYaml = serializeDocument( 4 | { 5 | mcpServers: { 6 | 'test-server': { 7 | command: 'npx', 8 | args: ['-y', 'openmcp@1', 'run', '--server', 'ag_abc123de'], 9 | }, 10 | }, 11 | }, 12 | 'yaml', 13 | ); 14 | 15 | export const emptyConfigYaml = serializeDocument( 16 | { 17 | mcpServers: {}, 18 | }, 19 | 'yaml', 20 | ); 21 | 22 | export const invalidConfigYaml = serializeDocument( 23 | { 24 | mcpServers: { 25 | 'test-server': { 26 | command: 'invalid-command', // Not 'npx', so it won't match SERVER_SCHEMA 27 | args: ['-y', 'openmcp@1', 'run', '--server', 'ag_abc123de'], 28 | }, 29 | 'valid-server': { 30 | command: 'npx', 31 | args: ['-y', 'openmcp@1', 'run', '--server', 'ag_xyz789'], 32 | }, 33 | }, 34 | }, 35 | 'yaml', 36 | ); 37 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/__tests__/fixtures/goose.ts: -------------------------------------------------------------------------------- 1 | import { serializeDocument } from '@openmcp/utils/documents'; 2 | 3 | export const configYaml = serializeDocument( 4 | { 5 | extensions: { 6 | 'test-server': { 7 | args: ['-y', 'openmcp@1', 'run', '--server', 'ag_abc123de'], 8 | bundled: null, 9 | cmd: 'npx', 10 | description: null, 11 | enabled: true, 12 | env_keys: [], 13 | envs: {}, 14 | name: 'Test Server', 15 | timeout: 300, 16 | type: 'stdio', 17 | }, 18 | }, 19 | }, 20 | 'yaml', 21 | ); 22 | 23 | export const emptyConfigYaml = serializeDocument( 24 | { 25 | extensions: {}, 26 | }, 27 | 'yaml', 28 | ); 29 | 30 | export const invalidConfigYaml = serializeDocument( 31 | { 32 | extensions: { 33 | 'test-server': { 34 | args: ['-y', 'openmcp@latest', 'run', '--server', 'ag_abc123de'], 35 | bundled: null, 36 | cmd: 'invalid-command', // Not 'npx', so it won't match SERVER_SCHEMA 37 | description: null, 38 | enabled: true, 39 | env_keys: [], 40 | envs: {}, 41 | name: 'Test Server', 42 | timeout: 300, 43 | type: 'stdio', 44 | }, 45 | 'valid-server': { 46 | args: ['-y', 'openmcp@latest', 'run', '--server', 'ag_xyz789'], 47 | bundled: null, 48 | cmd: 'npx', 49 | description: null, 50 | enabled: true, 51 | env_keys: [], 52 | envs: {}, 53 | name: 'Valid Server', 54 | timeout: 300, 55 | type: 'stdio', 56 | }, 57 | }, 58 | }, 59 | 'yaml', 60 | ); 61 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/__tests__/fixtures/vscode.ts: -------------------------------------------------------------------------------- 1 | export const settingsJson = JSON.stringify( 2 | { 3 | mcp: { 4 | servers: { 5 | 'test-server': { 6 | type: 'stdio', 7 | command: 'npx', 8 | args: ['-y', 'openmcp@1', 'run', '--server', 'ag_abc123de'], 9 | }, 10 | }, 11 | }, 12 | }, 13 | null, 14 | 2, 15 | ); 16 | 17 | export const emptySettingsJson = JSON.stringify( 18 | { 19 | mcp: { 20 | servers: {}, 21 | }, 22 | }, 23 | null, 24 | 2, 25 | ); 26 | 27 | export const mcpJson = JSON.stringify( 28 | { 29 | servers: { 30 | 'test-server': { 31 | type: 'stdio', 32 | command: 'npx', 33 | args: ['-y', 'openmcp@1', 'run', '--server', 'ag_abc123de'], 34 | }, 35 | }, 36 | }, 37 | null, 38 | 2, 39 | ); 40 | 41 | export const emptyMcpJson = JSON.stringify( 42 | { 43 | servers: {}, 44 | }, 45 | null, 46 | 2, 47 | ); 48 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/cursor.ts: -------------------------------------------------------------------------------- 1 | import { inferTargetType } from '#libs/mcp-utils'; 2 | 3 | import { resolveConfigPath } from '../config/index.ts'; 4 | import type { Context, FsInstallMethod, InstallMethod, McpHostClient, Server } from '../types.ts'; 5 | import createGenericClient from './generic.ts'; 6 | import generateDefinitionWorkspacePath from './utils/generate-definition-workspace-path.ts'; 7 | import unwrapMatchingInstallMethod from './utils/uwrap-matching-install-method.ts'; 8 | 9 | export default function createCursorClient(): McpHostClient { 10 | const client = createGenericClient('cursor', { 11 | global: '$HOME/.cursor/mcp.json', 12 | local: '$CWD/.cursor/mcp.json', 13 | }); 14 | 15 | return { 16 | name: client.name, 17 | installMethods: client.installMethods, 18 | install(ctx, server, location) { 19 | if (location === 'local' || location === 'prefer-local') { 20 | return client.install(ctx, resolveServer(ctx, client.installMethods, server), location); 21 | } 22 | 23 | return client.install(ctx, server, location); 24 | }, 25 | uninstall(ctx, server, location) { 26 | if (location === 'local' || location === 'prefer-local') { 27 | return client.uninstall(ctx, resolveServer(ctx, client.installMethods, server), location); 28 | } 29 | 30 | return client.uninstall(ctx, server, location); 31 | }, 32 | }; 33 | } 34 | 35 | function resolveServer(ctx: Context, installMethods: InstallMethod[], server: S): S { 36 | const installMethod = unwrapMatchingInstallMethod(installMethods, server, 'local'); 37 | const resolvedPath = resolveConfigPath(ctx.constants, installMethod.filepath); 38 | if (inferTargetType(server.target) !== 'openapi') { 39 | return server; 40 | } 41 | 42 | return { 43 | ...server, 44 | target: generateDefinitionWorkspacePath('${workspaceFolder}/.cursor', resolvedPath, server.target), 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/index.ts: -------------------------------------------------------------------------------- 1 | import createCursorClient from './cursor.ts'; 2 | import createGenericClient from './generic.ts'; 3 | import createGooseClient from './goose.ts'; 4 | import createVSCodeClient from './vscode.ts'; 5 | 6 | export const integrations = { 7 | boltai: createGenericClient('boltai', { 8 | // https://docs.boltai.com/docs/plugins/mcp-servers#faqs 9 | global: '$HOME/.boltai/mcp.json', 10 | local: null, 11 | }), 12 | claude: createGenericClient('claude', { 13 | // https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server 14 | global: '$CONFIG/Claude/claude_desktop_config.json', 15 | local: null, 16 | }), 17 | cline: createGenericClient('cline', { 18 | global: '$VSCODE/saoudrizwan.claude-dev/settings/cline_mcp_settings.json', 19 | local: null, 20 | }), 21 | cursor: createCursorClient(), 22 | goose: createGooseClient(), 23 | roocode: createGenericClient('roocode', { 24 | global: '$VSCODE/rooveterinaryinc.roo-cline/settings/mcp_settings.json', 25 | local: null, 26 | }), 27 | vscode: createVSCodeClient('vscode', { 28 | global: '$VSCODE/settings.json', 29 | // https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server 30 | local: '$CWD/.vscode/mcp.json', 31 | }), 32 | 'vscode-insiders': createVSCodeClient('vscode-insiders', { 33 | global: '$CONFIG/Code - Insiders/User/settings.json', 34 | // https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server 35 | local: '$CWD/.vscode/mcp.json', 36 | }), 37 | windsurf: createGenericClient('windsurf', { 38 | // https://docs.windsurf.com/windsurf/mcp#mcp-config-json 39 | global: '$HOME/.codeium/windsurf/mcp_config.json', 40 | local: null, 41 | }), 42 | witsy: createGenericClient('witsy', { 43 | global: '$CONFIG/Witsy/settings.json', 44 | local: null, 45 | }), 46 | } as const; 47 | 48 | export { default as generateRemixName } from './utils/generate-server-name.ts'; 49 | 50 | export type IntegrationName = keyof typeof integrations; 51 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/__tests__/generate-transport.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import type { InstallMethodLocation, Server } from '../../../types.ts'; 4 | import generateTransport from '../generate-transport.ts'; 5 | 6 | describe('generateTransport', () => { 7 | it('should generate transport with --server flag when target starts with ag_', () => { 8 | const server: Server = { 9 | id: 'ag_abc1234de', 10 | name: 'Test Server', 11 | target: 'ag_abc1234de', 12 | }; 13 | 14 | const transport = generateTransport(server); 15 | 16 | expect(transport).toStrictEqual({ 17 | command: 'npx', 18 | args: ['-y', 'openmcp@1', 'run', '--server', 'ag_abc1234de'], 19 | }); 20 | }); 21 | 22 | it('should generate transport with --config flag when target does not start with ag_', () => { 23 | const server: Server = { 24 | id: 'test-server-id', 25 | name: 'Test Server', 26 | target: '/path/to/config.json', 27 | }; 28 | 29 | const transport = generateTransport(server); 30 | 31 | expect(transport).toStrictEqual({ 32 | command: 'npx', 33 | args: ['-y', 'openmcp@1', 'run', '--config', '/path/to/config.json'], 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/find-existing-server.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | import type { Server } from '../../types.ts'; 4 | import type { InstalledServer } from './types.ts'; 5 | 6 | function _findExistingServer(configFilepath: string, installedServer: InstalledServer, server: Server): boolean { 7 | if (installedServer.args.length <= 3) { 8 | return false; 9 | } 10 | 11 | let isOpenmcpCli = false; 12 | let currentFlag: string = ''; 13 | for (let i = 0; i < installedServer.args.length; i++) { 14 | const arg = installedServer.args[i]!; 15 | if (!isOpenmcpCli) { 16 | isOpenmcpCli ||= arg.startsWith('openmcp'); 17 | continue; 18 | } 19 | 20 | if (arg.startsWith('-')) { 21 | currentFlag = arg; 22 | continue; 23 | } 24 | 25 | switch (currentFlag) { 26 | case '--server': 27 | return arg === server.target; 28 | case '--config': 29 | return ( 30 | (arg.startsWith('$') && arg === server.target) || 31 | (path.isAbsolute(arg) ? arg : path.join(configFilepath, '..', arg)) === server.target 32 | ); 33 | } 34 | } 35 | 36 | return false; 37 | } 38 | 39 | export default function findExistingServer( 40 | configFilepath: string, 41 | installedServers: readonly InstalledServer[], 42 | server: Server, 43 | ): number { 44 | return installedServers.findIndex(installedServer => _findExistingServer(configFilepath, installedServer, server)); 45 | } 46 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/find-matching-install-method.ts: -------------------------------------------------------------------------------- 1 | import type { InstallLocation, InstallMethod } from '../../types.ts'; 2 | 3 | export default function findMatchingInstallMethod( 4 | methods: I[], 5 | location: InstallLocation, 6 | ): I | null { 7 | let globalMethod: I | null = null; 8 | for (const method of methods) { 9 | if (method.location === location) { 10 | return method; 11 | } 12 | 13 | if (method.location === 'local' && location === 'prefer-local') { 14 | return method; 15 | } 16 | 17 | if (method.location === 'global') { 18 | globalMethod = method; 19 | } 20 | } 21 | 22 | if (globalMethod !== null && location === 'prefer-local') { 23 | return globalMethod; 24 | } 25 | 26 | return null; 27 | } 28 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/generate-definition-workspace-path.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | export default function generateDefinitionWorkspacePath(variable: string, configFilepath: string, target: string) { 4 | if (URL.canParse(target)) { 5 | return target; 6 | } 7 | 8 | return `${variable}/${path.relative(path.dirname(configFilepath), target)}`; 9 | } 10 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/generate-server-name.ts: -------------------------------------------------------------------------------- 1 | import slugify from '@sindresorhus/slugify'; 2 | 3 | import type { Server } from '../../types.ts'; 4 | import type { InstalledServer } from './types.ts'; 5 | 6 | export default function generateServerName(installedServers: readonly InstalledServer[], server: Server): string { 7 | const names = installedServers.map(s => s.name); 8 | const base = slugify(server.name); 9 | let i = 1; 10 | let name = base; 11 | while (names.includes(name)) { 12 | name = `${base}-${++i}`; 13 | } 14 | 15 | return name; 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/generate-transport.ts: -------------------------------------------------------------------------------- 1 | import { inferTargetType } from '#libs/mcp-utils'; 2 | 3 | import type { Server } from '../../types.ts'; 4 | 5 | export default function generateTransport(server: Server) { 6 | const args = ['-y', 'openmcp@1', 'run']; 7 | // @todo: remove --config / --server flags once run is updated 8 | switch (inferTargetType(server.target)) { 9 | case 'agent-id': 10 | args.push('--server', server.target); 11 | break; 12 | case 'openapi': 13 | args.push('--config', server.target); 14 | break; 15 | } 16 | 17 | return { 18 | command: 'npx', 19 | args, 20 | } as const; 21 | } 22 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/guards.ts: -------------------------------------------------------------------------------- 1 | import { ServerConflict } from '../../errors/index.ts'; 2 | import type { Server } from '../../types.ts'; 3 | import findExistingServer from './find-existing-server.ts'; 4 | import type { InstalledServer } from './types.ts'; 5 | 6 | export function assertNoExistingServer( 7 | configFilepath: string, 8 | installedServers: readonly InstalledServer[], 9 | server: Server, 10 | ) { 11 | if (findExistingServer(configFilepath, installedServers, server) >= 0) { 12 | throw new ServerConflict(server); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/parse-generic-server.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const SERVER_SCHEMA = z 4 | .object({ 5 | command: z.literal('npx'), 6 | args: z.array(z.string()), 7 | }) 8 | .passthrough(); 9 | 10 | export default function parseGenericServer(maybeServer: unknown): z.infer | null { 11 | const result = SERVER_SCHEMA.safeParse(maybeServer); 12 | if (!result.success) { 13 | return null; 14 | } 15 | 16 | return result.data; 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type InstalledServer = { 2 | readonly name: string; 3 | readonly command: 'npx' | 'node'; 4 | readonly args: readonly string[]; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/integrations/utils/uwrap-matching-install-method.ts: -------------------------------------------------------------------------------- 1 | import InstallLocationUnavailable from '../../errors/install-method-unavailable.ts'; 2 | import type { InstallLocation, InstallMethod, Server } from '../../types.ts'; 3 | import findMatchingInstallMethod from './find-matching-install-method.ts'; 4 | 5 | export default function unwrapMatchingInstallMethod( 6 | methods: I[], 7 | server: Server, 8 | location: InstallLocation, 9 | ): I { 10 | const method = findMatchingInstallMethod(methods, location); 11 | if (method === null) { 12 | throw new InstallLocationUnavailable(server, location === 'prefer-local' ? 'local' : location); 13 | } 14 | 15 | return method; 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/types.ts: -------------------------------------------------------------------------------- 1 | import type * as fs from 'node:fs/promises'; 2 | 3 | import type { z } from 'zod'; 4 | 5 | import type { ResolvableConfigPath } from './config/index.ts'; 6 | 7 | export type Constants = { 8 | readonly HOMEDIR: string; 9 | readonly CONFIGDIR: string; 10 | }; 11 | 12 | export type Context = { 13 | readonly platform: 'darwin' | 'linux' | 'unix' | 'win32'; 14 | readonly constants: Constants & { readonly CWD: string }; 15 | readonly logger: Logger; 16 | readonly fs: { 17 | readonly readFile: typeof fs.readFile; 18 | readonly writeFile: typeof fs.writeFile; 19 | readonly mkdir: typeof fs.mkdir; 20 | }; 21 | }; 22 | 23 | export type Logger = { 24 | start(message: string): void; 25 | success(message: string): void; 26 | verbose(message: string): void; 27 | info(message: string): void; 28 | warn(message: string): void; 29 | error(error: Error & { cause?: unknown }): void; 30 | }; 31 | 32 | export type FsInstallMethod = { 33 | readonly type: 'fs'; 34 | readonly filepath: ResolvableConfigPath; 35 | readonly schema: z.Schema; 36 | readonly location: InstallMethodLocation; 37 | }; 38 | 39 | export type InstallMethodLocation = 'local' | 'global'; 40 | 41 | export type InstallMethod = FsInstallMethod; 42 | 43 | export type Remix = { 44 | readonly id?: string; 45 | readonly name: string; 46 | readonly target: string; 47 | }; 48 | 49 | export type InstallLocation = 'local' | 'global' | 'prefer-local'; 50 | 51 | export type Server = Remix; 52 | 53 | export type McpHostClient = { 54 | readonly name: string; 55 | readonly installMethods: M; 56 | install(ctx: Context, server: Server, location: InstallLocation): Promise; 57 | uninstall(ctx: Context, server: Server, location: InstallLocation): Promise; 58 | }; 59 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-clients/uninstall.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | 3 | import { constants as osConstants, getPlatform as platform } from '#libs/platform'; 4 | 5 | import resolveConfigPath from './config/resolve-path.ts'; 6 | import { ServerNotInstalled } from './errors/index.ts'; 7 | import { type IntegrationName, integrations } from './integrations/index.ts'; 8 | import type { InstallLocation, Logger, Server } from './types.ts'; 9 | 10 | export default async function uninstall( 11 | { 12 | cwd, 13 | logger, 14 | }: { 15 | readonly cwd: string; 16 | readonly logger: Logger; 17 | }, 18 | integrationName: IntegrationName, 19 | server: Server, 20 | location: InstallLocation, 21 | ): Promise { 22 | const integration = integrations[integrationName]; 23 | const serverName = JSON.stringify(server.name); 24 | logger.start(`Uninstalling ${serverName}`); 25 | try { 26 | const constants = { 27 | ...osConstants, 28 | CWD: cwd, 29 | }; 30 | const { filepath } = await integration.uninstall( 31 | { 32 | platform: platform(), 33 | constants, 34 | fs, 35 | logger, 36 | }, 37 | server, 38 | location, 39 | ); 40 | 41 | const resolvedConfigPath = resolveConfigPath(constants, filepath); 42 | logger.success(`${serverName} was successfully uninstalled from ${JSON.stringify(resolvedConfigPath)}`); 43 | } catch (error) { 44 | if (error instanceof ServerNotInstalled) { 45 | logger.info( 46 | `${serverName} is not installed. If your server is installed globally, you need to change scope to global.`, 47 | ); 48 | return; 49 | } 50 | 51 | logger.error(new Error(`Failed to uninstall ${serverName}`, { cause: error })); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as inferTargetType } from './infer-target-type.ts'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/libs/mcp-utils/infer-target-type.ts: -------------------------------------------------------------------------------- 1 | export default function inferTargetType(target: string) { 2 | switch (true) { 3 | case target.startsWith('ag_'): 4 | return 'agent-id' as const; 5 | case true: 6 | default: 7 | // for now everything else is OpenAPI 8 | return 'openapi' as const; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/cli/src/libs/observable/array.ts: -------------------------------------------------------------------------------- 1 | import type { Subscriber } from './types.ts'; 2 | 3 | export function createShallowObservableArray(initialValues: T[]) { 4 | const array: T[] = [...initialValues]; 5 | const subscribers = new Set>(); 6 | 7 | return { 8 | get() { 9 | return array.slice(); 10 | }, 11 | get length() { 12 | return array.length; 13 | }, 14 | push(...items: T[]) { 15 | array.push(...items); 16 | for (const subscriber of subscribers) { 17 | subscriber(array.slice()); 18 | } 19 | 20 | return array.length; 21 | }, 22 | subscribe(subscriber: Subscriber) { 23 | subscribers.add(subscriber); 24 | return () => { 25 | subscribers.delete(subscriber); 26 | }; 27 | }, 28 | [Symbol.dispose]() { 29 | subscribers.clear(); 30 | }, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/cli/src/libs/observable/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from 'react'; 2 | 3 | import type { Observable } from '../types.ts'; 4 | 5 | /** 6 | * A React hook that subscribes to an observable and returns its current value 7 | */ 8 | export function useObservable(observable: Observable): T { 9 | const [value, setValue] = useState(observable.get()); 10 | 11 | useLayoutEffect(() => { 12 | return observable.subscribe(newValue => { 13 | setValue(newValue); 14 | }); 15 | }, [observable]); 16 | 17 | return value; 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/libs/observable/index.ts: -------------------------------------------------------------------------------- 1 | export { createShallowObservableArray } from './array.ts'; 2 | export { createObservableRef } from './ref.ts'; 3 | -------------------------------------------------------------------------------- /packages/cli/src/libs/observable/ref.ts: -------------------------------------------------------------------------------- 1 | import type { Subscriber } from './types.ts'; 2 | 3 | /** 4 | * Creates an observable box that notifies subscribers when its value changes 5 | */ 6 | export function createObservableRef(initialValue: T) { 7 | let currentValue = initialValue; 8 | const subscribers = new Set>(); 9 | 10 | return { 11 | get() { 12 | return currentValue; 13 | }, 14 | set(newValue: T) { 15 | currentValue = newValue; 16 | for (const subscriber of subscribers) { 17 | subscriber(newValue); 18 | } 19 | }, 20 | subscribe(subscriber: Subscriber) { 21 | subscribers.add(subscriber); 22 | return () => { 23 | subscribers.delete(subscriber); 24 | }; 25 | }, 26 | [Symbol.dispose]() { 27 | subscribers.clear(); 28 | }, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/libs/observable/types.ts: -------------------------------------------------------------------------------- 1 | export type Subscriber = (value: T) => void; 2 | 3 | export type Observable = { 4 | get(): T; 5 | subscribe(subscriber: Subscriber): () => void; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/cli.ts: -------------------------------------------------------------------------------- 1 | export { default as negotiateSecurityStrategy } from './cli/security.ts'; 2 | export { default as negotiateServerUrl } from './cli/server-url.ts'; 3 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/cli/server-url.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | import * as prompt from '#libs/console/prompts'; 4 | 5 | import { resolveServers } from '../server/index.ts'; 6 | 7 | export default async function negotiateServerUrl(service: IHttpService): Promise { 8 | const resolvedServers = resolveServers(service); 9 | 10 | if (resolvedServers.length === 0) { 11 | return prompt.url('No servers found. Please provide a server URL:'); 12 | } 13 | 14 | const validServers = resolvedServers.filter(server => server.valid); 15 | 16 | if (validServers.length === 0) { 17 | return prompt.url( 18 | `We found ${service.servers?.length ?? 0} servers, but could not construct a valid URL. Please provide a server URL:`, 19 | ); 20 | } 21 | 22 | if (validServers.length === 1) { 23 | return validServers[0]!.value; 24 | } 25 | 26 | const options: { label: string; hint: string; value: string }[] = []; 27 | let initial: string | undefined; 28 | 29 | for (const [i, server] of validServers.entries()) { 30 | options.push({ 31 | label: server.name ?? `Server #${i + 1}`, 32 | hint: server.value, 33 | value: server.value, 34 | }); 35 | 36 | if (!initial && server.name?.toLowerCase().includes('production')) { 37 | initial = server.value; 38 | } 39 | } 40 | 41 | return prompt.select({ 42 | message: 'Please select a server:', 43 | initialValue: initial, 44 | options, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/http-spec.ts: -------------------------------------------------------------------------------- 1 | import { OPERATION_CONFIG } from '@stoplight/http-spec/oas'; 2 | import { transformOas2Operation, transformOas2Service } from '@stoplight/http-spec/oas2'; 3 | import { transformOas3Operation, transformOas3Service } from '@stoplight/http-spec/oas3'; 4 | import { isPlainObject } from '@stoplight/json'; 5 | import type { IHttpOperation } from '@stoplight/types'; 6 | 7 | function isOas3(document: Record) { 8 | return 'openapi' in document; 9 | } 10 | 11 | export function transformOasService(document: Record) { 12 | if (isOas3(document)) { 13 | return transformOas3Service({ document }); 14 | } 15 | 16 | return transformOas2Service({ document }); 17 | } 18 | 19 | export function* transformOasOperations(document: Record): Iterable { 20 | const paths = document['paths']; 21 | if (!isPlainObject(paths)) return; 22 | 23 | const verbs = new Set(['get', 'post', 'put', 'delete', 'options', 'head', 'patch']); 24 | if (isOas3(document)) { 25 | verbs.add('trace'); 26 | } 27 | 28 | const transformOperation = isOas3(document) ? transformOas3Operation : transformOas2Operation; 29 | for (const [path, pathItem] of Object.entries(paths)) { 30 | if (!isPlainObject(pathItem)) continue; 31 | for (const prop of Object.keys(pathItem)) { 32 | if (!verbs.has(prop)) continue; 33 | yield { 34 | path, 35 | ...transformOperation({ 36 | document, 37 | config: OPERATION_CONFIG, 38 | name: path, 39 | method: prop, 40 | }), 41 | }; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/index.ts: -------------------------------------------------------------------------------- 1 | export { negotiateSecurityStrategy, negotiateServerUrl } from './cli.ts'; 2 | export { transformOasService as parseAsService } from './http-spec.ts'; 3 | export { default as listTools } from './list-tools.ts'; 4 | export { default as loadDocumentAsService } from './load-document-as-service.ts'; 5 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/list-tools.ts: -------------------------------------------------------------------------------- 1 | import { getToolName } from '@openmcp/openapi'; 2 | 3 | import { transformOasOperations } from './http-spec.ts'; 4 | 5 | type ListedTool = { 6 | readonly name: string; 7 | readonly route: string; 8 | }; 9 | 10 | export default function listTools(document: Record): ListedTool[] { 11 | const list: ListedTool[] = []; 12 | 13 | for (const operation of transformOasOperations(document)) { 14 | const value = getToolName(operation); 15 | list.push({ 16 | name: value, 17 | route: `${operation.method.toUpperCase()} ${operation.path}`, 18 | }); 19 | } 20 | 21 | return list; 22 | } 23 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/load-document-as-service.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | 3 | import { loadDocument } from '@openmcp/utils/documents'; 4 | 5 | import { transformOasService } from './http-spec.ts'; 6 | 7 | export type { IHttpService } from '@stoplight/types'; 8 | 9 | export default async function loadDocumentAsService(location: string) { 10 | const document = await loadDocument( 11 | { 12 | fetch, 13 | fs, 14 | }, 15 | location, 16 | ); 17 | 18 | return transformOasService(document); 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/api-key-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with API Key security schemes 4 | export const apiKeySecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'api-key-header', 9 | key: 'apiKey', 10 | type: 'apiKey', 11 | name: 'X-API-KEY', 12 | in: 'header', 13 | }, 14 | ], 15 | ], 16 | securitySchemes: [ 17 | { 18 | id: 'api-key-header', 19 | key: 'apiKey', 20 | type: 'apiKey', 21 | name: 'X-API-KEY', 22 | in: 'header', 23 | }, 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/empty-security-array-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with empty security array 4 | export const emptySecurityArrayFragment: Pick = { 5 | security: [], 6 | securitySchemes: undefined, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/empty-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with no security 4 | export const emptySecurityFragment: Pick = { 5 | security: undefined, 6 | securitySchemes: undefined, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/http-basic-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with HTTP Basic security scheme 4 | export const httpBasicSecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'basic-auth', 9 | key: 'basicAuth', 10 | type: 'http', 11 | scheme: 'basic', 12 | }, 13 | ], 14 | ], 15 | securitySchemes: [ 16 | { 17 | id: 'basic-auth', 18 | key: 'basicAuth', 19 | type: 'http', 20 | scheme: 'basic', 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/http-bearer-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with HTTP Bearer security scheme 4 | export const httpBearerSecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'bearer-auth', 9 | key: 'bearerAuth', 10 | type: 'http', 11 | scheme: 'bearer', 12 | }, 13 | ], 14 | ], 15 | securitySchemes: [ 16 | { 17 | id: 'bearer-auth', 18 | key: 'bearerAuth', 19 | type: 'http', 20 | scheme: 'bearer', 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/http-bearer-with-format-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with HTTP Bearer security scheme with format 4 | export const httpBearerWithFormatSecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'bearer-auth-jwt', 9 | key: 'bearerAuthJwt', 10 | type: 'http', 11 | scheme: 'bearer', 12 | bearerFormat: 'JWT', 13 | }, 14 | ], 15 | ], 16 | securitySchemes: [ 17 | { 18 | id: 'bearer-auth-jwt', 19 | key: 'bearerAuthJwt', 20 | type: 'http', 21 | scheme: 'bearer', 22 | bearerFormat: 'JWT', 23 | }, 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/http-unsupported-scheme-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with HTTP security scheme with unsupported scheme 4 | export const httpUnsupportedSchemeFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'digest-auth', 9 | key: 'digestAuth', 10 | type: 'http', 11 | scheme: 'digest', 12 | }, 13 | ], 14 | ], 15 | securitySchemes: [ 16 | { 17 | id: 'digest-auth', 18 | key: 'digestAuth', 19 | type: 'http', 20 | scheme: 'digest', 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/multiple-and-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with multiple security schemes (AND relationship) 4 | export const multipleAndSecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'api-key-header', 9 | key: 'apiKey', 10 | type: 'apiKey', 11 | name: 'X-API-KEY', 12 | in: 'header', 13 | }, 14 | { 15 | id: 'bearer-auth', 16 | key: 'bearerAuth', 17 | type: 'http', 18 | scheme: 'bearer', 19 | }, 20 | ], 21 | ], 22 | securitySchemes: [ 23 | { 24 | id: 'api-key-header', 25 | key: 'apiKey', 26 | type: 'apiKey', 27 | name: 'X-API-KEY', 28 | in: 'header', 29 | }, 30 | { 31 | id: 'bearer-auth', 32 | key: 'bearerAuth', 33 | type: 'http', 34 | scheme: 'bearer', 35 | }, 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/multiple-or-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with multiple security schemes (OR relationship) 4 | export const multipleOrSecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'api-key-header', 9 | key: 'apiKeyHeader', 10 | type: 'apiKey', 11 | name: 'X-API-KEY', 12 | in: 'header', 13 | }, 14 | ], 15 | [ 16 | { 17 | id: 'bearer-auth', 18 | key: 'bearerAuth', 19 | type: 'http', 20 | scheme: 'bearer', 21 | }, 22 | ], 23 | ], 24 | securitySchemes: [ 25 | { 26 | id: 'api-key-header', 27 | key: 'apiKeyHeader', 28 | type: 'apiKey', 29 | name: 'X-API-KEY', 30 | in: 'header', 31 | }, 32 | { 33 | id: 'bearer-auth', 34 | key: 'bearerAuth', 35 | type: 'http', 36 | scheme: 'bearer', 37 | }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/mutual-tls-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with Mutual TLS security scheme 4 | export const mutualTlsSecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'mtls-auth', 9 | key: 'mtlsAuth', 10 | type: 'mutualTLS', 11 | }, 12 | ], 13 | ], 14 | securitySchemes: [ 15 | { 16 | id: 'mtls-auth', 17 | key: 'mtlsAuth', 18 | type: 'mutualTLS', 19 | }, 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/no-global-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with security schemes but no global security 4 | export const noGlobalSecurityFragment: Pick = { 5 | security: undefined, 6 | securitySchemes: [ 7 | { 8 | id: 'api-key-header', 9 | key: 'apiKeyHeader', 10 | type: 'apiKey', 11 | name: 'X-API-KEY', 12 | in: 'header', 13 | }, 14 | { 15 | id: 'api-key-query', 16 | key: 'apiKeyQuery', 17 | type: 'apiKey', 18 | name: 'api_key', 19 | in: 'query', 20 | }, 21 | { 22 | id: 'api-key-cookie', 23 | key: 'apiKeyCookie', 24 | type: 'apiKey', 25 | name: 'api_key', 26 | in: 'cookie', 27 | }, 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/oauth2-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with OAuth2 security scheme 4 | export const oauth2SecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'oauth2-auth', 9 | key: 'petstoreAuth', 10 | type: 'oauth2', 11 | flows: { 12 | implicit: { 13 | authorizationUrl: 'https://example.com/oauth/authorize', 14 | scopes: { 15 | 'read:api': 'Read access to the API', 16 | 'write:api': 'Write access to the API', 17 | }, 18 | }, 19 | }, 20 | }, 21 | ], 22 | ], 23 | securitySchemes: [ 24 | { 25 | id: 'oauth2-auth', 26 | key: 'petstoreAuth', 27 | type: 'oauth2', 28 | flows: { 29 | implicit: { 30 | authorizationUrl: 'https://example.com/oauth/authorize', 31 | scopes: { 32 | 'read:api': 'Read access to the API', 33 | 'write:api': 'Write access to the API', 34 | }, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/__tests__/fixtures/openid-connect-security-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with OpenID Connect security scheme 4 | export const openIdConnectSecurityFragment: Pick = { 5 | security: [ 6 | [ 7 | { 8 | id: 'openid-auth', 9 | key: 'openId', 10 | type: 'openIdConnect', 11 | openIdConnectUrl: 'https://example.com/.well-known/openid-configuration', 12 | }, 13 | ], 14 | ], 15 | securitySchemes: [ 16 | { 17 | id: 'openid-auth', 18 | key: 'openId', 19 | type: 'openIdConnect', 20 | openIdConnectUrl: 'https://example.com/.well-known/openid-configuration', 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/security/index.ts: -------------------------------------------------------------------------------- 1 | export { type ResolvedSecurityScheme, default as resolveSecuritySchemes } from './resolve-security-schemes.ts'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/empty-servers-array-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with empty servers array 4 | export const emptyServersArrayFragment: Pick = { 5 | servers: [], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/empty-servers-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with no servers 4 | export const emptyServersFragment: Pick = { 5 | servers: undefined, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/invalid-templated-server-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with a templated server that resolves to an invalid URL 4 | export const invalidTemplatedServerFragment: Pick = { 5 | servers: [ 6 | { 7 | id: 'invalid-templated-path', 8 | url: 'https://api.example.com/{path}', 9 | description: 'API with invalid path', 10 | variables: { 11 | path: { 12 | default: 'not a valid path with spaces', 13 | }, 14 | }, 15 | }, 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/invalid-url-server-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with a server that has an invalid URL 4 | export const invalidUrlServerFragment: Pick = { 5 | servers: [{ id: 'invalid-url-server', url: 'not a valid url', description: 'Invalid URL' }], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/mixed-servers-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with a mix of templated and non-templated servers 4 | export const mixedServersFragment: Pick = { 5 | servers: [ 6 | { id: 'static-api', url: 'https://static-api.example.com', description: 'Static API' }, 7 | { 8 | id: 'mixed-versioned-api', 9 | url: 'https://api.example.com/{version}', 10 | name: 'Versioned API', 11 | variables: { 12 | version: { 13 | default: 'v1', 14 | }, 15 | }, 16 | }, 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/multiple-variables-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with multiple templated variables 4 | export const multipleVariablesFragment: Pick = { 5 | servers: [ 6 | { 7 | id: 'multi-variable-api', 8 | url: 'https://{environment}.api.example.com/{version}', 9 | description: 'Multi-variable API', 10 | variables: { 11 | environment: { 12 | default: 'dev', 13 | enum: ['dev', 'staging', 'prod'], 14 | }, 15 | version: { 16 | default: 'v1', 17 | enum: ['v1', 'v2'], 18 | }, 19 | }, 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/non-templated-servers-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with non-templated servers 4 | export const nonTemplatedServersFragment: Pick = { 5 | servers: [ 6 | { id: 'prod-api', url: 'https://api.example.com/v1', description: 'Production API' }, 7 | { id: 'dev-api', url: 'https://dev-api.example.com/v1', name: 'Development API' }, 8 | { id: 'invalid-url', url: 'invalid-url', description: 'Invalid URL' }, 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/server-with-name-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with a server that has name but no description 4 | export const serverWithNameFragment: Pick = { 5 | servers: [{ id: 'name-only-api', url: 'https://api.example.com/v1', name: 'Production API' }], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/templated-server-with-default-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with a templated server with only default value 4 | export const templatedServerWithDefaultFragment: Pick = { 5 | servers: [ 6 | { 7 | id: 'versioned-api-default', 8 | url: 'https://api.example.com/{version}', 9 | name: 'Versioned API', 10 | variables: { 11 | version: { 12 | default: 'v1', 13 | }, 14 | }, 15 | }, 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/__tests__/fixtures/templated-server-with-enum-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | // fragment with a templated server with enum values 4 | export const templatedServerWithEnumFragment: Pick = { 5 | servers: [ 6 | { 7 | id: 'versioned-api-enum', 8 | url: 'https://api.example.com/{version}', 9 | description: 'API with version', 10 | variables: { 11 | version: { 12 | default: 'v1', 13 | enum: ['v1', 'v2'], 14 | }, 15 | }, 16 | }, 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/index.ts: -------------------------------------------------------------------------------- 1 | export { type ResolvedServer, default as resolveServers } from './resolve-servers.ts'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/resolve-servers.ts: -------------------------------------------------------------------------------- 1 | import type { IHttpService } from '@stoplight/types'; 2 | 3 | import resolveTemplatedServer, { isTemplatedServer } from './resolve-templated-server.ts'; 4 | 5 | export type ResolvedServer = { 6 | name: string | undefined; 7 | value: string; 8 | valid: boolean; 9 | }; 10 | 11 | function toResolvedServer(name: string | undefined, url: string): ResolvedServer { 12 | return { 13 | name, 14 | value: url, 15 | valid: URL.canParse(url), 16 | }; 17 | } 18 | 19 | export default function resolveServers(service: Pick): ResolvedServer[] { 20 | return ( 21 | service.servers?.flatMap(server => 22 | isTemplatedServer(server) 23 | ? resolveTemplatedServer(server).map(url => toResolvedServer(server.description ?? server.name, url)) 24 | : toResolvedServer(server.description ?? server.name, server.url), 25 | ) ?? [] 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/cli/src/libs/openapi/server/resolve-templated-server.ts: -------------------------------------------------------------------------------- 1 | import type { INodeVariable, IServer } from '@stoplight/types'; 2 | 3 | import applyUrlVariables from './apply-url-variables.ts'; 4 | 5 | export default function resolveTemplatedServer( 6 | server: IServer & { variables: Record }, 7 | ): string[] { 8 | const variablePairs: [name: string, values: string[]][] = []; 9 | 10 | for (const [name, value] of Object.entries(server.variables)) { 11 | // technically default is required by spec, but we cannot be sure whether spec is valid or not 12 | if (value.default !== undefined) { 13 | variablePairs.push([name, [value.default]]); 14 | } else if (value.enum !== undefined) { 15 | variablePairs.push([name, value.enum.map(enumValue => String(enumValue))]); 16 | } else { 17 | variablePairs.push([name, []]); 18 | } 19 | } 20 | 21 | return applyUrlVariables(server.url, variablePairs); 22 | } 23 | 24 | export function isTemplatedServer(server: IServer): server is IServer & { variables: Record } { 25 | return Object.hasOwn(server, 'variables'); 26 | } 27 | -------------------------------------------------------------------------------- /packages/cli/src/libs/platform/constants.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'node:os'; 2 | import path from 'node:path'; 3 | import process from 'node:process'; 4 | 5 | import platform from './get-platform.ts'; 6 | 7 | export const HOMEDIR = os.homedir(); 8 | 9 | let configdir: string; 10 | 11 | switch (platform()) { 12 | case 'win32': 13 | // Use %APPDATA% on Windows 14 | configdir = process.env['APPDATA'] || path.join(HOMEDIR, 'AppData', 'Roaming'); 15 | break; 16 | case 'darwin': 17 | // Use ~/Library/Application Support on macOS 18 | configdir = path.join(HOMEDIR, 'Library', 'Application Support'); 19 | break; 20 | case 'linux': 21 | case 'unix': 22 | // Default to ~/.config on Linux/Unix 23 | configdir = process.env['XDG_CONFIG_HOME'] || path.join(HOMEDIR, '.config'); 24 | } 25 | 26 | export { configdir as CONFIGDIR }; 27 | -------------------------------------------------------------------------------- /packages/cli/src/libs/platform/get-platform.ts: -------------------------------------------------------------------------------- 1 | import { platform } from 'node:os'; 2 | 3 | export default () => { 4 | const p = platform(); 5 | if (p === 'win32') return 'win32'; 6 | else if (p === 'darwin') return 'darwin'; 7 | else if (p === 'linux') return 'linux'; 8 | else return 'unix'; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/cli/src/libs/platform/index.ts: -------------------------------------------------------------------------------- 1 | export * as constants from './constants.ts'; 2 | export { default as getPlatform } from './get-platform.ts'; 3 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/__tests__/fixtures/config-with-absolute-path.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": {}, 3 | "servers": { 4 | "test_server": { 5 | "type": "openapi", 6 | "openapi": "/placeholder/absolute/path/to/openapi.json", 7 | "serverUrl": "https://example.com", 8 | "tools": ["tool1"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/__tests__/fixtures/config-with-env.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": { 3 | "test_server": { "ENV_VAR": "" } 4 | }, 5 | "servers": { 6 | "test_server": { 7 | "type": "sse", 8 | "url": "https://example.com", 9 | "tools": ["tool1"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/__tests__/fixtures/config-with-file-url.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": {}, 3 | "servers": { 4 | "test_server": { 5 | "type": "openapi", 6 | "openapi": "file:///placeholder/openapi.json", 7 | "serverUrl": "https://example.com", 8 | "tools": ["tool1"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/__tests__/fixtures/config-with-undefined-env.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": { 3 | "test_server": { "UNDEFINED_ENV_VAR": "" } 4 | }, 5 | "servers": { 6 | "test_server": { 7 | "type": "sse", 8 | "url": "https://example.com", 9 | "tools": ["tool1"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/__tests__/fixtures/config-with-url.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": {}, 3 | "servers": { 4 | "test_server": { 5 | "type": "openapi", 6 | "openapi": "https://example.com/openapi.json", 7 | "serverUrl": "https://example.com", 8 | "tools": ["tool1"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/__tests__/fixtures/remote-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": { 3 | "test_server": { "key": "value" } 4 | }, 5 | "servers": { 6 | "test_server": { 7 | "type": "sse", 8 | "url": "https://example.com", 9 | "tools": ["tool1"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/__tests__/fixtures/valid-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": { 3 | "test_server": { "key": "value" } 4 | }, 5 | "servers": { 6 | "test_server": { 7 | "type": "sse", 8 | "url": "https://example.com", 9 | "tools": ["tool1"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/index.ts: -------------------------------------------------------------------------------- 1 | export { default as load } from './load.ts'; 2 | export { default as parse } from './parse.ts'; 3 | export type { 4 | Config, 5 | OpenAPIServer, 6 | RemixServer, 7 | SSEServer, 8 | StdIOServer, 9 | StreamableHTTPServer, 10 | Tool, 11 | } from './schemas.ts'; 12 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/config/parse.ts: -------------------------------------------------------------------------------- 1 | import { type Config, ConfigSchema } from './schemas.ts'; 2 | 3 | export default function parseConfig(config: unknown): Config { 4 | const res = ConfigSchema.safeParse(config); 5 | if (!res.success) { 6 | throw new Error( 7 | `Invalid config: ${res.error.issues.map(issue => `${issue.path.join('.')} - ${issue.message}`).join(', ')}`, 8 | ); 9 | } 10 | 11 | return res.data; 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/errors.ts: -------------------------------------------------------------------------------- 1 | export class ServerRegistrationError extends Error { 2 | override readonly name = 'ServerRegistrationError'; 3 | 4 | constructor(name: string, reason: unknown) { 5 | super(`Failed to register server: ${name}`, { 6 | cause: reason, 7 | }); 8 | } 9 | } 10 | 11 | export class ClientServerRegistrationError extends Error { 12 | override readonly name = 'ClientServerRegistrationError'; 13 | 14 | constructor(name: string, reason: unknown) { 15 | super(`Failed to register client server: ${name}`, { 16 | cause: reason, 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Config, 3 | OpenAPIServer, 4 | RemixServer, 5 | SSEServer, 6 | StdIOServer, 7 | StreamableHTTPServer, 8 | } from './config/index.ts'; 9 | export { load as loadConfig, parse as parseConfig } from './config/index.ts'; 10 | export { default as createRemixServer } from './server.ts'; 11 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/manager/client-servers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as registerClientServers } from './register-client-servers.ts'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/manager/client-servers/scoped-client-server.ts: -------------------------------------------------------------------------------- 1 | import { ClientServer, type ClientServerOptions, type ClientServerStorageData, type Tool } from '@openmcp/manager'; 2 | 3 | import { resolveToolName } from '../../utils/tools.ts'; 4 | 5 | export default class ScopedClientServer extends ClientServer { 6 | readonly #allowedTools: Set; 7 | 8 | constructor(data: ClientServerStorageData, options: ClientServerOptions, allowedTools: Set) { 9 | super(data, options); 10 | this.#allowedTools = allowedTools; 11 | } 12 | 13 | override async listTools(): Promise { 14 | const tools = await super.listTools({ lazyConnect: true }); 15 | const exposedToolNames = new Set(); 16 | const exposedTools: Tool[] = []; 17 | for (const tool of tools) { 18 | if (this.#allowedTools.size !== 0 && !this.#allowedTools.has(tool.name)) continue; 19 | 20 | const resolvedToolName = resolveToolName(this.serverId, tool.name); 21 | if (exposedToolNames.has(tool.name)) { 22 | // todo: we could try to handle it by renaming the tool 23 | throw new Error(`Tool name collision: ${tool.name} and ${resolvedToolName}`); 24 | } 25 | 26 | exposedToolNames.add(resolvedToolName); 27 | exposedTools.push({ 28 | ...tool, 29 | name: resolvedToolName, 30 | }); 31 | } 32 | 33 | return exposedTools; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/manager/servers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as registerServers } from './register-servers.ts'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/manager/servers/interpolate-openapi-client-config.ts: -------------------------------------------------------------------------------- 1 | import { type ClientConfig } from '@openmcp/openapi'; 2 | 3 | import strictReplaceVariables from '../../utils/strict-replace-variables.ts'; 4 | 5 | export default function interpolateOpenAPIClientConfig( 6 | config: Record, 7 | userConfig: unknown, 8 | ): ClientConfig { 9 | if (!config) { 10 | return {}; 11 | } 12 | 13 | const interpolatedConfig: ClientConfig = {}; 14 | for (const [key, value] of Object.entries(config)) { 15 | if (typeof value === 'object' && value !== null) { 16 | interpolatedConfig[key] = Object.entries(value).reduce( 17 | (acc, [k, v]) => { 18 | acc[k] = typeof v === 'string' ? strictReplaceVariables(v, userConfig) : v; 19 | return acc; 20 | }, 21 | {} as Record, 22 | ); 23 | } 24 | } 25 | 26 | return interpolatedConfig; 27 | } 28 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/manager/servers/to-transport-config.ts: -------------------------------------------------------------------------------- 1 | import type { TransportConfig } from '@openmcp/manager'; 2 | 3 | import type { RemixServer, SSEServer } from '../../config/index.ts'; 4 | import type { StreamableHTTPServer } from '../../config/schemas.ts'; 5 | import strictReplaceVariables from '../../utils/strict-replace-variables.ts'; 6 | 7 | function getHttpConfig( 8 | { url, headers }: StreamableHTTPServer | SSEServer, 9 | userConfig: unknown, 10 | ): TransportConfig['config'] { 11 | return { 12 | url: strictReplaceVariables(url, userConfig), 13 | requestInit: { 14 | headers: headers 15 | ? Object.entries(headers).reduce( 16 | (acc, [key, value]) => { 17 | acc[key] = strictReplaceVariables(value, userConfig); 18 | return acc; 19 | }, 20 | {} as Record, 21 | ) 22 | : undefined, 23 | }, 24 | }; 25 | } 26 | 27 | export default function toTransportConfig(server: RemixServer, userConfig: unknown): TransportConfig { 28 | switch (server.type) { 29 | case 'stdio': 30 | return { 31 | type: 'stdio', 32 | config: { 33 | command: strictReplaceVariables(server.command, userConfig), 34 | args: server.args.map(arg => strictReplaceVariables(arg, userConfig)), 35 | }, 36 | }; 37 | case 'streamable-http': 38 | return { 39 | type: 'streamableHttp', 40 | config: getHttpConfig(server, userConfig), 41 | }; 42 | case 'sse': 43 | return { 44 | type: 'sse', 45 | config: getHttpConfig(server, userConfig), 46 | }; 47 | case 'openapi': 48 | return { 49 | type: 'inMemory', 50 | config: {}, 51 | }; 52 | default: 53 | throw new Error(`Unsupported transport type: ${server['type']}`); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/utils/__tests__/strict-replace-variables.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import strictReplaceVariables from '../strict-replace-variables.ts'; 4 | 5 | describe('strictReplaceVariables', () => { 6 | it('should replace variables when all are provided', () => { 7 | const input = 'Hello, {{name}}!'; 8 | const values = { name: 'World' }; 9 | const result = strictReplaceVariables(input, values); 10 | expect(result).toBe('Hello, World!'); 11 | }); 12 | 13 | it('should throw an error when a variable is missing', () => { 14 | const input = 'Hello, {{name}}!'; 15 | const values = {}; 16 | expect(() => strictReplaceVariables(input, values)).toThrow('Missing variable: name'); 17 | }); 18 | 19 | it('should handle inputs with no variables', () => { 20 | const input = 'Hello, World!'; 21 | const values = { name: 'Unused' }; 22 | const result = strictReplaceVariables(input, values); 23 | expect(result).toBe('Hello, World!'); 24 | }); 25 | 26 | it('should ignore non-object values for the variables parameter', () => { 27 | const input = 'Hello, {{name}}!'; 28 | const values = null; 29 | expect(() => strictReplaceVariables(input, values)).toThrow('Missing variable: name'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/utils/strict-replace-variables.ts: -------------------------------------------------------------------------------- 1 | import { replaceVariables } from '@openmcp/utils'; 2 | 3 | function isPlainObject(value: unknown): value is Record { 4 | return typeof value === 'object' && value !== null && !Array.isArray(value); 5 | } 6 | 7 | export default function strictReplaceVariables(input: string, values: unknown): string { 8 | const obj = isPlainObject(values) ? values : {}; 9 | return replaceVariables( 10 | input, 11 | new Proxy(obj, { 12 | get(target, p, recv) { 13 | if (typeof p === 'symbol') { 14 | return Reflect.get(target, p, recv); 15 | } 16 | 17 | if (!Reflect.has(target, p)) { 18 | throw new Error(`Missing variable: ${String(p)}`); 19 | } 20 | 21 | return Reflect.get(target, p, recv); 22 | }, 23 | }), 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/cli/src/libs/remix/utils/tools.ts: -------------------------------------------------------------------------------- 1 | import type { ToolName } from '@openmcp/server'; 2 | 3 | const DELIMITER = '_'; 4 | 5 | export function parseToolName(name: ToolName): [serverId: string, toolName: ToolName] { 6 | const delimIndex = name.indexOf(DELIMITER); 7 | if (delimIndex === -1) { 8 | throw new Error(`Invalid tool name: ${name}`); 9 | } 10 | 11 | return [name.slice(0, delimIndex), name.slice(delimIndex + 2)]; 12 | } 13 | 14 | export function resolveToolName(serverId: string, name: ToolName): ToolName { 15 | const toolName = [serverId, name].join(DELIMITER); 16 | return toolName.length > 64 ? toolName.slice(0, 64) : toolName; 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/libs/string-utils/index.ts: -------------------------------------------------------------------------------- 1 | import snakeCase from 'lodash-es/snakeCase.js'; 2 | 3 | export { default as slugify } from '@sindresorhus/slugify'; 4 | 5 | export function interpolable(value: string): string { 6 | return `{{${value}}}`; 7 | } 8 | 9 | export const screamCase = (value: string) => snakeCase(value).toUpperCase(); 10 | export { default as camelCase } from 'lodash-es/camelCase.js'; 11 | -------------------------------------------------------------------------------- /packages/cli/src/register.ts: -------------------------------------------------------------------------------- 1 | import { default as yargs } from 'yargs'; 2 | import { hideBin } from 'yargs/helpers'; 3 | 4 | import console from '#libs/console'; 5 | 6 | import pkgJson from '../package.json' with { type: 'json' }; 7 | import installCommand from './commands/install/index.ts'; 8 | import loginCommand from './commands/login/index.ts'; 9 | import logoutCommand from './commands/logout/index.ts'; 10 | import runCommand from './commands/run/index.ts'; 11 | import uninstallCommand from './commands/uninstall/index.ts'; 12 | import uploadCommand from './commands/upload/index.ts'; 13 | import whoamiCommand from './commands/whoami/index.ts'; 14 | import { HandlerError } from './errors/index.ts'; 15 | 16 | export default async function register(argv: string[]) { 17 | try { 18 | await yargs(hideBin(process.argv)) 19 | .scriptName('openmcp') 20 | .version(pkgJson.version) 21 | .help(true) 22 | .fail((msg, err, yargs) => { 23 | if (err instanceof HandlerError) { 24 | console.error(err.message); 25 | } else { 26 | console.restoreAll(); 27 | if (msg !== null) { 28 | process.stderr.write(String(msg) + '\n'); 29 | } 30 | yargs.showHelp(); 31 | process.exit(1); 32 | } 33 | }) 34 | .wrap(yargs().terminalWidth()) 35 | .strictCommands() 36 | .command(loginCommand) 37 | .command(logoutCommand) 38 | .command(whoamiCommand) 39 | .command(installCommand) 40 | .command(uninstallCommand) 41 | .command(runCommand) 42 | .command(uploadCommand) 43 | .demandCommand(1, '') 44 | .parse(argv); 45 | } catch { 46 | process.exit(1); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/cli/src/rpc/agents.ts: -------------------------------------------------------------------------------- 1 | import { type } from '@orpc/contract'; 2 | import { z } from 'zod'; 3 | 4 | import type { Config as RemixDefinition } from '#libs/remix'; 5 | 6 | import { base } from './base.ts'; 7 | 8 | export type Agent = { 9 | id: string; 10 | name: string; 11 | }; 12 | 13 | const listAgentsContract = base 14 | .input(z.object({ name: z.string().optional() })) 15 | .output(type()) 16 | .errors({ NOT_FOUND: {} }); 17 | 18 | const getAgentContract = base 19 | .input(z.object({ agentId: z.string() })) 20 | .output(type()) 21 | .errors({ NOT_FOUND: {} }); 22 | 23 | const getRemixContract = base 24 | .input(z.object({ agentId: z.string() })) 25 | .output(type()) 26 | .errors({ NOT_FOUND: {} }); 27 | 28 | export const agentsRouterContract = { 29 | agents: base.router({ 30 | listAgents: listAgentsContract, 31 | getAgent: getAgentContract, 32 | getRemix: getRemixContract, 33 | }), 34 | }; 35 | -------------------------------------------------------------------------------- /packages/cli/src/rpc/base.ts: -------------------------------------------------------------------------------- 1 | import { oc, type } from '@orpc/contract'; 2 | 3 | export const base = oc.errors({ 4 | UNAUTHORIZED: {}, 5 | INPUT_VALIDATION_FAILED: { 6 | status: 422, 7 | message: 'Input validation failed', 8 | data: type<{ 9 | formErrors: string[]; 10 | fieldErrors: Record; 11 | }>(), 12 | }, 13 | BAD_REQUEST: {}, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/cli/src/rpc/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ContractRouterClient as BaseContractRouterClient, 3 | InferContractRouterInputs, 4 | InferContractRouterOutputs, 5 | } from '@orpc/contract'; 6 | 7 | import { agentsRouterContract } from './agents.ts'; 8 | import { mcpServersRouterContract } from './mcp-servers.ts'; 9 | 10 | export const routerContract = { 11 | cli: { 12 | ...agentsRouterContract, 13 | ...mcpServersRouterContract, 14 | }, 15 | }; 16 | 17 | export type RouterInputs = InferContractRouterInputs; 18 | 19 | export type RouterOutputs = InferContractRouterOutputs; 20 | 21 | export type ContractRouterClient = BaseContractRouterClient; 22 | -------------------------------------------------------------------------------- /packages/cli/src/rpc/mcp-servers.ts: -------------------------------------------------------------------------------- 1 | import { TransportSchema } from '@openmcp/schemas/mcp'; 2 | import { type } from '@orpc/contract'; 3 | import { z } from 'zod'; 4 | 5 | import { base } from './base.ts'; 6 | import { McpClientConfigSchemaSchema, ToolInputSchemaSchema, ToolOutputSchemaSchema } from './schemas.ts'; 7 | 8 | const uploadContract = base 9 | .input( 10 | z.object({ 11 | name: z.string(), 12 | externalId: z.string().min(2).max(255), 13 | summary: z.string().optional(), 14 | description: z.string().optional(), 15 | instructions: z.string().optional(), 16 | iconUrl: z.string().url().optional(), 17 | developer: z.string().optional(), 18 | developerUrl: z.string().url().optional(), 19 | sourceUrl: z.string().url().optional(), 20 | configSchema: McpClientConfigSchemaSchema.optional(), 21 | transport: TransportSchema, 22 | visibility: z.enum(['public', 'private']).optional(), 23 | tools: z 24 | .array( 25 | z.object({ 26 | name: z.string(), 27 | displayName: z.string().optional(), 28 | summary: z.string().optional(), 29 | description: z.string().optional(), 30 | instructions: z.string().optional(), 31 | inputSchema: ToolInputSchemaSchema.optional(), 32 | outputSchema: ToolOutputSchemaSchema.optional(), 33 | isReadonly: z.boolean().optional(), 34 | isDestructive: z.boolean().optional(), 35 | isIdempotent: z.boolean().optional(), 36 | isOpenWorld: z.boolean().optional(), 37 | }), 38 | ) 39 | .default([]), 40 | }), 41 | ) 42 | .output(type<{ id: string }>()); 43 | 44 | export const mcpServersRouterContract = { 45 | mcpServers: { 46 | upload: uploadContract, 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /packages/cli/src/rpc/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const McpClientConfigSchemaSchema = z.object({ 4 | type: z.literal('object').optional(), 5 | properties: z 6 | .record( 7 | z.object({ 8 | type: z.enum(['string', 'number', 'boolean']), 9 | title: z.string().optional(), 10 | description: z.string().optional(), 11 | default: z.union([z.string(), z.number(), z.boolean()]).optional(), 12 | enum: z.array(z.string()).optional(), 13 | format: z.union([z.literal('secret'), z.string()]).optional(), 14 | example: z.union([z.string(), z.number(), z.boolean()]).optional(), 15 | }), 16 | ) 17 | .optional(), 18 | required: z.array(z.string()).optional(), 19 | }); 20 | 21 | export const ToolInputSchemaSchema = z 22 | .object({ 23 | type: z.literal('object').optional(), 24 | properties: z.optional(z.object({}).passthrough()), 25 | }) 26 | .passthrough(); 27 | 28 | export const ToolOutputSchemaSchema = z 29 | .object({ 30 | type: z.string().optional(), 31 | properties: z.optional(z.object({}).passthrough()), 32 | }) 33 | .passthrough(); 34 | -------------------------------------------------------------------------------- /packages/cli/src/ui/app.tsx: -------------------------------------------------------------------------------- 1 | import { Box, useInput } from 'ink'; 2 | import React from 'react'; 3 | 4 | import { OperationCanceledError } from '#errors'; 5 | import { state } from '#libs/console/prompts'; 6 | import { useObservable } from '#libs/observable/hooks'; 7 | 8 | import Logs from './logs.tsx'; 9 | import Prompt from './prompt.tsx'; 10 | 11 | interface AppProps { 12 | onCancel(): void; 13 | } 14 | 15 | const App = React.memo(({ onCancel }: AppProps) => { 16 | const currentPrompt = useObservable(state.currentPrompt); 17 | useInput((input, key) => { 18 | if (input !== 'c' || !key.ctrl) { 19 | return; 20 | } 21 | 22 | if (currentPrompt) { 23 | currentPrompt?.reject(new OperationCanceledError()); 24 | } else { 25 | onCancel(); 26 | } 27 | }); 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | ); 35 | }); 36 | 37 | export default App; 38 | -------------------------------------------------------------------------------- /packages/cli/src/ui/components/input-label.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from 'ink'; 2 | import React from 'react'; 3 | 4 | import TextRow from './text-row.tsx'; 5 | 6 | interface InputLabelProps { 7 | /** 8 | * The label text to display 9 | */ 10 | label: string; 11 | 12 | /** 13 | * Optional hint text to display below the label 14 | */ 15 | hint?: string; 16 | } 17 | 18 | /** 19 | * Component for rendering a consistent label and hint for input components 20 | */ 21 | const InputLabel = React.memo(({ label, hint }) => { 22 | return ( 23 | 24 | 25 | {hint ? ( 26 | 27 | 28 | {hint} 29 | 30 | 31 | ) : null} 32 | 33 | ); 34 | }); 35 | 36 | export default InputLabel; 37 | -------------------------------------------------------------------------------- /packages/cli/src/ui/components/text-row.tsx: -------------------------------------------------------------------------------- 1 | import type { ColorName } from 'chalk'; 2 | import { Box, Text } from 'ink'; 3 | import React from 'react'; 4 | 5 | interface TextRowProps { 6 | icon: string; 7 | color: ColorName; 8 | value: string; 9 | } 10 | 11 | const TextRow = React.memo(({ icon, color, value }) => { 12 | return ( 13 | 14 | 15 | {icon} 16 | 17 | 18 | 19 | {value} 20 | 21 | 22 | 23 | ); 24 | }); 25 | 26 | export default TextRow; 27 | -------------------------------------------------------------------------------- /packages/cli/src/ui/index.ts: -------------------------------------------------------------------------------- 1 | import { render as inkRender } from 'ink'; 2 | import React from 'react'; 3 | 4 | import console from '#libs/console'; 5 | 6 | import App from './app.tsx'; 7 | 8 | export function render() { 9 | const node = React.createElement(App, { 10 | onCancel() { 11 | console.error('Operation canceled'); 12 | instance.rerender(node); 13 | process.exit(1); 14 | }, 15 | }); 16 | const instance = inkRender(node, { 17 | exitOnCtrlC: false, 18 | }); 19 | return { 20 | unmount: instance.unmount.bind(instance), 21 | rerender() { 22 | instance.rerender(node); 23 | }, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/confirm.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text, useInput } from 'ink'; 2 | import React, { useState } from 'react'; 3 | 4 | interface IConfirmInputProps { 5 | label: string; 6 | defaultValue?: boolean; 7 | onSubmit(value: boolean): void; 8 | } 9 | 10 | const ConfirmInput = React.memo(({ defaultValue = true, label, onSubmit }) => { 11 | const [value, setValue] = useState(defaultValue); 12 | 13 | useInput((input, key) => { 14 | if (input === 'y' || input === 'Y') { 15 | setValue(true); 16 | } else if (input === 'n' || input === 'N') { 17 | setValue(false); 18 | } else if (key.return) { 19 | onSubmit(value); 20 | } 21 | }); 22 | 23 | return ( 24 | 25 | {value ? '✅' : '❌'} 26 | 27 | {label} 28 | 29 | {value ? '(Y/n)' : '(y/N)'} 30 | 31 | ); 32 | }); 33 | 34 | export default ConfirmInput; 35 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ConfirmInput } from './confirm.tsx'; 2 | export { MultiSelectInput, SelectInput } from './selects/index.ts'; 3 | export { default as TextInput } from './text.tsx'; 4 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/components/option-item.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from 'ink'; 2 | import React from 'react'; 3 | 4 | interface OptionItemProps { 5 | /** 6 | * The label to display 7 | */ 8 | label: string; 9 | 10 | /** 11 | * Whether this option is currently highlighted 12 | */ 13 | isHighlighted: boolean; 14 | 15 | /** 16 | * Whether this option is currently selected (for multi-select) 17 | */ 18 | isSelected?: boolean; 19 | 20 | /** 21 | * Whether to show checkbox (for multi-select) 22 | */ 23 | showCheckbox?: boolean; 24 | } 25 | 26 | /** 27 | * Component for rendering a single option item in a select list 28 | */ 29 | const OptionItem = React.memo(({ label, isHighlighted, isSelected, showCheckbox = false }) => { 30 | const checkbox = showCheckbox ? (isSelected ? '✅' : '⬜') : null; 31 | 32 | return ( 33 | 34 | {checkbox === null ? null : {`${checkbox} `}} 35 | 40 | {label} 41 | 42 | 43 | ); 44 | }); 45 | 46 | export default OptionItem; 47 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/components/search-display.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from 'ink'; 2 | import React from 'react'; 3 | 4 | interface SearchDisplayProps { 5 | /** 6 | * The current search query 7 | */ 8 | searchQuery: string; 9 | } 10 | 11 | /** 12 | * Component for displaying the current search query 13 | */ 14 | const SearchDisplay = React.memo(({ searchQuery }) => { 15 | if (searchQuery.length === 0) { 16 | return null; 17 | } 18 | 19 | return ( 20 | 21 | Search: 22 | {searchQuery} 23 | 24 | ); 25 | }); 26 | 27 | export default SearchDisplay; 28 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/hooks/useFilteredOptions.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import type { Option } from '../types.ts'; 4 | 5 | /** 6 | * Hook to filter options based on a search query 7 | * @param options The list of options to filter 8 | * @param searchQuery The search query to filter by 9 | * @returns The filtered list of options 10 | */ 11 | export default function useFilteredOptions(options: Option[], searchQuery: string): Option[] { 12 | return useMemo(() => { 13 | if (searchQuery.length === 0) { 14 | return options; 15 | } 16 | 17 | return options.filter(option => { 18 | const query = searchQuery.toLowerCase(); 19 | return option.label.toLowerCase().includes(query) || option.hint?.toLowerCase().includes(query); 20 | }); 21 | }, [searchQuery, options]); 22 | } 23 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/hooks/useMultiSelectInput.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import type { Option } from '../types.ts'; 4 | import useSelectInputBase from './useSelectInputBase.ts'; 5 | 6 | interface UseMultiSelectInputOptions { 7 | /** 8 | * The list of options to select from 9 | */ 10 | options: Option[]; 11 | 12 | /** 13 | * The initial selected values 14 | * @default [] 15 | */ 16 | defaultValues?: string[]; 17 | 18 | /** 19 | * Callback when values are submitted 20 | */ 21 | onSubmit(values: string[]): void; 22 | } 23 | 24 | /** 25 | * Hook to handle input for multi-select components 26 | */ 27 | function useMultiSelectInput({ options, defaultValues = [], onSubmit }: UseMultiSelectInputOptions) { 28 | const result = useSelectInputBase({ 29 | options, 30 | multiple: true, 31 | defaultValues, 32 | onSubmit, 33 | }); 34 | 35 | return useMemo( 36 | () => ({ 37 | searchQuery: result.searchQuery, 38 | filteredOptions: result.filteredOptions, 39 | highlightedIndex: result.highlightedIndex, 40 | selectedValues: result.selectedValues, 41 | allSelected: result.allSelected, 42 | showAllToggle: result.showAllToggle, 43 | }), 44 | [result], 45 | ); 46 | } 47 | 48 | export default useMultiSelectInput; 49 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/hooks/useSearchInput.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react'; 2 | 3 | /** 4 | * Hook to manage search input functionality 5 | * @param initialQuery Initial search query 6 | * @returns Object with search query and functions to update it 7 | */ 8 | function useSearchInput(initialQuery: string = '') { 9 | const [searchQuery, setSearchQuery] = useState(initialQuery); 10 | 11 | const addCharacter = useCallback((char: string) => { 12 | setSearchQuery(prev => prev + char); 13 | }, []); 14 | 15 | const removeCharacter = useCallback(() => { 16 | setSearchQuery(prev => prev.slice(0, -1)); 17 | }, []); 18 | 19 | const clearQuery = useCallback(() => { 20 | setSearchQuery(''); 21 | }, []); 22 | 23 | return useMemo( 24 | () => ({ 25 | searchQuery, 26 | setSearchQuery, 27 | addCharacter, 28 | removeCharacter, 29 | clearQuery, 30 | }), 31 | [searchQuery, setSearchQuery, addCharacter, removeCharacter, clearQuery], 32 | ); 33 | } 34 | 35 | export default useSearchInput; 36 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/hooks/useSelectInput.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import type { Option } from '../types.ts'; 4 | import useSelectInputBase from './useSelectInputBase.ts'; 5 | 6 | interface UseSelectInputOptions { 7 | /** 8 | * The list of options to select from 9 | */ 10 | options: Option[]; 11 | 12 | /** 13 | * The initial index to highlight 14 | * @default 0 15 | */ 16 | initialHighlightedIndex?: number; 17 | 18 | /** 19 | * The minimum index value (can be negative for special items like "All" toggle) 20 | * @default 0 21 | */ 22 | minIndex?: number; 23 | 24 | /** 25 | * Default value for the search input 26 | * @default '' 27 | */ 28 | defaultValue?: string; 29 | 30 | /** 31 | * Callback when an item is submitted 32 | */ 33 | onSubmit(value: string): void; 34 | } 35 | 36 | /** 37 | * Hook to handle input for select components 38 | */ 39 | export default function useSelectInput({ 40 | options, 41 | initialHighlightedIndex = 0, 42 | minIndex = 0, 43 | defaultValue = '', 44 | onSubmit, 45 | }: UseSelectInputOptions) { 46 | const result = useSelectInputBase({ 47 | options, 48 | multiple: false, 49 | initialHighlightedIndex, 50 | minIndex, 51 | defaultValues: defaultValue ? [defaultValue] : [], 52 | onSubmit([value]) { 53 | onSubmit(value); 54 | }, 55 | }); 56 | 57 | return useMemo( 58 | () => ({ 59 | searchQuery: result.searchQuery, 60 | filteredOptions: result.filteredOptions, 61 | highlightedIndex: result.highlightedIndex, 62 | setHighlightedIndex: result.setHighlightedIndex, 63 | }), 64 | [result], 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MultiSelectInput } from './multi-select.tsx'; 2 | export { default as SelectInput } from './select.tsx'; 3 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/multi-select.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from 'ink'; 2 | import React from 'react'; 3 | 4 | import InputLabel from '../../components/input-label.tsx'; 5 | import OptionsList from './components/options-list.tsx'; 6 | import SearchDisplay from './components/search-display.tsx'; 7 | import useMultiSelectInput from './hooks/useMultiSelectInput.ts'; 8 | import type { Option } from './types.ts'; 9 | 10 | interface IMultiSelectInputProps { 11 | label: string; 12 | options: Option[]; 13 | defaultValues?: string[]; 14 | optional?: boolean; 15 | onSubmit(values: string[]): void; 16 | /** 17 | * Maximum number of items to show at once (enables scrolling) 18 | * @defaultValue 10 19 | */ 20 | maxVisibleItems?: number; 21 | } 22 | 23 | const MultiSelectInput = React.memo( 24 | ({ label, options, defaultValues = [], optional = false, onSubmit, maxVisibleItems = 5 }) => { 25 | const { searchQuery, filteredOptions, highlightedIndex, selectedValues, allSelected, showAllToggle } = 26 | useMultiSelectInput({ 27 | options, 28 | defaultValues, 29 | onSubmit, 30 | }); 31 | 32 | return ( 33 | 34 | 38 | 39 | 40 | 48 | 49 | 50 | ); 51 | }, 52 | ); 53 | 54 | export default MultiSelectInput; 55 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/select.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from 'ink'; 2 | import React from 'react'; 3 | 4 | import InputLabel from '../../components/input-label.tsx'; 5 | import OptionsList from './components/options-list.tsx'; 6 | import SearchDisplay from './components/search-display.tsx'; 7 | import useSelectInput from './hooks/useSelectInput.ts'; 8 | import type { Option } from './types.ts'; 9 | 10 | interface ISelectInputProps { 11 | label: string; 12 | options: Option[]; 13 | defaultValue?: string; 14 | onSubmit(value: string): void; 15 | /** 16 | * Maximum number of items to show at once (enables scrolling) 17 | * @defaultValue 10 18 | */ 19 | maxVisibleItems?: number; 20 | } 21 | 22 | const SelectInput = React.memo( 23 | ({ label, options, defaultValue = '', onSubmit, maxVisibleItems = 5 }) => { 24 | const { searchQuery, filteredOptions, highlightedIndex } = useSelectInput({ 25 | options, 26 | defaultValue, 27 | onSubmit, 28 | }); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 40 | 41 | 42 | ); 43 | }, 44 | ); 45 | 46 | export default SelectInput; 47 | -------------------------------------------------------------------------------- /packages/cli/src/ui/input/selects/types.ts: -------------------------------------------------------------------------------- 1 | export interface Option { 2 | label: string; 3 | value: string; 4 | hint?: string; 5 | } 6 | -------------------------------------------------------------------------------- /packages/cli/src/ui/logs.tsx: -------------------------------------------------------------------------------- 1 | import type { ColorName } from 'chalk'; 2 | import { Box } from 'ink'; 3 | import React from 'react'; 4 | 5 | import { logs, type LogType } from '#libs/console/reporters/fancy'; 6 | import { useObservable } from '#libs/observable/hooks'; 7 | 8 | import TextRow from './components/text-row.tsx'; 9 | 10 | const COLORS_MAP = { 11 | success: 'green', 12 | error: 'red', 13 | info: 'blue', 14 | start: 'cyanBright', 15 | warn: 'yellow', 16 | verbose: 'gray', 17 | } as const satisfies Record; 18 | 19 | const ICONS_MAP = { 20 | success: '🌟', 21 | error: '💥', 22 | info: '💡', 23 | start: '🚀', 24 | warn: '⚠️', 25 | verbose: '📝', 26 | } as const satisfies Record; 27 | 28 | const Logs = React.memo(() => { 29 | const logEntries = useObservable(logs); 30 | return ( 31 | 32 | {logEntries.map(log => ( 33 | 34 | ))} 35 | 36 | ); 37 | }); 38 | 39 | export default Logs; 40 | -------------------------------------------------------------------------------- /packages/cli/src/ui/prompt.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { state } from '#libs/console/prompts'; 4 | import { useObservable } from '#libs/observable/hooks'; 5 | 6 | import { ConfirmInput, MultiSelectInput, SelectInput, TextInput } from './input/index.ts'; 7 | 8 | const Prompt = React.memo(() => { 9 | const currentPrompt = useObservable(state.currentPrompt); 10 | switch (currentPrompt?.type) { 11 | case 'text': 12 | return ( 13 | 22 | ); 23 | case 'confirm': 24 | return ( 25 | 30 | ); 31 | case 'select': 32 | return ( 33 | 39 | ); 40 | case 'multi-select': 41 | return ( 42 | 48 | ); 49 | case undefined: 50 | return null; 51 | } 52 | }); 53 | 54 | export default Prompt; 55 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@datanaut/tsconfig/tsconfig.node.json", 3 | "include": ["**/*.ts", "**/*.tsx"], 4 | "compilerOptions": { 5 | "jsx": "react", 6 | "rootDir": "." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/cli/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'node', 6 | include: ['src/**/*.spec.ts'], 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/manager/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openmcp/manager 2 | 3 | ## 0.0.3 4 | 5 | ### Patch Changes 6 | 7 | - [`c3ec1af`](https://github.com/getdatanaut/openmcp/commit/c3ec1afdf557b8552d62a3981ced2bb2a5bf6371) Thanks 8 | [@P0lip](https://github.com/P0lip)! - Initial release 9 | -------------------------------------------------------------------------------- /packages/manager/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { baseConfig } from '@datanaut/eslint-config/base'; 2 | 3 | export default baseConfig; 4 | -------------------------------------------------------------------------------- /packages/manager/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openmcp/manager", 3 | "version": "0.0.3", 4 | "description": "Manage connections between MCP Servers and Clients through a single interface.", 5 | "type": "module", 6 | "license": "MIT", 7 | "sideEffects": false, 8 | "keywords": [ 9 | "mcp", 10 | "model context protocol" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/getdatanaut/openmcp", 15 | "directory": "packages/manager" 16 | }, 17 | "files": [ 18 | "dist", 19 | "src", 20 | "README.md", 21 | "CHANGELOG.md", 22 | "LICENSE", 23 | "package.json" 24 | ], 25 | "exports": { 26 | ".": { 27 | "types": "./src/index.ts", 28 | "development": "./src/index.ts", 29 | "browser": "./dist/browser/index.js", 30 | "default": "./dist/node/index.js" 31 | } 32 | }, 33 | "scripts": { 34 | "build": "tsup", 35 | "lint": "eslint .", 36 | "test": "vitest run", 37 | "test.watch": "vitest watch", 38 | "typecheck": "tsc" 39 | }, 40 | "dependencies": { 41 | "@modelcontextprotocol/sdk": "^1.11.0" 42 | }, 43 | "devDependencies": { 44 | "@datanaut/eslint-config": "0.1.1", 45 | "@datanaut/tsconfig": "0.1.3", 46 | "eslint": "9.26.0", 47 | "tsup": "8.4.0", 48 | "type-fest": "4.41.0", 49 | "typescript": "5.8.3", 50 | "vitest": "3.1.3" 51 | }, 52 | "publishConfig": { 53 | "access": "public" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/manager/src/index.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface ImportMetaEnv { 3 | readonly PLATFORM: 'node' | 'browser'; 4 | } 5 | 6 | interface ImportMeta { 7 | readonly env: ImportMetaEnv; 8 | } 9 | } 10 | 11 | export type { ClientServerId, ClientServerOptions, ClientServerStorageData, Tool } from './client-servers.ts'; 12 | export { ClientServer, ClientServerManager } from './client-servers.ts'; 13 | export type { McpManagerOptions, McpManagerStorage } from './manager.ts'; 14 | export { createMcpManager, McpManager } from './manager.ts'; 15 | export type { ServerOptions, ServerStorageData } from './servers.ts'; 16 | export { Server } from './servers.ts'; 17 | export type { Storage } from './storage/index.ts'; 18 | export type { TransportConfig, TransportConfigs } from './transport.ts'; 19 | export type { ClientId, McpManagerId, ServerId } from './types.ts'; 20 | -------------------------------------------------------------------------------- /packages/manager/src/manager.ts: -------------------------------------------------------------------------------- 1 | import { type ClientServerManager, type ClientServerStorageData, createClientServerManager } from './client-servers.ts'; 2 | import { createServerManager, type ServerManager, type ServerStorageData } from './servers.ts'; 3 | import type { Storage } from './storage/index.ts'; 4 | import { createMemoryStorage } from './storage/memory.ts'; 5 | import type { McpManagerId } from './types.ts'; 6 | 7 | export interface McpManagerOptions { 8 | /** 9 | * A unique identifier for this manager 10 | */ 11 | id?: McpManagerId; 12 | 13 | /** 14 | * Optionally provide a Storage implementation to persist manager state 15 | * 16 | * @default MemoryStorage 17 | */ 18 | storage?: Partial; 19 | } 20 | 21 | export interface McpManagerStorage { 22 | servers: Storage; 23 | clientServers: Storage; 24 | } 25 | 26 | export function createMcpManager(options?: McpManagerOptions) { 27 | return new McpManager(options); 28 | } 29 | 30 | /** 31 | * The McpManager maintains knowledge of registered servers, 32 | * connected clients, server<->client connections. 33 | */ 34 | export class McpManager { 35 | public readonly id: McpManagerId; 36 | public readonly servers: ServerManager; 37 | public readonly clientServers: ClientServerManager; 38 | public readonly storage: McpManagerStorage; 39 | 40 | constructor({ id, storage }: McpManagerOptions = {}) { 41 | this.id = id ?? 'no-id'; 42 | this.storage = { 43 | servers: storage?.servers ?? createMemoryStorage(), 44 | clientServers: storage?.clientServers ?? createMemoryStorage(), 45 | }; 46 | this.servers = createServerManager({ manager: this }); 47 | this.clientServers = createClientServerManager({ manager: this }); 48 | } 49 | 50 | public async close() { 51 | // @TODO close / dispose of any connections or other resources 52 | await this.clientServers.close(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/manager/src/storage/index.ts: -------------------------------------------------------------------------------- 1 | export type StorageItem = Record; 2 | 3 | export type Storage = StorageTable; 4 | 5 | export interface StorageTable { 6 | /** Inserts a new row into the table */ 7 | insert(row: T): Promise; 8 | 9 | /** Creates or updates a row by its primary key */ 10 | upsert({ id }: { id: string }, row: T): Promise; 11 | 12 | /** Updates an existing row by its primary key */ 13 | update({ id }: { id: string }, row: Partial): Promise; 14 | 15 | /** Deletes a row by its primary key */ 16 | delete({ id }: { id: string }): Promise; 17 | 18 | /** Retrieves rows matching a predicate */ 19 | findMany(where?: Partial): Promise; 20 | 21 | /** Retrieves a row by its primary key */ 22 | getById({ id }: { id: string }): Promise; 23 | } 24 | -------------------------------------------------------------------------------- /packages/manager/src/types.ts: -------------------------------------------------------------------------------- 1 | export type McpManagerId = string; 2 | 3 | export type ClientId = string; 4 | 5 | export type ServerId = string; 6 | 7 | export type ClientServerId = `${ClientId}-${ServerId}`; 8 | 9 | export type ToolName = string; 10 | -------------------------------------------------------------------------------- /packages/manager/tests/manager.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { createMcpManager } from '../src/manager.ts'; 4 | 5 | describe('createManager()', () => { 6 | it('should create a manager', async () => { 7 | const manager = createMcpManager(); 8 | 9 | await manager.servers.create({ 10 | id: 'test', 11 | name: 'Test Server', 12 | version: '1.0.0', 13 | transport: { 14 | type: 'inMemory', 15 | config: {}, 16 | }, 17 | }); 18 | 19 | expect(manager).toBeDefined(); 20 | expect((await manager.servers.findMany()).length).toBe(1); 21 | expect(await manager.servers.get({ id: 'test' })).toBeDefined(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/manager/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@datanaut/tsconfig/tsconfig.isomorphic.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "rootDir": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/manager/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ['src/index.ts'], 6 | format: ['esm'], 7 | sourcemap: true, 8 | clean: true, 9 | platform: 'node', 10 | env: { 11 | PLATFORM: 'node', 12 | }, 13 | outDir: 'dist/node', 14 | }, 15 | { 16 | entry: ['src/index.ts'], 17 | format: ['esm'], 18 | sourcemap: true, 19 | clean: true, 20 | platform: 'browser', 21 | env: { 22 | PLATFORM: 'browser', 23 | }, 24 | outDir: 'dist/browser', 25 | }, 26 | ]); 27 | -------------------------------------------------------------------------------- /packages/openapi/.turbo/turbo-build.log: -------------------------------------------------------------------------------- 1 | 2 | CLI Building entry: src/index.ts 3 | CLI Using tsconfig: tsconfig.json 4 | CLI tsup v8.4.0 5 | CLI Using tsup config: /Users/marc/dev/openmcp/packages/openapi/tsup.config.ts 6 | CLI Target: es2023 7 | CLI Cleaning output folder 8 | ESM Build start 9 | ESM dist/index.js 7.21 KB 10 | ESM dist/index.js.map 15.53 KB 11 | ESM ⚡️ Build success in 8ms 12 | -------------------------------------------------------------------------------- /packages/openapi/.turbo/turbo-lint.log: -------------------------------------------------------------------------------- 1 | 2 |  3 | -------------------------------------------------------------------------------- /packages/openapi/.turbo/turbo-test.log: -------------------------------------------------------------------------------- 1 | 2 | [?25l 3 |  RUN  v3.1.1 /Users/marc/dev/openmcp/packages/openapi 4 | 5 | [?2026h 6 |  ❯ tests/index.test.ts [queued] 7 | 8 |  Test Files 0 passed (1) 9 |  Tests 0 passed (0) 10 |  Start at 14:04:47 11 |  Duration 201ms 12 | [?2026l[?2026h 13 |  ❯ tests/index.test.ts 0/3 14 | 15 |  Test Files 0 passed (1) 16 |  Tests 0 passed (3) 17 |  Start at 14:04:47 18 |  Duration 703ms 19 | [?2026l[?2026h 20 |  ❯ tests/index.test.ts 1/3 21 | 22 |  Test Files 0 passed (1) 23 |  Tests 1 passed (3) 24 |  Start at 14:04:47 25 |  Duration 804ms 26 | [?2026l ✓ tests/index.test.ts (3 tests) 103ms 27 | ✓ createMcpServer > should create a MCP server for petstore.json 24ms 28 | ✓ createMcpServer > should create a MCP server for slack.json 52ms 29 | ✓ createMcpServer > should create a MCP server for weather-gov.json 24ms 30 | 31 |  Test Files  1 passed (1) 32 |  Tests  3 passed (3) 33 |  Start at  14:04:47 34 |  Duration  879ms (transform 66ms, setup 0ms, collect 444ms, tests 103ms, environment 0ms, prepare 74ms) 35 | 36 | [?25h 37 | -------------------------------------------------------------------------------- /packages/openapi/.turbo/turbo-typecheck.log: -------------------------------------------------------------------------------- 1 | 2 |  3 | -------------------------------------------------------------------------------- /packages/openapi/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openmcp/openapi 2 | 3 | ## 0.1.2 4 | 5 | ### Patch Changes 6 | 7 | - [`7ee5b16`](https://github.com/getdatanaut/openmcp/commit/7ee5b169d621211ed85dbb11625a8dd6b951178b) Thanks 8 | [@P0lip](https://github.com/P0lip)! - Added a separate exports entry for the request client 9 | 10 | ## 0.1.1 11 | 12 | ### Patch Changes 13 | 14 | - [`00af5e2`](https://github.com/getdatanaut/openmcp/commit/00af5e2dc9e639c3877172bef5637e147bcd1b67) Thanks 15 | [@P0lip](https://github.com/P0lip)! - Minor adjustment in Blob availability detection 16 | 17 | ## 0.1.0 18 | 19 | ### Minor Changes 20 | 21 | - [`fc24b8d`](https://github.com/getdatanaut/openmcp/commit/fc24b8d5d47c9e7fb9f6bbc0498824432c0b432b) Thanks 22 | [@P0lip](https://github.com/P0lip)! - Introduced improvement request client 23 | 24 | ## 0.0.3 25 | 26 | ### Patch Changes 27 | 28 | - [`c3ec1af`](https://github.com/getdatanaut/openmcp/commit/c3ec1afdf557b8552d62a3981ced2bb2a5bf6371) Thanks 29 | [@P0lip](https://github.com/P0lip)! - Initial release 30 | 31 | - Updated dependencies 32 | [[`c3ec1af`](https://github.com/getdatanaut/openmcp/commit/c3ec1afdf557b8552d62a3981ced2bb2a5bf6371)]: 33 | - @openmcp/server@0.0.3 34 | -------------------------------------------------------------------------------- /packages/openapi/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from 'node:fs'; 2 | import { resolve } from 'node:path'; 3 | 4 | import pickBy from 'lodash-es/pickBy.js'; 5 | import { http, HttpResponse } from 'msw'; 6 | import { setupServer } from 'msw/node'; 7 | import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; 8 | 9 | import { openApiToMcpServerOptions } from '../src/index.ts'; 10 | 11 | const fixtureDir = resolve(__dirname, '__fixtures__/openapi'); 12 | const fixtures = readdirSync(fixtureDir) 13 | .filter(file => file.endsWith('.json')) 14 | .map(file => { 15 | return [file, require(`${fixtureDir}/${file}`)]; 16 | }); 17 | 18 | const serverUrl = 'http://createMcpServer-test.com'; 19 | export const restHandlers = [ 20 | http.all(`${serverUrl}/*`, () => { 21 | return HttpResponse.json({ success: true }); 22 | }), 23 | ]; 24 | 25 | const server = setupServer(...restHandlers); 26 | 27 | describe('createMcpServer', () => { 28 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 29 | afterAll(() => server.close()); 30 | afterEach(() => server.resetHandlers()); 31 | 32 | test.each(fixtures)('should create a MCP server for %s', async (filename, openapi) => { 33 | const { 34 | options: { tools }, 35 | } = await openApiToMcpServerOptions({ openapi, serverUrl }); 36 | 37 | await expect( 38 | Object.entries(tools).map(([name, tool]) => ({ 39 | name, 40 | hasDescription: !!tool.description, 41 | hasInputSchema: !!tool.parameters, 42 | hasOutputSchema: !!tool['output'], 43 | hints: pickBy(tool['annotations']?.hints, v => v), 44 | })), 45 | ).toMatchFileSnapshot(`./__snapshots__/openapi/${filename}.snap`); 46 | 47 | await expect(Object.values(tools)[0]!.execute({})).resolves.toHaveProperty('success', true); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/openapi/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { baseConfig } from '@datanaut/eslint-config/base'; 2 | 3 | export default baseConfig; 4 | -------------------------------------------------------------------------------- /packages/openapi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openmcp/openapi", 3 | "version": "0.1.2", 4 | "type": "module", 5 | "license": "MIT", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/getdatanaut/openmcp", 10 | "directory": "packages/openapi" 11 | }, 12 | "files": [ 13 | "dist", 14 | "src", 15 | "README.md", 16 | "CHANGELOG.md", 17 | "LICENSE", 18 | "package.json" 19 | ], 20 | "exports": { 21 | ".": { 22 | "types": "./src/index.ts", 23 | "development": "./src/index.ts", 24 | "default": "./dist/index.js" 25 | }, 26 | "./client": { 27 | "types": "./src/client.ts", 28 | "development": "./src/client.ts", 29 | "default": "./dist/client.js" 30 | } 31 | }, 32 | "scripts": { 33 | "build": "tsup", 34 | "lint": "eslint .", 35 | "test": "vitest run", 36 | "test.watch": "vitest watch", 37 | "typecheck": "tsc" 38 | }, 39 | "dependencies": { 40 | "@apidevtools/json-schema-ref-parser": "^11.9.3", 41 | "@modelcontextprotocol/sdk": "^1.11.0", 42 | "@openmcp/server": "workspace:*", 43 | "@stoplight/http-spec": "^7.1.0", 44 | "@stoplight/json": "^3.21.7", 45 | "ai": "^4.3.13", 46 | "lodash-es": "^4.17.21", 47 | "neverthrow": "^8.2.0", 48 | "url-template": "^3.1.1" 49 | }, 50 | "devDependencies": { 51 | "@datanaut/eslint-config": "0.1.1", 52 | "@datanaut/tsconfig": "0.1.3", 53 | "eslint": "9.26.0", 54 | "fetch-mock": "^12.5.2", 55 | "json-schema": "0.4.0", 56 | "msw": "2.7.6", 57 | "tsup": "8.4.0", 58 | "typescript": "5.8.3", 59 | "vitest": "3.1.3" 60 | }, 61 | "publishConfig": { 62 | "access": "public" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/openapi/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Client, collectOperationClientMeta, type OperationClientMeta } from './client.ts'; 2 | export type { ClientConfig, ServerConfig } from './openapi-to-mcp.ts'; 3 | export { getToolName, openApiToMcpServerOptions } from './openapi-to-mcp.ts'; 4 | -------------------------------------------------------------------------------- /packages/openapi/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@datanaut/tsconfig/tsconfig.isomorphic.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "rootDir": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/openapi/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | format: 'esm', 5 | sourcemap: true, 6 | clean: true, 7 | entry: ['src/index.ts', 'src/client.ts'], 8 | }); 9 | -------------------------------------------------------------------------------- /packages/schemas/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openmcp/schemas 2 | 3 | ## 0.0.3 4 | 5 | ### Patch Changes 6 | 7 | - [`c3ec1af`](https://github.com/getdatanaut/openmcp/commit/c3ec1afdf557b8552d62a3981ced2bb2a5bf6371) Thanks 8 | [@P0lip](https://github.com/P0lip)! - Initial release 9 | -------------------------------------------------------------------------------- /packages/schemas/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { baseConfig } from '@datanaut/eslint-config/base'; 2 | 3 | export default baseConfig; 4 | -------------------------------------------------------------------------------- /packages/schemas/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openmcp/schemas", 3 | "version": "0.0.3", 4 | "type": "module", 5 | "license": "MIT", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/getdatanaut/openmcp", 10 | "directory": "packages/schemas" 11 | }, 12 | "files": [ 13 | "dist", 14 | "src", 15 | "README.md", 16 | "CHANGELOG.md", 17 | "LICENSE", 18 | "package.json" 19 | ], 20 | "exports": { 21 | "./mcp": { 22 | "types": "./src/mcp.ts", 23 | "development": "./src/mcp.ts", 24 | "default": "./dist/mcp.js" 25 | } 26 | }, 27 | "scripts": { 28 | "build": "tsup", 29 | "lint": "eslint .", 30 | "typecheck": "tsc" 31 | }, 32 | "dependencies": { 33 | "is-valid-path": "0.1.1", 34 | "zod": "^3.24.3" 35 | }, 36 | "devDependencies": { 37 | "@datanaut/eslint-config": "0.1.1", 38 | "@datanaut/tsconfig": "0.1.3", 39 | "@types/is-valid-path": "^0.1.2", 40 | "eslint": "9.26.0", 41 | "tsup": "8.4.0", 42 | "typescript": "5.8.3" 43 | }, 44 | "publishConfig": { 45 | "access": "public" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/schemas/src/mcp.ts: -------------------------------------------------------------------------------- 1 | import isValidPath from 'is-valid-path'; 2 | import { z } from 'zod'; 3 | 4 | export const ToolName = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/, 'Tool name must match /^[a-zA-Z0-9_-]{1,64}$/'); 5 | 6 | export const OpenAPITransportSchema = z.object({ 7 | type: z.literal('openapi'), 8 | openapi: z.string().refine( 9 | value => { 10 | return URL.canParse(value) || isValidPath(value); 11 | }, 12 | { 13 | message: 'openapi must be a valid URL or file path', 14 | }, 15 | ), 16 | serverUrl: z.string().url().optional(), 17 | path: z.record(z.unknown()).optional(), 18 | query: z.record(z.unknown()).optional(), 19 | headers: z.record(z.unknown()).optional(), 20 | body: z.record(z.unknown()).optional(), 21 | }); 22 | 23 | export type OpenAPITransport = z.infer; 24 | 25 | export const StreamableHTTPTransportSchema = z.object({ 26 | type: z.literal('streamable-http'), 27 | url: z.string().url(), 28 | headers: z.record(z.string()).optional(), 29 | }); 30 | 31 | export type StreamableHTTPTransport = z.infer; 32 | 33 | export const SSETransportSchema = z.object({ 34 | type: z.literal('sse'), 35 | url: z.string().url(), 36 | headers: z.record(z.string()).optional(), 37 | }); 38 | 39 | export type SSETransport = z.infer; 40 | 41 | export const StdIOTransportSchema = z.object({ 42 | type: z.literal('stdio'), 43 | command: z.string().nonempty('Command must be provided'), 44 | args: z.array(z.string()), 45 | env: z.record(z.string()).optional(), 46 | cwd: z.string().optional(), 47 | }); 48 | 49 | export type StdIOTransport = z.infer; 50 | 51 | export const TransportSchema = z.discriminatedUnion('type', [ 52 | StreamableHTTPTransportSchema, 53 | SSETransportSchema, 54 | OpenAPITransportSchema, 55 | StdIOTransportSchema, 56 | ]); 57 | 58 | export type Transport = z.infer; 59 | -------------------------------------------------------------------------------- /packages/schemas/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@datanaut/tsconfig/tsconfig.isomorphic.json", 3 | "include": ["**/*.ts", "**/*.tsx"], 4 | "compilerOptions": { 5 | "rootDir": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/schemas/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | format: 'esm', 5 | sourcemap: true, 6 | clean: true, 7 | entry: ['src/mcp.ts'], 8 | }); 9 | -------------------------------------------------------------------------------- /packages/server/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openmcp/server 2 | 3 | ## 0.0.3 4 | 5 | ### Patch Changes 6 | 7 | - [`c3ec1af`](https://github.com/getdatanaut/openmcp/commit/c3ec1afdf557b8552d62a3981ced2bb2a5bf6371) Thanks 8 | [@P0lip](https://github.com/P0lip)! - Initial release 9 | 10 | - Updated dependencies 11 | [[`c3ec1af`](https://github.com/getdatanaut/openmcp/commit/c3ec1afdf557b8552d62a3981ced2bb2a5bf6371)]: 12 | - @openmcp/utils@0.0.3 13 | -------------------------------------------------------------------------------- /packages/server/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { baseConfig } from '@datanaut/eslint-config/base'; 2 | 3 | export default baseConfig; 4 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openmcp/server", 3 | "version": "0.0.3", 4 | "type": "module", 5 | "license": "MIT", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/getdatanaut/openmcp", 10 | "directory": "packages/server" 11 | }, 12 | "files": [ 13 | "dist", 14 | "src", 15 | "README.md", 16 | "CHANGELOG.md", 17 | "LICENSE", 18 | "package.json" 19 | ], 20 | "exports": { 21 | ".": { 22 | "types": "./src/index.ts", 23 | "development": "./src/index.ts", 24 | "default": "./dist/index.js" 25 | } 26 | }, 27 | "scripts": { 28 | "build": "tsup", 29 | "lint": "eslint .", 30 | "typecheck": "tsc" 31 | }, 32 | "dependencies": { 33 | "@ai-sdk/ui-utils": "^1.2.10", 34 | "@modelcontextprotocol/sdk": "^1.11.0", 35 | "@openmcp/utils": "workspace:*", 36 | "ai": "^4.3.13", 37 | "zod": "^3.24.3" 38 | }, 39 | "devDependencies": { 40 | "@datanaut/eslint-config": "0.1.1", 41 | "@datanaut/tsconfig": "0.1.3", 42 | "eslint": "9.26.0", 43 | "json-schema": "0.4.0", 44 | "tsup": "8.4.0", 45 | "typescript": "5.8.3", 46 | "vitest": "3.1.3" 47 | }, 48 | "publishConfig": { 49 | "access": "public" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './resources.ts'; 2 | export type { OpenMcpServerOptions } from './server.ts'; 3 | export { OpenMcpServer } from './server.ts'; 4 | export * from './tools.ts'; 5 | -------------------------------------------------------------------------------- /packages/server/src/tools.ts: -------------------------------------------------------------------------------- 1 | import type { Schema } from 'ai'; 2 | import type { z } from 'zod'; 3 | 4 | // aisdk does not export these, so copied over 5 | export type ToolParameters = z.ZodTypeAny | Schema; 6 | export type inferToolParameters = 7 | Params extends Schema ? Params['_type'] : Params extends z.ZodTypeAny ? z.infer : never; 8 | 9 | export type ToolOutput = z.ZodTypeAny | Schema; 10 | 11 | export type ToolName = string; 12 | 13 | export type ToolAnnotations = Record> = { 14 | title?: string; 15 | hints?: Hints; 16 | }; 17 | 18 | export interface McpServerTool< 19 | Params extends ToolParameters = ToolParameters, 20 | ToolResult = any, 21 | OutputSchema extends ToolOutput = ToolOutput, 22 | Annotations extends ToolAnnotations = ToolAnnotations, 23 | > { 24 | parameters?: Params; 25 | description?: string; 26 | output?: OutputSchema; 27 | execute: (args: inferToolParameters) => Promise; 28 | annotations?: Annotations; 29 | } 30 | 31 | export function tool< 32 | Params extends ToolParameters, 33 | ToolResult, 34 | OutputSchema extends ToolOutput, 35 | Annotations extends ToolAnnotations, 36 | >(tool: McpServerTool) { 37 | return tool; 38 | } 39 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@datanaut/tsconfig/tsconfig.isomorphic.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "rootDir": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | format: 'esm', 5 | sourcemap: true, 6 | clean: true, 7 | entry: ['src/index.ts'], 8 | }); 9 | -------------------------------------------------------------------------------- /packages/utils/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openmcp/utils 2 | 3 | ## 0.0.3 4 | 5 | ### Patch Changes 6 | 7 | - [`c3ec1af`](https://github.com/getdatanaut/openmcp/commit/c3ec1afdf557b8552d62a3981ced2bb2a5bf6371) Thanks 8 | [@P0lip](https://github.com/P0lip)! - Initial release 9 | -------------------------------------------------------------------------------- /packages/utils/eslint.config.js: -------------------------------------------------------------------------------- 1 | import { baseConfig } from '@datanaut/eslint-config/base'; 2 | 3 | export default baseConfig; 4 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openmcp/utils", 3 | "version": "0.0.3", 4 | "type": "module", 5 | "license": "MIT", 6 | "sideEffects": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/getdatanaut/openmcp", 10 | "directory": "packages/utils" 11 | }, 12 | "files": [ 13 | "dist", 14 | "src", 15 | "README.md", 16 | "CHANGELOG.md", 17 | "LICENSE", 18 | "package.json" 19 | ], 20 | "exports": { 21 | ".": { 22 | "types": "./src/index.ts", 23 | "development": "./src/index.ts", 24 | "default": "./dist/index.js" 25 | }, 26 | "./documents": { 27 | "types": "./src/documents/index.ts", 28 | "development": "./src/documents/index.ts", 29 | "default": "./dist/documents/index.js" 30 | } 31 | }, 32 | "scripts": { 33 | "build": "tsup", 34 | "lint": "eslint .", 35 | "test": "vitest run", 36 | "test.watch": "vitest watch", 37 | "typecheck": "tsc" 38 | }, 39 | "dependencies": { 40 | "@stoplight/json": "^3.21.7", 41 | "@stoplight/yaml": "^4.3.0", 42 | "ai": "^4.3.13", 43 | "dedent": "^1.6.0", 44 | "gpt-tokenizer": "^2.9.0", 45 | "jsonpath-rfc9535": "^1.3.0", 46 | "lodash-es": "^4.17.21", 47 | "neverthrow": "^8.2.0", 48 | "zod": "^3.24.3" 49 | }, 50 | "devDependencies": { 51 | "@datanaut/eslint-config": "0.1.1", 52 | "@datanaut/tsconfig": "0.1.3", 53 | "eslint": "9.26.0", 54 | "json-schema": "0.4.0", 55 | "tsup": "8.4.0", 56 | "typescript": "5.8.3", 57 | "vitest": "3.1.3" 58 | }, 59 | "publishConfig": { 60 | "access": "public" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/utils/src/documents/__tests__/fixtures/invalid-document.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0",,,,, 3 | } 4 | -------------------------------------------------------------------------------- /packages/utils/src/documents/__tests__/fixtures/invalid-document.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Test API 4 | version: 1.0.0 5 | description: A test API for loadDocument 6 | paths: 7 | /test: 8 | get: 9 | summary: Test endpoint 10 | responses: 11 | '200': 12 | description: OK 13 | - not properly indented 14 | -------------------------------------------------------------------------------- /packages/utils/src/documents/__tests__/fixtures/non-object-document.json: -------------------------------------------------------------------------------- 1 | "This is a string, not an object" 2 | -------------------------------------------------------------------------------- /packages/utils/src/documents/__tests__/fixtures/unsupported-document.txt: -------------------------------------------------------------------------------- 1 | This is a text file with an unsupported extension. 2 | -------------------------------------------------------------------------------- /packages/utils/src/documents/__tests__/fixtures/valid-document.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "Test API", 5 | "version": "1.0.0", 6 | "description": "A test API for loadDocument" 7 | }, 8 | "paths": { 9 | "/test": { 10 | "get": { 11 | "summary": "Test endpoint", 12 | "responses": { 13 | "200": { 14 | "description": "OK" 15 | } 16 | } 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/utils/src/documents/__tests__/fixtures/valid-document.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Test Document", 3 | "version": "1.0.0", 4 | "description": "A test document for document utils", 5 | // comment 6 | "properties": { 7 | "name": { 8 | "type": "string", 9 | }, 10 | "age": { 11 | "type": "number", 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /packages/utils/src/documents/__tests__/fixtures/valid-document.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Test API 4 | version: 1.0.0 5 | description: A test API for loadDocument 6 | paths: 7 | /test: 8 | get: 9 | summary: Test endpoint 10 | responses: 11 | '200': 12 | description: OK 13 | -------------------------------------------------------------------------------- /packages/utils/src/documents/__tests__/fixtures/valid-document.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Test API 4 | version: 1.0.0 5 | description: A test API for loadDocument 6 | paths: 7 | /test: 8 | get: 9 | summary: Test endpoint 10 | responses: 11 | '200': 12 | description: OK 13 | -------------------------------------------------------------------------------- /packages/utils/src/documents/index.ts: -------------------------------------------------------------------------------- 1 | export { default as loadDocument, type LoadOptions } from './load.ts'; 2 | export { default as parseDocument } from './parse.ts'; 3 | export { type IO, default as readDocument } from './read.ts'; 4 | export { default as serializeDocument } from './serialize.ts'; 5 | -------------------------------------------------------------------------------- /packages/utils/src/documents/load.ts: -------------------------------------------------------------------------------- 1 | import parseDocument from './parse.ts'; 2 | import readDocument, { type IO } from './read.ts'; 3 | 4 | export type LoadOptions = { 5 | /** 6 | * @defaultValue {false} 7 | */ 8 | parseJsonAsJsonc?: boolean; 9 | }; 10 | 11 | /** 12 | * Loads a document from a specified location, processes its content based on the 13 | * document type, and returns the parsed result. 14 | * 15 | * @param {IO} io - The IO interface providing methods for reading the document content. 16 | * @param location - The location or path from which the document will be loaded. 17 | * @param {LoadOptions} [options={}] - Optional configuration for loading the document, such as parsing options. 18 | * @return {Promise>} A promise that resolves to the parsed contents of the document as a record. 19 | */ 20 | export default async function loadDocument( 21 | io: IO, 22 | location: string, 23 | options: LoadOptions = {}, 24 | ): Promise> { 25 | const { content, type } = await readDocument(io, location); 26 | if (type === 'json' && options.parseJsonAsJsonc) { 27 | return parseDocument(content, 'jsonc'); 28 | } 29 | 30 | return parseDocument(content, type); 31 | } 32 | -------------------------------------------------------------------------------- /packages/utils/src/documents/serialize.ts: -------------------------------------------------------------------------------- 1 | import { safeStringify } from '@stoplight/yaml'; 2 | 3 | /** 4 | * Serializes the provided document into the specified format. 5 | * 6 | * @param document - The document to be serialized. 7 | * @param type - The format type for serialization. Supported values are 'json', 'jsonc', 'yaml', and 'yml'. 8 | * @return The serialized string based on the specified format. 9 | * @throws {Error} Throws an error if the provided type is unsupported. 10 | */ 11 | export default function serializeDocument(document: unknown, type: string): string { 12 | switch (type) { 13 | case 'jsonc': 14 | case 'json': { 15 | return JSON.stringify(document, null, 2); 16 | } 17 | case 'yaml': 18 | case 'yml': { 19 | return safeStringify(document, { 20 | noRefs: true, 21 | indent: 2, 22 | skipInvalid: true, 23 | }); 24 | } 25 | default: 26 | throw new Error(`Unsupported file type: ${type}`); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { AutoTrimToolResultError } from './auto-trim.ts'; 2 | export { addToolRequirementsToSchema, autoTrimToolResult } from './auto-trim.ts'; 3 | export * as errors from './errors.ts'; 4 | export { replaceVariables } from './replace-variables.ts'; 5 | -------------------------------------------------------------------------------- /packages/utils/src/replace-variables.ts: -------------------------------------------------------------------------------- 1 | const VAR_REGEX = /\{\{([^}]+)}}/g; 2 | 3 | export function replaceVariables(input: string, variables: Record): string { 4 | return input.replace(VAR_REGEX, (match, name) => String(variables[name] ?? match)); 5 | } 6 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@datanaut/tsconfig/tsconfig.isomorphic.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "rootDir": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/utils/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | format: 'esm', 5 | sourcemap: true, 6 | clean: true, 7 | entry: ['src/index.ts', 'src/documents/index.ts'], 8 | }); 9 | -------------------------------------------------------------------------------- /scripts/openmcp-dev.sh: -------------------------------------------------------------------------------- 1 | NODE_ENV=development \ 2 | NODE_OPTIONS="--conditions=development --loader=ts-node/esm" \ 3 | TS_NODE_PROJECT="$(pwd)/packages/cli/tsconfig.json" \ 4 | TS_NODE_TRANSPILE_ONLY=1 \ 5 | openmcp "$@" 6 | -------------------------------------------------------------------------------- /yarn.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@yarnpkg/types')} */ 2 | const { defineConfig } = require(`@yarnpkg/types`); 3 | 4 | // Example here: https://github.com/yarnpkg/berry/blob/master/yarn.config.cjs 5 | 6 | /** 7 | * This rule will enforce that a workspace MUST depend on the same version of 8 | * a dependency as the one used by the other workspaces. 9 | * 10 | * @param {Context} context 11 | */ 12 | function enforceConsistentDependenciesAcrossTheProject({ Yarn }) { 13 | for (const dependency of Yarn.dependencies()) { 14 | if (dependency.type === `peerDependencies`) continue; 15 | 16 | // exception for react 17 | if (dependency.ident === 'react') continue; 18 | 19 | for (const otherDependency of Yarn.dependencies({ ident: dependency.ident })) { 20 | if (otherDependency.type === `peerDependencies`) continue; 21 | 22 | dependency.update(otherDependency.range); 23 | } 24 | } 25 | } 26 | 27 | module.exports = defineConfig({ 28 | constraints: async ctx => { 29 | enforceConsistentDependenciesAcrossTheProject(ctx); 30 | }, 31 | }); 32 | --------------------------------------------------------------------------------