mirror of
https://github.com/block/goose.git
synced 2026-08-20 22:04:56 +00:00
Signed-off-by: Jack Amadeo <jackamadeo@squareup.com> Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
This commit is contained in:
parent
810bb68fff
commit
2f5169af35
18 changed files with 1380 additions and 126 deletions
91
.github/workflows/mcp-conformance.yml
vendored
Normal file
91
.github/workflows/mcp-conformance.yml
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
name: MCP Conformance
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Conformance Binaries
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt update -y
|
||||
sudo apt install -y libdbus-1-dev libxcb1-dev
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
key: mcp-conformance
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
source ./bin/activate-hermit
|
||||
export CARGO_INCREMENTAL=0
|
||||
just mcp-conformance-build
|
||||
|
||||
- name: Upload Conformance Binaries
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mcp-conformance-binaries
|
||||
path: |
|
||||
target/debug/goose
|
||||
target/debug/mcp_conformance_driver
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
conformance:
|
||||
name: Conformance ${{ matrix.spec-version }} / ${{ matrix.conformance-version }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- spec-version: "2025-11-25"
|
||||
conformance-version: "0.1.16"
|
||||
baseline: "crates/goose-cli/tests/mcp-conformance/expected-failures-2025-11-25-0.1.16.yaml"
|
||||
- spec-version: "2025-11-25"
|
||||
conformance-version: "0.2.0-alpha.10"
|
||||
baseline: "crates/goose-cli/tests/mcp-conformance/expected-failures-2025-11-25-0.2.0-alpha.10.yaml"
|
||||
- spec-version: "2026-07-28"
|
||||
conformance-version: "0.2.0-alpha.10"
|
||||
baseline: "crates/goose-cli/tests/mcp-conformance/expected-failures-2026-07-28-0.2.0-alpha.10.yaml"
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
sudo apt update -y
|
||||
sudo apt install -y libdbus-1-dev libxcb1-dev
|
||||
|
||||
- name: Download Conformance Binaries
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: mcp-conformance-binaries
|
||||
path: target/debug
|
||||
|
||||
- name: Restore Executable Permissions
|
||||
run: chmod +x target/debug/goose target/debug/mcp_conformance_driver
|
||||
|
||||
- name: Run Conformance Suite
|
||||
env:
|
||||
GOOSE_DISABLE_KEYRING: "1"
|
||||
run: |
|
||||
source ./bin/activate-hermit
|
||||
just mcp-conformance "${{ matrix.spec-version }}" all "${{ matrix.conformance-version }}" false "${{ matrix.baseline }}"
|
||||
27
Justfile
27
Justfile
|
|
@ -413,6 +413,33 @@ win-total-rls *allparam:
|
|||
just win-bld-rls{{allparam}}
|
||||
just win-run-rls
|
||||
|
||||
# Build the binaries the MCP conformance driver needs.
|
||||
mcp-conformance-build:
|
||||
cargo build -p goose-cli --bin goose --bin mcp_conformance_driver
|
||||
|
||||
# suite: all, core, extensions, backcompat, auth, metadata, draft, sep-835
|
||||
# build: "false" reuses the existing target/debug binaries instead of rebuilding
|
||||
# Example: just mcp-conformance
|
||||
# Example: just mcp-conformance 2025-11-25 auth
|
||||
# Example: just mcp-conformance 2025-11-25 auth 0.2.0-alpha.10
|
||||
# Example: just mcp-conformance 2025-11-25 auth 0.2.0-alpha.10 false
|
||||
# Example: just mcp-conformance 2025-11-25 all 0.2.0-alpha.10 true crates/goose-cli/tests/mcp-conformance/expected-failures-2025-11-25-0.2.0-alpha.10.yaml
|
||||
[doc("Run an MCP client conformance suite against Goose.")]
|
||||
mcp-conformance version="2025-11-25" suite="all" conformance_version="0.2.0-alpha.10" build="true" baseline="":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ "{{build}}" = "true" ]; then
|
||||
just mcp-conformance-build
|
||||
elif [ ! -x target/debug/mcp_conformance_driver ]; then
|
||||
echo "target/debug/mcp_conformance_driver not found; run 'just mcp-conformance-build' first" >&2
|
||||
exit 1
|
||||
fi
|
||||
baseline_args=()
|
||||
if [ -n "{{baseline}}" ]; then
|
||||
baseline_args=(--expected-failures "{{baseline}}")
|
||||
fi
|
||||
GOOSE_DISABLE_KEYRING=1 npx -y @modelcontextprotocol/conformance@{{conformance_version}} client --command "target/debug/mcp_conformance_driver" --spec-version "{{version}}" --suite "{{suite}}" ${baseline_args[@]+"${baseline_args[@]}"}
|
||||
|
||||
build-test-tools:
|
||||
cargo build -p goose-test
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ path = "src/main.rs"
|
|||
name = "generate_manpages"
|
||||
path = "src/bin/generate_manpages.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "mcp_conformance_driver"
|
||||
path = "src/bin/mcp_conformance_driver.rs"
|
||||
|
||||
[dependencies]
|
||||
clap_mangen = { version = "0.3", default-features = false }
|
||||
goose = { path = "../goose", default-features = false }
|
||||
|
|
|
|||
132
crates/goose-cli/src/bin/mcp_conformance_driver.rs
Normal file
132
crates/goose-cli/src/bin/mcp_conformance_driver.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
fn script_for_scenario(scenario: Option<&str>) -> Value {
|
||||
let context: Map<String, Value> = std::env::var("MCP_CONFORMANCE_CONTEXT")
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut script = match scenario {
|
||||
Some("tools_call") => json!({
|
||||
"steps": [{ "action": "callTool", "name": "add_numbers", "arguments": { "a": 2, "b": 3 } }],
|
||||
}),
|
||||
Some("elicitation-sep1034-client-defaults") => json!({
|
||||
"steps": [{ "action": "callTool", "name": "test_client_elicitation_defaults", "arguments": {} }],
|
||||
"elicitation": { "action": "acceptSchemaDefaults" },
|
||||
}),
|
||||
Some("auth/scope-step-up") => json!({
|
||||
"steps": [{ "action": "callTool", "name": "test-tool", "arguments": {} }],
|
||||
}),
|
||||
Some("sse-retry") => json!({
|
||||
"steps": [{ "action": "callTool", "name": "test_reconnection", "arguments": {} }],
|
||||
}),
|
||||
Some("auth/basic-cimd") => json!({
|
||||
"steps": [{ "action": "listTools" }],
|
||||
"oauth": { "clientMetadataUrl": "https://conformance-test.local/client-metadata.json" },
|
||||
}),
|
||||
Some("auth/pre-registration") => json!({
|
||||
"steps": [{ "action": "listTools" }],
|
||||
"oauth": { "clientId": context.get("client_id"), "clientSecret": context.get("client_secret") },
|
||||
}),
|
||||
Some("sep-2322-client-request-state") => json!({
|
||||
"steps": [
|
||||
{ "action": "callTool", "name": "test_mrtr_echo_state", "arguments": {} },
|
||||
{ "action": "callTool", "name": "test_mrtr_no_state", "arguments": {} },
|
||||
{ "action": "callTool", "name": "test_mrtr_unrelated", "arguments": {} },
|
||||
{ "action": "callTool", "name": "test_mrtr_no_result_type", "arguments": {} },
|
||||
],
|
||||
"elicitation": { "action": "accept", "content": { "confirmed": true } },
|
||||
}),
|
||||
Some("http-custom-headers") => {
|
||||
let steps: Vec<Value> = context
|
||||
.get("toolCalls")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|call| {
|
||||
let mut step = Map::new();
|
||||
step.insert("action".to_string(), json!("callTool"));
|
||||
if let Value::Object(call) = call {
|
||||
step.extend(call);
|
||||
}
|
||||
Value::Object(step)
|
||||
})
|
||||
.collect();
|
||||
json!({ "steps": steps })
|
||||
}
|
||||
Some("http-invalid-tool-headers") => json!({
|
||||
"steps": [{ "action": "callTool", "name": "valid_tool", "arguments": {} }],
|
||||
}),
|
||||
Some("http-standard-headers") => json!({
|
||||
"steps": [
|
||||
{ "action": "listTools" },
|
||||
{ "action": "callTool", "name": "test_headers", "arguments": {} },
|
||||
{ "action": "listPrompts" },
|
||||
{ "action": "getPrompt", "name": "test_prompt", "arguments": {} },
|
||||
{ "action": "listResources" },
|
||||
{ "action": "readResource", "uri": "file:///path/to/file%20name.txt" },
|
||||
],
|
||||
}),
|
||||
_ => json!({
|
||||
"steps": [
|
||||
{ "action": "listTools" },
|
||||
{ "action": "listPrompts" },
|
||||
{ "action": "listResources" },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
// Runner 0.1.16 does not set MCP_CONFORMANCE_PROTOCOL_VERSION; default to
|
||||
// the 2025-11-25 spec version those scenarios expect.
|
||||
let protocol_version = std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION")
|
||||
.unwrap_or_else(|_| "2025-11-25".to_string());
|
||||
{
|
||||
script["protocolVersion"] = json!(protocol_version);
|
||||
}
|
||||
script
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [target] = args.as_slice() else {
|
||||
eprintln!("usage: mcp_conformance_driver <server-url-or-stdio-command>");
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
let scenario = std::env::var("MCP_CONFORMANCE_SCENARIO").ok();
|
||||
let script = script_for_scenario(scenario.as_deref());
|
||||
|
||||
let goose = std::env::var("GOOSE_BIN").unwrap_or_else(|_| "target/debug/goose".to_string());
|
||||
let path_root = tempfile::Builder::new()
|
||||
.prefix("goose-mcp-conformance-")
|
||||
.tempdir()
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!("failed to create temporary GOOSE_PATH_ROOT: {err}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let mut child = Command::new(&goose)
|
||||
.args(["mcp-probe", target, "--script", "-"])
|
||||
.env("GOOSE_OAUTH_AUTOMATIC_CALLBACK", "1")
|
||||
.env("GOOSE_DISABLE_KEYRING", "1")
|
||||
.env("GOOSE_PATH_ROOT", path_root.path())
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap_or_else(|err| {
|
||||
eprintln!("failed to spawn {goose}: {err}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("stdin was piped")
|
||||
.write_all(script.to_string().as_bytes())
|
||||
.expect("write probe script to goose stdin");
|
||||
|
||||
let status = child.wait().expect("wait for goose");
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
|
|
@ -1194,6 +1194,23 @@ enum Command {
|
|||
#[arg(help = "Path to the bundled-extensions.json file")]
|
||||
file: PathBuf,
|
||||
},
|
||||
|
||||
#[command(
|
||||
name = "mcp-probe",
|
||||
about = "Start a Goose MCP session without an LLM and inspect a stdio MCP server",
|
||||
hide = true
|
||||
)]
|
||||
McpProbe {
|
||||
#[arg(help = "Stdio MCP server command to inspect")]
|
||||
extension: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PATH|-",
|
||||
help = "JSON probe script; use - for stdin"
|
||||
)]
|
||||
script: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-inference")]
|
||||
|
|
@ -1358,10 +1375,216 @@ fn get_command_name(command: &Option<Command>) -> &'static str {
|
|||
Some(Command::Completion { .. }) => "completion",
|
||||
Some(Command::Review { .. }) => "review",
|
||||
Some(Command::ValidateExtensions { .. }) => "validate-extensions",
|
||||
Some(Command::McpProbe { .. }) => "mcp-probe",
|
||||
None => "default_session",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct McpProbeScript {
|
||||
#[serde(default)]
|
||||
steps: Vec<McpProbeStep>,
|
||||
elicitation: Option<McpProbeElicitation>,
|
||||
#[serde(default)]
|
||||
oauth: goose::oauth::OAuthFlowConfig,
|
||||
protocol_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "camelCase")]
|
||||
enum McpProbeStep {
|
||||
ListTools,
|
||||
ListPrompts,
|
||||
ListResources,
|
||||
CallTool {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
arguments: serde_json::Map<String, serde_json::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "camelCase")]
|
||||
enum McpProbeElicitation {
|
||||
Accept { content: serde_json::Value },
|
||||
AcceptSchemaDefaults,
|
||||
Decline,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
async fn handle_mcp_probe(extension_command: String, script_path: Option<String>) -> Result<()> {
|
||||
use goose::agents::{Agent, AgentConfig, ToolCallContext};
|
||||
use goose::config::ExtensionConfig;
|
||||
use rmcp::model::{ElicitRequestParams, ElicitResult, ElicitationAction};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
let script = if let Some(path) = script_path {
|
||||
let json = if path == "-" {
|
||||
let mut json = String::new();
|
||||
std::io::stdin().read_to_string(&mut json)?;
|
||||
json
|
||||
} else {
|
||||
std::fs::read_to_string(path)?
|
||||
};
|
||||
serde_json::from_str::<McpProbeScript>(&json)?
|
||||
} else {
|
||||
McpProbeScript {
|
||||
steps: vec![
|
||||
McpProbeStep::ListTools,
|
||||
McpProbeStep::ListPrompts,
|
||||
McpProbeStep::ListResources,
|
||||
],
|
||||
elicitation: None,
|
||||
oauth: goose::oauth::OAuthFlowConfig::default(),
|
||||
protocol_version: None,
|
||||
}
|
||||
};
|
||||
|
||||
let mut extension = if url::Url::parse(&extension_command)
|
||||
.is_ok_and(|url| matches!(url.scheme(), "http" | "https"))
|
||||
{
|
||||
crate::session::CliSession::parse_streamable_http_extension(
|
||||
&extension_command,
|
||||
goose::config::DEFAULT_EXTENSION_TIMEOUT,
|
||||
)
|
||||
} else {
|
||||
crate::session::CliSession::parse_stdio_extension(&extension_command)?
|
||||
};
|
||||
match &mut extension {
|
||||
ExtensionConfig::Stdio { name, .. } | ExtensionConfig::StreamableHttp { name, .. } => {
|
||||
*name = "probe".to_string();
|
||||
}
|
||||
_ => unreachable!("MCP probe only creates stdio or streamable HTTP extensions"),
|
||||
}
|
||||
|
||||
if let Some(client_id) = &script.oauth.client_id {
|
||||
std::env::set_var("GOOSE_MCP_OAUTH_CLIENT_ID", client_id);
|
||||
}
|
||||
if let Some(client_secret) = &script.oauth.client_secret {
|
||||
std::env::set_var("GOOSE_MCP_OAUTH_CLIENT_SECRET", client_secret);
|
||||
}
|
||||
if let Some(client_metadata_url) = &script.oauth.client_metadata_url {
|
||||
std::env::set_var("GOOSE_MCP_OAUTH_CLIENT_METADATA_URL", client_metadata_url);
|
||||
}
|
||||
|
||||
let config = goose::config::Config::global();
|
||||
let mut agent_config = AgentConfig::new(
|
||||
std::sync::Arc::new(SessionManager::instance()),
|
||||
goose::config::permission::PermissionManager::instance(),
|
||||
None,
|
||||
config.get_goose_mode().unwrap_or_default(),
|
||||
true,
|
||||
GoosePlatform::GooseCli,
|
||||
);
|
||||
if let Some(protocol_version) = script.protocol_version.as_deref() {
|
||||
agent_config.mcp_protocol_version = Some(serde_json::from_value(
|
||||
serde_json::Value::String(protocol_version.to_string()),
|
||||
)?);
|
||||
}
|
||||
if let Some(action) = script.elicitation.clone() {
|
||||
agent_config.elicitation_handler =
|
||||
Some(std::sync::Arc::new(move |request| match &action {
|
||||
McpProbeElicitation::Accept { content } => {
|
||||
ElicitResult::new(ElicitationAction::Accept).with_content(content.clone())
|
||||
}
|
||||
McpProbeElicitation::AcceptSchemaDefaults => {
|
||||
let content = match request {
|
||||
ElicitRequestParams::FormElicitationParams {
|
||||
requested_schema, ..
|
||||
} => serde_json::to_value(requested_schema)
|
||||
.ok()
|
||||
.and_then(|schema| schema.get("properties").cloned())
|
||||
.and_then(|properties| properties.as_object().cloned())
|
||||
.map(|properties| {
|
||||
properties
|
||||
.into_iter()
|
||||
.filter_map(|(name, schema)| {
|
||||
schema.get("default").cloned().map(|value| (name, value))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
ElicitResult::new(ElicitationAction::Accept)
|
||||
.with_content(serde_json::Value::Object(content))
|
||||
}
|
||||
McpProbeElicitation::Decline => ElicitResult::new(ElicitationAction::Decline),
|
||||
McpProbeElicitation::Cancel => ElicitResult::new(ElicitationAction::Cancel),
|
||||
}));
|
||||
}
|
||||
let agent = Agent::with_config(agent_config);
|
||||
let session = agent
|
||||
.config
|
||||
.session_manager
|
||||
.create_session(
|
||||
std::env::current_dir()?,
|
||||
"MCP Probe".to_string(),
|
||||
goose::session::session_manager::SessionType::Hidden,
|
||||
agent.config.goose_mode,
|
||||
)
|
||||
.await?;
|
||||
let session_id = session.id.as_str();
|
||||
agent.add_extension(extension, session_id).await?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for step in script.steps {
|
||||
let result = match step {
|
||||
McpProbeStep::ListTools => serde_json::json!({
|
||||
"action": "listTools",
|
||||
"result": agent.extension_manager.list_tools_from_extension(
|
||||
session_id,
|
||||
"probe",
|
||||
CancellationToken::new(),
|
||||
).await?,
|
||||
}),
|
||||
McpProbeStep::ListPrompts => serde_json::json!({
|
||||
"action": "listPrompts",
|
||||
"result": agent.extension_manager.list_prompts_from_extension(
|
||||
session_id,
|
||||
"probe",
|
||||
CancellationToken::new(),
|
||||
).await?,
|
||||
}),
|
||||
McpProbeStep::ListResources => serde_json::json!({
|
||||
"action": "listResources",
|
||||
"result": agent.extension_manager.list_resources_result_from_extension(
|
||||
session_id,
|
||||
"probe",
|
||||
CancellationToken::new(),
|
||||
).await?,
|
||||
}),
|
||||
McpProbeStep::CallTool { name, arguments } => {
|
||||
let scoped_name = format!("probe__{name}");
|
||||
let ctx = ToolCallContext::new(
|
||||
session_id.to_string(),
|
||||
Some(std::env::current_dir()?),
|
||||
Some("mcp-probe-tool-call".to_string()),
|
||||
);
|
||||
let result = agent
|
||||
.extension_manager
|
||||
.dispatch_tool_call(
|
||||
&ctx,
|
||||
rmcp::model::CallToolRequestParams::new(scoped_name)
|
||||
.with_arguments(arguments),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await?
|
||||
.result
|
||||
.await?;
|
||||
serde_json::json!({ "action": "callTool", "name": name, "result": result })
|
||||
}
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({ "results": results }))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_mcp_command(server: McpCommand) -> Result<()> {
|
||||
let name = server.name();
|
||||
let _ = crate::logging::setup_logging(Some(&format!("mcp-{name}")));
|
||||
|
|
@ -2385,6 +2608,7 @@ pub async fn cli() -> anyhow::Result<()> {
|
|||
}
|
||||
}
|
||||
}
|
||||
Some(Command::McpProbe { extension, script }) => handle_mcp_probe(extension, script).await,
|
||||
None => handle_default_session().await,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,12 +118,7 @@ impl McpClientTrait for MockClient {
|
|||
_next_cursor: Option<String>,
|
||||
_cancel_token: CancellationToken,
|
||||
) -> Result<ListPromptsResult, Error> {
|
||||
Ok(ListPromptsResult {
|
||||
prompts: vec![],
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
..Default::default()
|
||||
})
|
||||
Ok(ListPromptsResult::with_all_items(vec![]))
|
||||
}
|
||||
|
||||
async fn get_prompt(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
# MCP conformance baseline: known-failing client scenarios.
|
||||
#
|
||||
# Spec version: 2025-11-25
|
||||
# Conformance version: 0.1.16
|
||||
#
|
||||
# Scenarios listed here are allowed to fail (or emit warnings) without failing
|
||||
# CI. The conformance runner will error if any of these scenarios start passing
|
||||
# (stale entry) or if a scenario not listed here fails (unexpected failure), so
|
||||
# keep this list in sync as behavior changes.
|
||||
#
|
||||
# All scenarios currently pass; keep this file so CI flags any regression as an
|
||||
# unexpected failure.
|
||||
client: []
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# MCP conformance baseline: known-failing client scenarios.
|
||||
#
|
||||
# Spec version: 2025-11-25
|
||||
# Conformance version: 0.2.0-alpha.10
|
||||
#
|
||||
# Scenarios listed here are allowed to fail (or emit warnings) without failing
|
||||
# CI. The conformance runner will error if any of these scenarios start passing
|
||||
# (stale entry) or if a scenario not listed here fails (unexpected failure), so
|
||||
# keep this list in sync as behavior changes.
|
||||
#
|
||||
# All scenarios currently pass; keep this file so CI flags any regression as an
|
||||
# unexpected failure.
|
||||
client: []
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# MCP conformance baseline: known-failing client scenarios.
|
||||
#
|
||||
# Spec version: 2026-07-28
|
||||
# Conformance version: 0.2.0-alpha.10
|
||||
#
|
||||
# Scenarios listed here are allowed to fail (or emit warnings) without failing
|
||||
# CI. The conformance runner will error if any of these scenarios start passing
|
||||
# (stale entry) or if a scenario not listed here fails (unexpected failure), so
|
||||
# keep this list in sync as behavior changes.
|
||||
#
|
||||
# All scenarios currently pass; keep this file so CI flags any regression as an
|
||||
# unexpected failure.
|
||||
client: []
|
||||
|
|
@ -73,7 +73,7 @@ use goose_providers::thinking::{ThinkingEffort, ThinkingEffortSupport};
|
|||
use regex::Regex;
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, ContentBlock, ElicitationAction, ErrorCode, ErrorData,
|
||||
GetPromptResult, Prompt, Tool,
|
||||
GetPromptResult, Prompt, ProtocolVersion, Tool,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
|
@ -93,6 +93,8 @@ fn provider_creation_error(error: anyhow::Error, context: impl fmt::Display) ->
|
|||
error.context(message)
|
||||
}
|
||||
|
||||
pub const MCP_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::V_2025_11_25;
|
||||
|
||||
fn normalize_legacy_provider_thinking_effort(
|
||||
mut model_config: goose_providers::model::ModelConfig,
|
||||
effort_support: &ThinkingEffortSupport,
|
||||
|
|
@ -222,6 +224,8 @@ pub struct AgentConfig {
|
|||
pub disable_session_naming: bool,
|
||||
pub goose_platform: GoosePlatform,
|
||||
pub mcp_host_info: Option<GooseMcpHostInfo>,
|
||||
pub elicitation_handler: Option<crate::agents::mcp_client::ElicitationHandler>,
|
||||
pub mcp_protocol_version: Option<rmcp::model::ProtocolVersion>,
|
||||
pub session_name_update_tx: Option<mpsc::UnboundedSender<SessionNameUpdate>>,
|
||||
pub use_login_shell_path: Option<bool>,
|
||||
pub is_subagent: bool,
|
||||
|
|
@ -244,6 +248,8 @@ impl AgentConfig {
|
|||
disable_session_naming,
|
||||
goose_platform,
|
||||
mcp_host_info: None,
|
||||
elicitation_handler: None,
|
||||
mcp_protocol_version: Some(MCP_PROTOCOL_VERSION),
|
||||
session_name_update_tx: None,
|
||||
use_login_shell_path: None,
|
||||
is_subagent: false,
|
||||
|
|
@ -413,6 +419,8 @@ impl Agent {
|
|||
let capabilities = ExtensionManagerCapabilities {
|
||||
mcpui,
|
||||
host_info: explicit_mcp_host_info.clone(),
|
||||
elicitation_handler: config.elicitation_handler.clone(),
|
||||
protocol_version: config.mcp_protocol_version.clone(),
|
||||
};
|
||||
let client_name = explicit_mcp_host_info
|
||||
.as_ref()
|
||||
|
|
|
|||
|
|
@ -42,12 +42,15 @@ use crate::builtin_extension::get_builtin_extension;
|
|||
use crate::config::extensions::name_to_key;
|
||||
use crate::config::search_path::SearchPaths;
|
||||
use crate::config::{get_all_extensions, Config};
|
||||
use crate::oauth::{oauth_flow, GooseCredentialStore, StaticOAuthClientConfig};
|
||||
use crate::oauth::{
|
||||
oauth_flow, oauth_flow_with_challenge, GooseCredentialStore, StaticOAuthClientConfig,
|
||||
};
|
||||
use crate::prompt_template;
|
||||
use crate::subprocess::spawn_long_lived_mcp_subprocess;
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, ContentBlock, ErrorCode, ErrorData, GetPromptResult,
|
||||
MetaObject, Prompt, Resource, ResourceContents, ServerInfo, ServerNotification, Tool,
|
||||
ListResourcesResult, ListToolsResult, MetaObject, Prompt, Resource, ResourceContents,
|
||||
ServerInfo, ServerNotification, Tool,
|
||||
};
|
||||
use rmcp::transport::auth::{AuthClient, CredentialStore};
|
||||
use schemars::_private::NoSerialize;
|
||||
|
|
@ -167,6 +170,8 @@ impl Extension {
|
|||
pub struct ExtensionManagerCapabilities {
|
||||
pub mcpui: bool,
|
||||
pub host_info: Option<GooseMcpHostInfo>,
|
||||
pub elicitation_handler: Option<crate::agents::mcp_client::ElicitationHandler>,
|
||||
pub protocol_version: Option<rmcp::model::ProtocolVersion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
|
|
@ -492,8 +497,12 @@ fn is_oauth_auth_failure(err: &ClientInitializeError) -> bool {
|
|||
|
||||
if let Some(http_err) = error.downcast_ref::<StreamableHttpError<reqwest::Error>>() {
|
||||
return match http_err {
|
||||
StreamableHttpError::AuthRequired(_) => true,
|
||||
StreamableHttpError::UnexpectedServerResponse(body) => body.starts_with("HTTP 401"),
|
||||
StreamableHttpError::AuthRequired(_) | StreamableHttpError::InsufficientScope(_) => {
|
||||
true
|
||||
}
|
||||
StreamableHttpError::UnexpectedServerResponse(body) => {
|
||||
body.starts_with("HTTP 401") || body.starts_with("HTTP 403")
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
|
@ -504,21 +513,81 @@ fn is_oauth_auth_failure(err: &ClientInitializeError) -> bool {
|
|||
)
|
||||
{
|
||||
return match http_err {
|
||||
StreamableHttpError::AuthRequired(_) => true,
|
||||
StreamableHttpError::UnexpectedServerResponse(body) => body.starts_with("HTTP 401"),
|
||||
StreamableHttpError::AuthRequired(_) | StreamableHttpError::InsufficientScope(_) => {
|
||||
true
|
||||
}
|
||||
StreamableHttpError::UnexpectedServerResponse(body) => {
|
||||
body.starts_with("HTTP 401") || body.starts_with("HTTP 403")
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
error
|
||||
.to_string()
|
||||
.contains("unexpected server response: HTTP 401")
|
||||
let message = error.to_string();
|
||||
message.contains("unexpected server response: HTTP 401")
|
||||
|| message.contains("unexpected server response: HTTP 403")
|
||||
|| message.contains("Auth required")
|
||||
|| message.contains("Authorization required")
|
||||
}
|
||||
|
||||
fn should_attempt_oauth_fallback(res: &Result<McpClient, ClientInitializeError>) -> bool {
|
||||
res.as_ref().err().is_some_and(is_oauth_auth_failure)
|
||||
}
|
||||
|
||||
/// Extract the `WWW-Authenticate` challenge from a failed initialization, so
|
||||
/// OAuth discovery can be seeded from the server's 401/403 response instead of
|
||||
/// probing well-known locations.
|
||||
fn auth_challenge_from_error(err: &ClientInitializeError) -> Option<String> {
|
||||
let ClientInitializeError::TransportError {
|
||||
error: DynamicTransportError { error, .. },
|
||||
..
|
||||
} = err
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if let Some(http_err) = error.downcast_ref::<StreamableHttpError<reqwest::Error>>() {
|
||||
return http_err.auth_challenge().map(str::to_string);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Some(http_err) = error
|
||||
.downcast_ref::<StreamableHttpError<rmcp::transport::common::unix_socket::UnixSocketError>>(
|
||||
)
|
||||
{
|
||||
return http_err.auth_challenge().map(str::to_string);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn auth_challenge_from_result(res: &Result<McpClient, ClientInitializeError>) -> Option<String> {
|
||||
res.as_ref().err().and_then(auth_challenge_from_error)
|
||||
}
|
||||
|
||||
/// Extract the `WWW-Authenticate` challenge from a post-initialization request
|
||||
/// failure (401 auth required or 403 insufficient scope), so a step-up
|
||||
/// authorization can be started reactively.
|
||||
fn auth_challenge_from_service_error(err: &ServiceError) -> Option<String> {
|
||||
let ServiceError::TransportSend(DynamicTransportError { error, .. }) = err else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if let Some(http_err) = error.downcast_ref::<StreamableHttpError<reqwest::Error>>() {
|
||||
return http_err.auth_challenge().map(str::to_string);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Some(http_err) = error
|
||||
.downcast_ref::<StreamableHttpError<rmcp::transport::common::unix_socket::UnixSocketError>>(
|
||||
)
|
||||
{
|
||||
return http_err.auth_challenge().map(str::to_string);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn clear_credentials_on_post_refresh_auth_failure(
|
||||
credential_store: &dyn CredentialStore,
|
||||
name: &str,
|
||||
|
|
@ -528,7 +597,13 @@ async fn clear_credentials_on_post_refresh_auth_failure(
|
|||
return false;
|
||||
};
|
||||
|
||||
if !is_oauth_auth_failure(err) {
|
||||
if !is_oauth_auth_failure(err)
|
||||
|| auth_challenge_from_error(err).is_some_and(|challenge| {
|
||||
challenge
|
||||
.to_ascii_lowercase()
|
||||
.contains("insufficient_scope")
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -698,7 +773,7 @@ async fn connect_with_auth(
|
|||
capabilities: GooseMcpClientCapabilities,
|
||||
roots_dir: &std::path::Path,
|
||||
extension_manager: Weak<ExtensionManager>,
|
||||
) -> ExtensionResult<Box<dyn McpClientTrait>> {
|
||||
) -> ExtensionResult<McpClient> {
|
||||
let mut auth_headers = HeaderMap::new();
|
||||
auth_headers.insert(reqwest::header::USER_AGENT, GOOSE_USER_AGENT);
|
||||
for (key, value) in headers {
|
||||
|
|
@ -724,19 +799,307 @@ async fn connect_with_auth(
|
|||
auth_client,
|
||||
StreamableHttpClientTransportConfig::with_uri(uri),
|
||||
);
|
||||
Ok(Box::new(
|
||||
McpClient::connect(
|
||||
transport,
|
||||
timeout,
|
||||
provider,
|
||||
client_name,
|
||||
capabilities,
|
||||
roots_dir.to_path_buf(),
|
||||
action_required,
|
||||
extension_manager,
|
||||
Ok(McpClient::connect(
|
||||
transport,
|
||||
timeout,
|
||||
provider,
|
||||
client_name,
|
||||
capabilities,
|
||||
roots_dir.to_path_buf(),
|
||||
action_required,
|
||||
extension_manager,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Connection parameters needed to re-establish an authorized streamable HTTP
|
||||
/// client after a post-initialization auth challenge (401/403).
|
||||
#[derive(Clone)]
|
||||
struct StreamableHttpConnectParams {
|
||||
uri: String,
|
||||
name: String,
|
||||
timeout: Duration,
|
||||
headers: HashMap<String, String>,
|
||||
provider: SharedProvider,
|
||||
client_name: String,
|
||||
capabilities: GooseMcpClientCapabilities,
|
||||
roots_dir: PathBuf,
|
||||
action_required: Arc<ActionRequiredManager>,
|
||||
extension_manager: Weak<ExtensionManager>,
|
||||
static_oauth_client: Option<StaticOAuthClientConfig>,
|
||||
}
|
||||
|
||||
/// Wraps a streamable HTTP `McpClient` and handles step-up authorization:
|
||||
/// when a request fails with a 401/403 carrying a `WWW-Authenticate`
|
||||
/// challenge after initialization succeeded, re-authorize using the challenge
|
||||
/// (requesting the union of scopes), reconnect, and retry the request once.
|
||||
struct OAuthStepUpClient {
|
||||
inner: tokio::sync::RwLock<McpClient>,
|
||||
server_info: Option<ServerInfo>,
|
||||
params: tokio::sync::RwLock<StreamableHttpConnectParams>,
|
||||
step_up_lock: tokio::sync::Mutex<()>,
|
||||
notification_subscribers: Arc<Mutex<Vec<mpsc::Sender<ServerNotification>>>>,
|
||||
}
|
||||
|
||||
impl OAuthStepUpClient {
|
||||
async fn new(inner: McpClient, params: StreamableHttpConnectParams) -> Self {
|
||||
let server_info = inner.get_info().cloned();
|
||||
let notification_subscribers = Arc::new(Mutex::new(Vec::new()));
|
||||
Self::forward_notifications(&inner, notification_subscribers.clone()).await;
|
||||
Self {
|
||||
inner: tokio::sync::RwLock::new(inner),
|
||||
server_info,
|
||||
params: tokio::sync::RwLock::new(params),
|
||||
step_up_lock: tokio::sync::Mutex::new(()),
|
||||
notification_subscribers,
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward_notifications(
|
||||
client: &McpClient,
|
||||
subscribers: Arc<Mutex<Vec<mpsc::Sender<ServerNotification>>>>,
|
||||
) {
|
||||
let mut receiver = client.subscribe().await;
|
||||
tokio::spawn(async move {
|
||||
while let Some(notification) = receiver.recv().await {
|
||||
let mut subscribers = subscribers.lock().await;
|
||||
subscribers.retain(|subscriber| subscriber.try_send(notification.clone()).is_ok());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn step_up_reconnect(
|
||||
&self,
|
||||
challenge: String,
|
||||
) -> Result<(), crate::agents::mcp_client::Error> {
|
||||
let params = self.params.read().await;
|
||||
let auth_manager = oauth_flow_with_challenge(
|
||||
¶ms.uri,
|
||||
¶ms.name,
|
||||
params.static_oauth_client.as_ref(),
|
||||
Some(challenge),
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::agents::mcp_client::Error::McpError(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!("step-up authorization failed: {e}"),
|
||||
None,
|
||||
))
|
||||
})?;
|
||||
let client = connect_with_auth(
|
||||
auth_manager,
|
||||
params.action_required.clone(),
|
||||
¶ms.uri,
|
||||
params.timeout,
|
||||
¶ms.headers,
|
||||
params.provider.clone(),
|
||||
params.client_name.clone(),
|
||||
params.capabilities.clone(),
|
||||
¶ms.roots_dir,
|
||||
params.extension_manager.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::agents::mcp_client::Error::McpError(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!("reconnect after step-up authorization failed: {e}"),
|
||||
None,
|
||||
))
|
||||
})?;
|
||||
Self::forward_notifications(&client, self.notification_subscribers.clone()).await;
|
||||
*self.inner.write().await = client;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `op` against the current client; on an auth challenge, re-authorize
|
||||
/// and retry once.
|
||||
async fn with_step_up_retry<T, F>(&self, op: F) -> Result<T, crate::agents::mcp_client::Error>
|
||||
where
|
||||
F: for<'a> Fn(
|
||||
&'a McpClient,
|
||||
) -> Pin<
|
||||
Box<
|
||||
dyn std::future::Future<Output = Result<T, crate::agents::mcp_client::Error>>
|
||||
+ Send
|
||||
+ 'a,
|
||||
>,
|
||||
>,
|
||||
{
|
||||
let first = {
|
||||
let client = self.inner.read().await;
|
||||
op(&client).await
|
||||
};
|
||||
match first {
|
||||
Err(err) => {
|
||||
if let Some(challenge) = auth_challenge_from_service_error(&err) {
|
||||
let _step_up_guard = self.step_up_lock.lock().await;
|
||||
let retry = {
|
||||
let client = self.inner.read().await;
|
||||
op(&client).await
|
||||
};
|
||||
match retry {
|
||||
Ok(value) => Ok(value),
|
||||
Err(retry_err)
|
||||
if auth_challenge_from_service_error(&retry_err).is_some() =>
|
||||
{
|
||||
self.step_up_reconnect(challenge).await?;
|
||||
let client = self.inner.read().await;
|
||||
op(&client).await
|
||||
}
|
||||
Err(retry_err) => Err(retry_err),
|
||||
}
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
ok => ok,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpClientTrait for OAuthStepUpClient {
|
||||
async fn list_tools(
|
||||
&self,
|
||||
session_id: &str,
|
||||
next_cursor: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<rmcp::model::ListToolsResult, crate::agents::mcp_client::Error> {
|
||||
let session_id = session_id.to_string();
|
||||
self.with_step_up_retry(move |client| {
|
||||
let session_id = session_id.clone();
|
||||
let next_cursor = next_cursor.clone();
|
||||
let cancel_token = cancel_token.clone();
|
||||
Box::pin(async move {
|
||||
client
|
||||
.list_tools(&session_id, next_cursor, cancel_token)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
ctx: &ToolCallContext,
|
||||
name: &str,
|
||||
arguments: Option<rmcp::model::JsonObject>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<CallToolResult, crate::agents::mcp_client::Error> {
|
||||
let ctx = ctx.clone();
|
||||
let name = name.to_string();
|
||||
self.with_step_up_retry(move |client| {
|
||||
let ctx = ctx.clone();
|
||||
let name = name.clone();
|
||||
let arguments = arguments.clone();
|
||||
let cancel_token = cancel_token.clone();
|
||||
Box::pin(async move { client.call_tool(&ctx, &name, arguments, cancel_token).await })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn get_info(&self) -> Option<&ServerInfo> {
|
||||
self.server_info.as_ref()
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
session_id: &str,
|
||||
next_cursor: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<rmcp::model::ListResourcesResult, crate::agents::mcp_client::Error> {
|
||||
let session_id = session_id.to_string();
|
||||
self.with_step_up_retry(move |client| {
|
||||
let session_id = session_id.clone();
|
||||
let next_cursor = next_cursor.clone();
|
||||
let cancel_token = cancel_token.clone();
|
||||
Box::pin(async move {
|
||||
client
|
||||
.list_resources(&session_id, next_cursor, cancel_token)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
session_id: &str,
|
||||
uri: &str,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<rmcp::model::ReadResourceResult, crate::agents::mcp_client::Error> {
|
||||
let session_id = session_id.to_string();
|
||||
let uri = uri.to_string();
|
||||
self.with_step_up_retry(move |client| {
|
||||
let session_id = session_id.clone();
|
||||
let uri = uri.clone();
|
||||
let cancel_token = cancel_token.clone();
|
||||
Box::pin(async move { client.read_resource(&session_id, &uri, cancel_token).await })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_prompts(
|
||||
&self,
|
||||
session_id: &str,
|
||||
next_cursor: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<rmcp::model::ListPromptsResult, crate::agents::mcp_client::Error> {
|
||||
let session_id = session_id.to_string();
|
||||
self.with_step_up_retry(move |client| {
|
||||
let session_id = session_id.clone();
|
||||
let next_cursor = next_cursor.clone();
|
||||
let cancel_token = cancel_token.clone();
|
||||
Box::pin(async move {
|
||||
client
|
||||
.list_prompts(&session_id, next_cursor, cancel_token)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_prompt(
|
||||
&self,
|
||||
session_id: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<GetPromptResult, crate::agents::mcp_client::Error> {
|
||||
let session_id = session_id.to_string();
|
||||
let name = name.to_string();
|
||||
self.with_step_up_retry(move |client| {
|
||||
let session_id = session_id.clone();
|
||||
let name = name.clone();
|
||||
let arguments = arguments.clone();
|
||||
let cancel_token = cancel_token.clone();
|
||||
Box::pin(async move {
|
||||
client
|
||||
.get_prompt(&session_id, &name, arguments, cancel_token)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn subscribe(&self) -> tokio::sync::mpsc::Receiver<rmcp::model::ServerNotification> {
|
||||
let (sender, receiver) = mpsc::channel(32);
|
||||
self.notification_subscribers.lock().await.push(sender);
|
||||
receiver
|
||||
}
|
||||
|
||||
async fn get_moim(&self, session_id: &str) -> Option<String> {
|
||||
self.inner.read().await.get_moim(session_id).await
|
||||
}
|
||||
|
||||
async fn update_working_dir(
|
||||
&self,
|
||||
new_dir: PathBuf,
|
||||
) -> Result<(), crate::agents::mcp_client::Error> {
|
||||
self.params.write().await.roots_dir = new_dir.clone();
|
||||
self.inner.read().await.update_working_dir(new_dir).await
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
|
@ -810,6 +1173,20 @@ async fn create_streamable_http_client(
|
|||
StreamableHttpClientTransportConfig::with_uri(uri),
|
||||
);
|
||||
|
||||
let connect_params = StreamableHttpConnectParams {
|
||||
uri: uri.to_string(),
|
||||
name: name.to_string(),
|
||||
timeout: timeout_duration,
|
||||
headers: headers.clone(),
|
||||
provider: provider.clone(),
|
||||
client_name: client_name.clone(),
|
||||
capabilities: capabilities.clone(),
|
||||
roots_dir: roots_dir.to_path_buf(),
|
||||
action_required: action_required.clone(),
|
||||
extension_manager: extension_manager.clone(),
|
||||
static_oauth_client: static_oauth_client.clone(),
|
||||
};
|
||||
|
||||
// If we have stored OAuth credentials, try refreshing and connecting directly.
|
||||
// This avoids the unnecessary 401 → browser re-auth cycle on every new session.
|
||||
if credential_store.load().await.is_ok_and(|c| c.is_some()) {
|
||||
|
|
@ -848,10 +1225,14 @@ async fn create_streamable_http_client(
|
|||
name
|
||||
);
|
||||
} else {
|
||||
return auth_result;
|
||||
return Ok(Box::new(
|
||||
OAuthStepUpClient::new(auth_result?, connect_params).await,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return auth_result;
|
||||
return Ok(Box::new(
|
||||
OAuthStepUpClient::new(auth_result?, connect_params).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -876,15 +1257,17 @@ async fn create_streamable_http_client(
|
|||
.await;
|
||||
|
||||
if should_attempt_oauth_fallback(&client_res) {
|
||||
match oauth_flow(
|
||||
let challenge = auth_challenge_from_result(&client_res);
|
||||
match oauth_flow_with_challenge(
|
||||
&uri.to_string(),
|
||||
&name.to_string(),
|
||||
static_oauth_client.as_ref(),
|
||||
challenge,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(auth_manager) => {
|
||||
connect_with_auth(
|
||||
let client = connect_with_auth(
|
||||
auth_manager,
|
||||
action_required,
|
||||
uri,
|
||||
|
|
@ -896,7 +1279,10 @@ async fn create_streamable_http_client(
|
|||
roots_dir,
|
||||
extension_manager,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
Ok(Box::new(
|
||||
OAuthStepUpClient::new(client, connect_params).await,
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
|
|
@ -907,7 +1293,9 @@ async fn create_streamable_http_client(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Ok(Box::new(client_res?))
|
||||
Ok(Box::new(
|
||||
OAuthStepUpClient::new(client_res?, connect_params).await,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -982,6 +1370,8 @@ impl ExtensionManager {
|
|||
GooseMcpClientCapabilities {
|
||||
mcpui: self.capabilities.mcpui,
|
||||
host_info: self.capabilities.host_info.clone(),
|
||||
elicitation_handler: self.capabilities.elicitation_handler.clone(),
|
||||
protocol_version: self.capabilities.protocol_version.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1020,6 +1410,8 @@ impl ExtensionManager {
|
|||
ExtensionManagerCapabilities {
|
||||
mcpui: false,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
|
@ -1411,6 +1803,35 @@ impl ExtensionManager {
|
|||
Ok(self.filter_tools(&all_tools, extension_name.as_deref(), None))
|
||||
}
|
||||
|
||||
pub async fn list_tools_from_extension(
|
||||
&self,
|
||||
session_id: &str,
|
||||
extension_name: &str,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
let client = self
|
||||
.get_server_client(extension_name)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INVALID_PARAMS,
|
||||
format!("Extension {} is not valid", extension_name),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
|
||||
client
|
||||
.list_tools(session_id, None, cancellation_token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!("Unable to list tools for {}, {:?}", extension_name, e),
|
||||
None,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_prefixed_tools_excluding(
|
||||
&self,
|
||||
session_id: &str,
|
||||
|
|
@ -1729,12 +2150,12 @@ impl ExtensionManager {
|
|||
Ok(ui_resources)
|
||||
}
|
||||
|
||||
async fn list_resources_from_extension(
|
||||
pub async fn list_resources_result_from_extension(
|
||||
&self,
|
||||
session_id: &str,
|
||||
extension_name: &str,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<Vec<ContentBlock>, ErrorData> {
|
||||
) -> Result<ListResourcesResult, ErrorData> {
|
||||
let client = self
|
||||
.get_server_client(extension_name)
|
||||
.await
|
||||
|
|
@ -1756,6 +2177,16 @@ impl ExtensionManager {
|
|||
None,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_resources_from_extension(
|
||||
&self,
|
||||
session_id: &str,
|
||||
extension_name: &str,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<Vec<ContentBlock>, ErrorData> {
|
||||
self.list_resources_result_from_extension(session_id, extension_name, cancellation_token)
|
||||
.await
|
||||
.map(|lr| {
|
||||
let resource_list = lr
|
||||
.resources
|
||||
|
|
@ -3684,6 +4115,8 @@ mod tests {
|
|||
let capabilities = GooseMcpClientCapabilities {
|
||||
mcpui: false,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
};
|
||||
|
||||
let result = create_streamable_http_client(
|
||||
|
|
@ -3722,6 +4155,8 @@ mod tests {
|
|||
let capabilities = GooseMcpClientCapabilities {
|
||||
mcpui: false,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
};
|
||||
|
||||
let result = create_streamable_http_client(
|
||||
|
|
@ -3769,6 +4204,8 @@ mod tests {
|
|||
let capabilities = GooseMcpClientCapabilities {
|
||||
mcpui: false,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
};
|
||||
|
||||
// The MCP handshake will fail against the stub server. We only care that
|
||||
|
|
@ -3855,6 +4292,8 @@ mod tests {
|
|||
let capabilities = GooseMcpClientCapabilities {
|
||||
mcpui: false,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
};
|
||||
|
||||
// connect_with_auth will fail (mock server isn't an MCP server) but we
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ use rmcp::{
|
|||
CallToolRequestParams, CallToolResult, CancelledNotificationParam, ClientCapabilities,
|
||||
ClientInfo, ClientRequest, GetPromptRequestParams, GetPromptResult, Implementation,
|
||||
InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourcesResult,
|
||||
ListToolsResult, Notification, PaginatedRequestParams, ReadResourceRequestParams,
|
||||
ReadResourceResult, Request, RequestId, RequestOptionalParam, Role, ServerNotification,
|
||||
ServerResult,
|
||||
ListToolsResult, Notification, PaginatedRequestParams, ProtocolVersion,
|
||||
ReadResourceRequestParams, ReadResourceResult, Request, RequestId, RequestOptionalParam,
|
||||
Role, ServerNotification, ServerResult,
|
||||
},
|
||||
service::{
|
||||
ClientInitializeError, PeerRequestOptions, RequestContext, RequestHandle, RunningService,
|
||||
ServiceRole,
|
||||
ClientInitializeError, ClientLifecycleMode, ClientServiceExt, PeerRequestOptions,
|
||||
RequestContext, RequestHandle, RunningService, ServiceRole,
|
||||
},
|
||||
transport::IntoTransport,
|
||||
ClientHandler, ErrorData, Peer, RoleClient, ServiceError, ServiceExt,
|
||||
ClientHandler, ErrorData, Peer, RoleClient, ServiceError,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
|
|
@ -499,6 +499,10 @@ impl ClientHandler for GooseClient {
|
|||
request: ElicitRequestParams,
|
||||
context: RequestContext<RoleClient>,
|
||||
) -> Result<ElicitResult, ErrorData> {
|
||||
if let Some(handler) = &self.capabilities.elicitation_handler {
|
||||
return Ok(handler(&request));
|
||||
}
|
||||
|
||||
let session_id = self
|
||||
.resolve_session_id(&context.extensions)
|
||||
.await
|
||||
|
|
@ -571,18 +575,40 @@ impl ClientHandler for GooseClient {
|
|||
.build(),
|
||||
self.resolved_client_info(),
|
||||
)
|
||||
.with_protocol_version(
|
||||
self.capabilities
|
||||
.protocol_version
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub type ElicitationHandler = Arc<dyn Fn(&ElicitRequestParams) -> ElicitResult + Send + Sync>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct GooseMcpClientCapabilities {
|
||||
pub mcpui: bool,
|
||||
pub host_info: Option<GooseMcpHostInfo>,
|
||||
pub elicitation_handler: Option<ElicitationHandler>,
|
||||
pub protocol_version: Option<ProtocolVersion>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GooseMcpClientCapabilities {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("GooseMcpClientCapabilities")
|
||||
.field("mcpui", &self.mcpui)
|
||||
.field("host_info", &self.host_info)
|
||||
.field("elicitation_handler", &self.elicitation_handler.is_some())
|
||||
.field("protocol_version", &self.protocol_version)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The MCP client is the interface for MCP operations.
|
||||
pub struct McpClient {
|
||||
client: Mutex<RunningService<RoleClient, GooseClient>>,
|
||||
client: Mutex<Arc<RunningService<RoleClient, GooseClient>>>,
|
||||
notification_subscribers: Arc<Mutex<Vec<mpsc::Sender<ServerNotification>>>>,
|
||||
server_info: Option<InitializeResult>,
|
||||
timeout: std::time::Duration,
|
||||
|
|
@ -648,7 +674,26 @@ impl McpClient {
|
|||
extension_manager,
|
||||
);
|
||||
let client: rmcp::service::RunningService<rmcp::RoleClient, GooseClient> =
|
||||
client.serve(transport).await?;
|
||||
if let Some(protocol_version) = capabilities.protocol_version {
|
||||
let lifecycle = if protocol_version >= ProtocolVersion::STANDARD_HEADERS {
|
||||
ClientLifecycleMode::Discover {
|
||||
preferred_versions: vec![protocol_version],
|
||||
}
|
||||
} else {
|
||||
ClientLifecycleMode::Initialize
|
||||
};
|
||||
client.serve_with_lifecycle(transport, lifecycle).await?
|
||||
} else {
|
||||
client
|
||||
.serve_with_lifecycle(
|
||||
transport,
|
||||
ClientLifecycleMode::Auto {
|
||||
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
|
||||
legacy_version: Some(ProtocolVersion::LATEST),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let server_info = client.peer_info().map(|info| {
|
||||
let mut initialize_result = InitializeResult::new(info.capabilities.clone())
|
||||
.with_protocol_version(info.protocol_version.clone());
|
||||
|
|
@ -661,7 +706,7 @@ impl McpClient {
|
|||
});
|
||||
|
||||
Ok(Self {
|
||||
client: Mutex::new(client),
|
||||
client: Mutex::new(Arc::new(client)),
|
||||
notification_subscribers,
|
||||
server_info,
|
||||
timeout,
|
||||
|
|
@ -788,14 +833,27 @@ impl McpClientTrait for McpClient {
|
|||
uri: &str,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<ReadResourceResult, Error> {
|
||||
let params = ReadResourceRequestParams::new(uri.to_string());
|
||||
let client = self.client.lock().await.clone();
|
||||
if client
|
||||
.peer_info()
|
||||
.is_some_and(|info| info.protocol_version == ProtocolVersion::V_2026_07_28)
|
||||
{
|
||||
client.service().set_session_id(session_id).await;
|
||||
return tokio::select! {
|
||||
result = client.read_resource(params) => result,
|
||||
_ = tokio::time::sleep(self.timeout) => Err(ServiceError::Timeout { timeout: self.timeout }),
|
||||
_ = cancel_token.cancelled() => Err(ServiceError::Cancelled { reason: None }),
|
||||
};
|
||||
}
|
||||
drop(client);
|
||||
|
||||
let res = self
|
||||
.send_request_with_context(
|
||||
session_id,
|
||||
None,
|
||||
None,
|
||||
ClientRequest::ReadResourceRequest(Request::new(ReadResourceRequestParams::new(
|
||||
uri.to_string(),
|
||||
))),
|
||||
ClientRequest::ReadResourceRequest(Request::new(params)),
|
||||
cancel_token,
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -841,6 +899,46 @@ impl McpClientTrait for McpClient {
|
|||
if let Some(args) = arguments {
|
||||
params = params.with_arguments(args);
|
||||
}
|
||||
let protocol_version = {
|
||||
let client = self.client.lock().await;
|
||||
client.peer_info().map(|info| info.protocol_version.clone())
|
||||
};
|
||||
if protocol_version.as_ref() == Some(&ProtocolVersion::V_2026_07_28) {
|
||||
let extensions = inject_session_context_into_extensions(
|
||||
Extensions::new(),
|
||||
Some(&ctx.session_id),
|
||||
ctx.working_dir_str(),
|
||||
ctx.tool_call_request_id.as_deref(),
|
||||
);
|
||||
if let Some(meta) = extensions.get::<MetaObject>() {
|
||||
params.meta.get_or_insert_default().0 .0.extend(
|
||||
meta.0
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone())),
|
||||
);
|
||||
}
|
||||
let client = self.client.lock().await.clone();
|
||||
client.service().set_session_id(&ctx.session_id).await;
|
||||
let _active_tool_call_guard = ctx
|
||||
.tool_call_request_id
|
||||
.as_deref()
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(|tool_call_request_id| {
|
||||
client
|
||||
.service()
|
||||
.register_active_tool_call(&ctx.session_id, tool_call_request_id)
|
||||
});
|
||||
return tokio::select! {
|
||||
result = client.call_tool(params) => result,
|
||||
_ = tokio::time::sleep(self.timeout) => {
|
||||
Err(ServiceError::Timeout { timeout: self.timeout })
|
||||
}
|
||||
_ = cancel_token.cancelled() => {
|
||||
Err(ServiceError::Cancelled { reason: None })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let request = ClientRequest::CallToolRequest(Request::new(params));
|
||||
|
||||
let result = self
|
||||
|
|
@ -898,6 +996,20 @@ impl McpClientTrait for McpClient {
|
|||
if let Some(args) = arguments {
|
||||
params = params.with_arguments(args);
|
||||
}
|
||||
let client = self.client.lock().await.clone();
|
||||
if client
|
||||
.peer_info()
|
||||
.is_some_and(|info| info.protocol_version == ProtocolVersion::V_2026_07_28)
|
||||
{
|
||||
client.service().set_session_id(session_id).await;
|
||||
return tokio::select! {
|
||||
result = client.get_prompt(params) => result,
|
||||
_ = tokio::time::sleep(self.timeout) => Err(ServiceError::Timeout { timeout: self.timeout }),
|
||||
_ = cancel_token.cancelled() => Err(ServiceError::Cancelled { reason: None }),
|
||||
};
|
||||
}
|
||||
drop(client);
|
||||
|
||||
let res = self
|
||||
.send_request_with_context(
|
||||
session_id,
|
||||
|
|
@ -1103,10 +1215,14 @@ mod tests {
|
|||
GoosePlatform::GooseDesktop => GooseMcpClientCapabilities {
|
||||
mcpui: true,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
},
|
||||
GoosePlatform::GooseCli => GooseMcpClientCapabilities {
|
||||
mcpui: false,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -1157,6 +1273,8 @@ mod tests {
|
|||
GooseMcpClientCapabilities {
|
||||
mcpui: false,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
},
|
||||
temp_dir.path().to_path_buf(),
|
||||
Arc::new(ActionRequiredManager::new()),
|
||||
|
|
@ -1533,6 +1651,8 @@ mod tests {
|
|||
client_name: Some("goose2".to_string()),
|
||||
client_version: Some("0.1.0".to_string()),
|
||||
}),
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
},
|
||||
std::env::current_dir().unwrap_or_default(),
|
||||
Arc::new(ActionRequiredManager::new()),
|
||||
|
|
@ -1566,6 +1686,8 @@ mod tests {
|
|||
client_name: Some("goose2".to_string()),
|
||||
client_version: Some("0.1.0".to_string()),
|
||||
}),
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
},
|
||||
std::env::current_dir().unwrap_or_default(),
|
||||
Arc::new(ActionRequiredManager::new()),
|
||||
|
|
@ -1596,6 +1718,8 @@ mod tests {
|
|||
client_name: Some("goose2".to_string()),
|
||||
client_version: Some("0.1.0".to_string()),
|
||||
}),
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
},
|
||||
std::env::current_dir().unwrap_or_default(),
|
||||
Arc::new(ActionRequiredManager::new()),
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ pub mod subagent_execution_tool;
|
|||
pub(crate) mod subagent_handler;
|
||||
pub(crate) mod subagent_task_config;
|
||||
mod tool_confirmation_router;
|
||||
mod tool_execution;
|
||||
pub mod tool_execution;
|
||||
mod tool_schema_normalize;
|
||||
pub mod types;
|
||||
pub mod validate_extensions;
|
||||
|
||||
pub use agent::{Agent, AgentConfig, ExtensionLoadResult, GoosePlatform};
|
||||
pub use agent::{Agent, AgentConfig, ExtensionLoadResult, GoosePlatform, MCP_PROTOCOL_VERSION};
|
||||
pub use container::Container;
|
||||
pub use execute_commands::{context_management_unsupported_message, COMPACT_TRIGGERS};
|
||||
pub use extension::{ExtensionConfig, ExtensionError};
|
||||
|
|
|
|||
|
|
@ -763,6 +763,8 @@ async fn build_test_pipeline(
|
|||
ExtensionManagerCapabilities {
|
||||
mcpui: false,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: None,
|
||||
},
|
||||
false,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use minijinja::render;
|
|||
use oauth2::{Scope, TokenResponse};
|
||||
use rmcp::transport::auth::{
|
||||
AuthError, AuthorizationRequest, CredentialStore, OAuthClientConfig, OAuthState,
|
||||
OAuthTokenResponse, StoredCredentials,
|
||||
OAuthTokenResponse, StoredCredentials, WWWAuthenticateParams,
|
||||
};
|
||||
use rmcp::transport::AuthorizationManager;
|
||||
use serde::Deserialize;
|
||||
|
|
@ -26,9 +26,20 @@ const CLIENT_METADATA_URL: &str = "https://goose-docs.ai/oauth/client-metadata.j
|
|||
const DEFAULT_OAUTH_CALLBACK_TIMEOUT_SECS: u64 = 300;
|
||||
const OAUTH_CALLBACK_TIMEOUT_ENV: &str = "GOOSE_OAUTH_CALLBACK_TIMEOUT_SECONDS";
|
||||
|
||||
/// Pre-registered OAuth client supplied by a probe script, for servers whose
|
||||
/// authorization server supports neither Dynamic Client Registration nor
|
||||
/// Client ID Metadata Documents.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OAuthFlowConfig {
|
||||
pub client_id: Option<String>,
|
||||
pub client_secret: Option<String>,
|
||||
pub client_metadata_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
code_receiver: Arc<Mutex<Option<oneshot::Sender<CallbackParams>>>>,
|
||||
callback_receiver: Arc<Mutex<Option<oneshot::Sender<String>>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -62,14 +73,45 @@ fn announce_authorization_url(name: &str, authorization_url: &str) {
|
|||
);
|
||||
}
|
||||
|
||||
async fn complete_automatic_authorization(
|
||||
authorization_url: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<Option<String>, anyhow::Error> {
|
||||
if std::env::var_os("GOOSE_OAUTH_AUTOMATIC_CALLBACK").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?
|
||||
.get(authorization_url)
|
||||
.send()
|
||||
.await?;
|
||||
let location = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.ok_or_else(|| anyhow::anyhow!("authorization response did not include Location"))?
|
||||
.to_str()?;
|
||||
let callback_url = url::Url::parse(location)?;
|
||||
let expected_redirect = url::Url::parse(redirect_uri)?;
|
||||
if callback_url.scheme() != expected_redirect.scheme()
|
||||
|| callback_url.host_str() != expected_redirect.host_str()
|
||||
|| callback_url.port_or_known_default() != expected_redirect.port_or_known_default()
|
||||
|| callback_url.path() != expected_redirect.path()
|
||||
{
|
||||
anyhow::bail!("authorization response redirected to an unexpected callback URI");
|
||||
}
|
||||
Ok(Some(callback_url.to_string()))
|
||||
}
|
||||
|
||||
async fn wait_for_callback(
|
||||
code_receiver: oneshot::Receiver<CallbackParams>,
|
||||
callback_receiver: oneshot::Receiver<String>,
|
||||
timeout_duration: Duration,
|
||||
name: &str,
|
||||
authorization_url: &str,
|
||||
) -> Result<CallbackParams, anyhow::Error> {
|
||||
match tokio::time::timeout(timeout_duration, code_receiver).await {
|
||||
Ok(Ok(params)) => Ok(params),
|
||||
) -> Result<String, anyhow::Error> {
|
||||
match tokio::time::timeout(timeout_duration, callback_receiver).await {
|
||||
Ok(Ok(callback_url)) => Ok(callback_url),
|
||||
Ok(Err(e)) => Err(anyhow::anyhow!(
|
||||
"OAuth authorization for {} ended before the callback was received: {}",
|
||||
name,
|
||||
|
|
@ -101,6 +143,22 @@ pub struct StaticOAuthClientConfig {
|
|||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Pre-registered client supplied through the environment, used by tools that
|
||||
/// drive the flow without an extension config (`goose mcp-probe`, conformance
|
||||
/// driver).
|
||||
fn env_static_oauth_client() -> Option<StaticOAuthClientConfig> {
|
||||
Some(StaticOAuthClientConfig {
|
||||
client_id: std::env::var("GOOSE_MCP_OAUTH_CLIENT_ID").ok()?,
|
||||
client_secret: std::env::var("GOOSE_MCP_OAUTH_CLIENT_SECRET").ok(),
|
||||
scopes: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn client_metadata_url() -> String {
|
||||
std::env::var("GOOSE_MCP_OAUTH_CLIENT_METADATA_URL")
|
||||
.unwrap_or_else(|_| CLIENT_METADATA_URL.to_string())
|
||||
}
|
||||
|
||||
fn scope_set(scopes: &[String]) -> BTreeSet<&str> {
|
||||
scopes.iter().map(String::as_str).collect()
|
||||
}
|
||||
|
|
@ -167,6 +225,9 @@ fn restore_omitted_scopes(
|
|||
fn build_authorization_request(
|
||||
redirect_uri: String,
|
||||
static_client: Option<&StaticOAuthClientConfig>,
|
||||
challenge: Option<String>,
|
||||
mcp_server_url: &str,
|
||||
previously_granted_scopes: &[String],
|
||||
) -> AuthorizationRequest {
|
||||
let mut request = AuthorizationRequest::new(redirect_uri).with_client_name("goose");
|
||||
match static_client {
|
||||
|
|
@ -180,9 +241,34 @@ fn build_authorization_request(
|
|||
}
|
||||
}
|
||||
None => {
|
||||
request = request.with_client_metadata_url(CLIENT_METADATA_URL);
|
||||
request = request.with_client_metadata_url(client_metadata_url());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(challenge) = challenge {
|
||||
// SEP-2350: a re-authorization triggered by a scope challenge requests
|
||||
// the union of previously-granted scopes and the newly challenged
|
||||
// scopes. The fresh AuthorizationManager has no scope memory, so seed
|
||||
// the union from the stored grant.
|
||||
let mut scopes = previously_granted_scopes.to_vec();
|
||||
scopes.extend(
|
||||
static_client
|
||||
.into_iter()
|
||||
.flat_map(|client| client.scopes.iter().cloned()),
|
||||
);
|
||||
if let Ok(base_url) = url::Url::parse(mcp_server_url) {
|
||||
if let Some(challenged) = WWWAuthenticateParams::parse(&challenge, &base_url).scope {
|
||||
scopes.extend(challenged.split_whitespace().map(str::to_string));
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
scopes.retain(|scope| seen.insert(scope.clone()));
|
||||
if !scopes.is_empty() {
|
||||
request = request.with_scopes(scopes);
|
||||
}
|
||||
request = request.with_challenge(challenge);
|
||||
}
|
||||
|
||||
request
|
||||
}
|
||||
|
||||
|
|
@ -191,14 +277,33 @@ pub async fn oauth_flow(
|
|||
name: &String,
|
||||
static_client: Option<&StaticOAuthClientConfig>,
|
||||
) -> Result<AuthorizationManager, anyhow::Error> {
|
||||
oauth_flow_with_challenge(mcp_server_url, name, static_client, None).await
|
||||
}
|
||||
|
||||
pub async fn oauth_flow_with_challenge(
|
||||
mcp_server_url: &String,
|
||||
name: &String,
|
||||
static_client: Option<&StaticOAuthClientConfig>,
|
||||
challenge: Option<String>,
|
||||
) -> Result<AuthorizationManager, anyhow::Error> {
|
||||
let env_client = env_static_oauth_client();
|
||||
let static_client = static_client.or(env_client.as_ref());
|
||||
let credential_store = GooseCredentialStore::new(name.clone());
|
||||
let mut auth_manager = AuthorizationManager::new(mcp_server_url).await?;
|
||||
auth_manager.set_credential_store(credential_store.clone());
|
||||
|
||||
let stored_credentials = credential_store.load().await?;
|
||||
let previous_requested_scopes = credential_store.load_requested_scopes()?;
|
||||
let previously_granted_scopes = stored_credentials
|
||||
.as_ref()
|
||||
.map(|stored| stored.granted_scopes.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
if auth_manager.initialize_from_store().await? {
|
||||
// With a challenge in hand (e.g. a 403 insufficient_scope after a
|
||||
// previously successful authorization), a refresh cannot satisfy the new
|
||||
// scope requirement: skip straight to a full re-authorization that
|
||||
// requests the union of scopes.
|
||||
if auth_manager.initialize_from_store().await? && challenge.is_none() {
|
||||
let stored_credentials = stored_credentials
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OAuth credentials disappeared during startup"))?;
|
||||
|
|
@ -271,18 +376,26 @@ pub async fn oauth_flow(
|
|||
}
|
||||
}
|
||||
|
||||
// No existing credentials or they were invalid - need to do the full oauth flow
|
||||
let (code_sender, code_receiver) = oneshot::channel::<CallbackParams>();
|
||||
let (callback_sender, callback_receiver) = oneshot::channel::<String>();
|
||||
let app_state = AppState {
|
||||
code_receiver: Arc::new(Mutex::new(Some(code_sender))),
|
||||
callback_receiver: Arc::new(Mutex::new(Some(callback_sender))),
|
||||
};
|
||||
|
||||
let rendered = render!(CALLBACK_TEMPLATE, name => name);
|
||||
let handler = move |Query(params): Query<CallbackParams>, State(state): State<AppState>| {
|
||||
let rendered = rendered.clone();
|
||||
async move {
|
||||
if let Some(sender) = state.code_receiver.lock().await.take() {
|
||||
let _ = sender.send(params);
|
||||
if let Some(sender) = state.callback_receiver.lock().await.take() {
|
||||
let query = serde_urlencoded::to_string([
|
||||
("code", params.code.as_str()),
|
||||
("state", params.state.as_str()),
|
||||
])
|
||||
.unwrap_or_default();
|
||||
let issuer = params
|
||||
.iss
|
||||
.as_deref()
|
||||
.map(|iss| format!("&iss={}", urlencoding::encode(iss)))
|
||||
.unwrap_or_default();
|
||||
let _ = sender.send(format!("http://callback/oauth_callback?{query}{issuer}"));
|
||||
}
|
||||
Html(rendered)
|
||||
}
|
||||
|
|
@ -291,60 +404,66 @@ pub async fn oauth_flow(
|
|||
.route("/oauth_callback", get(handler))
|
||||
.with_state(app_state);
|
||||
|
||||
let port: u16 = std::env::var("GOOSE_OAUTH_CALLBACK_PORT")
|
||||
let port = std::env::var("GOOSE_OAUTH_CALLBACK_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port))).await?;
|
||||
let used_addr = listener.local_addr()?;
|
||||
let server_handle = tokio::spawn(async move {
|
||||
let result = axum::serve(listener, app).await;
|
||||
if let Err(e) = result {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
eprintln!("Callback server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let mut oauth_state = OAuthState::new(mcp_server_url, None).await?;
|
||||
|
||||
let redirect_uri = format!("http://127.0.0.1:{}/oauth_callback", used_addr.port());
|
||||
oauth_state
|
||||
.start_authorization(build_authorization_request(redirect_uri, static_client))
|
||||
.start_authorization(build_authorization_request(
|
||||
redirect_uri.clone(),
|
||||
static_client,
|
||||
challenge,
|
||||
mcp_server_url,
|
||||
&previously_granted_scopes,
|
||||
))
|
||||
.await?;
|
||||
|
||||
let authorization_url = oauth_state.get_authorization_url().await?;
|
||||
announce_authorization_url(name, authorization_url.as_str());
|
||||
if let Err(e) = webbrowser::open(authorization_url.as_str()) {
|
||||
warn!(
|
||||
"[OAuth:{}] Failed to open browser automatically: {}",
|
||||
name, e
|
||||
);
|
||||
let callback_url = async {
|
||||
if let Some(callback_url) =
|
||||
complete_automatic_authorization(authorization_url.as_str(), &redirect_uri).await?
|
||||
{
|
||||
Ok(callback_url)
|
||||
} else {
|
||||
announce_authorization_url(name, authorization_url.as_str());
|
||||
if let Err(e) = webbrowser::open(authorization_url.as_str()) {
|
||||
warn!(
|
||||
"[OAuth:{}] Failed to open browser automatically: {}",
|
||||
name, e
|
||||
);
|
||||
}
|
||||
wait_for_callback(
|
||||
callback_receiver,
|
||||
oauth_callback_timeout(),
|
||||
name,
|
||||
authorization_url.as_str(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
let callback_params = wait_for_callback(
|
||||
code_receiver,
|
||||
oauth_callback_timeout(),
|
||||
name,
|
||||
authorization_url.as_str(),
|
||||
)
|
||||
.await;
|
||||
server_handle.abort();
|
||||
let CallbackParams {
|
||||
code: auth_code,
|
||||
state: csrf_token,
|
||||
iss,
|
||||
} = callback_params?;
|
||||
oauth_state
|
||||
.handle_callback_with_issuer(&auth_code, &csrf_token, iss.as_deref())
|
||||
.await?;
|
||||
oauth_state.handle_callback_url(&callback_url?).await?;
|
||||
|
||||
let (client_id, token_response) = oauth_state.get_credentials().await?;
|
||||
|
||||
let mut auth_manager = oauth_state
|
||||
.into_authorization_manager()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?;
|
||||
|
||||
let granted_scopes = auth_manager.get_current_scopes().await;
|
||||
let granted_scopes = match token_response.as_ref().and_then(|tr| tr.scopes()) {
|
||||
Some(scopes) => scopes.iter().map(|scope| scope.to_string()).collect(),
|
||||
None => auth_manager.get_current_scopes().await,
|
||||
};
|
||||
credential_store.save_with_requested_scopes(
|
||||
StoredCredentials::new(
|
||||
client_id,
|
||||
|
|
@ -394,17 +513,12 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_callback_returns_received_callback_params() {
|
||||
async fn wait_for_callback_returns_received_callback_url() {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
sender
|
||||
.send(CallbackParams {
|
||||
code: "auth-code".to_string(),
|
||||
state: "csrf-state".to_string(),
|
||||
iss: Some("https://auth.example".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
let expected = "http://callback/oauth_callback?code=auth-code&state=csrf-state";
|
||||
sender.send(expected.to_string()).unwrap();
|
||||
|
||||
let params = wait_for_callback(
|
||||
let callback_url = wait_for_callback(
|
||||
receiver,
|
||||
Duration::from_secs(1),
|
||||
"test-server",
|
||||
|
|
@ -413,9 +527,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(params.code, "auth-code");
|
||||
assert_eq!(params.state, "csrf-state");
|
||||
assert_eq!(params.iss.as_deref(), Some("https://auth.example"));
|
||||
assert_eq!(callback_url, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -557,8 +669,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn authorization_request_uses_client_metadata_url_without_static_client() {
|
||||
let request =
|
||||
build_authorization_request("http://127.0.0.1:1234/oauth_callback".to_string(), None);
|
||||
let request = build_authorization_request(
|
||||
"http://127.0.0.1:1234/oauth_callback".to_string(),
|
||||
None,
|
||||
None,
|
||||
"https://mcp.example",
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(request.client_id, None);
|
||||
assert_eq!(request.client_secret, None);
|
||||
|
|
@ -580,6 +697,9 @@ mod tests {
|
|||
let request = build_authorization_request(
|
||||
"http://127.0.0.1:1234/oauth_callback".to_string(),
|
||||
Some(&static_client),
|
||||
None,
|
||||
"https://mcp.example",
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(request.client_id.as_deref(), Some("registered-client"));
|
||||
|
|
@ -599,6 +719,9 @@ mod tests {
|
|||
let request = build_authorization_request(
|
||||
"http://127.0.0.1:1234/oauth_callback".to_string(),
|
||||
Some(&static_client),
|
||||
None,
|
||||
"https://mcp.example",
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(request.client_id.as_deref(), Some("registered-client"));
|
||||
|
|
@ -607,6 +730,45 @@ mod tests {
|
|||
assert!(request.scopes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_request_asks_for_the_union_of_granted_and_challenged_scopes() {
|
||||
let request = build_authorization_request(
|
||||
"http://127.0.0.1:1234/oauth_callback".to_string(),
|
||||
None,
|
||||
Some(
|
||||
r#"Bearer error="insufficient_scope", scope="scope.write scope.admin""#.to_string(),
|
||||
),
|
||||
"https://mcp.example",
|
||||
&["scope.read".to_string(), "scope.write".to_string()],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
request.scopes,
|
||||
vec!["scope.read", "scope.write", "scope.admin"]
|
||||
);
|
||||
assert!(request.challenge.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_request_keeps_static_client_scopes() {
|
||||
let static_client = StaticOAuthClientConfig {
|
||||
client_id: "registered-client".to_string(),
|
||||
client_secret: None,
|
||||
scopes: vec!["scope.read".to_string()],
|
||||
};
|
||||
|
||||
let request = build_authorization_request(
|
||||
"http://127.0.0.1:1234/oauth_callback".to_string(),
|
||||
Some(&static_client),
|
||||
Some(r#"Bearer error="insufficient_scope", scope="scope.write""#.to_string()),
|
||||
"https://mcp.example",
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(request.client_id.as_deref(), Some("registered-client"));
|
||||
assert_eq!(request.scopes, vec!["scope.read", "scope.write"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_callback_times_out_with_authorization_url() {
|
||||
let (_sender, receiver) = oneshot::channel();
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use tokio_util::sync::CancellationToken;
|
|||
|
||||
use goose::agents::extension::{Envs, ExtensionConfig};
|
||||
use goose::agents::extension_manager::{ExtensionManager, ExtensionManagerCapabilities};
|
||||
use goose::agents::GoosePlatform;
|
||||
use goose::agents::{GoosePlatform, MCP_PROTOCOL_VERSION};
|
||||
use goose_providers::model::ModelConfig;
|
||||
|
||||
use test_case::test_case;
|
||||
|
|
@ -167,18 +167,23 @@ async fn test_replayed_session(
|
|||
tool_calls: Vec<CallToolRequestParams>,
|
||||
required_envs: Vec<&str>,
|
||||
) {
|
||||
// The working directory is sent to the server verbatim in our `roots/list`
|
||||
// response, so it is part of the recorded protocol traffic. It must be an
|
||||
// absolute path (relative paths are not convertible to a `file://` URL) and
|
||||
// it must be stable across machines, otherwise playback compares a recorded
|
||||
// path against whatever cwd the test happens to run in.
|
||||
const TEST_WORKING_DIR: &str = "/tmp/goose_test";
|
||||
fs::create_dir_all(TEST_WORKING_DIR).ok();
|
||||
|
||||
let _env = env_lock::lock_env([
|
||||
("GOOSE_MCP_CLIENT_VERSION", Some("0.0.0")),
|
||||
("GOOSE_PROVIDER", Some("openai")),
|
||||
("GOOSE_MODEL", Some("gpt-4o")),
|
||||
("GOOSE_WORKING_DIR", Some("/tmp/goose_test")),
|
||||
("GOOSE_WORKING_DIR", Some(TEST_WORKING_DIR)),
|
||||
]);
|
||||
|
||||
// Setup test file for developer extension tests
|
||||
let test_file_path = "/tmp/goose_test/goose.txt";
|
||||
if let Some(parent) = std::path::Path::new(test_file_path).parent() {
|
||||
fs::create_dir_all(parent).ok();
|
||||
}
|
||||
fs::write(test_file_path, "# goose\n").ok();
|
||||
let replay_file_name = command
|
||||
.iter()
|
||||
|
|
@ -257,6 +262,8 @@ async fn test_replayed_session(
|
|||
ExtensionManagerCapabilities {
|
||||
mcpui: true,
|
||||
host_info: None,
|
||||
elicitation_handler: None,
|
||||
protocol_version: Some(MCP_PROTOCOL_VERSION),
|
||||
},
|
||||
true,
|
||||
));
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"roots":{},"sampling":{},"elicitation":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}}
|
||||
STDERR: warning: The `native-tls` setting is deprecated and will be removed in a future release. Use `system-certs` instead.
|
||||
STDERR: /Users/jackamadeo/.cache/uv/archive-v0/qVA6xesA-RY1n0SG/lib/python3.14/site-packages/fastmcp/server/auth/providers/jwt.py:10: AuthlibDeprecationWarning: authlib.jose module is deprecated, please use joserfc instead.
|
||||
STDERR: /Users/jackamadeo/.cache/uv/archive-v0/al1Eg6tdenSv08iE/lib/python3.14/site-packages/fastmcp/server/auth/providers/jwt.py:10: AuthlibDeprecationWarning: authlib.jose module is deprecated, please use joserfc instead.
|
||||
STDERR: It will be compatible before version 2.0.0.
|
||||
STDERR: from authlib.jose import JsonWebKey, JsonWebToken
|
||||
STDERR:
|
||||
|
|
@ -25,14 +25,14 @@ STDERR: │ ✨ FastMCP 3.0 is coming!
|
|||
STDERR: │ Pin `fastmcp < 3` in production, then upgrade when you're ready. │
|
||||
STDERR: ╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
STDERR: ╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
STDERR: │ 🎉 Update available: 3.4.5 │
|
||||
STDERR: │ 🎉 Update available: 3.4.7 │
|
||||
STDERR: │ Run: pip install --upgrade fastmcp │
|
||||
STDERR: ╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
STDERR:
|
||||
STDERR:
|
||||
STDERR: [07/29/26 10:52:55] INFO Starting MCP server 'mymcp' with server.py:2506
|
||||
STDERR: [08/11/26 11:30:18] INFO Starting MCP server 'mymcp' with server.py:2506
|
||||
STDERR: transport 'stdio'
|
||||
STDERR: /Users/jackamadeo/.cache/uv/archive-v0/qVA6xesA-RY1n0SG/lib/python3.14/site-packages/redis/asyncio/connection.py:1628: DeprecationWarning: FakeConnection is deprecated. Use FakeAsyncRedisConnection instead
|
||||
STDERR: /Users/jackamadeo/.cache/uv/archive-v0/al1Eg6tdenSv08iE/lib/python3.14/site-packages/redis/asyncio/connection.py:2861: DeprecationWarning: FakeConnection is deprecated. Use FakeAsyncRedisConnection instead
|
||||
STDERR: return self.connection_class(**self.connection_kwargs)
|
||||
STDOUT: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":true},"tasks":{"list":{},"cancel":{},"requests":{"tools":{"call":{}},"prompts":{"get":{}},"resources":{"read":{}}}}},"serverInfo":{"name":"mymcp","version":"2.14.4"}}}
|
||||
STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue