Skip to content

Commit 609d4d5

Browse files
olaservoclaude
andcommitted
feat(skills): support MCP-served Agent Skills per the skills-over-MCP SEP
Implements the host side of the pre-submission SEP draft at modelcontextprotocol/experimental-ext-skills#69 ("io.modelcontextprotocol/skills") so that skills served by connected MCP servers are discovered, surfaced in the model's context, and loaded on demand through Goose's existing skills pipeline — identical treatment to filesystem-based skills. Discovery - New crates/goose/src/agents/platform_extensions/mcp_skills.rs reads each connected server's skill://index.json at extension-connect time (5s timeout, graceful on failure) and caches concrete skill entries on the owning Extension. Scheme-agnostic: index entries MAY use any URI scheme per the SEP, so the cache stores whatever the index gave us. - Capability declaration (capabilities.extensions["io.modelcontextprotocol/skills"]) is recognized via rmcp's typed ExtensionCapabilities but not gated on — direct resource/read of skill://index.json always runs. Dynamic instructions - New McpClientTrait::get_dynamic_instructions hook, queried per-turn from ExtensionManager::get_extensions_info. SkillsClient implements it to render an MCP-skills section alongside the existing FS list, with <server>__<name> disambiguation on name collisions. Loading - load_skill now routes to MCP skills by name (or <server>__<name>), with supporting-file composition against the cached base_uri. URI-shaped inputs are rejected with a redirect hint pointing at read_resource (which already exists on the extensionmanager platform extension). Hardening - developer/edit.rs: added reject_uri_path guard on file_read, file_write, and file_edit so the model can't accidentally Path()-resolve a URI (a documented pitfall from fast-agent's SEP host implementation). - Sharpened extensionmanager's read_resource tool description to steer the model toward load_skill for named skills. API / UI - SlashCommand gains an optional `origin` field identifying MCP-served skills; /config/slash_commands accepts an optional session_id to include that session's MCP skills. SkillsView renders a "MCP · <server>" pill on MCP-origin entries. Security (per SEP) - MCP skill content is treated as untrusted model input. Only `name` and `description` are parsed from frontmatter; no hook / pre-post / shell fields are honored. Tests - 13 new unit tests: MCP index discovery (6), load_skill MCP routing (5), URI rejection in developer tools (2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4656d1a commit 609d4d5

13 files changed

Lines changed: 1222 additions & 36 deletions

File tree

crates/goose-server/src/routes/config_management.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ pub struct SlashCommand {
143143
pub command: String,
144144
pub help: String,
145145
pub command_type: CommandType,
146+
/// For MCP-sourced skills, the name of the originating MCP extension
147+
/// (e.g. "github"). `None` for filesystem skills and non-skill commands.
148+
#[serde(skip_serializing_if = "Option::is_none")]
149+
pub origin: Option<String>,
146150
}
147151
#[derive(Serialize, ToSchema)]
148152
pub struct SlashCommandsResponse {
@@ -395,6 +399,9 @@ pub async fn get_provider_models(
395399
pub struct SlashCommandsQuery {
396400
/// Optional working directory to discover local skills from
397401
pub working_dir: Option<String>,
402+
/// Optional session id; when provided, the endpoint also includes
403+
/// MCP-served skills from the corresponding agent's connected extensions.
404+
pub session_id: Option<String>,
398405
}
399406

400407
#[utoipa::path(
@@ -406,6 +413,7 @@ pub struct SlashCommandsQuery {
406413
)
407414
)]
408415
pub async fn get_slash_commands(
416+
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
409417
axum::extract::Query(query): axum::extract::Query<SlashCommandsQuery>,
410418
) -> Result<Json<SlashCommandsResponse>, ErrorResponse> {
411419
let mut commands: Vec<_> = slash_commands::list_commands()
@@ -414,6 +422,7 @@ pub async fn get_slash_commands(
414422
command: command.command.clone(),
415423
help: command.recipe_path.clone(),
416424
command_type: CommandType::Recipe,
425+
origin: None,
417426
})
418427
.collect();
419428

@@ -422,6 +431,7 @@ pub async fn get_slash_commands(
422431
command: cmd_def.name.to_string(),
423432
help: cmd_def.description.to_string(),
424433
command_type: CommandType::Builtin,
434+
origin: None,
425435
});
426436
}
427437

@@ -433,9 +443,27 @@ pub async fn get_slash_commands(
433443
command: source.name,
434444
help: source.description,
435445
command_type: CommandType::Skill,
446+
origin: None,
436447
});
437448
}
438449

450+
// Session-scoped MCP skills. When a session id is supplied, surface
451+
// any skills that session's agent has discovered from connected MCP
452+
// extensions. Same CommandType::Skill — the UI distinguishes via
453+
// the `origin` badge.
454+
if let Some(session_id) = query.session_id.as_deref() {
455+
if let Ok(agent) = state.get_agent(session_id.to_string()).await {
456+
for entry in agent.extension_manager.aggregated_mcp_skills().await {
457+
commands.push(SlashCommand {
458+
command: entry.name,
459+
help: entry.description,
460+
command_type: CommandType::Skill,
461+
origin: Some(entry.server),
462+
});
463+
}
464+
}
465+
}
466+
439467
Ok(Json(SlashCommandsResponse { commands }))
440468
}
441469

crates/goose/src/agents/agent.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2051,7 +2051,11 @@ impl Agent {
20512051
.await?;
20522052
let extensions_info = self
20532053
.extension_manager
2054-
.get_extensions_info(&session.working_dir)
2054+
.get_extensions_info(
2055+
session_id,
2056+
&session.working_dir,
2057+
tokio_util::sync::CancellationToken::new(),
2058+
)
20552059
.await;
20562060
tracing::debug!("Retrieved {} extensions info", extensions_info.len());
20572061
let (extension_count, tool_count) = self

crates/goose/src/agents/extension_manager.rs

Lines changed: 101 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ struct Extension {
7777
client: McpClientBox,
7878
server_info: Option<ServerInfo>,
7979
_temp_dir: Option<tempfile::TempDir>,
80+
/// Cache of MCP-served skills discovered from this extension's
81+
/// `skill://index.json` at connect time. Populated synchronously
82+
/// during extension registration (see `populate_mcp_skills_cache`).
83+
/// Empty for extensions that don't serve a parseable index.
84+
/// TODO: invalidate on `notifications/resources/list_changed`.
85+
mcp_skills: Vec<crate::agents::platform_extensions::mcp_skills::McpSkillEntry>,
8086
}
8187

8288
impl Extension {
@@ -86,13 +92,15 @@ impl Extension {
8692
client: McpClientBox,
8793
server_info: Option<ServerInfo>,
8894
temp_dir: Option<tempfile::TempDir>,
95+
mcp_skills: Vec<crate::agents::platform_extensions::mcp_skills::McpSkillEntry>,
8996
) -> Self {
9097
Self {
9198
client,
9299
config,
93100
resolved_config,
94101
server_info,
95102
_temp_dir: temp_dir,
103+
mcp_skills,
96104
}
97105
}
98106

@@ -900,16 +908,30 @@ impl ExtensionManager {
900908
};
901909

902910
let server_info = client.get_info().cloned();
911+
let client_arc: McpClientBox = Arc::from(client);
912+
913+
// Populate MCP skills cache before inserting so the first turn's
914+
// system prompt sees any skills this server publishes. The fetch
915+
// is bounded by `INDEX_FETCH_TIMEOUT`; on timeout/error the cache
916+
// is empty and extension registration still succeeds.
917+
let mcp_skills = crate::agents::platform_extensions::mcp_skills::fetch_server_skills(
918+
&sanitized_name,
919+
client_arc.as_ref(),
920+
"",
921+
CancellationToken::new(),
922+
)
923+
.await;
903924

904925
let mut extensions = self.extensions.lock().await;
905926
extensions.insert(
906927
sanitized_name,
907928
Extension::new(
908929
config,
909930
resolved_config,
910-
Arc::from(client),
931+
client_arc,
911932
server_info,
912933
temp_dir,
934+
mcp_skills,
913935
),
914936
);
915937
drop(extensions);
@@ -927,26 +949,76 @@ impl ExtensionManager {
927949
temp_dir: Option<TempDir>,
928950
) {
929951
let normalized = name_to_key(&name);
952+
953+
// Populate MCP skills cache at connect time. See the doc on
954+
// `mcp_skills` above for why this is synchronous.
955+
let mcp_skills = crate::agents::platform_extensions::mcp_skills::fetch_server_skills(
956+
&normalized,
957+
client.as_ref(),
958+
"",
959+
CancellationToken::new(),
960+
)
961+
.await;
962+
930963
self.extensions.lock().await.insert(
931964
normalized,
932-
Extension::new(config.clone(), config.clone(), client, info, temp_dir),
965+
Extension::new(
966+
config.clone(),
967+
config.clone(),
968+
client,
969+
info,
970+
temp_dir,
971+
mcp_skills,
972+
),
933973
);
934974
self.invalidate_tools_cache_and_bump_version().await;
935975
}
936976

937-
/// Get extensions info for building the system prompt
938-
pub async fn get_extensions_info(&self, working_dir: &std::path::Path) -> Vec<ExtensionInfo> {
977+
/// Get extensions info for building the system prompt. Combines each
978+
/// extension's static `InitializeResult.instructions` with any per-turn
979+
/// dynamic contribution via `McpClientTrait::get_dynamic_instructions`.
980+
pub async fn get_extensions_info(
981+
&self,
982+
session_id: &str,
983+
working_dir: &std::path::Path,
984+
cancel_token: CancellationToken,
985+
) -> Vec<ExtensionInfo> {
939986
let working_dir_str = working_dir.to_string_lossy();
940-
self.extensions
941-
.lock()
942-
.await
943-
.iter()
944-
.map(|(name, ext)| {
945-
let instructions = ext.get_instructions().unwrap_or_default();
946-
let instructions = instructions.replace("{{WORKING_DIR}}", &working_dir_str);
947-
ExtensionInfo::new(name, &instructions, ext.supports_resources())
948-
})
949-
.collect()
987+
988+
let snapshots: Vec<(String, String, McpClientBox, bool)> = {
989+
let extensions = self.extensions.lock().await;
990+
extensions
991+
.iter()
992+
.map(|(name, ext)| {
993+
(
994+
name.clone(),
995+
ext.get_instructions().unwrap_or_default(),
996+
ext.client.clone(),
997+
ext.supports_resources(),
998+
)
999+
})
1000+
.collect()
1001+
};
1002+
1003+
let mut infos = Vec::with_capacity(snapshots.len());
1004+
for (name, static_instructions, client, supports_resources) in snapshots {
1005+
let dynamic = client
1006+
.get_dynamic_instructions(session_id, cancel_token.clone())
1007+
.await
1008+
.unwrap_or_default();
1009+
1010+
let combined = if dynamic.is_empty() {
1011+
static_instructions
1012+
} else if static_instructions.is_empty() {
1013+
dynamic
1014+
} else {
1015+
format!("{}\n{}", static_instructions, dynamic)
1016+
};
1017+
1018+
let combined = combined.replace("{{WORKING_DIR}}", &working_dir_str);
1019+
infos.push(ExtensionInfo::new(&name, &combined, supports_resources));
1020+
}
1021+
infos
9501022
}
9511023

9521024
/// Get aggregated usage statistics
@@ -982,6 +1054,19 @@ impl ExtensionManager {
9821054
Ok(self.extensions.lock().await.keys().cloned().collect())
9831055
}
9841056

1057+
/// Snapshot every connected extension's cached MCP skill entries. Read
1058+
/// path for the skills platform extension's per-turn system-prompt
1059+
/// assembly — no network I/O.
1060+
pub async fn aggregated_mcp_skills(
1061+
&self,
1062+
) -> Vec<crate::agents::platform_extensions::mcp_skills::McpSkillEntry> {
1063+
let mut out = Vec::new();
1064+
for ext in self.extensions.lock().await.values() {
1065+
out.extend(ext.mcp_skills.iter().cloned());
1066+
}
1067+
out
1068+
}
1069+
9851070
pub async fn is_extension_enabled(&self, name: &str) -> bool {
9861071
let normalized = name_to_key(name);
9871072
self.extensions.lock().await.contains_key(&normalized)
@@ -1842,7 +1927,8 @@ mod tests {
18421927
bundled: None,
18431928
available_tools,
18441929
};
1845-
let extension = Extension::new(config.clone(), config.clone(), client, None, None);
1930+
let extension =
1931+
Extension::new(config.clone(), config.clone(), client, None, None, Vec::new());
18461932
self.extensions
18471933
.lock()
18481934
.await

crates/goose/src/agents/mcp_client.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,19 @@ pub trait McpClientTrait: Send + Sync {
101101
None
102102
}
103103

104+
/// Optional per-turn dynamic addition to the extension's instructions.
105+
/// Returned text is appended to the static `InitializeResult.instructions`
106+
/// when `ExtensionManager::get_extensions_info` assembles the system
107+
/// prompt. Called on every reply, so implementations MUST NOT do network
108+
/// I/O inline — read from caches instead. Default: no dynamic contribution.
109+
async fn get_dynamic_instructions(
110+
&self,
111+
_session_id: &str,
112+
_cancel_token: CancellationToken,
113+
) -> Option<String> {
114+
None
115+
}
116+
104117
async fn update_working_dir(&self, _new_dir: PathBuf) -> Result<(), Error> {
105118
Ok(())
106119
}

crates/goose/src/agents/platform_extensions/developer/edit.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ impl EditTools {
4343
params: FileReadParams,
4444
working_dir: Option<&Path>,
4545
) -> CallToolResult {
46+
if let Some(err) = reject_uri_path(&params.path) {
47+
return err;
48+
}
4649
let path = resolve_path(&params.path, working_dir);
4750

4851
match fs::read_to_string(&path) {
@@ -67,6 +70,9 @@ impl EditTools {
6770
params: FileWriteParams,
6871
working_dir: Option<&Path>,
6972
) -> CallToolResult {
73+
if let Some(err) = reject_uri_path(&params.path) {
74+
return err;
75+
}
7076
let path = resolve_path(&params.path, working_dir);
7177

7278
if let Some(parent) = path.parent() {
@@ -111,6 +117,9 @@ impl EditTools {
111117
params: FileEditParams,
112118
working_dir: Option<&Path>,
113119
) -> CallToolResult {
120+
if let Some(err) = reject_uri_path(&params.path) {
121+
return err;
122+
}
114123
let path = resolve_path(&params.path, working_dir);
115124

116125
let content = match fs::read_to_string(&path) {
@@ -225,6 +234,43 @@ pub fn resolve_path(path: &str, working_dir: Option<&Path>) -> PathBuf {
225234
}
226235
}
227236

237+
/// Guards file-reading / editing paths against URIs being passed in.
238+
///
239+
/// Hosts that expose both a filesystem reader and an MCP resource reader
240+
/// can trip over `skill://…`, `github://…`, and other scheme-prefixed
241+
/// strings: the model sometimes tries the filesystem reader on them, and
242+
/// `PathBuf::from` silently produces a bogus relative path under cwd.
243+
/// See `Skills SEP host implementation guidelines.md` for the recorded
244+
/// pitfall. Detect the `<scheme>://` shape and redirect the model toward
245+
/// `read_resource` / `load_skill` with a clear error.
246+
pub fn reject_uri_path(path: &str) -> Option<CallToolResult> {
247+
// A very narrow predicate: we only want to match `<scheme>://...`,
248+
// not paths that happen to contain `://` (rare on POSIX, impossible
249+
// on well-formed Windows absolute paths). RFC 3986 schemes start
250+
// with ASCII alpha and may contain alphanumerics, `+`, `-`, `.`.
251+
let Some(colon_idx) = path.find("://") else {
252+
return None;
253+
};
254+
let scheme = &path[..colon_idx];
255+
if scheme.is_empty() {
256+
return None;
257+
}
258+
let mut chars = scheme.chars();
259+
let first = chars.next().unwrap();
260+
if !first.is_ascii_alphabetic() {
261+
return None;
262+
}
263+
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
264+
return None;
265+
}
266+
267+
Some(CallToolResult::error(vec![Content::text(format!(
268+
"'{}' is an MCP resource URI, not a filesystem path. Use the read_resource tool (it takes server + uri) for raw URIs, or load_skill for named skills.",
269+
path
270+
))
271+
.with_priority(0.0)]))
272+
}
273+
228274
fn count_lines_before(content: &str, byte_pos: usize) -> usize {
229275
content
230276
.char_indices()
@@ -297,6 +343,30 @@ mod tests {
297343
}
298344
}
299345

346+
#[test]
347+
fn test_reject_uri_path_rejects_schemed_inputs() {
348+
// Recognizes the canonical `skill://` scheme as well as domain-
349+
// native schemes that the SEP explicitly permits.
350+
assert!(reject_uri_path("skill://pull-requests/SKILL.md").is_some());
351+
assert!(reject_uri_path("github://owner/repo/skills/x/SKILL.md").is_some());
352+
assert!(reject_uri_path("repo://foo").is_some());
353+
assert!(reject_uri_path("https://example.com/a").is_some());
354+
// RFC 3986 allows +, -, . in scheme; all should be recognized.
355+
assert!(reject_uri_path("svn+ssh://host/path").is_some());
356+
}
357+
358+
#[test]
359+
fn test_reject_uri_path_passes_through_filesystem_paths() {
360+
assert!(reject_uri_path("/absolute/path").is_none());
361+
assert!(reject_uri_path("C:\\Users\\me\\file.txt").is_none());
362+
assert!(reject_uri_path("relative/path.md").is_none());
363+
// A literal colon in the name is not enough — we require `://`.
364+
assert!(reject_uri_path("my:file").is_none());
365+
// `://` without a valid scheme prefix is passed through.
366+
assert!(reject_uri_path("://foo").is_none());
367+
assert!(reject_uri_path("1bad://foo").is_none());
368+
}
369+
300370
#[test_case(None, None, "line1\nline2\nline3" ; "full content")]
301371
#[test_case(Some(2), None, "line2\nline3" ; "from line 2")]
302372
#[test_case(None, Some(2), "line1\nline2\n" ; "limit 2")]

0 commit comments

Comments
 (0)