bytestay-shipshape-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-shipshape-mcp-v0.2.2 && git push origin bytestay-shipshape-mcp-v0.2.2), or run it by hand on a tag.
5
+ on:
6
+ push:
7
+ tags: ["bytestay-shipshape-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/shipshape-mcp/compare/hybridforgelab-shipshape-mcp-v0.2.1...hybridforgelab-shipshape-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/shipshape-mcp/issues/11)) ([1d9549b](https://github.com/hybridforgelab/shipshape-mcp/commit/1d9549b37281febc9e2cf5a98ac4296c135441b0))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * M8ven Trust Index badge ([#9](https://github.com/hybridforgelab/shipshape-mcp/issues/9)) ([52e3800](https://github.com/hybridforgelab/shipshape-mcp/commit/52e3800c9309192a75e227357042f6a62d01d726))
14
+
15
+ ## [0.2.1](https://github.com/hybridforgelab/shipshape-mcp/compare/hybridforgelab-shipshape-mcp-v0.2.0...hybridforgelab-shipshape-mcp-v0.2.1) (2026-11-03)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * declare all four tool hints ([#7](https://github.com/hybridforgelab/shipshape-mcp/issues/7)) ([c91633f](https://github.com/hybridforgelab/shipshape-mcp/commit/c91633fbe91414d9958976b383041962a45d025e))
21
+
22
+ ## [0.2.0](https://github.com/hybridforgelab/shipshape-mcp/compare/hybridforgelab-shipshape-mcp-v0.1.0...hybridforgelab-shipshape-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/shipshape-mcp/issues/3)) ([fd64b04](https://github.com/hybridforgelab/shipshape-mcp/commit/fd64b0481e6c78264c9ed57a62dc3c14574b2d03))
28
+
29
+ ## 0.1.0 (2026-10-30)
30
+
31
+
32
+ ### Features
33
+
34
+ * shipshape MCP server ([17b662e](https://github.com/hybridforgelab/shipshape-mcp/commit/17b662e4090a784d566939e8234aab8df4f1b093))
@@ -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,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: bytestay-shipshape-mcp
3
+ Version: 0.2.2
4
+ Summary: Dockerfile and Kubernetes lint for coding agents: Pod Security Standards, A–F grade, a fix per finding. Bring your own RapidAPI or api.market key.
5
+ Project-URL: Homepage, https://shipshape.bytestay.pt
6
+ Project-URL: Repository, https://github.com/bytestay/shipshape-mcp
7
+ Project-URL: Issues, https://github.com/bytestay/shipshape-mcp/issues
8
+ Project-URL: Changelog, https://github.com/bytestay/shipshape-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
+ # Shipshape MCP server
23
+
24
+ <!-- mcp-name: io.github.bytestay/shipshape-mcp -->
25
+
26
+ [![M8ven Score](https://m8ven.ai/badge/mcp/hybridforgelab/shipshape-mcp?variant=verified)](https://m8ven.ai/mcp/hybridforgelab/shipshape-mcp?s=readme)
27
+ [![CI](https://github.com/bytestay/shipshape-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bytestay/shipshape-mcp/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/bytestay-shipshape-mcp)](https://pypi.org/project/bytestay-shipshape-mcp/)
29
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/bytestay-shipshape-mcp/)
30
+ [![License: MIT](https://img.shields.io/github/license/bytestay/shipshape-mcp)](LICENSE)
31
+
32
+ Let your coding agent lint a Dockerfile or Kubernetes manifest before handing it over: root in the final stage, unpinned base images, `curl | sh`, secrets in ENV/ARG, Pod Security Standards, resources and probes.
33
+
34
+ The server is open source (MIT). It calls the [Shipshape API](https://shipshape.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/shipshape-dockerfile-and-kubernetes-lint-api/pricing) |
45
+ | api.market | [store](https://api.market/store/hybrid-forge-lab/shipshape) |
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
+ "shipshape": {
57
+ "command": "uvx",
58
+ "args": ["--from", "bytestay-shipshape-mcp", "shipshape-mcp"],
59
+ "env": { "RAPIDAPI_KEY": "your-rapidapi-key" }
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ **Claude Code:**
66
+
67
+ ```bash
68
+ claude mcp add shipshape --env RAPIDAPI_KEY=your-rapidapi-key -- uvx --from bytestay-shipshape-mcp shipshape-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
+ [![shipshape-mcp on Glama](https://glama.ai/mcp/servers/hybridforgelab/shipshape-mcp/badge)](https://glama.ai/mcp/servers/hybridforgelab/shipshape-mcp)
84
+
85
+ ## 3. Tools
86
+
87
+ | tool | what it does |
88
+ |---|---|
89
+ | `lint(content=… \| path=…, type="auto", fail_on="high", ignore=[…])` | lints one file and returns the A–F grade, score, `passed` and every finding with its line, fix and reference |
90
+
91
+ - With `path`, files named like `Dockerfile`, `*.Dockerfile`, `Dockerfile.*` or `Containerfile` are linted as Dockerfiles.
92
+ - `passed` is false when a finding is at or above `fail_on`.
93
+
94
+ Every tool returns the API's JSON response.
95
+
96
+ - When the marketplace reports it, `quota_remaining` is added: the requests left on your plan this month.
97
+ - Errors are explained in plain words, with what to do next:
98
+ - key refused, with the link to subscribe;
99
+ - quota reached, with the plans;
100
+ - input the API couldn't process, with the API's reason.
101
+
102
+ ## Privacy
103
+
104
+ - Local files are read only when a tool is given their path. They are sent to the API for that one request.
105
+ - The API processes inputs in memory and doesn't store them. See the [privacy policy](https://shipshape.bytestay.pt/privacy.html).
106
+ - Your key is only sent to the marketplace you chose.
107
+
108
+ ## Limits
109
+
110
+ - Files up to 512 KiB.
111
+ - Helm templates must be rendered first (`helm template`).
112
+ - NetworkPolicies, PodDisruptionBudgets and RBAC are not checked.
113
+ - The server needs Python 3.10+ (uv installs it for you).
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ uv venv && uv pip install -e ".[dev]"
119
+ .venv/bin/pytest # unit tests, no network; 100% coverage required
120
+ .venv/bin/ruff check src tests scripts
121
+ ```
122
+
123
+ `scripts/e2e_stdio.py shipshape` 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`.
124
+
125
+ ## Licence
126
+
127
+ MIT. Shipshape itself is a paid API with a free plan, run by [ByteStay](https://bytestay.pt).
@@ -0,0 +1,106 @@
1
+ # Shipshape MCP server
2
+
3
+ <!-- mcp-name: io.github.bytestay/shipshape-mcp -->
4
+
5
+ [![M8ven Score](https://m8ven.ai/badge/mcp/hybridforgelab/shipshape-mcp?variant=verified)](https://m8ven.ai/mcp/hybridforgelab/shipshape-mcp?s=readme)
6
+ [![CI](https://github.com/bytestay/shipshape-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bytestay/shipshape-mcp/actions/workflows/ci.yml)
7
+ [![PyPI](https://img.shields.io/pypi/v/bytestay-shipshape-mcp)](https://pypi.org/project/bytestay-shipshape-mcp/)
8
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/bytestay-shipshape-mcp/)
9
+ [![License: MIT](https://img.shields.io/github/license/bytestay/shipshape-mcp)](LICENSE)
10
+
11
+ Let your coding agent lint a Dockerfile or Kubernetes manifest before handing it over: root in the final stage, unpinned base images, `curl | sh`, secrets in ENV/ARG, Pod Security Standards, resources and probes.
12
+
13
+ The server is open source (MIT). It calls the [Shipshape API](https://shipshape.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/shipshape-dockerfile-and-kubernetes-lint-api/pricing) |
24
+ | api.market | [store](https://api.market/store/hybrid-forge-lab/shipshape) |
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
+ "shipshape": {
36
+ "command": "uvx",
37
+ "args": ["--from", "bytestay-shipshape-mcp", "shipshape-mcp"],
38
+ "env": { "RAPIDAPI_KEY": "your-rapidapi-key" }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ **Claude Code:**
45
+
46
+ ```bash
47
+ claude mcp add shipshape --env RAPIDAPI_KEY=your-rapidapi-key -- uvx --from bytestay-shipshape-mcp shipshape-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
+ [![shipshape-mcp on Glama](https://glama.ai/mcp/servers/hybridforgelab/shipshape-mcp/badge)](https://glama.ai/mcp/servers/hybridforgelab/shipshape-mcp)
63
+
64
+ ## 3. Tools
65
+
66
+ | tool | what it does |
67
+ |---|---|
68
+ | `lint(content=… \| path=…, type="auto", fail_on="high", ignore=[…])` | lints one file and returns the A–F grade, score, `passed` and every finding with its line, fix and reference |
69
+
70
+ - With `path`, files named like `Dockerfile`, `*.Dockerfile`, `Dockerfile.*` or `Containerfile` are linted as Dockerfiles.
71
+ - `passed` is false when a finding is at or above `fail_on`.
72
+
73
+ Every tool returns the API's JSON response.
74
+
75
+ - When the marketplace reports it, `quota_remaining` is added: the requests left on your plan this month.
76
+ - Errors are explained in plain words, with what to do next:
77
+ - key refused, with the link to subscribe;
78
+ - quota reached, with the plans;
79
+ - input the API couldn't process, with the API's reason.
80
+
81
+ ## Privacy
82
+
83
+ - Local files are read only when a tool is given their path. They are sent to the API for that one request.
84
+ - The API processes inputs in memory and doesn't store them. See the [privacy policy](https://shipshape.bytestay.pt/privacy.html).
85
+ - Your key is only sent to the marketplace you chose.
86
+
87
+ ## Limits
88
+
89
+ - Files up to 512 KiB.
90
+ - Helm templates must be rendered first (`helm template`).
91
+ - NetworkPolicies, PodDisruptionBudgets and RBAC are not checked.
92
+ - The server needs Python 3.10+ (uv installs it for you).
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ uv venv && uv pip install -e ".[dev]"
98
+ .venv/bin/pytest # unit tests, no network; 100% coverage required
99
+ .venv/bin/ruff check src tests scripts
100
+ ```
101
+
102
+ `scripts/e2e_stdio.py shipshape` 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`.
103
+
104
+ ## Licence
105
+
106
+ MIT. Shipshape 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-shipshape-mcp"
3
+ version = "0.2.2"
4
+ description = "Dockerfile and Kubernetes lint for coding agents: Pod Security Standards, A–F grade, a fix per finding. 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://shipshape.bytestay.pt"
13
+ Repository = "https://github.com/bytestay/shipshape-mcp"
14
+ Issues = "https://github.com/bytestay/shipshape-mcp/issues"
15
+ Changelog = "https://github.com/bytestay/shipshape-mcp/blob/main/CHANGELOG.md"
16
+
17
+ [project.scripts]
18
+ shipshape-mcp = "shipshape_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/shipshape_mcp"]
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "auto"
32
+ addopts = "-q --cov=shipshape_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-shipshape-mcp",
7
+ "changelog-path": "CHANGELOG.md",
8
+ "extra-files": [
9
+ "src/shipshape_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,64 @@
1
+ """End-to-end check of the Shipshape server over stdio, as an MCP client would run it, against the real API.
2
+
3
+ HFL_API_URL=https://shipshape-api.bytestay.pt HFL_API_BEARER=... python scripts/e2e_stdio.py shipshape
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
+ ENV_KEYS = ("HFL_API_URL", "HFL_API_BEARER", "RAPIDAPI_KEY", "API_MARKET_KEY", "HFL_MARKETPLACE", "PATH", "HOME")
20
+
21
+
22
+ def data(result):
23
+ assert not result.is_error, result.content[0].text if result.content else "error"
24
+ return json.loads(result.content[0].text)
25
+
26
+
27
+ def checks(name, tmp: Path):
28
+ if name == "shipshape":
29
+ dockerfile = tmp / "Dockerfile"
30
+ dockerfile.write_text("FROM node:latest\nCOPY . /app\nCMD node /app/server.js\n")
31
+ return [
32
+ ("lint local Dockerfile", "lint", {"path": str(dockerfile)},
33
+ lambda d: d["passed"] is False and any(f["severity"] == "high" for f in d["findings"])),
34
+ ("lint content", "lint", {"content": "apiVersion: v1\nkind: Pod\nmetadata: {name: p}\n"
35
+ "spec: {containers: [{name: c, image: nginx}]}\n"},
36
+ lambda d: "grade" in d and len(d["findings"]) > 0),
37
+ ]
38
+ raise SystemExit(f"unknown server {name}")
39
+
40
+
41
+ async def main(name):
42
+ env = {k: os.environ[k] for k in ENV_KEYS if k in os.environ}
43
+ params = StdioServerParameters(command=str(ROOT / ".venv/bin/python"), args=["-m", "shipshape_mcp.server"], env=env)
44
+ failures = 0
45
+ with tempfile.TemporaryDirectory() as tmp:
46
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
47
+ await session.initialize()
48
+ tools = sorted(t.name for t in (await session.list_tools()).tools)
49
+ print(f"{name}: tools {tools}")
50
+ for label, tool, args, ok in checks(name, Path(tmp)):
51
+ try:
52
+ d = data(await session.call_tool(tool, args))
53
+ good = ok(d)
54
+ extra = f" quota_remaining={d['quota_remaining']}" if "quota_remaining" in d else ""
55
+ print(f" {'PASS' if good else 'FAIL'} {label}{extra}")
56
+ except Exception as exc: # noqa: BLE001 - report and continue
57
+ good = False
58
+ print(f" FAIL {label}: {str(exc)[:200]}")
59
+ failures += not good
60
+ return failures
61
+
62
+
63
+ if __name__ == "__main__":
64
+ 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/shipshape-mcp",
4
+ "description": "Dockerfile and Kubernetes lint for AI agents: Pod Security Standards, A–F grade, a fix per finding.",
5
+ "repository": {
6
+ "url": "https://github.com/bytestay/shipshape-mcp",
7
+ "source": "github",
8
+ "id": "1445856229"
9
+ },
10
+ "websiteUrl": "https://shipshape.bytestay.pt/integration.html#mcp-server",
11
+ "version": "0.2.2",
12
+ "packages": [
13
+ {
14
+ "registryType": "pypi",
15
+ "identifier": "bytestay-shipshape-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
+ """Shipshape 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"shipshape-mcp/{__version__} (+https://shipshape.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
+ "shipshape": Product("shipshape", "Shipshape", "shipshape-dockerfile-and-kubernetes-lint-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,24 @@
1
+ """Reading local files for tools that accept a path instead of the content."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from mcp.server.mcpserver.exceptions import ToolError
7
+
8
+
9
+ def resolve(path: str) -> Path:
10
+ p = Path(path).expanduser()
11
+ if not p.is_file():
12
+ raise ToolError(f"No such file: {p} (relative paths start from {Path.cwd()}).")
13
+ return p
14
+
15
+
16
+ def read_text(path: str, max_bytes: int) -> str:
17
+ p = resolve(path)
18
+ size = p.stat().st_size
19
+ if size > max_bytes:
20
+ raise ToolError(f"{p} is {size:,} bytes; the limit is {max_bytes:,}.")
21
+ try:
22
+ return p.read_text(encoding="utf-8")
23
+ except UnicodeDecodeError:
24
+ raise ToolError(f"{p} is not UTF-8 text.")
@@ -0,0 +1,73 @@
1
+ """Shipshape MCP server: lint Dockerfiles and Kubernetes manifests (Pod Security Standards, A–F grade)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Annotated, Literal
7
+
8
+ from mcp.server.mcpserver import MCPServer
9
+ from mcp.server.mcpserver.exceptions import ToolError
10
+ from mcp.types import ToolAnnotations
11
+ from pydantic import Field
12
+
13
+ from . import files
14
+ from .client import PRODUCTS, call
15
+
16
+ PRODUCT = PRODUCTS["shipshape"]
17
+ MAX_FILE = 512 * 1024
18
+ Severity = Literal["high", "medium", "low", "info", "never"]
19
+
20
+ server = MCPServer(
21
+ "shipshape",
22
+ title="Shipshape: Dockerfile and Kubernetes lint",
23
+ instructions=(
24
+ "Lint a Dockerfile or Kubernetes manifest before you hand it over: root in the final stage, unpinned base "
25
+ "images, curl | sh, secrets in ENV/ARG, Pod Security Standards, resources and probes. Every finding has the "
26
+ "line, a fix and the standard behind it, and the file gets an A–F grade. Content is processed in memory and "
27
+ "not stored. Each call is one request on the user's Shipshape plan."
28
+ ),
29
+ website_url="https://shipshape.bytestay.pt",
30
+ )
31
+
32
+ READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=True)
33
+
34
+
35
+ def _type_for(path: str) -> str:
36
+ name = Path(path).name.lower()
37
+ if name in ("dockerfile", "containerfile") or name.endswith(".dockerfile") or name.startswith("dockerfile."):
38
+ return "dockerfile"
39
+ return "auto"
40
+
41
+
42
+ @server.tool(annotations=READ_ONLY)
43
+ async def lint(
44
+ content: Annotated[str | dict | list | None,
45
+ Field(description="The Dockerfile or manifest, as text (up to 512 KiB).")] = None,
46
+ path: Annotated[str | None, Field(description="A local file to lint instead of `content`.")] = None,
47
+ type: Annotated[Literal["auto", "dockerfile", "kubernetes"],
48
+ Field(description="auto detects Dockerfile vs Kubernetes YAML/JSON.")] = "auto",
49
+ fail_on: Annotated[Severity, Field(description="`passed` is false at this severity or worse.")] = "high",
50
+ ignore: Annotated[list[str] | None, Field(description='Rule ids to skip, e.g. ["DF-017"].')] = None,
51
+ ) -> dict:
52
+ """Lint a Dockerfile or Kubernetes manifest. Returns the grade, score, `passed` and every finding with its
53
+ line, fix and reference. Give either `content` or `path`."""
54
+ if bool(content) == bool(path):
55
+ raise ToolError("Give exactly one of content or path.")
56
+ if path:
57
+ content = files.read_text(path, MAX_FILE)
58
+ if type == "auto":
59
+ type = _type_for(path)
60
+ elif not isinstance(content, str):
61
+ # A JSON manifest the client (or the SDK) sent as an object: back to text for the linter.
62
+ content = json.dumps(content, indent=2)
63
+ body = {"content": content, "type": type, "fail_on": fail_on}
64
+ if ignore:
65
+ body["ignore"] = ignore
66
+ return await call(PRODUCT, "POST", "/v1/lint", json=body)
67
+
68
+
69
+ def main() -> None: # pragma: no cover - stdio entry point
70
+ server.run("stdio")
71
+
72
+ if __name__ == "__main__": # pragma: no cover
73
+ main()
@@ -0,0 +1,38 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from shipshape_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 shipshape_mcp import client
6
+ from shipshape_mcp.client import PRODUCTS, call, target
7
+
8
+ P = PRODUCTS["shipshape"]
9
+ HOST = "shipshape-dockerfile-and-kubernetes-lint-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/shipshape"
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/shipshape"):
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("shipshape-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.*shipshape-dockerfile-and-kubernetes-lint-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,63 @@
1
+ import json
2
+
3
+ import pytest
4
+ from mcp.server.mcpserver.exceptions import ToolError
5
+
6
+ from shipshape_mcp import files
7
+ from shipshape_mcp import server as shipshape
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 shipshape.server.list_tools()
15
+ assert {t.name for t in listed} == {"lint"}
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_shipshape_content_and_path(api, tmp_path):
23
+ await shipshape.lint(content="FROM node", fail_on="medium", ignore=["DF-017"])
24
+ assert api.last.url.path == "/v1/lint"
25
+ assert api.last_json() == {"content": "FROM node", "type": "auto", "fail_on": "medium", "ignore": ["DF-017"]}
26
+ for name, expected in (("Dockerfile", "dockerfile"), ("api.Dockerfile", "dockerfile"),
27
+ ("Dockerfile.prod", "dockerfile"), ("Containerfile", "dockerfile"), ("deploy.yaml", "auto")):
28
+ f = tmp_path / name
29
+ f.write_text("FROM node\n")
30
+ await shipshape.lint(path=str(f))
31
+ assert api.last_json()["type"] == expected and api.last_json()["content"] == "FROM node\n"
32
+ assert "ignore" not in api.last_json()
33
+ await shipshape.lint(path=str(tmp_path / "deploy.yaml"), type="kubernetes")
34
+ assert api.last_json()["type"] == "kubernetes"
35
+ with pytest.raises(ToolError, match="exactly one"):
36
+ await shipshape.lint()
37
+
38
+
39
+ async def test_text_file_errors(tmp_path):
40
+ binary = tmp_path / "Dockerfile"
41
+ binary.write_bytes(b"\xff\xfe\x00")
42
+ with pytest.raises(ToolError, match="not UTF-8"):
43
+ files.read_text(str(binary), 100)
44
+ with pytest.raises(ToolError, match="limit is"):
45
+ files.read_text(str(binary), 1)
46
+
47
+
48
+ async def test_json_manifest_through_the_server(api):
49
+ await shipshape.server.call_tool("lint", {"content": '{"apiVersion": "v1", "kind": "Pod"}'})
50
+ assert json.loads(api.last_json()["content"]) == {"apiVersion": "v1", "kind": "Pod"}
51
+
52
+
53
+ async def test_text_file_limits(tmp_path):
54
+ with pytest.raises(ToolError, match="No such file"):
55
+ files.read_text(str(tmp_path / "missing"), 100)
56
+ big = tmp_path / "big"
57
+ big.write_text("x" * 50)
58
+ with pytest.raises(ToolError, match="limit is"):
59
+ files.read_text(str(big), 10)
60
+ binary = tmp_path / "bin"
61
+ binary.write_bytes(b"\xff\xfe\x00")
62
+ with pytest.raises(ToolError, match="not UTF-8"):
63
+ files.read_text(str(binary), 100)