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// MCP publisher config: server details, repository, auth, packages, and
11// transport.
12//
13// The deprecated nested `mcp.github` migration shim is not carried — that
14// alias only existed for backwards compatibility with early 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/// MCP config (server details flattened onto the publisher block).
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 config-portability
47 /// shim: `McpConfig` carries `deny_unknown_fields`, so a migrated
48 /// an imported config 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 /// imported configs (the MCP config field
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 /// Override whether this publisher failing should fail the overall release.
81 ///
82 /// Default: `false` — a failure here is logged but does not abort the release.
83 /// Set to `true` to fail the release on any error.
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub required: Option<bool>,
86 /// Template-conditional gate: when the rendered result is falsy
87 /// (`"false"` / `"0"` / `"no"` / empty), the MCP publisher is skipped.
88 /// Render failure hard-errors. The `mcp.if:` conditional gate.
89 #[serde(rename = "if")]
90 pub if_condition: Option<String>,
91}
92
93/// Repository metadata for the MCP registry payload.
94/// Source-repository metadata for the MCP server.
95#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
96#[serde(default, deny_unknown_fields)]
97pub struct McpRepository {
98 /// Repository URL for browsing source code. Must support both web
99 /// browsing and git-clone operations. An empty value omits the entire
100 /// `repository` object from the published payload.
101 pub url: String,
102
103 /// Repository hosting service identifier. Used by registries to
104 /// determine validation and API access methods.
105 pub source: String,
106
107 /// Repository identifier from the hosting service (e.g. GitHub repo ID).
108 pub id: String,
109
110 /// Optional relative path from repository root to the server location
111 /// within a monorepo or nested package structure.
112 pub subfolder: String,
113}
114
115/// Authentication method + token for the MCP registry's `/v0/publish`
116/// endpoint.
117#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
118#[serde(deny_unknown_fields)]
119pub struct McpAuth {
120 /// Auth provider: `none` (anonymous), `github` (PAT exchange via
121 /// `/v0/auth/github-at`), or `github-oidc` (Actions OIDC token exchange
122 /// via `/v0/auth/github-oidc`). Templated.
123 #[serde(rename = "type", default)]
124 pub method: McpAuthMethod,
125
126 /// Static token for the `none` and `github` methods. Templated, so
127 /// `{{ envOrDefault "MCP_GITHUB_TOKEN" "" }}` works. Unused for
128 /// `github-oidc` (the OIDC token is fetched from GitHub Actions at
129 /// publish time).
130 #[serde(default, skip_serializing_if = "String::is_empty")]
131 pub token: String,
132}
133
134/// MCP auth method. Default is `None` (anonymous).
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
136pub enum McpAuthMethod {
137 /// Anonymous publish — for testing or registries that allow it.
138 /// Serializes / deserializes as `none`.
139 #[default]
140 #[serde(rename = "none")]
141 None,
142 /// GitHub Personal Access Token exchange via `/v0/auth/github-at`.
143 /// Serializes / deserializes as `github`.
144 #[serde(rename = "github")]
145 Github,
146 /// GitHub Actions OIDC token exchange via `/v0/auth/github-oidc`.
147 /// Serializes / deserializes as `github-oidc`.
148 #[serde(rename = "github-oidc")]
149 GithubOidc,
150}
151
152impl McpAuthMethod {
153 /// Parse the auth method from its over-the-wire string form. Accepts the
154 /// three valid methods plus empty (treated as `None`, matching
155 /// the anonymous default).
156 ///
157 /// Re-parsed from string AFTER template render so users can template
158 /// `auth.type: "{{ if eq .Env.MODE \"ci\" }}github-oidc{{ else }}none{{ end }}"`.
159 /// The render-emit-reparse round-trip is the cost of supporting templated
160 /// enum values; without it, the enum would be locked at config-load time
161 /// before tera context is available. Every string field
162 /// (including `auth.type`, which Go represents as `string`) is passed
163 /// through `tmpl.New(ctx).ApplyAll(...)` before being consumed.
164 pub fn parse(s: &str) -> anyhow::Result<Self> {
165 match s.trim() {
166 "" | "none" => Ok(Self::None),
167 "github" => Ok(Self::Github),
168 "github-oidc" => Ok(Self::GithubOidc),
169 other => anyhow::bail!(
170 "mcp: unknown auth method '{}' (expected one of: none, github, github-oidc)",
171 other
172 ),
173 }
174 }
175
176 /// Wire-format string for serialization + log output.
177 pub fn as_str(&self) -> &'static str {
178 match self {
179 Self::None => "none",
180 Self::Github => "github",
181 Self::GithubOidc => "github-oidc",
182 }
183 }
184}
185
186/// A single package distribution descriptor.
187#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
188#[serde(default, deny_unknown_fields)]
189pub struct McpPackage {
190 /// Registry type indicating how to download packages
191 /// (e.g. `oci`, `npm`, `pypi`, `nuget`, `mcpb`).
192 pub registry_type: McpRegistryType,
193
194 /// Package identifier. For npm/pypi/nuget: the package name; for OCI:
195 /// the full image reference (e.g. `ghcr.io/owner/repo:v1.0.0`); for
196 /// mcpb: the download URL. Templated.
197 pub identifier: String,
198
199 /// Transport protocol configuration for this package.
200 pub transport: McpTransport,
201}
202
203/// Package registry type
204/// enum and upstream `model.RegistryType*` constants.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
206pub enum McpRegistryType {
207 /// OCI image (registry_type = "oci"). The `version` field in the
208 /// published ServerJSON is intentionally empty for OCI packages — the
209 /// version is encoded in the image identifier's `:tag` suffix.
210 #[serde(rename = "oci")]
211 Oci,
212 /// npm registry (registry_type = "npm").
213 #[default]
214 #[serde(rename = "npm")]
215 Npm,
216 /// PyPI registry (registry_type = "pypi").
217 #[serde(rename = "pypi")]
218 Pypi,
219 /// NuGet registry (registry_type = "nuget").
220 #[serde(rename = "nuget")]
221 Nuget,
222 /// MCPB direct-download (registry_type = "mcpb").
223 #[serde(rename = "mcpb")]
224 Mcpb,
225}
226
227impl McpRegistryType {
228 /// Wire-format string for serialization.
229 pub fn as_str(&self) -> &'static str {
230 match self {
231 Self::Oci => "oci",
232 Self::Npm => "npm",
233 Self::Pypi => "pypi",
234 Self::Nuget => "nuget",
235 Self::Mcpb => "mcpb",
236 }
237 }
238}
239
240/// Transport descriptor for
241/// upstream `model.Transport`.
242#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
243#[serde(deny_unknown_fields)]
244pub struct McpTransport {
245 /// Transport type: `stdio`, `streamable-http`, or `sse`.
246 #[serde(rename = "type", default)]
247 pub kind: McpTransportType,
248
249 /// Endpoint URL for the remote transports (`streamable-http`, `sse`).
250 /// Required by the registry for those types and forbidden for `stdio`,
251 /// so it stays an optional plain string: leave it empty for `stdio`,
252 /// and set it for a remote transport. Templated, so
253 /// `url: "https://{{ Env.MCP_HOST }}/v1"` resolves at publish time.
254 #[serde(default, skip_serializing_if = "String::is_empty")]
255 pub url: String,
256
257 /// HTTP headers attached to a remote transport's requests. Each entry is
258 /// a `{ name, value }` pair; the `value` is templated, so a header can
259 /// carry a secret such as `value: "Bearer {{ Env.MCP_TOKEN }}"`. Omitted
260 /// entirely for `stdio` and for remote transports with no headers.
261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
262 pub headers: Vec<McpHeader>,
263}
264
265/// A single HTTP header attached to a remote MCP transport — mirrors the
266/// registry schema's `KeyValueInput` (`{ name, value }`).
267#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
268#[serde(deny_unknown_fields)]
269pub struct McpHeader {
270 /// Header name (e.g. `Authorization`).
271 pub name: String,
272
273 /// Header value. Templated, so it can reference an environment variable —
274 /// `value: "Bearer {{ Env.MCP_TOKEN }}"`.
275 #[serde(default, skip_serializing_if = "String::is_empty")]
276 pub value: String,
277}
278
279/// Transport protocol — mirrors upstream `model.TransportType*` constants.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
281pub enum McpTransportType {
282 /// Local stdio transport.
283 #[default]
284 #[serde(rename = "stdio")]
285 Stdio,
286 /// Streamable HTTP remote transport.
287 #[serde(rename = "streamable-http")]
288 StreamableHttp,
289 /// Server-Sent Events remote transport.
290 #[serde(rename = "sse")]
291 Sse,
292}
293
294impl McpTransportType {
295 /// Wire-format string for serialization.
296 pub fn as_str(&self) -> &'static str {
297 match self {
298 Self::Stdio => "stdio",
299 Self::StreamableHttp => "streamable-http",
300 Self::Sse => "sse",
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn auth_method_default_is_none() {
311 assert_eq!(McpAuthMethod::default(), McpAuthMethod::None);
312 let auth = McpAuth::default();
313 assert_eq!(auth.method, McpAuthMethod::None);
314 }
315
316 #[test]
317 fn auth_method_parse_accepts_empty_as_none() {
318 assert_eq!(McpAuthMethod::parse("").unwrap(), McpAuthMethod::None);
319 assert_eq!(McpAuthMethod::parse("none").unwrap(), McpAuthMethod::None);
320 assert_eq!(
321 McpAuthMethod::parse("github").unwrap(),
322 McpAuthMethod::Github
323 );
324 assert_eq!(
325 McpAuthMethod::parse("github-oidc").unwrap(),
326 McpAuthMethod::GithubOidc
327 );
328 }
329
330 #[test]
331 fn auth_method_parse_rejects_unknown() {
332 let err = McpAuthMethod::parse("oauth").unwrap_err();
333 assert!(err.to_string().contains("unknown auth method"));
334 }
335
336 #[test]
337 fn yaml_roundtrip_minimal() {
338 let yaml = r#"
339name: io.github.test/server
340title: Test
341description: A test server
342packages:
343 - registry_type: oci
344 identifier: ghcr.io/test/server:v1.0.0
345 transport:
346 type: stdio
347auth:
348 type: github-oidc
349"#;
350 let cfg: McpConfig = serde_yaml_ng::from_str(yaml).expect("parse mcp yaml");
351 assert_eq!(cfg.name.as_deref(), Some("io.github.test/server"));
352 assert_eq!(cfg.packages.len(), 1);
353 assert_eq!(cfg.packages[0].registry_type, McpRegistryType::Oci);
354 assert_eq!(cfg.packages[0].transport.kind, McpTransportType::Stdio);
355 assert_eq!(cfg.auth.method, McpAuthMethod::GithubOidc);
356 }
357
358 #[test]
359 fn yaml_roundtrip_skip_template() {
360 let yaml = r#"
361name: io.github.test/server
362title: Test
363description: A test server
364skip: "{{ if .IsSnapshot }}true{{ endif }}"
365"#;
366 let cfg: McpConfig = serde_yaml_ng::from_str(yaml).expect("parse mcp yaml");
367 assert!(cfg.skip.is_some());
368 let s = cfg.skip.as_ref().unwrap();
369 match s {
370 StringOrBool::String(v) => assert!(v.contains("IsSnapshot")),
371 _ => panic!("expected String variant"),
372 }
373 }
374
375 #[test]
376 fn yaml_roundtrip_disable_alias_for_back_compat() {
377 // Legacy imported configs use `disable:`; the alias should keep
378 // parsing them as the canonical `skip:` field.
379 let yaml = r#"
380name: io.github.test/server
381disable: "{{ if .IsSnapshot }}true{{ endif }}"
382"#;
383 let cfg: McpConfig = serde_yaml_ng::from_str(yaml).expect("parse mcp yaml");
384 assert!(cfg.skip.is_some(), "disable: alias must populate skip");
385 }
386
387 #[test]
388 fn transport_defaults_to_stdio_with_no_url_or_headers() {
389 // A bare `type: stdio` transport must parse without url/headers and
390 // leave both empty (the registry forbids url on stdio).
391 let t: McpTransport = serde_yaml_ng::from_str("type: stdio").expect("parse transport");
392 assert_eq!(t.kind, McpTransportType::Stdio);
393 assert!(t.url.is_empty());
394 assert!(t.headers.is_empty());
395 }
396
397 #[test]
398 fn remote_transport_parses_url_and_headers() {
399 let yaml = r#"
400type: streamable-http
401url: "https://{{ .Env.MCP_HOST }}/v1"
402headers:
403 - name: Authorization
404 value: "Bearer {{ .Env.MCP_TOKEN }}"
405"#;
406 let t: McpTransport = serde_yaml_ng::from_str(yaml).expect("parse transport");
407 assert_eq!(t.kind, McpTransportType::StreamableHttp);
408 assert_eq!(t.url, "https://{{ .Env.MCP_HOST }}/v1");
409 assert_eq!(t.headers.len(), 1);
410 assert_eq!(t.headers[0].name, "Authorization");
411 assert_eq!(t.headers[0].value, "Bearer {{ .Env.MCP_TOKEN }}");
412 }
413
414 #[test]
415 fn auth_token_optional_and_omitted_when_empty() {
416 // Tokens default to empty and stay out of the serialized form.
417 let auth = McpAuth::default();
418 let s = serde_yaml_ng::to_string(&auth).expect("serialize");
419 assert!(s.contains("type: none"), "type field always rendered");
420 assert!(!s.contains("token:"), "empty token omitted from yaml");
421 }
422}