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.
- bytestay_specwise_mcp-0.2.2/.github/workflows/ci.yml +30 -0
- bytestay_specwise_mcp-0.2.2/.github/workflows/mcp-registry.yml +42 -0
- bytestay_specwise_mcp-0.2.2/.github/workflows/publish.yml +31 -0
- bytestay_specwise_mcp-0.2.2/.github/workflows/release.yml +42 -0
- bytestay_specwise_mcp-0.2.2/.gitignore +7 -0
- bytestay_specwise_mcp-0.2.2/.release-please-manifest.json +1 -0
- bytestay_specwise_mcp-0.2.2/CHANGELOG.md +34 -0
- bytestay_specwise_mcp-0.2.2/LICENSE +21 -0
- bytestay_specwise_mcp-0.2.2/PKG-INFO +132 -0
- bytestay_specwise_mcp-0.2.2/README.md +111 -0
- bytestay_specwise_mcp-0.2.2/glama.json +4 -0
- bytestay_specwise_mcp-0.2.2/pyproject.toml +36 -0
- bytestay_specwise_mcp-0.2.2/release-please-config.json +23 -0
- bytestay_specwise_mcp-0.2.2/scripts/e2e_stdio.py +66 -0
- bytestay_specwise_mcp-0.2.2/server.json +50 -0
- bytestay_specwise_mcp-0.2.2/src/specwise_mcp/__init__.py +2 -0
- bytestay_specwise_mcp-0.2.2/src/specwise_mcp/client.py +111 -0
- bytestay_specwise_mcp-0.2.2/src/specwise_mcp/files.py +41 -0
- bytestay_specwise_mcp-0.2.2/src/specwise_mcp/server.py +106 -0
- bytestay_specwise_mcp-0.2.2/tests/conftest.py +38 -0
- bytestay_specwise_mcp-0.2.2/tests/test_client.py +99 -0
- bytestay_specwise_mcp-0.2.2/tests/test_server.py +106 -0
|
@@ -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 @@
|
|
|
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
|
+
[](https://m8ven.ai/mcp/hybridforgelab/specwise-mcp?s=readme)
|
|
27
|
+
[](https://github.com/bytestay/specwise-mcp/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/bytestay-specwise-mcp/)
|
|
29
|
+
[](https://pypi.org/project/bytestay-specwise-mcp/)
|
|
30
|
+
[](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
|
+
[](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
|
+
[](https://m8ven.ai/mcp/hybridforgelab/specwise-mcp?s=readme)
|
|
6
|
+
[](https://github.com/bytestay/specwise-mcp/actions/workflows/ci.yml)
|
|
7
|
+
[](https://pypi.org/project/bytestay-specwise-mcp/)
|
|
8
|
+
[](https://pypi.org/project/bytestay-specwise-mcp/)
|
|
9
|
+
[](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
|
+
[](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,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,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)
|