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