Skip to main content

mcp_execution_core/
metadata.rs

1//! Structured sidecar metadata describing a server's generated tools.
2//!
3//! `mcp-execution-codegen` emits a `_meta.json` file alongside the generated
4//! TypeScript tool files for each server. `mcp-execution-skill` (and
5//! `mcp-execution-server`) read that file back to build `SKILL.md` and
6//! runtime tool listings, instead of re-parsing the generated `.ts` source.
7//!
8//! This module is the shared wire contract between the two sides: the
9//! producer (codegen) and the consumer (skill/server) both depend on
10//! `mcp-execution-core`, so the schema lives here rather than in either
11//! crate directly.
12//!
13//! # Examples
14//!
15//! ```
16//! use mcp_execution_core::metadata::{ServerMetadata, ToolMetadata, METADATA_SCHEMA_VERSION};
17//! use mcp_execution_core::provenance::GenerationProvenance;
18//! use mcp_execution_core::{ServerConfig, ServerId, ToolName};
19//!
20//! let config = ServerConfig::builder().command("docker".to_string()).build().unwrap();
21//!
22//! let meta = ServerMetadata {
23//!     schema_version: METADATA_SCHEMA_VERSION,
24//!     server_id: ServerId::new("github").unwrap(),
25//!     server_name: "GitHub".to_string(),
26//!     server_version: "1.0.0".to_string(),
27//!     tools: vec![ToolMetadata {
28//!         name: ToolName::new("create_issue").unwrap(),
29//!         typescript_name: "createIssue".to_string(),
30//!         category: Some("issues".to_string()),
31//!         keywords: vec!["create".to_string(), "issue".to_string()],
32//!         description: Some("Creates a new issue".to_string()),
33//!         parameters: vec![],
34//!     }],
35//!     provenance: GenerationProvenance::capture(&config, &[]),
36//! };
37//!
38//! let json = serde_json::to_string_pretty(&meta).unwrap();
39//! let round_tripped: ServerMetadata = serde_json::from_str(&json).unwrap();
40//! assert_eq!(round_tripped, meta);
41//! ```
42
43use crate::provenance::GenerationProvenance;
44use crate::{ServerId, ToolName};
45use serde::{Deserialize, Serialize};
46
47/// Current schema version of the `_meta.json` sidecar format.
48///
49/// Bump this when making a breaking change to [`ServerMetadata`] or its
50/// nested types, so that a consumer built against an older schema fails
51/// loudly (via a schema-version mismatch check) instead of silently
52/// misinterpreting the new shape.
53///
54/// Bumped from `1` to `2` when [`ServerMetadata::provenance`] was added: a `schema_version: 1`
55/// sidecar has no `provenance` key at all, so a consumer must check this value *before*
56/// attempting a typed deserialization (see `mcp-execution-skill`'s parser).
57///
58/// # Examples
59///
60/// ```
61/// use mcp_execution_core::metadata::METADATA_SCHEMA_VERSION;
62///
63/// assert_eq!(METADATA_SCHEMA_VERSION, 2);
64/// ```
65pub const METADATA_SCHEMA_VERSION: u32 = 2;
66
67/// Filename of the sidecar metadata file emitted alongside generated tool files.
68///
69/// Shared between the producer (`mcp-execution-codegen`) and the consumer
70/// (`mcp-execution-skill`) to avoid a stringly-typed filename duplicated in
71/// two crates.
72///
73/// # Examples
74///
75/// ```
76/// use mcp_execution_core::metadata::METADATA_FILE_NAME;
77///
78/// assert_eq!(METADATA_FILE_NAME, "_meta.json");
79/// ```
80pub const METADATA_FILE_NAME: &str = "_meta.json";
81
82/// Filename of the generated re-export entry point emitted alongside per-tool files.
83///
84/// Shared between the producer (`mcp-execution-codegen`, which renders it) and its consumers
85/// (`mcp-execution-skill` and `mcp-execution-server`, which must recognize it as the package's
86/// aggregator file rather than a per-tool file) to avoid a stringly-typed filename duplicated
87/// across crates.
88///
89/// # Examples
90///
91/// ```
92/// use mcp_execution_core::metadata::INDEX_FILE_NAME;
93///
94/// assert_eq!(INDEX_FILE_NAME, "index.ts");
95/// ```
96pub const INDEX_FILE_NAME: &str = "index.ts";
97
98/// Structured sidecar describing one server's generated tools.
99///
100/// Serialized as `_meta.json` by `mcp-execution-codegen` and deserialized by
101/// `mcp-execution-skill` / `mcp-execution-server`, replacing a fragile
102/// regex-based re-parse of the generated TypeScript files.
103///
104/// # Examples
105///
106/// ```
107/// use mcp_execution_core::metadata::{ServerMetadata, METADATA_SCHEMA_VERSION};
108/// use mcp_execution_core::provenance::GenerationProvenance;
109/// use mcp_execution_core::{ServerConfig, ServerId};
110///
111/// let config = ServerConfig::builder().command("docker".to_string()).build().unwrap();
112///
113/// let meta = ServerMetadata {
114///     schema_version: METADATA_SCHEMA_VERSION,
115///     server_id: ServerId::new("github").unwrap(),
116///     server_name: "GitHub".to_string(),
117///     server_version: "1.0.0".to_string(),
118///     tools: vec![],
119///     provenance: GenerationProvenance::capture(&config, &[]),
120/// };
121///
122/// assert_eq!(meta.tools.len(), 0);
123/// ```
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct ServerMetadata {
126    /// Schema version this sidecar was produced with.
127    ///
128    /// Consumers should compare this against [`METADATA_SCHEMA_VERSION`] and
129    /// fail loudly on a mismatch rather than risk misinterpreting an
130    /// incompatible future shape.
131    pub schema_version: u32,
132
133    /// MCP server identifier (e.g. `github`).
134    ///
135    /// [`ServerId`]'s derived `Serialize`/`Deserialize` round-trip through a plain JSON string
136    /// (single-field newtype structs serialize transparently), so this field's on-the-wire
137    /// shape is unchanged from when it was a bare `String`.
138    pub server_id: ServerId,
139
140    /// Human-readable server name.
141    pub server_name: String,
142
143    /// Server version string, as reported by the MCP server.
144    pub server_version: String,
145
146    /// Metadata for every generated tool, in generation order.
147    pub tools: Vec<ToolMetadata>,
148
149    /// When and against what server state this sidecar was generated.
150    ///
151    /// Required rather than `Option`: a `schema_version: 1` sidecar (produced before this
152    /// field existed) is rejected by the schema-version check before a consumer ever
153    /// constructs a `ServerMetadata`, so every value that exists already carries real
154    /// provenance — see `mcp-execution-skill`'s parser and
155    /// [`crate::provenance::GenerationProvenance`]'s own doc comment.
156    pub provenance: GenerationProvenance,
157}
158
159/// Structured metadata for a single generated tool.
160///
161/// # Examples
162///
163/// ```
164/// use mcp_execution_core::metadata::ToolMetadata;
165/// use mcp_execution_core::ToolName;
166///
167/// let tool = ToolMetadata {
168///     name: ToolName::new("create_issue").unwrap(),
169///     typescript_name: "createIssue".to_string(),
170///     category: Some("issues".to_string()),
171///     keywords: vec!["create".to_string(), "issue".to_string()],
172///     description: Some("Creates a new issue".to_string()),
173///     parameters: vec![],
174/// };
175///
176/// assert_eq!(tool.name.as_str(), "create_issue");
177/// ```
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179pub struct ToolMetadata {
180    /// Original MCP tool name (the call identifier), unmodified.
181    ///
182    /// [`ToolName`]'s derived `Serialize`/`Deserialize` round-trip through a plain JSON string
183    /// (see [`ServerMetadata::server_id`]'s doc comment), so this field's on-the-wire shape is
184    /// unchanged from when it was a bare `String`.
185    pub name: ToolName,
186
187    /// TypeScript-friendly name (camelCase), matching the generated file's
188    /// basename (e.g. `createIssue` for `createIssue.ts`).
189    pub typescript_name: String,
190
191    /// Optional category for tool grouping.
192    pub category: Option<String>,
193
194    /// Keywords for discovery, split from the source comma-separated string.
195    pub keywords: Vec<String>,
196
197    /// Human-readable tool description, as reported by the MCP server.
198    pub description: Option<String>,
199
200    /// Metadata for each of the tool's input parameters.
201    pub parameters: Vec<ParameterMetadata>,
202}
203
204/// Structured metadata for a single tool parameter.
205///
206/// # Examples
207///
208/// ```
209/// use mcp_execution_core::metadata::ParameterMetadata;
210///
211/// let param = ParameterMetadata {
212///     name: "title".to_string(),
213///     typescript_type: "string".to_string(),
214///     required: true,
215///     description: Some("Issue title".to_string()),
216/// };
217///
218/// assert!(param.required);
219/// ```
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct ParameterMetadata {
222    /// Parameter name.
223    pub name: String,
224
225    /// TypeScript type (e.g. `string`, `number`, `boolean`).
226    pub typescript_type: String,
227
228    /// Whether the parameter is required.
229    pub required: bool,
230
231    /// Parameter description, sourced from the tool's input JSON Schema.
232    pub description: Option<String>,
233}
234
235#[cfg(test)]
236mod tests {
237    use super::{METADATA_SCHEMA_VERSION, ParameterMetadata, ServerMetadata, ToolMetadata};
238    use crate::provenance::GenerationProvenance;
239    use crate::{ServerConfig, ServerId, ToolName};
240
241    fn test_provenance() -> GenerationProvenance {
242        let config = ServerConfig::builder()
243            .command("docker".to_string())
244            .build()
245            .unwrap();
246        GenerationProvenance::capture(&config, &[])
247    }
248
249    #[test]
250    fn round_trips_through_json() {
251        let meta = ServerMetadata {
252            schema_version: METADATA_SCHEMA_VERSION,
253            server_id: ServerId::new("github").unwrap(),
254            server_name: "GitHub".to_string(),
255            server_version: "1.0.0".to_string(),
256            tools: vec![ToolMetadata {
257                name: ToolName::new("create_issue").unwrap(),
258                typescript_name: "createIssue".to_string(),
259                category: Some("issues".to_string()),
260                keywords: vec!["create".to_string(), "issue".to_string()],
261                description: Some("Creates a new issue".to_string()),
262                parameters: vec![ParameterMetadata {
263                    name: "title".to_string(),
264                    typescript_type: "string".to_string(),
265                    required: true,
266                    description: Some("Issue title".to_string()),
267                }],
268            }],
269            provenance: test_provenance(),
270        };
271
272        let json = serde_json::to_string_pretty(&meta).unwrap();
273        let round_tripped: ServerMetadata = serde_json::from_str(&json).unwrap();
274
275        assert_eq!(round_tripped, meta);
276    }
277
278    #[test]
279    fn deserializes_minimal_tool() {
280        let json = r#"{
281            "schema_version": 2,
282            "server_id": "github",
283            "server_name": "GitHub",
284            "server_version": "1.0.0",
285            "tools": [{
286                "name": "get_user",
287                "typescript_name": "getUser",
288                "category": null,
289                "keywords": [],
290                "description": null,
291                "parameters": []
292            }],
293            "provenance": {
294                "generated_at": "2026-01-01T00:00:00Z",
295                "config_fingerprint": "0000000000000000000000000000000000000000000000000000000000000000",
296                "tool_digest": "0000000000000000000000000000000000000000000000000000000000000000"
297            }
298        }"#;
299
300        let meta: ServerMetadata = serde_json::from_str(json).unwrap();
301
302        assert_eq!(meta.tools.len(), 1);
303        assert!(meta.tools[0].category.is_none());
304        assert!(meta.tools[0].keywords.is_empty());
305    }
306
307    /// A genuine `schema_version: 1` sidecar has no `provenance` key at all — typed
308    /// deserialization must fail (a consumer is expected to check `schema_version` *first*, see
309    /// `mcp-execution-skill`'s parser, rather than rely on this generic failure).
310    #[test]
311    fn deserialize_rejects_v1_shaped_document_missing_provenance() {
312        let json = r#"{
313            "schema_version": 1,
314            "server_id": "github",
315            "server_name": "GitHub",
316            "server_version": "1.0.0",
317            "tools": []
318        }"#;
319
320        let result: Result<ServerMetadata, _> = serde_json::from_str(json);
321        assert!(result.is_err());
322    }
323}