Skip to content

Commit b7f69d1

Browse files
authored
Merge pull request DeusData#1547 from DeusData/feat/mcpb-bundles
feat(release): ship MCPB bundles and publish them to the MCP Registry (DeusData#1246)
2 parents b377c62 + 141efe3 commit b7f69d1

10 files changed

Lines changed: 661 additions & 41 deletions

‎.github/workflows/_build.yml‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,26 @@ jobs:
8585
# Archive layout/name live in the ONE canonical script the local
8686
# artifact-flow smoke lane also runs (venue-parity contract).
8787
- name: Archive release binary (canonical package-release.sh)
88+
env:
89+
VERSION: ${{ inputs.version }}
8890
run: scripts/package-release.sh ${{ matrix.goos }} ${{ matrix.goarch }}
8991

9092
- name: Attest release binary provenance
9193
if: ${{ inputs.attest }}
9294
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
9395
with:
94-
subject-path: codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
96+
# *.mcpb matches nothing on the glibc-dynamic linux targets — the
97+
# canonical packager builds bundles only where static/one-click holds.
98+
subject-path: |
99+
codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
100+
*.mcpb
95101
96102
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
97103
with:
98104
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
99-
path: "*.tar.gz"
105+
path: |
106+
*.tar.gz
107+
*.mcpb
100108
101109
build-windows:
102110
runs-on: windows-latest
@@ -133,18 +141,24 @@ jobs:
133141
# artifact-flow smoke lane also runs (venue-parity contract).
134142
- name: Archive release binary (canonical package-release.sh)
135143
shell: msys2 {0}
144+
env:
145+
VERSION: ${{ inputs.version }}
136146
run: scripts/package-release.sh windows amd64 CC=clang CXX=clang++
137147

138148
- name: Attest release binary provenance
139149
if: ${{ inputs.attest }}
140150
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
141151
with:
142-
subject-path: codebase-memory-mcp-windows-amd64.zip
152+
subject-path: |
153+
codebase-memory-mcp-windows-amd64.zip
154+
*.mcpb
143155
144156
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
145157
with:
146158
name: binaries-windows-amd64
147-
path: "*.zip"
159+
path: |
160+
*.zip
161+
*.mcpb
148162
149163
build-windows-arm64:
150164
# Native ARM64 Windows binary via the CLANGARM64 toolchain on the
@@ -183,18 +197,24 @@ jobs:
183197
184198
- name: Archive release binary (canonical package-release.sh)
185199
shell: msys2 {0}
200+
env:
201+
VERSION: ${{ inputs.version }}
186202
run: scripts/package-release.sh windows arm64 CC=clang CXX=clang++
187203

188204
- name: Attest release binary provenance
189205
if: ${{ inputs.attest }}
190206
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
191207
with:
192-
subject-path: codebase-memory-mcp-windows-arm64.zip
208+
subject-path: |
209+
codebase-memory-mcp-windows-arm64.zip
210+
*.mcpb
193211
194212
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
195213
with:
196214
name: binaries-windows-arm64
197-
path: "*.zip"
215+
path: |
216+
*.zip
217+
*.mcpb
198218
199219
build-linux-portable:
200220
# Fully static Linux binaries (gcc -static on Ubuntu).
@@ -235,15 +255,21 @@ jobs:
235255
ldd build/c/codebase-memory-mcp 2>&1 | grep -q "not a dynamic executable" || ldd build/c/codebase-memory-mcp 2>&1 | grep -q "statically linked"
236256
237257
- name: Archive release binary (canonical package-release.sh)
258+
env:
259+
VERSION: ${{ inputs.version }}
238260
run: scripts/package-release.sh linux ${{ matrix.arch }}-portable
239261

240262
- name: Attest release binary provenance
241263
if: ${{ inputs.attest }}
242264
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
243265
with:
244-
subject-path: codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz
266+
subject-path: |
267+
codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz
268+
*.mcpb
245269
246270
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
247271
with:
248272
name: binaries-linux-${{ matrix.arch }}-portable
249-
path: "*.tar.gz"
273+
path: |
274+
*.tar.gz
275+
*.mcpb

‎.github/workflows/release.yml‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,16 +129,25 @@ jobs:
129129
merge-multiple: true
130130

131131
- name: List artifacts
132-
run: ls -la *.tar.gz *.zip
132+
run: ls -la *.tar.gz *.zip *.mcpb
133133

134134
- name: Generate checksums
135-
run: sha256sum *.tar.gz *.zip > checksums.txt
135+
run: sha256sum *.tar.gz *.zip *.mcpb > checksums.txt
136136

137137
- name: Attest checksum provenance
138138
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
139139
with:
140140
subject-path: checksums.txt
141141

142+
# publish-mcp-registry reads the *.mcpb sha256 lines from here — a
143+
# same-run workflow artifact, not a draft-release download, so that
144+
# job keeps contents: read and no gh dependency.
145+
- name: Preserve checksums for the registry job
146+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
147+
with:
148+
name: release-checksums
149+
path: checksums.txt
150+
142151
# SBOM content lives in the canonical scripts/ci entry, not inline YAML
143152
# (venue-parity contract): vendored versions are reviewable there.
144153
- name: Generate SBOM
@@ -155,7 +164,7 @@ jobs:
155164

156165
- name: Sign artifacts
157166
run: |
158-
for f in *.tar.gz *.zip checksums.txt; do
167+
for f in *.tar.gz *.zip *.mcpb checksums.txt; do
159168
cosign sign-blob --yes --bundle "${f}.bundle" "$f"
160169
done
161170
@@ -187,6 +196,7 @@ jobs:
187196
files: |
188197
*.tar.gz
189198
*.zip
199+
*.mcpb
190200
checksums.txt
191201
sbom.json
192202
*.bundle
@@ -214,11 +224,14 @@ jobs:
214224
run: |
215225
ARCHIVE_DIR="$RUNNER_TEMP/release-archives"
216226
mkdir -p "$ARCHIVE_DIR" binaries
217-
gh release download "$VERSION" --dir "$ARCHIVE_DIR" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip'
227+
gh release download "$VERSION" --dir "$ARCHIVE_DIR" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip' --pattern '*.mcpb'
228+
# 14 = 8 archives + 6 MCPB bundles; runtime 42 = 8×3 archive
229+
# sidecars + 6×3 bundle members (manifest.json, LICENSE, notices).
230+
# The bundle binaries dedupe to the archive objects byte-for-byte.
218231
scripts/ci/extract-release-archives.sh "$ARCHIVE_DIR" binaries \
219-
--expect-archives=8 \
220-
--expect-binaries=8 \
221-
--expect-runtime-files=24
232+
--expect-archives=14 \
233+
--expect-binaries=14 \
234+
--expect-runtime-files=42
222235
223236
- name: Security audits on all unique extracted release objects
224237
run: |
@@ -418,6 +431,20 @@ jobs:
418431
jq --arg v "$VERSION" '.version = $v | (.packages[].version) = $v' \
419432
server.json > server.tmp && mv server.tmp server.json
420433
echo "server.json pinned to $VERSION"
434+
435+
# The MCPB entries need each bundle's sha256; checksums.txt comes from
436+
# the release-draft job as a same-run artifact (not a draft-release
437+
# download — that would need contents beyond read plus gh).
438+
- name: Fetch release checksums
439+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
440+
with:
441+
name: release-checksums
442+
443+
- name: Append MCPB package entries to server.json
444+
env:
445+
RELEASE_VERSION: ${{ inputs.version }}
446+
run: |
447+
scripts/ci/gen-mcpb-registry-entries.sh server.json checksums.txt "$RELEASE_VERSION"
421448
cat server.json
422449
423450
- name: Install mcp-publisher

‎scripts/ci/extract-release-archives.sh‎

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ from __future__ import annotations
3333
3434
import csv
3535
import hashlib
36+
import json
3637
import os
3738
import pathlib
3839
import re
@@ -71,9 +72,20 @@ UNIX_TARGETS = (
7172
"linux-arm64-portable",
7273
)
7374
WINDOWS_TARGETS = ("windows-amd64", "windows-arm64")
75+
# MCPB bundles exist for darwin/windows and the STATIC linux builds only —
76+
# the same eligibility rule scripts/package-release.sh encodes.
77+
MCPB_TARGETS = (
78+
"darwin-amd64",
79+
"darwin-arm64",
80+
"linux-amd64-portable",
81+
"linux-arm64-portable",
82+
"windows-amd64",
83+
"windows-arm64",
84+
)
7485
CANONICAL_ARCHIVES = frozenset(
7586
[f"codebase-memory-mcp-{target}.tar.gz" for target in UNIX_TARGETS]
7687
+ [f"codebase-memory-mcp-{target}.zip" for target in WINDOWS_TARGETS]
88+
+ [f"codebase-memory-mcp-{target}.mcpb" for target in MCPB_TARGETS]
7789
)
7890
# One composition ships; the association column is retained so the schema stays
7991
# stable for the gate and release-notes consumers.
@@ -308,27 +320,70 @@ def validate_namespace(archive_name: str, names: Iterable[str]) -> Dict[str, str
308320
if len(names_list) != len(set(names_list)):
309321
duplicate = next(name for name in names_list if names_list.count(name) > 1)
310322
raise ContractError(f"duplicate archive member in {archive_name}: {duplicate}")
311-
windows = archive_name.endswith(".zip")
312-
binary = "codebase-memory-mcp.exe" if windows else "codebase-memory-mcp"
313-
installer = "install.ps1" if windows else "install.sh"
314-
fixed = {
315-
binary: "binary",
316-
"LICENSE": "runtime",
317-
installer: "runtime",
318-
"THIRD_PARTY_NOTICES.md": "runtime",
319-
}
323+
# The archive name is already validated against CANONICAL_ARCHIVES, so
324+
# platform detection by name is sound for every container kind.
325+
windows = "-windows-" in archive_name
326+
if archive_name.endswith(".mcpb"):
327+
binary = "server/codebase-memory-mcp.exe" if windows else "server/codebase-memory-mcp"
328+
fixed = {
329+
"manifest.json": "runtime",
330+
binary: "binary",
331+
"server/LICENSE": "runtime",
332+
"server/THIRD_PARTY_NOTICES.md": "runtime",
333+
}
334+
else:
335+
binary = "codebase-memory-mcp.exe" if windows else "codebase-memory-mcp"
336+
installer = "install.ps1" if windows else "install.sh"
337+
fixed = {
338+
binary: "binary",
339+
"LICENSE": "runtime",
340+
installer: "runtime",
341+
"THIRD_PARTY_NOTICES.md": "runtime",
342+
}
320343
name_set = set(names_list)
321344
extras = name_set - set(fixed)
322345
if extras:
323346
raise ContractError(f"unexpected archive member in {archive_name}: {sorted(extras)[0]}")
324347
if name_set != set(fixed):
325348
missing = sorted(set(fixed) - name_set)
326349
raise ContractError(
327-
f"member namespace mismatch in {archive_name}: expected exactly 4 root files; missing={missing}"
350+
f"member namespace mismatch in {archive_name}: expected exactly 4 members; missing={missing}"
328351
)
329352
return fixed
330353
331354
355+
def validate_mcpb_manifest(path: pathlib.Path, *, archive_name: str) -> None:
356+
"""A structurally broken bundle must fail the matrix, not ship.
357+
358+
The namespace check above proves manifest.json EXISTS; this proves it
359+
actually describes the binary the bundle carries. Full schema validation
360+
belongs to MCPB hosts — the gate pins only what a wrong build would break.
361+
"""
362+
with zipfile.ZipFile(path, "r") as archive:
363+
try:
364+
manifest = json.loads(archive.read("manifest.json"))
365+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
366+
raise ContractError(f"manifest.json in {archive_name} is not valid JSON: {error}")
367+
if not isinstance(manifest, dict):
368+
raise ContractError(f"manifest.json in {archive_name} must be a JSON object")
369+
if not manifest.get("version"):
370+
raise ContractError(f"manifest.json in {archive_name} lacks a version")
371+
server = manifest.get("server")
372+
if not isinstance(server, dict) or server.get("type") != "binary":
373+
raise ContractError(f"manifest.json in {archive_name} must declare a binary server")
374+
entry = server.get("entry_point")
375+
if entry not in set(archive.namelist()):
376+
raise ContractError(
377+
f"manifest entry_point is not a member of {archive_name}: {entry}"
378+
)
379+
mcp_config = server.get("mcp_config")
380+
command = mcp_config.get("command") if isinstance(mcp_config, dict) else None
381+
if not isinstance(command, str) or not command.endswith(entry):
382+
raise ContractError(
383+
f"manifest mcp_config.command does not target the entry_point in {archive_name}"
384+
)
385+
386+
332387
def add_association(
333388
rows: List[Dict[str, object]],
334389
scan_object: ScanObject,
@@ -582,6 +637,8 @@ def main(argv: Sequence[str]) -> None:
582637
total_members += member_total
583638
if total_members > MAX_TOTAL_MEMBER_BYTES:
584639
raise ContractError("release matrix exceeds total uncompressed byte ceiling")
640+
if archive_name.endswith(".mcpb"):
641+
validate_mcpb_manifest(archive_object.path, archive_name=archive_name)
585642
counts["archives"] += 1
586643
counts["binaries"] += sum(kind == "binary" for kind in kinds.values())
587644
counts["runtime_files"] += sum(kind == "runtime" for kind in kinds.values())
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env bash
2+
# gen-mcpb-registry-entries.sh — append MCPB package entries to server.json.
3+
#
4+
# The npm/PyPI entries are static repo content, but MCPB entries carry a
5+
# per-release fileSha256, so they can only exist at publish time. This script
6+
# derives them from the same checksums.txt the draft release ships (and
7+
# attests), never by re-hashing anything itself: one checksum authority.
8+
#
9+
# Usage: gen-mcpb-registry-entries.sh <server.json> <checksums.txt> <release-tag>
10+
#
11+
# release-tag the GitHub release tag, v-prefixed (v0.11.0); it forms the
12+
# download URL. The package version field drops the prefix.
13+
#
14+
# Idempotent: existing mcpb entries are replaced, never duplicated, so a
15+
# re-run of the registry job cannot grow the manifest. A checksums file
16+
# without a single .mcpb line is a hard failure — publishing a manifest that
17+
# silently un-lists the bundles would hide a broken artifact chain
18+
# (the #1522 silent-empty-results lesson).
19+
set -euo pipefail
20+
21+
if [ "$#" -ne 3 ]; then
22+
echo "Usage: $0 <server.json> <checksums.txt> <release-tag>" >&2
23+
exit 2
24+
fi
25+
26+
command -v python3 >/dev/null 2>&1 || {
27+
echo "FAIL: python3 is required to generate MCPB registry entries" >&2
28+
exit 1
29+
}
30+
31+
python3 - "$@" <<'PY'
32+
import json
33+
import pathlib
34+
import re
35+
import sys
36+
37+
server_json = pathlib.Path(sys.argv[1])
38+
checksums = pathlib.Path(sys.argv[2])
39+
tag = sys.argv[3]
40+
41+
if not tag:
42+
print("FAIL: empty release tag", file=sys.stderr)
43+
raise SystemExit(1)
44+
for path in (server_json, checksums):
45+
if not path.is_file():
46+
print(f"FAIL: missing {path}", file=sys.stderr)
47+
raise SystemExit(1)
48+
49+
manifest = json.loads(server_json.read_text(encoding="utf-8"))
50+
repo_url = (manifest.get("repository") or {}).get("url")
51+
if not repo_url:
52+
print(f"FAIL: {server_json} has no repository.url to build download URLs from",
53+
file=sys.stderr)
54+
raise SystemExit(1)
55+
repo_url = repo_url.rstrip("/")
56+
version = tag[1:] if tag.startswith("v") else tag
57+
58+
entries = []
59+
for line in checksums.read_text(encoding="utf-8").splitlines():
60+
parts = line.split()
61+
if len(parts) != 2 or not parts[1].endswith(".mcpb"):
62+
continue
63+
sha, name = parts
64+
if not re.fullmatch(r"[0-9a-f]{64}", sha):
65+
print(f"FAIL: malformed sha256 for {name} in {checksums}", file=sys.stderr)
66+
raise SystemExit(1)
67+
entries.append((name, sha))
68+
69+
if not entries:
70+
print(f"FAIL: no .mcpb lines in {checksums}", file=sys.stderr)
71+
raise SystemExit(1)
72+
73+
packages = [package for package in manifest.get("packages", [])
74+
if package.get("registryType") != "mcpb"]
75+
for name, sha in sorted(entries):
76+
packages.append({
77+
"registryType": "mcpb",
78+
"identifier": f"{repo_url}/releases/download/{tag}/{name}",
79+
"version": version,
80+
"fileSha256": sha,
81+
"transport": {"type": "stdio"},
82+
})
83+
print(f"mcpb entry: {name} ({sha})")
84+
manifest["packages"] = packages
85+
86+
server_json.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
87+
print(f"appended {len(entries)} mcpb package entries to {server_json} for {tag}")
88+
PY

0 commit comments

Comments
 (0)