Compare commits

..
5 Commits
6 changed files with 919 additions and 50 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ use crate::mcp::{
spawn_mcp_server,
};
use anyhow::{Result, anyhow};
use anyhow::Result;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::Path;
@@ -111,10 +111,10 @@ impl McpFactory {
.await
.map_err(|e| {
if is_auth_required_error(&e) {
anyhow!(
e.context(format!(
"MCP server '{name}' requires OAuth authentication. \
Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate."
)
))
} else {
e
}
+586 -11
View File
@@ -1,3 +1,4 @@
use super::agent::AgentConfig;
use super::rag_cache::{RagCache, RagKey};
use super::session::Session;
use super::skill::{SKILL_SCAFFOLD, Skill};
@@ -20,7 +21,7 @@ use crate::function::{
};
use crate::mcp::{
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
MCP_SEARCH_META_FUNCTION_NAME_PREFIX,
MCP_SEARCH_META_FUNCTION_NAME_PREFIX, is_auth_required_error,
};
use crate::rag::Rag;
use crate::supervisor::Supervisor;
@@ -87,6 +88,37 @@ pub fn should_inject_skill_instructions(app: &AppConfig, policy: &SkillPolicy) -
app.function_calling_support && policy.skills_enabled && !policy.compatible_enabled.is_empty()
}
fn print_asset_names(kind: &str, names: &[String]) -> Result<()> {
if names.is_empty() {
println!("No {kind} found.");
return Ok(());
}
let mut header: Vec<char> = kind.chars().collect();
header[0] = header[0].to_ascii_uppercase();
let header: String = header.into_iter().collect();
println!("{header}:");
for name in names {
println!("{name}");
}
Ok(())
}
fn complete_skills_with_descriptions(names: Vec<String>) -> Vec<(String, Option<String>)> {
names
.into_iter()
.map(|name| {
let description = Skill::load(&name)
.ok()
.map(|s| s.description().to_string())
.filter(|d| !d.is_empty());
(name, description)
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RenderMode {
#[default]
@@ -999,6 +1031,273 @@ impl RequestContext {
}
}
fn concrete_tool_names(&self) -> Vec<String> {
self.tool_scope
.functions
.declarations()
.iter()
.filter(|v| {
!v.name.starts_with("user__")
&& !v.name.starts_with("mcp_")
&& !v.name.starts_with("todo__")
&& !v.name.starts_with("agent__")
})
.map(|v| v.name.clone())
.collect()
}
fn tool_list_covers(&self, list: &[String], name: &str) -> bool {
for item in list {
let item = item.trim();
if item == name {
return true;
}
if let Some(values) = self.app.config.mapping_tools.get(item)
&& values.split(',').any(|v| v.trim() == name)
{
return true;
}
}
false
}
fn mcp_list_covers(&self, list: &[String], name: &str) -> bool {
for item in list {
let item = item.trim();
if item == name {
return true;
}
if let Some(values) = self.app.config.mapping_mcp_servers.get(item)
&& values.split(',').any(|v| v.trim() == name)
{
return true;
}
}
false
}
fn write_layer_label(&self) -> &'static str {
if self.session.is_some() {
"session"
} else if self.agent.is_some() {
"agent"
} else if self.role.is_some() {
"role"
} else {
"global, in-memory"
}
}
pub fn toggle_tool(&mut self, action: &str, name: &str) -> Result<()> {
let name = name.trim();
let enable = match action {
"enable" => true,
"disable" => false,
_ => bail!("Unknown action '{action}'. Usage: .tool <enable|disable> <name>"),
};
if self.session.is_none() && self.agent.is_some() {
bail!(
"Cannot adjust tools for an agent outside a session; start a session first with `.session`"
);
}
let pool = self.concrete_tool_names();
let is_alias = self.app.config.mapping_tools.contains_key(name);
if !pool.iter().any(|v| v == name) && !is_alias {
bail!("Unknown tool '{name}'. Run `.list tools` to see what's available");
}
let current: Option<Vec<String>> = if let Some(session) = &self.session {
session.enabled_tools()
} else if let Some(role) = &self.role {
role.enabled_tools()
} else {
self.app.config.enabled_tools.clone()
};
let layer = self.write_layer_label();
let new_list: Vec<String> = if enable {
match current {
Some(list) if list.iter().any(|s| s.trim() == "all") => {
println!("Tool '{name}' is already enabled ('all' is set).");
return Ok(());
}
Some(list) if self.tool_list_covers(&list, name) => {
println!("Tool '{name}' is already enabled.");
return Ok(());
}
Some(mut list) => {
list.push(name.to_string());
list
}
None => vec![name.to_string()],
}
} else {
match current {
None => {
println!("No tools are enabled in this context; nothing to disable.");
return Ok(());
}
Some(list) if list.is_empty() => {
println!("No tools are enabled in this context; nothing to disable.");
return Ok(());
}
Some(list) if list.iter().any(|s| s.trim() == "all") => {
let mut materialized = pool;
materialized.retain(|v| v != name);
println!(
"Note: expanded 'all' into {} concrete tools.",
materialized.len()
);
materialized
}
Some(mut list) => {
if list.iter().any(|s| s.trim() == name) {
list.retain(|s| s.trim() != name);
list
} else if self.tool_list_covers(&list, name) {
bail!(
"Tool '{name}' is enabled via an alias in 'mapping_tools'. \
Disable the alias instead, or set the list explicitly with `.set enabled_tools`."
);
} else {
println!("Tool '{name}' is not enabled; nothing to do.");
return Ok(());
}
}
}
};
let new_list = Some(new_list);
if !self.set_enabled_tools_on_role_like(new_list.clone()) {
self.update_app_config(|app| app.enabled_tools = new_list.clone());
}
let verb = if enable { "Enabled" } else { "Disabled" };
println!("{verb} tool '{name}' ({layer}).");
Ok(())
}
pub async fn toggle_mcp_server(
&mut self,
action: &str,
name: &str,
abort_signal: AbortSignal,
) -> Result<()> {
let name = name.trim();
let enable = match action {
"enable" => true,
"disable" => false,
_ => bail!("Unknown action '{action}'. Usage: .mcp <enable|disable> <server_name>"),
};
if self.agent.is_some() {
bail!(
"Agent MCP servers are defined by the agent's config ('mcp_servers'); \
edit the agent config with `.edit agent-config` instead."
);
}
let configured_keys: Vec<String> = match &self.app.mcp_config {
Some(mcp_config) if !mcp_config.mcp_servers.is_empty() => {
mcp_config.mcp_servers.keys().cloned().collect()
}
_ => bail!("No MCP servers are configured. Please configure MCP servers first."),
};
let is_alias = self.app.config.mapping_mcp_servers.contains_key(name);
if !configured_keys.iter().any(|v| v == name) && !is_alias {
bail!(
"MCP server '{name}' is not configured. Run `.list mcp-servers` to see what's available"
);
}
let current: Option<Vec<String>> = if let Some(session) = &self.session {
session.enabled_mcp_servers()
} else if let Some(role) = &self.role {
role.enabled_mcp_servers()
} else {
self.app.config.enabled_mcp_servers.clone()
};
let layer = self.write_layer_label();
let new_list: Vec<String> = if enable {
match current {
Some(list) if list.iter().any(|s| s.trim() == "all") => {
println!("MCP server '{name}' is already enabled ('all' is set).");
return Ok(());
}
Some(list) if self.mcp_list_covers(&list, name) => {
println!("MCP server '{name}' is already enabled.");
return Ok(());
}
Some(mut list) => {
list.push(name.to_string());
list
}
None => vec![name.to_string()],
}
} else {
match current {
None => {
println!("No MCP servers are enabled in this context; nothing to disable.");
return Ok(());
}
Some(list) if list.is_empty() => {
println!("No MCP servers are enabled in this context; nothing to disable.");
return Ok(());
}
Some(list) if list.iter().any(|s| s.trim() == "all") => {
let mut materialized = configured_keys;
materialized.retain(|v| v != name);
materialized
}
Some(mut list) => {
if list.iter().any(|s| s.trim() == name) {
list.retain(|s| s.trim() != name);
list
} else if self.mcp_list_covers(&list, name) {
bail!(
"MCP server '{name}' is enabled via an alias in 'mapping_mcp_servers'. \
Disable the alias instead, or set the list explicitly with `.set enabled_mcp_servers`."
);
} else {
println!("MCP server '{name}' is not enabled; nothing to do.");
return Ok(());
}
}
}
};
if !enable && self.skill_registry.loaded_mcp_servers().contains(name) {
println!(
"Note: '{name}' is granted by a loaded skill and will keep running until that skill is unloaded."
);
}
let new_list = Some(new_list);
if !self.set_enabled_mcp_servers_on_role_like(new_list.clone()) {
self.update_app_config(|app| app.enabled_mcp_servers = new_list.clone());
}
if self.app.config.mcp_server_support {
let app = Arc::clone(&self.app.config);
self.bootstrap_tools(app.as_ref(), true, abort_signal)
.await?;
}
let verb = if enable { "Enabled" } else { "Disabled" };
println!("{verb} MCP server '{name}' ({layer}).");
Ok(())
}
pub fn set_save_session_on_session(&mut self, value: Option<bool>) -> bool {
match self.session.as_mut() {
Some(session) => {
@@ -1911,6 +2210,127 @@ impl RequestContext {
Ok(())
}
pub fn list_assets(&self, kind: &str) -> Result<()> {
match kind {
"roles" => print_asset_names("roles", &paths::list_roles(true)),
"sessions" => print_asset_names("sessions", &self.list_sessions()),
"rags" => print_asset_names("RAGs", &paths::list_rags()),
"macros" => print_asset_names("macros", &paths::list_macros()),
"agents" => {
let names = list_agents();
if names.is_empty() {
println!("No agents found.");
return Ok(());
}
println!("Agents:");
for name in names {
let description = AgentConfig::load(&paths::agent_config_file(&name))
.ok()
.map(|c| c.description)
.filter(|d| !d.is_empty());
match description {
Some(description) => println!("{name}{description}"),
None => println!("{name}"),
}
}
Ok(())
}
"skills" => {
let policy = SkillPolicy::effective(
&self.app.config,
self.role.as_ref(),
self.agent.as_ref(),
self.session.as_ref(),
)?;
if !policy.skills_enabled {
bail!("Skills are disabled in this context");
}
let visible_names: Vec<String> = match self.app.config.visible_skills.as_deref() {
Some(list) => list.to_vec(),
None => paths::list_skills(),
};
let mut entries = Vec::new();
for name in visible_names {
if !policy.compatible_enabled.contains(&name) {
continue;
}
let skill = match Skill::load(&name) {
Ok(s) => s,
Err(e) => {
warn!("Failed to open skill '{name}' for listing: {e}");
continue;
}
};
let loaded = self.skill_registry.is_loaded(skill.name());
entries.push((
skill.name().to_string(),
skill.description().to_string(),
loaded,
));
}
if entries.is_empty() {
println!("No skills found.");
return Ok(());
}
println!("Skills:");
for (name, description, loaded) in entries {
let marker = if loaded { " (loaded)" } else { "" };
println!("{name}{marker}{description}");
}
Ok(())
}
"tools" => {
let mut names: Vec<String> = self
.tool_scope
.functions
.declarations()
.iter()
.filter(|v| {
!v.name.starts_with("user__")
&& !v.name.starts_with("mcp_")
&& !v.name.starts_with("todo__")
&& !v.name.starts_with("agent__")
})
.map(|v| v.name.clone())
.collect();
names.extend(self.app.config.mapping_tools.keys().map(|v| v.to_string()));
names.sort_unstable();
names.dedup();
print_asset_names("tools", &names)
}
"mcp-servers" => {
let mut names: Vec<String> = vec![];
if let Some(mcp_config) = &self.app.mcp_config {
names.extend(mcp_config.mcp_servers.keys().map(|v| v.to_string()));
}
names.extend(
self.app
.config
.mapping_mcp_servers
.keys()
.map(|v| v.to_string()),
);
names.sort_unstable();
names.dedup();
print_asset_names("MCP servers", &names)
}
_ => bail!(
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers"
),
}
}
pub fn delete(&self, kind: &str) -> Result<()> {
let (dir, file_ext) = match kind {
"role" => (paths::roles_dir(), Some(".md")),
@@ -2327,7 +2747,16 @@ impl RequestContext {
}
}
".rag" => super::map_completion_values(paths::list_rags()),
".agent" => super::map_completion_values(list_agents()),
".agent" => list_agents()
.into_iter()
.map(|name| {
let description = AgentConfig::load(&paths::agent_config_file(&name))
.ok()
.map(|c| c.description)
.filter(|d| !d.is_empty());
(name, description)
})
.collect(),
".install" => {
let mut values: Vec<String> =
AssetCategory::NAMES.iter().map(|s| s.to_string()).collect();
@@ -2391,6 +2820,16 @@ impl RequestContext {
"skill",
"agent-data",
]),
".list" => super::map_completion_values(vec![
"roles",
"sessions",
"agents",
"rags",
"macros",
"skills",
"tools",
"mcp-servers",
]),
".vault" => {
let mut values = vec!["add", "get", "update", "delete", "list"];
values.sort_unstable();
@@ -2412,12 +2851,91 @@ impl RequestContext {
.collect(),
);
}
} else if cmd == ".mcp"
&& (args.first() == Some(&"enable") || args.first() == Some(&"disable"))
&& args.len() == 2
{
let current = if let Some(session) = &self.session {
session.enabled_mcp_servers()
} else if let Some(role) = &self.role {
role.enabled_mcp_servers()
} else {
self.app.config.enabled_mcp_servers.clone()
}
.unwrap_or_default();
let has_all = current.iter().any(|s| s.trim() == "all");
let candidates: Vec<String> = if args.first() == Some(&"enable") {
if has_all {
vec![]
} else {
let mut candidates: Vec<String> = vec![];
if let Some(mcp_config) = &self.app.mcp_config {
candidates.extend(mcp_config.mcp_servers.keys().cloned());
}
candidates.extend(self.app.config.mapping_mcp_servers.keys().cloned());
candidates.sort_unstable();
candidates.dedup();
candidates.retain(|v| !self.mcp_list_covers(&current, v));
candidates
}
} else if has_all {
self.app
.mcp_config
.as_ref()
.map(|c| c.mcp_servers.keys().cloned().collect())
.unwrap_or_default()
} else {
current
.iter()
.map(|s| s.trim().to_string())
.filter(|s| s != "all" && !s.is_empty())
.collect()
};
values = super::map_completion_values(candidates);
} else if cmd == ".tool"
&& (args.first() == Some(&"enable") || args.first() == Some(&"disable"))
&& args.len() == 2
{
let current = if let Some(session) = &self.session {
session.enabled_tools()
} else if let Some(role) = &self.role {
role.enabled_tools()
} else {
self.app.config.enabled_tools.clone()
}
.unwrap_or_default();
let has_all = current.iter().any(|s| s.trim() == "all");
let candidates: Vec<String> = if args.first() == Some(&"enable") {
if has_all {
vec![]
} else {
let mut candidates = self.concrete_tool_names();
candidates.extend(self.app.config.mapping_tools.keys().cloned());
candidates.sort_unstable();
candidates.dedup();
candidates.retain(|v| !self.tool_list_covers(&current, v));
candidates
}
} else if has_all {
self.concrete_tool_names()
} else {
current
.iter()
.map(|s| s.trim().to_string())
.filter(|s| s != "all" && !s.is_empty())
.collect()
};
values = super::map_completion_values(candidates);
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
{
values = super::map_completion_values(paths::list_skills());
values = complete_skills_with_descriptions(paths::list_skills());
} else if cmd == ".skill" && args.first() == Some(&"unload") && args.len() == 2 {
values = super::map_completion_values(self.skill_registry.loaded_names());
values = complete_skills_with_descriptions(self.skill_registry.loaded_names());
} else if cmd == ".install" && args.first() == Some(&"remote") && args.len() >= 2 {
let prev = args.get(args.len() - 2).copied().unwrap_or("");
if prev == "--filter" {
@@ -2643,18 +3161,25 @@ impl RequestContext {
let app_ref = &self.app;
let acquire_all = async {
let mut handles = Vec::new();
let mut auth_required = Vec::new();
for id in &server_ids {
if let Some(spec) = mcp_config.mcp_servers.get(id) {
let handle = app_ref
match app_ref
.mcp_factory
.acquire(id, spec, app_ref.mcp_log_path.as_deref())
.await?;
handles.push((id.clone(), handle));
.await
{
Ok(handle) => handles.push((id.clone(), handle)),
Err(e) if is_auth_required_error(&e) => {
auth_required.push(id.clone())
}
Err(e) => return Err(e),
}
}
}
Ok::<_, Error>(handles)
Ok::<_, Error>((handles, auth_required))
};
let handles = abortable_run_with_spinner(
let (handles, auth_required) = abortable_run_with_spinner(
acquire_all,
"Loading MCP servers",
abort_signal.clone(),
@@ -2663,6 +3188,12 @@ impl RequestContext {
for (id, handle) in handles {
mcp_runtime.insert(id, handle);
}
for id in auth_required {
eprintln!(
"Warning: MCP server '{id}' requires OAuth authentication and was not started. \
Run `.mcp auth {id}` (or `coyote --auth-mcp {id}`) to authenticate and attach it."
);
}
}
}
@@ -2763,9 +3294,18 @@ impl RequestContext {
);
}
}
let prev_role = self.role.clone();
let prev_session = self.session.clone();
self.use_role_obj(role)?;
self.rebuild_tool_scope(app, mcp_servers, abort_signal)
if let Err(e) = self
.rebuild_tool_scope(app, mcp_servers, abort_signal)
.await
{
self.role = prev_role;
self.session = prev_session;
return Err(e);
}
Ok(())
}
pub async fn use_session(
@@ -3793,6 +4333,14 @@ mod tests {
}
fn app_state_with_mcp_config(mcp_server_support: bool, server_names: &[&str]) -> Arc<AppState> {
app_state_with_mcp_command(mcp_server_support, server_names, "echo")
}
fn app_state_with_mcp_command(
mcp_server_support: bool,
server_names: &[&str],
command: &str,
) -> Arc<AppState> {
let app_config = AppConfig {
mcp_server_support,
..AppConfig::default()
@@ -3807,7 +4355,7 @@ mod tests {
name.to_string(),
McpServer {
transport_type: McpTransportType::Stdio,
command: Some("echo".to_string()),
command: Some(command.to_string()),
args: None,
env: None,
cwd: None,
@@ -3856,6 +4404,33 @@ mod tests {
assert!(ctx.tool_scope.mcp_runtime.is_empty());
}
#[test]
#[serial]
fn use_role_rolls_back_when_mcp_startup_fails() {
let _guard = TestConfigDirGuard::new();
let roles_dir = paths::roles_dir();
create_dir_all(&roles_dir).unwrap();
write(
roles_dir.join("broken_mcp.md"),
"---\nenabled_mcp_servers: failing\n---\nYou use MCP servers.",
)
.unwrap();
let app_state =
app_state_with_mcp_command(true, &["failing"], "/nonexistent/coyote-test-mcp-binary");
let mut ctx = RequestContext::new(app_state, WorkingMode::Cmd);
let app = ctx.app.config.clone();
let abort = utils::create_abort_signal();
let result = run_async(ctx.use_role(&app, "broken_mcp", abort));
assert!(result.is_err());
assert!(
ctx.role.is_none(),
"role must be rolled back when MCP startup fails"
);
}
#[test]
#[serial]
fn rebuild_tool_scope_no_enabled_servers_yields_empty_runtime() {
+25 -4
View File
@@ -33,7 +33,7 @@ async fn extract_via_extractor(
parent_ctx: &mut RequestContext,
is_repair: bool,
) -> Result<Value> {
let role = build_extractor_role()?;
let role = build_extractor_role(parent_ctx);
let prompt = build_extractor_prompt(raw, schema, is_repair);
let saved_role = parent_ctx.role.clone();
@@ -53,11 +53,12 @@ async fn extract_via_extractor(
}
}
fn build_extractor_role() -> Result<Role> {
fn build_extractor_role(ctx: &RequestContext) -> Role {
let mut role = Role::new(EXTRACTOR_ROLE_NAME, EXTRACTOR_ROLE_PROMPT);
role.set_model(ctx.current_model().clone());
role.set_enabled_tools(Some(Vec::new()));
role.set_enabled_mcp_servers(Some(Vec::new()));
Ok(role)
role
}
fn build_extractor_prompt(raw: &str, schema: &Value, is_repair: bool) -> String {
@@ -107,8 +108,14 @@ fn strip_code_fences(s: &str) -> &str {
#[cfg(test)]
mod tests {
use super::*;
use crate::client::Model;
use crate::config::{AppState, WorkingMode};
use serde_json::json;
fn make_ctx() -> RequestContext {
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
}
#[test]
fn try_parse_json_accepts_plain_object() {
let v = try_parse_json(r#"{"a": 1}"#).unwrap();
@@ -181,9 +188,23 @@ mod tests {
#[test]
fn build_extractor_role_disables_tools_and_mcp() {
let role = build_extractor_role().expect("builtin role must exist");
let ctx = make_ctx();
let role = build_extractor_role(&ctx);
assert_eq!(role.enabled_tools().as_deref(), Some([].as_slice()));
assert_eq!(role.enabled_mcp_servers().as_deref(), Some([].as_slice()));
}
#[test]
fn build_extractor_role_uses_parent_context_model() {
let mut ctx = make_ctx();
let mut parent_role = Role::new("parent", "parent prompt");
parent_role.set_model(Model::new("client-x", "model-y"));
ctx.role = Some(parent_role);
let role = build_extractor_role(&ctx);
assert_eq!(role.model().id(), "client-x:model-y");
}
}
+10
View File
@@ -1061,4 +1061,14 @@ mod tests {
assert!(!is_auth_required_error(&e));
}
#[test]
fn is_auth_required_error_survives_context_wrapping() {
let e = anyhow!("Auth required, when send initialize request").context(
"MCP server 'github' requires OAuth authentication. \
Run `coyote --auth-mcp github` or `.mcp auth github` in the REPL to authenticate.",
);
assert!(is_auth_required_error(&e));
}
}
+201 -28
View File
@@ -14,6 +14,8 @@ use url::Url;
struct ProtectedResourceMetadata {
#[serde(default)]
authorization_servers: Vec<String>,
#[serde(default)]
scopes_supported: Vec<String>,
}
#[derive(Debug, Deserialize)]
@@ -187,46 +189,122 @@ async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
}
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
let base = extract_base_url(server_url)?;
let client = Client::new();
let mut tried: Vec<String> = Vec::new();
// RFC 9728: try protected resource metadata first; it points to the auth server
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
if let Ok(resp) = client.get(&pr_url).send().await
&& resp.status().is_success()
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
&& let Some(auth_server) = pr.authorization_servers.first()
{
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
if let Ok(resp) = client.get(&as_url).send().await
&& resp.status().is_success()
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
{
return Ok(meta);
// RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose
// WWW-Authenticate challenge advertises the protected resource metadata URL.
let mut pr_urls = Vec::new();
if let Some(url) = probe_resource_metadata_url(&client, server_url).await {
pr_urls.push(url);
}
// RFC 9728 @ 3.1: path-aware well-known URL, then root as legacy fallback.
pr_urls.extend(well_known_urls(server_url, "oauth-protected-resource")?);
pr_urls.dedup();
for pr_url in &pr_urls {
tried.push(pr_url.clone());
let Ok(resp) = client.get(pr_url).send().await else {
continue;
};
if !resp.status().is_success() {
continue;
}
let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await else {
continue;
};
let Some(issuer) = pr.authorization_servers.first() else {
continue;
};
// RFC 8414 @ 3.1: for issuers with a path component the well-known
// segment is inserted BEFORE the path (with the legacy appended form
// and root as fallbacks).
for as_url in well_known_urls(issuer, "oauth-authorization-server")? {
tried.push(as_url.clone());
if let Ok(resp) = client.get(&as_url).send().await
&& resp.status().is_success()
&& let Ok(mut meta) = resp.json::<OAuthServerMetadata>().await
{
// Some auth servers (e.g. GitHub) omit scopes_supported from
// their metadata; fall back to the resource's advertised scopes.
if meta.scopes_supported.is_empty() {
meta.scopes_supported = pr.scopes_supported.clone();
}
return Ok(meta);
}
}
}
let as_url = format!("{base}/.well-known/oauth-authorization-server");
let resp = client
.get(&as_url)
.send()
.await
.with_context(|| format!("Failed to reach {as_url}"))?;
if resp.status().is_success() {
return resp
.json::<OAuthServerMetadata>()
.await
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
// Last resort: the MCP server itself may host authorization server metadata.
for as_url in well_known_urls(server_url, "oauth-authorization-server")? {
tried.push(as_url.clone());
if let Ok(resp) = client.get(&as_url).send().await
&& resp.status().is_success()
{
return resp
.json::<OAuthServerMetadata>()
.await
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
}
}
Err(anyhow!(
"Could not discover OAuth metadata for '{server_url}'.\n\
Tried:\n {pr_url}\n {as_url}\n\
Ensure the server supports MCP OAuth discovery, or consult its documentation."
Tried:\n {}\n\
Ensure the server supports MCP OAuth discovery, or consult its documentation.",
tried.join("\n ")
))
}
/// Probes the MCP server with an unauthenticated request and extracts the
/// `resource_metadata` URL from the 401 `WWW-Authenticate` challenge (RFC 9728 @ 5.1).
async fn probe_resource_metadata_url(client: &Client, server_url: &str) -> Option<String> {
let resp = client.get(server_url).send().await.ok()?;
let header = resp.headers().get(reqwest::header::WWW_AUTHENTICATE)?;
parse_resource_metadata(header.to_str().ok()?)
}
/// Extracts the `resource_metadata` parameter value from a `WWW-Authenticate`
/// challenge, e.g. `Bearer error="...", resource_metadata="https://..."`.
fn parse_resource_metadata(challenge: &str) -> Option<String> {
let (_, rest) = challenge.split_once("resource_metadata=")?;
let rest = rest.trim_start();
let value = if let Some(stripped) = rest.strip_prefix('"') {
stripped.split('"').next()?
} else {
rest.split([',', ' ']).next()?
};
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
/// Builds candidate well-known metadata URLs for `url`, ordered by spec preference:
/// 1. Path-aware (RFC 8414 @ 3.1 / RFC 9728 @ 3.1): `{origin}/.well-known/{suffix}{path}`
/// 2. Legacy appended form: `{url}/.well-known/{suffix}`
/// 3. Root: `{origin}/.well-known/{suffix}`
///
/// URLs without a path component yield only the root form.
fn well_known_urls(url: &str, suffix: &str) -> Result<Vec<String>> {
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
let origin = extract_base_url(url)?;
let path = parsed.path().trim_end_matches('/');
let mut urls = Vec::new();
if !path.is_empty() && path != "/" {
urls.push(format!("{origin}/.well-known/{suffix}{path}"));
urls.push(format!("{origin}{path}/.well-known/{suffix}"));
}
urls.push(format!("{origin}/.well-known/{suffix}"));
Ok(urls)
}
fn extract_base_url(url: &str) -> Result<String> {
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
let scheme = parsed.scheme();
@@ -296,6 +374,101 @@ mod tests {
assert!(extract_base_url("not-a-url").is_err());
}
#[test]
fn well_known_urls_path_aware_first_for_url_with_path() {
let urls = well_known_urls(
"https://api.githubcopilot.com/mcp",
"oauth-protected-resource",
)
.unwrap();
assert_eq!(
urls,
vec![
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp",
"https://api.githubcopilot.com/mcp/.well-known/oauth-protected-resource",
"https://api.githubcopilot.com/.well-known/oauth-protected-resource",
]
);
}
#[test]
fn well_known_urls_inserts_before_issuer_path() {
let urls = well_known_urls(
"https://github.com/login/oauth",
"oauth-authorization-server",
)
.unwrap();
assert_eq!(
urls[0],
"https://github.com/.well-known/oauth-authorization-server/login/oauth"
);
}
#[test]
fn well_known_urls_root_only_for_url_without_path() {
let urls = well_known_urls("https://mcp.notion.com", "oauth-authorization-server").unwrap();
assert_eq!(
urls,
vec!["https://mcp.notion.com/.well-known/oauth-authorization-server"]
);
}
#[test]
fn well_known_urls_ignores_trailing_slash() {
let urls = well_known_urls(
"https://api.githubcopilot.com/mcp/",
"oauth-protected-resource",
)
.unwrap();
assert_eq!(
urls[0],
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
);
}
#[test]
fn parse_resource_metadata_extracts_quoted_url() {
let challenge = r#"Bearer error="invalid_request", error_description="No access token was provided in this request", resource_metadata="https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp""#;
let url = parse_resource_metadata(challenge);
assert_eq!(
url,
Some(
"https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp"
.to_string()
)
);
}
#[test]
fn parse_resource_metadata_extracts_unquoted_url() {
let challenge = "Bearer resource_metadata=https://example.com/.well-known/oauth-protected-resource/mcp, error=\"invalid_token\"";
let url = parse_resource_metadata(challenge);
assert_eq!(
url,
Some("https://example.com/.well-known/oauth-protected-resource/mcp".to_string())
);
}
#[test]
fn parse_resource_metadata_returns_none_when_absent() {
assert_eq!(
parse_resource_metadata(r#"Bearer error="invalid_token""#),
None
);
assert_eq!(
parse_resource_metadata(r#"Bearer resource_metadata="""#),
None
);
}
#[test]
#[serial]
fn registered_client_id_roundtrip() {
+94 -4
View File
@@ -52,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
4. Continue with the next pending item now. Call tools immediately."
};
static REPL_COMMANDS: LazyLock<[ReplCommand; 52]> = LazyLock::new(|| {
static REPL_COMMANDS: LazyLock<[ReplCommand; 57]> = LazyLock::new(|| {
[
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -71,6 +71,26 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 52]> = LazyLock::new(|| {
"Authenticate with an MCP server via OAuth",
AssertState::pass(),
),
ReplCommand::new(
".mcp enable",
"Enable a single MCP server in the current context",
AssertState::pass(),
),
ReplCommand::new(
".mcp disable",
"Disable a single MCP server in the current context",
AssertState::pass(),
),
ReplCommand::new(
".tool enable",
"Enable a single tool in the current context",
AssertState::True(StateFlags::FUNCTION_CALLING),
),
ReplCommand::new(
".tool disable",
"Disable a single tool in the current context",
AssertState::True(StateFlags::FUNCTION_CALLING),
),
ReplCommand::new(
".edit config",
"Modify configuration file",
@@ -269,6 +289,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 52]> = LazyLock::new(|| {
"Delete roles, sessions, RAGs, or agents",
AssertState::pass(),
),
ReplCommand::new(
".list",
"List roles, sessions, agents, RAGs, macros, skills, tools, or MCP servers",
AssertState::pass(),
),
ReplCommand::new(
".vault",
"View or modify the Coyote vault",
@@ -673,14 +698,69 @@ pub async fn run_repl_command(
)
.await?;
println!("Authentication saved.");
if ctx.app.config.mcp_server_support {
let app = Arc::clone(&ctx.app.config);
ctx.bootstrap_tools(
app.as_ref(),
true,
abort_signal.clone(),
)
.await?;
if ctx.tool_scope.mcp_runtime.get(server_name).is_some()
{
println!(
"✓ MCP server '{server_name}' started and attached to the current context."
);
} else {
println!(
"MCP server '{server_name}' is not enabled in the current context. \
Run `.mcp enable {server_name}` to attach it."
);
}
}
}
}
}
}
"enable" | "disable" => {
if rest.is_empty() {
println!("Usage: .mcp {sub} <server_name>");
} else {
ctx.toggle_mcp_server(sub, rest, abort_signal.clone())
.await?;
}
}
_ => unknown_command()?,
}
}
None => println!("Usage: .mcp auth <server_name>"),
None => println!(
r#"Usage:
.mcp auth <server_name> # Authenticate with an MCP server via OAuth
.mcp enable <server_name> # Enable a single MCP server in the current context
.mcp disable <server_name> # Disable a single MCP server in the current context"#
),
},
".tool" => match args {
Some(args) => {
let mut parts = args.splitn(2, char::is_whitespace);
let sub = parts.next().unwrap_or("").trim();
let rest = parts.next().map(str::trim).unwrap_or("");
match sub {
"enable" | "disable" => {
if rest.is_empty() {
println!("Usage: .tool {sub} <name>");
} else {
ctx.toggle_tool(sub, rest)?;
}
}
_ => unknown_command()?,
}
}
None => println!(
r#"Usage:
.tool enable <name> # Enable a single tool in the current context
.tool disable <name> # Disable a single tool in the current context"#
),
},
".prompt" => match args {
Some(text) => {
@@ -1092,6 +1172,16 @@ pub async fn run_repl_command(
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
}
},
".list" => match args {
Some(args) => {
ctx.list_assets(args.trim())?;
}
_ => {
println!(
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers>"
)
}
},
".copy" => {
let output = match ctx
.last_message
@@ -1614,8 +1704,8 @@ mod tests {
}
#[test]
fn repl_commands_has_52_entries() {
assert_eq!(REPL_COMMANDS.len(), 52);
fn repl_commands_has_57_entries() {
assert_eq!(REPL_COMMANDS.len(), 57);
}
#[test]