1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//! `server.json`'s `version` must equal the root crate's version.
//!
//! # The defect this fences
//!
//! `release.yml`'s `publish-mcp` job runs `./mcp-publisher publish`, which reads
//! `server.json` from the checkout and publishes THAT version to the MCP
//! Registry. Nothing bumped it, so it sat at `1.10.3` while `pmcp` moved to
//! `2.20.3`, and every tag since re-published an already-published version:
//!
//! ```text
//! Logging in with github-oidc... ✓ Successfully logged in
//! Error: publish failed: server returned status 400:
//! {"detail":"Failed to publish server",
//! "errors":[{"message":"invalid version: cannot publish duplicate version"}]}
//! ```
//!
//! Measured on tag `v2.20.3` (run 35456958817, job 105935595972). Note the
//! OIDC login SUCCEEDS — the failure is the stale version, and repo notes that
//! blamed OIDC were wrong about it for several releases.
//!
//! # Why a test, and not a release-time check
//!
//! The two files were in lockstep once (`dcfc9dc1 chore: bump versions for
//! v1.10.3 release` set both to `1.10.3`, and `773cd834 fix: ... sync version`
//! repaired an earlier drift), but nothing ENFORCED it, so the third drift went
//! unnoticed through five releases. `server.json` had zero references anywhere
//! in the repo — no test, no script, no Makefile leg.
//!
//! A release-time check would be too late: the tag is already pushed and the
//! crates are already published by the time `publish-mcp` runs, so the only
//! remedy left is a follow-up tag. Failing `cargo test` instead makes the bump
//! a precondition of the version change that requires it.
//!
//! This is the same defect class as `PMCP_VERSION` in
//! `cargo-pmcp/src/templates/workbook_server.rs` — a hand-maintained version
//! emitter — and it is fenced the same way: parse both sides, compare exactly,
//! and name the fix in the failure message.
//!
//! # Oracle
//!
//! `specified`, not implicit. The registry rejects a duplicate version, and the
//! established convention in this repo is that `server.json` carries the `pmcp`
//! crate version verbatim (`dcfc9dc1`: both `1.10.3`). Equality is therefore the
//! contract, not an approximation of one.
//!
//! # NOT feature-gated, on purpose
//!
//! No `#![cfg]` header: this must be reached by `make test-integration`
//! (`cargo test --test '*' --features full`). A guard that only runs under a
//! feature nothing enables is the bug it exists to prevent, one level up.
use std::fs;
/// The workspace-root manifest, whose `[package] version` is the source of truth.
const MANIFEST: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
/// The MCP Registry descriptor that `mcp-publisher publish` reads.
const SERVER_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/server.json");
/// The root crate version, PARSED rather than pattern-matched.
///
/// Panics naming the file on a read or parse failure: a manifest this test
/// cannot read must be a loud failure, not a skipped comparison.
fn crate_version() -> String {
let text =
fs::read_to_string(MANIFEST).unwrap_or_else(|e| panic!("cannot read {MANIFEST}: {e}"));
let parsed: toml::Value =
toml::from_str(&text).unwrap_or_else(|e| panic!("cannot parse {MANIFEST} as TOML: {e}"));
parsed
.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.unwrap_or_else(|| panic!("{MANIFEST} has no [package] version"))
.to_string()
}
/// The `server.json` document, parsed as JSON for the same reason.
fn server_json() -> serde_json::Value {
let text = fs::read_to_string(SERVER_JSON)
.unwrap_or_else(|e| panic!("cannot read {SERVER_JSON}: {e}"));
serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("cannot parse {SERVER_JSON} as JSON: {e}"))
}
#[test]
fn server_json_version_matches_the_crate_version() {
let expected = crate_version();
let doc = server_json();
let actual = doc
.get("version")
.and_then(|v| v.as_str())
.unwrap_or_else(|| panic!("{SERVER_JSON} has no string `version` field"));
// Non-vacuity: an empty string on either side would satisfy a careless
// comparison while proving nothing about either file.
assert!(
!expected.is_empty() && !actual.is_empty(),
"empty version string — crate `{expected}`, server.json `{actual}`; this guard is \
not comparing anything"
);
assert_eq!(
actual, expected,
"server.json declares version `{actual}` but the pmcp crate is `{expected}`.\n\
\n\
`release.yml`'s publish-mcp job publishes server.json's version to the MCP \
Registry, which REFUSES a version it already has:\n\
\x20 400 invalid version: cannot publish duplicate version\n\
\n\
So a stale server.json makes every release fail that job — silently, because the \
crates.io publish succeeds independently and the run's only red job is this one.\n\
\n\
FIX: set \"version\" in server.json to `{expected}`, in the SAME commit as the \
crate bump."
);
}
#[test]
fn server_json_still_declares_the_registry_identity_the_publisher_expects() {
// The version pin above is worthless if the document it lives in stops being
// the one `mcp-publisher` publishes. `name` is the registry's primary key, so
// a change here is a different server, not a new version of this one — which
// would make the duplicate-version error above impossible for a reason that
// has nothing to do with the pin.
let doc = server_json();
let name = doc
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_else(|| panic!("{SERVER_JSON} has no string `name` field"));
assert_eq!(
name, "io.github.paiml/pmcp",
"server.json `name` changed to `{name}`. That renames the registry entry rather \
than versioning it. If the rename is deliberate, update this test in the same \
commit and expect the first publish under the new name to create a fresh entry."
);
assert!(
doc.get("$schema").and_then(|v| v.as_str()).is_some(),
"server.json lost its `$schema` — mcp-publisher validates against it, and a \
document that fails validation fails the publish for a reason the version pin \
cannot describe."
);
}