├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .releaserc.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DOCKERHUB_README.md ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── logo-400.png └── logo-full.png ├── eslint.config.js ├── jest.config.js ├── justfile ├── llms-install.md ├── memory-bank ├── activeContext.md ├── productContext.md ├── progress.md ├── projectbrief.md ├── systemPatterns.md └── techContext.md ├── package-lock.json ├── package.json ├── scripts └── generate-version.js ├── src ├── config.ts ├── handlers │ ├── component-detail-handler.ts │ ├── component-map-handler.ts │ ├── handler-utils.ts │ ├── operation-handler.ts │ ├── path-item-handler.ts │ └── top-level-field-handler.ts ├── index.ts ├── rendering │ ├── components.ts │ ├── document.ts │ ├── path-item.ts │ ├── paths.ts │ ├── types.ts │ └── utils.ts ├── services │ ├── formatters.ts │ ├── reference-transform.ts │ └── spec-loader.ts ├── types.ts ├── utils │ └── uri-builder.ts └── version.ts ├── test ├── __tests__ │ ├── e2e │ │ ├── format.test.ts │ │ ├── resources.test.ts │ │ └── spec-loading.test.ts │ └── unit │ │ ├── config.test.ts │ │ ├── handlers │ │ ├── component-detail-handler.test.ts │ │ ├── component-map-handler.test.ts │ │ ├── handler-utils.test.ts │ │ ├── operation-handler.test.ts │ │ ├── path-item-handler.test.ts │ │ └── top-level-field-handler.test.ts │ │ ├── rendering │ │ ├── components.test.ts │ │ ├── document.test.ts │ │ ├── path-item.test.ts │ │ └── paths.test.ts │ │ ├── services │ │ ├── formatters.test.ts │ │ ├── reference-transform.test.ts │ │ └── spec-loader.test.ts │ │ └── utils │ │ └── uri-builder.test.ts ├── fixtures │ ├── complex-endpoint.json │ ├── empty-api.json │ ├── multi-component-types.json │ ├── paths-test.json │ ├── sample-api.json │ └── sample-v2-api.json ├── setup.ts └── utils │ ├── console-helpers.ts │ ├── mcp-test-helpers.ts │ └── test-types.ts ├── tsconfig.json └── tsconfig.test.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine3.21 2 | 3 | WORKDIR /workspaces/mcp-openapi-schema-explorer 4 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MCP OpenAPI Schema Explorer", 3 | "dockerFile": "Dockerfile", // Updated path 4 | "features": { 5 | "ghcr.io/devcontainers/features/common-utils:2": { 6 | "username": "vscode" 7 | }, 8 | "ghcr.io/guiyomh/features/just:0": {} 9 | }, 10 | "remoteUser": "vscode", 11 | "postCreateCommand": "just install", 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "ms-azuretools.vscode-docker", 16 | "GitHub.vscode-github-actions", 17 | "saoudrizwan.claude-dev", 18 | "dbaeumer.vscode-eslint", 19 | "rvest.vs-code-prettier-eslint", 20 | "ms-vscode.vscode-typescript-next" 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: 'github-actions' 10 | directory: '/' 11 | schedule: 12 | interval: 'weekly' 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: # Add default permissions, release job will override if needed 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | tags: 10 | - 'v*' 11 | pull_request: 12 | branches: [main] 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 22 # Match Dockerfile 25 | cache: 'npm' 26 | 27 | - name: Setup Just 28 | uses: extractions/setup-just@v3 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | 33 | - name: Run all checks (format, lint, build, test) 34 | run: just all # Uses justfile for consistency 35 | 36 | - name: Upload coverage reports artifact 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: coverage-report-${{ github.run_id }} # Unique name per run 40 | path: coverage/ 41 | if: always() # Upload even if previous steps fail 42 | 43 | - name: Upload coverage to Codecov 44 | uses: codecov/codecov-action@v5 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | # fail_ci_if_error: true # Optional: fail CI if upload fails 48 | 49 | security: 50 | runs-on: ubuntu-latest 51 | permissions: 52 | contents: read # Needed for checkout and CodeQL 53 | security-events: write # Needed for CodeQL alert uploads 54 | steps: 55 | - name: Checkout code 56 | uses: actions/checkout@v4 57 | 58 | - name: Setup Node.js 59 | uses: actions/setup-node@v4 60 | with: 61 | node-version: 22 # Match Dockerfile and test job 62 | cache: 'npm' 63 | 64 | - name: Setup Just 65 | uses: extractions/setup-just@v3 66 | 67 | - name: Install dependencies 68 | run: npm ci 69 | 70 | - name: Run Security Checks (Audit, Licenses) 71 | run: just security # Uses justfile, includes npm audit and license-checker 72 | continue-on-error: true # Allow workflow to continue even if npm audit finds vulnerabilities 73 | 74 | # Static code analysis with CodeQL (Keep separate as it's not in justfile) 75 | - name: Initialize CodeQL 76 | uses: github/codeql-action/init@v3 77 | # Auto-detect languages: javascript, typescript 78 | # queries: +security-extended # Optional: run more queries 79 | 80 | - name: Perform CodeQL Analysis 81 | uses: github/codeql-action/analyze@v3 82 | 83 | release: 84 | name: Release 85 | runs-on: ubuntu-latest 86 | needs: [test, security] # Run after test and security checks pass 87 | # Run only on pushes to main, not on tags (semantic-release creates tags) 88 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 89 | permissions: 90 | contents: write # Allow tagging, committing package.json/changelog/version.ts 91 | issues: write # Allow commenting on issues/PRs 92 | pull-requests: write # Allow commenting on issues/PRs 93 | id-token: write # Needed for provenance publishing to npm (alternative to NPM_TOKEN) 94 | steps: 95 | - name: Checkout 96 | uses: actions/checkout@v4 97 | with: 98 | persist-credentials: false 99 | 100 | - name: Setup Node.js 101 | uses: actions/setup-node@v4 102 | with: 103 | node-version: 22 # Match Dockerfile and other jobs 104 | cache: 'npm' 105 | 106 | - name: Install all dependencies 107 | run: npm ci --include=dev 108 | 109 | # Docker setup steps (Still needed for the environment where the action runs) 110 | - name: Set up QEMU 111 | uses: docker/setup-qemu-action@v3 112 | - name: Set up Docker Buildx 113 | uses: docker/setup-buildx-action@v3 114 | - name: Log in to Docker Hub 115 | uses: docker/login-action@v3 116 | with: 117 | username: ${{ secrets.DOCKERHUB_USERNAME }} 118 | password: ${{ secrets.DOCKERHUB_TOKEN }} 119 | 120 | - name: Semantic Release 121 | uses: cycjimmy/semantic-release-action@v4 122 | with: 123 | # Add the docker plugin to extra_plugins 124 | extra_plugins: | 125 | @semantic-release/changelog 126 | @semantic-release/exec 127 | @semantic-release/git 128 | @codedependant/semantic-release-docker 129 | env: 130 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} # Use dedicated release token if needed 131 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 132 | # Docker login is handled by the login-action step above 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | 138 | # Personal files 139 | kadykov-* 140 | 141 | # Local documentation and examples 142 | local-docs/ 143 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | prettier $(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g') --write --ignore-unknown 3 | git update-index --again 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | local-docs/ 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "arrowParens": "avoid", 11 | "endOfLine": "lf" 12 | } 13 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/changelog", 7 | "@semantic-release/npm", 8 | [ 9 | "@semantic-release/exec", 10 | { 11 | "prepareCmd": "node ./scripts/generate-version.js ${nextRelease.version}" 12 | } 13 | ], 14 | [ 15 | "@semantic-release/git", 16 | { 17 | "assets": ["package.json", "package-lock.json", "CHANGELOG.md", "src/version.ts"], 18 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 19 | } 20 | ], 21 | [ 22 | "@codedependant/semantic-release-docker", 23 | { 24 | "dockerProject": "kadykov", 25 | "dockerImage": "mcp-openapi-schema-explorer", 26 | "dockerLogin": false 27 | } 28 | ], 29 | "@semantic-release/github" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.2.1](https://github.com/kadykov/mcp-openapi-schema-explorer/compare/v1.2.0...v1.2.1) (2025-04-13) 2 | 3 | ### Bug Fixes 4 | 5 | - update Node.js setup to match Dockerfile version and include dev dependencies ([8658705](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/86587059268ad4c18d219729b39e4e4f990e05e9)) 6 | 7 | # [1.2.0](https://github.com/kadykov/mcp-openapi-schema-explorer/compare/v1.1.0...v1.2.0) (2025-04-13) 8 | 9 | ### Bug Fixes 10 | 11 | - remove husky.sh sourcing from pre-commit hook ([2cf9455](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/2cf9455f1432cb0c6cbda71d61cad9f2f87031ab)) 12 | - update Docker Hub login to use secrets for credentials ([ab2136b](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/ab2136bd8c052d7287ef1fd6d2768a9fd93148c8)) 13 | 14 | ### Features 15 | 16 | - implement Docker support with multi-stage builds and CI integration ([910dc02](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/910dc021b3e203574dee93198ce5896a9e8aa16d)) 17 | 18 | # [1.1.0](https://github.com/kadykov/mcp-openapi-schema-explorer/compare/v1.0.2...v1.1.0) (2025-04-13) 19 | 20 | ### Features 21 | 22 | - enhance component and path item rendering with descriptions and examples in hints ([6989159](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/698915972338b4a16419c9cea3e2377b7701f50b)) 23 | 24 | ## [1.0.2](https://github.com/kadykov/mcp-openapi-schema-explorer/compare/v1.0.1...v1.0.2) (2025-04-13) 25 | 26 | ### Bug Fixes 27 | 28 | - update CI workflow to use RELEASE_TOKEN and disable credential persistence ([e7b18f9](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/e7b18f9055b95f0e2c6e2a356cb87482db6205da)) 29 | 30 | ## [1.0.1](https://github.com/kadykov/mcp-openapi-schema-explorer/compare/v1.0.0...v1.0.1) (2025-04-12) 31 | 32 | ### Bug Fixes 33 | 34 | - add openapi-types dependency to package.json and package-lock.json ([d348fb9](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/d348fb92a30cdb9d213ee92f1779258f43bbbcd9)) 35 | 36 | # 1.0.0 (2025-04-12) 37 | 38 | ### Bug Fixes 39 | 40 | - add codecov badge to README for improved visibility of test coverage ([ed7bf93](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/ed7bf93de6c6efbf3a890551b67321b0d003c3cf)) 41 | 42 | ### Features 43 | 44 | - add CI workflow and dependabot configuration for automated updates ([2d0b22e](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/2d0b22ea20afd58297b2169d3761db32b4c92606)) 45 | - Add configuration management for OpenAPI Explorer ([b9f4771](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/b9f47712e754983d292bd6d53c82fa7e344b45a6)) 46 | - add CONTRIBUTING.md and enhance README with detailed project information ([1f4b2d5](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/1f4b2d59d7a19e54556cf8933fc4e4952d8f438c)) 47 | - Add end-to-end tests for OpenAPI resource handling ([d1ba7ab](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/d1ba7ab5db84717ed6c326d0c7d625906572be2c)) 48 | - Add pre-commit hook to format staged files with Prettier ([af58250](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/af582509fadbffd52afcd36d6113a1965a2bfcef)) 49 | - Add SchemaListHandler and implement schema listing resource with error handling ([873bbee](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/873bbee9cee5233e97202458a6b261e6ac58b651)) 50 | - Add support for minified JSON output format and related enhancements ([f0cb5b8](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/f0cb5b80eeb73d2656b1d8fb37ab8fe21dacf12a)) 51 | - Enhance endpoint features and add endpoint list handler with improved error handling ([32082ac](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/32082acd3f187bb0611a2adbbfb107f0c153aae2)) 52 | - Enhance OpenAPI resource handling with new templates and completion tests ([45e4938](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/45e4938b226dc6e1baeb506b8c23c615fef78065)) 53 | - Enhance output formatting with JSON and YAML support, including formatter implementations and configuration updates ([e63fafe](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/e63fafe82abb36a56bbb976ff3098f2d4d6a7d6c)) 54 | - Implement dynamic server name based on OpenAPI spec title ([aaa691f](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/aaa691fa2c545a433e09fb3f1faa0d31d4e8624d)) 55 | - Implement EndpointListHandler and add endpoint list resource to server ([b81a606](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/b81a60645eeec9b2e9bd7eb46914cdf3178f9457)) 56 | - Implement Map-based validation helpers to enhance security and error handling ([a4394c9](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/a4394c9846482d53436019a0498ca5d91fddefdf)) 57 | - Implement resource completion logic and add related tests ([de8f297](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/de8f29785882a6bd68d4fcaf38de971de4bad222)) 58 | - Implement SchemaHandler and add schema resource support with error handling ([2fae461](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/2fae461e5de51b7610135922b4a4c9a55cd5b126)) 59 | - initialize MCP OpenAPI schema explorer project ([fd64242](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/fd642421274172e5ca330c9b85015f597f4a96c1)) 60 | - Introduce suppressExpectedConsoleError utility to manage console.error during tests ([ef088c2](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/ef088c2f98bacd0dd7ae3f4aa75e44ba52a41712)) 61 | - Update dependencies to include swagger2openapi and @types/js-yaml ([8acb951](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/8acb951eb88843c72f8eb7d6d7feff681b56ff84)) 62 | - update descriptions in API methods to include URL-encoding notes ([b71dbdf](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/b71dbdfd8c5f0c02d9a47f99143416787f76bf50)) 63 | - Update endpoint URI template to support wildcard parameters ([ce1281f](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/ce1281f16f81a0fd7a74b20fe6bb92e7ed19e158)) 64 | - Update EndpointHandler to return detailed operation responses for GET and POST methods ([af55400](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/af554008c35c9be5bdbf53e51b791e90d135e283)) 65 | - Update license compliance check to include Python-2.0 ([e00c5e2](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/e00c5e23cca6070d6833017b567d7c5402276f45)) 66 | - Update MCP inspector command to support YAML output format ([f7fb551](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/f7fb551cc3a9d7e84fb47100cf8e0430c2634070)) 67 | - update release job to match Node.js version and include dev dependencies ([f3aeb87](https://github.com/kadykov/mcp-openapi-schema-explorer/commit/f3aeb87dcd8bed9920fe2eccdcd8f253b310f761)) 68 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to MCP OpenAPI Schema Explorer 2 | 3 | Thank you for considering contributing to this project! We welcome improvements and bug fixes. 4 | 5 | ## Getting Started 6 | 7 | - **Devcontainer:** The easiest way to get a consistent development environment is to use the provided [Dev Container](https://code.visualstudio.com/docs/devcontainers/containers) configuration (`.devcontainer/`). If you have Docker and the VS Code Dev Containers extension installed, simply reopen the project folder in a container. 8 | - **Manual Setup:** If you prefer not to use the devcontainer, ensure you have Node.js (v22 or later recommended) and npm installed. Clone the repository and run `npm install` to install dependencies. 9 | 10 | ## Development Workflow 11 | 12 | This project uses [`just`](https://github.com/casey/just) as a command runner for common development tasks. See the `justfile` for all available commands. Key commands include: 13 | 14 | - `just install`: Install dependencies (`npm install`). 15 | - `just format`: Format code using Prettier. 16 | - `just lint`: Check code for linting errors using ESLint. 17 | - `just build`: Compile TypeScript code (`npx tsc`). 18 | - `just test`: Run unit and end-to-end tests using Jest. 19 | - `just test-coverage`: Run tests and generate a coverage report. 20 | - `just security`: Run security checks (npm audit, license check). 21 | - `just all`: Run format, lint, build, test-coverage, and security checks sequentially. 22 | 23 | Please ensure `just all` passes before submitting a pull request. 24 | 25 | ## Code Style 26 | 27 | - **Formatting:** We use [Prettier](https://prettier.io/) for automatic code formatting. Please run `just format` before committing. 28 | - **Linting:** We use [ESLint](https://eslint.org/) for code analysis. Please run `just lint` to check for issues. 29 | 30 | ## Commit Messages 31 | 32 | This project uses [`semantic-release`](https://github.com/semantic-release/semantic-release) to automate versioning and releases. Therefore, commit messages **must** follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. This allows the release process to automatically determine the version bump (patch, minor, major) and generate changelogs. 33 | 34 | Common commit types include: 35 | 36 | - `feat`: A new feature 37 | - `fix`: A bug fix 38 | - `docs`: Documentation only changes 39 | - `style`: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 40 | - `refactor`: A code change that neither fixes a bug nor adds a feature 41 | - `perf`: A code change that improves performance 42 | - `test`: Adding missing tests or correcting existing tests 43 | - `build`: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) 44 | - `ci`: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) 45 | - `chore`: Other changes that don't modify src or test files 46 | 47 | Example: `feat: add support for YAML output format` 48 | Example: `fix: correct handling of remote URL loading errors` 49 | Example: `docs: update README with client configuration examples` 50 | 51 | ## Cline & Memory Bank 52 | 53 | This project utilizes [Cline](https://github.com/cline/cline) for AI-assisted development. The `memory-bank/` directory contains documentation specifically for Cline's context. Maintaining this memory bank helps ensure Cline can effectively assist with development tasks. 54 | 55 | If you make significant changes to the project's architecture, features, or development process, please consider updating the relevant files in `memory-bank/`. You can learn more about the Cline Memory Bank [here](https://docs.cline.bot/improving-your-prompting-skills/cline-memory-bank). 56 | 57 | ## Submitting Changes 58 | 59 | 1. Fork the repository. 60 | 2. Create a new branch for your feature or fix (`git checkout -b feat/my-new-feature` or `git checkout -b fix/my-bug-fix`). 61 | 3. Make your changes. 62 | 4. Ensure all checks pass (`just all`). 63 | 5. Commit your changes using the Conventional Commits format. 64 | 6. Push your branch to your fork (`git push origin feat/my-new-feature`). 65 | 7. Open a pull request against the `main` branch of the original repository. 66 | 67 | Thank you for your contribution! 68 | -------------------------------------------------------------------------------- /DOCKERHUB_README.md: -------------------------------------------------------------------------------- 1 | # MCP OpenAPI Schema Explorer 2 | 3 | [![Docker Pulls](https://img.shields.io/docker/pulls/kadykov/mcp-openapi-schema-explorer.svg)](https://hub.docker.com/r/kadykov/mcp-openapi-schema-explorer) 4 | [![GitHub Repo](https://img.shields.io/badge/GitHub-kadykov/mcp--openapi--schema--explorer-blue?logo=github)](https://github.com/kadykov/mcp-openapi-schema-explorer) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | 7 | This Docker image runs the **MCP OpenAPI Schema Explorer**, an MCP (Model Context Protocol) server that provides token-efficient access to OpenAPI (v3.0) and Swagger (v2.0) specifications via **MCP Resources**. 8 | 9 | It allows MCP clients (like Cline or Claude Desktop) to explore the structure and details of large OpenAPI specifications without needing to load the entire file into an LLM's context window. 10 | 11 | **Source Code & Full Documentation:** [https://github.com/kadykov/mcp-openapi-schema-explorer](https://github.com/kadykov/mcp-openapi-schema-explorer) 12 | 13 | ## Features 14 | 15 | - **MCP Resource Access:** Explore OpenAPI specs via intuitive URIs (`openapi://info`, `openapi://paths/...`, `openapi://components/...`). 16 | - **OpenAPI v3.0 & Swagger v2.0 Support:** Loads both formats, automatically converting v2.0 to v3.0. 17 | - **Local & Remote Files:** Load specs from local file paths (via volume mount) or HTTP/HTTPS URLs. 18 | - **Token-Efficient:** Designed to minimize token usage for LLMs. 19 | - **Multiple Output Formats:** Get detailed views in JSON (default), YAML, or minified JSON (`--output-format`). 20 | - **Dynamic Server Name:** Server name in MCP clients reflects the `info.title` from the loaded spec. 21 | - **Reference Transformation:** Internal `$ref`s (`#/components/...`) are transformed into clickable MCP URIs. 22 | 23 | ## How to Run 24 | 25 | Pull the image: 26 | 27 | ```bash 28 | docker pull kadykov/mcp-openapi-schema-explorer:latest 29 | ``` 30 | 31 | The container expects the path or URL to the OpenAPI specification as a command-line argument. 32 | 33 | ### Using a Remote Specification URL 34 | 35 | Pass the URL directly to `docker run`: 36 | 37 | ```bash 38 | docker run --rm -i kadykov/mcp-openapi-schema-explorer:latest https://petstore3.swagger.io/api/v3/openapi.json 39 | ``` 40 | 41 | ### Using a Local Specification File 42 | 43 | Mount your local file into the container using the `-v` flag and provide the path _inside the container_ as the argument: 44 | 45 | ```bash 46 | # Example: Mount local file ./my-spec.yaml to /spec/api.yaml inside the container 47 | docker run --rm -i -v "$(pwd)/my-spec.yaml:/spec/api.yaml" kadykov/mcp-openapi-schema-explorer:latest /spec/api.yaml 48 | ``` 49 | 50 | _(Note: Replace `$(pwd)/my-spec.yaml` with the actual absolute path to your local file on the host machine)_ 51 | 52 | ### Specifying Output Format 53 | 54 | Use the `--output-format` flag (optional, defaults to `json`): 55 | 56 | ```bash 57 | # Using YAML output with a remote URL 58 | docker run --rm -i kadykov/mcp-openapi-schema-explorer:latest https://petstore3.swagger.io/api/v3/openapi.json --output-format yaml 59 | 60 | # Using minified JSON with a local file 61 | docker run --rm -i -v "$(pwd)/my-spec.yaml:/spec/api.yaml" kadykov/mcp-openapi-schema-explorer:latest /spec/api.yaml --output-format json-minified 62 | ``` 63 | 64 | Supported formats: `json`, `yaml`, `json-minified`. 65 | 66 | ## Tags 67 | 68 | - `latest`: Points to the most recent stable release. 69 | - Specific version tags (e.g., `1.2.1`) are available corresponding to the npm package versions. 70 | 71 | ## Usage with MCP Clients 72 | 73 | You can configure your MCP client (like Cline or Claude Desktop) to run this Docker image as an MCP server. 74 | 75 | ### Example: Remote URL Specification 76 | 77 | ```json 78 | // Example: ~/.config/cline/mcp_config.json 79 | { 80 | "mcpServers": { 81 | "My API Spec (Docker Remote)": { 82 | "command": "docker", 83 | "args": [ 84 | "run", 85 | "--rm", 86 | "-i", // Required for MCP communication 87 | "kadykov/mcp-openapi-schema-explorer:latest", 88 | "https://petstore3.swagger.io/api/v3/openapi.json" 89 | // Optional: Add "--output-format", "yaml" here if needed 90 | ], 91 | "env": {} 92 | } 93 | } 94 | } 95 | ``` 96 | 97 | ### Example: Local File Specification 98 | 99 | ```json 100 | // Example: ~/.config/cline/mcp_config.json 101 | { 102 | "mcpServers": { 103 | "My API Spec (Docker Local)": { 104 | "command": "docker", 105 | "args": [ 106 | "run", 107 | "--rm", 108 | "-i", // Required for MCP communication 109 | "-v", 110 | "/full/path/to/your/local/openapi.yaml:/spec/api.yaml", // Host path : Container path 111 | "kadykov/mcp-openapi-schema-explorer:latest", 112 | "/spec/api.yaml", // Path inside the container 113 | "--output-format", 114 | "yaml" // Optional format 115 | ], 116 | "env": {} 117 | } 118 | } 119 | } 120 | ``` 121 | 122 | _(Remember to replace `/full/path/to/your/local/openapi.yaml` with the correct absolute path on your host machine)_ 123 | 124 | ## Support 125 | 126 | For issues or questions, please refer to the [GitHub repository](https://github.com/kadykov/mcp-openapi-schema-explorer) or open an [issue](https://github.com/kadykov/mcp-openapi-schema-explorer/issues). 127 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Builder 2 | FROM node:22-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Copy necessary files for installation and build 7 | COPY package.json package-lock.json ./ 8 | COPY tsconfig.json ./ 9 | COPY src ./src 10 | 11 | # Install all dependencies (including devDependencies needed for build) 12 | RUN npm ci 13 | 14 | # Build the project 15 | RUN npm run build 16 | 17 | # Stage 2: Release 18 | FROM node:22-alpine AS release 19 | 20 | WORKDIR /app 21 | 22 | # Copy only necessary files from the builder stage 23 | COPY --from=builder /app/package.json ./package.json 24 | COPY --from=builder /app/package-lock.json ./package-lock.json 25 | COPY --from=builder /app/dist ./dist 26 | 27 | # Install only production dependencies 28 | RUN npm ci --omit=dev --ignore-scripts 29 | 30 | # Set the entrypoint to run the compiled server 31 | # Corrected path based on tsconfig.json ("rootDir": ".", "outDir": "dist") 32 | ENTRYPOINT ["node", "dist/src/index.js"] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Aleksandr Kadykov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/logo-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kadykov/mcp-openapi-schema-explorer/250e4e0b0a471c35fc064820a41168b03603ae84/assets/logo-400.png -------------------------------------------------------------------------------- /assets/logo-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kadykov/mcp-openapi-schema-explorer/250e4e0b0a471c35fc064820a41168b03603ae84/assets/logo-full.png -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import tseslint from '@typescript-eslint/eslint-plugin'; 2 | import tsparser from '@typescript-eslint/parser'; 3 | import eslintJs from '@eslint/js'; 4 | import globals from 'globals'; 5 | import security from 'eslint-plugin-security'; 6 | 7 | export default [ 8 | { 9 | ignores: ['dist/**', 'node_modules/**', 'local-docs/**'], 10 | }, 11 | eslintJs.configs.recommended, 12 | { 13 | files: ['src/**/*.ts'], 14 | languageOptions: { 15 | parser: tsparser, 16 | parserOptions: { 17 | project: './tsconfig.json', 18 | ecmaVersion: 2020, 19 | sourceType: 'module', 20 | }, 21 | globals: { 22 | ...globals.node, 23 | }, 24 | }, 25 | plugins: { 26 | '@typescript-eslint': tseslint, 27 | security: security, 28 | }, 29 | rules: { 30 | ...tseslint.configs['recommended'].rules, 31 | ...tseslint.configs['recommended-requiring-type-checking'].rules, 32 | '@typescript-eslint/explicit-function-return-type': 'error', 33 | '@typescript-eslint/no-explicit-any': 'error', 34 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 35 | '@typescript-eslint/naming-convention': [ 36 | 'error', 37 | { 38 | selector: 'interface', 39 | format: ['PascalCase'], 40 | }, 41 | ], 42 | 'no-console': ['error', { allow: ['warn', 'error'] }], 43 | ...security.configs.recommended.rules, 44 | }, 45 | }, 46 | { 47 | files: ['test/**/*.ts'], 48 | languageOptions: { 49 | parser: tsparser, 50 | parserOptions: { 51 | project: './tsconfig.test.json', 52 | ecmaVersion: 2020, 53 | sourceType: 'module', 54 | }, 55 | globals: { 56 | ...globals.node, 57 | ...globals.jest, 58 | }, 59 | }, 60 | plugins: { 61 | '@typescript-eslint': tseslint, 62 | security: security, 63 | }, 64 | rules: { 65 | ...tseslint.configs['recommended'].rules, 66 | ...tseslint.configs['recommended-requiring-type-checking'].rules, 67 | '@typescript-eslint/explicit-function-return-type': 'error', 68 | '@typescript-eslint/no-explicit-any': 'error', 69 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 70 | 'no-console': 'off', // Allow console in tests 71 | }, 72 | }, 73 | // Configuration for scripts (like generate-version.js) 74 | { 75 | files: ['scripts/**/*.js'], 76 | languageOptions: { 77 | globals: { 78 | ...globals.node, // Enable Node.js global variables 79 | }, 80 | ecmaVersion: 2022, // Use a recent version supporting top-level await etc. 81 | sourceType: 'module', // Treat .js files in scripts/ as ES Modules 82 | }, 83 | rules: { 84 | // Add any specific rules for scripts if needed, e.g., allow console 85 | 'no-console': 'off', 86 | }, 87 | }, 88 | ]; 89 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | export default { 3 | testPathIgnorePatterns: ['/node_modules/', '/local-docs/', '/dist/'], 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | extensionsToTreatAsEsm: ['.ts'], 7 | moduleNameMapper: { 8 | '^(\\.{1,2}/.*)\\.js$': '$1', 9 | }, 10 | transform: { 11 | '^.+\\.tsx?$': [ 12 | 'ts-jest', 13 | { 14 | useESM: true, 15 | tsconfig: 'tsconfig.test.json', 16 | }, 17 | ], 18 | }, 19 | setupFilesAfterEnv: ['./test/setup.ts'], 20 | reporters: [ 21 | [ 22 | 'jest-silent-reporter', 23 | { 24 | useDots: true, 25 | showPaths: true, 26 | showInlineStatus: true, 27 | showWarnings: true, 28 | }, 29 | ], 30 | ], 31 | verbose: true, 32 | }; 33 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: 2 | @just --list 3 | 4 | # Install dependencies 5 | install: 6 | npm install 7 | 8 | # Run tests 9 | test *ARGS='': 10 | npm run test {{ARGS}} 11 | @echo "All tests passed successfully!" 12 | 13 | # Run tests with coverage 14 | test-coverage *ARGS='': 15 | npm run test:coverage {{ARGS}} 16 | 17 | # Lint code 18 | lint: 19 | npm run lint 20 | @echo "Linting completed successfully!" 21 | 22 | # Format code 23 | format: 24 | npm run format 25 | 26 | # Type check 27 | type-check: 28 | npm run type-check 29 | @echo "Type checking completed successfully!" 30 | 31 | # Security scan dependencies for vulnerabilities 32 | audit: 33 | npm audit 34 | 35 | # Check license compliance 36 | check-licenses: 37 | npx license-checker --production \ 38 | --onlyAllow "MIT;ISC;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Python-2.0" \ 39 | --excludePrivatePackages \ 40 | --summary 41 | 42 | # Run all security checks 43 | security: 44 | @just audit 45 | @just check-licenses 46 | 47 | # Build the project 48 | build: 49 | npm run build 50 | 51 | # Launch MCP inspector server 52 | inspect: 53 | #!/usr/bin/env sh 54 | just build 55 | npx @modelcontextprotocol/inspector \ 56 | node dist/src/index.js \ 57 | test/fixtures/complex-endpoint.json \ 58 | --output-format yaml 59 | 60 | # Run all checks including security 61 | all: 62 | @just format 63 | @just lint 64 | @just build 65 | @just test-coverage 66 | @just security 67 | -------------------------------------------------------------------------------- /llms-install.md: -------------------------------------------------------------------------------- 1 | # MCP OpenAPI Schema Explorer Usage Guide 2 | 3 | This guide explains how to add the MCP OpenAPI Schema Explorer server to your MCP client (e.g., Claude Desktop, Windsurf, Cline). This involves adding a configuration entry to your client's settings file that tells the client how to run the server process. The server itself doesn't require separate configuration beyond the command-line arguments specified in the client settings. 4 | 5 | ## Prerequisites 6 | 7 | 1. Node.js (Latest LTS version recommended) OR Docker installed. 8 | 2. Access to an OpenAPI v3.0 or Swagger v2.0 specification file, either via a local file path or a remote HTTP/HTTPS URL. 9 | 3. An MCP client application (e.g., Claude Desktop, Windsurf, Cline, etc.). 10 | 11 | ## Installation 12 | 13 | For the recommended usage methods (`npx` and Docker, described below), **no separate installation step is required**. Your MCP client will download the package or pull the Docker image automatically based on the configuration you provide in its settings. 14 | 15 | If you prefer to install the server explicitly: 16 | 17 | - **Global Install:** Run `npm install -g mcp-openapi-schema-explorer`. See **Usage Method 3** for how to configure your client to use this. 18 | - **Local Install (for Development):** Clone the repository (`git clone ...`), install dependencies (`npm install`), and build (`npm run build`). See **Usage Method 4** for how to configure your client to use this. 19 | 20 | ## Usage Method 1: npx (Recommended) 21 | 22 | This is the recommended method as it avoids global/local installation and ensures you use the latest published version. 23 | 24 | ### Client Configuration Entry (npx Method) 25 | 26 | Add the following JSON object to the `mcpServers` section of your MCP client's configuration file (e.g., `claude_desktop_config.json`). This entry instructs the client on how to run the server using `npx`: 27 | 28 | ```json 29 | { 30 | "mcpServers": { 31 | "My API Spec (npx)": { 32 | "command": "npx", 33 | "args": [ 34 | "-y", 35 | "mcp-openapi-schema-explorer@latest", 36 | "", 37 | "--output-format", 38 | "yaml" 39 | ], 40 | "env": {} 41 | } 42 | } 43 | } 44 | ``` 45 | 46 | **Configuration Details:** 47 | 48 | 1. **Replace `"My API Spec (npx)"`:** Choose a descriptive name for this server instance. 49 | 2. **Replace ``:** Provide the **required** absolute local file path (e.g., `/path/to/your/api.yaml`) or the full remote URL (e.g., `https://petstore3.swagger.io/api/v3/openapi.json`). 50 | 3. **(Optional)** Adjust the `--output-format` value (`yaml`, `json`, `json-minified`). Defaults to `json`. 51 | 52 | ## Usage Method 2: Docker 53 | 54 | You can instruct your MCP client to run the server using the official Docker image: `kadykov/mcp-openapi-schema-explorer`. 55 | 56 | ### Client Configuration Entry (Docker Method) 57 | 58 | - **Using a Remote URL:** 59 | 60 | ```json 61 | { 62 | "mcpServers": { 63 | "My API Spec (Docker Remote)": { 64 | "command": "docker", 65 | "args": [ 66 | "run", 67 | "--rm", 68 | "-i", 69 | "kadykov/mcp-openapi-schema-explorer:latest", 70 | "" 71 | ], 72 | "env": {} 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | - **Using a Local File:** (Requires mounting the file into the container) 79 | ```json 80 | { 81 | "mcpServers": { 82 | "My API Spec (Docker Local)": { 83 | "command": "docker", 84 | "args": [ 85 | "run", 86 | "--rm", 87 | "-i", 88 | "-v", 89 | "/full/host/path/to/spec.yaml:/spec/api.yaml", 90 | "kadykov/mcp-openapi-schema-explorer:latest", 91 | "/spec/api.yaml", 92 | "--output-format", 93 | "yaml" 94 | ], 95 | "env": {} 96 | } 97 | } 98 | } 99 | ``` 100 | **Important:** Replace `/full/host/path/to/spec.yaml` with the correct absolute path on your host machine. The path `/spec/api.yaml` is the corresponding path inside the container. 101 | 102 | ## Usage Method 3: Global Installation (Less Common) 103 | 104 | You can install the package globally, although `npx` is generally preferred. 105 | 106 | ```bash 107 | # Run this command once in your terminal 108 | npm install -g mcp-openapi-schema-explorer 109 | ``` 110 | 111 | ### Client Configuration Entry (Global Install Method) 112 | 113 | Add the following entry to your MCP client's configuration file. This assumes the `mcp-openapi-schema-explorer` command is accessible in the client's execution environment PATH. 114 | 115 | ```json 116 | { 117 | "mcpServers": { 118 | "My API Spec (Global)": { 119 | "command": "mcp-openapi-schema-explorer", 120 | "args": ["", "--output-format", "yaml"], 121 | "env": {} 122 | } 123 | } 124 | } 125 | ``` 126 | 127 | - **`command`:** Use the globally installed command name. You might need the full path if it's not in your system's PATH environment variable accessible by the MCP client. 128 | 129 | ## Usage Method 4: Local Development/Installation 130 | 131 | This method is useful for development or running a locally modified version of the server. 132 | 133 | ### Setup Steps (Run once in your terminal) 134 | 135 | 1. Clone the repository: `git clone https://github.com/kadykov/mcp-openapi-schema-explorer.git` 136 | 2. Navigate into the directory: `cd mcp-openapi-schema-explorer` 137 | 3. Install dependencies: `npm install` 138 | 4. Build the project: `npm run build` (or `just build`) 139 | 140 | ### Client Configuration Entry (Local Development Method) 141 | 142 | Add the following entry to your MCP client's configuration file. This instructs the client to run the locally built server using `node`. 143 | 144 | ```json 145 | { 146 | "mcpServers": { 147 | "My API Spec (Local Dev)": { 148 | "command": "node", 149 | "args": [ 150 | "/full/path/to/cloned/mcp-openapi-schema-explorer/dist/src/index.js", 151 | "", 152 | "--output-format", 153 | "yaml" 154 | ], 155 | 156 | "env": {} 157 | } 158 | } 159 | } 160 | ``` 161 | 162 | **Important:** Replace `/full/path/to/cloned/mcp-openapi-schema-explorer/dist/src/index.js` with the correct absolute path to the built `index.js` file in your cloned repository. 163 | 164 | ## Verification 165 | 166 | After adding the server entry to your MCP client's configuration: 167 | 168 | 1. The server should appear in the list of available MCP servers within your client (e.g., named "My API Spec (npx)" or whatever key you used). The server name might dynamically update based on the spec's `info.title` (e.g., "Schema Explorer for Petstore API"). 169 | 2. Test the connection by accessing a basic resource, for example (using your chosen server name): 170 | ``` 171 | /mcp "My API Spec (npx)" access openapi://info 172 | ``` 173 | 174 | ## Troubleshooting 175 | 176 | Common issues and solutions: 177 | 178 | 1. **Server Fails to Start:** 179 | - Verify the `` is correct, accessible, and properly quoted in the JSON configuration. 180 | - Ensure the specification file is a valid OpenAPI v3.0 or Swagger v2.0 document (JSON or YAML). 181 | - Check Node.js version (LTS recommended) if using `npx`, global, or local install. 182 | - Check Docker installation and permissions if using Docker. 183 | - For remote URLs, check network connectivity. 184 | - For Docker with local files, ensure the volume mount path (`-v` flag) is correct and the host path exists. 185 | - For Local Development, ensure the path to `dist/src/index.js` is correct and the project has been built (`npm run build`). 186 | 2. **Resources Not Loading or Errors:** 187 | - Double-check the resource URI syntax (e.g., `openapi://paths`, `openapi://components/schemas/MySchema`). Remember that path segments in URIs need URL encoding (e.g., `/users/{id}` becomes `users%2F%7Bid%7D`). 188 | - Ensure the requested path, method, or component exists in the specification. 189 | 190 | ## Environment Variables 191 | 192 | No environment variables are required for the server to operate. 193 | 194 | ## Additional Notes 195 | 196 | - The server automatically handles loading specs from local files or remote URLs. 197 | - Swagger v2.0 specifications are automatically converted to OpenAPI v3.0 internally. 198 | - Internal references (`#/components/...`) are transformed into clickable MCP URIs (`openapi://components/...`). 199 | - The server name displayed in the client might be dynamically generated from the specification's title. 200 | 201 | ## Support 202 | 203 | If you encounter any issues: 204 | 205 | 1. Check the project's main README for more details: [https://github.com/kadykov/mcp-openapi-schema-explorer#readme](https://github.com/kadykov/mcp-openapi-schema-explorer#readme) 206 | 2. Submit an issue on GitHub: [https://github.com/kadykov/mcp-openapi-schema-explorer/issues](https://github.com/kadykov/mcp-openapi-schema-explorer/issues) 207 | 208 | --- 209 | 210 | This guide provides instructions for adding the server to your MCP client using various execution methods. Refer to the main project README for comprehensive documentation on features and resource usage. 211 | -------------------------------------------------------------------------------- /memory-bank/activeContext.md: -------------------------------------------------------------------------------- 1 | # Active Context 2 | 3 | ## Current Focus 4 | 5 | Successfully upgraded @modelcontextprotocol/sdk from 1.10.1 to 1.11.0 and addressed breaking changes in test mocks. 6 | 7 | ## Implementation Status 8 | 9 | - @modelcontextprotocol/sdk updated from 1.10.1 to 1.11.0 10 | - Updated all test mocks to include new `RequestHandlerExtra` property (`requestId`). 11 | - Corrected import path for `RequestId` to `@modelcontextprotocol/sdk/types.js`. 12 | - Modified test files: 13 | - component-map-handler.test.ts 14 | - component-detail-handler.test.ts 15 | - operation-handler.test.ts 16 | - path-item-handler.test.ts 17 | - top-level-field-handler.test.ts 18 | - All tests passing successfully 19 | - Server now loads OpenAPI v3.0 and Swagger v2.0 specs from local files or remote URLs 20 | - Swagger v2.0 specs are automatically converted to v3.0 21 | - Internal references are transformed to MCP URIs 22 | - Added `json-minified` output format option 23 | - Server name is now dynamically set based on the loaded spec's `info.title` 24 | - Automated versioning and release process implemented using `semantic-release` 25 | - CI workflow adapted for Node 22, uses `just` for checks, and includes a `release` job 26 | - Docker support added with automated Docker Hub publishing 27 | - Dependencies correctly categorized 28 | - Resource completion logic implemented 29 | - Dynamic server name implemented 30 | - Minified JSON output format added 31 | - Remote spec loading and Swagger v2.0 conversion support added 32 | - Core resource exploration functionality remains operational 33 | - Unit tests updated for latest SDK version 34 | - E2E tests cover all main functionality 35 | 36 | ## Recent Changes 37 | 38 | ### SDK Update to v1.11.0 & Test Fixes (✓) 39 | 40 | 1. **Dependency Update:** 41 | 42 | - Updated @modelcontextprotocol/sdk from 1.10.1 to 1.11.0 in `package.json`. 43 | - Identified breaking change in `RequestHandlerExtra` type requiring a new `requestId` property. 44 | 45 | 2. **Test Suite Updates:** 46 | - Added the `requestId` property to `mockExtra` objects in all handler unit tests: 47 | - `test/__tests__/unit/handlers/top-level-field-handler.test.ts` 48 | - `test/__tests__/unit/handlers/component-map-handler.test.ts` 49 | - `test/__tests__/unit/handlers/path-item-handler.test.ts` 50 | - `test/__tests__/unit/handlers/operation-handler.test.ts` 51 | - `test/__tests__/unit/handlers/component-detail-handler.test.ts` 52 | - Corrected the import path for `RequestId` to `import { RequestId } from '@modelcontextprotocol/sdk/types.js';` in these files. This resolved previous TypeScript import errors and an ESLint warning regarding unsafe assignment also disappeared. 53 | - Confirmed all test fixes by running `just build && just test` successfully. 54 | 55 | ## Next Actions 56 | 57 | 1. **Continue with Previous Plans:** 58 | 59 | - Complete README updates with release process details 60 | - Clean up any remaining TODOs in codebase 61 | - Address minor ESLint warnings 62 | 63 | 2. **Documentation:** 64 | 65 | - Document the SDK upgrade in CHANGELOG.md 66 | - Update dependencies section in relevant documentation 67 | 68 | 3. **Testing:** 69 | 70 | - Monitor for any new breaking changes in future SDK updates 71 | - Consider adding test utilities to simplify mock creation 72 | 73 | 4. **Code Cleanup:** 74 | - Refactor duplicated mock setup code in tests 75 | - Consider creating shared test fixtures for common mocks 76 | 77 | ## Future Considerations 78 | 79 | 1. **SDK Integration:** 80 | 81 | - Stay updated with MCP SDK releases 82 | - Plan for future breaking changes 83 | - Consider automated dependency update checks 84 | 85 | 2. **Testing Infrastructure:** 86 | 87 | - Improve test mock reusability 88 | - Add test coverage for edge cases 89 | - Consider adding integration tests 90 | 91 | 3. **Previous Future Considerations:** 92 | - Implement reference traversal/resolution service 93 | - Enhance support for all component types 94 | -------------------------------------------------------------------------------- /memory-bank/productContext.md: -------------------------------------------------------------------------------- 1 | # Product Context 2 | 3 | ## Problem Statement 4 | 5 | When working with large OpenAPI specifications, loading the entire spec into an LLM's context: 6 | 7 | 1. Consumes excessive tokens due to fully resolved references 8 | 2. May confuse the LLM with too much information 9 | 3. Makes it difficult to focus on specific parts of the API 10 | 4. Duplicates schema information across multiple endpoints 11 | 12 | ## Solution 13 | 14 | An MCP server that: 15 | 16 | 1. Loads OpenAPI v3.0 and Swagger v2.0 specs from local files or remote URLs. 17 | 2. Automatically converts Swagger v2.0 specs to OpenAPI v3.0. 18 | 3. Transforms internal references (`#/components/...`) to token-efficient MCP URIs. 19 | 4. Provides selective access to specific parts of the spec via MCP resources. 20 | 5. Returns information in token-efficient formats (text lists, JSON/YAML details). 21 | 6. Makes it easy for LLMs to explore API structures without loading the entire spec. 22 | 23 | ## User Experience Goals 24 | 25 | 1. Easy installation/usage via npm (`npx`) or Docker. 26 | 2. Simple configuration via a single command-line argument (path or URL). 27 | 3. Intuitive resource URIs for exploring API parts. 28 | 4. Clear and consistent response formats. 29 | 30 | ## Usage Workflow 31 | 32 | 1. User configures the server in their MCP client using either: 33 | - `npx mcp-openapi-schema-explorer [options]` (Recommended for most users) 34 | - `docker run kadykov/mcp-openapi-schema-explorer:latest [options]` (Requires Docker, local files need volume mounting) 35 | - Global installation (`npm i -g ...` then `mcp-openapi-schema-explorer ...`) (Less common) 36 | 2. Server loads the spec (from file or URL), converts v2.0 to v3.0 if necessary, and transforms internal references to MCP URIs. 37 | 3. LLM explores API structure through exposed resources: 38 | - List paths, components, methods. 39 | - View details for info, operations, components, etc. 40 | - Follow transformed reference URIs (`openapi://components/...`) to view component details without loading the whole spec initially. 41 | 4. Server restarts required if the source specification file/URL content changes. 42 | -------------------------------------------------------------------------------- /memory-bank/projectbrief.md: -------------------------------------------------------------------------------- 1 | # OpenAPI Schema Explorer MCP Server 2 | 3 | ## Project Overview 4 | 5 | Building an MCP server that allows exploration of OpenAPI specification files in a selective, token-efficient manner. 6 | 7 | ## Core Requirements (✓) 8 | 9 | 1. Allow loading and exploring OpenAPI spec files without consuming excessive LLM tokens 10 | - Token-efficient plain text listings 11 | - JSON format for detailed views 12 | - Error handling without excessive details 13 | 2. Expose key parts of OpenAPI specs through MCP resources 14 | - Endpoint details with full operation info 15 | - Multiple values support for batch operations 16 | - Resource completion support (✓) 17 | 3. Support local OpenAPI specification files 18 | - OpenAPI v3.0 support 19 | - Local file loading 20 | - Error handling for invalid specs 21 | 4. Provide test coverage with Jest 22 | - Full unit test coverage 23 | - E2E test coverage 24 | - Type-safe test implementation 25 | 26 | ## Future Extensions (Out of Scope) 27 | 28 | - Remote OpenAPI specs 29 | - Different specification formats 30 | - Search functionality 31 | 32 | ## Technical Constraints (✓) 33 | 34 | - Built with TypeScript MCP SDK 35 | - Published to npm 36 | - Comprehensive test coverage 37 | - Optimized for testability and extensibility 38 | 39 | ## Project Boundaries 40 | 41 | - Initial focus on local OpenAPI spec files only 42 | - Focus on most important parts: endpoints and type definitions 43 | - Real-time spec updates are out of scope (server restart required for updates) 44 | 45 | ## Next Optimizations 46 | 47 | - YAML output format for improved token efficiency 48 | - $ref resolution using URI links 49 | - Parameter validation implementation 50 | - Enhanced documentation support 51 | -------------------------------------------------------------------------------- /memory-bank/techContext.md: -------------------------------------------------------------------------------- 1 | # Technical Context 2 | 3 | ## Development Stack 4 | 5 | - TypeScript for implementation 6 | - MCP SDK for server functionality 7 | - Jest for testing 8 | - npm for package distribution 9 | 10 | ## Key Dependencies 11 | 12 | - `@modelcontextprotocol/sdk`: Core MCP functionality 13 | - `swagger2openapi`: OpenAPI/Swagger spec loading, parsing, and v2->v3 conversion (Runtime dependency) 14 | - `js-yaml`: YAML parsing (Runtime dependency) 15 | - `zod`: Schema validation (Runtime dependency) 16 | - `openapi-types`: OpenAPI type definitions (devDependency) 17 | - `typescript`: TypeScript compiler (devDependency) 18 | - `@types/*`: Various type definitions (devDependencies) 19 | - `jest`: Testing framework (devDependency) 20 | - `eslint`: Code linting (devDependency) 21 | - `prettier`: Code formatting (devDependency) 22 | - `semantic-release` & plugins (`@semantic-release/*`, `@codedependant/semantic-release-docker`): Automated releases (devDependencies) 23 | - `just`: Task runner (Used locally, installed via action in CI) 24 | 25 | ## Technical Requirements 26 | 27 | 1. Must follow MCP protocol specifications. 28 | 2. Must handle large OpenAPI/Swagger specs efficiently. 29 | 3. Must provide type-safe reference handling (transforming internal refs to MCP URIs). 30 | 4. Must support loading specs from local file paths and remote HTTP/HTTPS URLs. 31 | 5. Must support OpenAPI v3.0 and Swagger v2.0 formats (with v2.0 being converted to v3.0). 32 | 6. Must be easily testable and maintainable. 33 | 34 | ## Development Environment 35 | 36 | - TypeScript setup with strict type checking 37 | - Jest testing framework with coverage 38 | - ESLint for code quality 39 | - Prettier for code formatting 40 | - `just` task runner (`justfile`) for common development tasks (build, test, lint, etc.) 41 | - Conventional Commits standard for commit messages (required for `semantic-release`) 42 | - Test fixtures and helpers 43 | 44 | ## Code Organization 45 | 46 | - Services layer: 47 | - `SpecLoaderService`: Uses `swagger2openapi` to load specs from files/URLs and handle v2->v3 conversion. 48 | - `ReferenceTransformService`: Transforms internal `#/components/...` refs to MCP URIs. 49 | - `Formatters`: Handle JSON/YAML output. 50 | - Handlers layer for resource endpoints. 51 | - Rendering layer for generating resource content. 52 | - Utilities (e.g., URI builder). 53 | - Strong typing with generics. 54 | - Comprehensive test coverage. 55 | 56 | ## Testing Infrastructure 57 | 58 | - Unit tests: 59 | - `SpecLoaderService` (mocking `swagger2openapi`). 60 | - `ReferenceTransformService`. 61 | - Rendering classes. 62 | - Handlers (mocking services). 63 | - End-to-end tests: 64 | - Verify resource access for local v3, local v2, and remote v3 specs. 65 | - Test multi-value parameters. 66 | - Cover success and error scenarios. 67 | - Verify resource completion logic using `client.complete()`. 68 | - Type-safe test utilities (`mcp-test-helpers`). 69 | - Test fixtures (including v2.0 and v3.0 examples). 70 | - Coverage reporting via Jest and upload to Codecov via GitHub Actions. 71 | - CI Integration (`.github/workflows/ci.yml`): 72 | - Runs checks (`just all`, `just security`, CodeQL) on pushes/PRs to `main`. 73 | - Uses Node 22 environment. 74 | - Includes automated release job using `cycjimmy/semantic-release-action@v4`. 75 | 76 | ## Response Formats 77 | 78 | 1. Base Formats 79 | 80 | - JSON format (default format) 81 | - YAML format support 82 | - URI-based reference links 83 | - Token-efficient structure 84 | - OpenAPI v3 type compliance 85 | 86 | 2. Format Service 87 | 88 | - Pluggable formatter architecture 89 | - Format-specific MIME types (`application/json`, `text/yaml`) 90 | - Type-safe formatter interface (`IFormatter`) 91 | - Consistent error formatting (`text/plain`) 92 | - CLI-configurable output format (`--output-format`) 93 | 94 | 3. Implementation 95 | - Format-specific serialization 96 | - Shared type system 97 | - Error response handling 98 | - Multiple operation support 99 | - Reference transformation 100 | 101 | ## Deployment / Release Process 102 | 103 | - Automated publishing to npm **and Docker Hub** (`kadykov/mcp-openapi-schema-explorer`) via `semantic-release` triggered by pushes to `main` branch in GitHub Actions. 104 | - Uses `cycjimmy/semantic-release-action@v4` in the CI workflow. 105 | - Relies on Conventional Commits to determine version bumps. 106 | - Uses `@codedependant/semantic-release-docker` plugin for Docker build and push. 107 | - Creates version tags (e.g., `v1.2.3`) and GitHub Releases automatically. 108 | - Requires `NPM_TOKEN`, `DOCKERHUB_USERNAME`, and `DOCKERHUB_TOKEN` secrets/variables configured in GitHub repository. 109 | - `CHANGELOG.md` is automatically generated and updated. 110 | - Server version is dynamically set at runtime based on the release version. 111 | 112 | ## Configuration 113 | 114 | - Command-line argument based configuration (`src/config.ts`). 115 | - Single required argument: ``. 116 | - Optional argument: `--output-format `. 117 | - Required argument validation. 118 | - TypeScript type safety (`ServerConfig` interface). 119 | - Error handling for missing/invalid arguments. 120 | 121 | ## Error Handling 122 | 123 | - Descriptive error messages 124 | - Type-safe error handling 125 | - Consistent error format 126 | - Proper error propagation 127 | 128 | ## Future Extensions 129 | 130 | - AsyncAPI format support 131 | - GraphQL schema support 132 | - External reference resolution 133 | - Enhanced schema resources 134 | - Reference validation 135 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-openapi-schema-explorer", 3 | "version": "1.2.1", 4 | "description": "MCP OpenAPI schema explorer", 5 | "type": "module", 6 | "main": "dist/src/index.js", 7 | "types": "dist/src/index.d.ts", 8 | "bin": { 9 | "mcp-openapi-schema-explorer": "dist/src/index.js" 10 | }, 11 | "scripts": { 12 | "build": "rm -rf dist && mkdir -p dist && npx tsc && chmod +x dist/src/index.js", 13 | "test": "jest", 14 | "test:watch": "jest --watch", 15 | "test:coverage": "jest --coverage", 16 | "lint": "eslint . --ext .ts", 17 | "lint:fix": "eslint . --ext .ts --fix", 18 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 19 | "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", 20 | "type-check": "tsc --noEmit", 21 | "prepare": "husky", 22 | "prepublishOnly": "npm run build" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/kadykov/mcp-openapi-schema-explorer.git" 27 | }, 28 | "keywords": [ 29 | "mcp", 30 | "openapi" 31 | ], 32 | "author": "", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/kadykov/mcp-openapi-schema-explorer/issues" 36 | }, 37 | "homepage": "https://github.com/kadykov/mcp-openapi-schema-explorer#readme", 38 | "dependencies": { 39 | "@modelcontextprotocol/sdk": "^1.10.1", 40 | "js-yaml": "^4.1.0", 41 | "openapi-types": "^12.1.3", 42 | "swagger2openapi": "7.0.8", 43 | "zod": "^3.24.2" 44 | }, 45 | "devDependencies": { 46 | "@codedependant/semantic-release-docker": "^5.1.0", 47 | "@eslint/js": "^9.24.0", 48 | "@semantic-release/changelog": "^6.0.3", 49 | "@semantic-release/commit-analyzer": "^13.0.1", 50 | "@semantic-release/exec": "^7.0.3", 51 | "@semantic-release/git": "^10.0.1", 52 | "@semantic-release/github": "^11.0.1", 53 | "@semantic-release/npm": "^12.0.1", 54 | "@semantic-release/release-notes-generator": "^14.0.3", 55 | "@types/jest": "^29.5.14", 56 | "@types/js-yaml": "^4.0.9", 57 | "@types/node": "^22.14.1", 58 | "@types/node-fetch": "^2.6.12", 59 | "@types/swagger2openapi": "^7.0.4", 60 | "@typescript-eslint/eslint-plugin": "^8.29.0", 61 | "@typescript-eslint/parser": "^8.29.0", 62 | "axios": "^1.8.4", 63 | "eslint": "^9.24.0", 64 | "eslint-plugin-security": "^3.0.1", 65 | "globals": "^16.0.0", 66 | "husky": "^9.1.7", 67 | "jest": "^29.7.0", 68 | "jest-silent-reporter": "^0.6.0", 69 | "license-checker": "^25.0.1", 70 | "msw": "^2.7.4", 71 | "openapi-typescript": "^7.6.1", 72 | "prettier": "^3.5.3", 73 | "semantic-release": "^24.2.3", 74 | "ts-jest": "^29.3.1", 75 | "typescript": "^5.8.3" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /scripts/generate-version.js: -------------------------------------------------------------------------------- 1 | // scripts/generate-version.js 2 | // Purpose: Writes the release version provided by semantic-release to src/version.ts 3 | // Called by: @semantic-release/exec during the 'prepare' step 4 | 5 | import fs from 'fs'; // Use import 6 | import path from 'path'; // Use import 7 | import { fileURLToPath } from 'url'; // Needed to convert import.meta.url 8 | 9 | // Get version from the command line argument passed by semantic-release exec 10 | // process is globally available in ESM 11 | const version = process.argv[2]; 12 | 13 | if (!version) { 14 | console.error('Error: No version argument provided to generate-version.js!'); 15 | process.exit(1); 16 | } 17 | 18 | // Basic check for semantic version format (adjust regex if needed) 19 | if (!/^\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/.test(version)) { 20 | console.error(`Error: Invalid version format received: "${version}"`); 21 | process.exit(1); 22 | } 23 | 24 | const content = `// Auto-generated by scripts/generate-version.js during semantic-release prepare step 25 | // Do not edit this file manually. 26 | 27 | export const VERSION = '${version}'; 28 | `; 29 | 30 | // Derive the directory path in ESM 31 | const __filename = fileURLToPath(import.meta.url); 32 | const __dirname = path.dirname(__filename); 33 | 34 | // Construct the absolute path to src/version.ts 35 | const filePath = path.join(__dirname, '..', 'src', 'version.ts'); 36 | const fileDir = path.dirname(filePath); 37 | 38 | try { 39 | // Ensure the src directory exists (though it should) 40 | if (!fs.existsSync(fileDir)) { 41 | fs.mkdirSync(fileDir, { recursive: true }); 42 | } 43 | // Write the version file 44 | fs.writeFileSync(filePath, content, { encoding: 'utf-8' }); 45 | console.log(`Successfully wrote version ${version} to ${filePath}`); 46 | } catch (error) { 47 | console.error(`Error writing version file to ${filePath}:`, error); 48 | process.exit(1); 49 | } 50 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration management for the OpenAPI Explorer MCP server 3 | */ 4 | 5 | import { OutputFormat } from './services/formatters.js'; 6 | 7 | /** Server configuration */ 8 | export interface ServerConfig { 9 | /** Path to OpenAPI specification file */ 10 | specPath: string; 11 | /** Output format for responses */ 12 | outputFormat: OutputFormat; 13 | } 14 | 15 | /** Load server configuration from command line arguments */ 16 | export function loadConfig(specPath?: string, options?: { outputFormat?: string }): ServerConfig { 17 | if (!specPath) { 18 | throw new Error( 19 | 'OpenAPI spec path is required. Usage: npx mcp-openapi-schema-explorer [--output-format json|yaml]' 20 | ); 21 | } 22 | 23 | const format = options?.outputFormat || 'json'; 24 | if (format !== 'json' && format !== 'yaml' && format !== 'json-minified') { 25 | throw new Error('Invalid output format. Supported formats: json, yaml, json-minified'); 26 | } 27 | 28 | return { 29 | specPath, 30 | // Cast is safe here due to the validation above 31 | outputFormat: format as OutputFormat, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/handlers/component-detail-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadResourceTemplateCallback, 3 | ResourceTemplate, 4 | } from '@modelcontextprotocol/sdk/server/mcp.js'; 5 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 6 | import { SpecLoaderService } from '../types.js'; 7 | import { IFormatter } from '../services/formatters.js'; 8 | import { 9 | RenderableComponentMap, 10 | ComponentType, 11 | VALID_COMPONENT_TYPES, 12 | } from '../rendering/components.js'; 13 | import { RenderContext, RenderResultItem } from '../rendering/types.js'; 14 | import { createErrorResult } from '../rendering/utils.js'; 15 | // Import shared handler utils 16 | import { 17 | formatResults, 18 | isOpenAPIV3, 19 | FormattedResultItem, 20 | getValidatedComponentMap, // Import helper 21 | getValidatedComponentDetails, // Import helper 22 | } from './handler-utils.js'; // Already has .js 23 | 24 | const BASE_URI = 'openapi://'; 25 | 26 | // Removed duplicated FormattedResultItem type - now imported from handler-utils 27 | // Removed duplicated formatResults function - now imported from handler-utils 28 | // Removed duplicated isOpenAPIV3 function - now imported from handler-utils 29 | 30 | /** 31 | * Handles requests for specific component details. 32 | * Corresponds to the `openapi://components/{type}/{name*}` template. 33 | */ 34 | export class ComponentDetailHandler { 35 | constructor( 36 | private specLoader: SpecLoaderService, 37 | private formatter: IFormatter 38 | ) {} 39 | 40 | getTemplate(): ResourceTemplate { 41 | // TODO: Add completion logic if needed 42 | return new ResourceTemplate(`${BASE_URI}components/{type}/{name*}`, { 43 | list: undefined, 44 | complete: undefined, 45 | }); 46 | } 47 | 48 | handleRequest: ReadResourceTemplateCallback = async ( 49 | uri: URL, 50 | variables: Variables 51 | ): Promise<{ contents: FormattedResultItem[] }> => { 52 | const type = variables.type as string; 53 | // Correct variable access key: 'name', not 'name*' 54 | const nameVar = variables['name']; // Can be string or string[] 55 | const mapUriSuffix = `components/${type}`; 56 | const context: RenderContext = { formatter: this.formatter, baseUri: BASE_URI }; 57 | let resultItems: RenderResultItem[]; 58 | 59 | try { 60 | if (!VALID_COMPONENT_TYPES.includes(type as ComponentType)) { 61 | throw new Error(`Invalid component type: ${type}`); 62 | } 63 | const componentType = type as ComponentType; 64 | 65 | // Normalize names: Handle string for single value, array for multiple. 66 | let names: string[] = []; 67 | if (Array.isArray(nameVar)) { 68 | names = nameVar.map(n => String(n).trim()); // Ensure elements are strings 69 | } else if (typeof nameVar === 'string') { 70 | names = [nameVar.trim()]; // Treat as single item array 71 | } 72 | names = names.filter(n => n.length > 0); // Remove empty strings 73 | 74 | if (names.length === 0) { 75 | throw new Error('No valid component name specified.'); 76 | } 77 | 78 | const spec = await this.specLoader.getTransformedSpec({ 79 | resourceType: 'schema', // Use 'schema' for now 80 | format: 'openapi', 81 | }); 82 | 83 | // Use imported type guard 84 | if (!isOpenAPIV3(spec)) { 85 | throw new Error('Only OpenAPI v3 specifications are supported'); 86 | } 87 | 88 | // --- Use helper to get validated component map --- 89 | const componentMapObj = getValidatedComponentMap(spec, componentType); 90 | 91 | // --- Create Map and use helper to get validated component names/details --- 92 | // Create the Map from the validated object 93 | const detailsMap = new Map(Object.entries(componentMapObj)); 94 | // Pass the Map to the helper 95 | const validDetails = getValidatedComponentDetails(detailsMap, names, componentType); 96 | const validNames = validDetails.map(detail => detail.name); // Extract names 97 | 98 | // Instantiate RenderableComponentMap with the validated map object 99 | const renderableMap = new RenderableComponentMap( 100 | componentMapObj, // componentMapObj retrieved safely via helper 101 | componentType, 102 | mapUriSuffix 103 | ); 104 | // Pass the validated names to the rendering function 105 | resultItems = renderableMap.renderComponentDetail(context, validNames); 106 | } catch (error: unknown) { 107 | // Catch errors from helpers (e.g., type/name not found) or rendering 108 | const message = error instanceof Error ? error.message : String(error); 109 | console.error(`Error handling request ${uri.href}: ${message}`); 110 | // Create a single error item representing the overall request failure 111 | resultItems = createErrorResult( 112 | uri.href.substring(BASE_URI.length), // Use request URI suffix 113 | message 114 | ); 115 | } 116 | 117 | // Use imported formatResults 118 | const contents = formatResults(context, resultItems); 119 | return { contents }; 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /src/handlers/component-map-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadResourceTemplateCallback, 3 | ResourceTemplate, 4 | } from '@modelcontextprotocol/sdk/server/mcp.js'; 5 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 6 | import { SpecLoaderService } from '../types.js'; 7 | import { IFormatter } from '../services/formatters.js'; 8 | import { 9 | RenderableComponentMap, 10 | ComponentType, 11 | VALID_COMPONENT_TYPES, 12 | } from '../rendering/components.js'; 13 | import { RenderContext, RenderResultItem } from '../rendering/types.js'; 14 | import { createErrorResult } from '../rendering/utils.js'; 15 | // Import shared handler utils 16 | import { 17 | formatResults, 18 | isOpenAPIV3, 19 | FormattedResultItem, 20 | getValidatedComponentMap, // Import the helper 21 | } from './handler-utils.js'; // Already has .js 22 | 23 | const BASE_URI = 'openapi://'; 24 | 25 | // Removed duplicated FormattedResultItem type - now imported from handler-utils 26 | // Removed duplicated formatResults function - now imported from handler-utils 27 | // Removed duplicated isOpenAPIV3 function - now imported from handler-utils 28 | 29 | /** 30 | * Handles requests for listing component names of a specific type. 31 | * Corresponds to the `openapi://components/{type}` template. 32 | */ 33 | export class ComponentMapHandler { 34 | constructor( 35 | private specLoader: SpecLoaderService, 36 | private formatter: IFormatter // Needed for context 37 | ) {} 38 | 39 | getTemplate(): ResourceTemplate { 40 | // TODO: Add completion logic if needed 41 | return new ResourceTemplate(`${BASE_URI}components/{type}`, { 42 | list: undefined, 43 | complete: undefined, 44 | }); 45 | } 46 | 47 | handleRequest: ReadResourceTemplateCallback = async ( 48 | uri: URL, 49 | variables: Variables 50 | ): Promise<{ contents: FormattedResultItem[] }> => { 51 | const type = variables.type as string; 52 | const mapUriSuffix = `components/${type}`; 53 | const context: RenderContext = { formatter: this.formatter, baseUri: BASE_URI }; 54 | let resultItems: RenderResultItem[]; 55 | 56 | try { 57 | if (!VALID_COMPONENT_TYPES.includes(type as ComponentType)) { 58 | throw new Error(`Invalid component type: ${type}`); 59 | } 60 | const componentType = type as ComponentType; 61 | 62 | const spec = await this.specLoader.getTransformedSpec({ 63 | resourceType: 'schema', // Use 'schema' for now 64 | format: 'openapi', 65 | }); 66 | 67 | // Use imported type guard 68 | if (!isOpenAPIV3(spec)) { 69 | throw new Error('Only OpenAPI v3 specifications are supported'); 70 | } 71 | 72 | // --- Use helper to get validated component map --- 73 | const componentMapObj = getValidatedComponentMap(spec, componentType); 74 | 75 | // Instantiate RenderableComponentMap with the validated map 76 | const renderableMap = new RenderableComponentMap( 77 | componentMapObj, // componentMapObj retrieved safely via helper 78 | componentType, 79 | mapUriSuffix 80 | ); 81 | resultItems = renderableMap.renderList(context); 82 | } catch (error: unknown) { 83 | const message = error instanceof Error ? error.message : String(error); 84 | console.error(`Error handling request ${uri.href}: ${message}`); 85 | resultItems = createErrorResult(mapUriSuffix, message); 86 | } 87 | 88 | // Use imported formatResults 89 | const contents = formatResults(context, resultItems); 90 | return { contents }; 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/handlers/operation-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadResourceTemplateCallback, 3 | ResourceTemplate, 4 | } from '@modelcontextprotocol/sdk/server/mcp.js'; 5 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 6 | import { SpecLoaderService } from '../types.js'; 7 | import { IFormatter } from '../services/formatters.js'; 8 | import { RenderablePathItem } from '../rendering/path-item.js'; 9 | import { RenderContext, RenderResultItem } from '../rendering/types.js'; 10 | import { createErrorResult } from '../rendering/utils.js'; 11 | import { buildPathItemUriSuffix } from '../utils/uri-builder.js'; // Added .js extension 12 | // Import shared handler utils 13 | import { 14 | formatResults, 15 | isOpenAPIV3, 16 | FormattedResultItem, 17 | getValidatedPathItem, // Import new helper 18 | getValidatedOperations, // Import new helper 19 | } from './handler-utils.js'; // Already has .js 20 | 21 | const BASE_URI = 'openapi://'; 22 | 23 | // Removed duplicated FormattedResultItem type - now imported from handler-utils 24 | // Removed duplicated formatResults function - now imported from handler-utils 25 | // Removed duplicated isOpenAPIV3 function - now imported from handler-utils 26 | 27 | /** 28 | * Handles requests for specific operation details within a path. 29 | * Corresponds to the `openapi://paths/{path}/{method*}` template. 30 | */ 31 | export class OperationHandler { 32 | constructor( 33 | private specLoader: SpecLoaderService, 34 | private formatter: IFormatter 35 | ) {} 36 | 37 | getTemplate(): ResourceTemplate { 38 | // TODO: Add completion logic if needed 39 | return new ResourceTemplate(`${BASE_URI}paths/{path}/{method*}`, { 40 | list: undefined, 41 | complete: undefined, 42 | }); 43 | } 44 | 45 | handleRequest: ReadResourceTemplateCallback = async ( 46 | uri: URL, 47 | variables: Variables 48 | ): Promise<{ contents: FormattedResultItem[] }> => { 49 | const encodedPath = variables.path as string; 50 | // Correct variable access key: 'method', not 'method*' 51 | const methodVar = variables['method']; // Can be string or string[] 52 | // Decode the path received from the URI variable 53 | const decodedPath = decodeURIComponent(encodedPath || ''); 54 | // Use the builder to create the suffix, which will re-encode the path correctly 55 | const pathUriSuffix = buildPathItemUriSuffix(decodedPath); 56 | const context: RenderContext = { formatter: this.formatter, baseUri: BASE_URI }; 57 | let resultItems: RenderResultItem[]; 58 | 59 | try { 60 | // Normalize methods: Handle string for single value, array for multiple. 61 | let methods: string[] = []; 62 | if (Array.isArray(methodVar)) { 63 | methods = methodVar.map(m => String(m).trim().toLowerCase()); // Ensure elements are strings 64 | } else if (typeof methodVar === 'string') { 65 | methods = [methodVar.trim().toLowerCase()]; // Treat as single item array 66 | } 67 | methods = methods.filter(m => m.length > 0); // Remove empty strings 68 | 69 | if (methods.length === 0) { 70 | throw new Error('No valid HTTP method specified.'); 71 | } 72 | 73 | const spec = await this.specLoader.getTransformedSpec({ 74 | resourceType: 'schema', // Use 'schema' for now 75 | format: 'openapi', 76 | }); 77 | 78 | // Use imported type guard 79 | if (!isOpenAPIV3(spec)) { 80 | throw new Error('Only OpenAPI v3 specifications are supported'); 81 | } 82 | 83 | // --- Use helper to get validated path item --- 84 | const lookupPath = decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; 85 | const pathItemObj = getValidatedPathItem(spec, lookupPath); 86 | 87 | // --- Use helper to get validated requested methods --- 88 | const validMethods = getValidatedOperations(pathItemObj, methods, lookupPath); 89 | 90 | // Instantiate RenderablePathItem with the validated pathItemObj 91 | const renderablePathItem = new RenderablePathItem( 92 | pathItemObj, // pathItemObj retrieved safely via helper 93 | lookupPath, // Pass the raw, decoded path 94 | pathUriSuffix // Pass the correctly built suffix 95 | ); 96 | 97 | // Use the validated methods returned by the helper 98 | resultItems = renderablePathItem.renderOperationDetail(context, validMethods); 99 | } catch (error: unknown) { 100 | // Catch errors from helpers (e.g., path/method not found) or rendering 101 | const message = error instanceof Error ? error.message : String(error); 102 | console.error(`Error handling request ${uri.href}: ${message}`); 103 | // Create a single error item representing the overall request failure 104 | resultItems = createErrorResult( 105 | uri.href.substring(BASE_URI.length), // Use request URI suffix 106 | message 107 | ); 108 | } 109 | 110 | // Use imported formatResults 111 | const contents = formatResults(context, resultItems); 112 | return { contents }; 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /src/handlers/path-item-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadResourceTemplateCallback, 3 | ResourceTemplate, 4 | } from '@modelcontextprotocol/sdk/server/mcp.js'; 5 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 6 | import { SpecLoaderService } from '../types.js'; 7 | import { IFormatter } from '../services/formatters.js'; 8 | import { RenderablePathItem } from '../rendering/path-item.js'; 9 | import { RenderContext, RenderResultItem } from '../rendering/types.js'; 10 | import { createErrorResult } from '../rendering/utils.js'; 11 | import { buildPathItemUriSuffix } from '../utils/uri-builder.js'; // Added .js extension 12 | // Import shared handler utils 13 | import { 14 | formatResults, 15 | isOpenAPIV3, 16 | FormattedResultItem, 17 | getValidatedPathItem, // Import the helper 18 | } from './handler-utils.js'; // Already has .js 19 | 20 | const BASE_URI = 'openapi://'; 21 | 22 | // Removed duplicated FormattedResultItem type - now imported from handler-utils 23 | // Removed duplicated formatResults function - now imported from handler-utils 24 | // Removed duplicated isOpenAPIV3 function - now imported from handler-utils 25 | 26 | /** 27 | * Handles requests for listing methods for a specific path. 28 | * Corresponds to the `openapi://paths/{path}` template. 29 | */ 30 | export class PathItemHandler { 31 | constructor( 32 | private specLoader: SpecLoaderService, 33 | private formatter: IFormatter // Although unused in list view, needed for context 34 | ) {} 35 | 36 | getTemplate(): ResourceTemplate { 37 | // TODO: Add completion logic if needed 38 | return new ResourceTemplate(`${BASE_URI}paths/{path}`, { 39 | list: undefined, 40 | complete: undefined, 41 | }); 42 | } 43 | 44 | handleRequest: ReadResourceTemplateCallback = async ( 45 | uri: URL, 46 | variables: Variables 47 | ): Promise<{ contents: FormattedResultItem[] }> => { 48 | const encodedPath = variables.path as string; 49 | // Decode the path received from the URI variable 50 | const decodedPath = decodeURIComponent(encodedPath || ''); 51 | // Use the builder to create the suffix, which will re-encode the path correctly 52 | const pathUriSuffix = buildPathItemUriSuffix(decodedPath); 53 | const context: RenderContext = { formatter: this.formatter, baseUri: BASE_URI }; 54 | let resultItems: RenderResultItem[]; 55 | 56 | try { 57 | const spec = await this.specLoader.getTransformedSpec({ 58 | resourceType: 'schema', // Use 'schema' for now 59 | format: 'openapi', 60 | }); 61 | 62 | // Use imported type guard 63 | if (!isOpenAPIV3(spec)) { 64 | throw new Error('Only OpenAPI v3 specifications are supported'); 65 | } 66 | 67 | // --- Use helper to get validated path item --- 68 | const lookupPath = decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; 69 | const pathItemObj = getValidatedPathItem(spec, lookupPath); 70 | 71 | // Instantiate RenderablePathItem with the validated pathItemObj 72 | const renderablePathItem = new RenderablePathItem( 73 | pathItemObj, // pathItemObj retrieved safely via helper 74 | lookupPath, // Pass the raw, decoded path 75 | pathUriSuffix // Pass the correctly built suffix 76 | ); 77 | resultItems = renderablePathItem.renderList(context); 78 | } catch (error: unknown) { 79 | const message = error instanceof Error ? error.message : String(error); 80 | console.error(`Error handling request ${uri.href}: ${message}`); 81 | resultItems = createErrorResult(pathUriSuffix, message); 82 | } 83 | 84 | // Use imported formatResults 85 | const contents = formatResults(context, resultItems); 86 | return { contents }; 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/handlers/top-level-field-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadResourceTemplateCallback, 3 | ResourceTemplate, 4 | } from '@modelcontextprotocol/sdk/server/mcp.js'; 5 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 6 | // ResourceContents is the base type for a single item, not the array type needed here. 7 | // We'll define the array structure inline based on TextResourceContentsSchema. 8 | 9 | import { SpecLoaderService } from '../types.js'; 10 | import { IFormatter } from '../services/formatters.js'; 11 | import { RenderableDocument } from '../rendering/document.js'; 12 | import { RenderablePaths } from '../rendering/paths.js'; 13 | import { RenderableComponents } from '../rendering/components.js'; 14 | import { RenderContext, RenderResultItem } from '../rendering/types.js'; 15 | import { createErrorResult } from '../rendering/utils.js'; 16 | // Import shared handler utils 17 | import { formatResults, isOpenAPIV3, FormattedResultItem } from './handler-utils.js'; // Already has .js 18 | 19 | const BASE_URI = 'openapi://'; 20 | 21 | // Removed duplicated FormattedResultItem type - now imported from handler-utils 22 | // Removed duplicated formatResults function - now imported from handler-utils 23 | 24 | /** 25 | * Handles requests for top-level OpenAPI fields (info, servers, paths list, components list). 26 | * Corresponds to the `openapi://{field}` template. 27 | */ 28 | export class TopLevelFieldHandler { 29 | constructor( 30 | private specLoader: SpecLoaderService, 31 | private formatter: IFormatter 32 | ) {} 33 | 34 | getTemplate(): ResourceTemplate { 35 | // TODO: Add completion logic if needed 36 | return new ResourceTemplate(`${BASE_URI}{field}`, { 37 | list: undefined, 38 | complete: undefined, 39 | }); 40 | } 41 | 42 | handleRequest: ReadResourceTemplateCallback = async ( 43 | uri: URL, 44 | variables: Variables 45 | // matchedTemplate is not needed if we only handle one template 46 | ): Promise<{ contents: FormattedResultItem[] }> => { 47 | // Return type uses the defined array structure 48 | const field = variables.field as string; 49 | const context: RenderContext = { formatter: this.formatter, baseUri: BASE_URI }; 50 | let resultItems: RenderResultItem[]; 51 | 52 | try { 53 | const spec = await this.specLoader.getTransformedSpec({ 54 | // Use 'schema' as placeholder resourceType for transformation context 55 | resourceType: 'schema', 56 | format: 'openapi', 57 | }); 58 | 59 | // Use imported type guard 60 | if (!isOpenAPIV3(spec)) { 61 | throw new Error('Only OpenAPI v3 specifications are supported'); 62 | } 63 | 64 | const renderableDoc = new RenderableDocument(spec); 65 | 66 | // Route based on the field name 67 | if (field === 'paths') { 68 | const pathsObj = renderableDoc.getPathsObject(); 69 | resultItems = new RenderablePaths(pathsObj).renderList(context); 70 | } else if (field === 'components') { 71 | const componentsObj = renderableDoc.getComponentsObject(); 72 | resultItems = new RenderableComponents(componentsObj).renderList(context); 73 | } else { 74 | // Handle other top-level fields (info, servers, tags, etc.) 75 | const fieldObject = renderableDoc.getTopLevelField(field); 76 | resultItems = renderableDoc.renderTopLevelFieldDetail(context, fieldObject, field); 77 | } 78 | } catch (error: unknown) { 79 | const message = error instanceof Error ? error.message : String(error); 80 | console.error(`Error handling request ${uri.href}: ${message}`); 81 | resultItems = createErrorResult(field, message); // Use field as uriSuffix for error 82 | } 83 | 84 | // Format results into the final structure 85 | const contents: FormattedResultItem[] = formatResults(context, resultItems); 86 | // Return the object with the correctly typed contents array 87 | // Use imported formatResults 88 | return { contents }; 89 | }; 90 | 91 | // Removed duplicated isOpenAPIV3 type guard - now imported from handler-utils 92 | } // Ensure class closing brace is present 93 | -------------------------------------------------------------------------------- /src/rendering/document.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RenderableSpecObject, RenderContext, RenderResultItem } from './types.js'; // Add .js 3 | // No longer need ResourceContents here 4 | 5 | // Placeholder for other renderable objects we'll create 6 | // import { RenderablePaths } from './paths.js'; // Add .js 7 | // import { RenderableComponents } from './components.js'; // Add .js 8 | 9 | /** 10 | * Wraps an OpenAPIV3.Document to make it renderable. 11 | * Handles rendering for top-level fields like 'info', 'servers', etc. 12 | * Delegates list rendering for 'paths' and 'components' to respective objects. 13 | */ 14 | export class RenderableDocument implements RenderableSpecObject { 15 | // TODO: Add RenderablePaths and RenderableComponents instances 16 | // private renderablePaths: RenderablePaths; 17 | // private renderableComponents: RenderableComponents; 18 | 19 | constructor(private document: OpenAPIV3.Document) { 20 | // Initialize renderable wrappers for paths and components here 21 | // this.renderablePaths = new RenderablePaths(document.paths); 22 | // this.renderableComponents = new RenderableComponents(document.components); 23 | } 24 | 25 | /** 26 | * Renders a list view. For the document level, this is intended 27 | * to be called only when the requested field is 'paths' or 'components'. 28 | * The actual routing/delegation will happen in the handler based on the field. 29 | */ 30 | renderList(_context: RenderContext): RenderResultItem[] { 31 | // Prefix context with _ 32 | // This method should ideally not be called directly on the document 33 | // without specifying 'paths' or 'components' as the field. 34 | // The handler for openapi://{field} will delegate to the appropriate 35 | // sub-object's renderList. 36 | // Returning an error result item. 37 | return [ 38 | { 39 | uriSuffix: 'error', 40 | data: null, // No specific data for this error 41 | isError: true, 42 | errorText: 43 | 'Error: List rendering is only supported for specific fields like "paths" or "components" at the top level.', 44 | renderAsList: true, // Errors often shown as plain text 45 | }, 46 | ]; 47 | } 48 | 49 | /** 50 | * Renders the detail view. For the document level, this should not be called 51 | * directly without specifying a field. The handler should call 52 | * `renderTopLevelFieldDetail` instead. 53 | */ 54 | renderDetail(_context: RenderContext): RenderResultItem[] { 55 | // Prefix context with _ 56 | // This method implementation fulfills the interface requirement, 57 | // but direct detail rendering of the whole document isn't meaningful here. 58 | return [ 59 | { 60 | uriSuffix: 'error', 61 | data: null, 62 | isError: true, 63 | errorText: 64 | 'Error: Detail rendering requires specifying a top-level field (e.g., "info", "servers").', 65 | renderAsList: true, // Errors often shown as plain text 66 | }, 67 | ]; 68 | } 69 | 70 | /** 71 | * Renders the detail view for a *specific* top-level field (e.g., 'info', 'servers'). 72 | * This is called by the handler after identifying the field. 73 | * 74 | * @param context - The rendering context. 75 | * @param fieldObject - The actual top-level field object to render (e.g., document.info). 76 | * @param fieldName - The name of the field being rendered (e.g., 'info'). 77 | * @returns An array of RenderResultItem representing the detail view. 78 | */ 79 | renderTopLevelFieldDetail( 80 | context: RenderContext, 81 | fieldObject: unknown, 82 | fieldName: string 83 | ): RenderResultItem[] { 84 | // Ensure fieldObject is provided (handler should validate fieldName exists) 85 | if (fieldObject === undefined || fieldObject === null) { 86 | return [ 87 | { 88 | uriSuffix: fieldName, 89 | data: null, 90 | isError: true, 91 | errorText: `Error: Field "${fieldName}" not found in the OpenAPI document.`, 92 | renderAsList: true, 93 | }, 94 | ]; 95 | } 96 | 97 | // Avoid rendering structural fields that have dedicated list views 98 | if (fieldName === 'paths' || fieldName === 'components') { 99 | return [ 100 | { 101 | uriSuffix: fieldName, 102 | data: null, 103 | isError: true, 104 | errorText: `Error: Field "${fieldName}" should be accessed via its list view (${context.baseUri}${fieldName}). Use the list view first.`, 105 | renderAsList: true, 106 | }, 107 | ]; 108 | } 109 | 110 | try { 111 | // For successful detail rendering, return the data object itself. 112 | // The handler will format it using the context.formatter. 113 | return [ 114 | { 115 | uriSuffix: fieldName, 116 | data: fieldObject, // Pass the raw data 117 | // isError defaults to false 118 | // renderAsList defaults to false (meaning use detail formatter) 119 | }, 120 | ]; 121 | } catch (error: unknown) { 122 | // Handle potential errors during data access or initial checks 123 | // Formatting errors will be caught by the handler later 124 | return [ 125 | { 126 | uriSuffix: fieldName, 127 | data: null, 128 | isError: true, 129 | errorText: `Error preparing field "${fieldName}" for rendering: ${ 130 | error instanceof Error ? error.message : String(error) 131 | }`, 132 | renderAsList: true, 133 | }, 134 | ]; 135 | } 136 | } // End of renderTopLevelFieldDetail 137 | 138 | // --- Helper methods to access specific parts --- 139 | 140 | getPathsObject(): OpenAPIV3.PathsObject | undefined { 141 | return this.document.paths; 142 | } 143 | 144 | getComponentsObject(): OpenAPIV3.ComponentsObject | undefined { 145 | return this.document.components; 146 | } 147 | 148 | getTopLevelField(fieldName: string): unknown { 149 | // Define allowed top-level OpenAPI document properties 150 | const allowedFields: Array = [ 151 | 'openapi', 152 | 'info', 153 | 'servers', 154 | 'paths', 155 | 'components', 156 | 'security', 157 | 'tags', 158 | 'externalDocs', 159 | ]; 160 | 161 | // Only allow access to documented OpenAPI properties 162 | if (allowedFields.includes(fieldName as keyof OpenAPIV3.Document)) { 163 | return this.document[fieldName as keyof OpenAPIV3.Document]; 164 | } 165 | return undefined; 166 | } 167 | } // End of RenderableDocument class 168 | -------------------------------------------------------------------------------- /src/rendering/path-item.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RenderableSpecObject, RenderContext, RenderResultItem } from './types.js'; // Add .js 3 | import { getOperationSummary, createErrorResult, generateListHint } from './utils.js'; // Add .js 4 | 5 | /** 6 | * Wraps an OpenAPIV3.PathItemObject to make it renderable. 7 | * Handles rendering the list of methods for a specific path and 8 | * the details of specific operations (methods). 9 | */ 10 | export class RenderablePathItem implements RenderableSpecObject { 11 | constructor( 12 | private pathItem: OpenAPIV3.PathItemObject | undefined, 13 | private path: string, // The raw, decoded path string e.g., "/users/{userId}" 14 | private pathUriSuffix: string // Built using buildPathItemUriSuffix(path) e.g., 'paths/users%7BuserId%7D' 15 | ) {} 16 | 17 | /** 18 | * Renders a token-efficient list of methods available for this path. 19 | * Corresponds to the `openapi://paths/{path}` URI. 20 | */ 21 | renderList(context: RenderContext): RenderResultItem[] { 22 | if (!this.pathItem) { 23 | return createErrorResult(this.pathUriSuffix, 'Path item not found.'); 24 | } 25 | 26 | // Correctly check if the lowercase key is one of the enum values 27 | const methods = Object.keys(this.pathItem).filter(key => 28 | Object.values(OpenAPIV3.HttpMethods).includes(key.toLowerCase() as OpenAPIV3.HttpMethods) 29 | ) as OpenAPIV3.HttpMethods[]; 30 | 31 | // Check if methods array is empty *after* filtering 32 | if (methods.length === 0) { 33 | // Return a specific non-error message indicating no methods were found 34 | return [ 35 | { 36 | uriSuffix: this.pathUriSuffix, 37 | data: `No standard HTTP methods found for path: ${decodeURIComponent( 38 | this.pathUriSuffix.substring('paths/'.length) // Get original path for display 39 | )}`, 40 | renderAsList: true, 41 | // isError is implicitly false here 42 | }, 43 | ]; 44 | } 45 | 46 | // Sort methods first to get the correct example 47 | methods.sort(); 48 | const firstMethodExample = methods.length > 0 ? methods[0] : undefined; 49 | 50 | // Generate hint using the new structure, providing the first *sorted* method as an example 51 | const hint = generateListHint(context, { 52 | itemType: 'pathMethod', 53 | parentPath: this.path, // Use the stored raw path 54 | firstItemExample: firstMethodExample, 55 | }); 56 | // Hint includes leading newline, so start output with it directly 57 | let outputLines: string[] = [hint.trim(), '']; // Trim leading newline from hint for first line 58 | 59 | // Iterate over the already sorted methods 60 | methods.forEach(method => { 61 | const operation = this.getOperation(method); 62 | // Use summary or operationId (via getOperationSummary) 63 | const summaryText = getOperationSummary(operation); 64 | // Format as METHOD: Summary or just METHOD if no summary/opId 65 | outputLines.push(`${method.toUpperCase()}${summaryText ? `: ${summaryText}` : ''}`); 66 | }); 67 | 68 | return [ 69 | { 70 | uriSuffix: this.pathUriSuffix, 71 | data: outputLines.join('\n'), // Join lines into a single string 72 | renderAsList: true, 73 | }, 74 | ]; 75 | } 76 | 77 | /** 78 | * Renders the detail view for one or more specific operations (methods) 79 | * Renders the detail view. For a PathItem, this usually means listing 80 | * the methods, similar to renderList. The handler should call 81 | * `renderOperationDetail` for specific method details. 82 | */ 83 | renderDetail(context: RenderContext): RenderResultItem[] { 84 | // Delegate to renderList as the primary view for a path item itself. 85 | return this.renderList(context); 86 | } 87 | 88 | /** 89 | * Renders the detail view for one or more specific operations (methods) 90 | * within this path item. 91 | * Corresponds to the `openapi://paths/{path}/{method*}` URI. 92 | * This is called by the handler after identifying the method(s). 93 | * 94 | * @param context - The rendering context. 95 | * @param methods - Array of method names (e.g., ['get', 'post']). 96 | * @returns An array of RenderResultItem representing the operation details. 97 | */ 98 | renderOperationDetail( 99 | _context: RenderContext, // Context might be needed later 100 | methods: string[] 101 | ): RenderResultItem[] { 102 | if (!this.pathItem) { 103 | // Create error results for all requested methods if path item is missing 104 | return methods.map(method => ({ 105 | uriSuffix: `${this.pathUriSuffix}/${method}`, 106 | data: null, 107 | isError: true, 108 | errorText: 'Path item not found.', 109 | renderAsList: true, 110 | })); 111 | } 112 | 113 | const results: RenderResultItem[] = []; 114 | 115 | for (const method of methods) { 116 | const operation = this.getOperation(method); 117 | const operationUriSuffix = `${this.pathUriSuffix}/${method}`; 118 | 119 | if (!operation) { 120 | results.push({ 121 | uriSuffix: operationUriSuffix, 122 | data: null, 123 | isError: true, 124 | errorText: `Method "${method.toUpperCase()}" not found for path.`, 125 | renderAsList: true, 126 | }); 127 | } else { 128 | // Return the raw operation object; handler will format it 129 | results.push({ 130 | uriSuffix: operationUriSuffix, 131 | data: operation, 132 | // isError: false (default) 133 | // renderAsList: false (default) 134 | }); 135 | } 136 | } 137 | return results; 138 | } 139 | 140 | /** 141 | * Gets the OperationObject for a specific HTTP method within this path item. 142 | * Performs case-insensitive lookup. 143 | * @param method - The HTTP method string (e.g., 'get', 'POST'). 144 | * @returns The OperationObject or undefined if not found. 145 | */ 146 | getOperation(method: string): OpenAPIV3.OperationObject | undefined { 147 | if (!this.pathItem) { 148 | return undefined; 149 | } 150 | const lowerMethod = method.toLowerCase(); 151 | 152 | // Check if the key is a standard HTTP method defined in the enum 153 | if (Object.values(OpenAPIV3.HttpMethods).includes(lowerMethod as OpenAPIV3.HttpMethods)) { 154 | const operation = this.pathItem[lowerMethod as keyof OpenAPIV3.PathItemObject]; 155 | // Basic check to ensure it looks like an operation object 156 | if (typeof operation === 'object' && operation !== null && 'responses' in operation) { 157 | // The check above narrows the type sufficiently, assertion is redundant 158 | return operation; 159 | } 160 | } 161 | return undefined; // Not a valid method or not an operation object 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/rendering/paths.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RenderableSpecObject, RenderContext, RenderResultItem } from './types.js'; // Add .js 3 | 4 | /** 5 | * Wraps an OpenAPIV3.PathsObject to make it renderable. 6 | * Handles rendering the list of all paths and methods. 7 | */ 8 | export class RenderablePaths implements RenderableSpecObject { 9 | constructor(private paths: OpenAPIV3.PathsObject | undefined) {} 10 | 11 | /** 12 | * Renders a token-efficient list of all paths and their methods. 13 | * Corresponds to the `openapi://paths` URI. 14 | */ 15 | renderList(context: RenderContext): RenderResultItem[] { 16 | if (!this.paths || Object.keys(this.paths).length === 0) { 17 | return [ 18 | { 19 | uriSuffix: 'paths', 20 | data: 'No paths found in the specification.', 21 | renderAsList: true, 22 | }, 23 | ]; 24 | } 25 | 26 | // Generate hint first and prepend "Hint: " 27 | const hintText = `Use '${context.baseUri}paths/{encoded_path}' to list methods for a specific path, or '${context.baseUri}paths/{encoded_path}/{method}' to view details for a specific operation.`; 28 | let outputLines: string[] = [`Hint: ${hintText}`, '']; // Start with hint and a blank line 29 | 30 | const pathEntries = Object.entries(this.paths).sort(([pathA], [pathB]) => 31 | pathA.localeCompare(pathB) 32 | ); 33 | 34 | for (const [path, pathItem] of pathEntries) { 35 | if (!pathItem) continue; 36 | 37 | // Create a list of valid, sorted, uppercase methods for the current path 38 | const methods: string[] = []; 39 | for (const key in pathItem) { 40 | const lowerKey = key.toLowerCase(); 41 | if (Object.values(OpenAPIV3.HttpMethods).includes(lowerKey as OpenAPIV3.HttpMethods)) { 42 | // Check if it's a valid operation object before adding the method 43 | const operation = pathItem[key as keyof OpenAPIV3.PathItemObject]; 44 | if (typeof operation === 'object' && operation !== null && 'responses' in operation) { 45 | methods.push(lowerKey.toUpperCase()); 46 | } 47 | } 48 | } 49 | methods.sort(); // Sort methods alphabetically 50 | 51 | // Format the line: METHODS /path 52 | const methodsString = methods.length > 0 ? methods.join(' ') : '(No methods)'; 53 | outputLines.push(`${methodsString} ${path}`); 54 | } 55 | 56 | return [ 57 | { 58 | uriSuffix: 'paths', 59 | data: outputLines.join('\n'), // Join lines into a single string 60 | renderAsList: true, // This result is always plain text 61 | }, 62 | ]; 63 | } 64 | 65 | /** 66 | * Renders the detail view. For the Paths object level, this isn't 67 | * typically used directly. Details are requested per path or operation. 68 | */ 69 | renderDetail(context: RenderContext): RenderResultItem[] { 70 | // Delegate to renderList as the primary view for the collection of paths 71 | return this.renderList(context); 72 | } 73 | 74 | /** 75 | * Gets the PathItemObject for a specific path. 76 | * @param path - The decoded path string. 77 | * @returns The PathItemObject or undefined if not found. 78 | */ 79 | getPathItem(path: string): OpenAPIV3.PathItemObject | undefined { 80 | // Use Map for safe access 81 | if (!this.paths) { 82 | return undefined; 83 | } 84 | const pathsMap = new Map(Object.entries(this.paths)); 85 | return pathsMap.get(path); // Map.get returns ValueType | undefined 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/rendering/types.ts: -------------------------------------------------------------------------------- 1 | import { IFormatter } from '../services/formatters'; 2 | // We don't need ResourceContents/ResourceContent here anymore 3 | 4 | /** 5 | * Intermediate result structure returned by render methods. 6 | * Contains the core data needed to build the final ResourceContent. 7 | */ 8 | export interface RenderResultItem { 9 | /** The raw data object to be formatted. */ 10 | data: unknown; 11 | /** The suffix to append to the base URI (e.g., 'info', 'paths/users', 'components/schemas/User'). */ 12 | uriSuffix: string; 13 | /** Optional flag indicating an error for this specific item. */ 14 | isError?: boolean; 15 | /** Optional error message if isError is true. */ 16 | errorText?: string; 17 | /** Optional flag to indicate this should be rendered as a list (text/plain). */ 18 | renderAsList?: boolean; 19 | } 20 | 21 | /** 22 | * Context required for rendering OpenAPI specification objects. 23 | */ 24 | export interface RenderContext { 25 | /** Formatter instance for handling output (JSON/YAML). */ 26 | formatter: IFormatter; 27 | /** Base URI for generating resource links (e.g., "openapi://"). */ 28 | baseUri: string; 29 | } 30 | 31 | /** 32 | * Represents an OpenAPI specification object that can be rendered 33 | * in different formats (list or detail). 34 | */ 35 | export interface RenderableSpecObject { 36 | /** 37 | * Generates data for a token-efficient list representation. 38 | * @param context - The rendering context. 39 | * @returns An array of RenderResultItem. 40 | */ 41 | renderList(context: RenderContext): RenderResultItem[]; 42 | 43 | /** 44 | * Generates data for a detailed representation. 45 | * @param context - The rendering context. 46 | * @returns An array of RenderResultItem. 47 | */ 48 | renderDetail(context: RenderContext): RenderResultItem[]; 49 | } 50 | 51 | /** 52 | * Type guard to check if an object implements RenderableSpecObject. 53 | * @param obj - The object to check. 54 | * @returns True if the object implements RenderableSpecObject, false otherwise. 55 | */ 56 | export function isRenderableSpecObject(obj: unknown): obj is RenderableSpecObject { 57 | return ( 58 | typeof obj === 'object' && 59 | obj !== null && 60 | typeof (obj as RenderableSpecObject).renderList === 'function' && 61 | typeof (obj as RenderableSpecObject).renderDetail === 'function' 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/rendering/utils.ts: -------------------------------------------------------------------------------- 1 | // NOTE: This block replaces the previous import block to ensure types/interfaces are defined correctly. 2 | import { OpenAPIV3 } from 'openapi-types'; 3 | import { RenderContext, RenderResultItem } from './types.js'; // Add .js 4 | import { 5 | buildComponentDetailUriSuffix, 6 | buildComponentMapUriSuffix, 7 | buildOperationUriSuffix, 8 | // buildPathItemUriSuffix, // Not currently used by generateListHint 9 | } from '../utils/uri-builder.js'; // Added .js extension 10 | 11 | // Define possible types for list items to guide hint generation 12 | type ListItemType = 'componentType' | 'componentName' | 'pathMethod'; 13 | 14 | // Define context needed for generating the correct detail URI suffix 15 | interface HintContext { 16 | itemType: ListItemType; 17 | firstItemExample?: string; // Example value from the first item in the list 18 | // For componentName hints, the parent component type is needed 19 | parentComponentType?: string; 20 | // For pathMethod hints, the parent path is needed 21 | parentPath?: string; 22 | } 23 | 24 | /** 25 | * Safely retrieves the summary from an Operation object. 26 | * Handles cases where the operation might be undefined or lack a summary. 27 | * 28 | * @param operation - The Operation object or undefined. 29 | * @returns The operation summary or operationId string, truncated if necessary, or null if neither is available. 30 | */ 31 | export function getOperationSummary( 32 | operation: OpenAPIV3.OperationObject | undefined 33 | ): string | null { 34 | // Return summary or operationId without truncation 35 | return operation?.summary || operation?.operationId || null; 36 | } 37 | 38 | /** 39 | * Helper to generate a standard hint text for list views, using the centralized URI builders. 40 | * @param renderContext - The rendering context containing the base URI. 41 | * @param hintContext - Context about the type of items being listed and their parent context. 42 | * @returns The hint string. 43 | */ 44 | export function generateListHint(renderContext: RenderContext, hintContext: HintContext): string { 45 | let detailUriSuffixPattern: string; 46 | let itemTypeName: string; // User-friendly name for the item type in the hint text 47 | let exampleUriSuffix: string | undefined; // To hold the generated example URI 48 | 49 | switch (hintContext.itemType) { 50 | case 'componentType': 51 | // Listing component types (e.g., schemas, responses) at openapi://components 52 | // Hint should point to openapi://components/{type} 53 | detailUriSuffixPattern = buildComponentMapUriSuffix('{type}'); // Use placeholder 54 | itemTypeName = 'component type'; 55 | if (hintContext.firstItemExample) { 56 | exampleUriSuffix = buildComponentMapUriSuffix(hintContext.firstItemExample); 57 | } 58 | break; 59 | case 'componentName': 60 | // Listing component names (e.g., MySchema, User) at openapi://components/{type} 61 | // Hint should point to openapi://components/{type}/{name} 62 | if (!hintContext.parentComponentType) { 63 | console.warn('generateListHint called for componentName without parentComponentType'); 64 | return ''; // Avoid generating a broken hint 65 | } 66 | // Use the actual parent type and a placeholder for the name 67 | detailUriSuffixPattern = buildComponentDetailUriSuffix( 68 | hintContext.parentComponentType, 69 | '{name}' 70 | ); 71 | itemTypeName = hintContext.parentComponentType.slice(0, -1); // e.g., 'schema' from 'schemas' 72 | if (hintContext.firstItemExample) { 73 | exampleUriSuffix = buildComponentDetailUriSuffix( 74 | hintContext.parentComponentType, 75 | hintContext.firstItemExample 76 | ); 77 | } 78 | break; 79 | case 'pathMethod': 80 | // Listing methods (e.g., get, post) at openapi://paths/{path} 81 | // Hint should point to openapi://paths/{path}/{method} 82 | if (!hintContext.parentPath) { 83 | console.warn('generateListHint called for pathMethod without parentPath'); 84 | return ''; // Avoid generating a broken hint 85 | } 86 | // Use the actual parent path and a placeholder for the method 87 | detailUriSuffixPattern = buildOperationUriSuffix(hintContext.parentPath, '{method}'); 88 | itemTypeName = 'operation'; // Or 'method'? 'operation' seems clearer 89 | if (hintContext.firstItemExample) { 90 | // Ensure the example method is valid if needed, though usually it's just 'get', 'post' etc. 91 | exampleUriSuffix = buildOperationUriSuffix( 92 | hintContext.parentPath, 93 | hintContext.firstItemExample 94 | ); 95 | } 96 | break; 97 | default: 98 | // Explicitly cast to string to avoid potential 'never' type issue in template literal 99 | console.warn(`Unknown itemType in generateListHint: ${String(hintContext.itemType)}`); 100 | return ''; // Avoid generating a hint if context is unknown 101 | } 102 | 103 | // Construct the full hint URI pattern using the base URI 104 | const fullHintPattern = `${renderContext.baseUri}${detailUriSuffixPattern}`; 105 | const fullExampleUri = exampleUriSuffix 106 | ? `${renderContext.baseUri}${exampleUriSuffix}` 107 | : undefined; 108 | 109 | let hintText = `\nHint: Use '${fullHintPattern}' to view details for a specific ${itemTypeName}.`; 110 | if (fullExampleUri) { 111 | hintText += ` (e.g., ${fullExampleUri})`; 112 | } 113 | 114 | return hintText; 115 | } 116 | 117 | /** 118 | * Helper to generate a standard error item for RenderResultItem arrays. 119 | * @param uriSuffix - The URI suffix for the error context. 120 | * @param message - The error message. 121 | * @returns A RenderResultItem array containing the error. 122 | */ 123 | export function createErrorResult(uriSuffix: string, message: string): RenderResultItem[] { 124 | return [ 125 | { 126 | uriSuffix: uriSuffix, 127 | data: null, 128 | isError: true, 129 | errorText: message, 130 | renderAsList: true, // Errors are typically plain text 131 | }, 132 | ]; 133 | } 134 | -------------------------------------------------------------------------------- /src/services/formatters.ts: -------------------------------------------------------------------------------- 1 | import { dump as yamlDump } from 'js-yaml'; 2 | 3 | /** 4 | * Supported output formats 5 | */ 6 | export type OutputFormat = 'json' | 'yaml' | 'json-minified'; 7 | 8 | /** 9 | * Interface for formatters that handle different output formats 10 | */ 11 | export interface IFormatter { 12 | format(data: unknown): string; 13 | getMimeType(): string; 14 | } 15 | 16 | /** 17 | * JSON formatter with pretty printing 18 | */ 19 | export class JsonFormatter implements IFormatter { 20 | format(data: unknown): string { 21 | return JSON.stringify(data, null, 2); 22 | } 23 | 24 | getMimeType(): string { 25 | return 'application/json'; 26 | } 27 | } 28 | 29 | /** 30 | * Formats data as minified JSON. 31 | */ 32 | export class MinifiedJsonFormatter implements IFormatter { 33 | format(data: unknown): string { 34 | return JSON.stringify(data); 35 | } 36 | 37 | getMimeType(): string { 38 | return 'application/json'; 39 | } 40 | } 41 | 42 | /** 43 | * YAML formatter using js-yaml library 44 | */ 45 | export class YamlFormatter implements IFormatter { 46 | format(data: unknown): string { 47 | return yamlDump(data, { 48 | indent: 2, 49 | lineWidth: -1, // Don't wrap long lines 50 | noRefs: true, // Don't use references 51 | }); 52 | } 53 | 54 | getMimeType(): string { 55 | return 'text/yaml'; 56 | } 57 | } 58 | 59 | /** 60 | * Creates a formatter instance based on format name 61 | */ 62 | export function createFormatter(format: OutputFormat): IFormatter { 63 | switch (format) { 64 | case 'json': 65 | return new JsonFormatter(); 66 | case 'yaml': 67 | return new YamlFormatter(); 68 | case 'json-minified': 69 | return new MinifiedJsonFormatter(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/services/reference-transform.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { buildComponentDetailUri } from '../utils/uri-builder.js'; // Added .js extension 3 | 4 | export interface TransformContext { 5 | resourceType: 'endpoint' | 'schema'; 6 | format: 'openapi' | 'asyncapi' | 'graphql'; 7 | path?: string; 8 | method?: string; 9 | } 10 | 11 | export interface ReferenceObject { 12 | $ref: string; 13 | } 14 | 15 | export interface TransformedReference { 16 | $ref: string; 17 | } 18 | 19 | export interface ReferenceTransform { 20 | transformRefs(document: T, context: TransformContext): T; 21 | } 22 | 23 | export class ReferenceTransformService { 24 | private transformers = new Map>(); 25 | 26 | registerTransformer(format: string, transformer: ReferenceTransform): void { 27 | this.transformers.set(format, transformer as ReferenceTransform); 28 | } 29 | 30 | transformDocument(document: T, context: TransformContext): T { 31 | const transformer = this.transformers.get(context.format) as ReferenceTransform; 32 | if (!transformer) { 33 | throw new Error(`No transformer registered for format: ${context.format}`); 34 | } 35 | return transformer.transformRefs(document, context); 36 | } 37 | } 38 | 39 | export class OpenAPITransformer implements ReferenceTransform { 40 | // Handle nested objects recursively 41 | private transformObject(obj: unknown, _context: TransformContext): unknown { 42 | if (!obj || typeof obj !== 'object') { 43 | return obj; 44 | } 45 | 46 | // Handle arrays 47 | if (Array.isArray(obj)) { 48 | return obj.map(item => this.transformObject(item, _context)); 49 | } 50 | 51 | // Handle references 52 | if (this.isReferenceObject(obj)) { 53 | return this.transformReference(obj.$ref); 54 | } 55 | 56 | // Recursively transform object properties 57 | const result: Record = {}; 58 | if (typeof obj === 'object') { 59 | for (const [key, value] of Object.entries(obj)) { 60 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 61 | Object.defineProperty(result, key, { 62 | value: this.transformObject(value, _context), 63 | enumerable: true, 64 | writable: true, 65 | configurable: true, 66 | }); 67 | } 68 | } 69 | } 70 | return result; 71 | } 72 | 73 | private isReferenceObject(obj: unknown): obj is ReferenceObject { 74 | return typeof obj === 'object' && obj !== null && '$ref' in obj; 75 | } 76 | 77 | private transformReference(ref: string): TransformedReference { 78 | // Handle only internal references for now 79 | if (!ref.startsWith('#/')) { 80 | return { $ref: ref }; // Keep external refs as-is 81 | } 82 | 83 | // Example ref: #/components/schemas/MySchema 84 | const parts = ref.split('/'); 85 | // Check if it's an internal component reference 86 | if (parts[0] === '#' && parts[1] === 'components' && parts.length === 4) { 87 | const componentType = parts[2]; 88 | const componentName = parts[3]; 89 | 90 | // Use the centralized builder to create the correct URI 91 | const newUri = buildComponentDetailUri(componentType, componentName); 92 | return { 93 | $ref: newUri, 94 | }; 95 | } 96 | 97 | // Keep other internal references (#/paths/...) and external references as-is 98 | return { $ref: ref }; 99 | } 100 | 101 | transformRefs(document: OpenAPIV3.Document, context: TransformContext): OpenAPIV3.Document { 102 | const transformed = this.transformObject(document, context); 103 | return transformed as OpenAPIV3.Document; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/services/spec-loader.ts: -------------------------------------------------------------------------------- 1 | import * as swagger2openapi from 'swagger2openapi'; 2 | import { OpenAPI } from 'openapi-types'; 3 | import { ReferenceTransformService, TransformContext } from './reference-transform.js'; 4 | 5 | /** 6 | * Service for loading and transforming OpenAPI specifications 7 | */ 8 | export class SpecLoaderService { 9 | private specData: OpenAPI.Document | null = null; 10 | 11 | constructor( 12 | private specPath: string, 13 | private referenceTransform: ReferenceTransformService 14 | ) {} 15 | 16 | /** 17 | * Load, potentially convert (from v2), and parse the OpenAPI specification. 18 | */ 19 | async loadSpec(): Promise { 20 | const options = { 21 | patch: true, // Fix minor errors in the spec 22 | warnOnly: true, // Add warnings for non-patchable errors instead of throwing 23 | origin: this.specPath, // Helps with resolving relative references if needed 24 | source: this.specPath, 25 | }; 26 | 27 | try { 28 | let result; 29 | // Check if specPath is a URL 30 | if (this.specPath.startsWith('http://') || this.specPath.startsWith('https://')) { 31 | result = await swagger2openapi.convertUrl(this.specPath, options); 32 | } else { 33 | result = await swagger2openapi.convertFile(this.specPath, options); 34 | } 35 | 36 | // swagger2openapi returns the result in result.openapi 37 | if (!result || !result.openapi) { 38 | throw new Error('Conversion or parsing failed to produce an OpenAPI document.'); 39 | } 40 | 41 | // TODO: Check result.options?.warnings for potential issues? 42 | 43 | this.specData = result.openapi as OpenAPI.Document; // Assuming result.openapi is compatible 44 | return this.specData; 45 | } catch (error) { 46 | // Improve error message clarity 47 | let message = `Failed to load/convert OpenAPI spec from ${this.specPath}: `; 48 | if (error instanceof Error) { 49 | message += error.message; 50 | // Include stack trace if available and helpful? 51 | // console.error(error.stack); 52 | } else { 53 | message += String(error); 54 | } 55 | throw new Error(message); 56 | } 57 | } 58 | 59 | /** 60 | * Get the loaded specification 61 | */ 62 | async getSpec(): Promise { 63 | if (!this.specData) { 64 | await this.loadSpec(); 65 | } 66 | return this.specData!; 67 | } 68 | 69 | /** 70 | * Get transformed specification with MCP resource references 71 | */ 72 | async getTransformedSpec(context: TransformContext): Promise { 73 | const spec = await this.getSpec(); 74 | return this.referenceTransform.transformDocument(spec, context); 75 | } 76 | } 77 | 78 | /** 79 | * Create and initialize a new SpecLoaderService instance 80 | */ 81 | export async function createSpecLoader( 82 | specPath: string, 83 | referenceTransform: ReferenceTransformService 84 | ): Promise { 85 | const loader = new SpecLoaderService(specPath, referenceTransform); 86 | await loader.loadSpec(); 87 | return loader; 88 | } 89 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPI } from 'openapi-types'; 2 | import type { TransformContext } from './services/reference-transform.js'; 3 | 4 | /** Common HTTP methods used in OpenAPI specs */ 5 | export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'patch'; 6 | 7 | /** Interface for spec loader */ 8 | export interface SpecLoaderService { 9 | getSpec(): Promise; 10 | getTransformedSpec(context: TransformContext): Promise; 11 | } 12 | 13 | // Re-export transform types 14 | export type { TransformContext }; 15 | 16 | // Re-export OpenAPI types 17 | export type { OpenAPI }; 18 | -------------------------------------------------------------------------------- /src/utils/uri-builder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for building standardized MCP URIs for this server. 3 | */ 4 | 5 | const BASE_URI_SCHEME = 'openapi://'; 6 | 7 | /** 8 | * Encodes a string component for safe inclusion in a URI path segment. 9 | * Uses standard encodeURIComponent. 10 | * Encodes a path string for safe inclusion in a URI. 11 | * This specifically targets path strings which might contain characters 12 | * like '{', '}', etc., that need encoding when forming the URI path part. 13 | * Uses standard encodeURIComponent. 14 | * Encodes a path string for safe inclusion in a URI path segment. 15 | * This is necessary because the path segment comes from the user potentially 16 | * containing characters that need encoding (like '{', '}'). 17 | * Uses standard encodeURIComponent. 18 | * @param path The path string to encode. 19 | * @returns The encoded path string, with leading slashes removed before encoding. 20 | */ 21 | export function encodeUriPathComponent(path: string): string { 22 | // Added export 23 | // Remove leading slashes before encoding 24 | const pathWithoutLeadingSlash = path.replace(/^\/+/, ''); 25 | return encodeURIComponent(pathWithoutLeadingSlash); 26 | } 27 | 28 | // --- Full URI Builders --- 29 | 30 | /** 31 | * Builds the URI for accessing a specific component's details. 32 | * Example: openapi://components/schemas/MySchema 33 | * @param type The component type (e.g., 'schemas', 'responses'). 34 | * @param name The component name. 35 | * @returns The full component detail URI. 36 | */ 37 | export function buildComponentDetailUri(type: string, name: string): string { 38 | // Per user instruction, do not encode type or name here. 39 | return `${BASE_URI_SCHEME}components/${type}/${name}`; 40 | } 41 | 42 | /** 43 | * Builds the URI for listing components of a specific type. 44 | * Example: openapi://components/schemas 45 | * @param type The component type (e.g., 'schemas', 'responses'). 46 | * @returns The full component map URI. 47 | */ 48 | export function buildComponentMapUri(type: string): string { 49 | // Per user instruction, do not encode type here. 50 | return `${BASE_URI_SCHEME}components/${type}`; 51 | } 52 | 53 | /** 54 | * Builds the URI for accessing a specific operation's details. 55 | * Example: openapi://paths/users/{userId}/GET 56 | * @param path The API path (e.g., '/users/{userId}'). 57 | * @param method The HTTP method (e.g., 'GET', 'POST'). 58 | * @returns The full operation detail URI. 59 | */ 60 | export function buildOperationUri(path: string, method: string): string { 61 | // Encode only the path component. Assume 'path' is raw/decoded. 62 | // Method is assumed to be safe or handled by SDK/client. 63 | return `${BASE_URI_SCHEME}paths/${encodeUriPathComponent(path)}/${method.toLowerCase()}`; // Standardize method to lowercase 64 | } 65 | 66 | /** 67 | * Builds the URI for listing methods available at a specific path. 68 | * Example: openapi://paths/users/{userId} 69 | * @param path The API path (e.g., '/users/{userId}'). 70 | * @returns The full path item URI. 71 | */ 72 | export function buildPathItemUri(path: string): string { 73 | // Encode only the path component. Assume 'path' is raw/decoded. 74 | return `${BASE_URI_SCHEME}paths/${encodeUriPathComponent(path)}`; 75 | } 76 | 77 | /** 78 | * Builds the URI for accessing a top-level field (like 'info' or 'servers') 79 | * or triggering a list view ('paths', 'components'). 80 | * Example: openapi://info, openapi://paths 81 | * @param field The top-level field name. 82 | * @returns The full top-level field URI. 83 | */ 84 | export function buildTopLevelFieldUri(field: string): string { 85 | // Per user instruction, do not encode field here. 86 | return `${BASE_URI_SCHEME}${field}`; 87 | } 88 | 89 | // --- URI Suffix Builders (for RenderResultItem) --- 90 | 91 | /** 92 | * Builds the URI suffix for a specific component's details. 93 | * Example: components/schemas/MySchema 94 | */ 95 | export function buildComponentDetailUriSuffix(type: string, name: string): string { 96 | // Per user instruction, do not encode type or name here. 97 | return `components/${type}/${name}`; 98 | } 99 | 100 | /** 101 | * Builds the URI suffix for listing components of a specific type. 102 | * Example: components/schemas 103 | */ 104 | export function buildComponentMapUriSuffix(type: string): string { 105 | // Per user instruction, do not encode type here. 106 | return `components/${type}`; 107 | } 108 | 109 | /** 110 | * Builds the URI suffix for a specific operation's details. 111 | * Example: paths/users/{userId}/get 112 | */ 113 | export function buildOperationUriSuffix(path: string, method: string): string { 114 | // Encode only the path component for the suffix. Assume 'path' is raw/decoded. 115 | return `paths/${encodeUriPathComponent(path)}/${method.toLowerCase()}`; 116 | } 117 | 118 | /** 119 | * Builds the URI suffix for listing methods available at a specific path. 120 | * Example: paths/users/{userId} 121 | */ 122 | export function buildPathItemUriSuffix(path: string): string { 123 | // Encode only the path component for the suffix. Assume 'path' is raw/decoded. 124 | return `paths/${encodeUriPathComponent(path)}`; 125 | } 126 | 127 | /** 128 | * Builds the URI suffix for a top-level field. 129 | * Example: info, paths 130 | */ 131 | export function buildTopLevelFieldUriSuffix(field: string): string { 132 | // Per user instruction, do not encode field here. 133 | return field; 134 | } 135 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | // Auto-generated by scripts/generate-version.js during semantic-release prepare step 2 | // Do not edit this file manually. 3 | 4 | export const VERSION = '1.2.1'; 5 | -------------------------------------------------------------------------------- /test/__tests__/e2e/spec-loading.test.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { ReadResourceResult, TextResourceContents } from '@modelcontextprotocol/sdk/types.js'; 3 | import { startMcpServer, McpTestContext } from '../../utils/mcp-test-helpers'; 4 | import path from 'path'; 5 | 6 | // Helper function to parse JSON safely 7 | function parseJsonSafely(text: string | undefined): unknown { 8 | if (text === undefined) { 9 | throw new Error('Received undefined text for JSON parsing'); 10 | } 11 | try { 12 | return JSON.parse(text); 13 | } catch (e) { 14 | console.error('Failed to parse JSON:', text); 15 | throw new Error(`Invalid JSON received: ${e instanceof Error ? e.message : String(e)}`); 16 | } 17 | } 18 | 19 | // Type guard to check if content is TextResourceContents 20 | function hasTextContent( 21 | content: ReadResourceResult['contents'][0] 22 | ): content is TextResourceContents { 23 | return typeof (content as TextResourceContents).text === 'string'; 24 | } 25 | 26 | describe('E2E Tests for Spec Loading Scenarios', () => { 27 | let testContext: McpTestContext | null = null; // Allow null for cleanup 28 | let client: Client | null = null; // Allow null 29 | 30 | // Helper to setup client for tests, allowing different spec paths 31 | async function setup(specPathOrUrl: string): Promise { 32 | // Cleanup previous context if exists 33 | if (testContext) { 34 | await testContext.cleanup(); 35 | testContext = null; 36 | client = null; 37 | } 38 | try { 39 | testContext = await startMcpServer(specPathOrUrl, { outputFormat: 'json' }); 40 | client = testContext.client; 41 | } catch (error) { 42 | // Explicitly convert error to string for logging 43 | const errorMsg = error instanceof Error ? error.message : String(error); 44 | console.warn(`Skipping tests for ${specPathOrUrl} due to setup error: ${errorMsg}`); 45 | testContext = null; // Ensure cleanup doesn't run on failed setup 46 | client = null; // Ensure tests are skipped 47 | } 48 | } 49 | 50 | afterEach(async () => { 51 | await testContext?.cleanup(); 52 | testContext = null; 53 | client = null; 54 | }); 55 | 56 | // Helper to read resource and perform basic checks 57 | async function readResourceAndCheck(uri: string): Promise { 58 | if (!client) throw new Error('Client not initialized, skipping test.'); 59 | const result = await client.readResource({ uri }); 60 | expect(result.contents).toHaveLength(1); 61 | const content = result.contents[0]; 62 | expect(content.uri).toBe(uri); 63 | return content; 64 | } 65 | 66 | // Helper to read resource and check for text/plain list content 67 | async function checkTextListResponse(uri: string, expectedSubstrings: string[]): Promise { 68 | const content = await readResourceAndCheck(uri); 69 | expect(content.mimeType).toBe('text/plain'); 70 | expect(content.isError).toBeFalsy(); 71 | if (!hasTextContent(content)) throw new Error('Expected text content'); 72 | for (const sub of expectedSubstrings) { 73 | expect(content.text).toContain(sub); 74 | } 75 | return content.text; 76 | } 77 | 78 | // Helper to read resource and check for JSON detail content 79 | async function checkJsonDetailResponse(uri: string, expectedObject: object): Promise { 80 | const content = await readResourceAndCheck(uri); 81 | expect(content.mimeType).toBe('application/json'); 82 | expect(content.isError).toBeFalsy(); 83 | if (!hasTextContent(content)) throw new Error('Expected text content'); 84 | const data = parseJsonSafely(content.text); 85 | expect(data).toMatchObject(expectedObject); 86 | return data; 87 | } 88 | 89 | // --- Tests for Local Swagger v2.0 Spec --- 90 | describe('Local Swagger v2.0 Spec (sample-v2-api.json)', () => { 91 | const v2SpecPath = path.resolve(__dirname, '../../fixtures/sample-v2-api.json'); 92 | 93 | beforeAll(async () => await setup(v2SpecPath)); // Use beforeAll for this block 94 | 95 | it('should retrieve the converted "info" field', async () => { 96 | if (!client) return; // Skip if setup failed 97 | await checkJsonDetailResponse('openapi://info', { 98 | title: 'Simple Swagger 2.0 API', 99 | version: '1.0.0', 100 | }); 101 | }); 102 | 103 | it('should retrieve the converted "paths" list', async () => { 104 | if (!client) return; // Skip if setup failed 105 | await checkTextListResponse('openapi://paths', [ 106 | 'Hint:', 107 | 'GET /v2/ping', // Note the basePath is included 108 | ]); 109 | }); 110 | 111 | it('should retrieve the converted "components" list', async () => { 112 | if (!client) return; // Skip if setup failed 113 | await checkTextListResponse('openapi://components', [ 114 | 'Available Component Types:', 115 | '- schemas', 116 | "Hint: Use 'openapi://components/{type}'", 117 | ]); 118 | }); 119 | 120 | it('should get details for converted schema Pong', async () => { 121 | if (!client) return; // Skip if setup failed 122 | await checkJsonDetailResponse('openapi://components/schemas/Pong', { 123 | type: 'object', 124 | properties: { message: { type: 'string', example: 'pong' } }, 125 | }); 126 | }); 127 | }); 128 | 129 | // --- Tests for Remote OpenAPI v3.0 Spec (Petstore) --- 130 | // Increase timeout for remote fetch 131 | jest.setTimeout(20000); // 20 seconds 132 | 133 | describe('Remote OpenAPI v3.0 Spec (Petstore)', () => { 134 | const petstoreUrl = 'https://petstore3.swagger.io/api/v3/openapi.json'; 135 | 136 | beforeAll(async () => await setup(petstoreUrl)); // Use beforeAll for this block 137 | 138 | it('should retrieve the "info" field from Petstore', async () => { 139 | if (!client) return; // Skip if setup failed 140 | await checkJsonDetailResponse('openapi://info', { 141 | title: 'Swagger Petstore - OpenAPI 3.0', 142 | // version might change, so don't assert exact value 143 | }); 144 | }); 145 | 146 | it('should retrieve the "paths" list from Petstore', async () => { 147 | if (!client) return; // Skip if setup failed 148 | // Check for a known path 149 | await checkTextListResponse('openapi://paths', ['/pet/{petId}']); 150 | }); 151 | 152 | it('should retrieve the "components" list from Petstore', async () => { 153 | if (!client) return; // Skip if setup failed 154 | // Check for known component types 155 | await checkTextListResponse('openapi://components', [ 156 | '- schemas', 157 | '- requestBodies', 158 | '- securitySchemes', 159 | ]); 160 | }); 161 | 162 | it('should get details for schema Pet from Petstore', async () => { 163 | if (!client) return; // Skip if setup failed 164 | await checkJsonDetailResponse('openapi://components/schemas/Pet', { 165 | required: ['name', 'photoUrls'], 166 | type: 'object', 167 | // Check a known property 168 | properties: { id: { type: 'integer', format: 'int64' } }, 169 | }); 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /test/__tests__/unit/config.test.ts: -------------------------------------------------------------------------------- 1 | import { loadConfig } from '../../../src/config.js'; 2 | 3 | describe('Config', () => { 4 | describe('loadConfig', () => { 5 | it('returns valid configuration with default format when only path is provided', () => { 6 | const config = loadConfig('/path/to/spec.json'); 7 | expect(config).toEqual({ 8 | specPath: '/path/to/spec.json', 9 | outputFormat: 'json', 10 | }); 11 | }); 12 | 13 | it('returns valid configuration when path and format are provided', () => { 14 | const config = loadConfig('/path/to/spec.json', { outputFormat: 'yaml' }); 15 | expect(config).toEqual({ 16 | specPath: '/path/to/spec.json', 17 | outputFormat: 'yaml', 18 | }); 19 | }); 20 | 21 | it('throws error when invalid format is provided', () => { 22 | expect(() => loadConfig('/path/to/spec.json', { outputFormat: 'invalid' })).toThrow( 23 | 'Invalid output format. Supported formats: json, yaml' 24 | ); 25 | }); 26 | 27 | it('throws error when path is not provided', () => { 28 | expect(() => loadConfig()).toThrow( 29 | 'OpenAPI spec path is required. Usage: npx mcp-openapi-schema-explorer ' 30 | ); 31 | }); 32 | 33 | it('throws error when path is empty string', () => { 34 | expect(() => loadConfig('')).toThrow( 35 | 'OpenAPI spec path is required. Usage: npx mcp-openapi-schema-explorer ' 36 | ); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/__tests__/unit/handlers/component-map-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RequestId } from '@modelcontextprotocol/sdk/types.js'; 3 | import { ComponentMapHandler } from '../../../../src/handlers/component-map-handler'; 4 | import { SpecLoaderService } from '../../../../src/types'; 5 | import { IFormatter, JsonFormatter } from '../../../../src/services/formatters'; 6 | import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 8 | import { suppressExpectedConsoleError } from '../../../utils/console-helpers'; 9 | 10 | // Mocks 11 | const mockGetTransformedSpec = jest.fn(); 12 | const mockSpecLoader: SpecLoaderService = { 13 | getSpec: jest.fn(), 14 | getTransformedSpec: mockGetTransformedSpec, 15 | }; 16 | 17 | const mockFormatter: IFormatter = new JsonFormatter(); // Needed for context 18 | 19 | // Sample Data 20 | const sampleSpec: OpenAPIV3.Document = { 21 | openapi: '3.0.3', 22 | info: { title: 'Test API', version: '1.0.0' }, 23 | paths: {}, 24 | components: { 25 | schemas: { 26 | User: { type: 'object', properties: { name: { type: 'string' } } }, 27 | Error: { type: 'object', properties: { message: { type: 'string' } } }, 28 | }, 29 | parameters: { 30 | limitParam: { name: 'limit', in: 'query', schema: { type: 'integer' } }, 31 | }, 32 | examples: {}, // Empty type 33 | }, 34 | }; 35 | 36 | describe('ComponentMapHandler', () => { 37 | let handler: ComponentMapHandler; 38 | 39 | beforeEach(() => { 40 | handler = new ComponentMapHandler(mockSpecLoader, mockFormatter); 41 | mockGetTransformedSpec.mockReset(); 42 | mockGetTransformedSpec.mockResolvedValue(sampleSpec); // Default mock 43 | }); 44 | 45 | it('should return the correct template', () => { 46 | const template = handler.getTemplate(); 47 | expect(template).toBeInstanceOf(ResourceTemplate); 48 | expect(template.uriTemplate.toString()).toBe('openapi://components/{type}'); 49 | }); 50 | 51 | describe('handleRequest (List Component Names)', () => { 52 | const mockExtra = { 53 | signal: new AbortController().signal, 54 | sendNotification: jest.fn(), 55 | sendRequest: jest.fn(), 56 | requestId: 'test-request-id' as RequestId, 57 | }; 58 | 59 | it('should list names for a valid component type (schemas)', async () => { 60 | const variables: Variables = { type: 'schemas' }; 61 | const uri = new URL('openapi://components/schemas'); 62 | 63 | const result = await handler.handleRequest(uri, variables, mockExtra); 64 | 65 | expect(mockGetTransformedSpec).toHaveBeenCalledWith({ 66 | resourceType: 'schema', 67 | format: 'openapi', 68 | }); 69 | expect(result.contents).toHaveLength(1); 70 | expect(result.contents[0]).toMatchObject({ 71 | uri: 'openapi://components/schemas', 72 | mimeType: 'text/plain', 73 | isError: false, 74 | }); 75 | expect(result.contents[0].text).toContain('Available schemas:'); 76 | expect(result.contents[0].text).toMatch(/-\sError\n/); // Sorted 77 | expect(result.contents[0].text).toMatch(/-\sUser\n/); 78 | expect(result.contents[0].text).toContain("Hint: Use 'openapi://components/schemas/{name}'"); 79 | }); 80 | 81 | it('should list names for another valid type (parameters)', async () => { 82 | const variables: Variables = { type: 'parameters' }; 83 | const uri = new URL('openapi://components/parameters'); 84 | 85 | const result = await handler.handleRequest(uri, variables, mockExtra); 86 | 87 | expect(result.contents).toHaveLength(1); 88 | expect(result.contents[0]).toMatchObject({ 89 | uri: 'openapi://components/parameters', 90 | mimeType: 'text/plain', 91 | isError: false, 92 | }); 93 | expect(result.contents[0].text).toContain('Available parameters:'); 94 | expect(result.contents[0].text).toMatch(/-\slimitParam\n/); 95 | expect(result.contents[0].text).toContain( 96 | "Hint: Use 'openapi://components/parameters/{name}'" 97 | ); 98 | }); 99 | 100 | it('should handle component type with no components defined (examples)', async () => { 101 | const variables: Variables = { type: 'examples' }; 102 | const uri = new URL('openapi://components/examples'); 103 | 104 | const result = await handler.handleRequest(uri, variables, mockExtra); 105 | 106 | expect(result.contents).toHaveLength(1); 107 | expect(result.contents[0]).toEqual({ 108 | uri: 'openapi://components/examples', 109 | mimeType: 'text/plain', 110 | text: 'No components of type "examples" found.', 111 | isError: true, // Treat as error because map exists but is empty 112 | }); 113 | }); 114 | 115 | it('should handle component type not present in spec (securitySchemes)', async () => { 116 | const variables: Variables = { type: 'securitySchemes' }; 117 | const uri = new URL('openapi://components/securitySchemes'); 118 | const expectedLogMessage = /Component type "securitySchemes" not found/; 119 | 120 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 121 | handler.handleRequest(uri, variables, mockExtra) 122 | ); 123 | 124 | expect(result.contents).toHaveLength(1); 125 | // Expect the specific error message from getValidatedComponentMap 126 | expect(result.contents[0]).toEqual({ 127 | uri: 'openapi://components/securitySchemes', 128 | mimeType: 'text/plain', 129 | text: 'Component type "securitySchemes" not found in the specification. Available types: schemas, parameters, examples', 130 | isError: true, 131 | }); 132 | }); 133 | 134 | it('should return error for invalid component type', async () => { 135 | const variables: Variables = { type: 'invalidType' }; 136 | const uri = new URL('openapi://components/invalidType'); 137 | const expectedLogMessage = /Invalid component type: invalidType/; 138 | 139 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 140 | handler.handleRequest(uri, variables, mockExtra) 141 | ); 142 | 143 | expect(result.contents).toHaveLength(1); 144 | expect(result.contents[0]).toEqual({ 145 | uri: 'openapi://components/invalidType', 146 | mimeType: 'text/plain', 147 | text: 'Invalid component type: invalidType', 148 | isError: true, 149 | }); 150 | expect(mockGetTransformedSpec).not.toHaveBeenCalled(); // Should fail before loading spec 151 | }); 152 | 153 | it('should handle spec loading errors', async () => { 154 | const error = new Error('Spec load failed'); 155 | mockGetTransformedSpec.mockRejectedValue(error); 156 | const variables: Variables = { type: 'schemas' }; 157 | const uri = new URL('openapi://components/schemas'); 158 | const expectedLogMessage = /Spec load failed/; 159 | 160 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 161 | handler.handleRequest(uri, variables, mockExtra) 162 | ); 163 | 164 | expect(result.contents).toHaveLength(1); 165 | expect(result.contents[0]).toEqual({ 166 | uri: 'openapi://components/schemas', 167 | mimeType: 'text/plain', 168 | text: 'Spec load failed', 169 | isError: true, 170 | }); 171 | }); 172 | 173 | it('should handle non-OpenAPI v3 spec', async () => { 174 | const invalidSpec = { swagger: '2.0', info: {} }; 175 | mockGetTransformedSpec.mockResolvedValue(invalidSpec as unknown as OpenAPIV3.Document); 176 | const variables: Variables = { type: 'schemas' }; 177 | const uri = new URL('openapi://components/schemas'); 178 | const expectedLogMessage = /Only OpenAPI v3 specifications are supported/; 179 | 180 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 181 | handler.handleRequest(uri, variables, mockExtra) 182 | ); 183 | 184 | expect(result.contents).toHaveLength(1); 185 | expect(result.contents[0]).toEqual({ 186 | uri: 'openapi://components/schemas', 187 | mimeType: 'text/plain', 188 | text: 'Only OpenAPI v3 specifications are supported', 189 | isError: true, 190 | }); 191 | }); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /test/__tests__/unit/handlers/operation-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RequestId } from '@modelcontextprotocol/sdk/types.js'; 3 | import { OperationHandler } from '../../../../src/handlers/operation-handler'; 4 | import { SpecLoaderService } from '../../../../src/types'; 5 | import { IFormatter, JsonFormatter } from '../../../../src/services/formatters'; 6 | import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 8 | import { suppressExpectedConsoleError } from '../../../utils/console-helpers'; 9 | 10 | // Mocks 11 | const mockGetTransformedSpec = jest.fn(); 12 | const mockSpecLoader: SpecLoaderService = { 13 | getSpec: jest.fn(), 14 | getTransformedSpec: mockGetTransformedSpec, 15 | }; 16 | 17 | const mockFormatter: IFormatter = new JsonFormatter(); 18 | 19 | // Sample Data 20 | const getOperation: OpenAPIV3.OperationObject = { 21 | summary: 'Get Item', 22 | responses: { '200': { description: 'OK' } }, 23 | }; 24 | const postOperation: OpenAPIV3.OperationObject = { 25 | summary: 'Create Item', 26 | responses: { '201': { description: 'Created' } }, 27 | }; 28 | const sampleSpec: OpenAPIV3.Document = { 29 | openapi: '3.0.3', 30 | info: { title: 'Test API', version: '1.0.0' }, 31 | paths: { 32 | '/items': { 33 | get: getOperation, 34 | post: postOperation, 35 | }, 36 | '/items/{id}': { 37 | get: { summary: 'Get Single Item', responses: { '200': { description: 'OK' } } }, 38 | }, 39 | }, 40 | components: {}, 41 | }; 42 | 43 | const encodedPathItems = encodeURIComponent('items'); 44 | const encodedPathNonExistent = encodeURIComponent('nonexistent'); 45 | 46 | describe('OperationHandler', () => { 47 | let handler: OperationHandler; 48 | 49 | beforeEach(() => { 50 | handler = new OperationHandler(mockSpecLoader, mockFormatter); 51 | mockGetTransformedSpec.mockReset(); 52 | mockGetTransformedSpec.mockResolvedValue(sampleSpec); // Default mock 53 | }); 54 | 55 | it('should return the correct template', () => { 56 | const template = handler.getTemplate(); 57 | expect(template).toBeInstanceOf(ResourceTemplate); 58 | expect(template.uriTemplate.toString()).toBe('openapi://paths/{path}/{method*}'); 59 | }); 60 | 61 | describe('handleRequest', () => { 62 | const mockExtra = { 63 | signal: new AbortController().signal, 64 | sendNotification: jest.fn(), 65 | sendRequest: jest.fn(), 66 | requestId: 'test-request-id' as RequestId, 67 | }; 68 | 69 | it('should return detail for a single valid method', async () => { 70 | const variables: Variables = { path: encodedPathItems, method: 'get' }; // Use 'method' key 71 | const uri = new URL(`openapi://paths/${encodedPathItems}/get`); 72 | 73 | const result = await handler.handleRequest(uri, variables, mockExtra); 74 | 75 | expect(mockGetTransformedSpec).toHaveBeenCalledWith({ 76 | resourceType: 'schema', 77 | format: 'openapi', 78 | }); 79 | expect(result.contents).toHaveLength(1); 80 | expect(result.contents[0]).toEqual({ 81 | uri: `openapi://paths/${encodedPathItems}/get`, 82 | mimeType: 'application/json', 83 | text: JSON.stringify(getOperation, null, 2), 84 | isError: false, 85 | }); 86 | }); 87 | 88 | it('should return details for multiple valid methods (array input)', async () => { 89 | const variables: Variables = { path: encodedPathItems, method: ['get', 'post'] }; // Use 'method' key with array 90 | const uri = new URL(`openapi://paths/${encodedPathItems}/get,post`); // URI might not reflect array input 91 | 92 | const result = await handler.handleRequest(uri, variables, mockExtra); 93 | 94 | expect(result.contents).toHaveLength(2); 95 | expect(result.contents).toContainEqual({ 96 | uri: `openapi://paths/${encodedPathItems}/get`, 97 | mimeType: 'application/json', 98 | text: JSON.stringify(getOperation, null, 2), 99 | isError: false, 100 | }); 101 | expect(result.contents).toContainEqual({ 102 | uri: `openapi://paths/${encodedPathItems}/post`, 103 | mimeType: 'application/json', 104 | text: JSON.stringify(postOperation, null, 2), 105 | isError: false, 106 | }); 107 | }); 108 | 109 | it('should return error for non-existent path', async () => { 110 | const variables: Variables = { path: encodedPathNonExistent, method: 'get' }; 111 | const uri = new URL(`openapi://paths/${encodedPathNonExistent}/get`); 112 | const expectedLogMessage = /Path "\/nonexistent" not found/; 113 | 114 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 115 | handler.handleRequest(uri, variables, mockExtra) 116 | ); 117 | 118 | expect(result.contents).toHaveLength(1); 119 | // Expect the specific error message from getValidatedPathItem 120 | expect(result.contents[0]).toEqual({ 121 | uri: `openapi://paths/${encodedPathNonExistent}/get`, 122 | mimeType: 'text/plain', 123 | text: 'Path "/nonexistent" not found in the specification.', 124 | isError: true, 125 | }); 126 | }); 127 | 128 | it('should return error for non-existent method', async () => { 129 | const variables: Variables = { path: encodedPathItems, method: 'put' }; 130 | const uri = new URL(`openapi://paths/${encodedPathItems}/put`); 131 | const expectedLogMessage = /None of the requested methods \(put\) are valid/; 132 | 133 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 134 | handler.handleRequest(uri, variables, mockExtra) 135 | ); 136 | 137 | expect(result.contents).toHaveLength(1); 138 | // Expect the specific error message from getValidatedOperations 139 | expect(result.contents[0]).toEqual({ 140 | uri: `openapi://paths/${encodedPathItems}/put`, 141 | mimeType: 'text/plain', 142 | text: 'None of the requested methods (put) are valid for path "/items". Available methods: get, post', 143 | isError: true, 144 | }); 145 | }); 146 | 147 | // Remove test for mix of valid/invalid methods, as getValidatedOperations throws now 148 | // it('should handle mix of valid and invalid methods', async () => { ... }); 149 | 150 | it('should handle empty method array', async () => { 151 | const variables: Variables = { path: encodedPathItems, method: [] }; 152 | const uri = new URL(`openapi://paths/${encodedPathItems}/`); 153 | const expectedLogMessage = /No valid HTTP method specified/; 154 | 155 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 156 | handler.handleRequest(uri, variables, mockExtra) 157 | ); 158 | 159 | expect(result.contents).toHaveLength(1); 160 | expect(result.contents[0]).toEqual({ 161 | uri: `openapi://paths/${encodedPathItems}/`, 162 | mimeType: 'text/plain', 163 | text: 'No valid HTTP method specified.', 164 | isError: true, 165 | }); 166 | }); 167 | 168 | it('should handle spec loading errors', async () => { 169 | const error = new Error('Spec load failed'); 170 | mockGetTransformedSpec.mockRejectedValue(error); 171 | const variables: Variables = { path: encodedPathItems, method: 'get' }; 172 | const uri = new URL(`openapi://paths/${encodedPathItems}/get`); 173 | const expectedLogMessage = /Spec load failed/; 174 | 175 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 176 | handler.handleRequest(uri, variables, mockExtra) 177 | ); 178 | 179 | expect(result.contents).toHaveLength(1); 180 | expect(result.contents[0]).toEqual({ 181 | uri: `openapi://paths/${encodedPathItems}/get`, 182 | mimeType: 'text/plain', 183 | text: 'Spec load failed', 184 | isError: true, 185 | }); 186 | }); 187 | 188 | it('should handle non-OpenAPI v3 spec', async () => { 189 | const invalidSpec = { swagger: '2.0', info: {} }; 190 | mockGetTransformedSpec.mockResolvedValue(invalidSpec as unknown as OpenAPIV3.Document); 191 | const variables: Variables = { path: encodedPathItems, method: 'get' }; 192 | const uri = new URL(`openapi://paths/${encodedPathItems}/get`); 193 | const expectedLogMessage = /Only OpenAPI v3 specifications are supported/; 194 | 195 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 196 | handler.handleRequest(uri, variables, mockExtra) 197 | ); 198 | 199 | expect(result.contents).toHaveLength(1); 200 | expect(result.contents[0]).toEqual({ 201 | uri: `openapi://paths/${encodedPathItems}/get`, 202 | mimeType: 'text/plain', 203 | text: 'Only OpenAPI v3 specifications are supported', 204 | isError: true, 205 | }); 206 | }); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /test/__tests__/unit/handlers/path-item-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RequestId } from '@modelcontextprotocol/sdk/types.js'; 3 | import { PathItemHandler } from '../../../../src/handlers/path-item-handler'; 4 | import { SpecLoaderService } from '../../../../src/types'; 5 | import { IFormatter, JsonFormatter } from '../../../../src/services/formatters'; 6 | import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 8 | import { suppressExpectedConsoleError } from '../../../utils/console-helpers'; 9 | 10 | // Mocks 11 | const mockGetTransformedSpec = jest.fn(); 12 | const mockSpecLoader: SpecLoaderService = { 13 | getSpec: jest.fn(), 14 | getTransformedSpec: mockGetTransformedSpec, 15 | }; 16 | 17 | const mockFormatter: IFormatter = new JsonFormatter(); // Needed for context 18 | 19 | // Sample Data 20 | const samplePathItem: OpenAPIV3.PathItemObject = { 21 | get: { summary: 'Get Item', responses: { '200': { description: 'OK' } } }, 22 | post: { summary: 'Create Item', responses: { '201': { description: 'Created' } } }, 23 | }; 24 | const sampleSpec: OpenAPIV3.Document = { 25 | openapi: '3.0.3', 26 | info: { title: 'Test API', version: '1.0.0' }, 27 | paths: { 28 | '/items': samplePathItem, 29 | '/empty': {}, // Path with no methods 30 | }, 31 | components: {}, 32 | }; 33 | 34 | const encodedPathItems = encodeURIComponent('items'); 35 | const encodedPathEmpty = encodeURIComponent('empty'); 36 | const encodedPathNonExistent = encodeURIComponent('nonexistent'); 37 | 38 | describe('PathItemHandler', () => { 39 | let handler: PathItemHandler; 40 | 41 | beforeEach(() => { 42 | handler = new PathItemHandler(mockSpecLoader, mockFormatter); 43 | mockGetTransformedSpec.mockReset(); 44 | mockGetTransformedSpec.mockResolvedValue(sampleSpec); // Default mock 45 | }); 46 | 47 | it('should return the correct template', () => { 48 | const template = handler.getTemplate(); 49 | expect(template).toBeInstanceOf(ResourceTemplate); 50 | expect(template.uriTemplate.toString()).toBe('openapi://paths/{path}'); 51 | }); 52 | 53 | describe('handleRequest (List Methods)', () => { 54 | const mockExtra = { 55 | signal: new AbortController().signal, 56 | sendNotification: jest.fn(), 57 | sendRequest: jest.fn(), 58 | requestId: 'test-request-id' as RequestId, 59 | }; 60 | 61 | it('should list methods for a valid path', async () => { 62 | const variables: Variables = { path: encodedPathItems }; 63 | const uri = new URL(`openapi://paths/${encodedPathItems}`); 64 | 65 | const result = await handler.handleRequest(uri, variables, mockExtra); 66 | 67 | expect(mockGetTransformedSpec).toHaveBeenCalledWith({ 68 | resourceType: 'schema', 69 | format: 'openapi', 70 | }); 71 | expect(result.contents).toHaveLength(1); 72 | expect(result.contents[0]).toMatchObject({ 73 | uri: `openapi://paths/${encodedPathItems}`, 74 | mimeType: 'text/plain', 75 | isError: false, 76 | }); 77 | // Check for hint first, then methods 78 | expect(result.contents[0].text).toContain("Hint: Use 'openapi://paths/items/{method}'"); 79 | expect(result.contents[0].text).toContain('GET: Get Item'); 80 | expect(result.contents[0].text).toContain('POST: Create Item'); 81 | // Ensure the old "Methods for..." header is not present if hint is first 82 | expect(result.contents[0].text).not.toContain('Methods for items:'); 83 | }); 84 | 85 | it('should handle path with no methods', async () => { 86 | const variables: Variables = { path: encodedPathEmpty }; 87 | const uri = new URL(`openapi://paths/${encodedPathEmpty}`); 88 | 89 | const result = await handler.handleRequest(uri, variables, mockExtra); 90 | 91 | expect(result.contents).toHaveLength(1); 92 | expect(result.contents[0]).toEqual({ 93 | uri: `openapi://paths/${encodedPathEmpty}`, 94 | mimeType: 'text/plain', 95 | text: 'No standard HTTP methods found for path: empty', 96 | isError: false, // Not an error, just no methods 97 | }); 98 | }); 99 | 100 | it('should return error for non-existent path', async () => { 101 | const variables: Variables = { path: encodedPathNonExistent }; 102 | const uri = new URL(`openapi://paths/${encodedPathNonExistent}`); 103 | const expectedLogMessage = /Path "\/nonexistent" not found/; 104 | 105 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 106 | handler.handleRequest(uri, variables, mockExtra) 107 | ); 108 | 109 | expect(result.contents).toHaveLength(1); 110 | // Expect the specific error message from getValidatedPathItem 111 | expect(result.contents[0]).toEqual({ 112 | uri: `openapi://paths/${encodedPathNonExistent}`, 113 | mimeType: 'text/plain', 114 | text: 'Path "/nonexistent" not found in the specification.', 115 | isError: true, 116 | }); 117 | }); 118 | 119 | it('should handle spec loading errors', async () => { 120 | const error = new Error('Spec load failed'); 121 | mockGetTransformedSpec.mockRejectedValue(error); 122 | const variables: Variables = { path: encodedPathItems }; 123 | const uri = new URL(`openapi://paths/${encodedPathItems}`); 124 | const expectedLogMessage = /Spec load failed/; 125 | 126 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 127 | handler.handleRequest(uri, variables, mockExtra) 128 | ); 129 | 130 | expect(result.contents).toHaveLength(1); 131 | expect(result.contents[0]).toEqual({ 132 | uri: `openapi://paths/${encodedPathItems}`, 133 | mimeType: 'text/plain', 134 | text: 'Spec load failed', 135 | isError: true, 136 | }); 137 | }); 138 | 139 | it('should handle non-OpenAPI v3 spec', async () => { 140 | const invalidSpec = { swagger: '2.0', info: {} }; 141 | mockGetTransformedSpec.mockResolvedValue(invalidSpec as unknown as OpenAPIV3.Document); 142 | const variables: Variables = { path: encodedPathItems }; 143 | const uri = new URL(`openapi://paths/${encodedPathItems}`); 144 | const expectedLogMessage = /Only OpenAPI v3 specifications are supported/; 145 | 146 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 147 | handler.handleRequest(uri, variables, mockExtra) 148 | ); 149 | 150 | expect(result.contents).toHaveLength(1); 151 | expect(result.contents[0]).toEqual({ 152 | uri: `openapi://paths/${encodedPathItems}`, 153 | mimeType: 'text/plain', 154 | text: 'Only OpenAPI v3 specifications are supported', 155 | isError: true, 156 | }); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/__tests__/unit/handlers/top-level-field-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RequestId } from '@modelcontextprotocol/sdk/types.js'; 3 | import { TopLevelFieldHandler } from '../../../../src/handlers/top-level-field-handler'; 4 | import { SpecLoaderService } from '../../../../src/types'; 5 | import { IFormatter, JsonFormatter } from '../../../../src/services/formatters'; 6 | import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; 8 | import { suppressExpectedConsoleError } from '../../../utils/console-helpers'; 9 | 10 | // Mocks 11 | const mockGetTransformedSpec = jest.fn(); 12 | const mockSpecLoader: SpecLoaderService = { 13 | getSpec: jest.fn(), // Not used by this handler directly 14 | getTransformedSpec: mockGetTransformedSpec, 15 | }; 16 | 17 | const mockFormatter: IFormatter = new JsonFormatter(); // Use real formatter for structure check 18 | 19 | // Sample Data 20 | const sampleSpec: OpenAPIV3.Document = { 21 | openapi: '3.0.3', 22 | info: { title: 'Test API', version: '1.1.0' }, 23 | paths: { '/test': { get: { responses: { '200': { description: 'OK' } } } } }, 24 | components: { schemas: { Test: { type: 'string' } } }, 25 | servers: [{ url: 'http://example.com' }], 26 | }; 27 | 28 | describe('TopLevelFieldHandler', () => { 29 | let handler: TopLevelFieldHandler; 30 | 31 | beforeEach(() => { 32 | handler = new TopLevelFieldHandler(mockSpecLoader, mockFormatter); 33 | mockGetTransformedSpec.mockReset(); // Reset mock before each test 34 | }); 35 | 36 | it('should return the correct template', () => { 37 | const template = handler.getTemplate(); 38 | expect(template).toBeInstanceOf(ResourceTemplate); 39 | // Compare against the string representation of the UriTemplate object 40 | expect(template.uriTemplate.toString()).toBe('openapi://{field}'); 41 | }); 42 | 43 | describe('handleRequest', () => { 44 | const mockExtra = { 45 | signal: new AbortController().signal, 46 | sendNotification: jest.fn(), 47 | sendRequest: jest.fn(), 48 | requestId: 'test-request-id' as RequestId, 49 | }; 50 | 51 | it('should handle request for "info" field', async () => { 52 | mockGetTransformedSpec.mockResolvedValue(sampleSpec); 53 | const variables: Variables = { field: 'info' }; 54 | const uri = new URL('openapi://info'); 55 | 56 | // Pass the mock extra object as the third argument 57 | const result = await handler.handleRequest(uri, variables, mockExtra); 58 | 59 | expect(mockGetTransformedSpec).toHaveBeenCalledWith({ 60 | resourceType: 'schema', 61 | format: 'openapi', 62 | }); 63 | expect(result.contents).toHaveLength(1); 64 | expect(result.contents[0]).toEqual({ 65 | uri: 'openapi://info', 66 | mimeType: 'application/json', 67 | text: JSON.stringify(sampleSpec.info, null, 2), 68 | isError: false, 69 | }); 70 | }); 71 | 72 | it('should handle request for "servers" field', async () => { 73 | mockGetTransformedSpec.mockResolvedValue(sampleSpec); 74 | const variables: Variables = { field: 'servers' }; 75 | const uri = new URL('openapi://servers'); 76 | 77 | const result = await handler.handleRequest(uri, variables, mockExtra); 78 | 79 | expect(result.contents).toHaveLength(1); 80 | expect(result.contents[0]).toEqual({ 81 | uri: 'openapi://servers', 82 | mimeType: 'application/json', 83 | text: JSON.stringify(sampleSpec.servers, null, 2), 84 | isError: false, 85 | }); 86 | }); 87 | 88 | it('should handle request for "paths" field (list view)', async () => { 89 | mockGetTransformedSpec.mockResolvedValue(sampleSpec); 90 | const variables: Variables = { field: 'paths' }; 91 | const uri = new URL('openapi://paths'); 92 | 93 | const result = await handler.handleRequest(uri, variables, mockExtra); 94 | 95 | expect(result.contents).toHaveLength(1); 96 | expect(result.contents[0].uri).toBe('openapi://paths'); 97 | expect(result.contents[0].mimeType).toBe('text/plain'); 98 | expect(result.contents[0].isError).toBe(false); 99 | expect(result.contents[0].text).toContain('GET /test'); // Check content format 100 | // Check that the hint contains the essential URI patterns 101 | expect(result.contents[0].text).toContain('Hint:'); 102 | expect(result.contents[0].text).toContain('openapi://paths/{encoded_path}'); 103 | expect(result.contents[0].text).toContain('openapi://paths/{encoded_path}/{method}'); 104 | }); 105 | 106 | it('should handle request for "components" field (list view)', async () => { 107 | mockGetTransformedSpec.mockResolvedValue(sampleSpec); 108 | const variables: Variables = { field: 'components' }; 109 | const uri = new URL('openapi://components'); 110 | 111 | const result = await handler.handleRequest(uri, variables, mockExtra); 112 | 113 | expect(result.contents).toHaveLength(1); 114 | expect(result.contents[0].uri).toBe('openapi://components'); 115 | expect(result.contents[0].mimeType).toBe('text/plain'); 116 | expect(result.contents[0].isError).toBe(false); 117 | expect(result.contents[0].text).toContain('- schemas'); // Check content format 118 | expect(result.contents[0].text).toContain("Hint: Use 'openapi://components/{type}'"); 119 | }); 120 | 121 | it('should return error for non-existent field', async () => { 122 | mockGetTransformedSpec.mockResolvedValue(sampleSpec); 123 | const variables: Variables = { field: 'nonexistent' }; 124 | const uri = new URL('openapi://nonexistent'); 125 | 126 | const result = await handler.handleRequest(uri, variables, mockExtra); 127 | 128 | expect(result.contents).toHaveLength(1); 129 | expect(result.contents[0]).toEqual({ 130 | uri: 'openapi://nonexistent', 131 | mimeType: 'text/plain', 132 | text: 'Error: Field "nonexistent" not found in the OpenAPI document.', 133 | isError: true, 134 | }); 135 | }); 136 | 137 | it('should handle spec loading errors', async () => { 138 | const error = new Error('Failed to load spec'); 139 | mockGetTransformedSpec.mockRejectedValue(error); 140 | const variables: Variables = { field: 'info' }; 141 | const uri = new URL('openapi://info'); 142 | // Match the core error message using RegExp 143 | const expectedLogMessage = /Failed to load spec/; 144 | 145 | // Use the helper, letting TypeScript infer the return type 146 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 147 | handler.handleRequest(uri, variables, mockExtra) 148 | ); 149 | 150 | expect(result.contents).toHaveLength(1); 151 | expect(result.contents[0]).toEqual({ 152 | uri: 'openapi://info', 153 | mimeType: 'text/plain', 154 | text: 'Failed to load spec', 155 | isError: true, 156 | }); 157 | }); 158 | 159 | it('should handle non-OpenAPI v3 spec', async () => { 160 | const invalidSpec = { swagger: '2.0', info: {} }; // Not OpenAPI v3 161 | mockGetTransformedSpec.mockResolvedValue(invalidSpec as unknown as OpenAPIV3.Document); 162 | const variables: Variables = { field: 'info' }; 163 | const uri = new URL('openapi://info'); 164 | // Match the core error message using RegExp 165 | const expectedLogMessage = /Only OpenAPI v3 specifications are supported/; 166 | 167 | // Use the helper, letting TypeScript infer the return type 168 | const result = await suppressExpectedConsoleError(expectedLogMessage, () => 169 | handler.handleRequest(uri, variables, mockExtra) 170 | ); 171 | 172 | expect(result.contents).toHaveLength(1); 173 | expect(result.contents[0]).toEqual({ 174 | uri: 'openapi://info', 175 | mimeType: 'text/plain', 176 | text: 'Only OpenAPI v3 specifications are supported', 177 | isError: true, 178 | }); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /test/__tests__/unit/rendering/document.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RenderableDocument } from '../../../../src/rendering/document'; 3 | import { RenderContext } from '../../../../src/rendering/types'; 4 | import { IFormatter, JsonFormatter } from '../../../../src/services/formatters'; 5 | 6 | // Mock Formatter 7 | const mockFormatter: IFormatter = new JsonFormatter(); // Use JSON for predictable output 8 | 9 | const mockContext: RenderContext = { 10 | formatter: mockFormatter, 11 | baseUri: 'openapi://', 12 | }; 13 | 14 | // Sample OpenAPI Document Fixture 15 | const sampleDoc: OpenAPIV3.Document = { 16 | openapi: '3.0.0', 17 | info: { 18 | title: 'Test API', 19 | version: '1.0.0', 20 | }, 21 | paths: { 22 | '/test': { 23 | get: { 24 | summary: 'Test GET', 25 | responses: { 26 | '200': { description: 'OK' }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | components: { 32 | schemas: { 33 | TestSchema: { type: 'string' }, 34 | }, 35 | }, 36 | servers: [{ url: 'http://localhost:3000' }], 37 | }; 38 | 39 | describe('RenderableDocument', () => { 40 | let renderableDoc: RenderableDocument; 41 | 42 | beforeEach(() => { 43 | renderableDoc = new RenderableDocument(sampleDoc); 44 | }); 45 | 46 | it('should instantiate correctly', () => { 47 | expect(renderableDoc).toBeInstanceOf(RenderableDocument); 48 | }); 49 | 50 | // Test the internal detail rendering method 51 | describe('renderTopLevelFieldDetail', () => { 52 | it('should render detail for a valid top-level field (info)', () => { 53 | const fieldObject = renderableDoc.getTopLevelField('info'); 54 | const result = renderableDoc.renderTopLevelFieldDetail(mockContext, fieldObject, 'info'); 55 | 56 | expect(result).toHaveLength(1); 57 | expect(result[0]).toEqual({ 58 | uriSuffix: 'info', 59 | data: sampleDoc.info, // Expect raw data 60 | isError: undefined, // Should default to false implicitly 61 | renderAsList: undefined, // Should default to false implicitly 62 | }); 63 | }); 64 | 65 | it('should render detail for another valid field (servers)', () => { 66 | const fieldObject = renderableDoc.getTopLevelField('servers'); 67 | const result = renderableDoc.renderTopLevelFieldDetail(mockContext, fieldObject, 'servers'); 68 | 69 | expect(result).toHaveLength(1); 70 | expect(result[0]).toEqual({ 71 | uriSuffix: 'servers', 72 | data: sampleDoc.servers, 73 | isError: undefined, 74 | renderAsList: undefined, 75 | }); 76 | }); 77 | 78 | it('should return error for non-existent field', () => { 79 | const fieldObject = renderableDoc.getTopLevelField('nonexistent'); 80 | const result = renderableDoc.renderTopLevelFieldDetail( 81 | mockContext, 82 | fieldObject, // Will be undefined 83 | 'nonexistent' 84 | ); 85 | 86 | expect(result).toHaveLength(1); 87 | expect(result[0]).toEqual({ 88 | uriSuffix: 'nonexistent', 89 | data: null, 90 | isError: true, 91 | errorText: 'Error: Field "nonexistent" not found in the OpenAPI document.', 92 | renderAsList: true, 93 | }); 94 | }); 95 | 96 | it('should return error when trying to render "paths" via detail method', () => { 97 | const fieldObject = renderableDoc.getTopLevelField('paths'); 98 | const result = renderableDoc.renderTopLevelFieldDetail(mockContext, fieldObject, 'paths'); 99 | 100 | expect(result).toHaveLength(1); 101 | expect(result[0]).toEqual({ 102 | uriSuffix: 'paths', 103 | data: null, 104 | isError: true, 105 | errorText: `Error: Field "paths" should be accessed via its list view (${mockContext.baseUri}paths). Use the list view first.`, 106 | renderAsList: true, 107 | }); 108 | }); 109 | 110 | it('should return error when trying to render "components" via detail method', () => { 111 | const fieldObject = renderableDoc.getTopLevelField('components'); 112 | const result = renderableDoc.renderTopLevelFieldDetail( 113 | mockContext, 114 | fieldObject, 115 | 'components' 116 | ); 117 | 118 | expect(result).toHaveLength(1); 119 | expect(result[0]).toEqual({ 120 | uriSuffix: 'components', 121 | data: null, 122 | isError: true, 123 | errorText: `Error: Field "components" should be accessed via its list view (${mockContext.baseUri}components). Use the list view first.`, 124 | renderAsList: true, 125 | }); 126 | }); 127 | }); 128 | 129 | // Test the interface methods (which currently return errors) 130 | describe('Interface Methods', () => { 131 | it('renderList should return error', () => { 132 | const result = renderableDoc.renderList(mockContext); 133 | expect(result).toHaveLength(1); 134 | expect(result[0]).toMatchObject({ 135 | uriSuffix: 'error', 136 | isError: true, 137 | errorText: expect.stringContaining( 138 | 'List rendering is only supported for specific fields' 139 | ) as string, 140 | renderAsList: true, 141 | }); 142 | }); 143 | 144 | it('renderDetail should return error', () => { 145 | const result = renderableDoc.renderDetail(mockContext); 146 | expect(result).toHaveLength(1); 147 | expect(result[0]).toMatchObject({ 148 | uriSuffix: 'error', 149 | isError: true, 150 | errorText: expect.stringContaining( 151 | 'Detail rendering requires specifying a top-level field' 152 | ) as string, 153 | renderAsList: true, 154 | }); 155 | }); 156 | }); 157 | 158 | // Test helper methods 159 | describe('Helper Methods', () => { 160 | it('getPathsObject should return paths', () => { 161 | expect(renderableDoc.getPathsObject()).toBe(sampleDoc.paths); 162 | }); 163 | it('getComponentsObject should return components', () => { 164 | expect(renderableDoc.getComponentsObject()).toBe(sampleDoc.components); 165 | }); 166 | it('getTopLevelField should return correct field', () => { 167 | expect(renderableDoc.getTopLevelField('info')).toBe(sampleDoc.info); 168 | expect(renderableDoc.getTopLevelField('servers')).toBe(sampleDoc.servers); 169 | expect(renderableDoc.getTopLevelField('nonexistent')).toBeUndefined(); 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /test/__tests__/unit/rendering/path-item.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RenderablePathItem } from '../../../../src/rendering/path-item'; 3 | import { RenderContext } from '../../../../src/rendering/types'; 4 | import { IFormatter, JsonFormatter } from '../../../../src/services/formatters'; 5 | 6 | // Mock Formatter & Context 7 | const mockFormatter: IFormatter = new JsonFormatter(); 8 | const mockContext: RenderContext = { 9 | formatter: mockFormatter, 10 | baseUri: 'openapi://', 11 | }; 12 | 13 | // Sample PathItem Object Fixture 14 | const samplePathItem: OpenAPIV3.PathItemObject = { 15 | get: { 16 | summary: 'Get Item', 17 | responses: { '200': { description: 'OK' } }, 18 | }, 19 | post: { 20 | summary: 'Create Item', 21 | responses: { '201': { description: 'Created' } }, 22 | }, 23 | delete: { 24 | // No summary 25 | responses: { '204': { description: 'No Content' } }, 26 | }, 27 | parameters: [ 28 | // Example path-level parameter 29 | { name: 'commonParam', in: 'query', schema: { type: 'string' } }, 30 | ], 31 | }; 32 | 33 | // Define both the raw path and the expected suffix (built using the builder logic) 34 | const rawPath = '/items'; 35 | const pathUriSuffix = 'paths/items'; // Builder removes leading '/' and encodes, but '/items' has no special chars 36 | 37 | describe('RenderablePathItem', () => { 38 | describe('renderList (List Methods)', () => { 39 | it('should render a list of methods correctly', () => { 40 | // Provide all 3 arguments to constructor 41 | const renderable = new RenderablePathItem(samplePathItem, rawPath, pathUriSuffix); 42 | const result = renderable.renderList(mockContext); 43 | 44 | expect(result).toHaveLength(1); 45 | expect(result[0].uriSuffix).toBe(pathUriSuffix); 46 | expect(result[0].renderAsList).toBe(true); 47 | expect(result[0].isError).toBeUndefined(); 48 | 49 | // Define expected output lines based on the new format and builder logic 50 | // generateListHint uses buildOperationUriSuffix which encodes the path 51 | // Since rawPath is '/items', encoded is 'items'. 52 | // The first sorted method is 'delete'. 53 | const expectedHint = 54 | "Hint: Use 'openapi://paths/items/{method}' to view details for a specific operation. (e.g., openapi://paths/items/delete)"; 55 | const expectedLineDelete = 'DELETE'; // No summary/opId 56 | const expectedLineGet = 'GET: Get Item'; // Summary exists 57 | const expectedLinePost = 'POST: Create Item'; // Summary exists 58 | const expectedOutput = `${expectedHint}\n\n${expectedLineDelete}\n${expectedLineGet}\n${expectedLinePost}`; 59 | 60 | // Check the full output string 61 | expect(result[0].data).toBe(expectedOutput); 62 | }); 63 | 64 | it('should handle path item with no standard methods', () => { 65 | const noMethodsPathItem: OpenAPIV3.PathItemObject = { 66 | parameters: samplePathItem.parameters, 67 | }; 68 | // Provide all 3 arguments to constructor 69 | const renderable = new RenderablePathItem(noMethodsPathItem, rawPath, pathUriSuffix); 70 | const result = renderable.renderList(mockContext); 71 | expect(result).toHaveLength(1); 72 | expect(result[0]).toEqual({ 73 | uriSuffix: pathUriSuffix, 74 | data: 'No standard HTTP methods found for path: items', 75 | renderAsList: true, 76 | }); 77 | }); 78 | 79 | it('should return error if path item is undefined', () => { 80 | // Provide all 3 arguments to constructor 81 | const renderable = new RenderablePathItem(undefined, rawPath, pathUriSuffix); 82 | const result = renderable.renderList(mockContext); 83 | expect(result).toHaveLength(1); 84 | expect(result[0]).toMatchObject({ 85 | uriSuffix: pathUriSuffix, 86 | isError: true, 87 | errorText: 'Path item not found.', 88 | renderAsList: true, 89 | }); 90 | }); 91 | }); 92 | 93 | describe('renderOperationDetail (Get Operation Detail)', () => { 94 | it('should return detail for a single valid method', () => { 95 | // Provide all 3 arguments to constructor 96 | const renderable = new RenderablePathItem(samplePathItem, rawPath, pathUriSuffix); 97 | const result = renderable.renderOperationDetail(mockContext, ['get']); 98 | expect(result).toHaveLength(1); 99 | expect(result[0]).toEqual({ 100 | uriSuffix: `${pathUriSuffix}/get`, 101 | data: samplePathItem.get, // Expect raw operation object 102 | }); 103 | }); 104 | 105 | it('should return details for multiple valid methods', () => { 106 | // Provide all 3 arguments to constructor 107 | const renderable = new RenderablePathItem(samplePathItem, rawPath, pathUriSuffix); 108 | const result = renderable.renderOperationDetail(mockContext, ['post', 'delete']); 109 | expect(result).toHaveLength(2); 110 | expect(result).toContainEqual({ 111 | uriSuffix: `${pathUriSuffix}/post`, 112 | data: samplePathItem.post, 113 | }); 114 | expect(result).toContainEqual({ 115 | uriSuffix: `${pathUriSuffix}/delete`, 116 | data: samplePathItem.delete, 117 | }); 118 | }); 119 | 120 | it('should return error for non-existent method', () => { 121 | // Provide all 3 arguments to constructor 122 | const renderable = new RenderablePathItem(samplePathItem, rawPath, pathUriSuffix); 123 | const result = renderable.renderOperationDetail(mockContext, ['put']); 124 | expect(result).toHaveLength(1); 125 | expect(result[0]).toEqual({ 126 | uriSuffix: `${pathUriSuffix}/put`, 127 | data: null, 128 | isError: true, 129 | errorText: 'Method "PUT" not found for path.', 130 | renderAsList: true, 131 | }); 132 | }); 133 | 134 | it('should handle mix of valid and invalid methods', () => { 135 | // Provide all 3 arguments to constructor 136 | const renderable = new RenderablePathItem(samplePathItem, rawPath, pathUriSuffix); 137 | const result = renderable.renderOperationDetail(mockContext, ['get', 'patch']); 138 | expect(result).toHaveLength(2); 139 | expect(result).toContainEqual({ 140 | uriSuffix: `${pathUriSuffix}/get`, 141 | data: samplePathItem.get, 142 | }); 143 | expect(result).toContainEqual({ 144 | uriSuffix: `${pathUriSuffix}/patch`, 145 | data: null, 146 | isError: true, 147 | errorText: 'Method "PATCH" not found for path.', 148 | renderAsList: true, 149 | }); 150 | }); 151 | 152 | it('should return error if path item is undefined', () => { 153 | // Provide all 3 arguments to constructor 154 | const renderable = new RenderablePathItem(undefined, rawPath, pathUriSuffix); 155 | const result = renderable.renderOperationDetail(mockContext, ['get']); 156 | expect(result).toHaveLength(1); 157 | expect(result[0]).toEqual({ 158 | uriSuffix: `${pathUriSuffix}/get`, 159 | data: null, 160 | isError: true, 161 | errorText: 'Path item not found.', 162 | renderAsList: true, 163 | }); 164 | }); 165 | }); 166 | 167 | describe('renderDetail (Interface Method)', () => { 168 | it('should delegate to renderList', () => { 169 | // Provide all 3 arguments to constructor 170 | const renderable = new RenderablePathItem(samplePathItem, rawPath, pathUriSuffix); 171 | const listResult = renderable.renderList(mockContext); 172 | const detailResult = renderable.renderDetail(mockContext); 173 | expect(detailResult).toEqual(listResult); 174 | }); 175 | }); 176 | 177 | describe('getOperation', () => { 178 | it('should return correct operation object (case-insensitive)', () => { 179 | // Provide all 3 arguments to constructor 180 | const renderable = new RenderablePathItem(samplePathItem, rawPath, pathUriSuffix); 181 | expect(renderable.getOperation('get')).toBe(samplePathItem.get); 182 | expect(renderable.getOperation('POST')).toBe(samplePathItem.post); 183 | expect(renderable.getOperation('Delete')).toBe(samplePathItem.delete); 184 | }); 185 | 186 | it('should return undefined for non-existent method', () => { 187 | // Provide all 3 arguments to constructor 188 | const renderable = new RenderablePathItem(samplePathItem, rawPath, pathUriSuffix); 189 | expect(renderable.getOperation('put')).toBeUndefined(); 190 | }); 191 | 192 | it('should return undefined if path item is undefined', () => { 193 | // Provide all 3 arguments to constructor 194 | const renderable = new RenderablePathItem(undefined, rawPath, pathUriSuffix); 195 | expect(renderable.getOperation('get')).toBeUndefined(); 196 | }); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /test/__tests__/unit/rendering/paths.test.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | import { RenderablePaths } from '../../../../src/rendering/paths'; 3 | import { RenderContext } from '../../../../src/rendering/types'; 4 | import { IFormatter, JsonFormatter } from '../../../../src/services/formatters'; 5 | 6 | // Mock Formatter & Context 7 | const mockFormatter: IFormatter = new JsonFormatter(); 8 | const mockContext: RenderContext = { 9 | formatter: mockFormatter, 10 | baseUri: 'openapi://', 11 | }; 12 | 13 | // Sample Paths Object Fixture 14 | const samplePaths: OpenAPIV3.PathsObject = { 15 | '/users': { 16 | get: { 17 | summary: 'List Users', 18 | responses: { '200': { description: 'OK' } }, 19 | }, 20 | post: { 21 | summary: 'Create User', 22 | responses: { '201': { description: 'Created' } }, 23 | }, 24 | }, 25 | '/users/{userId}': { 26 | get: { 27 | summary: 'Get User by ID', 28 | responses: { '200': { description: 'OK' } }, 29 | }, 30 | delete: { 31 | // No summary 32 | responses: { '204': { description: 'No Content' } }, 33 | }, 34 | }, 35 | // Removed /ping path with custom operation to avoid type errors 36 | }; 37 | 38 | const emptyPaths: OpenAPIV3.PathsObject = {}; 39 | 40 | describe('RenderablePaths', () => { 41 | describe('renderList', () => { 42 | it('should render a list of paths and methods correctly', () => { 43 | const renderablePaths = new RenderablePaths(samplePaths); 44 | const result = renderablePaths.renderList(mockContext); 45 | 46 | expect(result).toHaveLength(1); 47 | expect(result[0].uriSuffix).toBe('paths'); 48 | expect(result[0].renderAsList).toBe(true); 49 | expect(result[0].isError).toBeUndefined(); 50 | 51 | // Define expected output lines based on the new format 52 | const expectedLineUsers = 'GET POST /users'; // Methods sorted alphabetically and uppercased 53 | const expectedLineUserDetail = 'DELETE GET /users/{userId}'; // Methods sorted alphabetically and uppercased 54 | 55 | // Check essential parts instead of exact match 56 | expect(result[0].data).toContain('Hint:'); 57 | expect(result[0].data).toContain('openapi://paths/{encoded_path}'); 58 | expect(result[0].data).toContain('openapi://paths/{encoded_path}/{method}'); 59 | expect(result[0].data).toContain(expectedLineUsers); 60 | expect(result[0].data).toContain(expectedLineUserDetail); 61 | }); 62 | 63 | it('should handle empty paths object', () => { 64 | const renderablePaths = new RenderablePaths(emptyPaths); 65 | const result = renderablePaths.renderList(mockContext); 66 | 67 | expect(result).toHaveLength(1); 68 | expect(result[0]).toEqual({ 69 | uriSuffix: 'paths', 70 | data: 'No paths found in the specification.', 71 | renderAsList: true, 72 | }); 73 | }); 74 | 75 | it('should handle undefined paths object', () => { 76 | const renderablePaths = new RenderablePaths(undefined); 77 | const result = renderablePaths.renderList(mockContext); 78 | 79 | expect(result).toHaveLength(1); 80 | expect(result[0]).toEqual({ 81 | uriSuffix: 'paths', 82 | data: 'No paths found in the specification.', 83 | renderAsList: true, 84 | }); 85 | }); 86 | }); 87 | 88 | describe('renderDetail', () => { 89 | it('should delegate to renderList', () => { 90 | const renderablePaths = new RenderablePaths(samplePaths); 91 | const listResult = renderablePaths.renderList(mockContext); 92 | const detailResult = renderablePaths.renderDetail(mockContext); 93 | // Check if the output is the same as renderList 94 | expect(detailResult).toEqual(listResult); 95 | }); 96 | }); 97 | 98 | describe('getPathItem', () => { 99 | it('should return the correct PathItemObject', () => { 100 | const renderablePaths = new RenderablePaths(samplePaths); 101 | expect(renderablePaths.getPathItem('/users')).toBe(samplePaths['/users']); 102 | expect(renderablePaths.getPathItem('/users/{userId}')).toBe(samplePaths['/users/{userId}']); 103 | }); 104 | 105 | it('should return undefined for non-existent path', () => { 106 | const renderablePaths = new RenderablePaths(samplePaths); 107 | expect(renderablePaths.getPathItem('/nonexistent')).toBeUndefined(); 108 | }); 109 | 110 | it('should return undefined if paths object is undefined', () => { 111 | const renderablePaths = new RenderablePaths(undefined); 112 | expect(renderablePaths.getPathItem('/users')).toBeUndefined(); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/__tests__/unit/services/formatters.test.ts: -------------------------------------------------------------------------------- 1 | import { dump as yamlDump } from 'js-yaml'; 2 | import { 3 | JsonFormatter, 4 | YamlFormatter, 5 | MinifiedJsonFormatter, 6 | createFormatter, 7 | } from '../../../../src/services/formatters.js'; 8 | 9 | describe('Formatters', () => { 10 | const testData = { 11 | method: 'GET', 12 | path: '/test', 13 | summary: 'Test endpoint', 14 | parameters: [ 15 | { 16 | name: 'id', 17 | in: 'path', 18 | required: true, 19 | schema: { type: 'string' }, 20 | }, 21 | ], 22 | }; 23 | 24 | describe('JsonFormatter', () => { 25 | const formatter = new JsonFormatter(); 26 | 27 | it('should format data as JSON with proper indentation', () => { 28 | const result = formatter.format(testData); 29 | expect(result).toBe(JSON.stringify(testData, null, 2)); 30 | }); 31 | 32 | it('should return application/json mime type', () => { 33 | expect(formatter.getMimeType()).toBe('application/json'); 34 | }); 35 | 36 | it('should handle empty objects', () => { 37 | expect(formatter.format({})).toBe('{}'); 38 | }); 39 | 40 | it('should handle null values', () => { 41 | expect(formatter.format(null)).toBe('null'); 42 | }); 43 | }); 44 | 45 | describe('YamlFormatter', () => { 46 | const formatter = new YamlFormatter(); 47 | 48 | it('should format data as YAML', () => { 49 | const result = formatter.format(testData); 50 | expect(result).toBe( 51 | yamlDump(testData, { 52 | indent: 2, 53 | lineWidth: -1, 54 | noRefs: true, 55 | }) 56 | ); 57 | }); 58 | 59 | it('should return text/yaml mime type', () => { 60 | expect(formatter.getMimeType()).toBe('text/yaml'); 61 | }); 62 | 63 | it('should handle empty objects', () => { 64 | expect(formatter.format({})).toBe('{}\n'); 65 | }); 66 | 67 | it('should handle null values', () => { 68 | expect(formatter.format(null)).toBe('null\n'); 69 | }); 70 | }); 71 | 72 | describe('MinifiedJsonFormatter', () => { 73 | const formatter = new MinifiedJsonFormatter(); 74 | 75 | it('should format data as minified JSON', () => { 76 | const result = formatter.format(testData); 77 | expect(result).toBe(JSON.stringify(testData)); 78 | }); 79 | 80 | it('should return application/json mime type', () => { 81 | expect(formatter.getMimeType()).toBe('application/json'); 82 | }); 83 | 84 | it('should handle empty objects', () => { 85 | expect(formatter.format({})).toBe('{}'); 86 | }); 87 | 88 | it('should handle null values', () => { 89 | expect(formatter.format(null)).toBe('null'); 90 | }); 91 | }); 92 | 93 | describe('createFormatter', () => { 94 | it('should create JsonFormatter for json format', () => { 95 | const formatter = createFormatter('json'); 96 | expect(formatter).toBeInstanceOf(JsonFormatter); 97 | }); 98 | 99 | it('should create YamlFormatter for yaml format', () => { 100 | const formatter = createFormatter('yaml'); 101 | expect(formatter).toBeInstanceOf(YamlFormatter); 102 | }); 103 | 104 | it('should create MinifiedJsonFormatter for json-minified format', () => { 105 | const formatter = createFormatter('json-minified'); 106 | expect(formatter).toBeInstanceOf(MinifiedJsonFormatter); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /test/__tests__/unit/services/spec-loader.test.ts: -------------------------------------------------------------------------------- 1 | import { SpecLoaderService } from '../../../../src/services/spec-loader.js'; 2 | import { ReferenceTransformService } from '../../../../src/services/reference-transform.js'; 3 | import { OpenAPIV3 } from 'openapi-types'; 4 | 5 | // Define mock implementations first 6 | const mockConvertUrlImplementation = jest.fn(); 7 | const mockConvertFileImplementation = jest.fn(); 8 | 9 | // Mock the module, referencing the defined implementations 10 | // IMPORTANT: The factory function for jest.mock runs BEFORE top-level variable assignments in the module scope. 11 | // We need to access the mocks indirectly. 12 | interface Swagger2OpenapiResult { 13 | openapi: OpenAPIV3.Document; 14 | options: unknown; // Use unknown for options as we don't have precise types here 15 | } 16 | 17 | jest.mock('swagger2openapi', () => { 18 | // Return an object where the properties are functions that call our mocks 19 | return { 20 | convertUrl: (url: string, options: unknown): Promise => 21 | mockConvertUrlImplementation(url, options) as Promise, // Cast return type 22 | convertFile: (filename: string, options: unknown): Promise => 23 | mockConvertFileImplementation(filename, options) as Promise, // Cast return type 24 | }; 25 | }); 26 | 27 | describe('SpecLoaderService', () => { 28 | const mockV3Spec: OpenAPIV3.Document = { 29 | openapi: '3.0.0', 30 | info: { 31 | title: 'Test V3 API', 32 | version: '1.0.0', 33 | }, 34 | paths: {}, 35 | }; 36 | 37 | // Simulate the structure returned by swagger2openapi 38 | const mockS2OResult = { 39 | openapi: mockV3Spec, 40 | options: {}, // Add other properties if needed by tests 41 | }; 42 | 43 | let referenceTransform: ReferenceTransformService; 44 | 45 | beforeEach(() => { 46 | // Reset the mock implementations 47 | mockConvertUrlImplementation.mockReset(); 48 | mockConvertFileImplementation.mockReset(); 49 | referenceTransform = new ReferenceTransformService(); 50 | // Mock the transformDocument method for simplicity in these tests 51 | jest.spyOn(referenceTransform, 'transformDocument').mockImplementation(spec => spec); 52 | }); 53 | 54 | describe('loadSpec', () => { 55 | it('loads local v3 spec using convertFile', async () => { 56 | mockConvertFileImplementation.mockResolvedValue(mockS2OResult); 57 | const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform); 58 | const spec = await loader.loadSpec(); 59 | 60 | expect(mockConvertFileImplementation).toHaveBeenCalledWith( 61 | '/path/to/spec.json', 62 | expect.any(Object) 63 | ); 64 | expect(mockConvertUrlImplementation).not.toHaveBeenCalled(); 65 | expect(spec).toEqual(mockV3Spec); 66 | }); 67 | 68 | it('loads remote v3 spec using convertUrl', async () => { 69 | mockConvertUrlImplementation.mockResolvedValue(mockS2OResult); 70 | const loader = new SpecLoaderService('http://example.com/spec.json', referenceTransform); 71 | const spec = await loader.loadSpec(); 72 | 73 | expect(mockConvertUrlImplementation).toHaveBeenCalledWith( 74 | 'http://example.com/spec.json', 75 | expect.any(Object) 76 | ); 77 | expect(mockConvertFileImplementation).not.toHaveBeenCalled(); 78 | expect(spec).toEqual(mockV3Spec); 79 | }); 80 | 81 | it('loads and converts local v2 spec using convertFile', async () => { 82 | // Assume convertFile handles v2 internally and returns v3 83 | mockConvertFileImplementation.mockResolvedValue(mockS2OResult); 84 | const loader = new SpecLoaderService('/path/to/v2spec.json', referenceTransform); 85 | const spec = await loader.loadSpec(); 86 | 87 | expect(mockConvertFileImplementation).toHaveBeenCalledWith( 88 | '/path/to/v2spec.json', 89 | expect.any(Object) 90 | ); 91 | expect(mockConvertUrlImplementation).not.toHaveBeenCalled(); 92 | expect(spec).toEqual(mockV3Spec); // Should be the converted v3 spec 93 | }); 94 | 95 | it('loads and converts remote v2 spec using convertUrl', async () => { 96 | // Assume convertUrl handles v2 internally and returns v3 97 | mockConvertUrlImplementation.mockResolvedValue(mockS2OResult); 98 | const loader = new SpecLoaderService('https://example.com/v2spec.yaml', referenceTransform); 99 | const spec = await loader.loadSpec(); 100 | 101 | expect(mockConvertUrlImplementation).toHaveBeenCalledWith( 102 | 'https://example.com/v2spec.yaml', 103 | expect.any(Object) 104 | ); 105 | expect(mockConvertFileImplementation).not.toHaveBeenCalled(); 106 | expect(spec).toEqual(mockV3Spec); // Should be the converted v3 spec 107 | }); 108 | 109 | it('throws error if convertFile fails', async () => { 110 | const loadError = new Error('File not found'); 111 | mockConvertFileImplementation.mockRejectedValue(loadError); 112 | const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform); 113 | 114 | await expect(loader.loadSpec()).rejects.toThrow( 115 | 'Failed to load/convert OpenAPI spec from /path/to/spec.json: File not found' 116 | ); 117 | }); 118 | 119 | it('throws error if convertUrl fails', async () => { 120 | const loadError = new Error('Network error'); 121 | mockConvertUrlImplementation.mockRejectedValue(loadError); 122 | const loader = new SpecLoaderService('http://example.com/spec.json', referenceTransform); 123 | 124 | await expect(loader.loadSpec()).rejects.toThrow( 125 | 'Failed to load/convert OpenAPI spec from http://example.com/spec.json: Network error' 126 | ); 127 | }); 128 | 129 | it('throws error if result object is invalid', async () => { 130 | mockConvertFileImplementation.mockResolvedValue({ options: {} }); // Missing openapi property 131 | const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform); 132 | 133 | await expect(loader.loadSpec()).rejects.toThrow( 134 | 'Failed to load/convert OpenAPI spec from /path/to/spec.json: Conversion or parsing failed to produce an OpenAPI document.' 135 | ); 136 | }); 137 | }); 138 | 139 | describe('getSpec', () => { 140 | it('returns loaded spec after loadSpec called', async () => { 141 | mockConvertFileImplementation.mockResolvedValue(mockS2OResult); 142 | const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform); 143 | await loader.loadSpec(); // Load first 144 | const spec = await loader.getSpec(); 145 | 146 | expect(spec).toEqual(mockV3Spec); 147 | // Ensure loadSpec was only called once implicitly by the first await 148 | expect(mockConvertFileImplementation).toHaveBeenCalledTimes(1); 149 | }); 150 | 151 | it('loads spec via convertFile if not already loaded', async () => { 152 | mockConvertFileImplementation.mockResolvedValue(mockS2OResult); 153 | const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform); 154 | const spec = await loader.getSpec(); // Should trigger loadSpec 155 | 156 | expect(mockConvertFileImplementation).toHaveBeenCalledWith( 157 | '/path/to/spec.json', 158 | expect.any(Object) 159 | ); 160 | expect(spec).toEqual(mockV3Spec); 161 | }); 162 | 163 | it('loads spec via convertUrl if not already loaded', async () => { 164 | mockConvertUrlImplementation.mockResolvedValue(mockS2OResult); 165 | const loader = new SpecLoaderService('http://example.com/spec.json', referenceTransform); 166 | const spec = await loader.getSpec(); // Should trigger loadSpec 167 | 168 | expect(mockConvertUrlImplementation).toHaveBeenCalledWith( 169 | 'http://example.com/spec.json', 170 | expect.any(Object) 171 | ); 172 | expect(spec).toEqual(mockV3Spec); 173 | }); 174 | }); 175 | 176 | describe('getTransformedSpec', () => { 177 | // Mock the transformer to return a distinctly modified object 178 | const mockTransformedSpec = { 179 | ...mockV3Spec, 180 | info: { ...mockV3Spec.info, title: 'Transformed API' }, 181 | }; 182 | 183 | beforeEach(() => { 184 | jest 185 | .spyOn(referenceTransform, 'transformDocument') 186 | .mockImplementation(() => mockTransformedSpec); 187 | }); 188 | 189 | it('returns transformed spec after loading', async () => { 190 | mockConvertFileImplementation.mockResolvedValue(mockS2OResult); 191 | const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform); 192 | const spec = await loader.getTransformedSpec({ resourceType: 'endpoint', format: 'openapi' }); // Should load then transform 193 | 194 | expect(mockConvertFileImplementation).toHaveBeenCalledTimes(1); // Ensure loading happened 195 | const transformSpy = jest.spyOn(referenceTransform, 'transformDocument'); 196 | expect(transformSpy).toHaveBeenCalledWith( 197 | mockV3Spec, 198 | expect.objectContaining({ resourceType: 'endpoint', format: 'openapi' }) 199 | ); 200 | expect(spec).toEqual(mockTransformedSpec); 201 | }); 202 | 203 | it('loads spec if not loaded before transforming', async () => { 204 | mockConvertFileImplementation.mockResolvedValue(mockS2OResult); 205 | const loader = new SpecLoaderService('/path/to/spec.json', referenceTransform); 206 | await loader.getTransformedSpec({ resourceType: 'endpoint', format: 'openapi' }); // Trigger load 207 | 208 | expect(mockConvertFileImplementation).toHaveBeenCalledWith( 209 | '/path/to/spec.json', 210 | expect.any(Object) 211 | ); 212 | }); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /test/__tests__/unit/utils/uri-builder.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | buildComponentDetailUri, 3 | buildComponentMapUri, 4 | buildOperationUri, 5 | buildPathItemUri, 6 | buildTopLevelFieldUri, 7 | buildComponentDetailUriSuffix, 8 | buildComponentMapUriSuffix, 9 | buildOperationUriSuffix, 10 | buildPathItemUriSuffix, 11 | buildTopLevelFieldUriSuffix, 12 | } from '../../../../src/utils/uri-builder'; 13 | 14 | describe('URI Builder Utilities', () => { 15 | // --- Full URI Builders --- 16 | 17 | test('buildComponentDetailUri builds correct URI', () => { 18 | expect(buildComponentDetailUri('schemas', 'MySchema')).toBe( 19 | 'openapi://components/schemas/MySchema' 20 | ); 21 | expect(buildComponentDetailUri('responses', 'NotFound')).toBe( 22 | 'openapi://components/responses/NotFound' 23 | ); 24 | // Test with characters that might need encoding if rules change (but currently don't) 25 | expect(buildComponentDetailUri('parameters', 'user-id')).toBe( 26 | 'openapi://components/parameters/user-id' 27 | ); 28 | }); 29 | 30 | test('buildComponentMapUri builds correct URI', () => { 31 | expect(buildComponentMapUri('schemas')).toBe('openapi://components/schemas'); 32 | expect(buildComponentMapUri('parameters')).toBe('openapi://components/parameters'); 33 | }); 34 | 35 | test('buildOperationUri builds correct URI and encodes path (no leading slash)', () => { 36 | expect(buildOperationUri('/users', 'get')).toBe('openapi://paths/users/get'); // No leading slash encoded 37 | expect(buildOperationUri('/users/{userId}', 'post')).toBe( 38 | 'openapi://paths/users%2F%7BuserId%7D/post' // Path encoded, no leading %2F 39 | ); 40 | expect(buildOperationUri('/pets/{petId}/uploadImage', 'post')).toBe( 41 | 'openapi://paths/pets%2F%7BpetId%7D%2FuploadImage/post' // Path encoded, no leading %2F 42 | ); 43 | expect(buildOperationUri('users', 'get')).toBe('openapi://paths/users/get'); // Handles no leading slash input 44 | expect(buildOperationUri('users/{userId}', 'post')).toBe( 45 | 'openapi://paths/users%2F%7BuserId%7D/post' // Handles no leading slash input 46 | ); 47 | expect(buildOperationUri('/users', 'GET')).toBe('openapi://paths/users/get'); // Method lowercased 48 | }); 49 | 50 | test('buildPathItemUri builds correct URI and encodes path (no leading slash)', () => { 51 | expect(buildPathItemUri('/users')).toBe('openapi://paths/users'); // No leading slash encoded 52 | expect(buildPathItemUri('/users/{userId}')).toBe('openapi://paths/users%2F%7BuserId%7D'); // Path encoded, no leading %2F 53 | expect(buildPathItemUri('/pets/{petId}/uploadImage')).toBe( 54 | 'openapi://paths/pets%2F%7BpetId%7D%2FuploadImage' // Path encoded, no leading %2F 55 | ); 56 | expect(buildPathItemUri('users')).toBe('openapi://paths/users'); // Handles no leading slash input 57 | expect(buildPathItemUri('users/{userId}')).toBe('openapi://paths/users%2F%7BuserId%7D'); // Handles no leading slash input 58 | }); 59 | 60 | test('buildTopLevelFieldUri builds correct URI', () => { 61 | expect(buildTopLevelFieldUri('info')).toBe('openapi://info'); 62 | expect(buildTopLevelFieldUri('paths')).toBe('openapi://paths'); 63 | expect(buildTopLevelFieldUri('components')).toBe('openapi://components'); 64 | }); 65 | 66 | // --- URI Suffix Builders --- 67 | 68 | test('buildComponentDetailUriSuffix builds correct suffix', () => { 69 | expect(buildComponentDetailUriSuffix('schemas', 'MySchema')).toBe( 70 | 'components/schemas/MySchema' 71 | ); 72 | expect(buildComponentDetailUriSuffix('responses', 'NotFound')).toBe( 73 | 'components/responses/NotFound' 74 | ); 75 | }); 76 | 77 | test('buildComponentMapUriSuffix builds correct suffix', () => { 78 | expect(buildComponentMapUriSuffix('schemas')).toBe('components/schemas'); 79 | expect(buildComponentMapUriSuffix('parameters')).toBe('components/parameters'); 80 | }); 81 | 82 | test('buildOperationUriSuffix builds correct suffix and encodes path (no leading slash)', () => { 83 | expect(buildOperationUriSuffix('/users', 'get')).toBe('paths/users/get'); // No leading slash encoded 84 | expect(buildOperationUriSuffix('/users/{userId}', 'post')).toBe( 85 | 'paths/users%2F%7BuserId%7D/post' // Path encoded, no leading %2F 86 | ); 87 | expect(buildOperationUriSuffix('users/{userId}', 'post')).toBe( 88 | 'paths/users%2F%7BuserId%7D/post' // Handles no leading slash input 89 | ); 90 | expect(buildOperationUriSuffix('/users', 'GET')).toBe('paths/users/get'); // Method lowercased 91 | }); 92 | 93 | test('buildPathItemUriSuffix builds correct suffix and encodes path (no leading slash)', () => { 94 | expect(buildPathItemUriSuffix('/users')).toBe('paths/users'); // No leading slash encoded 95 | expect(buildPathItemUriSuffix('/users/{userId}')).toBe('paths/users%2F%7BuserId%7D'); // Path encoded, no leading %2F 96 | expect(buildPathItemUriSuffix('users/{userId}')).toBe('paths/users%2F%7BuserId%7D'); // Handles no leading slash input 97 | }); 98 | 99 | test('buildTopLevelFieldUriSuffix builds correct suffix', () => { 100 | expect(buildTopLevelFieldUriSuffix('info')).toBe('info'); 101 | expect(buildTopLevelFieldUriSuffix('paths')).toBe('paths'); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/fixtures/complex-endpoint.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Complex Endpoint Test API", 5 | "version": "1.0.0" 6 | }, 7 | "paths": { 8 | "/api/v1/organizations/{orgId}/projects/{projectId}/tasks": { 9 | "get": { 10 | "operationId": "getProjectTasks", 11 | "summary": "Get Tasks", 12 | "description": "Retrieve a list of tasks for a specific project.", 13 | "parameters": [ 14 | { 15 | "name": "orgId", 16 | "in": "path", 17 | "required": true, 18 | "schema": { "type": "string" } 19 | }, 20 | { 21 | "name": "projectId", 22 | "in": "path", 23 | "required": true, 24 | "schema": { "type": "string" } 25 | }, 26 | { 27 | "name": "status", 28 | "in": "query", 29 | "schema": { 30 | "type": "string", 31 | "enum": ["active", "completed"] 32 | } 33 | }, 34 | { 35 | "name": "sort", 36 | "in": "query", 37 | "schema": { 38 | "type": "string", 39 | "enum": ["created", "updated", "priority"] 40 | } 41 | } 42 | ], 43 | "responses": { 44 | "200": { 45 | "description": "List of tasks", 46 | "content": { 47 | "application/json": { 48 | "schema": { 49 | "$ref": "#/components/schemas/TaskList" 50 | } 51 | } 52 | } 53 | } 54 | } 55 | }, 56 | "post": { 57 | "operationId": "createProjectTask", 58 | "summary": "Create Task", 59 | "description": "Create a new task within a project.", 60 | "requestBody": { 61 | "required": true, 62 | "content": { 63 | "application/json": { 64 | "schema": { 65 | "$ref": "#/components/schemas/CreateTaskRequest" 66 | } 67 | } 68 | } 69 | }, 70 | "responses": { 71 | "201": { 72 | "description": "Task created", 73 | "content": { 74 | "application/json": { 75 | "schema": { 76 | "$ref": "#/components/schemas/Task" 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | }, 85 | "components": { 86 | "schemas": { 87 | "Task": { 88 | "type": "object", 89 | "required": ["id", "title", "status"], 90 | "properties": { 91 | "id": { 92 | "type": "string", 93 | "format": "uuid" 94 | }, 95 | "title": { 96 | "type": "string" 97 | }, 98 | "status": { 99 | "type": "string", 100 | "enum": ["active", "completed"] 101 | }, 102 | "priority": { 103 | "type": "integer", 104 | "minimum": 1, 105 | "maximum": 5 106 | } 107 | } 108 | }, 109 | "TaskList": { 110 | "type": "object", 111 | "required": ["items"], 112 | "properties": { 113 | "items": { 114 | "type": "array", 115 | "items": { 116 | "$ref": "#/components/schemas/Task" 117 | } 118 | }, 119 | "totalCount": { 120 | "type": "integer" 121 | } 122 | } 123 | }, 124 | "CreateTaskRequest": { 125 | "type": "object", 126 | "required": ["title"], 127 | "properties": { 128 | "title": { 129 | "type": "string" 130 | }, 131 | "status": { 132 | "type": "string", 133 | "enum": ["active", "completed"], 134 | "default": "active" 135 | }, 136 | "priority": { 137 | "type": "integer", 138 | "minimum": 1, 139 | "maximum": 5, 140 | "default": 3 141 | } 142 | } 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /test/fixtures/empty-api.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "Empty API", 5 | "version": "1.0.0" 6 | }, 7 | "paths": {} 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/multi-component-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "Multi Component Type Test API", 5 | "version": "1.0.0" 6 | }, 7 | "paths": { 8 | "/ping": { 9 | "get": { 10 | "summary": "Ping", 11 | "operationId": "ping", 12 | "responses": { 13 | "200": { 14 | "description": "OK", 15 | "content": { 16 | "application/json": { 17 | "schema": { 18 | "$ref": "#/components/schemas/Pong" 19 | } 20 | } 21 | } 22 | } 23 | }, 24 | "parameters": [ 25 | { 26 | "$ref": "#/components/parameters/TraceId" 27 | } 28 | ] 29 | } 30 | } 31 | }, 32 | "components": { 33 | "schemas": { 34 | "Pong": { 35 | "type": "object", 36 | "properties": { 37 | "message": { 38 | "type": "string" 39 | } 40 | } 41 | } 42 | }, 43 | "parameters": { 44 | "TraceId": { 45 | "name": "X-Trace-ID", 46 | "in": "header", 47 | "required": false, 48 | "schema": { 49 | "type": "string", 50 | "format": "uuid" 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/fixtures/paths-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Path Testing API", 5 | "version": "1.0.0" 6 | }, 7 | "paths": { 8 | "/project/tasks/{taskId}": { 9 | "get": { 10 | "summary": "Get task details", 11 | "parameters": [ 12 | { 13 | "name": "taskId", 14 | "in": "path", 15 | "required": true, 16 | "schema": { "type": "string" } 17 | } 18 | ], 19 | "responses": { 20 | "200": { 21 | "description": "Task details" 22 | } 23 | } 24 | } 25 | }, 26 | "/article/{articleId}/comment/{commentId}": { 27 | "get": { 28 | "summary": "Get comment on article", 29 | "parameters": [ 30 | { 31 | "name": "articleId", 32 | "in": "path", 33 | "required": true, 34 | "schema": { "type": "string" } 35 | }, 36 | { 37 | "name": "commentId", 38 | "in": "path", 39 | "required": true, 40 | "schema": { "type": "string" } 41 | } 42 | ], 43 | "responses": { 44 | "200": { 45 | "description": "Comment details" 46 | } 47 | } 48 | } 49 | }, 50 | "/sub/sub/sub/sub/folded/entrypoint": { 51 | "post": { 52 | "summary": "Deeply nested endpoint", 53 | "responses": { 54 | "201": { 55 | "description": "Created" 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/fixtures/sample-api.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Sample API", 5 | "version": "1.0.0" 6 | }, 7 | "paths": { 8 | "/users": { 9 | "get": { 10 | "summary": "List users", 11 | "operationId": "listUsers", 12 | "responses": { 13 | "200": { 14 | "description": "List of users", 15 | "content": { 16 | "application/json": { 17 | "schema": { 18 | "$ref": "#/components/schemas/UserList" 19 | } 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | }, 27 | "components": { 28 | "schemas": { 29 | "User": { 30 | "type": "object", 31 | "required": ["id", "email"], 32 | "properties": { 33 | "id": { 34 | "type": "integer", 35 | "format": "int64" 36 | }, 37 | "email": { 38 | "type": "string", 39 | "format": "email" 40 | }, 41 | "name": { 42 | "type": "string" 43 | }, 44 | "status": { 45 | "type": "string", 46 | "enum": ["active", "inactive"] 47 | } 48 | } 49 | }, 50 | "UserList": { 51 | "type": "object", 52 | "required": ["users"], 53 | "properties": { 54 | "users": { 55 | "type": "array", 56 | "items": { 57 | "$ref": "#/components/schemas/User" 58 | } 59 | }, 60 | "total": { 61 | "type": "integer", 62 | "format": "int32" 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/fixtures/sample-v2-api.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "Simple Swagger 2.0 API", 5 | "version": "1.0.0", 6 | "description": "A simple API definition in Swagger 2.0 format for testing conversion." 7 | }, 8 | "host": "localhost:3000", 9 | "basePath": "/v2", 10 | "schemes": ["http"], 11 | "paths": { 12 | "/ping": { 13 | "get": { 14 | "summary": "Check service health", 15 | "description": "Returns a simple pong message.", 16 | "produces": ["application/json"], 17 | "responses": { 18 | "200": { 19 | "description": "Successful response", 20 | "schema": { 21 | "$ref": "#/definitions/Pong" 22 | } 23 | } 24 | } 25 | } 26 | } 27 | }, 28 | "definitions": { 29 | "Pong": { 30 | "type": "object", 31 | "properties": { 32 | "message": { 33 | "type": "string", 34 | "example": "pong" 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'path'; 3 | 4 | // Extend timeout for E2E tests 5 | jest.setTimeout(30000); 6 | 7 | // Clean up any previous test artifacts 8 | beforeAll(async () => { 9 | // Create required directories if they don't exist 10 | const dirs = ['dist', 'dist/src', 'test/fixtures']; 11 | 12 | for (const dir of dirs) { 13 | try { 14 | await fs.mkdir(dir, { recursive: true }); 15 | } catch (error) { 16 | // Ignore if directory already exists 17 | if ((error as { code?: string }).code !== 'EEXIST') { 18 | throw error; 19 | } 20 | } 21 | } 22 | 23 | // Verify sample OpenAPI spec exists 24 | const specPath = path.resolve(process.cwd(), 'test/fixtures/sample-api.json'); 25 | try { 26 | await fs.access(specPath); 27 | } catch { 28 | throw new Error(`Sample OpenAPI spec not found at ${specPath}`); 29 | } 30 | }); 31 | 32 | // Custom matchers could be added here if needed 33 | -------------------------------------------------------------------------------- /test/utils/console-helpers.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | // Define a type for the console.error function signature using unknown 4 | type ConsoleErrorType = (message?: unknown, ...optionalParams: unknown[]) => void; 5 | 6 | /** 7 | * Temporarily suppresses console.error messages that match the expected pattern 8 | * during the execution of a provided function (typically an async test operation). 9 | * Unexpected console.error messages will still be logged. 10 | * 11 | * @param expectedMessage The exact string or a RegExp to match against the console.error message. 12 | * @param fnToRun The async function to execute while suppression is active. 13 | * @returns The result of the fnToRun function. 14 | */ 15 | export async function suppressExpectedConsoleError( 16 | expectedMessage: string | RegExp, 17 | fnToRun: () => T | Promise 18 | ): Promise { 19 | // Use the defined type for the original function 20 | const originalConsoleError: ConsoleErrorType = console.error; 21 | 22 | // Use unknown in the mock implementation signature 23 | const consoleErrorSpy = jest 24 | .spyOn(console, 'error') 25 | .mockImplementation((message?: unknown, ...args: unknown[]) => { 26 | const messageStr = String(message); // String conversion handles unknown 27 | const shouldSuppress = 28 | expectedMessage instanceof RegExp 29 | ? expectedMessage.test(messageStr) 30 | : messageStr === expectedMessage; 31 | 32 | if (!shouldSuppress) { 33 | // Call the original implementation for unexpected errors 34 | // We still need to handle the potential type mismatch for the spread 35 | // Using Function.prototype.apply is a safer way to call with dynamic args 36 | Function.prototype.apply.call(originalConsoleError, console, [message, ...args]); 37 | } 38 | // If it matches, do nothing (suppress) 39 | }); 40 | 41 | try { 42 | // Execute the function that is expected to trigger the error log 43 | return await fnToRun(); 44 | } finally { 45 | // Restore the original console.error 46 | consoleErrorSpy.mockRestore(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/utils/mcp-test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 3 | // import path from 'path'; 4 | 5 | // Export the interface 6 | export interface McpTestContext { 7 | client: Client; 8 | transport: StdioClientTransport; 9 | cleanup: () => Promise; 10 | } 11 | 12 | interface StartServerOptions { 13 | outputFormat?: 'json' | 'yaml' | 'json-minified'; 14 | } 15 | 16 | /** 17 | * Start MCP server with test configuration 18 | */ 19 | export async function startMcpServer( 20 | specPath: string, 21 | options: StartServerOptions = {} 22 | ): Promise { 23 | let transport: StdioClientTransport | undefined; 24 | let client: Client | undefined; 25 | 26 | try { 27 | // Initialize transport with spec path as argument 28 | transport = new StdioClientTransport({ 29 | command: 'node', 30 | args: [ 31 | 'dist/src/index.js', 32 | // path.resolve(specPath), 33 | specPath, 34 | ...(options.outputFormat ? ['--output-format', options.outputFormat] : []), 35 | ], 36 | stderr: 'inherit', // Pass through server errors normally - they're part of E2E testing 37 | }); 38 | 39 | // Initialize client 40 | client = new Client({ 41 | name: 'test-client', 42 | version: '1.0.0', 43 | }); 44 | 45 | await client.connect(transport); 46 | 47 | // Create cleanup function 48 | const cleanup = async (): Promise => { 49 | if (transport) { 50 | await transport.close(); 51 | } 52 | }; 53 | 54 | return { 55 | client, 56 | transport, 57 | cleanup, 58 | }; 59 | } catch (error) { 60 | // Clean up on error 61 | if (transport) { 62 | await transport.close(); 63 | } 64 | throw error; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/utils/test-types.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from 'openapi-types'; 2 | 3 | export interface EndpointSuccessResponse { 4 | method: string; 5 | path: string; 6 | parameters?: OpenAPIV3.ParameterObject[]; 7 | requestBody?: OpenAPIV3.RequestBodyObject; 8 | responses: { [key: string]: OpenAPIV3.ResponseObject }; 9 | } 10 | 11 | export interface EndpointErrorResponse { 12 | method: string; 13 | path: string; 14 | error: string; 15 | } 16 | 17 | export type EndpointResponse = EndpointSuccessResponse | EndpointErrorResponse; 18 | 19 | export function isEndpointErrorResponse(obj: unknown): obj is EndpointErrorResponse { 20 | return ( 21 | typeof obj === 'object' && 22 | obj !== null && 23 | typeof (obj as EndpointErrorResponse).error === 'string' 24 | ); 25 | } 26 | 27 | export interface ResourceContent { 28 | uri: string; 29 | mimeType: string; 30 | text: string; 31 | } 32 | 33 | export interface ResourceResponse { 34 | contents: ResourceContent[]; 35 | } 36 | 37 | // Types for Schema Resource E2E tests 38 | export type SchemaSuccessResponse = OpenAPIV3.SchemaObject; // Use type alias 39 | 40 | export interface SchemaErrorResponse { 41 | name: string; 42 | error: string; 43 | } 44 | 45 | export type SchemaResponse = SchemaSuccessResponse | SchemaErrorResponse; 46 | 47 | export function isSchemaErrorResponse(obj: unknown): obj is SchemaErrorResponse { 48 | return ( 49 | typeof obj === 'object' && 50 | obj !== null && 51 | typeof (obj as SchemaErrorResponse).name === 'string' && // Check for name property 52 | typeof (obj as SchemaErrorResponse).error === 'string' 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist", 11 | "declaration": true, 12 | "sourceMap": true, 13 | "rootDir": ".", 14 | "lib": ["ES2020"], 15 | "noImplicitAny": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "types": ["jest", "node"] 22 | }, 23 | "include": ["src/**/*"], 24 | "exclude": ["node_modules", "dist", "local-docs", "test"] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*", "test/**/*"], 4 | "exclude": ["node_modules", "dist", "local-docs"] 5 | } 6 | --------------------------------------------------------------------------------