16 Commits

Author SHA1 Message Date
Dark-Alex-17 2ec2aec4c0 style: updated the previous conversation marker a tad
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 26s
2026-07-02 16:49:38 -06:00
Dark-Alex-17 c2cb4ac433 feat: Session-specific, file-backed history in the REPL
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 25s
2026-07-02 16:44:55 -06:00
Dark-Alex-17 605a9170b0 feat: Replay session output when a user re-enters a session so all output can be seen again 2026-07-02 16:35:10 -06:00
Dark-Alex-17 385bd3eda2 fix: Overrode the default JSON content-type for MCP OAuth so its properly application/x-www-form-urlencoded
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 26s
2026-07-02 15:53:29 -06:00
Dark-Alex-17 6c3d96ac83 feat: Added confirmation message after MCP Oauth succeeds when invoked from --auth-mcp
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 26s
2026-07-02 15:22:22 -06:00
Dark-Alex-17 aa1fe7f7aa fmt: applied formatting 2026-07-02 15:22:00 -06:00
Dark-Alex-17 5e50828108 fix: typo in mcp file name 2026-07-02 15:20:57 -06:00
Dark-Alex-17 693e2d9672 feat: Created the --auth-mcp CLI flag to let users auth with remote MCP servers without needing to be in the REPL
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 26s
2026-07-02 14:51:52 -06:00
Dark-Alex-17 16f324cefc feat: add OAuth authentication support for remote MCP servers 2026-07-02 14:43:24 -06:00
Dark-Alex-17 cc50d39ab4 fix: Added uvx wrapper for macos-based sandboxes
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 28s
2026-07-02 12:57:12 -06:00
Dark-Alex-17 fc23b532d9 feat: Added mixin for sisyphus so the ddg MCP server can search arbitrary domains 2026-07-02 12:56:18 -06:00
Dark-Alex-17 c2d4240138 perf: updated the memory injection warning so it only logs once, rather than after each keystroke
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 27s
2026-07-02 12:47:57 -06:00
Dark-Alex-17 cd1b043b1e feat: added improved error messaging on MCP server initialization
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 26s
2026-07-02 11:42:12 -06:00
Dark-Alex-17 81b4f6a76e feat: prefer musl versions for linux when running --update/.update
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 27s
2026-07-02 11:31:34 -06:00
github-actions[bot] d48b11dcfa chore: bump Cargo.toml to 0.7.4
CI / All (ubuntu-latest) (push) Failing after 24s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-02 01:06:00 +00:00
github-actions[bot] 86dd922d2c bump: version 0.7.3 → 0.7.4 [skip ci] 2026-07-02 01:05:42 +00:00
19 changed files with 944 additions and 121 deletions
+17
View File
@@ -1,3 +1,20 @@
## v0.7.4 (2026-07-02)
### Feat
- Pin specific usql version to sbx kit
- recursively take ownership over the copied in coyote config for the sbx
- explicitly specify the COYOTE_CONFIG_DIR in the sbx kit
- --tail-logs can track log rollovers and incoporates a sleep timer to minimize idle CPU cycles
- Added support for log rolling so log files don't just blow up over time
### Fix
- Added back in --kit specification for the running of the sbx
- sbx isn't copying base files in their respective directories
- Update deprecated sbx kit config
- Properly chown the coyote config recursively and password file in the sbx
## v0.7.3 (2026-06-24)
### Fix
Generated
+54 -60
View File
@@ -141,9 +141,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
@@ -174,9 +174,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.1"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
dependencies = [
"rustversion",
]
@@ -321,11 +321,11 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
dependencies = [
"aws-lc-sys 0.41.0",
"aws-lc-sys 0.42.0",
"zeroize",
]
@@ -344,14 +344,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -1193,14 +1194,14 @@ dependencies = [
[[package]]
name = "clap_complete"
version = "4.6.5"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b"
dependencies = [
"clap",
"clap_lex",
"is_executable",
"shlex 1.3.0",
"shlex 2.0.1",
]
[[package]]
@@ -1321,9 +1322,9 @@ dependencies = [
[[package]]
name = "console"
version = "0.16.3"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
@@ -1408,7 +1409,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "coyote-ai"
version = "0.7.3"
version = "0.7.4"
dependencies = [
"ansi_colours",
"anyhow",
@@ -2045,9 +2046,9 @@ dependencies = [
[[package]]
name = "env_filter"
version = "1.0.1"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
dependencies = [
"log",
"regex",
@@ -2055,9 +2056,9 @@ dependencies = [
[[package]]
name = "env_logger"
version = "0.11.10"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
@@ -2775,9 +2776,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hybrid-array"
version = "0.4.12"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
@@ -3069,9 +3070,9 @@ dependencies = [
[[package]]
name = "indicatif"
version = "0.18.4"
version = "0.18.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
dependencies = [
"console",
"portable-atomic",
@@ -3189,9 +3190,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.29"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46"
checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634"
dependencies = [
"defmt",
"jiff-static",
@@ -3203,9 +3204,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.29"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f"
checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2"
dependencies = [
"proc-macro2",
"quote",
@@ -3273,9 +3274,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.102"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
@@ -3345,9 +3346,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.17"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
@@ -3940,13 +3941,12 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.5"
version = "5.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
@@ -4112,12 +4112,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.6"
@@ -4986,9 +4980,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"web-time",
"zeroize",
@@ -6265,9 +6259,9 @@ dependencies = [
[[package]]
name = "tree-sitter"
version = "0.26.9"
version = "0.26.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3"
checksum = "3c343ed63e3f5c64d1acdecb5d2c13d4e169cb5fde0052106ebaa6c6f27f9e55"
dependencies = [
"cc",
"regex",
@@ -6543,9 +6537,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.3"
version = "1.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -6645,9 +6639,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -6658,9 +6652,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.75"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6668,9 +6662,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -6678,9 +6672,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -6691,9 +6685,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.125"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
@@ -6796,9 +6790,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.102"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -7450,9 +7444,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.6.4"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3"
checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5"
[[package]]
name = "zmij"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "coyote-ai"
version = "0.7.3"
version = "0.7.4"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "An all-in-one, batteries included LLM CLI Tool"
+11
View File
@@ -0,0 +1,11 @@
schemaVersion: '1'
kind: mixin
name: sisyphus-ddg
description: >
Allows Sisyphus to hit all domains since it utilizes the DuckDuckGo
MCP server. This allows the MCP server to actually perform web searches
on arbitrary domains and retrieve info for the agent.
network:
allowedDomains:
- '*'
+7 -2
View File
@@ -252,9 +252,14 @@ commands:
bzip2
user: '1000'
description: Install system prerequisites (including pandoc for fetch_url_via_curl)
- command: 'curl -LsSf https://astral.sh/uv/install.sh | sh'
- command: |
curl -LsSf https://astral.sh/uv/install.sh | sh
if [ -f "$HOME/.local/bin/uv" ]; then
printf '#!/bin/sh\nexec uv tool run "$@"\n' > "$HOME/.local/bin/uvx"
chmod +x "$HOME/.local/bin/uvx"
fi
user: '1000'
description: Install uv (required for Python-based custom tools)
description: Install uv and write a uvx shell wrapper (the installer may place a macOS binary at this path on Docker-for-Mac hosts, which the Linux container cannot execute)
- command: |
set -euo pipefail
USQL_VERSION=0.21.4
+29 -1
View File
@@ -5,9 +5,9 @@ use crate::utils::list_file_names;
use crate::vault::Vault;
use clap_complete::{CompletionCandidate, Shell, generate};
use clap_complete_nushell::Nushell;
use std::env;
use std::ffi::OsStr;
use std::io;
use std::{env, fs};
const COYOTE_CLI_NAME: &str = "coyote";
@@ -134,6 +134,34 @@ pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
.collect()
}
pub(super) fn mcp_server_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
let content = match fs::read_to_string(paths::mcp_config_file()) {
Ok(c) => c,
Err(_) => return vec![],
};
let json: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return vec![],
};
let servers = match json.get("mcpServers").and_then(|v| v.as_object()) {
Some(s) => s,
None => return vec![],
};
servers
.iter()
.filter(|(_, v)| {
v.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "http" || t == "sse")
.unwrap_or(false)
})
.filter(|(k, _)| k.starts_with(&*cur))
.map(|(k, _)| CompletionCandidate::new(k))
.collect()
}
pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_app_config_for_completion() {
+5 -2
View File
@@ -1,8 +1,8 @@
mod completer;
use crate::cli::completer::{
ShellCompletion, agent_completer, macro_completer, model_completer, rag_completer,
role_completer, secrets_completer, session_completer,
ShellCompletion, agent_completer, macro_completer, mcp_server_completer, model_completer,
rag_completer, role_completer, secrets_completer, session_completer,
};
use crate::config::{AssetCategory, InstallFilter, MemoryScope};
use anyhow::{Context, Result};
@@ -171,6 +171,9 @@ pub struct Cli {
/// Authenticate with an LLM provider using OAuth (e.g., --authenticate client_name)
#[arg(long, exclusive = true, value_name = "CLIENT_NAME")]
pub authenticate: Option<Option<String>>,
/// Authenticate with an OAuth-protected remote MCP server (e.g., --auth-mcp server_name)
#[arg(long, exclusive = true, value_name = "SERVER_NAME", add = ArgValueCompleter::new(mcp_server_completer))]
pub auth_mcp: Option<String>,
/// Generate static shell completion scripts
#[arg(long, value_name = "SHELL", value_enum)]
pub completions: Option<ShellCompletion>,
+7
View File
@@ -133,6 +133,13 @@ impl MessageContent {
}
}
pub fn as_text(&self) -> Option<&str> {
match self {
MessageContent::Text(text) => Some(text),
_ => None,
}
}
pub fn merge_prompt(&mut self, replace_fn: impl Fn(&str) -> String) {
match self {
MessageContent::Text(text) => *text = replace_fn(text),
+10 -4
View File
@@ -53,6 +53,10 @@ pub trait OAuthProvider: Send + Sync {
fn extra_request_headers(&self) -> Vec<(&str, &str)> {
vec![]
}
fn fixed_redirect_uri(&self) -> Option<String> {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -72,14 +76,16 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
let state = Uuid::new_v4().to_string();
let redirect_uri = if provider.uses_localhost_redirect() {
let (redirect_uri, use_callback_listener) = if let Some(fixed) = provider.fixed_redirect_uri() {
(fixed, true)
} else if provider.uses_localhost_redirect() {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let uri = format!("http://127.0.0.1:{port}/callback");
drop(listener);
uri
(uri, true)
} else {
provider.redirect_uri().to_string()
(provider.redirect_uri().to_string(), false)
};
let encoded_scopes = urlencoding::encode(provider.scopes());
@@ -112,7 +118,7 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
let _ = open::that(&authorize_url);
let (code, returned_state) = if provider.uses_localhost_redirect() {
let (code, returned_state) = if use_callback_listener {
listen_for_oauth_callback(&redirect_uri)?
} else {
let input = Text::new("Paste the authorization code:").prompt()?;
+11 -2
View File
@@ -1,4 +1,6 @@
use crate::mcp::{ConnectedServer, JsonField, McpServer, McpTransportType, spawn_mcp_server};
use crate::mcp::{
ConnectedServer, JsonField, McpServer, McpTransportType, oauth, spawn_mcp_server,
};
use anyhow::Result;
use parking_lot::Mutex;
@@ -99,7 +101,12 @@ impl McpFactory {
return Ok(existing);
}
let handle = spawn_mcp_server(spec, log_path).await?;
let bearer_token = if spec.is_remote() {
oauth::load_valid_mcp_token(name)
} else {
None
};
let handle = spawn_mcp_server(spec, log_path, bearer_token).await?;
self.insert_active(key, &handle);
Ok(handle)
}
@@ -125,6 +132,7 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
}
}
@@ -141,6 +149,7 @@ mod tests {
cwd: None,
url: Some(url.to_string()),
headers,
oauth_client_id: None,
}
}
+1
View File
@@ -135,6 +135,7 @@ const RAGS_DIR_NAME: &str = "rags";
const FUNCTIONS_DIR_NAME: &str = "functions";
const FUNCTIONS_BIN_DIR_NAME: &str = "bin";
const AGENTS_DIR_NAME: &str = "agents";
const REPL_HISTORY_DIR_NAME: &str = "repl-history";
const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
+16
View File
@@ -8,6 +8,8 @@ use super::{
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
};
use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME;
use crate::config::session::Session;
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
use anyhow::{Context, Result, anyhow, bail};
@@ -320,6 +322,20 @@ pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
.join(MEMORY_DIR_NAME)
}
pub fn repl_history_dir() -> PathBuf {
cache_path().join(REPL_HISTORY_DIR_NAME)
}
pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
let history_key = if let Some(session) = &session {
format!("session_{}", session.name().replace('/', "_"))
} else {
"default".to_string()
};
repl_history_dir().join(history_key)
}
pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
let log_level = env::var(get_env_name("log_level"))
.ok()
+19 -1
View File
@@ -709,6 +709,10 @@ impl RequestContext {
}
pub fn extract_role(&self, app: &AppConfig) -> Result<Role> {
self.extract_role_impl(app, true)
}
fn extract_role_impl(&self, app: &AppConfig, inject_memory: bool) -> Result<Role> {
let mut role = if let Some(session) = self.session.as_ref() {
session.to_role()
} else if let Some(agent) = self.agent.as_ref() {
@@ -757,6 +761,7 @@ impl RequestContext {
}
}
if inject_memory {
let memory_config = self.memory_config();
if memory_config.enabled {
let store = MemoryStore {
@@ -787,6 +792,7 @@ impl RequestContext {
Err(e) => warn!("memory injection failed: {}", e),
}
}
}
Ok(self.skill_registry.effective_role(&role, &policy))
}
@@ -1276,7 +1282,7 @@ impl RequestContext {
pub fn generate_prompt_context(&self, app: &AppConfig) -> HashMap<&str, String> {
let mut output = HashMap::new();
let role = self.extract_role(app).unwrap_or_else(|err| {
let role = self.extract_role_impl(app, false).unwrap_or_else(|err| {
warn!("failed to compute effective role for prompt rendering: {err}");
Role::default()
});
@@ -2334,6 +2340,17 @@ impl RequestContext {
}
_ => vec![],
};
} else if cmd == ".mcp" && args.first() == Some(&"auth") && args.len() == 2 {
if let Some(mcp_config) = &self.app.mcp_config {
values = super::map_completion_values(
mcp_config
.mcp_servers
.iter()
.filter(|(_, spec)| spec.is_remote())
.map(|(name, _)| name.clone())
.collect(),
);
}
} else if (cmd == ".edit" && args.first() == Some(&"skill") && args.len() == 2)
|| (cmd == ".skill" && args.first() == Some(&"load") && args.len() == 2)
{
@@ -3681,6 +3698,7 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
},
);
}
+8
View File
@@ -163,6 +163,14 @@ impl Session {
self.messages.is_empty() && self.compressed_messages.is_empty()
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn compressed_messages(&self) -> &[Message] {
&self.compressed_messages
}
pub fn name(&self) -> &str {
&self.name
}
+11
View File
@@ -68,6 +68,14 @@ fn normalize_version(requested: Option<String>) -> Option<String> {
}
}
fn preferred_update_target() -> Option<&'static str> {
match (env::consts::OS, env::consts::ARCH) {
("linux", "x86_64") => Some("x86_64-unknown-linux-musl"),
("linux", "aarch64") => Some("aarch64-unknown-linux-musl"),
_ => None,
}
}
fn is_dir_writable(dir: &Path) -> bool {
let probe = dir.join(format!(".coyote-update-write-test-{}", process::id()));
match OpenOptions::new().write(true).create_new(true).open(&probe) {
@@ -147,6 +155,9 @@ pub fn run_self_update(requested: Option<String>, force: bool) -> Result<()> {
if let Some(tag) = &target_tag {
builder.target_version_tag(tag.as_str());
}
if let Some(target) = preferred_update_target() {
builder.target(target);
}
let status = builder
.build()
.context("Failed to configure the self-update")?
+46 -2
View File
@@ -28,11 +28,12 @@ use crate::config::{
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
};
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
use crate::mcp::McpServersConfig;
use crate::render::{prompt_theme, render_error};
use crate::repl::Repl;
use crate::utils::*;
use crate::vault::Vault;
use anyhow::{Result, anyhow, bail};
use crate::vault::{Vault, interpolate_secrets};
use anyhow::{Context, Result, anyhow, bail};
use clap::{CommandFactory, Parser};
use clap_complete::CompleteEnv;
use client::ClientConfig;
@@ -120,6 +121,49 @@ async fn main() -> Result<()> {
return Ok(());
}
if let Some(server_name) = &cli.auth_mcp {
let cfg = Config::load_with_interpolation(true).await?;
let app_config = AppConfig::from_config(cfg)?;
let vault = Vault::init(&app_config)?;
let mcp_path = paths::mcp_config_file();
if !mcp_path.exists() {
bail!(
"No MCP configuration file found at '{}'",
mcp_path.display()
);
}
let raw = tokio::fs::read_to_string(&mcp_path)
.await
.with_context(|| format!("Failed to read MCP config at '{}'", mcp_path.display()))?;
let (content, missing) = interpolate_secrets(&raw, &vault)?;
if !missing.is_empty() {
bail!(
"MCP config references vault secrets that are missing: {:?}",
missing
);
}
let mcp_config: McpServersConfig =
serde_json::from_str(&content).context("Failed to parse MCP config file")?;
let spec = mcp_config
.mcp_servers
.get(server_name.as_str())
.ok_or_else(|| anyhow!("MCP server '{server_name}' not found in mcp.json"))?;
if !spec.is_remote() {
bail!(
"MCP server '{server_name}' is a stdio server; OAuth is only supported for http/sse servers"
);
}
let url = spec.url.as_deref().expect("validated: remote spec has url");
mcp::oauth::run_mcp_oauth_flow(server_name, url, spec.oauth_client_id.as_deref()).await?;
println!("Authentication saved. '{server_name}' is now available for use.");
return Ok(());
}
if vault_flags {
let cfg = Config::load_with_interpolation(true).await?;
let app_config = AppConfig::from_config(cfg)?;
+172 -11
View File
@@ -1,3 +1,4 @@
pub(crate) mod oauth;
mod sse_transport;
use crate::config::AppConfig;
@@ -73,6 +74,8 @@ pub(crate) struct McpServer {
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<IndexMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth_client_id: Option<String>,
}
impl McpServer {
@@ -107,10 +110,10 @@ impl McpServer {
"MCP server '{name}' is missing a \"command\" field (required for stdio transport)"
));
}
if self.url.is_some() || self.headers.is_some() {
if self.url.is_some() || self.headers.is_some() || self.oauth_client_id.is_some() {
return Err(anyhow!(
"MCP server '{name}' has type \"stdio\" but also specifies remote fields \
(url/headers). Remove the remote fields or change the type to \"http\" or \"sse\"."
(url/headers/oauth_client_id). Remove the remote fields or change the type to \"http\" or \"sse\"."
));
}
}
@@ -237,7 +240,7 @@ impl McpRegistry {
debug!("Starting selected MCP servers: {:?}", ids_to_start);
let results: Vec<(String, Arc<_>, ServerCatalog)> = stream::iter(
let results: Vec<Option<(String, Arc<ConnectedServer>, ServerCatalog)>> = stream::iter(
ids_to_start
.into_iter()
.map(|id| async { self.start_server(id).await }),
@@ -246,7 +249,7 @@ impl McpRegistry {
.try_collect()
.await?;
for (id, server, catalog) in results {
for (id, server, catalog) in results.into_iter().flatten() {
self.servers.insert(id.clone(), server);
self.catalogs.insert(id, catalog);
}
@@ -257,14 +260,30 @@ impl McpRegistry {
async fn start_server(
&self,
id: String,
) -> Result<(String, Arc<ConnectedServer>, ServerCatalog)> {
) -> Result<Option<(String, Arc<ConnectedServer>, ServerCatalog)>> {
let spec = self
.config
.as_ref()
.and_then(|c| c.mcp_servers.get(&id))
.with_context(|| format!("MCP server not found in config: {id}"))?;
let service = spawn_mcp_server(spec, self.log_path.as_deref()).await?;
let bearer_token = if spec.is_remote() {
oauth::load_valid_mcp_token(&id)
} else {
None
};
let service = match spawn_mcp_server(spec, self.log_path.as_deref(), bearer_token).await {
Ok(s) => s,
Err(e) if is_auth_required_error(&e) => {
warn!(
"MCP server '{id}' requires OAuth authentication. \
Run `.mcp auth {id}` in the REPL to authenticate, then restart Coyote."
);
return Ok(None);
}
Err(e) => return Err(e),
};
let tools = service.list_tools(None).await?;
debug!("Available tools for MCP server {id}: {tools:?}");
@@ -289,7 +308,7 @@ impl McpRegistry {
info!("Started MCP server: {id}");
Ok((id.to_string(), service, catalog))
Ok(Some((id.to_string(), service, catalog)))
}
fn resolve_server_ids(&self, enabled_mcp_servers: Option<Vec<String>>) -> Vec<String> {
@@ -337,15 +356,18 @@ impl McpRegistry {
pub(crate) async fn spawn_mcp_server(
spec: &McpServer,
log_path: Option<&Path>,
bearer_token: Option<String>,
) -> Result<Arc<ConnectedServer>> {
match spec.transport_type {
McpTransportType::Http => {
let url = spec.url.as_deref().expect("validated: http spec has url");
spawn_http_mcp_server(url, spec.headers.as_ref()).await
let headers = merge_bearer_token(spec.headers.as_ref(), bearer_token);
spawn_http_mcp_server(url, headers.as_ref()).await
}
McpTransportType::Sse => {
let url = spec.url.as_deref().expect("validated: sse spec has url");
spawn_sse_mcp_server(url, spec.headers.as_ref()).await
let headers = merge_bearer_token(spec.headers.as_ref(), bearer_token);
spawn_sse_mcp_server(url, headers.as_ref()).await
}
McpTransportType::Stdio => {
let command = spec
@@ -357,6 +379,30 @@ pub(crate) async fn spawn_mcp_server(
}
}
fn merge_bearer_token(
headers: Option<&IndexMap<String, String>>,
bearer_token: Option<String>,
) -> Option<IndexMap<String, String>> {
match (headers, bearer_token) {
(None, None) => None,
(Some(h), None) => Some(h.clone()),
(None, Some(token)) => {
let mut m = IndexMap::new();
m.insert("Authorization".to_string(), format!("Bearer {token}"));
Some(m)
}
(Some(h), Some(token)) => {
let mut m = h.clone();
m.insert("Authorization".to_string(), format!("Bearer {token}"));
Some(m)
}
}
}
fn is_auth_required_error(e: &anyhow::Error) -> bool {
e.to_string().contains("Auth required")
}
async fn spawn_http_mcp_server(
url: &str,
headers: Option<&IndexMap<String, String>>,
@@ -433,8 +479,12 @@ async fn spawn_stdio_mcp_server(
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
let (transport, _) = TokioChildProcess::builder(cmd).stderr(log_file).spawn()?;
.open(log_path)
.with_context(|| format!("Failed to open MCP log file at '{}'", log_path.display()))?;
let (transport, _) = TokioChildProcess::builder(cmd)
.stderr(log_file)
.spawn()
.with_context(|| format!("Failed to spawn MCP server: {command}"))?;
transport
} else {
TokioChildProcess::new(cmd)?
@@ -461,6 +511,7 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
}
}
@@ -473,6 +524,7 @@ mod tests {
cwd: None,
url: Some(url.to_string()),
headers: None,
oauth_client_id: None,
}
}
@@ -485,6 +537,7 @@ mod tests {
cwd: None,
url: Some(url.to_string()),
headers: None,
oauth_client_id: None,
}
}
@@ -502,6 +555,7 @@ mod tests {
#[test]
fn validate_stdio_with_command_succeeds() {
let spec = stdio_server("npx");
assert!(spec.validate("test").is_ok());
}
@@ -515,8 +569,11 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"command\" field"));
}
@@ -530,8 +587,11 @@ mod tests {
cwd: None,
url: Some("http://localhost".into()),
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("remote fields"));
}
@@ -547,14 +607,18 @@ mod tests {
cwd: None,
url: None,
headers: Some(headers),
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("remote fields"));
}
#[test]
fn validate_http_with_url_succeeds() {
let spec = http_server("http://localhost:8080");
assert!(spec.validate("test").is_ok());
}
@@ -568,8 +632,11 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"url\" field"));
}
@@ -583,8 +650,11 @@ mod tests {
cwd: None,
url: Some("http://localhost".into()),
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
@@ -598,8 +668,11 @@ mod tests {
cwd: None,
url: Some("http://localhost".into()),
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
@@ -613,14 +686,18 @@ mod tests {
cwd: Some("/tmp".into()),
url: Some("http://localhost".into()),
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("stdio fields"));
}
#[test]
fn validate_sse_with_url_succeeds() {
let spec = sse_server("http://sse.example.com");
assert!(spec.validate("test").is_ok());
}
@@ -634,8 +711,11 @@ mod tests {
cwd: None,
url: None,
headers: None,
oauth_client_id: None,
};
let err = spec.validate("test").unwrap_err();
assert!(err.to_string().contains("missing a \"url\" field"));
}
@@ -661,9 +741,13 @@ mod tests {
}
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert!(config.mcp_servers.contains_key("my-server"));
let spec = &config.mcp_servers["my-server"];
assert_eq!(spec.transport_type, McpTransportType::Stdio);
assert_eq!(spec.command.as_deref(), Some("npx"));
assert_eq!(
@@ -684,7 +768,9 @@ mod tests {
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
let spec = &config.mcp_servers["remote"];
assert_eq!(spec.transport_type, McpTransportType::Http);
assert_eq!(spec.url.as_deref(), Some("http://localhost:8080/mcp"));
assert_eq!(
@@ -709,7 +795,9 @@ mod tests {
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
let env = config.mcp_servers["s"].env.as_ref().unwrap();
assert!(matches!(env["STR_VAR"], JsonField::Str(ref s) if s == "hello"));
assert!(matches!(env["BOOL_VAR"], JsonField::Bool(true)));
assert!(matches!(env["INT_VAR"], JsonField::Int(42)));
@@ -723,7 +811,9 @@ mod tests {
"remote-api": { "type": "http", "url": "http://api.example.com" }
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mcp_servers.len(), 2);
assert!(config.mcp_servers.contains_key("github"));
assert!(config.mcp_servers.contains_key("remote-api"));
@@ -732,7 +822,9 @@ mod tests {
#[test]
fn deserialize_empty_servers_map() {
let json = r#"{ "mcpServers": {} }"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert!(config.mcp_servers.is_empty());
}
@@ -747,77 +839,96 @@ mod tests {
}
}
}"#;
let config: McpServersConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mcp_servers["s"].cwd.as_deref(), Some("/tmp/work"));
}
#[test]
fn resolve_all_returns_all_configured_servers() {
let registry = make_registry_with_config(&["github", "slack", "jira"]);
let mut ids = registry.resolve_server_ids(Some(vec!["all".to_string()]));
ids.sort();
assert_eq!(ids, vec!["github", "jira", "slack"]);
}
#[test]
fn resolve_comma_separated_returns_matching_servers() {
let registry = make_registry_with_config(&["github", "slack", "jira"]);
let mut ids =
registry.resolve_server_ids(Some(vec!["github".to_string(), "jira".to_string()]));
ids.sort();
assert_eq!(ids, vec!["github", "jira"]);
}
#[test]
fn resolve_single_server_name() {
let registry = make_registry_with_config(&["github", "slack"]);
let ids = registry.resolve_server_ids(Some(vec!["slack".to_string()]));
assert_eq!(ids, vec!["slack"]);
}
#[test]
fn resolve_none_returns_empty() {
let registry = make_registry_with_config(&["github"]);
let ids = registry.resolve_server_ids(None);
assert!(ids.is_empty());
}
#[test]
fn resolve_no_config_returns_empty() {
let registry = McpRegistry::default();
let ids = registry.resolve_server_ids(Some(vec!["all".to_string()]));
assert!(ids.is_empty());
}
#[test]
fn resolve_nonexistent_server_filtered_out() {
let registry = make_registry_with_config(&["github"]);
let ids = registry
.resolve_server_ids(Some(vec!["github".to_string(), "nonexistent".to_string()]));
assert_eq!(ids, vec!["github"]);
}
#[test]
fn resolve_all_nonexistent_returns_empty() {
let registry = make_registry_with_config(&["github"]);
let ids = registry.resolve_server_ids(Some(vec!["foo".to_string(), "bar".to_string()]));
assert!(ids.is_empty());
}
#[test]
fn resolve_trims_whitespace() {
let registry = make_registry_with_config(&["github", "slack"]);
let mut ids = registry.resolve_server_ids(Some(vec![
" github ".to_string(),
" slack ".to_string(),
]));
ids.sort();
assert_eq!(ids, vec!["github", "slack"]);
}
#[test]
fn registry_default_is_empty() {
let registry = McpRegistry::default();
assert!(registry.is_empty());
assert!(registry.list_started_servers().is_empty());
assert!(registry.mcp_config().is_none());
@@ -827,6 +938,7 @@ mod tests {
#[test]
fn registry_with_config_reports_config() {
let registry = make_registry_with_config(&["github"]);
assert!(registry.mcp_config().is_some());
assert!(
registry
@@ -843,4 +955,53 @@ mod tests {
assert_eq!(MCP_SEARCH_META_FUNCTION_NAME_PREFIX, "mcp_search");
assert_eq!(MCP_DESCRIBE_META_FUNCTION_NAME_PREFIX, "mcp_describe");
}
#[test]
fn merge_bearer_token_both_none_returns_none() {
assert!(merge_bearer_token(None, None).is_none());
}
#[test]
fn merge_bearer_token_headers_only_passes_through() {
let mut h = IndexMap::new();
h.insert("X-Key".to_string(), "val".to_string());
let result = merge_bearer_token(Some(&h), None).unwrap();
assert_eq!(result["X-Key"], "val");
assert!(!result.contains_key("Authorization"));
}
#[test]
fn merge_bearer_token_token_only_injects_bearer() {
let result = merge_bearer_token(None, Some("tok123".to_string())).unwrap();
assert_eq!(result["Authorization"], "Bearer tok123");
}
#[test]
fn merge_bearer_token_both_merges_and_overrides_authorization() {
let mut h = IndexMap::new();
h.insert("Authorization".to_string(), "old".to_string());
h.insert("X-Custom".to_string(), "keep".to_string());
let result = merge_bearer_token(Some(&h), Some("newtoken".to_string())).unwrap();
assert_eq!(result["Authorization"], "Bearer newtoken");
assert_eq!(result["X-Custom"], "keep");
}
#[test]
fn is_auth_required_error_matches_rmcp_message() {
let e = anyhow!("Auth required, when send initialize request");
assert!(is_auth_required_error(&e));
}
#[test]
fn is_auth_required_error_does_not_match_unrelated() {
let e = anyhow!("Connection refused");
assert!(!is_auth_required_error(&e));
}
}
+329
View File
@@ -0,0 +1,329 @@
use crate::client::oauth::{OAuthProvider, TokenRequestFormat, load_oauth_tokens, run_oauth_flow};
use crate::config::paths;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use inquire::Text;
use log::warn;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs;
use std::net::TcpListener;
use url::Url;
#[derive(Debug, Deserialize)]
struct ProtectedResourceMetadata {
#[serde(default)]
authorization_servers: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct OAuthServerMetadata {
authorization_endpoint: String,
token_endpoint: String,
#[serde(default)]
scopes_supported: Vec<String>,
registration_endpoint: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct McpRegistration {
client_id: String,
}
struct McpOAuthProvider {
client_id: String,
authorize_url: String,
token_url: String,
scopes: String,
fixed_redirect: String,
}
impl OAuthProvider for McpOAuthProvider {
fn provider_name(&self) -> &str {
"MCP"
}
fn client_id(&self) -> &str {
&self.client_id
}
fn authorize_url(&self) -> &str {
&self.authorize_url
}
fn token_url(&self) -> &str {
&self.token_url
}
fn redirect_uri(&self) -> &str {
""
}
fn scopes(&self) -> &str {
&self.scopes
}
fn token_request_format(&self) -> TokenRequestFormat {
TokenRequestFormat::FormUrlEncoded
}
fn uses_localhost_redirect(&self) -> bool {
false
}
fn fixed_redirect_uri(&self) -> Option<String> {
Some(self.fixed_redirect.clone())
}
}
pub async fn run_mcp_oauth_flow(
server_name: &str,
server_url: &str,
configured_client_id: Option<&str>,
) -> Result<()> {
let metadata = discover_oauth_metadata(server_url).await?;
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
let redirect_uri = format!("http://127.0.0.1:{port}/callback");
let client_id = if let Some(id) = configured_client_id {
id.to_string()
} else if let Some(cached) = load_registered_client_id(server_name) {
cached
} else if let Some(reg_endpoint) = &metadata.registration_endpoint {
match register_client(reg_endpoint, &redirect_uri).await {
Ok(id) => {
let _ = save_registered_client_id(server_name, &id);
id
}
Err(e) => {
warn!("Dynamic client registration failed: {e}. Falling back to manual entry.");
Text::new("Enter the OAuth client ID for this MCP server:")
.prompt()
.context("Failed to read client ID")?
}
}
} else {
Text::new("Enter the OAuth client ID for this MCP server:")
.prompt()
.context("Failed to read client ID")?
};
let provider = McpOAuthProvider {
client_id,
authorize_url: metadata.authorization_endpoint,
token_url: metadata.token_endpoint,
scopes: metadata.scopes_supported.join(" "),
fixed_redirect: redirect_uri,
};
run_oauth_flow(&provider, &mcp_token_key(server_name)).await
}
pub fn load_valid_mcp_token(server_name: &str) -> Option<String> {
let tokens = load_oauth_tokens(&mcp_token_key(server_name))?;
if Utc::now().timestamp() < tokens.expires_at {
Some(tokens.access_token)
} else {
None
}
}
fn mcp_token_key(server_name: &str) -> String {
format!("mcp_{server_name}")
}
fn load_registered_client_id(server_name: &str) -> Option<String> {
let path = paths::oauth_tokens_path().join(format!("mcp_{server_name}_registration.json"));
let content = fs::read_to_string(path).ok()?;
let reg: McpRegistration = serde_json::from_str(&content).ok()?;
Some(reg.client_id)
}
fn save_registered_client_id(server_name: &str, client_id: &str) -> Result<()> {
let dir = paths::oauth_tokens_path();
fs::create_dir_all(&dir)?;
let path = dir.join(format!("mcp_{server_name}_registration.json"));
let reg = McpRegistration {
client_id: client_id.to_string(),
};
fs::write(path, serde_json::to_string_pretty(&reg)?)?;
Ok(())
}
async fn register_client(endpoint: &str, redirect_uri: &str) -> Result<String> {
let body = serde_json::json!({
"client_name": "Coyote",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
});
let response: serde_json::Value = Client::new()
.post(endpoint)
.json(&body)
.send()
.await
.context("Failed to reach registration endpoint")?
.json()
.await
.context("Failed to parse registration response")?;
response["client_id"]
.as_str()
.ok_or_else(|| anyhow!("Missing client_id in registration response: {response}"))
.map(|s| s.to_string())
}
async fn discover_oauth_metadata(server_url: &str) -> Result<OAuthServerMetadata> {
let base = extract_base_url(server_url)?;
let client = Client::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);
}
}
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}"));
}
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."
))
}
fn extract_base_url(url: &str) -> Result<String> {
let parsed = Url::parse(url).with_context(|| format!("Invalid URL: {url}"))?;
let scheme = parsed.scheme();
let host = parsed
.host_str()
.ok_or_else(|| anyhow!("No host in URL: {url}"))?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
Ok(format!("{scheme}://{host}{port}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::get_env_name;
use serial_test::serial;
use std::{
env, fs,
time::{self, SystemTime},
};
fn with_temp_cache<F: FnOnce()>(f: F) {
let unique = SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-mcp-oauth-test-{unique}"));
fs::create_dir_all(&root).unwrap();
let env_key = get_env_name("cache_dir");
let prev = env::var_os(&env_key);
unsafe {
env::set_var(&env_key, &root);
}
f();
unsafe {
match prev {
Some(v) => env::set_var(&env_key, v),
None => env::remove_var(&env_key),
}
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn extract_base_url_strips_path_and_query() {
let result = extract_base_url("https://mcp.notion.com/mcp?foo=bar").unwrap();
assert_eq!(result, "https://mcp.notion.com");
}
#[test]
fn extract_base_url_preserves_explicit_port() {
let result = extract_base_url("http://localhost:8080/mcp").unwrap();
assert_eq!(result, "http://localhost:8080");
}
#[test]
fn extract_base_url_standard_port_omitted() {
let result = extract_base_url("https://example.com/mcp/v1").unwrap();
assert_eq!(result, "https://example.com");
}
#[test]
fn extract_base_url_rejects_invalid_url() {
assert!(extract_base_url("not-a-url").is_err());
}
#[test]
#[serial]
fn registered_client_id_roundtrip() {
with_temp_cache(|| {
save_registered_client_id("notion", "client-xyz-123").unwrap();
let loaded = load_registered_client_id("notion");
assert_eq!(loaded, Some("client-xyz-123".to_string()));
});
}
#[test]
#[serial]
fn load_registered_client_id_returns_none_for_missing() {
with_temp_cache(|| {
let loaded = load_registered_client_id("no-such-server");
assert!(loaded.is_none());
});
}
#[test]
#[serial]
fn registered_client_id_second_save_overwrites_first() {
with_temp_cache(|| {
save_registered_client_id("github", "first-id").unwrap();
save_registered_client_id("github", "second-id").unwrap();
let loaded = load_registered_client_id("github");
assert_eq!(loaded, Some("second-id".to_string()));
});
}
}
+163 -8
View File
@@ -6,7 +6,10 @@ use self::completer::ReplCompleter;
use self::highlighter::ReplHighlighter;
use self::prompt::ReplPrompt;
use crate::client::{call_chat_completions, call_chat_completions_streaming, init_client, oauth};
use crate::client::{
Message, MessageRole, call_chat_completions, call_chat_completions_streaming, init_client,
oauth,
};
use crate::config::{
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
macro_execute,
@@ -20,7 +23,7 @@ use crate::utils::{
};
use crate::sandbox::SANDBOX_ENV_FLAG;
use crate::{config, graph, resolve_oauth_client};
use crate::{config, graph, mcp, resolve_oauth_client};
use anyhow::{Context, Result, bail};
use crossterm::cursor::SetCursorStyle;
use fancy_regex::Regex;
@@ -29,9 +32,9 @@ use log::warn;
use parking_lot::RwLock;
use reedline::CursorConfig;
use reedline::{
ColumnarMenu, EditCommand, EditMode, Emacs, KeyCode, KeyModifiers, Keybindings, Reedline,
ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi, default_emacs_keybindings,
default_vi_insert_keybindings, default_vi_normal_keybindings,
ColumnarMenu, EditCommand, EditMode, Emacs, FileBackedHistory, KeyCode, KeyModifiers,
Keybindings, Reedline, ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi,
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
};
use reedline::{MenuBuilder, Signal};
use std::sync::LazyLock;
@@ -49,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; 49]> = LazyLock::new(|| {
static REPL_COMMANDS: LazyLock<[ReplCommand; 50]> = LazyLock::new(|| {
[
ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
ReplCommand::new(".info", "Show system info", AssertState::pass()),
@@ -63,6 +66,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 49]> = LazyLock::new(|| {
"Authenticate the current model client via OAuth (if configured)",
AssertState::pass(),
),
ReplCommand::new(
".mcp auth",
"Authenticate with an MCP server via OAuth",
AssertState::pass(),
),
ReplCommand::new(
".edit config",
"Modify configuration file",
@@ -313,6 +321,58 @@ Type ".help" for additional help.
}
}
{
let (messages_snapshot, compressed_count) = {
let ctx = self.ctx.read();
if let Some(session) = &ctx.session {
let msgs: Vec<Message> = session
.messages()
.iter()
.filter(|m| !m.role.is_system())
.cloned()
.collect();
let compressed = session.compressed_messages().len();
(msgs, compressed)
} else {
(vec![], 0)
}
};
if !messages_snapshot.is_empty() || compressed_count > 0 {
let app = Arc::clone(&self.ctx.read().app.config);
if compressed_count > 0 {
println!(
"{}",
dimmed_text(&format!(
"({compressed_count} earlier messages not shown; compressed for context)"
))
);
println!();
}
for message in &messages_snapshot {
match message.role {
MessageRole::User => {
if let Some(text) = message.content.as_text() {
println!("{}", dimmed_text("You:"));
println!("{text}");
println!();
}
}
MessageRole::Assistant => {
if let Some(text) = message.content.as_text() {
app.print_markdown(text)?;
println!();
}
}
_ => {}
}
}
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
println!();
}
}
loop {
if self.abort_signal.aborted_ctrld() {
break;
@@ -388,6 +448,14 @@ Type ".help" for additional help.
editor = editor.with_buffer_editor(command, temp_file);
}
if app.save_shell_history {
let ctx = ctx.read();
let history_path = paths::repl_history_file(&ctx.session);
if let Ok(history) = FileBackedHistory::with_file(1000, history_path) {
editor = editor.with_history(Box::new(history));
}
}
Ok(editor)
}
@@ -541,6 +609,53 @@ pub async fn run_repl_command(
let (client_name, provider) = resolve_oauth_client(Some(client.name()), &clients)?;
oauth::run_oauth_flow(&*provider, &client_name).await?;
}
".mcp" => 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 {
"auth" => {
if rest.is_empty() {
println!("Usage: .mcp auth <server_name>");
} else {
let server_name = rest;
let server_spec = ctx
.app
.mcp_config
.as_ref()
.and_then(|c| c.mcp_servers.get(server_name))
.cloned();
match server_spec {
None => {
bail!("MCP server '{}' not found in mcp.json.", server_name)
}
Some(spec) if !spec.is_remote() => bail!(
"MCP server '{}' uses stdio transport; \
OAuth is only supported for http/sse servers.",
server_name
),
Some(spec) => {
let url = spec
.url
.as_deref()
.expect("validated: remote spec has url");
let client_id = spec.oauth_client_id.as_deref();
mcp::oauth::run_mcp_oauth_flow(server_name, url, client_id)
.await?;
println!(
"Authentication saved. \
Restart Coyote to connect to '{server_name}'."
);
}
}
}
}
_ => unknown_command()?,
}
}
None => println!("Usage: .mcp auth <server_name>"),
},
".prompt" => match args {
Some(text) => {
let app = Arc::clone(&ctx.app.config);
@@ -632,6 +747,46 @@ pub async fn run_repl_command(
session.set_autonaming(false);
}
}
if let Some(session) = &ctx.session {
let messages_snapshot: Vec<Message> = session
.messages()
.iter()
.filter(|m| !m.role.is_system())
.cloned()
.collect();
let compressed_count = session.compressed_messages().len();
if !messages_snapshot.is_empty() || compressed_count > 0 {
if compressed_count > 0 {
println!(
"{}",
dimmed_text(&format!(
"({compressed_count} earlier messages not shown — compressed for context)"
))
);
println!();
}
for message in &messages_snapshot {
match message.role {
MessageRole::User => {
if let Some(text) = message.content.as_text() {
println!("{}", dimmed_text("You:"));
println!("{text}");
println!();
}
}
MessageRole::Assistant => {
if let Some(text) = message.content.as_text() {
app.print_markdown(text)?;
println!();
}
}
_ => {}
}
}
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
println!();
}
}
}
".install" => {
let trimmed = args.map(str::trim).unwrap_or("");
@@ -1415,8 +1570,8 @@ mod tests {
}
#[test]
fn repl_commands_has_49_entries() {
assert_eq!(REPL_COMMANDS.len(), 49);
fn repl_commands_has_50_entries() {
assert_eq!(REPL_COMMANDS.len(), 50);
}
#[test]