Skip to main content

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