anodizer_core/config/mcp.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::{StringOrBool, deserialize_string_or_bool_opt};
5
6// ---------------------------------------------------------------------------
7// MCP (Model Context Protocol) registry publisher config
8// ---------------------------------------------------------------------------
9//
10// Mirrors GoReleaser's `MCP` / `MCPDetails` / `MCPRepository` / `MCPAuth` /
11// `MCPPackage` / `MCPTransport` structs (`pkg/config/config.go:1561-1603`).
12//
13// Anodizer collapses GR's deprecated nested `mcp.github` migration shim — that
14// alias only existed for backwards compatibility with early GR previews and
15// has no consumers in this repo. The top-level fields are the canonical
16// surface from day one.
17
18/// MCP server registry publisher configuration.
19///
20/// Publishes an `apiv0.ServerJSON` document to the MCP registry
21/// (`https://registry.modelcontextprotocol.io/v0/publish` by default).
22/// Mirrors GoReleaser `config.MCP` + `config.MCPDetails` flattened.
23#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
24#[serde(default, deny_unknown_fields)]
25pub struct McpConfig {
26 /// Server name in reverse-DNS format (e.g. `io.github.user/weather`).
27 /// Must contain exactly one forward slash separating namespace from
28 /// server name. An empty / unset value skips the publisher entirely.
29 pub name: Option<String>,
30
31 /// Optional human-readable title shown in registry UIs (max 100 chars).
32 /// Templated; supports `{{ .ProjectName | title }}`, `{{ .Version }}`, etc.
33 pub title: Option<String>,
34
35 /// Clear human-readable description of server functionality (max 100 chars).
36 pub description: Option<String>,
37
38 /// Optional URL to the server's homepage, documentation, or project
39 /// website. Serialized as `websiteUrl` in the registry payload.
40 pub homepage: Option<String>,
41
42 /// Distribution packages — one entry per package registry (npm, pypi,
43 /// nuget, oci, mcpb).
44 pub packages: Vec<McpPackage>,
45
46 /// Top-level transports list. Intentional GoReleaser config-portability
47 /// shim: `McpConfig` carries `deny_unknown_fields`, so a migrated
48 /// `.goreleaser.yaml` containing `transports:` would fail to parse if
49 /// the field were absent. The list is accepted and discarded — the
50 /// current MCP server schema derives transports per-package via
51 /// `packages[].transport`, so the top-level list is never read after
52 /// deserialization and is intentionally not emitted to the registry.
53 pub transports: Vec<McpTransport>,
54
55 /// Skip this publisher when the expression evaluates truthy. Accepts a
56 /// bool or a Tera template that renders to `"true"`/`"false"` (e.g.
57 /// `"{{ if .IsSnapshot }}true{{ endif }}"`). Accepts the legacy
58 /// `disable:` spelling via serde alias for back-compat with imported
59 /// GoReleaser configs (GR's MCP config field is `pkg/config/config.go`
60 /// `MCP.Disable string`).
61 #[serde(
62 default,
63 alias = "disable",
64 deserialize_with = "deserialize_string_or_bool_opt"
65 )]
66 pub skip: Option<StringOrBool>,
67
68 /// Optional source repository metadata. Emitted as the `repository`
69 /// object in the registry payload — omitted entirely when `url` is empty.
70 pub repository: McpRepository,
71
72 /// Authentication method for the registry's `/v0/publish` endpoint.
73 /// Defaults to `none` (anonymous publish, allowed for development /
74 /// staging registries).
75 pub auth: McpAuth,
76
77 /// Override the registry endpoint (for staging or a private mirror).
78 /// Defaults to `https://registry.modelcontextprotocol.io` when unset.
79 pub registry: Option<String>,
80}
81
82/// Repository metadata for the MCP registry payload.
83/// Mirrors GoReleaser `config.MCPRepository` + upstream `model.Repository`.
84#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
85#[serde(default, deny_unknown_fields)]
86pub struct McpRepository {
87 /// Repository URL for browsing source code. Must support both web
88 /// browsing and git-clone operations. An empty value omits the entire
89 /// `repository` object from the published payload.
90 pub url: String,
91
92 /// Repository hosting service identifier. Used by registries to
93 /// determine validation and API access methods.
94 pub source: String,
95
96 /// Repository identifier from the hosting service (e.g. GitHub repo ID).
97 pub id: String,
98
99 /// Optional relative path from repository root to the server location
100 /// within a monorepo or nested package structure.
101 pub subfolder: String,
102}
103
104/// Authentication method + token for the MCP registry's `/v0/publish`
105/// endpoint. Mirrors GoReleaser `config.MCPAuth`.
106#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
107#[serde(deny_unknown_fields)]
108pub struct McpAuth {
109 /// Auth provider: `none` (anonymous), `github` (PAT exchange via
110 /// `/v0/auth/github-at`), or `github-oidc` (Actions OIDC token exchange
111 /// via `/v0/auth/github-oidc`). Templated.
112 #[serde(rename = "type", default)]
113 pub method: McpAuthMethod,
114
115 /// Static token for the `none` and `github` methods. Templated, so
116 /// `{{ envOrDefault "MCP_GITHUB_TOKEN" "" }}` works. Unused for
117 /// `github-oidc` (the OIDC token is fetched from GitHub Actions at
118 /// publish time).
119 #[serde(default, skip_serializing_if = "String::is_empty")]
120 pub token: String,
121}
122
123/// MCP auth method. Default is `None` (anonymous) which matches GoReleaser's
124/// `mcp.go::Default` migration code (`cmp.Or(..., proto.MethodNone)`).
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
126pub enum McpAuthMethod {
127 /// Anonymous publish — for testing or registries that allow it.
128 /// Serializes / deserializes as `none`.
129 #[default]
130 #[serde(rename = "none")]
131 None,
132 /// GitHub Personal Access Token exchange via `/v0/auth/github-at`.
133 /// Serializes / deserializes as `github`.
134 #[serde(rename = "github")]
135 Github,
136 /// GitHub Actions OIDC token exchange via `/v0/auth/github-oidc`.
137 /// Serializes / deserializes as `github-oidc`.
138 #[serde(rename = "github-oidc")]
139 GithubOidc,
140}
141
142impl McpAuthMethod {
143 /// Parse the auth method from its over-the-wire string form. Accepts the
144 /// three valid methods plus empty (treated as `None`, matching
145 /// GoReleaser's `mcp.go::Default` defaulting behaviour).
146 ///
147 /// Re-parsed from string AFTER template render so users can template
148 /// `auth.type: "{{ if eq .Env.MODE \"ci\" }}github-oidc{{ else }}none{{ end }}"`.
149 /// The render-emit-reparse round-trip is the cost of supporting templated
150 /// enum values; without it, the enum would be locked at config-load time
151 /// before tera context is available. Mirrors GoReleaser
152 /// `internal/pipe/mcp/mcp.go::Publish` lines 72-85 where every string field
153 /// (including `auth.type`, which Go represents as `string`) is passed
154 /// through `tmpl.New(ctx).ApplyAll(...)` before being consumed.
155 pub fn parse(s: &str) -> anyhow::Result<Self> {
156 match s.trim() {
157 "" | "none" => Ok(Self::None),
158 "github" => Ok(Self::Github),
159 "github-oidc" => Ok(Self::GithubOidc),
160 other => anyhow::bail!(
161 "mcp: unknown auth method '{}' (expected one of: none, github, github-oidc)",
162 other
163 ),
164 }
165 }
166
167 /// Wire-format string for serialization + log output.
168 pub fn as_str(&self) -> &'static str {
169 match self {
170 Self::None => "none",
171 Self::Github => "github",
172 Self::GithubOidc => "github-oidc",
173 }
174 }
175}
176
177/// A single package distribution descriptor.
178#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
179#[serde(default, deny_unknown_fields)]
180pub struct McpPackage {
181 /// Registry type indicating how to download packages
182 /// (e.g. `oci`, `npm`, `pypi`, `nuget`, `mcpb`).
183 pub registry_type: McpRegistryType,
184
185 /// Package identifier. For npm/pypi/nuget: the package name; for OCI:
186 /// the full image reference (e.g. `ghcr.io/owner/repo:v1.0.0`); for
187 /// mcpb: the download URL. Templated.
188 pub identifier: String,
189
190 /// Transport protocol configuration for this package.
191 pub transport: McpTransport,
192}
193
194/// Package registry type — mirrors GoReleaser's `MCPPackage.RegistryType`
195/// enum and upstream `model.RegistryType*` constants.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
197pub enum McpRegistryType {
198 /// OCI image (registry_type = "oci"). The `version` field in the
199 /// published ServerJSON is intentionally empty for OCI packages — the
200 /// version is encoded in the image identifier's `:tag` suffix.
201 #[serde(rename = "oci")]
202 Oci,
203 /// npm registry (registry_type = "npm").
204 #[default]
205 #[serde(rename = "npm")]
206 Npm,
207 /// PyPI registry (registry_type = "pypi").
208 #[serde(rename = "pypi")]
209 Pypi,
210 /// NuGet registry (registry_type = "nuget").
211 #[serde(rename = "nuget")]
212 Nuget,
213 /// MCPB direct-download (registry_type = "mcpb").
214 #[serde(rename = "mcpb")]
215 Mcpb,
216}
217
218impl McpRegistryType {
219 /// Wire-format string for serialization.
220 pub fn as_str(&self) -> &'static str {
221 match self {
222 Self::Oci => "oci",
223 Self::Npm => "npm",
224 Self::Pypi => "pypi",
225 Self::Nuget => "nuget",
226 Self::Mcpb => "mcpb",
227 }
228 }
229}
230
231/// Transport descriptor — mirrors GoReleaser's `MCPTransport` and
232/// upstream `model.Transport`.
233#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
234#[serde(deny_unknown_fields)]
235pub struct McpTransport {
236 /// Transport type: `stdio`, `streamable-http`, or `sse`.
237 #[serde(rename = "type", default)]
238 pub kind: McpTransportType,
239}
240
241/// Transport protocol — mirrors upstream `model.TransportType*` constants.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
243pub enum McpTransportType {
244 /// Local stdio transport.
245 #[default]
246 #[serde(rename = "stdio")]
247 Stdio,
248 /// Streamable HTTP remote transport.
249 #[serde(rename = "streamable-http")]
250 StreamableHttp,
251 /// Server-Sent Events remote transport.
252 #[serde(rename = "sse")]
253 Sse,
254}
255
256impl McpTransportType {
257 /// Wire-format string for serialization.
258 pub fn as_str(&self) -> &'static str {
259 match self {
260 Self::Stdio => "stdio",
261 Self::StreamableHttp => "streamable-http",
262 Self::Sse => "sse",
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn auth_method_default_is_none() {
273 assert_eq!(McpAuthMethod::default(), McpAuthMethod::None);
274 let auth = McpAuth::default();
275 assert_eq!(auth.method, McpAuthMethod::None);
276 }
277
278 #[test]
279 fn auth_method_parse_accepts_empty_as_none() {
280 assert_eq!(McpAuthMethod::parse("").unwrap(), McpAuthMethod::None);
281 assert_eq!(McpAuthMethod::parse("none").unwrap(), McpAuthMethod::None);
282 assert_eq!(
283 McpAuthMethod::parse("github").unwrap(),
284 McpAuthMethod::Github
285 );
286 assert_eq!(
287 McpAuthMethod::parse("github-oidc").unwrap(),
288 McpAuthMethod::GithubOidc
289 );
290 }
291
292 #[test]
293 fn auth_method_parse_rejects_unknown() {
294 let err = McpAuthMethod::parse("oauth").unwrap_err();
295 assert!(err.to_string().contains("unknown auth method"));
296 }
297
298 #[test]
299 fn yaml_roundtrip_minimal() {
300 let yaml = r#"
301name: io.github.test/server
302title: Test
303description: A test server
304packages:
305 - registry_type: oci
306 identifier: ghcr.io/test/server:v1.0.0
307 transport:
308 type: stdio
309auth:
310 type: github-oidc
311"#;
312 let cfg: McpConfig = serde_yaml_ng::from_str(yaml).expect("parse mcp yaml");
313 assert_eq!(cfg.name.as_deref(), Some("io.github.test/server"));
314 assert_eq!(cfg.packages.len(), 1);
315 assert_eq!(cfg.packages[0].registry_type, McpRegistryType::Oci);
316 assert_eq!(cfg.packages[0].transport.kind, McpTransportType::Stdio);
317 assert_eq!(cfg.auth.method, McpAuthMethod::GithubOidc);
318 }
319
320 #[test]
321 fn yaml_roundtrip_skip_template() {
322 let yaml = r#"
323name: io.github.test/server
324title: Test
325description: A test server
326skip: "{{ if .IsSnapshot }}true{{ endif }}"
327"#;
328 let cfg: McpConfig = serde_yaml_ng::from_str(yaml).expect("parse mcp yaml");
329 assert!(cfg.skip.is_some());
330 let s = cfg.skip.as_ref().unwrap();
331 match s {
332 StringOrBool::String(v) => assert!(v.contains("IsSnapshot")),
333 _ => panic!("expected String variant"),
334 }
335 }
336
337 #[test]
338 fn yaml_roundtrip_disable_alias_for_back_compat() {
339 // Legacy GR-imported configs use `disable:`; the alias should keep
340 // parsing them as the canonical `skip:` field.
341 let yaml = r#"
342name: io.github.test/server
343disable: "{{ if .IsSnapshot }}true{{ endif }}"
344"#;
345 let cfg: McpConfig = serde_yaml_ng::from_str(yaml).expect("parse mcp yaml");
346 assert!(cfg.skip.is_some(), "disable: alias must populate skip");
347 }
348
349 #[test]
350 fn auth_token_optional_and_omitted_when_empty() {
351 // Tokens default to empty and stay out of the serialized form.
352 let auth = McpAuth::default();
353 let s = serde_yaml_ng::to_string(&auth).expect("serialize");
354 assert!(s.contains("type: none"), "type field always rendered");
355 assert!(!s.contains("token:"), "empty token omitted from yaml");
356 }
357}