Skip to main content

isb_server/server/mcp/
skills.rs

1//! Agent Skills over MCP: the `io.modelcontextprotocol/skills` extension
2//! (SEP-2640). Each file of a skill is a resource at `skill://NAME/PATH`;
3//! `skills/list` and `skills/get` hand out manifests with digests, and
4//! `resources/read` and `resources/directory/read` serve the files.
5//!
6//! Skills are compiled into the binary, so their digests only change on an
7//! upgrade and every caller sees the same ones, whatever its org.
8
9use base64::Engine;
10use base64::engine::general_purpose::STANDARD;
11use serde_json::{Value, json};
12
13use super::{INVALID_PARAMS, Registry, RpcResult};
14use crate::error::{Error, Result};
15
16const EXTENSION: &str = "io.modelcontextprotocol/skills";
17const SCHEME: &str = "skill://";
18/// How long a client may treat a listing, entry or file as current.
19const TTL_MS: u64 = 300_000;
20/// The extension's per-skill limits: what every conforming host accepts.
21const MAX_FILES: usize = 512;
22const MAX_BYTES: usize = 16 * 1024 * 1024;
23
24/// One skill: a `SKILL.md` and the files beside it.
25#[derive(Debug, Clone)]
26pub struct Skill {
27    name: String,
28    /// `SKILL.md`'s frontmatter as JSON, every field the author wrote.
29    frontmatter: Value,
30    /// (path under the skill root, bytes), `SKILL.md` first.
31    files: Vec<(String, Vec<u8>)>,
32}
33
34impl Skill {
35    /// A skill from its files, by path relative to the skill root. One must
36    /// be `SKILL.md`, with `name` and `description` in its frontmatter.
37    pub fn new<P, B>(files: impl IntoIterator<Item = (P, B)>) -> Result<Self>
38    where
39        P: Into<String>,
40        B: Into<Vec<u8>>,
41    {
42        let mut files: Vec<(String, Vec<u8>)> = files
43            .into_iter()
44            .map(|(p, b)| (p.into(), b.into()))
45            .collect();
46        for (p, _) in &files {
47            if p.is_empty()
48                || p.starts_with('/')
49                || p.ends_with('/')
50                || p.split('/').any(|s| matches!(s, "" | "." | ".."))
51            {
52                return Err(Error::invalid(format!("invalid skill file path {p:?}")));
53            }
54        }
55        files.sort_by(|a, b| (a.0 != "SKILL.md", &a.0).cmp(&(b.0 != "SKILL.md", &b.0)));
56        if files.windows(2).any(|w| w[0].0 == w[1].0) {
57            return Err(Error::invalid("a skill lists a file twice"));
58        }
59        let Some((_, md)) = files.first().filter(|(p, _)| p == "SKILL.md") else {
60            return Err(Error::invalid("a skill needs a SKILL.md"));
61        };
62        let frontmatter = frontmatter(md)?;
63        let name = frontmatter["name"].as_str().unwrap_or_default().to_string();
64        if !valid_name(&name) {
65            return Err(Error::invalid(format!(
66                "skill name {name:?}: 1-64 of a-z, 0-9 and single hyphens inside"
67            )));
68        }
69        if !frontmatter["description"].is_string() {
70            return Err(Error::invalid(format!("skill {name}: no description")));
71        }
72        let bytes: usize = files.iter().map(|(_, b)| b.len()).sum();
73        if files.len() > MAX_FILES || bytes > MAX_BYTES {
74            return Err(Error::invalid(format!(
75                "skill {name}: {} files, {bytes} bytes; hosts only promise {MAX_FILES} and {MAX_BYTES}",
76                files.len()
77            )));
78        }
79        Ok(Self {
80            name,
81            frontmatter,
82            files,
83        })
84    }
85
86    pub fn name(&self) -> &str {
87        &self.name
88    }
89
90    fn root(&self) -> String {
91        format!("{SCHEME}{}", self.name)
92    }
93
94    fn uri(&self, path: &str) -> String {
95        format!("{}/{path}", self.root())
96    }
97
98    /// The `Skill` entry `skills/list` and `skills/get` return.
99    fn entry(&self) -> Value {
100        let resources: Vec<Value> = self
101            .files
102            .iter()
103            .map(|(p, b)| json!({"uri": self.uri(p), "digest": digest(b), "size": b.len()}))
104            .collect();
105        json!({
106            "uri": self.uri("SKILL.md"),
107            "frontmatter": self.frontmatter,
108            "resources": resources,
109        })
110    }
111
112    /// A file's `Resource` metadata. `SKILL.md` carries the skill's name and
113    /// description, as the extension asks.
114    fn resource(&self, path: &str, bytes: &[u8]) -> Value {
115        let mut r = json!({
116            "uri": self.uri(path),
117            "name": path.rsplit('/').next().unwrap_or(path),
118            "mimeType": mime(path, bytes),
119            "size": bytes.len(),
120        });
121        if path == "SKILL.md" {
122            r["name"] = json!(self.name);
123            r["description"] = self.frontmatter["description"].clone();
124        }
125        r
126    }
127}
128
129/// `SKILL.md`'s YAML frontmatter, between the opening `---` and the next.
130fn frontmatter(md: &[u8]) -> Result<Value> {
131    let bad = || Error::invalid("SKILL.md must open with YAML frontmatter between --- lines");
132    let text = std::str::from_utf8(md).map_err(|_| bad())?;
133    let rest = text
134        .strip_prefix("---\n")
135        .or_else(|| text.strip_prefix("---\r\n"))
136        .ok_or_else(bad)?;
137    let end = rest
138        .match_indices("\n---")
139        .map(|(i, _)| i)
140        .find(|&i| matches!(rest[i + 4..].chars().next(), None | Some('\n' | '\r')))
141        .ok_or_else(bad)?;
142    let v: Value = serde_yaml_ng::from_str(&rest[..end])
143        .map_err(|e| Error::invalid(format!("SKILL.md frontmatter: {e}")))?;
144    if !v.is_object() {
145        return Err(bad());
146    }
147    Ok(v)
148}
149
150/// The Agent Skills naming rule.
151fn valid_name(n: &str) -> bool {
152    (1..=64).contains(&n.len())
153        && n.bytes()
154            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
155        && !n.starts_with('-')
156        && !n.ends_with('-')
157        && !n.contains("--")
158}
159
160fn digest(b: &[u8]) -> String {
161    let d = ring::digest::digest(&ring::digest::SHA256, b);
162    let hex: String = d.as_ref().iter().map(|x| format!("{x:02x}")).collect();
163    format!("sha256:{hex}")
164}
165
166fn mime(path: &str, bytes: &[u8]) -> &'static str {
167    let ext = path.rsplit_once('.').map(|(_, e)| e).unwrap_or_default();
168    match ext {
169        "md" => "text/markdown",
170        "yaml" | "yml" => "application/yaml",
171        "json" => "application/json",
172        "sh" => "text/x-shellscript",
173        "py" => "text/x-python",
174        _ if std::str::from_utf8(bytes).is_ok() => "text/plain",
175        _ => "application/octet-stream",
176    }
177}
178
179impl Registry {
180    /// Serve these skills at `skill://NAME/`. Names are unique.
181    pub fn skills(mut self, skills: impl IntoIterator<Item = Skill>) -> Result<Self> {
182        for s in skills {
183            if self.skills.iter().any(|t| t.name == s.name) {
184                return Err(Error::invalid(format!("skill {:?} added twice", s.name)));
185            }
186            self.skills.push(s);
187        }
188        Ok(self)
189    }
190}
191
192/// Declare the extension, and the resources it serves through, in
193/// `initialize`'s capabilities.
194pub(super) fn declare(skills: &[Skill], caps: &mut Value) {
195    if !skills.is_empty() {
196        caps["resources"] = json!({"listChanged": false});
197        caps["extensions"] = json!({EXTENSION: {"directoryRead": true}});
198    }
199}
200
201/// Answer a skills or resources method, or `None` when `method` is neither
202/// or there are no skills (then there are no resources either).
203pub(super) fn dispatch(skills: &[Skill], method: &str, params: &Value) -> Option<RpcResult> {
204    if skills.is_empty() {
205        return None;
206    }
207    let bad = |m: String| Err((INVALID_PARAMS, m));
208    let cached = |mut v: Value| {
209        v["resultType"] = json!("complete");
210        v["ttlMs"] = json!(TTL_MS);
211        v["cacheScope"] = json!("public");
212        Ok(v)
213    };
214    let uri = || params.get("uri").and_then(Value::as_str);
215    // A handful of files: nothing is ever paginated, so a cursor is ignored.
216    if params.get("cursor").is_some_and(|c| !c.is_string()) {
217        return Some(bad("cursor must be a string".into()));
218    }
219    Some(match method {
220        "skills/list" => {
221            cached(json!({"skills": skills.iter().map(Skill::entry).collect::<Vec<_>>()}))
222        }
223        "skills/get" => {
224            let Some(u) = uri() else {
225                return Some(bad("skills/get needs a uri".into()));
226            };
227            match skills.iter().find(|s| s.uri("SKILL.md") == u) {
228                Some(s) => cached(json!({"skill": s.entry()})),
229                None => bad(format!("No skill is served at {u}")),
230            }
231        }
232        "resources/list" => {
233            let r: Vec<Value> = skills
234                .iter()
235                .flat_map(|s| s.files.iter().map(move |(p, b)| s.resource(p, b)))
236                .collect();
237            cached(json!({"resources": r}))
238        }
239        "resources/templates/list" => cached(json!({"resourceTemplates": []})),
240        "resources/read" => {
241            let Some(u) = uri() else {
242                return Some(bad("resources/read needs a uri".into()));
243            };
244            let found = skills
245                .iter()
246                .find_map(|s| s.files.iter().find(|(p, _)| s.uri(p) == u));
247            let Some((p, b)) = found else {
248                return Some(bad(format!("no resource at {u}")));
249            };
250            let mut c = json!({"uri": u, "mimeType": mime(p, b)});
251            match std::str::from_utf8(b) {
252                Ok(t) => c["text"] = json!(t),
253                Err(_) => c["blob"] = json!(STANDARD.encode(b)),
254            }
255            cached(json!({"contents": [c]}))
256        }
257        "resources/directory/read" => {
258            let Some(u) = uri() else {
259                return Some(bad("resources/directory/read needs a uri".into()));
260            };
261            match directory(skills, u) {
262                Some(r) => Ok(json!({"resultType": "complete", "resources": r})),
263                None => bad(format!("{u} is not a directory resource")),
264            }
265        }
266        _ => return None,
267    })
268}
269
270/// The direct children of the directory at `uri` (no trailing slash), or
271/// `None` when no skill has a directory there.
272fn directory(skills: &[Skill], uri: &str) -> Option<Vec<Value>> {
273    for s in skills {
274        let root = s.root();
275        let dir = if uri == root {
276            ""
277        } else if let Some(d) = uri.strip_prefix(&root).and_then(|d| d.strip_prefix('/')) {
278            d
279        } else {
280            continue;
281        };
282        let prefix = if dir.is_empty() {
283            String::new()
284        } else {
285            format!("{dir}/")
286        };
287        let mut out: Vec<Value> = Vec::new();
288        let mut subdirs: Vec<&str> = Vec::new();
289        for (p, b) in &s.files {
290            let Some(rest) = p.strip_prefix(&prefix) else {
291                continue;
292            };
293            match rest.split_once('/') {
294                None => out.push(s.resource(p, b)),
295                Some((d, _)) if !subdirs.contains(&d) => subdirs.push(d),
296                Some(_) => {}
297            }
298        }
299        if out.is_empty() && subdirs.is_empty() {
300            return None;
301        }
302        out.extend(subdirs.into_iter().map(|d| {
303            json!({"uri": s.uri(&format!("{prefix}{d}")), "name": d, "mimeType": "inode/directory"})
304        }));
305        return Some(out);
306    }
307    None
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    const MD: &str = "---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n\nChoose the matching template from `templates/`.\n";
315
316    fn skill() -> Skill {
317        Skill::new([
318            (
319                "templates/purchase-order.md",
320                "# Purchase order\n\nSupplier:\nItems:\n",
321            ),
322            ("SKILL.md", MD),
323            ("templates/invoice.md", "# Invoice\n\nCustomer:\nAmount:\n"),
324        ])
325        .unwrap()
326    }
327
328    fn call(method: &str, params: Value) -> RpcResult {
329        dispatch(&[skill()], method, &params).expect("a skills method")
330    }
331
332    #[test]
333    fn entry_matches_the_spec_example() {
334        // The digests and sizes from the extension's own example flow.
335        let r = call(
336            "skills/get",
337            json!({"uri": "skill://pdf-processing/SKILL.md"}),
338        )
339        .unwrap();
340        assert_eq!(r["resultType"], "complete");
341        assert_eq!(r["cacheScope"], "public");
342        assert_eq!(
343            r["skill"],
344            json!({
345                "uri": "skill://pdf-processing/SKILL.md",
346                "frontmatter": {"name": "pdf-processing", "description": "Extract, fill, and assemble PDF documents"},
347                "resources": [
348                    {"uri": "skill://pdf-processing/SKILL.md", "digest": "sha256:99b737495721155ece826d57521e2d66141ebdc1344a400487481ea2642ab19e", "size": 151},
349                    {"uri": "skill://pdf-processing/templates/invoice.md", "digest": "sha256:61f4ea6d2c75fde1b4977219e7e3107d491c3c26aefb6686e84d6281c088d9ee", "size": 29},
350                    {"uri": "skill://pdf-processing/templates/purchase-order.md", "digest": "sha256:f2ff774b1737ff3dec81c47946f9976f18a1a9f69dda0a81f22eabd95173c158", "size": 35},
351                ],
352            })
353        );
354        let l = call("skills/list", json!({})).unwrap();
355        assert_eq!(l["skills"], json!([r["skill"]]));
356        assert_eq!(
357            call("skills/get", json!({"uri": "skill://nope/SKILL.md"}))
358                .unwrap_err()
359                .0,
360            INVALID_PARAMS
361        );
362    }
363
364    #[test]
365    fn files_read_as_resources() {
366        let r = call(
367            "resources/read",
368            json!({"uri": "skill://pdf-processing/SKILL.md"}),
369        )
370        .unwrap();
371        assert_eq!(
372            r["contents"],
373            json!([{"uri": "skill://pdf-processing/SKILL.md", "mimeType": "text/markdown", "text": MD}])
374        );
375        assert_eq!(
376            call(
377                "resources/read",
378                json!({"uri": "skill://pdf-processing/x.md"})
379            )
380            .unwrap_err()
381            .0,
382            INVALID_PARAMS
383        );
384        let l = call("resources/list", json!({})).unwrap();
385        let l = l["resources"].as_array().unwrap();
386        assert_eq!(l.len(), 3);
387        assert_eq!(l[0]["name"], "pdf-processing");
388        assert_eq!(
389            l[0]["description"],
390            "Extract, fill, and assemble PDF documents"
391        );
392        assert_eq!(l[1]["name"], "invoice.md");
393    }
394
395    #[test]
396    fn directories_list_direct_children() {
397        let root = call(
398            "resources/directory/read",
399            json!({"uri": "skill://pdf-processing"}),
400        )
401        .unwrap();
402        let names: Vec<_> = root["resources"]
403            .as_array()
404            .unwrap()
405            .iter()
406            .map(|r| (r["name"].as_str().unwrap(), r["mimeType"].as_str().unwrap()))
407            .collect();
408        assert_eq!(
409            names,
410            [
411                ("pdf-processing", "text/markdown"),
412                ("templates", "inode/directory")
413            ]
414        );
415        let t = call(
416            "resources/directory/read",
417            json!({"uri": "skill://pdf-processing/templates"}),
418        )
419        .unwrap();
420        assert_eq!(t["resources"].as_array().unwrap().len(), 2);
421        for not_dir in [
422            "skill://pdf-processing/SKILL.md",
423            "skill://pdf-processing/nope",
424            "skill://pdf",
425        ] {
426            assert_eq!(
427                call("resources/directory/read", json!({"uri": not_dir}))
428                    .unwrap_err()
429                    .0,
430                INVALID_PARAMS,
431                "{not_dir}"
432            );
433        }
434    }
435
436    #[test]
437    fn frontmatter_passes_through_verbatim() {
438        let md = "---\nname: isb\ndescription: >\n  Two\n  lines.\nlicense: MIT\nmetadata:\n  k: v\n---\nbody\n---\nmore\n";
439        let s = Skill::new([("SKILL.md", md)]).unwrap();
440        assert_eq!(
441            s.entry()["frontmatter"],
442            json!({"name": "isb", "description": "Two lines.\n", "license": "MIT", "metadata": {"k": "v"}})
443        );
444    }
445
446    #[test]
447    fn bad_skills_are_refused() {
448        let ok = "---\nname: a\ndescription: d\n---\n";
449        for files in [
450            vec![("README.md", ok)],
451            vec![("SKILL.md", "no frontmatter")],
452            vec![("SKILL.md", "---\nname: Bad_Name\ndescription: d\n---\n")],
453            vec![("SKILL.md", "---\nname: a--b\ndescription: d\n---\n")],
454            vec![("SKILL.md", "---\nname: a\n---\n")],
455            vec![("SKILL.md", ok), ("../x", "")],
456            vec![("SKILL.md", ok), ("a//b", "")],
457            vec![("SKILL.md", ok), ("SKILL.md", ok)],
458        ] {
459            assert!(Skill::new(files.clone()).is_err(), "{files:?}");
460        }
461    }
462}