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}