bytestay-specwise-mcp 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ name: pytest · ruff (Python ${{ matrix.python }})
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ matrix:
17
+ python: ["3.10", "3.13"]
18
+ steps:
19
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
20
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
21
+ - name: Install
22
+ run: |
23
+ uv venv -p ${{ matrix.python }} .venv
24
+ uv pip install --python .venv/bin/python -e ".[dev]"
25
+ - name: Tests (no network, 100% coverage)
26
+ run: .venv/bin/pytest
27
+ - name: Lint
28
+ run: .venv/bin/ruff check src tests scripts
29
+ - name: Build
30
+ run: uv build
@@ -0,0 +1,42 @@
1
+ name: MCP registry
2
+
3
+ # Publishes server.json to the official MCP registry (registry.modelcontextprotocol.io).
4
+ # GitHub OIDC proves the run belongs to the bytestay org, which the interactive
5
+ # login can't do for an org namespace. No secret is stored.
6
+ # Called by release.yml after the PyPI publish, or run by hand.
7
+ on:
8
+ workflow_call:
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ publish:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ id-token: write
19
+ contents: read
20
+ env:
21
+ MCP_PUBLISHER_VERSION: v1.8.7
22
+ steps:
23
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
24
+ - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
25
+ - name: Download and verify mcp-publisher
26
+ run: |
27
+ set -euo pipefail
28
+ archive="mcp-publisher_linux_amd64.tar.gz"
29
+ base_url="https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}"
30
+ curl --fail --location --silent --show-error --output "$archive" "$base_url/$archive"
31
+ curl --fail --location --silent --show-error --output "$archive.sigstore.json" "$base_url/$archive.sigstore.json"
32
+ cosign verify-blob --bundle "$archive.sigstore.json" \
33
+ --certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/${MCP_PUBLISHER_VERSION}" \
34
+ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
35
+ "$archive"
36
+ tar xzf "$archive" mcp-publisher
37
+ - name: Validate server.json
38
+ run: ./mcp-publisher validate
39
+ - name: Authenticate to the MCP registry
40
+ run: ./mcp-publisher login github-oidc
41
+ - name: Publish
42
+ run: ./mcp-publisher publish
@@ -0,0 +1,31 @@
1
+ name: publish
2
+
3
+ # ByteStay copy only (marca/rebrand overlay): manual PyPI publish from a tag, with Trusted Publishing.
4
+ # Push a tag (git tag bytestay-specwise-mcp-v0.2.2 && git push origin bytestay-specwise-mcp-v0.2.2), or run it by hand on a tag.
5
+ on:
6
+ push:
7
+ tags: ["bytestay-specwise-mcp-v*"]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ publish:
15
+ if: startsWith(github.ref, 'refs/tags/')
16
+ runs-on: ubuntu-latest
17
+ environment: pypi
18
+ permissions:
19
+ id-token: write
20
+ contents: read
21
+ steps:
22
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
23
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
24
+ - name: Check the tag matches the package version
25
+ run: |
26
+ version="$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)"
27
+ [ "${GITHUB_REF_NAME##*-v}" = "$version" ] || { echo "::error::tag $GITHUB_REF_NAME != version $version"; exit 1; }
28
+ - name: Build
29
+ run: uv build
30
+ - name: Publish to PyPI
31
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
@@ -0,0 +1,42 @@
1
+ name: Release
2
+
3
+ # release-please opens a release PR from Conventional Commits; merging it creates the GitHub release,
4
+ # then the package is published to PyPI with Trusted Publishing (no token stored anywhere).
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: write
12
+ pull-requests: write
13
+
14
+ jobs:
15
+ release-please:
16
+ runs-on: ubuntu-latest
17
+ outputs:
18
+ release_created: ${{ steps.release.outputs.release_created }}
19
+ steps:
20
+ - id: release
21
+ uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4
22
+ with:
23
+ # Falls back to GITHUB_TOKEN; needs "Allow GitHub Actions to create and approve pull requests".
24
+ token: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
25
+ config-file: release-please-config.json
26
+ manifest-file: .release-please-manifest.json
27
+
28
+ publish:
29
+ needs: release-please
30
+ if: needs.release-please.outputs.release_created == 'true'
31
+ runs-on: ubuntu-latest
32
+ environment: pypi
33
+ permissions:
34
+ id-token: write
35
+ contents: read
36
+ steps:
37
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
38
+ - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
39
+ - name: Build
40
+ run: uv build
41
+ - name: Publish to PyPI
42
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .coverage
5
+ dist/
6
+ *.egg-info/
7
+ .DS_Store
@@ -0,0 +1 @@
1
+ {".":"0.2.2"}
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## [0.2.2](https://github.com/hybridforgelab/specwise-mcp/compare/hybridforgelab-specwise-mcp-v0.2.1...hybridforgelab-specwise-mcp-v0.2.2) (2026-11-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * link the PyPI page to the GitHub repo ([#11](https://github.com/hybridforgelab/specwise-mcp/issues/11)) ([1dbf864](https://github.com/hybridforgelab/specwise-mcp/commit/1dbf86421cefd3d0b5fb8456fc7347c40f8b52cc))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * M8ven Trust Index badge ([#9](https://github.com/hybridforgelab/specwise-mcp/issues/9)) ([16ec6c2](https://github.com/hybridforgelab/specwise-mcp/commit/16ec6c247e84a5a92d9a40eeeff9ed8d0dcc6b56))
14
+
15
+ ## [0.2.1](https://github.com/hybridforgelab/specwise-mcp/compare/hybridforgelab-specwise-mcp-v0.2.0...hybridforgelab-specwise-mcp-v0.2.1) (2026-11-03)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * declare all four tool hints ([#7](https://github.com/hybridforgelab/specwise-mcp/issues/7)) ([887b3cc](https://github.com/hybridforgelab/specwise-mcp/commit/887b3cc836d8cb215eef39a36521f92de3dd6094))
21
+
22
+ ## [0.2.0](https://github.com/hybridforgelab/specwise-mcp/compare/hybridforgelab-specwise-mcp-v0.1.0...hybridforgelab-specwise-mcp-v0.2.0) (2026-11-02)
23
+
24
+
25
+ ### Features
26
+
27
+ * list the server in the official MCP registry ([#3](https://github.com/hybridforgelab/specwise-mcp/issues/3)) ([6ae6843](https://github.com/hybridforgelab/specwise-mcp/commit/6ae684387292bf14756d8503b182154054aed9d1))
28
+
29
+ ## 0.1.0 (2026-10-30)
30
+
31
+
32
+ ### Features
33
+
34
+ * specwise MCP server ([06a23c1](https://github.com/hybridforgelab/specwise-mcp/commit/06a23c10b5b7f0bee9f72b93bbe4d00dae6cc9fc))
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ByteStay
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.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.5
2
+ Name: bytestay-specwise-mcp
3
+ Version: 0.2.2
4
+ Summary: OpenAPI validation and breaking-change diffs (also against a git ref) and JSON Schema validation for coding agents. Bring your own RapidAPI or api.market key.
5
+ Project-URL: Homepage, https://specwise.bytestay.pt
6
+ Project-URL: Repository, https://github.com/bytestay/specwise-mcp
7
+ Project-URL: Issues, https://github.com/bytestay/specwise-mcp/issues
8
+ Project-URL: Changelog, https://github.com/bytestay/specwise-mcp/blob/main/CHANGELOG.md
9
+ Author-email: ByteStay <hello@bytestay.pt>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: mcp<3,>=2.3
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
17
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
18
+ Requires-Dist: pytest>=8; extra == 'dev'
19
+ Requires-Dist: ruff>=0.6; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Specwise MCP server
23
+
24
+ <!-- mcp-name: io.github.bytestay/specwise-mcp -->
25
+
26
+ [![M8ven Score](https://m8ven.ai/badge/mcp/hybridforgelab/specwise-mcp?variant=verified)](https://m8ven.ai/mcp/hybridforgelab/specwise-mcp?s=readme)
27
+ [![CI](https://github.com/bytestay/specwise-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bytestay/specwise-mcp/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/bytestay-specwise-mcp)](https://pypi.org/project/bytestay-specwise-mcp/)
29
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/bytestay-specwise-mcp/)
30
+ [![License: MIT](https://img.shields.io/github/license/bytestay/specwise-mcp)](LICENSE)
31
+
32
+ Let your coding agent check an API contract: validate an OpenAPI document, find the breaking changes between two versions (also against git), and validate JSON Schemas and data.
33
+
34
+ The server is open source (MIT). It calls the [Specwise API](https://specwise.bytestay.pt), which is paid, with a free plan.
35
+
36
+ - You use **your own key, from RapidAPI or api.market**.
37
+ - Every tool call is one request on your plan.
38
+ - The free Basic plan has 15 requests a month, with a hard limit, so it never runs up a bill.
39
+
40
+ ## 1. Get a key
41
+
42
+ | marketplace | |
43
+ |---|---|
44
+ | RapidAPI | [plans](https://rapidapi.com/hybrid-forge-lab-hybrid-forge-lab-default/api/specwise-openapi-validator-and-diff-api/pricing) |
45
+ | api.market | [store](https://api.market/store/hybrid-forge-lab/specwise) |
46
+
47
+ ## 2. Add the server to your MCP client
48
+
49
+ The server runs locally over stdio with [uv](https://docs.astral.sh/uv/). Put your key in its environment.
50
+
51
+ **JSON configuration** (Claude Desktop, Cursor, VS Code and most clients):
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "specwise": {
57
+ "command": "uvx",
58
+ "args": ["--from", "bytestay-specwise-mcp", "specwise-mcp"],
59
+ "env": { "RAPIDAPI_KEY": "your-rapidapi-key" }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ **Claude Code:**
66
+
67
+ ```bash
68
+ claude mcp add specwise --env RAPIDAPI_KEY=your-rapidapi-key -- uvx --from bytestay-specwise-mcp specwise-mcp
69
+ ```
70
+
71
+ ### Environment
72
+
73
+ | variable | meaning |
74
+ |---|---|
75
+ | `RAPIDAPI_KEY` | your RapidAPI key |
76
+ | `API_MARKET_KEY` | your api.market key, used when `RAPIDAPI_KEY` is not set |
77
+ | `HFL_MARKETPLACE` | `rapidapi` or `apimarket`, when both keys are set |
78
+
79
+ Without a key the server still starts. The first tool call then explains where to get one.
80
+
81
+ Also listed on Glama:
82
+
83
+ [![specwise-mcp on Glama](https://glama.ai/mcp/servers/hybridforgelab/specwise-mcp/badge)](https://glama.ai/mcp/servers/hybridforgelab/specwise-mcp)
84
+
85
+ ## 3. Tools
86
+
87
+ | tool | what it does |
88
+ |---|---|
89
+ | `validate_openapi(spec=… \| spec_path=…)` | every error with its JSON Pointer, line and column, plus lint warnings (OpenAPI 3.x and Swagger 2.0) |
90
+ | `diff_openapi(...)` | each change between two OpenAPI 3.x versions is breaking, warning, non-breaking or info, with a semver suggestion |
91
+ | `validate_json_schema(schema=… \| schema_path=…, instance=… \| instance_path=…, draft=None)` | checks the schema against its meta-schema and, if given, the data against the schema |
92
+
93
+ `diff_openapi` takes the two versions in one of three ways:
94
+
95
+ - `base` and `revision` as text;
96
+ - `base_path` and `revision_path` as files;
97
+ - `revision_path` with `base_git_ref`. For example, after editing a spec: `revision_path="openapi.yaml", base_git_ref="main"`.
98
+
99
+ Every tool returns the API's JSON response.
100
+
101
+ - When the marketplace reports it, `quota_remaining` is added: the requests left on your plan this month.
102
+ - Errors are explained in plain words, with what to do next:
103
+ - key refused, with the link to subscribe;
104
+ - quota reached, with the plans;
105
+ - input the API couldn't process, with the API's reason.
106
+
107
+ ## Privacy
108
+
109
+ - Local files are read only when a tool is given their path. They are sent to the API for that one request.
110
+ - The API processes inputs in memory and doesn't store them. See the [privacy policy](https://specwise.bytestay.pt/privacy.html).
111
+ - Your key is only sent to the marketplace you chose.
112
+
113
+ ## Limits
114
+
115
+ - Documents up to about 900 KiB each.
116
+ - External `$ref`s are not fetched. Bundle multi-file specs first (`redocly bundle`).
117
+ - The diff needs OpenAPI 3.x; Swagger 2.0 can be validated, not diffed.
118
+ - The server needs Python 3.10+ (uv installs it for you).
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ uv venv && uv pip install -e ".[dev]"
124
+ .venv/bin/pytest # unit tests, no network; 100% coverage required
125
+ .venv/bin/ruff check src tests scripts
126
+ ```
127
+
128
+ `scripts/e2e_stdio.py specwise` runs the server over stdio, as a client would, and calls its tools against the real API. It needs `RAPIDAPI_KEY` or `API_MARKET_KEY`.
129
+
130
+ ## Licence
131
+
132
+ MIT. Specwise itself is a paid API with a free plan, run by [ByteStay](https://bytestay.pt).
@@ -0,0 +1,111 @@
1
+ # Specwise MCP server
2
+
3
+ <!-- mcp-name: io.github.bytestay/specwise-mcp -->
4
+
5
+ [![M8ven Score](https://m8ven.ai/badge/mcp/hybridforgelab/specwise-mcp?variant=verified)](https://m8ven.ai/mcp/hybridforgelab/specwise-mcp?s=readme)
6
+ [![CI](https://github.com/bytestay/specwise-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bytestay/specwise-mcp/actions/workflows/ci.yml)
7
+ [![PyPI](https://img.shields.io/pypi/v/bytestay-specwise-mcp)](https://pypi.org/project/bytestay-specwise-mcp/)
8
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/bytestay-specwise-mcp/)
9
+ [![License: MIT](https://img.shields.io/github/license/bytestay/specwise-mcp)](LICENSE)
10
+
11
+ Let your coding agent check an API contract: validate an OpenAPI document, find the breaking changes between two versions (also against git), and validate JSON Schemas and data.
12
+
13
+ The server is open source (MIT). It calls the [Specwise API](https://specwise.bytestay.pt), which is paid, with a free plan.
14
+
15
+ - You use **your own key, from RapidAPI or api.market**.
16
+ - Every tool call is one request on your plan.
17
+ - The free Basic plan has 15 requests a month, with a hard limit, so it never runs up a bill.
18
+
19
+ ## 1. Get a key
20
+
21
+ | marketplace | |
22
+ |---|---|
23
+ | RapidAPI | [plans](https://rapidapi.com/hybrid-forge-lab-hybrid-forge-lab-default/api/specwise-openapi-validator-and-diff-api/pricing) |
24
+ | api.market | [store](https://api.market/store/hybrid-forge-lab/specwise) |
25
+
26
+ ## 2. Add the server to your MCP client
27
+
28
+ The server runs locally over stdio with [uv](https://docs.astral.sh/uv/). Put your key in its environment.
29
+
30
+ **JSON configuration** (Claude Desktop, Cursor, VS Code and most clients):
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "specwise": {
36
+ "command": "uvx",
37
+ "args": ["--from", "bytestay-specwise-mcp", "specwise-mcp"],
38
+ "env": { "RAPIDAPI_KEY": "your-rapidapi-key" }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ **Claude Code:**
45
+
46
+ ```bash
47
+ claude mcp add specwise --env RAPIDAPI_KEY=your-rapidapi-key -- uvx --from bytestay-specwise-mcp specwise-mcp
48
+ ```
49
+
50
+ ### Environment
51
+
52
+ | variable | meaning |
53
+ |---|---|
54
+ | `RAPIDAPI_KEY` | your RapidAPI key |
55
+ | `API_MARKET_KEY` | your api.market key, used when `RAPIDAPI_KEY` is not set |
56
+ | `HFL_MARKETPLACE` | `rapidapi` or `apimarket`, when both keys are set |
57
+
58
+ Without a key the server still starts. The first tool call then explains where to get one.
59
+
60
+ Also listed on Glama:
61
+
62
+ [![specwise-mcp on Glama](https://glama.ai/mcp/servers/hybridforgelab/specwise-mcp/badge)](https://glama.ai/mcp/servers/hybridforgelab/specwise-mcp)
63
+
64
+ ## 3. Tools
65
+
66
+ | tool | what it does |
67
+ |---|---|
68
+ | `validate_openapi(spec=… \| spec_path=…)` | every error with its JSON Pointer, line and column, plus lint warnings (OpenAPI 3.x and Swagger 2.0) |
69
+ | `diff_openapi(...)` | each change between two OpenAPI 3.x versions is breaking, warning, non-breaking or info, with a semver suggestion |
70
+ | `validate_json_schema(schema=… \| schema_path=…, instance=… \| instance_path=…, draft=None)` | checks the schema against its meta-schema and, if given, the data against the schema |
71
+
72
+ `diff_openapi` takes the two versions in one of three ways:
73
+
74
+ - `base` and `revision` as text;
75
+ - `base_path` and `revision_path` as files;
76
+ - `revision_path` with `base_git_ref`. For example, after editing a spec: `revision_path="openapi.yaml", base_git_ref="main"`.
77
+
78
+ Every tool returns the API's JSON response.
79
+
80
+ - When the marketplace reports it, `quota_remaining` is added: the requests left on your plan this month.
81
+ - Errors are explained in plain words, with what to do next:
82
+ - key refused, with the link to subscribe;
83
+ - quota reached, with the plans;
84
+ - input the API couldn't process, with the API's reason.
85
+
86
+ ## Privacy
87
+
88
+ - Local files are read only when a tool is given their path. They are sent to the API for that one request.
89
+ - The API processes inputs in memory and doesn't store them. See the [privacy policy](https://specwise.bytestay.pt/privacy.html).
90
+ - Your key is only sent to the marketplace you chose.
91
+
92
+ ## Limits
93
+
94
+ - Documents up to about 900 KiB each.
95
+ - External `$ref`s are not fetched. Bundle multi-file specs first (`redocly bundle`).
96
+ - The diff needs OpenAPI 3.x; Swagger 2.0 can be validated, not diffed.
97
+ - The server needs Python 3.10+ (uv installs it for you).
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ uv venv && uv pip install -e ".[dev]"
103
+ .venv/bin/pytest # unit tests, no network; 100% coverage required
104
+ .venv/bin/ruff check src tests scripts
105
+ ```
106
+
107
+ `scripts/e2e_stdio.py specwise` runs the server over stdio, as a client would, and calls its tools against the real API. It needs `RAPIDAPI_KEY` or `API_MARKET_KEY`.
108
+
109
+ ## Licence
110
+
111
+ MIT. Specwise itself is a paid API with a free plan, run by [ByteStay](https://bytestay.pt).
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
3
+ "maintainers": ["fredfrazao"]
4
+ }
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "bytestay-specwise-mcp"
3
+ version = "0.2.2"
4
+ description = "OpenAPI validation and breaking-change diffs (also against a git ref) and JSON Schema validation for coding agents. Bring your own RapidAPI or api.market key."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "ByteStay", email = "hello@bytestay.pt" }]
9
+ dependencies = ["mcp>=2.3,<3", "httpx>=0.27"]
10
+
11
+ [project.urls]
12
+ Homepage = "https://specwise.bytestay.pt"
13
+ Repository = "https://github.com/bytestay/specwise-mcp"
14
+ Issues = "https://github.com/bytestay/specwise-mcp/issues"
15
+ Changelog = "https://github.com/bytestay/specwise-mcp/blob/main/CHANGELOG.md"
16
+
17
+ [project.scripts]
18
+ specwise-mcp = "specwise_mcp.server:main"
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=5", "ruff>=0.6"]
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/specwise_mcp"]
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "auto"
32
+ addopts = "-q --cov=specwise_mcp --cov-report=term-missing:skip-covered --cov-fail-under=100"
33
+ testpaths = ["tests"]
34
+
35
+ [tool.ruff]
36
+ line-length = 120
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "release-type": "python",
4
+ "packages": {
5
+ ".": {
6
+ "package-name": "bytestay-specwise-mcp",
7
+ "changelog-path": "CHANGELOG.md",
8
+ "extra-files": [
9
+ "src/specwise_mcp/__init__.py",
10
+ {
11
+ "type": "json",
12
+ "path": "server.json",
13
+ "jsonpath": "$.version"
14
+ },
15
+ {
16
+ "type": "json",
17
+ "path": "server.json",
18
+ "jsonpath": "$.packages[0].version"
19
+ }
20
+ ]
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,66 @@
1
+ """End-to-end check of the Specwise server over stdio, as an MCP client would run it, against the real API.
2
+
3
+ HFL_API_URL=https://specwise-api.bytestay.pt HFL_API_BEARER=... python scripts/e2e_stdio.py specwise
4
+
5
+ Needs either HFL_API_URL + HFL_API_BEARER (maintainers, no marketplace quota spent) or RAPIDAPI_KEY / API_MARKET_KEY.
6
+ Prints one line per check and exits non-zero on any failure. Never prints a key.
7
+ """
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ from mcp import ClientSession
16
+ from mcp.client.stdio import StdioServerParameters, stdio_client
17
+
18
+ ROOT = Path(__file__).resolve().parent.parent
19
+ FIX = Path.home() / "code/ffrazao/specwise-action/tests/fixtures"
20
+ ENV_KEYS = ("HFL_API_URL", "HFL_API_BEARER", "RAPIDAPI_KEY", "API_MARKET_KEY", "HFL_MARKETPLACE", "PATH", "HOME")
21
+
22
+
23
+ def data(result):
24
+ assert not result.is_error, result.content[0].text if result.content else "error"
25
+ return json.loads(result.content[0].text)
26
+
27
+
28
+ def checks(name, tmp: Path):
29
+ if name == "specwise":
30
+ return [
31
+ ("validate_openapi file", "validate_openapi", {"spec_path": str(FIX / "base.yaml")},
32
+ lambda d: d["valid"] is True),
33
+ ("diff_openapi files", "diff_openapi",
34
+ {"base_path": str(FIX / "base.yaml"), "revision_path": str(FIX / "revision.yaml")},
35
+ lambda d: d["breaking"] is True and d["summary"]["breaking"] == 3),
36
+ ("validate_json_schema", "validate_json_schema",
37
+ {"schema": '{"type": "object", "required": ["a"]}', "instance": "{}"},
38
+ lambda d: d["valid"] is False and d["error_count"] == 1),
39
+ ]
40
+ raise SystemExit(f"unknown server {name}")
41
+
42
+
43
+ async def main(name):
44
+ env = {k: os.environ[k] for k in ENV_KEYS if k in os.environ}
45
+ params = StdioServerParameters(command=str(ROOT / ".venv/bin/python"), args=["-m", "specwise_mcp.server"], env=env)
46
+ failures = 0
47
+ with tempfile.TemporaryDirectory() as tmp:
48
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
49
+ await session.initialize()
50
+ tools = sorted(t.name for t in (await session.list_tools()).tools)
51
+ print(f"{name}: tools {tools}")
52
+ for label, tool, args, ok in checks(name, Path(tmp)):
53
+ try:
54
+ d = data(await session.call_tool(tool, args))
55
+ good = ok(d)
56
+ extra = f" quota_remaining={d['quota_remaining']}" if "quota_remaining" in d else ""
57
+ print(f" {'PASS' if good else 'FAIL'} {label}{extra}")
58
+ except Exception as exc: # noqa: BLE001 - report and continue
59
+ good = False
60
+ print(f" FAIL {label}: {str(exc)[:200]}")
61
+ failures += not good
62
+ return failures
63
+
64
+
65
+ if __name__ == "__main__":
66
+ sys.exit(1 if asyncio.run(main(sys.argv[1])) else 0)
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.bytestay/specwise-mcp",
4
+ "description": "OpenAPI and JSON Schema validation and OpenAPI breaking-change diffs for AI agents.",
5
+ "repository": {
6
+ "url": "https://github.com/bytestay/specwise-mcp",
7
+ "source": "github",
8
+ "id": "1445856363"
9
+ },
10
+ "websiteUrl": "https://specwise.bytestay.pt/integration.html#mcp-server",
11
+ "version": "0.2.2",
12
+ "packages": [
13
+ {
14
+ "registryType": "pypi",
15
+ "identifier": "bytestay-specwise-mcp",
16
+ "version": "0.2.2",
17
+ "runtimeHint": "uvx",
18
+ "transport": {
19
+ "type": "stdio"
20
+ },
21
+ "environmentVariables": [
22
+ {
23
+ "name": "RAPIDAPI_KEY",
24
+ "description": "Your RapidAPI key",
25
+ "isRequired": false,
26
+ "isSecret": true,
27
+ "format": "string"
28
+ },
29
+ {
30
+ "name": "API_MARKET_KEY",
31
+ "description": "Your api.market key, used when RAPIDAPI_KEY is not set",
32
+ "isRequired": false,
33
+ "isSecret": true,
34
+ "format": "string"
35
+ },
36
+ {
37
+ "name": "HFL_MARKETPLACE",
38
+ "description": "rapidapi or apimarket, when both keys are set",
39
+ "isRequired": false,
40
+ "isSecret": false,
41
+ "format": "string",
42
+ "choices": [
43
+ "rapidapi",
44
+ "apimarket"
45
+ ]
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ }
@@ -0,0 +1,2 @@
1
+ """Specwise MCP server."""
2
+ __version__ = "0.2.2" # x-release-please-version
@@ -0,0 +1,111 @@
1
+ """HTTP client: picks the marketplace from the environment and turns API errors into
2
+ messages an agent (and its user) can act on."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from mcp.server.mcpserver.exceptions import ToolError
11
+
12
+ from . import __version__
13
+
14
+ RAPIDAPI_LISTING = "https://rapidapi.com/hybrid-forge-lab-hybrid-forge-lab-default/api/"
15
+ APIMARKET_BASE = "https://prod.api.market/api/v1/hybrid-forge-lab/"
16
+ APIMARKET_STORE = "https://api.market/store/hybrid-forge-lab/"
17
+ USER_AGENT = f"specwise-mcp/{__version__} (+https://specwise.bytestay.pt)"
18
+ TIMEOUT = httpx.Timeout(60.0, connect=10.0)
19
+
20
+ # Tests swap this for an httpx.MockTransport.
21
+ TRANSPORT: httpx.AsyncBaseTransport | None = None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Product:
26
+ slug: str
27
+ name: str
28
+ rapidapi: str # RapidAPI listing slug; the host is "<slug>.p.rapidapi.com"
29
+ apimarket: bool # sold on api.market too
30
+
31
+ @property
32
+ def rapidapi_host(self) -> str:
33
+ return f"{self.rapidapi}.p.rapidapi.com"
34
+
35
+ @property
36
+ def plans(self) -> str:
37
+ return RAPIDAPI_LISTING + self.rapidapi + "/pricing"
38
+
39
+
40
+ PRODUCTS = {
41
+ "specwise": Product("specwise", "Specwise", "specwise-openapi-validator-and-diff-api", apimarket=True),
42
+ }
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Target:
47
+ base: str
48
+ headers: dict[str, str]
49
+ market: str # "rapidapi", "apimarket" or "direct"
50
+
51
+
52
+ def target(product: Product, env: dict[str, str] | None = None) -> Target:
53
+ """Where to send requests, from the environment. Read on every call, so the server starts without a key and
54
+ the first tool call explains how to get one."""
55
+ env = dict(os.environ if env is None else env)
56
+ # Maintainers only: call our own host directly with an internal bearer key.
57
+ if env.get("HFL_API_URL") and env.get("HFL_API_BEARER"):
58
+ return Target(env["HFL_API_URL"].rstrip("/"), {"Authorization": f"Bearer {env['HFL_API_BEARER']}"}, "direct")
59
+ rapid, market = env.get("RAPIDAPI_KEY", ""), env.get("API_MARKET_KEY", "")
60
+ choice = env.get("HFL_MARKETPLACE", "").lower()
61
+ if choice == "apimarket" or (market and not rapid and choice != "rapidapi"):
62
+ if not market:
63
+ raise ToolError("HFL_MARKETPLACE is apimarket but API_MARKET_KEY is not set.")
64
+ return Target(APIMARKET_BASE + product.slug, {"x-api-market-key": market}, "apimarket")
65
+ if not rapid:
66
+ where = f" or API_MARKET_KEY ({APIMARKET_STORE}{product.slug})" if product.apimarket else ""
67
+ raise ToolError(f"No API key. Set RAPIDAPI_KEY (free Basic plan: {product.plans}){where} in this MCP "
68
+ "server's environment, then restart it.")
69
+ return Target(f"https://{product.rapidapi_host}",
70
+ {"X-RapidAPI-Key": rapid, "X-RapidAPI-Host": product.rapidapi_host}, "rapidapi")
71
+
72
+
73
+ def _detail(response: httpx.Response) -> str:
74
+ try:
75
+ body = response.json()
76
+ except ValueError:
77
+ return response.text[:300]
78
+ if isinstance(body, dict):
79
+ detail = body.get("detail", body.get("message", body))
80
+ return detail if isinstance(detail, str) else str(detail)[:500]
81
+ return str(body)[:300]
82
+
83
+
84
+ async def call(product: Product, method: str, path: str, *, json: Any = None, params: dict | None = None,
85
+ files: dict | None = None, data: dict | None = None) -> dict:
86
+ """One API request. Returns the JSON body, plus `quota_remaining` when the marketplace reports it."""
87
+ t = target(product)
88
+ url = t.base + path
89
+ headers = {"User-Agent": USER_AGENT, **t.headers}
90
+ try:
91
+ async with httpx.AsyncClient(timeout=TIMEOUT, transport=TRANSPORT) as client:
92
+ r = await client.request(method, url, headers=headers, json=json, params=params, files=files, data=data)
93
+ except httpx.HTTPError as exc:
94
+ raise ToolError(f"The {product.name} API could not be reached ({type(exc).__name__}). Try again later.")
95
+ if r.status_code in (401, 403):
96
+ how = f"{APIMARKET_STORE}{product.slug}" if t.market == "apimarket" else product.plans
97
+ raise ToolError(f"The {product.name} API refused the key (HTTP {r.status_code}: {_detail(r)}). "
98
+ f"Check the key and the subscription: {how}")
99
+ if r.status_code == 429:
100
+ raise ToolError(f"{product.name} rate or monthly quota limit reached ({_detail(r)}). Plans: {product.plans}")
101
+ if r.status_code == 413:
102
+ raise ToolError(f"The input is too large for {product.name} ({_detail(r)}).")
103
+ if r.status_code == 422:
104
+ raise ToolError(f"{product.name} could not process the input: {_detail(r)}")
105
+ if r.status_code >= 400:
106
+ raise ToolError(f"The {product.name} API answered HTTP {r.status_code}: {_detail(r)}. Try again later.")
107
+ body = r.json()
108
+ quota = r.headers.get("x-ratelimit-requests-remaining")
109
+ if quota is not None and isinstance(body, dict):
110
+ body["quota_remaining"] = int(quota) if quota.isdigit() else quota
111
+ return body
@@ -0,0 +1,41 @@
1
+ """Reading local files and git revisions for tools that accept a path instead of the content."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from mcp.server.mcpserver.exceptions import ToolError
8
+
9
+
10
+ def resolve(path: str) -> Path:
11
+ p = Path(path).expanduser()
12
+ if not p.is_file():
13
+ raise ToolError(f"No such file: {p} (relative paths start from {Path.cwd()}).")
14
+ return p
15
+
16
+
17
+ def read_text(path: str, max_bytes: int) -> str:
18
+ p = resolve(path)
19
+ size = p.stat().st_size
20
+ if size > max_bytes:
21
+ raise ToolError(f"{p} is {size:,} bytes; the limit is {max_bytes:,}.")
22
+ try:
23
+ return p.read_text(encoding="utf-8")
24
+ except UnicodeDecodeError:
25
+ raise ToolError(f"{p} is not UTF-8 text.")
26
+
27
+ def git_show(path: str, ref: str) -> str:
28
+ """The file as it is at `ref` (a branch, tag or commit) in the git repository that contains it."""
29
+ p = resolve(path).resolve()
30
+
31
+ def run(*args: str) -> subprocess.CompletedProcess:
32
+ return subprocess.run(["git", "-C", str(p.parent), *args], check=False, capture_output=True, text=True)
33
+
34
+ top = run("rev-parse", "--show-toplevel")
35
+ if top.returncode != 0:
36
+ raise ToolError(f"{p} is not in a git repository.")
37
+ rel = p.relative_to(Path(top.stdout.strip()).resolve()).as_posix()
38
+ shown = run("show", f"{ref}:{rel}")
39
+ if shown.returncode != 0:
40
+ raise ToolError(f"Could not read {rel} at {ref!r}: {shown.stderr.strip()[:200]}")
41
+ return shown.stdout
@@ -0,0 +1,106 @@
1
+ """Specwise MCP server: OpenAPI and JSON Schema validation, and OpenAPI breaking-change diffs."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Annotated, Any, Literal
5
+
6
+ from mcp.server.mcpserver import MCPServer
7
+ from mcp.server.mcpserver.exceptions import ToolError
8
+ from mcp.types import ToolAnnotations
9
+ from pydantic import Field
10
+
11
+ from . import files
12
+ from .client import PRODUCTS, call
13
+
14
+ PRODUCT = PRODUCTS["specwise"]
15
+ MAX_FILE = 900 * 1024 # two documents must fit in the API's 2 MB request
16
+
17
+ server = MCPServer(
18
+ "specwise",
19
+ title="Specwise: OpenAPI validation and breaking changes",
20
+ instructions=(
21
+ "Validate an OpenAPI (3.x or Swagger 2.0) document and get every error with its JSON Pointer, line and "
22
+ "column. Compare two OpenAPI 3.x versions and get each change classified as breaking, warning, "
23
+ "non-breaking or info, with a semver suggestion; use it after editing a spec, against the git version. "
24
+ "Validate JSON Schemas and data. Documents are processed in memory and not stored, and external $refs are "
25
+ "never fetched. Each call is one request on the user's Specwise plan."
26
+ ),
27
+ website_url="https://specwise.bytestay.pt",
28
+ )
29
+
30
+ READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=True)
31
+
32
+
33
+ # MCP clients may send JSON documents as objects, and the SDK parses JSON-looking strings into objects too.
34
+ # The API takes both: text keeps line numbers, objects are validated as they are.
35
+ Doc = str | dict | list | bool | int | float
36
+
37
+
38
+ def _one(value: Doc | None, path: str | None, what: str) -> Doc:
39
+ given = value is not None and value != ""
40
+ if given == bool(path):
41
+ raise ToolError(f"Give exactly one of {what} or {what}_path.")
42
+ return value if given else files.read_text(path, MAX_FILE)
43
+
44
+
45
+ @server.tool(annotations=READ_ONLY)
46
+ async def validate_openapi(
47
+ spec: Annotated[Doc | None, Field(description="The document as YAML or JSON text (or a JSON object).")] = None,
48
+ spec_path: Annotated[str | None, Field(description="A local file instead of `spec`.")] = None,
49
+ ) -> dict:
50
+ """Validate an OpenAPI 3.0/3.1/3.2 or Swagger 2.0 document. Returns every error (not just the first) with its
51
+ JSON Pointer, line and column, plus lint warnings."""
52
+ return await call(PRODUCT, "POST", "/v1/openapi/validate", json={"spec": _one(spec, spec_path, "spec")})
53
+
54
+
55
+ @server.tool(annotations=READ_ONLY)
56
+ async def diff_openapi(
57
+ base: Annotated[Doc | None, Field(description="The old version, as YAML or JSON text (or a JSON object).")] = None,
58
+ base_path: Annotated[str | None, Field(description="The old version as a local file.")] = None,
59
+ revision: Annotated[Doc | None, Field(description="The new version, as YAML or JSON text (or a JSON object).")] = None,
60
+ revision_path: Annotated[str | None, Field(description="The new version as a local file.")] = None,
61
+ base_git_ref: Annotated[str | None, Field(
62
+ description="Use revision_path as it is at this git ref (e.g. main, HEAD) as the base.")] = None,
63
+ ) -> dict:
64
+ """Compare two OpenAPI 3.x documents. Each change is breaking, warning, non-breaking or info; `breaking` says
65
+ whether any is breaking, and `semver` suggests the version bump.
66
+
67
+ Typical use after editing a spec: revision_path="openapi.yaml", base_git_ref="main"."""
68
+ new = _one(revision, revision_path, "revision")
69
+ if base_git_ref:
70
+ if base or base_path:
71
+ raise ToolError("Give base_git_ref or base/base_path, not both.")
72
+ if not revision_path:
73
+ raise ToolError("base_git_ref needs revision_path.")
74
+ old = files.git_show(revision_path, base_git_ref)
75
+ else:
76
+ old = _one(base, base_path, "base")
77
+ return await call(PRODUCT, "POST", "/v1/openapi/diff", json={"base": old, "revision": new})
78
+
79
+
80
+ @server.tool(annotations=READ_ONLY)
81
+ async def validate_json_schema(
82
+ schema: Annotated[Doc | None, Field(description="The JSON Schema, as JSON or YAML text (or a JSON object).")] = None,
83
+ schema_path: Annotated[str | None, Field(description="A local file instead of `schema`.")] = None,
84
+ instance: Annotated[Any, Field(description="Data to validate: JSON or YAML text, or the value itself. Optional.")] = None,
85
+ instance_path: Annotated[str | None, Field(description="Data to validate, from a local file.")] = None,
86
+ draft: Annotated[Literal["4", "6", "7", "2019-09", "2020-12"] | None,
87
+ Field(description="Force a draft; by default from $schema, else 2020-12.")] = None,
88
+ ) -> dict:
89
+ """Check a JSON Schema against its meta-schema and, if given, validate data against it. Every error has the
90
+ instance path, the schema path and, for text input, the line."""
91
+ body: dict = {"schema": _one(schema, schema_path, "schema")}
92
+ if instance is not None or instance_path:
93
+ value = _one(instance, instance_path, "instance")
94
+ body["instance"] = value
95
+ # Text is parsed as YAML (a superset of JSON) and gives line numbers; anything else is the value itself.
96
+ body["instance_format"] = "yaml" if isinstance(value, str) else "value"
97
+ if draft:
98
+ body["draft"] = draft
99
+ return await call(PRODUCT, "POST", "/v1/jsonschema/validate", json=body)
100
+
101
+
102
+ def main() -> None: # pragma: no cover - stdio entry point
103
+ server.run("stdio")
104
+
105
+ if __name__ == "__main__": # pragma: no cover
106
+ main()
@@ -0,0 +1,38 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from specwise_mcp import client
7
+
8
+
9
+ class Recorder:
10
+ """Answers every request with the next queued response and keeps what was sent."""
11
+
12
+ def __init__(self):
13
+ self.requests: list[httpx.Request] = []
14
+ self.responses: list[httpx.Response] = []
15
+
16
+ def queue(self, status=200, body=None, headers=None):
17
+ self.responses.append(httpx.Response(status, json=body if body is not None else {}, headers=headers or {}))
18
+
19
+ def __call__(self, request: httpx.Request) -> httpx.Response:
20
+ self.requests.append(request)
21
+ return self.responses.pop(0) if self.responses else httpx.Response(200, json={})
22
+
23
+ @property
24
+ def last(self) -> httpx.Request:
25
+ return self.requests[-1]
26
+
27
+ def last_json(self):
28
+ return json.loads(self.last.content)
29
+
30
+
31
+ @pytest.fixture
32
+ def api(monkeypatch):
33
+ rec = Recorder()
34
+ monkeypatch.setattr(client, "TRANSPORT", httpx.MockTransport(rec))
35
+ for name in ("RAPIDAPI_KEY", "API_MARKET_KEY", "HFL_MARKETPLACE", "HFL_API_URL", "HFL_API_BEARER"):
36
+ monkeypatch.delenv(name, raising=False)
37
+ monkeypatch.setenv("RAPIDAPI_KEY", "rk")
38
+ return rec
@@ -0,0 +1,99 @@
1
+ import httpx
2
+ import pytest
3
+ from mcp.server.mcpserver.exceptions import ToolError
4
+
5
+ from specwise_mcp import client
6
+ from specwise_mcp.client import PRODUCTS, call, target
7
+
8
+ P = PRODUCTS["specwise"]
9
+ HOST = "specwise-openapi-validator-and-diff-api.p.rapidapi.com"
10
+
11
+
12
+ def test_rapidapi_by_default():
13
+ t = target(P, {"RAPIDAPI_KEY": "rk"})
14
+ assert t.market == "rapidapi" and t.base == f"https://{HOST}"
15
+ assert t.headers == {"X-RapidAPI-Key": "rk", "X-RapidAPI-Host": HOST}
16
+
17
+
18
+ def test_direct_for_maintainers():
19
+ t = target(P, {"HFL_API_URL": "https://api.example/", "HFL_API_BEARER": "b", "RAPIDAPI_KEY": "rk"})
20
+ assert t.market == "direct" and t.base == "https://api.example" and t.headers == {"Authorization": "Bearer b"}
21
+
22
+
23
+ def test_env_is_read_from_os_by_default(monkeypatch):
24
+ monkeypatch.setenv("RAPIDAPI_KEY", "from-os")
25
+ monkeypatch.delenv("HFL_API_URL", raising=False)
26
+ monkeypatch.delenv("HFL_MARKETPLACE", raising=False)
27
+ assert target(P).headers["X-RapidAPI-Key"] == "from-os"
28
+
29
+
30
+ def test_apimarket_when_only_its_key_is_set_or_chosen():
31
+ t = target(P, {"API_MARKET_KEY": "mk"})
32
+ assert t.market == "apimarket" and t.base == "https://prod.api.market/api/v1/hybrid-forge-lab/specwise"
33
+ assert t.headers == {"x-api-market-key": "mk"}
34
+ assert target(P, {"RAPIDAPI_KEY": "rk", "API_MARKET_KEY": "mk", "HFL_MARKETPLACE": "apimarket"}).market == "apimarket"
35
+ assert target(P, {"RAPIDAPI_KEY": "rk", "API_MARKET_KEY": "mk"}).market == "rapidapi"
36
+ assert target(P, {"RAPIDAPI_KEY": "rk", "API_MARKET_KEY": "mk", "HFL_MARKETPLACE": "rapidapi"}).market == "rapidapi"
37
+
38
+
39
+ def test_key_errors_explain_what_to_do():
40
+ with pytest.raises(ToolError, match="No API key. Set RAPIDAPI_KEY .*api.market"):
41
+ target(P, {})
42
+ with pytest.raises(ToolError, match="API_MARKET_KEY is not set"):
43
+ target(P, {"RAPIDAPI_KEY": "rk", "HFL_MARKETPLACE": "apimarket"})
44
+
45
+
46
+ async def test_apimarket_refusal_points_to_the_store(api, monkeypatch):
47
+ monkeypatch.delenv("RAPIDAPI_KEY")
48
+ monkeypatch.setenv("API_MARKET_KEY", "mk")
49
+ api.queue(403, {"message": "no"})
50
+ with pytest.raises(ToolError, match="api.market/store/hybrid-forge-lab/specwise"):
51
+ await call(P, "GET", "/x")
52
+
53
+
54
+ async def test_success_adds_quota_and_user_agent(api):
55
+ api.queue(200, {"status": "ok"}, {"x-ratelimit-requests-remaining": "12"})
56
+ r = await call(P, "POST", "/v1/x", json={"a": 1})
57
+ assert r == {"status": "ok", "quota_remaining": 12}
58
+ assert api.last.headers["user-agent"].startswith("specwise-mcp/")
59
+ api.queue(200, {"a": 1}, {"x-ratelimit-requests-remaining": "n/a"})
60
+ assert (await call(P, "GET", "/x"))["quota_remaining"] == "n/a"
61
+ api.queue(200, [1, 2], {"x-ratelimit-requests-remaining": "3"})
62
+ assert await call(P, "GET", "/x") == [1, 2]
63
+
64
+
65
+ @pytest.mark.parametrize("status,body,match", [
66
+ (401, {"message": "Invalid API key"}, "refused the key .*401: Invalid API key.*specwise-openapi-validator-and-diff-api/pricing"),
67
+ (403, {"message": "You are not subscribed"}, "refused the key"),
68
+ (429, {"message": "quota"}, "quota limit reached"),
69
+ (413, {"detail": "body too large"}, "too large"),
70
+ (422, {"detail": [{"loc": ["x"], "msg": "bad"}]}, "could not process the input: .*bad"),
71
+ (500, {"detail": "boom"}, "HTTP 500: boom"),
72
+ ])
73
+ async def test_errors(api, status, body, match):
74
+ api.queue(status, body)
75
+ with pytest.raises(ToolError, match=match):
76
+ await call(P, "GET", "/x")
77
+
78
+
79
+ async def test_non_json_and_non_dict_error_bodies(monkeypatch):
80
+ def handler(request):
81
+ return httpx.Response(502, text="<html>bad gateway</html>") if "a" in request.url.path \
82
+ else httpx.Response(503, json=["down"])
83
+ monkeypatch.setattr(client, "TRANSPORT", httpx.MockTransport(handler))
84
+ monkeypatch.setenv("RAPIDAPI_KEY", "rk")
85
+ monkeypatch.delenv("HFL_API_URL", raising=False)
86
+ with pytest.raises(ToolError, match="502: <html>bad gateway"):
87
+ await call(P, "GET", "/a")
88
+ with pytest.raises(ToolError, match=r"503: \['down'\]"):
89
+ await call(P, "GET", "/b")
90
+
91
+
92
+ async def test_network_failure(monkeypatch):
93
+ def handler(request):
94
+ raise httpx.ConnectTimeout("slow")
95
+ monkeypatch.setattr(client, "TRANSPORT", httpx.MockTransport(handler))
96
+ monkeypatch.setenv("RAPIDAPI_KEY", "rk")
97
+ monkeypatch.delenv("HFL_API_URL", raising=False)
98
+ with pytest.raises(ToolError, match="could not be reached \\(ConnectTimeout\\)"):
99
+ await call(P, "GET", "/x")
@@ -0,0 +1,106 @@
1
+ import subprocess
2
+
3
+ import pytest
4
+ from mcp.server.mcpserver.exceptions import ToolError
5
+
6
+ from specwise_mcp import files
7
+ from specwise_mcp import server as specwise
8
+
9
+ SPEC_V1 = "openapi: 3.0.3\ninfo: {title: t, version: '1'}\npaths: {}\n"
10
+ SPEC_V2 = "openapi: 3.0.3\ninfo: {title: t, version: '2'}\npaths: {}\n"
11
+
12
+
13
+ async def test_server_lists_its_tools():
14
+ listed = await specwise.server.list_tools()
15
+ assert {t.name for t in listed} == {"validate_openapi", "diff_openapi", "validate_json_schema"}
16
+ for t in listed:
17
+ assert t.description
18
+ assert t.annotations.read_only_hint is True and t.annotations.destructive_hint is False
19
+ assert t.annotations.idempotent_hint is True and t.annotations.open_world_hint is True
20
+
21
+
22
+ async def test_validate_openapi(api, tmp_path):
23
+ await specwise.validate_openapi(spec=SPEC_V1)
24
+ assert api.last.url.path == "/v1/openapi/validate" and api.last_json() == {"spec": SPEC_V1}
25
+ f = tmp_path / "openapi.yaml"
26
+ f.write_text(SPEC_V2)
27
+ await specwise.validate_openapi(spec_path=str(f))
28
+ assert api.last_json() == {"spec": SPEC_V2}
29
+ with pytest.raises(ToolError, match="exactly one of spec or spec_path"):
30
+ await specwise.validate_openapi()
31
+
32
+
33
+ async def test_diff_openapi_text_and_files(api, tmp_path):
34
+ await specwise.diff_openapi(base=SPEC_V1, revision=SPEC_V2)
35
+ assert api.last.url.path == "/v1/openapi/diff" and api.last_json() == {"base": SPEC_V1, "revision": SPEC_V2}
36
+ a, b = tmp_path / "a.yaml", tmp_path / "b.yaml"
37
+ a.write_text(SPEC_V1)
38
+ b.write_text(SPEC_V2)
39
+ await specwise.diff_openapi(base_path=str(a), revision_path=str(b))
40
+ assert api.last_json() == {"base": SPEC_V1, "revision": SPEC_V2}
41
+
42
+
43
+ def git(repo, *args):
44
+ subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t", *args], check=True,
45
+ capture_output=True)
46
+
47
+
48
+ async def test_diff_against_a_git_ref(api, tmp_path):
49
+ repo = tmp_path / "repo"
50
+ (repo / "api").mkdir(parents=True)
51
+ git(repo, "init", "-q", "-b", "main")
52
+ spec = repo / "api" / "openapi.yaml"
53
+ spec.write_text(SPEC_V1)
54
+ git(repo, "add", "-A")
55
+ git(repo, "commit", "-qm", "v1")
56
+ spec.write_text(SPEC_V2) # uncommitted edit
57
+ await specwise.diff_openapi(revision_path=str(spec), base_git_ref="HEAD")
58
+ assert api.last_json() == {"base": SPEC_V1, "revision": SPEC_V2}
59
+ with pytest.raises(ToolError, match="Could not read api/openapi.yaml at 'nope'"):
60
+ await specwise.diff_openapi(revision_path=str(spec), base_git_ref="nope")
61
+ with pytest.raises(ToolError, match="not both"):
62
+ await specwise.diff_openapi(base=SPEC_V1, revision_path=str(spec), base_git_ref="HEAD")
63
+ with pytest.raises(ToolError, match="needs revision_path"):
64
+ await specwise.diff_openapi(revision=SPEC_V2, base_git_ref="HEAD")
65
+ outside = tmp_path / "outside.yaml"
66
+ outside.write_text(SPEC_V2)
67
+ with pytest.raises(ToolError, match="not in a git repository"):
68
+ await specwise.diff_openapi(revision_path=str(outside), base_git_ref="HEAD")
69
+
70
+
71
+ async def test_validate_json_schema(api, tmp_path):
72
+ await specwise.validate_json_schema(schema='{"type": "object"}')
73
+ assert api.last.url.path == "/v1/jsonschema/validate" and api.last_json() == {"schema": '{"type": "object"}'}
74
+ data = tmp_path / "data.json"
75
+ data.write_text('{"a": 1}')
76
+ await specwise.validate_json_schema(schema='{"type": "object"}', instance_path=str(data), draft="7")
77
+ assert api.last_json() == {"schema": '{"type": "object"}', "instance": '{"a": 1}', "instance_format": "yaml",
78
+ "draft": "7"}
79
+ with pytest.raises(ToolError, match="exactly one of schema or schema_path"):
80
+ await specwise.validate_json_schema()
81
+
82
+
83
+ async def test_json_arguments_through_the_server(api):
84
+ await specwise.server.call_tool("validate_json_schema", {"schema": '{"type": "object"}', "instance": "{}"})
85
+ assert api.last_json() == {"schema": {"type": "object"}, "instance": {}, "instance_format": "value"}
86
+ await specwise.server.call_tool("validate_json_schema", {"schema": "type: object", "instance": "a: 1"})
87
+ assert api.last_json() == {"schema": "type: object", "instance": "a: 1", "instance_format": "yaml"}
88
+ await specwise.server.call_tool("validate_openapi", {"spec": '{"openapi": "3.0.3"}'})
89
+ assert api.last_json() == {"spec": {"openapi": "3.0.3"}}
90
+ await specwise.server.call_tool("diff_openapi", {"base": '{"openapi": "3.0.3"}', "revision": SPEC_V2})
91
+ assert api.last_json() == {"base": {"openapi": "3.0.3"}, "revision": SPEC_V2}
92
+ await specwise.validate_json_schema(schema=True, instance=0)
93
+ assert api.last_json() == {"schema": True, "instance": 0, "instance_format": "value"}
94
+
95
+
96
+ async def test_text_file_limits(tmp_path):
97
+ with pytest.raises(ToolError, match="No such file"):
98
+ files.read_text(str(tmp_path / "missing"), 100)
99
+ big = tmp_path / "big"
100
+ big.write_text("x" * 50)
101
+ with pytest.raises(ToolError, match="limit is"):
102
+ files.read_text(str(big), 10)
103
+ binary = tmp_path / "bin"
104
+ binary.write_bytes(b"\xff\xfe\x00")
105
+ with pytest.raises(ToolError, match="not UTF-8"):
106
+ files.read_text(str(binary), 100)