Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd1e59815
|
||
|
|
f5085a773a
|
||
|
|
09afdeaf7c
|
||
|
|
0216d84eee
|
||
|
|
320dbf2479
|
@@ -3,7 +3,7 @@ use crate::mcp::{
|
|||||||
spawn_mcp_server,
|
spawn_mcp_server,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::Result;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -111,10 +111,10 @@ impl McpFactory {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if is_auth_required_error(&e) {
|
if is_auth_required_error(&e) {
|
||||||
anyhow!(
|
e.context(format!(
|
||||||
"MCP server '{name}' requires OAuth authentication. \
|
"MCP server '{name}' requires OAuth authentication. \
|
||||||
Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate."
|
Run `coyote --auth-mcp {name}` or `.mcp auth {name}` in the REPL to authenticate."
|
||||||
)
|
))
|
||||||
} else {
|
} else {
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
|
|||||||
+586
-11
@@ -1,3 +1,4 @@
|
|||||||
|
use super::agent::AgentConfig;
|
||||||
use super::rag_cache::{RagCache, RagKey};
|
use super::rag_cache::{RagCache, RagKey};
|
||||||
use super::session::Session;
|
use super::session::Session;
|
||||||
use super::skill::{SKILL_SCAFFOLD, Skill};
|
use super::skill::{SKILL_SCAFFOLD, Skill};
|
||||||
@@ -20,7 +21,7 @@ use crate::function::{
|
|||||||
};
|
};
|
||||||
use crate::mcp::{
|
use crate::mcp::{
|
||||||
MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, MCP_INVOKE_META_FUNCTION_NAME_PREFIX,
|
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::rag::Rag;
|
||||||
use crate::supervisor::Supervisor;
|
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()
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum RenderMode {
|
pub enum RenderMode {
|
||||||
#[default]
|
#[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 {
|
pub fn set_save_session_on_session(&mut self, value: Option<bool>) -> bool {
|
||||||
match self.session.as_mut() {
|
match self.session.as_mut() {
|
||||||
Some(session) => {
|
Some(session) => {
|
||||||
@@ -1911,6 +2210,127 @@ impl RequestContext {
|
|||||||
Ok(())
|
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<()> {
|
pub fn delete(&self, kind: &str) -> Result<()> {
|
||||||
let (dir, file_ext) = match kind {
|
let (dir, file_ext) = match kind {
|
||||||
"role" => (paths::roles_dir(), Some(".md")),
|
"role" => (paths::roles_dir(), Some(".md")),
|
||||||
@@ -2327,7 +2747,16 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
".rag" => super::map_completion_values(paths::list_rags()),
|
".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" => {
|
".install" => {
|
||||||
let mut values: Vec<String> =
|
let mut values: Vec<String> =
|
||||||
AssetCategory::NAMES.iter().map(|s| s.to_string()).collect();
|
AssetCategory::NAMES.iter().map(|s| s.to_string()).collect();
|
||||||
@@ -2391,6 +2820,16 @@ impl RequestContext {
|
|||||||
"skill",
|
"skill",
|
||||||
"agent-data",
|
"agent-data",
|
||||||
]),
|
]),
|
||||||
|
".list" => super::map_completion_values(vec![
|
||||||
|
"roles",
|
||||||
|
"sessions",
|
||||||
|
"agents",
|
||||||
|
"rags",
|
||||||
|
"macros",
|
||||||
|
"skills",
|
||||||
|
"tools",
|
||||||
|
"mcp-servers",
|
||||||
|
]),
|
||||||
".vault" => {
|
".vault" => {
|
||||||
let mut values = vec!["add", "get", "update", "delete", "list"];
|
let mut values = vec!["add", "get", "update", "delete", "list"];
|
||||||
values.sort_unstable();
|
values.sort_unstable();
|
||||||
@@ -2412,12 +2851,91 @@ impl RequestContext {
|
|||||||
.collect(),
|
.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(¤t, 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(¤t, 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)
|
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|
||||||
|| (cmd == ".skill" && args.first() == Some(&"load") && 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 {
|
} 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 {
|
} else if cmd == ".install" && args.first() == Some(&"remote") && args.len() >= 2 {
|
||||||
let prev = args.get(args.len() - 2).copied().unwrap_or("");
|
let prev = args.get(args.len() - 2).copied().unwrap_or("");
|
||||||
if prev == "--filter" {
|
if prev == "--filter" {
|
||||||
@@ -2643,18 +3161,25 @@ impl RequestContext {
|
|||||||
let app_ref = &self.app;
|
let app_ref = &self.app;
|
||||||
let acquire_all = async {
|
let acquire_all = async {
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
|
let mut auth_required = Vec::new();
|
||||||
for id in &server_ids {
|
for id in &server_ids {
|
||||||
if let Some(spec) = mcp_config.mcp_servers.get(id) {
|
if let Some(spec) = mcp_config.mcp_servers.get(id) {
|
||||||
let handle = app_ref
|
match app_ref
|
||||||
.mcp_factory
|
.mcp_factory
|
||||||
.acquire(id, spec, app_ref.mcp_log_path.as_deref())
|
.acquire(id, spec, app_ref.mcp_log_path.as_deref())
|
||||||
.await?;
|
.await
|
||||||
handles.push((id.clone(), handle));
|
{
|
||||||
|
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,
|
acquire_all,
|
||||||
"Loading MCP servers",
|
"Loading MCP servers",
|
||||||
abort_signal.clone(),
|
abort_signal.clone(),
|
||||||
@@ -2663,6 +3188,12 @@ impl RequestContext {
|
|||||||
for (id, handle) in handles {
|
for (id, handle) in handles {
|
||||||
mcp_runtime.insert(id, handle);
|
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.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
|
.await
|
||||||
|
{
|
||||||
|
self.role = prev_role;
|
||||||
|
self.session = prev_session;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn use_session(
|
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> {
|
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 {
|
let app_config = AppConfig {
|
||||||
mcp_server_support,
|
mcp_server_support,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
@@ -3807,7 +4355,7 @@ mod tests {
|
|||||||
name.to_string(),
|
name.to_string(),
|
||||||
McpServer {
|
McpServer {
|
||||||
transport_type: McpTransportType::Stdio,
|
transport_type: McpTransportType::Stdio,
|
||||||
command: Some("echo".to_string()),
|
command: Some(command.to_string()),
|
||||||
args: None,
|
args: None,
|
||||||
env: None,
|
env: None,
|
||||||
cwd: None,
|
cwd: None,
|
||||||
@@ -3856,6 +4404,33 @@ mod tests {
|
|||||||
assert!(ctx.tool_scope.mcp_runtime.is_empty());
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn rebuild_tool_scope_no_enabled_servers_yields_empty_runtime() {
|
fn rebuild_tool_scope_no_enabled_servers_yields_empty_runtime() {
|
||||||
|
|||||||
+25
-4
@@ -33,7 +33,7 @@ async fn extract_via_extractor(
|
|||||||
parent_ctx: &mut RequestContext,
|
parent_ctx: &mut RequestContext,
|
||||||
is_repair: bool,
|
is_repair: bool,
|
||||||
) -> Result<Value> {
|
) -> Result<Value> {
|
||||||
let role = build_extractor_role()?;
|
let role = build_extractor_role(parent_ctx);
|
||||||
let prompt = build_extractor_prompt(raw, schema, is_repair);
|
let prompt = build_extractor_prompt(raw, schema, is_repair);
|
||||||
|
|
||||||
let saved_role = parent_ctx.role.clone();
|
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);
|
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_tools(Some(Vec::new()));
|
||||||
role.set_enabled_mcp_servers(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 {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::client::Model;
|
||||||
|
use crate::config::{AppState, WorkingMode};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn make_ctx() -> RequestContext {
|
||||||
|
RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn try_parse_json_accepts_plain_object() {
|
fn try_parse_json_accepts_plain_object() {
|
||||||
let v = try_parse_json(r#"{"a": 1}"#).unwrap();
|
let v = try_parse_json(r#"{"a": 1}"#).unwrap();
|
||||||
@@ -181,9 +188,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_extractor_role_disables_tools_and_mcp() {
|
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_tools().as_deref(), Some([].as_slice()));
|
||||||
assert_eq!(role.enabled_mcp_servers().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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1061,4 +1061,14 @@ mod tests {
|
|||||||
|
|
||||||
assert!(!is_auth_required_error(&e));
|
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
@@ -14,6 +14,8 @@ use url::Url;
|
|||||||
struct ProtectedResourceMetadata {
|
struct ProtectedResourceMetadata {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
authorization_servers: Vec<String>,
|
authorization_servers: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
scopes_supported: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[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> {
|
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
|
||||||
let base = extract_base_url(server_url)?;
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
let mut tried: Vec<String> = Vec::new();
|
||||||
|
|
||||||
// RFC 9728: try protected resource metadata first; it points to the auth server
|
// RFC 9728 @ 5.1: an unauthenticated request should yield a 401 whose
|
||||||
let pr_url = format!("{base}/.well-known/oauth-protected-resource");
|
// WWW-Authenticate challenge advertises the protected resource metadata URL.
|
||||||
if let Ok(resp) = client.get(&pr_url).send().await
|
let mut pr_urls = Vec::new();
|
||||||
&& resp.status().is_success()
|
if let Some(url) = probe_resource_metadata_url(&client, server_url).await {
|
||||||
&& let Ok(pr) = resp.json::<ProtectedResourceMetadata>().await
|
pr_urls.push(url);
|
||||||
&& let Some(auth_server) = pr.authorization_servers.first()
|
}
|
||||||
{
|
|
||||||
let as_url = format!("{auth_server}/.well-known/oauth-authorization-server");
|
// RFC 9728 @ 3.1: path-aware well-known URL, then root as legacy fallback.
|
||||||
if let Ok(resp) = client.get(&as_url).send().await
|
pr_urls.extend(well_known_urls(server_url, "oauth-protected-resource")?);
|
||||||
&& resp.status().is_success()
|
pr_urls.dedup();
|
||||||
&& let Ok(meta) = resp.json::<OAuthServerMetadata>().await
|
|
||||||
{
|
for pr_url in &pr_urls {
|
||||||
return Ok(meta);
|
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");
|
// Last resort: the MCP server itself may host authorization server metadata.
|
||||||
let resp = client
|
for as_url in well_known_urls(server_url, "oauth-authorization-server")? {
|
||||||
.get(&as_url)
|
tried.push(as_url.clone());
|
||||||
.send()
|
if let Ok(resp) = client.get(&as_url).send().await
|
||||||
.await
|
&& resp.status().is_success()
|
||||||
.with_context(|| format!("Failed to reach {as_url}"))?;
|
{
|
||||||
|
return resp
|
||||||
if resp.status().is_success() {
|
.json::<OAuthServerMetadata>()
|
||||||
return resp
|
.await
|
||||||
.json::<OAuthServerMetadata>()
|
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
||||||
.await
|
}
|
||||||
.with_context(|| format!("Failed to parse OAuth metadata from {as_url}"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"Could not discover OAuth metadata for '{server_url}'.\n\
|
"Could not discover OAuth metadata for '{server_url}'.\n\
|
||||||
Tried:\n {pr_url}\n {as_url}\n\
|
Tried:\n {}\n\
|
||||||
Ensure the server supports MCP OAuth discovery, or consult its documentation."
|
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> {
|
fn extract_base_url(url: &str) -> Result<String> {
|
||||||
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
|
||||||
let scheme = parsed.scheme();
|
let scheme = parsed.scheme();
|
||||||
@@ -296,6 +374,101 @@ mod tests {
|
|||||||
assert!(extract_base_url("not-a-url").is_err());
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn registered_client_id_roundtrip() {
|
fn registered_client_id_roundtrip() {
|
||||||
|
|||||||
+94
-4
@@ -52,7 +52,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
|
|||||||
4. Continue with the next pending item now. Call tools immediately."
|
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(".help", "Show this help guide", AssertState::pass()),
|
||||||
ReplCommand::new(".info", "Show system info", 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",
|
"Authenticate with an MCP server via OAuth",
|
||||||
AssertState::pass(),
|
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(
|
ReplCommand::new(
|
||||||
".edit config",
|
".edit config",
|
||||||
"Modify configuration file",
|
"Modify configuration file",
|
||||||
@@ -269,6 +289,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 52]> = LazyLock::new(|| {
|
|||||||
"Delete roles, sessions, RAGs, or agents",
|
"Delete roles, sessions, RAGs, or agents",
|
||||||
AssertState::pass(),
|
AssertState::pass(),
|
||||||
),
|
),
|
||||||
|
ReplCommand::new(
|
||||||
|
".list",
|
||||||
|
"List roles, sessions, agents, RAGs, macros, skills, tools, or MCP servers",
|
||||||
|
AssertState::pass(),
|
||||||
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".vault",
|
".vault",
|
||||||
"View or modify the Coyote vault",
|
"View or modify the Coyote vault",
|
||||||
@@ -673,14 +698,69 @@ pub async fn run_repl_command(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
println!("Authentication saved.");
|
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()?,
|
_ => 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 {
|
".prompt" => match args {
|
||||||
Some(text) => {
|
Some(text) => {
|
||||||
@@ -1092,6 +1172,16 @@ pub async fn run_repl_command(
|
|||||||
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
|
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" => {
|
".copy" => {
|
||||||
let output = match ctx
|
let output = match ctx
|
||||||
.last_message
|
.last_message
|
||||||
@@ -1614,8 +1704,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_has_52_entries() {
|
fn repl_commands_has_57_entries() {
|
||||||
assert_eq!(REPL_COMMANDS.len(), 52);
|
assert_eq!(REPL_COMMANDS.len(), 57);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user