Skip to content

feat: MCP support for agentic CLI providers#6972

Merged
codefromthecrypt merged 5 commits intomainfrom
cli-provider-mcp
Feb 11, 2026
Merged

feat: MCP support for agentic CLI providers#6972
codefromthecrypt merged 5 commits intomainfrom
cli-provider-mcp

Conversation

@codefromthecrypt
Copy link
Collaborator

@codefromthecrypt codefromthecrypt commented Feb 5, 2026

Summary

Agentic CLI providers (claude-code, codex) can now pass MCP extensions to the underlying agent!

This works by adding Vec<ExtensionConfig> to ProviderDef::from_env, and threading it as necessary. This allows agentic providers to parse MCP extensions.

  • claude_code.rs converts extensions to --mcp-config JSON
  • codex.rs converts them to -c mcp_servers.* TOML overrides.

There was another dependency to avoid empty key names in the MCP config;

  • Extension names are derived at construction time (from URI host or command basename), so key() always has a name to normalize.
  • Secrets referenced via env_keys are resolved at runtime by the extension manager, never persisted to the session database.

Type of Change

  • Feature
  • Tests

AI Assistance

  • This PR was created or reviewed with AI assistance

Testing

$ cargo test --test providers -- test_claude_code_provider test_codex_provider --nocapture
   Compiling goose v1.23.0 (/Users/codefromthecrypt/oss/goose/crates/goose)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 12.36s
     Running tests/providers.rs (target/debug/deps/providers-71b9d9da4d87344c)

running 2 tests
=== codex::model_listing ===
[crates/goose/tests/providers.rs:315:9] &models = [
    "gpt-5.2-codex",
    "gpt-5.2",
    "gpt-5.1-codex-max",
    "gpt-5.1-codex-mini",
]
===================
=== claude-code::model_listing ===
[crates/goose/tests/providers.rs:315:9] &models = [
    "default",
    "sonnet",
    "haiku",
]
===================
=== codex::basic_response === Hello!
=== claude-code::basic_response === Hello! 👋 How can I help you today?
=== claude-code::tool_usage === test-uuid-12345-67890
=== codex::tool_usage === test-uuid-12345-67890
=== claude-code::image_content === the image is a simple white background with black text that reads:

**"hello goose! this is a test image."**
=== codex::image_content === the image is a white background with centered black text that reads: “hello goose! this is a test image.”
=== claude-code::model_switch (default -> sonnet) === Hello! 👋 How can I help you today?
test test_claude_code_provider ... ok
=== codex::context_length_exceeded_error ===
[crates/goose/tests/providers.rs:234:9] &result = Err(
    ContextLengthExceeded(
        "Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying.",
    ),
)
===================
test test_codex_provider ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 14 filtered out; finished in 18.04s


============== Providers ==============
✅ claude-code
✅ codex
=======================================

Codex

$ GOOSE_PROVIDER=codex GOOSE_MODEL=o4-mini target/release/goose run \                                                                                                                               
> --with-extension 'MY_TEST_VAR=hello_from_goose npx -y @modelcontextprotocol/server-everything' \
> -t 'Use the get-env tool and show me the value of MY_TEST_VAR'
starting session | provider: codex model: o4-mini
    session id: 20260211_65
    working directory: /Users/codefromthecrypt/oss/goose
"MY_TEST_VAR" is "hello_from_goose".

$ GOOSE_PROVIDER=codex GOOSE_MODEL=gpt-5.2-codex target/release/goose run \
    --with-streamable-http-extension 'https://mcp.kiwi.com' \
    -t 'Use kiwi to find the fastest itinerary from BKI to SYD tomorrow'
starting session | provider: codex model: gpt-5.2-codex
    session id: 20260211_14
    working directory: /Users/codefromthecrypt/oss/goose
**Summary (BKI → SYD, Thu Feb 12, 2026)**  
Below are the fastest results from Kiwi for tomorrow. Times are local to each airport. Currency: USD.

**Cheapest**  
| Route | Time (local) | Cabin | Price | Book |
|---|---|---|---:|---|
| BKI → CGK → DPS → MEL → SYD | 02/12 13:40 → 02/14 08:25 (39h 45m) | Economy | 535 | `https://on.kiwi.com/AWFTd8` |

**Shortest**  
| Route | Time (local) | Cabin | Price | Book |
|---|---|---|---:|---|
| BKI → KUL → SYD | 02/12 18:00 → 02/13 10:20 (13h 20m) | Economy | 1728 | `https://on.kiwi.com/Ybw567` |

**Other Options**  
| Route | Time (local) | Cabin | Price | Book |
|---|---|---|---:|---|
| BKI → KUL → SYD | 02/12 14:40 → 02/13 07:55 (14h 15m) | Economy | 1728 | `https://on.kiwi.com/96lHoq` |
| BKI → CAN → SYD | 02/12 19:05 → 02/13 13:35 (15h 30m) | Economy | 4539 | `https://on.kiwi.com/JdTF8N` |
| BKI → KUL → DPS → SYD | 02/12 09:10 → 02/13 06:15 (18h 05m) | Economy | 782 | `https://on.kiwi.com/fSdfLd` |
| BKI → CGK → DPS → ADL → SYD | 02/12 13:40 → 02/13 11:45 (19h 05m) | Economy | 677 | `https://on.kiwi.com/s0wttV` |
| BKI → SGN → SYD | 02/12 11:45 → 02/13 09:50 (19h 05m) | Economy | 719 | `https://on.kiwi.com/RdcE9E` |
| BKI → KUL → CGK → DPS → SYD | 02/12 07:50 → 02/13 06:15 (19h 25m) | Economy | 718 | `https://on.kiwi.com/6HXNzJ` |
| BKI → KUL → DPS → SYD | 02/12 07:50 → 02/13 06:15 (19h 25m) | Economy | 768 | `https://on.kiwi.com/qiCDKX` |
| BKI → KUL → CGK → DPS → SYD | 02/12 06:00 → 02/13 06:15 (21h 15m) | Economy | 701 | `https://on.kiwi.com/ewW63R` |
| BKI → SIN → SYD | 02/12 11:50 → 02/13 12:20 (21h 30m) | Economy | 999 | `https://on.kiwi.com/eOuJIV` |
| BKI → KUL → DRW → SYD | 02/12 23:25 → 02/14 06:10 (27h 45m) | Economy | 571 | `https://on.kiwi.com/TT8hNw` |
| BKI → SIN → SYD | 02/12 11:50 → 02/13 18:50 (28h 00m) | Economy | 1138 | `https://on.kiwi.com/LaHL81` |
| BKI → KUL → DPS → MEL → SYD | 02/12 23:25 → 02/14 08:25 (30h 00m) | Economy | 565 | `https://on.kiwi.com/yKC9Ac` |
| BKI → KUL → DPS → OOL → SYD | 02/12 23:25 → 02/14 11:50 (33h 25m) | Economy | 553 | `https://on.kiwi.com/i2TWuW` |

**Recommendation**  
If speed is the priority, the **13h 20m** option via KUL is the fastest. If budget matters most, the **$535** option is the cheapest but adds nearly 26 hours of travel time.

Have a nice trip — fun fact: the Sydney Opera House opened in 1973.

Claude Code

$ GOOSE_PROVIDER=claude-code GOOSE_MODEL=sonnet target/release/goose run \
> --with-extension 'MY_TEST_VAR=hello_from_goose npx -y @modelcontextprotocol/server-everything' \
> -t 'Use the get-env tool and show me the value of MY_TEST_VAR'
starting session | provider: claude-code model: sonnet
    session id: 20260211_64
    working directory: /Users/codefromthecrypt/oss/goose
I'll use the get-env tool to retrieve the environment variables and show you the value of MY_TEST_VAR.

The value of **MY_TEST_VAR** is: hello_from_goose

$ GOOSE_PROVIDER=claude-code GOOSE_MODEL=sonnet target/release/goose run \
    --with-streamable-http-extension 'https://mcp.kiwi.com' \
    -t 'Use kiwi to find the fastest itinerary from BKI to SYD tomorrow'

## Flight Results: BKI to SYD (February 12, 2026)

### ⚡ Fastest Flights

| Route | Departure → Arrival | Class | Price | Book |
|-------|---------------------|-------|-------|------|
| **BKI → KUL → SYD** | 02/12 18:00 → 10:20+1 (13h 20m) | Economy | $1,728 | [Book](https://on.kiwi.com/GEzE6K) |
| **BKI → KUL → SYD** | 02/12 14:40 → 07:55+1 (14h 15m) | Economy | $1,728 | [Book](https://on.kiwi.com/HQ3SNw) |

### 💰 Best Value Flights

| Route | Departure → Arrival | Class | Price | Book |
|-------|---------------------|-------|-------|------|
| **BKI → KUL → DPS → OOL → SYD** | 02/12 23:25 → 11:50+2 (33h 25m) | Economy | $553 | [Book](https://on.kiwi.com/HzN9Ur) |
| **BKI → KUL → DPS → MEL → SYD** | 02/12 23:25 → 08:25+2 (30h 0m) | Economy | $565 | [Book](https://on.kiwi.com/aEPJNu) |
| **BKI → KUL → DRW → SYD** | 02/12 23:25 → 06:10+2 (27h 45m) | Economy | $571 | [Book](https://on.kiwi.com/7fxPsQ) |

### 🎯 Other Options Worth Considering

| Route | Departure → Arrival | Class | Price | Book |
|-------|---------------------|-------|-------|------|
| **BKI → KUL → DPS → SYD** | 02/12 09:10 → 06:15+1 (18h 5m) | Economy | $782 | [Book](https://on.kiwi.com/VSf3LQ) |
| **BKI → KUL → DPS → SYD** | 02/11 07:50 → 06:15+1 (19h 25m) | Economy | $717 | [Book](https://on.kiwi.com/X4Hhx0) |
| **BKI → SGN → SYD** | 02/12 11:45 → 09:50+1 (19h 5m) | Economy | $719 | [Book](https://on.kiwi.com/b8ZCW8) |

## 🏆 Recommendation

**For the fastest journey:** Book the **18:00 departure via Kuala Lumpur** (13h 20m total) for $1,728. You'll arrive at 10:20 the next morning with just one stop in KUL (2h 20m layover).

**For best value:** If time isn't critical, the **$553 option** saves you $1,175 but takes 33+ hours with multiple stops.

## Summary
- **Fastest:** 13h 20m via KUL ($1,728)
- **Cheapest:** $553 (but 33+ hours with 3 stops)
- **Sweet spot:** The early morning 14:40 departure is almost as fast (14h 15m) at the same price

Have a wonderful trip to Sydney! 🦘 Fun fact: Sydney Harbour is the world's largest natural harbor and contains more water than Sydney's entire population could drink in 100 years!

Extension env_keys not persisted in DB

Extensions configured with env_keys resolve secrets from the environment at runtime
without persisting them to sqlite. This verifies that env_keys stays intact and
resolved secrets don't appear in envs.

cat <<'EOF' > /tmp/test_env_keys.yaml
version: "1.0.0"
title: "Test env_keys"
description: "Verify env_keys don't leak into sqlite"
prompt: "Use the echo tool to say hello"
extensions:
  - type: stdio
    name: everything
    cmd: npx
    args:
      - "-y"
      - "@modelcontextprotocol/server-everything"
    env_keys:
      - MY_SECRET
EOF

MY_SECRET=s3cret GOOSE_PROVIDER=claude-code GOOSE_MODEL=sonnet \
  target/release/goose run --recipe /tmp/test_env_keys.yaml

# Check the most recent session — env_keys should be intact, envs should be empty
sqlite3 ~/.local/share/goose/sessions/sessions.db \
  "SELECT extension_data FROM sessions ORDER BY rowid DESC LIMIT 1" \
  | python3 -m json.tool

Expected output shows env_keys: ["MY_SECRET"] and envs: {}:

{
    "enabled_extensions.v0": {
        "extensions": [
            {
                "type": "stdio",
                "name": "everything",
                "description": "",
                "cmd": "npx",
                "args": ["-y", "@modelcontextprotocol/server-everything"],
                "envs": {},
                "env_keys": ["MY_SECRET"],
                "timeout": null,
                "bundled": null,
                "available_tools": []
            }
        ]
    }
}

@codefromthecrypt codefromthecrypt marked this pull request as ready for review February 5, 2026 12:15
Copilot AI review requested due to automatic review settings February 5, 2026 12:15
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enables MCP (Model Context Protocol) support for agentic CLI providers (claude-code and codex) by threading ExtensionConfig through the provider creation pipeline. Previously, only LLM providers could use MCP extensions because CLI providers lacked access to extensions at construction time.

Changes:

  • Modified ProviderDef::from_env to accept Vec<ExtensionConfig>, enabling CLI providers to connect to MCP servers at creation time
  • Extracted shared test infrastructure (McpFixture, ExpectedSessionId) into goose-test-support crate
  • Updated all providers to use #[async_trait] instead of manually returning BoxFuture, reducing boilerplate

Reviewed changes

Copilot reviewed 62 out of 64 changed files in this pull request and generated no comments.

Show a summary per file
File Description
crates/goose/src/providers/base.rs Added #[async_trait] and extensions parameter to ProviderDef::from_env
crates/goose/src/providers/codex.rs Added TOML config overrides for MCP, refactored prepare_input to handle images/text in single pass
crates/goose/src/providers/claude_code.rs Switched to persistent stream-json session with OnceCell, added JSON MCP config generation
crates/goose/src/providers/init.rs Updated create functions to accept and pass through extensions
crates/goose/src/scheduler.rs Reordered to create provider after resolving extensions
crates/goose-test-support/ New shared crate for test fixtures (MCP server, session ID validation, test assets)
crates/goose/tests/providers.rs Extended test suite to cover CLI providers with MCP tool usage
All other provider files Updated from_env signatures to accept unused _extensions parameter

@codefromthecrypt
Copy link
Collaborator Author

I will split off all the groundwork into separate PRs to make this easier to review and merge

@codefromthecrypt
Copy link
Collaborator Author

#7019 pulls the MCP fixture refactoring out

@codefromthecrypt
Copy link
Collaborator Author

#7029 for claude refactor

@codefromthecrypt
Copy link
Collaborator Author

codex fixes mostly image related #7033

@codefromthecrypt codefromthecrypt force-pushed the cli-provider-mcp branch 2 times, most recently from ce578c7 to 72830bf Compare February 9, 2026 06:38
@codefromthecrypt codefromthecrypt marked this pull request as ready for review February 9, 2026 06:45
Copilot AI review requested due to automatic review settings February 9, 2026 06:45
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@codefromthecrypt
Copy link
Collaborator Author

@michaelneale I think this is ready now

@codefromthecrypt
Copy link
Collaborator Author

last rebase I accidentally deleted my code that added these two to providers.rs. will add it back

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 54 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (1)

crates/goose/src/providers/provider_registry.rs:136

  • Custom/declarative providers currently drop the passed extensions (_extensions is unused), so they still cannot receive extensions at creation time despite the PR goal; consider threading extensions into the custom provider constructor or documenting that extensions are intentionally unsupported for declarative providers.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 53 changed files in this pull request and generated 5 comments.

@codefromthecrypt codefromthecrypt marked this pull request as draft February 11, 2026 10:04
auto-merge was automatically disabled February 11, 2026 10:04

Pull request was converted to draft

@codefromthecrypt
Copy link
Collaborator Author

copilot helped find an ENV resolution issue which is not sorted and we shuold have a smoke test/integration for I think.

This catches it and now fixed:

$ GOOSE_PROVIDER=claude-code GOOSE_MODEL=sonnet target/release/goose run \
> --with-extension 'MY_TEST_VAR=hello_from_goose npx -y @modelcontextprotocol/server-everything' \
> -t 'Use the get-env tool and show me the value of MY_TEST_VAR'

but now it found a problem in how, which I need to sort. yes I need to separate ENV/secret resolution. however, I have to do that carefully so that the resolved values are not accidentally written back to goose config. marking draft until I sort that out.

@codefromthecrypt
Copy link
Collaborator Author

hoping to not have missed anything else but will leave in draft until next copilot and I pick up tomorrow.

Most notably I added a "Extension env_keys not persisted in DB" section which safeguards accidental mishandling.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 54 out of 55 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

crates/goose/src/agents/extension_manager.rs:496

  • config.key() can still be empty when an ExtensionConfig has an empty/whitespace name (e.g., sessions created before CLI-derived names, or user-supplied configs), which will store the extension under an empty key and may cause collisions or skipped loads; consider rejecting empty keys with a clear error or falling back to a derived name (URI host / cmd basename) as a compatibility measure.
        let sanitized_name = config.key();

        if self.extensions.lock().await.contains_key(&sanitized_name) {
            return Ok(());
        }

Thread ExtensionConfig through ProviderDef::from_env so CLI providers
(claude-code, codex) connect to MCP servers at construction time and
call tools internally.

Extract McpFixture and test assets into goose-test-support crate,
shared by goose-acp and providers.rs integration tests. Both CLI
providers now run the full test suite (basic response, tool usage,
context length, image content) against a real MCP fixture server.

Improve claude_code.rs with persistent stream-json sessions and content
blocks. Replace codex.rs two-pass message handling with single-pass
prepare_input.

Signed-off-by: Adrian Cole <adrian@tetrate.io>
Signed-off-by: Adrian Cole <adrian@tetrate.io>
Signed-off-by: Adrian Cole <adrian@tetrate.io>
Signed-off-by: Adrian Cole <adrian@tetrate.io>
@codefromthecrypt codefromthecrypt marked this pull request as ready for review February 11, 2026 22:12
Copilot AI review requested due to automatic review settings February 11, 2026 22:12
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 54 out of 55 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

crates/goose/src/agents/extension_manager.rs:496

  • ExtensionManager::add_extension uses config.key() as the map key and silently returns Ok(()) when that key already exists; with the new CLI-derived names (often just the command basename like npx) this can drop additional extensions without warning, and legacy sessions with empty names can produce an empty key. Consider rejecting empty keys and either erroring on collisions or auto-disambiguating (e.g., suffixing / incorporating args/uri) instead of silently skipping.
        let sanitized_name = config.key();

        if self.extensions.lock().await.contains_key(&sanitized_name) {
            return Ok(());
        }

Signed-off-by: Adrian Cole <adrian@tetrate.io>
Copy link
Collaborator Author

@codefromthecrypt codefromthecrypt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notes

}

pub fn parse_streamable_http_extension(extension_url: &str, timeout: u64) -> ExtensionConfig {
let name = url::Url::parse(extension_url)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

before we had logic in two places about backfilling name on empty (only possible with anonymous CLI extensions). This puts the logic in the ctor instead.

; "name_from_cmd_basename"
)]
#[test_case(
"MY_SECRET=s3cret npx -y @modelcontextprotocol/server-everything",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test case was missing and tripped me up for a while. basically it is fine for us to pass ENV the user sets like this as we have no way to know if it is secret or not (copilot is over-zealous on the secret concept). The thing to focus on is env_keys which do read from the secrets when there's no ENV associated.

.and_then(|s| EnabledExtensionsState::from_extension_data(&s.extension_data))
.map(|state| state.extensions)
.unwrap_or_else(get_enabled_extensions)
EnabledExtensionsState::for_session(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added this to avoid so many copy/paste

let extensions = EnabledExtensionsState::from_extension_data(&session.extension_data)
.map(|state| state.extensions)
.unwrap_or_else(goose::config::get_enabled_extensions);
let extensions = EnabledExtensionsState::extensions_or_default(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still falling back?

Copy link
Collaborator Author

@codefromthecrypt codefromthecrypt Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, same behavior. extensions_or_default does the same fallback internally. This just deduplicates the pattern that was copy/pasted across 5 call sites.

Copy link
Collaborator

@michaelneale michaelneale left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

large change but logically makes sense

@codefromthecrypt codefromthecrypt added this pull request to the merge queue Feb 11, 2026
Merged via the queue into main with commit 8d59c2d Feb 11, 2026
19 checks passed
@codefromthecrypt codefromthecrypt deleted the cli-provider-mcp branch February 11, 2026 23:42
lifeizhou-ap added a commit that referenced this pull request Feb 12, 2026
* main:
  fix text editor view broken (#7167)
  docs: White label guide (#6857)
  Add PATH detection back to developer extension (#7161)
  docs: pin version in ci/cd (#7168)
  Desktop: - No Custom Headers field for custom OpenAI-compatible providers  (#6681)
  feat: edit model and extensions of a recipe from GUI (#6804)
  feat: MCP support for agentic CLI providers (#6972)
jh-block added a commit that referenced this pull request Feb 12, 2026
* origin/main: (33 commits)
  fix: replace panic with proper error handling in get_tokenizer (#7175)
  Lifei/smoke test for developer (#7174)
  fix text editor view broken (#7167)
  docs: White label guide (#6857)
  Add PATH detection back to developer extension (#7161)
  docs: pin version in ci/cd (#7168)
  Desktop: - No Custom Headers field for custom OpenAI-compatible providers  (#6681)
  feat: edit model and extensions of a recipe from GUI (#6804)
  feat: MCP support for agentic CLI providers (#6972)
  docs: keyring fallback to secrets.yaml (#7165)
  feat: load provider/model specified inside the recipe config (#6884)
  fix ask-ai bot hitting tool call limits (#7162)
  fix flatpak icon (#7154)
  [docs] Skills Marketplace UI Improvements (#7158)
  More no-window flags (#7122)
  feat: Allow overriding default bat themes using environment variables (#7140)
  Make the system prompt smaller (#6991)
  Pre release script (#7145)
  Spelling (#7137)
  feat(mcp): upgrade rmcp to 0.15.0 and advertise MCP Apps UI extension capability (#6927)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants