3
on:
4
workflow_dispatch:
5
inputs:
6
- version:
7
- description: "Override the version in server.json (e.g. 0.2.0); leave blank to publish as-is"
6
+ hosted_version:
7
+ description: "Override the version in the hosted listing (e.g. 1.2.6); leave blank to publish it as-is"
8
required: false
9
+ skip_cli:
10
+ description: "Publish only the hosted server, leaving the CLI listing alone"
11
+ type: boolean
12
+ default: false
13
+ skip_hosted:
14
+ description: "Publish only the CLI, leaving the hosted listing alone"
15
+ type: boolean
16
+ default: false
17
10
-# A remote server proves namespace ownership through domain (DNS) verification,
11
-# not GitHub OIDC: the registry checks a v=MCPv1 TXT record on sigit.si against
12
-# a request signed with the matching Ed25519 private key. See the MCP Registry
13
-# doc in the sigit-si repo for the one-time key/DNS setup.
18
+# This repo owns two MCP Registry entries, one file each:
19
+#
20
+# server.json si.sigit/cli this CLI, a cargo package run as `si mcp`
21
+# server-sigit.json si.sigit/sigit the hosted server at sigit.si/api/v1/mcp
22
+#
23
+# The CLI is what this repo actually ships, so it holds the conventional
24
+# filename that `mcp-publisher` defaults to.
25
+#
26
+# Both sit under one namespace, so one credential covers them. A domain
27
+# namespace proves ownership through DNS, not GitHub OIDC: the registry checks
28
+# a v=MCPv1 TXT record on sigit.si against a request signed with the matching
29
+# Ed25519 private key. See the MCP Registry doc in the sigit-si repo for the
30
+# one-time key/DNS setup.
31
permissions:
32
contents: read
33
34
jobs:
35
publish:
19
- name: Publish server.json to the MCP Registry
36
+ name: Publish the MCP Registry listings
37
runs-on: ubuntu-latest
38
steps:
39
- name: Checkout
40
uses: actions/checkout@v6
41
25
- - name: Optionally override the version in server.json
42
+ - name: Take the CLI listing's version from Cargo.toml
43
+ if: ${{ !inputs.skip_cli }}
44
shell: bash
45
run: |
28
- version="${{ github.event.inputs.version }}"
29
- if [ -n "${version}" ]; then
30
- jq --arg v "${version}" '.version = $v' server.json > server.tmp
31
- mv server.tmp server.json
46
+ # The workspace manifest is the single source of truth: the listing
47
+ # names a crates.io version, so a hand-edited number here could point
48
+ # at a release that doesn't exist.
49
+ crate_version="$(sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)"
50
+ if [ -z "${crate_version}" ]; then
51
+ echo "Could not read the workspace version from Cargo.toml." >&2
52
+ exit 1
53
fi
54
+ echo "CRATE_VERSION=${crate_version}" >> "$GITHUB_ENV"
55
+
56
+ jq --arg v "${crate_version}" \
57
+ '.version = $v | .packages |= map(.version = $v)' \
58
+ server.json > server.tmp
59
+ mv server.tmp server.json
60
cat server.json
61
35
- - name: Check the remote URL matches the namespace domain
62
+ - name: Verify the published crate carries the ownership marker
63
+ if: ${{ !inputs.skip_cli }}
64
+ shell: bash
65
+ run: |
66
+ # The registry stores metadata only, and validates a package listing
67
+ # by fetching the package and looking for a marker naming the server.
68
+ # Checking the working tree isn't enough: a version published before
69
+ # the marker landed will never carry it, and crates.io versions are
70
+ # immutable. The comparison is case-sensitive.
71
+ server_name="$(jq -r '.name' server.json)"
72
+ crate="$(jq -r '.packages[0].identifier' server.json)"
73
+
74
+ for attempt in $(seq 1 20); do
75
+ if curl -fsSL -H "User-Agent: getsigit/si release-workflow" \
76
+ "https://crates.io/api/v1/crates/${crate}/${CRATE_VERSION}" >/dev/null; then
77
+ echo "${crate} ${CRATE_VERSION} is indexed on crates.io."
78
+ break
79
+ fi
80
+ if [ "${attempt}" -eq 20 ]; then
81
+ echo "${crate} ${CRATE_VERSION} is still not on crates.io after 10 minutes." >&2
82
+ echo "Publish the crate first: the listing can only name a version that exists." >&2
83
+ exit 1
84
+ fi
85
+ echo "Waiting for crates.io to index ${crate} ${CRATE_VERSION} (attempt ${attempt}/20)..."
86
+ sleep 30
87
+ done
88
+
89
+ curl -fsSL -H "User-Agent: getsigit/si release-workflow" -o package.crate \
90
+ "https://crates.io/api/v1/crates/${crate}/${CRATE_VERSION}/download"
91
+ # `readme` in crates/cli/Cargo.toml points at the repo-root README,
92
+ # which cargo copies to the crate root when packaging.
93
+ if ! tar -xOzf package.crate "${crate}-${CRATE_VERSION}/README.md" | grep -q "mcp-name: ${server_name}"; then
94
+ echo "${crate} ${CRATE_VERSION} README is missing the 'mcp-name: ${server_name}' marker." >&2
95
+ echo "crates.io versions are immutable — cut a new release with the marker in place." >&2
96
+ exit 1
97
+ fi
98
+ rm package.crate
99
+
100
+ - name: Optionally override the version in the hosted listing
101
+ if: ${{ !inputs.skip_hosted }}
102
+ shell: bash
103
+ run: |
104
+ version="${{ inputs.hosted_version }}"
105
+ if [ -n "${version}" ]; then
106
+ jq --arg v "${version}" '.version = $v' server-sigit.json > server-sigit.tmp
107
+ mv server-sigit.tmp server-sigit.json
108
+ fi
109
+ cat server-sigit.json
110
+
111
+ - name: Check the hosted remote URL matches the namespace domain
112
+ if: ${{ !inputs.skip_hosted }}
113
shell: bash
114
run: |
115
# The registry rejects a remote server whose URL isn't under the
116
# namespace's domain. si.sigit -> sigit.si, so every listed remote
117
# must live on sigit.si (or a subdomain).
41
- server_name="$(jq -r '.name' server.json)"
118
+ server_name="$(jq -r '.name' server-sigit.json)"
119
domain="$(echo "${server_name%%/*}" | awk -F. '{ for (i=NF; i>=1; i--) printf "%s%s", $i, (i>1 ? "." : "") }')"
120
echo "Server: ${server_name} Domain: ${domain}"
121
123
.remotes // []
124
| map(.url | sub("^[a-z]+://"; "") | sub("/.*$"; ""))
125
| map(select(. != $d and (endswith("." + $d) | not)))
49
- | .[]' server.json)"
126
+ | .[]' server-sigit.json)"
127
if [ -n "${bad}" ]; then
128
echo "Remote URL host(s) not under ${domain}: ${bad}" >&2
129
exit 1
147
148
- name: Publish
149
shell: bash
73
- run: ./mcp-publisher publish
150
+ run: |
151
+ # Republishing a version that is already up is not worth failing the
152
+ # run over: the other listing may still need to go out.
153
+ publish_or_skip() {
154
+ local out
155
+ out="$(./mcp-publisher publish "$1" 2>&1)" && { echo "${out}"; return 0; }
156
+ echo "${out}"
157
+ if echo "${out}" | grep -q "duplicate version"; then
158
+ echo "$1 is already published at this version — skipping."
159
+ return 0
160
+ fi
161
+ return 1
162
+ }
163
75
- - name: Verify the server is listed
164
+ published=0
165
+ if [ "${{ inputs.skip_cli }}" != "true" ]; then
166
+ publish_or_skip server.json
167
+ published=1
168
+ fi
169
+ if [ "${{ inputs.skip_hosted }}" != "true" ]; then
170
+ publish_or_skip server-sigit.json
171
+ published=1
172
+ fi
173
+ if [ "${published}" -eq 0 ]; then
174
+ echo "Both listings were skipped, so there was nothing to publish." >&2
175
+ exit 1
176
+ fi
177
+
178
+ - name: Verify the servers are listed
179
shell: bash
180
run: |
78
- server_name="$(jq -r '.name' server.json)"
79
- curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${server_name}" | jq .
181
+ if [ "${{ inputs.skip_cli }}" != "true" ]; then
182
+ files="server.json"
183
+ fi
184
+ if [ "${{ inputs.skip_hosted }}" != "true" ]; then
185
+ files="${files} server-sigit.json"
186
+ fi
187
+
188
+ for file in ${files}; do
189
+ server_name="$(jq -r '.name' "${file}")"
190
+ echo "== ${server_name} (${file}) =="
191
+ curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${server_name}" | jq .
192
+ done