jt-mcp 0.2.0__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.
- jt_mcp-0.2.0/.github/dependabot.yml +37 -0
- jt_mcp-0.2.0/.github/workflows/ci.yml +154 -0
- jt_mcp-0.2.0/.github/workflows/publish.yml +449 -0
- jt_mcp-0.2.0/.gitignore +8 -0
- jt_mcp-0.2.0/CHANGELOG.md +148 -0
- jt_mcp-0.2.0/LICENSE +674 -0
- jt_mcp-0.2.0/PKG-INFO +159 -0
- jt_mcp-0.2.0/README.md +134 -0
- jt_mcp-0.2.0/pyproject.toml +68 -0
- jt_mcp-0.2.0/server.json +26 -0
- jt_mcp-0.2.0/src/jt_mcp/__init__.py +9 -0
- jt_mcp-0.2.0/src/jt_mcp/listener.py +173 -0
- jt_mcp-0.2.0/src/jt_mcp/messages.py +112 -0
- jt_mcp-0.2.0/src/jt_mcp/server.py +492 -0
- jt_mcp-0.2.0/src/jt_mcp/state.py +282 -0
- jt_mcp-0.2.0/tests/test_cli_flags.py +23 -0
- jt_mcp-0.2.0/tests/test_end_to_end.py +48 -0
- jt_mcp-0.2.0/tests/test_listener_safety.py +117 -0
- jt_mcp-0.2.0/tests/test_mcp_conformance.py +218 -0
- jt_mcp-0.2.0/tests/test_messages.py +59 -0
- jt_mcp-0.2.0/tests/test_packaging.py +91 -0
- jt_mcp-0.2.0/tests/test_registry_metadata.py +41 -0
- jt_mcp-0.2.0/tests/test_release_gate.py +74 -0
- jt_mcp-0.2.0/tests/test_security.py +81 -0
- jt_mcp-0.2.0/tests/test_state.py +84 -0
- jt_mcp-0.2.0/tests/test_tools.py +98 -0
- jt_mcp-0.2.0/uv.lock +1966 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Dependabot: security updates only, grouped into one pull request.
|
|
2
|
+
#
|
|
3
|
+
# WHY THIS FILE EXISTS (2026-10-25). There was no dependabot.yml in any of the 22
|
|
4
|
+
# servers. Without one Dependabot runs *security* updates only -- those need no
|
|
5
|
+
# config -- and they are ungrouped by default: one advisory x one package x one
|
|
6
|
+
# repository = one pull request. Four upgrades produced **57 open pull requests**
|
|
7
|
+
# across the fleet, and KI7MT's objection was the right one: nobody could say
|
|
8
|
+
# what 57 pull requests did. Measured, they were four dependency upgrades closing
|
|
9
|
+
# 130 alerts, one of them CRITICAL. The volume was not noise, it was signal
|
|
10
|
+
# nobody could read.
|
|
11
|
+
#
|
|
12
|
+
# `groups` with `applies-to: security-updates` collapses that to one pull request
|
|
13
|
+
# per repository.
|
|
14
|
+
#
|
|
15
|
+
# open-pull-requests-limit: 0 disables *version* updates, which we do not want --
|
|
16
|
+
# ci.yml audits the locked dependencies on every pull request and weekly, so a
|
|
17
|
+
# stale lock already fails CI and does not need a bot to notice. Security updates
|
|
18
|
+
# are unaffected by this limit: they have their own fixed limit of 10, which
|
|
19
|
+
# cannot be configured. Checked against the Dependabot options reference rather
|
|
20
|
+
# than assumed, because setting this wrong would silently switch off the thing
|
|
21
|
+
# that found those 130 alerts.
|
|
22
|
+
#
|
|
23
|
+
# `uv` is the ecosystem for a uv.lock project, also checked against that
|
|
24
|
+
# reference and not inferred from the file name.
|
|
25
|
+
version: 2
|
|
26
|
+
updates:
|
|
27
|
+
- package-ecosystem: "uv"
|
|
28
|
+
directory: "/"
|
|
29
|
+
schedule:
|
|
30
|
+
interval: "weekly"
|
|
31
|
+
day: "monday"
|
|
32
|
+
open-pull-requests-limit: 0
|
|
33
|
+
groups:
|
|
34
|
+
security:
|
|
35
|
+
applies-to: security-updates
|
|
36
|
+
patterns:
|
|
37
|
+
- "*"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# A pull request is tested whatever it targets, and deliberately has no branch
|
|
4
|
+
# filter. `main` is the one long-lived branch now, so `push` names it; but a filter
|
|
5
|
+
# here would mean a pull request into any other branch ran no ruff, no mypy and no
|
|
6
|
+
# pytest while still reporting green. That is not hypothetical: narrowing this to
|
|
7
|
+
# [main] is what let the pull request making this very change merge with CodeQL as
|
|
8
|
+
# its only check.
|
|
9
|
+
on:
|
|
10
|
+
pull_request:
|
|
11
|
+
push:
|
|
12
|
+
branches: [main]
|
|
13
|
+
# Weekly, because an advisory is published against a lockfile that has not
|
|
14
|
+
# changed. Nothing in a pull-request-and-push trigger fires when the danger
|
|
15
|
+
# arrives from outside the repository -- see the audit job below.
|
|
16
|
+
schedule:
|
|
17
|
+
- cron: "17 6 * * 1"
|
|
18
|
+
|
|
19
|
+
concurrency:
|
|
20
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
21
|
+
cancel-in-progress: true
|
|
22
|
+
|
|
23
|
+
permissions:
|
|
24
|
+
contents: read
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
# Lint and types, once rather than per Python: both read the source, and
|
|
28
|
+
# neither answer changes with the interpreter running them (qso-graph-devel#66).
|
|
29
|
+
lint:
|
|
30
|
+
name: ruff and mypy
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v5
|
|
34
|
+
|
|
35
|
+
- uses: astral-sh/setup-uv@v7
|
|
36
|
+
with:
|
|
37
|
+
# Exact patch, not the minor line. Asking for "3.12" lets uv satisfy
|
|
38
|
+
# it with whatever interpreter the runner already has -- on
|
|
39
|
+
# ubuntu-latest that is the system /usr/bin/python3.12 at 3.12.3,
|
|
40
|
+
# twelve releases behind and without the eMudhra CA fix. The same
|
|
41
|
+
# run downloaded patched 3.11.18 and 3.13.18 only because no system
|
|
42
|
+
# copy existed to satisfy those. Naming the patch forces the managed,
|
|
43
|
+
# current build. https://discuss.python.org/t/109576
|
|
44
|
+
python-version: "3.11.18"
|
|
45
|
+
enable-cache: true
|
|
46
|
+
|
|
47
|
+
- name: Install (locked)
|
|
48
|
+
run: uv sync --group dev --frozen
|
|
49
|
+
|
|
50
|
+
- name: Lint (ruff)
|
|
51
|
+
run: uv run ruff check .
|
|
52
|
+
|
|
53
|
+
- name: Type-check (mypy)
|
|
54
|
+
run: uv run mypy src
|
|
55
|
+
|
|
56
|
+
test:
|
|
57
|
+
name: pytest (${{ matrix.python-version }})
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
strategy:
|
|
60
|
+
fail-fast: false
|
|
61
|
+
matrix:
|
|
62
|
+
python-version: ["3.11.18", "3.12.16", "3.13.18"]
|
|
63
|
+
steps:
|
|
64
|
+
- uses: actions/checkout@v5
|
|
65
|
+
|
|
66
|
+
- uses: astral-sh/setup-uv@v7
|
|
67
|
+
with:
|
|
68
|
+
python-version: ${{ matrix.python-version }}
|
|
69
|
+
enable-cache: true
|
|
70
|
+
|
|
71
|
+
- name: Install (locked)
|
|
72
|
+
run: uv sync --group dev --frozen
|
|
73
|
+
|
|
74
|
+
- name: Unit tests
|
|
75
|
+
run: uv run pytest tests --ignore=tests/test_security.py -v
|
|
76
|
+
|
|
77
|
+
- name: Security tests
|
|
78
|
+
run: uv run pytest tests/test_security.py -v
|
|
79
|
+
|
|
80
|
+
# Audits the LOCKED environment, which is a different question from the one
|
|
81
|
+
# the release gate asks.
|
|
82
|
+
#
|
|
83
|
+
# publish.yml runs `pip-audit --strict --desc on .` -- the project's *declared*
|
|
84
|
+
# dependencies, resolved fresh. That protects the people who `pip install` this
|
|
85
|
+
# package, because the ranges we publish must not resolve to something
|
|
86
|
+
# vulnerable. It cannot see uv.lock at all: `certifi` is declared unpinned, so a
|
|
87
|
+
# fresh resolve always picks the newest and the locked version is never examined.
|
|
88
|
+
#
|
|
89
|
+
# This job syncs `--frozen` and audits what that produced, so a stale lock
|
|
90
|
+
# cannot sit unnoticed. That is not hypothetical. On 2026-10-25 five
|
|
91
|
+
# repositories carried certifi 2026.7.22 against a CRITICAL advisory
|
|
92
|
+
# (GHSA-6q48-6c7w-9gw2, emSign roots with compromised intermediate CA keys) and
|
|
93
|
+
# **Dependabot had never opened a pull request for any of them** -- alerts on,
|
|
94
|
+
# security updates on, no pull request, no explanation available over the API.
|
|
95
|
+
# The release gate could not see it and nothing else was looking.
|
|
96
|
+
#
|
|
97
|
+
# Falsified rather than assumed: with certifi pinned back to 2026.7.22 this
|
|
98
|
+
# reports `certifi 2026.7.22 PYSEC-2026-4320` and exits 1; with the lock
|
|
99
|
+
# current it reports no known vulnerabilities and exits 0.
|
|
100
|
+
audit:
|
|
101
|
+
name: audit (locked dependencies)
|
|
102
|
+
runs-on: ubuntu-latest
|
|
103
|
+
steps:
|
|
104
|
+
- uses: actions/checkout@v5
|
|
105
|
+
|
|
106
|
+
- uses: astral-sh/setup-uv@v7
|
|
107
|
+
with:
|
|
108
|
+
python-version: "3.12.16"
|
|
109
|
+
enable-cache: true
|
|
110
|
+
|
|
111
|
+
- name: Install (locked)
|
|
112
|
+
run: uv sync --group dev --frozen
|
|
113
|
+
|
|
114
|
+
- name: No known vulnerability in the locked dependencies
|
|
115
|
+
# No --strict, deliberately. --strict fails on any distribution it
|
|
116
|
+
# could not audit, and `uv sync` installs the project itself -- so the
|
|
117
|
+
# moment a release bumps the version to one not yet on PyPI, pip-audit
|
|
118
|
+
# cannot look the project up and --strict turns that into a failure.
|
|
119
|
+
# It blocked every version bump in all 23 repositories, which is how it
|
|
120
|
+
# was found: the first real release, dxe-mcp 0.0.3, went red on it with
|
|
121
|
+
# "dxe-mcp: Dependency not found on PyPI and could not be audited"
|
|
122
|
+
# while the lockfile was perfectly clean.
|
|
123
|
+
#
|
|
124
|
+
# Without --strict, pip-audit still exits 1 when it FINDS a
|
|
125
|
+
# vulnerability, which is the whole job. An unauditable local project is
|
|
126
|
+
# reported as skipped, with its reason, and does not fail the run.
|
|
127
|
+
#
|
|
128
|
+
# Verified in both directions against a lock holding certifi 2026.7.22
|
|
129
|
+
# with the version bumped past PyPI: it reports PYSEC-2026-4320 and
|
|
130
|
+
# exits 1; with the lock current it reports no vulnerabilities, names the
|
|
131
|
+
# project as skipped, and exits 0.
|
|
132
|
+
run: uv run --with pip-audit pip-audit
|
|
133
|
+
|
|
134
|
+
ci-all-green:
|
|
135
|
+
name: ci-all-green
|
|
136
|
+
needs: [lint, test, audit]
|
|
137
|
+
runs-on: ubuntu-latest
|
|
138
|
+
if: always()
|
|
139
|
+
steps:
|
|
140
|
+
- name: Verify required jobs all succeeded
|
|
141
|
+
run: |
|
|
142
|
+
if [[ "${{ needs.lint.result }}" != "success" ]]; then
|
|
143
|
+
echo "FAIL: ruff or mypy did not pass (result=${{ needs.lint.result }})"
|
|
144
|
+
exit 1
|
|
145
|
+
fi
|
|
146
|
+
if [[ "${{ needs.test.result }}" != "success" ]]; then
|
|
147
|
+
echo "FAIL: test matrix did not all succeed (result=${{ needs.test.result }})"
|
|
148
|
+
exit 1
|
|
149
|
+
fi
|
|
150
|
+
if [[ "${{ needs.audit.result }}" != "success" ]]; then
|
|
151
|
+
echo "FAIL: the locked dependencies have a known vulnerability (result=${{ needs.audit.result }})"
|
|
152
|
+
exit 1
|
|
153
|
+
fi
|
|
154
|
+
echo "All required CI jobs passed."
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# A release happens on purpose, not as a side effect of merging (KI7MT, 2026-10-25).
|
|
4
|
+
#
|
|
5
|
+
# This used to fire on a push to `main`, which made "merge the release PR" the act of
|
|
6
|
+
# publishing — and that is what forced two pull requests per release: one to prepare the
|
|
7
|
+
# version, and a second to merge `develop` into `main` and ship it.
|
|
8
|
+
#
|
|
9
|
+
# Now `main` is the one working branch. Merging into it builds and tests and publishes
|
|
10
|
+
# nothing; a release is Actions -> Publish to PyPI -> Run workflow, with the version
|
|
11
|
+
# typed in. Typed, because it is a decision: it must match what the tree already says,
|
|
12
|
+
# and every gate below refuses anything it cannot confirm.
|
|
13
|
+
on:
|
|
14
|
+
workflow_dispatch:
|
|
15
|
+
inputs:
|
|
16
|
+
version:
|
|
17
|
+
description: "The version to publish, e.g. 0.2.1 — must match pyproject.toml"
|
|
18
|
+
required: true
|
|
19
|
+
type: string
|
|
20
|
+
|
|
21
|
+
concurrency:
|
|
22
|
+
group: publish
|
|
23
|
+
cancel-in-progress: false
|
|
24
|
+
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
security:
|
|
30
|
+
name: Security gates
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
checks: read
|
|
35
|
+
outputs:
|
|
36
|
+
version: ${{ steps.version.outputs.version }}
|
|
37
|
+
# "yes" when this version is on PyPI with no tag: an interrupted release
|
|
38
|
+
# being finished rather than a new one being made.
|
|
39
|
+
resume: ${{ steps.version.outputs.resume }}
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@v5
|
|
42
|
+
|
|
43
|
+
# workflow_dispatch offers a branch picker, so unlike the old push trigger this
|
|
44
|
+
# workflow can be started from anywhere. The lock is the pypi Environment's
|
|
45
|
+
# deployment branch rule (main only); this is the same rule stated where a failed
|
|
46
|
+
# run can show it, because an Environment rule rejects the deployment with no
|
|
47
|
+
# explanation of what was wrong.
|
|
48
|
+
- name: This runs from main
|
|
49
|
+
run: |
|
|
50
|
+
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
|
51
|
+
echo "FAIL: a release is published from main only (this is $GITHUB_REF)"
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Merging no longer publishes, so nothing else guarantees that what is about to
|
|
56
|
+
# be released is the code CI passed on. Under the old trigger the release PR had
|
|
57
|
+
# to be green before the merge that published it; this restores that property.
|
|
58
|
+
- name: CI passed on the commit being published
|
|
59
|
+
env:
|
|
60
|
+
GH_TOKEN: ${{ github.token }}
|
|
61
|
+
run: |
|
|
62
|
+
PASSED="$(gh api \
|
|
63
|
+
"repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?check_name=ci-all-green" \
|
|
64
|
+
--jq '[.check_runs[] | select(.conclusion == "success")] | length')"
|
|
65
|
+
if [ "${PASSED:-0}" -lt 1 ]; then
|
|
66
|
+
echo "FAIL: ci-all-green has not passed on ${GITHUB_SHA}"
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
echo "ci-all-green passed on ${GITHUB_SHA}"
|
|
70
|
+
|
|
71
|
+
# A release publishes a version that is not out yet. PyPI publishes
|
|
72
|
+
# pyproject's version and the Registry server.json's, so they must agree too.
|
|
73
|
+
# Computed once; every later job uses this value (needs.security.outputs.version).
|
|
74
|
+
# The gate fails closed: anything it cannot confirm stops the release.
|
|
75
|
+
- name: The version is the one asked for, new, and consistent
|
|
76
|
+
id: version
|
|
77
|
+
env:
|
|
78
|
+
# Through the environment rather than interpolated into the script: a
|
|
79
|
+
# ${{ }} expansion inside run: is substituted before the shell sees it, so
|
|
80
|
+
# the input would be script rather than data. Only a writer can dispatch
|
|
81
|
+
# this, and the rule is still input validation on every supplied string.
|
|
82
|
+
ASKED: ${{ inputs.version }}
|
|
83
|
+
run: |
|
|
84
|
+
VERSION="$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)"
|
|
85
|
+
if ! printf '%s' "$ASKED" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([a-z0-9.-]*)$'; then
|
|
86
|
+
echo "FAIL: '$ASKED' is not a version"
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
# Typed by the person releasing, so a slip names a version that is not here
|
|
90
|
+
# rather than shipping whatever happens to be on main.
|
|
91
|
+
if [ "$ASKED" != "$VERSION" ]; then
|
|
92
|
+
echo "FAIL: asked to publish ${ASKED}, but this tree is ${VERSION}"
|
|
93
|
+
exit 1
|
|
94
|
+
fi
|
|
95
|
+
PACKAGE="$(jq -r '.packages[0].identifier' server.json)"
|
|
96
|
+
echo "pyproject=${VERSION} server.json=$(jq -r '.version' server.json)/$(jq -r '.packages[0].version' server.json)"
|
|
97
|
+
if [ "$(jq -r '.version' server.json)" != "$VERSION" ] || [ "$(jq -r '.packages[0].version' server.json)" != "$VERSION" ]; then
|
|
98
|
+
echo "FAIL: server.json does not match pyproject.toml version ${VERSION}"
|
|
99
|
+
exit 1
|
|
100
|
+
fi
|
|
101
|
+
# Only a 404 means "not published yet"; unreachable or 5xx is not a yes.
|
|
102
|
+
# PyPI and the tag are read together, because neither alone says
|
|
103
|
+
# whether this version is releasable.
|
|
104
|
+
#
|
|
105
|
+
# This gate used to refuse on PyPI 200 full stop, and that made every
|
|
106
|
+
# interruption after the upload cost a version. The tag is written by
|
|
107
|
+
# the last job, so a transient failure in between -- the MCP Registry
|
|
108
|
+
# 504s on 2026-10-26, nine bytes served for the mcp-publisher
|
|
109
|
+
# download on 2026-10-27 -- left the version on PyPI, which is
|
|
110
|
+
# immutable, with no tag and no registry entry. The only recovery was
|
|
111
|
+
# to bump and release again, abandoning the version. Three releases
|
|
112
|
+
# went that way.
|
|
113
|
+
#
|
|
114
|
+
# So the four states are named, and only two of them refuse:
|
|
115
|
+
#
|
|
116
|
+
# PyPI 404, no tag a new release
|
|
117
|
+
# PyPI 200, no tag a release that was interrupted after the
|
|
118
|
+
# upload. RESUMABLE: the upload is skipped and
|
|
119
|
+
# the remaining jobs finish the release
|
|
120
|
+
# PyPI 200, tagged already released
|
|
121
|
+
# PyPI 404, tagged a tag with nothing behind it; something is
|
|
122
|
+
# wrong and guessing is worse than stopping
|
|
123
|
+
CODE="$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json")"
|
|
124
|
+
case "$CODE" in
|
|
125
|
+
404|200) : ;;
|
|
126
|
+
*) echo "FAIL: PyPI returned ${CODE}; cannot tell whether ${VERSION} is published"; exit 1 ;;
|
|
127
|
+
esac
|
|
128
|
+
# git ls-remote --exit-code: 2 means no such tag; anything else (0, 128) stops.
|
|
129
|
+
set +e
|
|
130
|
+
git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" > /dev/null
|
|
131
|
+
RC=$?
|
|
132
|
+
set -e
|
|
133
|
+
case "$RC" in
|
|
134
|
+
0) TAGGED=yes ;;
|
|
135
|
+
2) TAGGED=no ;;
|
|
136
|
+
*) echo "FAIL: could not check tags (git ls-remote exited ${RC})"; exit 1 ;;
|
|
137
|
+
esac
|
|
138
|
+
|
|
139
|
+
RESUME=false
|
|
140
|
+
if [ "$CODE" = 404 ] && [ "$TAGGED" = no ]; then
|
|
141
|
+
echo "PyPI: ${PACKAGE} ${VERSION} is new, tag v${VERSION} is free"
|
|
142
|
+
elif [ "$CODE" = 200 ] && [ "$TAGGED" = no ]; then
|
|
143
|
+
RESUME=true
|
|
144
|
+
echo "NOTE: ${PACKAGE} ${VERSION} is already on PyPI with no tag v${VERSION}."
|
|
145
|
+
echo " A previous run uploaded it and did not finish. Resuming:"
|
|
146
|
+
echo " the PyPI upload is skipped and the registry, verify and"
|
|
147
|
+
echo " tag jobs will complete this release."
|
|
148
|
+
elif [ "$CODE" = 200 ] && [ "$TAGGED" = yes ]; then
|
|
149
|
+
echo "FAIL: ${PACKAGE} ${VERSION} is on PyPI and tagged v${VERSION} — already released."
|
|
150
|
+
echo " Bump the version to release again."
|
|
151
|
+
exit 1
|
|
152
|
+
else
|
|
153
|
+
echo "FAIL: tag v${VERSION} exists but PyPI has no ${VERSION}."
|
|
154
|
+
echo " That is not a state this workflow created; stopping rather than guessing."
|
|
155
|
+
exit 1
|
|
156
|
+
fi
|
|
157
|
+
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
158
|
+
echo "resume=${RESUME}" >> "$GITHUB_OUTPUT"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
- uses: actions/setup-python@v6
|
|
163
|
+
with:
|
|
164
|
+
python-version: "3.12.16"
|
|
165
|
+
|
|
166
|
+
- name: Install test dependencies
|
|
167
|
+
run: |
|
|
168
|
+
# pip loads its OWN vendored certifi in addition to the system
|
|
169
|
+
# trust store, and there is no supported way to disable that, so a
|
|
170
|
+
# current certifi in uv.lock does not cover pip itself. 26.2.2 is
|
|
171
|
+
# the eMudhra fix. https://discuss.python.org/t/109576
|
|
172
|
+
python -m pip install --upgrade 'pip>=26.2.2'
|
|
173
|
+
python -m pip --version # in the log, not assumed
|
|
174
|
+
pip install pytest pip-audit
|
|
175
|
+
|
|
176
|
+
- name: Run security test suite
|
|
177
|
+
run: python -m pytest tests/test_security.py -v
|
|
178
|
+
|
|
179
|
+
- name: Audit dependencies for vulnerabilities
|
|
180
|
+
# Audits this package's declared dependencies, and can fail: a known CVE stops the release.
|
|
181
|
+
# A finding with no fix yet is excepted only explicitly: --ignore-vuln ID, with a reason and date.
|
|
182
|
+
run: pip-audit --strict --desc on .
|
|
183
|
+
|
|
184
|
+
- name: Grep check — no subprocess/shell
|
|
185
|
+
run: |
|
|
186
|
+
if grep -rn 'subprocess\.\|os\.system\|shell=True' src/; then
|
|
187
|
+
echo "FAIL: subprocess/shell found in source"
|
|
188
|
+
exit 1
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
- name: Grep check — no plaintext http://
|
|
192
|
+
run: |
|
|
193
|
+
# Tests the output rather than the pipeline's exit status, because the
|
|
194
|
+
# output is what this gate is actually asking about: are there any
|
|
195
|
+
# plaintext URLs. An exit status answers a different question and
|
|
196
|
+
# answers it through whichever grep is on PATH.
|
|
197
|
+
#
|
|
198
|
+
# The earlier comment here blamed macOS /usr/bin/grep for exiting 0 on
|
|
199
|
+
# empty input. Measured 2026-10-25, that is false: /usr/bin/grep exits
|
|
200
|
+
# 1 when no line is selected, as POSIX requires and as GNU grep does.
|
|
201
|
+
# The FAIL-on-a-clean-tree that prompted this form came from a
|
|
202
|
+
# non-conforming grep shim in an agent's shell. The form is kept
|
|
203
|
+
# because it is right for its own reason, not for that one.
|
|
204
|
+
hits="$(grep -rn 'http://' src/ | grep -v 'localhost\|127\.0\.0\.1\|::1' || true)"
|
|
205
|
+
if [ -n "$hits" ]; then
|
|
206
|
+
echo "$hits"
|
|
207
|
+
echo "FAIL: non-HTTPS URL found in source"
|
|
208
|
+
exit 1
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
- name: Grep check — no credential logging
|
|
212
|
+
run: |
|
|
213
|
+
if grep -rn -i 'print.*password\|print.*api_key\|logging.*password\|logging.*api_key' src/; then
|
|
214
|
+
echo "FAIL: credential logging found in source"
|
|
215
|
+
exit 1
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
publish:
|
|
219
|
+
name: Build and publish to PyPI
|
|
220
|
+
needs: security
|
|
221
|
+
runs-on: ubuntu-latest
|
|
222
|
+
environment: pypi
|
|
223
|
+
permissions:
|
|
224
|
+
id-token: write # Required for trusted publishing (OIDC)
|
|
225
|
+
steps:
|
|
226
|
+
- uses: actions/checkout@v5
|
|
227
|
+
|
|
228
|
+
- uses: actions/setup-python@v6
|
|
229
|
+
with:
|
|
230
|
+
python-version: "3.12.16"
|
|
231
|
+
|
|
232
|
+
- name: Install build dependencies
|
|
233
|
+
if: needs.security.outputs.resume != 'true'
|
|
234
|
+
run: pip install build
|
|
235
|
+
|
|
236
|
+
- name: Build package
|
|
237
|
+
if: needs.security.outputs.resume != 'true'
|
|
238
|
+
run: python -m build
|
|
239
|
+
|
|
240
|
+
- name: Publish to PyPI
|
|
241
|
+
if: needs.security.outputs.resume != 'true'
|
|
242
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
243
|
+
|
|
244
|
+
- name: Already on PyPI — resuming
|
|
245
|
+
if: needs.security.outputs.resume == 'true'
|
|
246
|
+
run: |
|
|
247
|
+
# The upload happened in an earlier run that did not finish. PyPI
|
|
248
|
+
# rejects a second upload of the same version anyway, so skipping it
|
|
249
|
+
# is required rather than merely tidy. The jobs after this one are
|
|
250
|
+
# what the resume is for.
|
|
251
|
+
echo "skipped the upload: this version is already on PyPI"
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# Registry-publish — pushes server.json to the Official MCP Registry after
|
|
255
|
+
# PyPI publish succeeds, so discovery surfaces stay in sync with PyPI.
|
|
256
|
+
# Canonical template: https://github.com/qso-graph/.github/blob/main/TEMPLATES.md
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
registry-publish:
|
|
259
|
+
name: Publish to MCP Registry
|
|
260
|
+
needs: [security, publish] # waits for PyPI publish to succeed
|
|
261
|
+
runs-on: ubuntu-latest
|
|
262
|
+
permissions:
|
|
263
|
+
id-token: write # GitHub OIDC
|
|
264
|
+
contents: read
|
|
265
|
+
|
|
266
|
+
steps:
|
|
267
|
+
- uses: actions/checkout@v5
|
|
268
|
+
|
|
269
|
+
- name: Install jq (server.json bump)
|
|
270
|
+
run: sudo apt-get update && sudo apt-get install -y jq
|
|
271
|
+
|
|
272
|
+
- name: Install mcp-publisher
|
|
273
|
+
run: |
|
|
274
|
+
# Downloaded to a file, not piped into tar, and with --fail.
|
|
275
|
+
#
|
|
276
|
+
# A bare `curl -L | tar xz` cannot fail usefully: curl writes the
|
|
277
|
+
# error body to stdout, so a 404 becomes nine bytes of "Not Found"
|
|
278
|
+
# fed to tar, which exits 2 with "not in gzip format" and says
|
|
279
|
+
# nothing about what actually went wrong. That is what happened on
|
|
280
|
+
# 2026-10-27 at 14:45:53Z -- the asset existed and the URL was right;
|
|
281
|
+
# GitHub served nine bytes for one request. --retry turns that into a
|
|
282
|
+
# retry instead of a failed release.
|
|
283
|
+
URL="https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz"
|
|
284
|
+
curl --fail --location --retry 5 --retry-all-errors --retry-delay 3 \
|
|
285
|
+
--output mcp-publisher.tar.gz "$URL"
|
|
286
|
+
tar xzf mcp-publisher.tar.gz mcp-publisher
|
|
287
|
+
./mcp-publisher --help > /dev/null # smoke test
|
|
288
|
+
|
|
289
|
+
- name: Set server.json to the released version
|
|
290
|
+
run: |
|
|
291
|
+
VERSION="${{ needs.security.outputs.version }}"
|
|
292
|
+
echo "Setting server.json to ${VERSION}"
|
|
293
|
+
jq --arg v "$VERSION" \
|
|
294
|
+
'.version = $v | .packages[0].version = $v' \
|
|
295
|
+
server.json > /tmp/server.json
|
|
296
|
+
mv /tmp/server.json server.json
|
|
297
|
+
cat server.json
|
|
298
|
+
|
|
299
|
+
# The Registry checks PyPI itself and refuses a version PyPI isn't
|
|
300
|
+
# serving yet, which can lag the upload by a minute or more.
|
|
301
|
+
- name: Wait until PyPI serves this version
|
|
302
|
+
run: |
|
|
303
|
+
VERSION="${{ needs.security.outputs.version }}"
|
|
304
|
+
PACKAGE="$(jq -r '.packages[0].identifier' server.json)"
|
|
305
|
+
for i in $(seq 1 40); do
|
|
306
|
+
if curl -fsS "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" > /dev/null; then
|
|
307
|
+
echo "PyPI serves ${PACKAGE} ${VERSION}"
|
|
308
|
+
exit 0
|
|
309
|
+
fi
|
|
310
|
+
echo "attempt ${i}: PyPI doesn't serve ${PACKAGE} ${VERSION} yet"
|
|
311
|
+
sleep 15
|
|
312
|
+
done
|
|
313
|
+
echo "FAIL: PyPI never served ${PACKAGE} ${VERSION}"
|
|
314
|
+
exit 1
|
|
315
|
+
|
|
316
|
+
# One step, because authentication has to happen INSIDE the retry loop.
|
|
317
|
+
#
|
|
318
|
+
# The Registry JWT lives about six minutes. This loop sleeps 30+60+90+120+150
|
|
319
|
+
# seconds and each attempt can take another minute, so it is built to run for
|
|
320
|
+
# roughly twelve. pota-mcp 0.5.0 on 2026-10-26 spent its last two attempts on
|
|
321
|
+
# a dead credential:
|
|
322
|
+
# 17:48:52 login
|
|
323
|
+
# 17:49:53 attempt 1 504 <- the Registry was genuinely down
|
|
324
|
+
# 17:51:28 attempt 2 504
|
|
325
|
+
# 17:53:29 attempt 3 504
|
|
326
|
+
# 17:54:59 attempt 4 401 "token has invalid claims: token is expired"
|
|
327
|
+
# 17:57:00 attempt 5 401
|
|
328
|
+
# PyPI had already accepted the upload, so the run failed after publishing,
|
|
329
|
+
# the tag job never ran, and 0.5.0 is on PyPI untagged and unregistered for
|
|
330
|
+
# good -- PyPI is immutable. The outage was theirs; not surviving it was ours.
|
|
331
|
+
- name: Authenticate and publish to the MCP Registry
|
|
332
|
+
run: |
|
|
333
|
+
VERSION="${{ needs.security.outputs.version }}"
|
|
334
|
+
NAME="$(jq -r '.name' server.json)"
|
|
335
|
+
REGISTRY="https://registry.modelcontextprotocol.io/v0/servers?search=${NAME}&version=latest"
|
|
336
|
+
for i in 1 2 3 4 5; do
|
|
337
|
+
# Fresh token per attempt. A login failure is not fatal on its own:
|
|
338
|
+
# if the Registry is down it will fail here too, and the next attempt
|
|
339
|
+
# after the backoff is the one that matters.
|
|
340
|
+
./mcp-publisher login github-oidc || echo "login attempt ${i} failed"
|
|
341
|
+
if ./mcp-publisher publish; then
|
|
342
|
+
exit 0
|
|
343
|
+
fi
|
|
344
|
+
echo "attempt ${i} failed; retrying in $((i * 30)) s"
|
|
345
|
+
sleep $((i * 30))
|
|
346
|
+
REG="$(curl -fsS "$REGISTRY" | jq -r --arg n "$NAME" '[.servers[] | select(.server.name == $n) | .server.version][0] // empty' || true)"
|
|
347
|
+
if [ "$REG" = "$VERSION" ]; then
|
|
348
|
+
echo "The Registry already has ${NAME} ${VERSION}; an earlier attempt landed"
|
|
349
|
+
exit 0
|
|
350
|
+
fi
|
|
351
|
+
done
|
|
352
|
+
echo "FAIL: the MCP Registry refused the publish 5 times"
|
|
353
|
+
exit 1
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Verify — a release is complete only when PyPI AND the MCP Registry both
|
|
357
|
+
# serve the tagged version. If either doesn't within 10 minutes, this run
|
|
358
|
+
# fails: a green run means published everywhere, a red one means not done.
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
verify:
|
|
361
|
+
name: Verify PyPI and MCP Registry
|
|
362
|
+
needs: [security, registry-publish]
|
|
363
|
+
runs-on: ubuntu-latest
|
|
364
|
+
steps:
|
|
365
|
+
- uses: actions/checkout@v5
|
|
366
|
+
|
|
367
|
+
- name: PyPI and Registry serve the released version
|
|
368
|
+
run: |
|
|
369
|
+
VERSION="${{ needs.security.outputs.version }}"
|
|
370
|
+
PACKAGE="$(jq -r '.packages[0].identifier' server.json)"
|
|
371
|
+
NAME="$(jq -r '.name' server.json)"
|
|
372
|
+
REGISTRY="https://registry.modelcontextprotocol.io/v0/servers?search=${NAME}&version=latest"
|
|
373
|
+
for i in $(seq 1 40); do
|
|
374
|
+
PYPI="$(curl -fsS "https://pypi.org/pypi/${PACKAGE}/json" | jq -r '.info.version' || true)"
|
|
375
|
+
REG="$(curl -fsS "$REGISTRY" | jq -r --arg n "$NAME" '[.servers[] | select(.server.name == $n) | .server.version][0] // empty' || true)"
|
|
376
|
+
echo "attempt ${i}: PyPI=${PYPI:-none} Registry=${REG:-none} want=${VERSION}"
|
|
377
|
+
if [ "$PYPI" = "$VERSION" ] && [ "$REG" = "$VERSION" ]; then
|
|
378
|
+
echo "Published everywhere: ${PACKAGE} ${VERSION} on PyPI and ${NAME} ${VERSION} in the MCP Registry"
|
|
379
|
+
exit 0
|
|
380
|
+
fi
|
|
381
|
+
sleep 15
|
|
382
|
+
done
|
|
383
|
+
echo "FAIL: release incomplete. PyPI=${PYPI:-none} Registry=${REG:-none}, expected ${VERSION}"
|
|
384
|
+
exit 1
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Tag the release, once it is published everywhere. The tag marks the commit on
|
|
388
|
+
# main that was released; nothing is triggered by it. If this job fails after a
|
|
389
|
+
# green verify, the release IS published: re-running this job is safe.
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
tag:
|
|
392
|
+
name: Tag the release
|
|
393
|
+
needs: [security, verify]
|
|
394
|
+
runs-on: ubuntu-latest
|
|
395
|
+
permissions:
|
|
396
|
+
contents: write
|
|
397
|
+
steps:
|
|
398
|
+
- uses: actions/checkout@v5
|
|
399
|
+
|
|
400
|
+
- name: Create and push vX.Y.Z
|
|
401
|
+
run: |
|
|
402
|
+
VERSION="${{ needs.security.outputs.version }}"
|
|
403
|
+
git config user.name "github-actions[bot]"
|
|
404
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
405
|
+
git tag -a "v${VERSION}" -m "${GITHUB_REPOSITORY#*/} ${VERSION}" "$GITHUB_SHA"
|
|
406
|
+
git push origin "v${VERSION}"
|
|
407
|
+
|
|
408
|
+
# The tag alone left every Releases page in this org empty. The body is
|
|
409
|
+
# this version's own CHANGELOG section, extracted by heading rather than
|
|
410
|
+
# by line count, because the fleet has three heading styles:
|
|
411
|
+
# ## [0.4.0] — date ## [2.0.0] - date ## 0.2.0 (date)
|
|
412
|
+
# It stops at the next "## " heading, and returns nothing for a version
|
|
413
|
+
# it cannot find -- so a missing section falls back to GitHub's own
|
|
414
|
+
# generated notes rather than publishing the whole changelog.
|
|
415
|
+
#
|
|
416
|
+
# After `verify` by construction: a Release only ever describes a version
|
|
417
|
+
# already confirmed on PyPI and the MCP Registry.
|
|
418
|
+
- name: Create the GitHub Release
|
|
419
|
+
env:
|
|
420
|
+
GH_TOKEN: ${{ github.token }}
|
|
421
|
+
run: |
|
|
422
|
+
VERSION="${{ needs.security.outputs.version }}"
|
|
423
|
+
python3 - "$VERSION" CHANGELOG.md > /tmp/notes.md <<'EXTRACT'
|
|
424
|
+
import re, sys, pathlib
|
|
425
|
+
ver, path = sys.argv[1], sys.argv[2]
|
|
426
|
+
text = pathlib.Path(path).read_text()
|
|
427
|
+
start = re.search(r'(?m)^##\s+\[?%s\]?\b.*$' % re.escape(ver), text)
|
|
428
|
+
if not start:
|
|
429
|
+
raise SystemExit(0)
|
|
430
|
+
rest = text[start.end():]
|
|
431
|
+
nxt = re.search(r'(?m)^##\s', rest)
|
|
432
|
+
print((rest[:nxt.start()] if nxt else rest).strip())
|
|
433
|
+
EXTRACT
|
|
434
|
+
|
|
435
|
+
# A version with a suffix (0.2.1rc1) is a pre-release, not Latest.
|
|
436
|
+
PRE=""
|
|
437
|
+
case "$VERSION" in
|
|
438
|
+
*[!0-9.]*) PRE="--prerelease" ;;
|
|
439
|
+
esac
|
|
440
|
+
|
|
441
|
+
if [ -s /tmp/notes.md ]; then
|
|
442
|
+
echo "release notes: this version's CHANGELOG section ($(wc -l < /tmp/notes.md) lines)"
|
|
443
|
+
gh release create "v${VERSION}" --verify-tag --title "v${VERSION}" \
|
|
444
|
+
--notes-file /tmp/notes.md $PRE
|
|
445
|
+
else
|
|
446
|
+
echo "release notes: no CHANGELOG section for ${VERSION}; using generated notes"
|
|
447
|
+
gh release create "v${VERSION}" --verify-tag --title "v${VERSION}" \
|
|
448
|
+
--generate-notes $PRE
|
|
449
|
+
fi
|