add MCP conformance tests to goose CI (combines #10800 + #10801) (#10940)

Signed-off-by: Jack Amadeo <jackamadeo@squareup.com>
Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
This commit is contained in:
Alex Hancock 2026-08-20 21:51:53 +00:00 committed by GitHub
parent 810bb68fff
commit 2f5169af35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1380 additions and 126 deletions

91
.github/workflows/mcp-conformance.yml vendored Normal file
View 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 }}"

View file

@ -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

View file

@ -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 }

View 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));
}

View file

@ -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,
}
}

View file

@ -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(

View file

@ -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: []

View file

@ -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: []

View file

@ -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: []

View file

@ -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()

View file

@ -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(
&params.uri,
&params.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(),
&params.uri,
params.timeout,
&params.headers,
params.provider.clone(),
params.client_name.clone(),
params.capabilities.clone(),
&params.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

View file

@ -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()),

View file

@ -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};

View file

@ -763,6 +763,8 @@ async fn build_test_pipeline(
ExtensionManagerCapabilities {
mcpui: false,
host_info: None,
elicitation_handler: None,
protocol_version: None,
},
false,
));

View file

@ -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();

View file

@ -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

View file

@ -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"}