Skip to content

Add mcp_connector() for provider-hosted MCP server connections#991

Open
gadenbuie wants to merge 39 commits into
mainfrom
feature/mcp-tool-content-types
Open

Add mcp_connector() for provider-hosted MCP server connections#991
gadenbuie wants to merge 39 commits into
mainfrom
feature/mcp-tool-content-types

Conversation

@gadenbuie

@gadenbuie gadenbuie commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Closes #985 (supersedes).

Summary

  • Adds mcp_connector(), a user-facing API for connecting to remote MCP servers via the provider's API. Both Anthropic and OpenAI are supported. Connectors are registered with $register_tool() and the provider handles tool discovery and execution server-side.
  • Adds ContentMcpToolRequest, ContentMcpToolResult, and ContentMcpListTools content classes that inherit from existing tool content types, so MCP tool calls integrate naturally with conversation display, echo, and streaming.
  • MCP tool results are echoed during chat() and yielded during streaming when yield_as_content = TRUE.
  • Refactors tool result formatting so that echoing tool requests/results during chat() and printing a Chat object use a consistent display style. Remote MCP servers like DeepWiki can return very large responses (17k+ lines), so tool results are now truncated in both echo output and conversation history, and long tool IDs are shortened for readability. The new formatting arguments flow from high-level methods and are prefixed with tool_ to avoid collisions.

Examples

Anthropic

chat <- chat_anthropic()
chat$register_tool(
  mcp_connector("https://mcp.deepwiki.com/mcp", name = "deepwiki")
)
chat$chat("Look up the tidyverse/ellmer repo with your deepwiki tools.")

OpenAI

chat <- chat_openai()
chat$register_tool(
  mcp_connector("https://mcp.deepwiki.com/mcp", name = "deepwiki")
)
chat$chat("Look up the tidyverse/ellmer repo with your deepwiki tools.")
Manual configuration without mcp_connector()

Anthropic

chat <- chat_anthropic(
  model = "claude-sonnet-4-5-20250929",
  beta_headers = "mcp-client-2025-11-20",
  api_args = list(
    mcp_servers = list(
      list(
        type = "url",
        url = "https://mcp.deepwiki.com/mcp",
        name = "deepwiki"
      )
    ),
    tools = list(
      list(
        type = "mcp_toolset",
        mcp_server_name = "deepwiki"
      )
    )
  )
)
chat$chat("Look up the tidyverse/ellmer repo with your deepwiki tools.")

OpenAI

chat <- chat_openai(
  model = "gpt-4.1",
  api_args = list(
    tools = list(
      list(
        type = "mcp",
        server_label = "deepwiki",
        server_url = "https://mcp.deepwiki.com/mcp",
        require_approval = "never"
      )
    )
  )
)
chat$chat("Look up the tidyverse/ellmer repo with your deepwiki tools.")

Documentation and resources

Anthropic

  • MCP connector docs — remote MCP server integration guide
  • Beta header required: mcp-client-2025-11-20
  • Implemented:
    • MCP server config goes in a separate mcp_servers body field; tools use type: "mcp_toolset"
    • Tool calls appear as separate mcp_tool_use and mcp_tool_result content blocks
    • Result content blocks can contain text, image, and document types
    • Auth via authorization_token field on the server config entry
  • Not implemented:
    • Tool filtering on mcp_toolset: default_config object with enabled and defer_loading booleans applied to all tools; configs named object with per-tool overrides keyed by tool name, each with its own enabled/defer_loading. Together these enable allow-listing (disable all by default, enable specific tools) or deny-listing individual tools.
    • cache_control on mcp_toolset: Prompt caching breakpoint on the toolset entry itself, similar to cache_control on other tool types.

OpenAI

  • Remote MCP servers guide — MCP tool connector docs
  • No beta header needed (production API)
  • Implemented:
    • MCP config is inline as a type: "mcp" tool entry
    • Tool calls appear as merged mcp_call output blocks (request + result combined); tool discovery uses mcp_list_tools output blocks
    • require_approval support ("never" by default in ellmer); mcp_approval_request triggers an informative error
    • Auth via authorization field on the tool entry
  • Not implemented:
    • Built-in OAuth connectors: Pre-built connectors identified by connector_id (e.g. connector_gmail, connector_google_drive, connector_dropbox) that use OAuth tokens instead of an arbitrary server URL. Our mcp_connector() only supports server_url.
    • Granular require_approval: Object form allowing per-tool approval control, e.g. {never: {tool_names: ["tool_a", "tool_b"]}}, so specific tools can bypass approval while others require it.
    • Interactive approval workflow: When require_approval is not "never", the API returns an mcp_approval_request output and expects an mcp_approval_response input in a follow-up request. We currently error instead of surfacing the approval request to the user.

kai-lin-cci and others added 30 commits May 19, 2026 13:00
Replaces 7 identical copies of the 11-line ProviderAnthropic
constructor with a shared helper function.
- Add `is_error` and `content` properties to ContentToolResponseMcp so
  callers can distinguish tool errors from successes without digging
  into the raw JSON blob.
- Improve format() to show error/result status and the response text
  instead of just the opaque tool_use_id.
- Drop defensive `%||% ""` fallbacks on required API fields (id, name,
  server_name, tool_use_id) to match existing patterns and surface
  malformed responses loudly.
- Assert @id and @JSON$input on mcp_tool_use (previously untested)
- Assert @is_error and @content on mcp_tool_result
- Add test for mcp_tool_result with is_error = TRUE
Add a `label` parameter to format(ContentToolRequest) and
format(ContentToolResult) so MCP format methods can delegate to them
while using "mcp tool request" / "mcp tool result" labels. MCP output
now shows IDs and arguments, matching the regular tool display style.
Wrap non-list input with list() before passing to ContentToolRequest
constructor, which requires arguments to be a list. Add format tests
for MCP tool request and result display.
Multiline tool results now render with cli_rule header (bold label,
cyan tool ID) and a closing rule, making long results easier to read.
Single-line results keep the inline format.
Record a cassette against the DeepWiki MCP server to test the full
round-trip: mcp_tool_use and mcp_tool_result parsing through
chat_anthropic with the mcp-client-2025-11-20 beta header.
Rename ContentToolRequestMcp -> ContentMcpToolRequest and
ContentToolResponseMcp -> ContentMcpToolResult for consistent
naming. Add new ContentMcpListTools class for OpenAI's
mcp_list_tools output (tool discovery caching).
Parse mcp_list_tools, mcp_call, and mcp_approval_request output
types from the OpenAI Responses API. mcp_call is split into a
ContentMcpToolRequest + ContentMcpToolResult pair, reusing the
same S7 classes as Anthropic. mcp_list_tools is preserved in
turn contents for round-trip caching. mcp_approval_request
errors with guidance to use require_approval = "never".
Add server-side MCP tools section to ?chat_openai with usage
example and auth token instructions. Add VCR-recorded integration
test against the DeepWiki MCP server verifying mcp_list_tools,
mcp_call, and message output types are parsed correctly.
Include chat_openai() alongside chat_anthropic() in the MCP
connector NEWS entry.
Add the core infrastructure for provider-hosted MCP server connections.
The McpConnector S7 class stores URL, name, credentials, and extra
provider-specific args. The mcp_connector() constructor validates
inputs and is registered as an exported function.

Updated check_tool() to accept McpConnector objects and added a
check_mcp_connector_tool() generic with a default Provider method
that errors, so unsupported providers fail fast at registration time.
ProviderAnthropic now accepts McpConnector objects via register_tool().
chat_body() extracts connectors to build the mcp_servers body field,
and as_json() returns mcp_toolset tool entries. A chat_request()
override injects the mcp-client-2025-11-20 beta header automatically
when MCP connectors are present, merging with any existing beta
headers.
ProviderOpenAI now accepts McpConnector objects via register_tool().
as_json() maps name to server_label, url to server_url, and
credentials() to authorization. Extra args (require_approval,
allowed_tools, etc.) are merged into the tool entry.
The tools list is named (keyed by tool name), so lapply() preserved
those names on mcp_servers. jsonlite serializes named lists as JSON
objects, but the Anthropic API expects mcp_servers as a JSON array.
ContentMcpToolRequest now inherits from ContentToolRequest and
ContentMcpToolResult inherits from ContentToolResult, letting downstream
consumers rely on existing tool request/result patterns.

Key changes:

- Add mcp_tool_def() helper to create a no-op ToolDef for MCP tools
- Add local_only parameter (default TRUE) to is_tool_request() and
  is_tool_result() so MCP content doesn't enter invoke_tools()
- Add provider-specific as_json() methods for MCP content on both
  ProviderAnthropic and ProviderOpenAI to prevent S7 multi-dispatch
  from falling through to parent ContentToolRequest/Result methods
- Echo MCP tool requests, results, and tool lists during
  echo="output" via maybe_echo_tool() and echo_server_tool_contents()
- Yield MCP content during streaming when yield_as_content=TRUE
- Use "__" separator for MCP tool display names (server__tool)
Add truncation helpers for formatted tool output:
- `truncate_lines()` limits multi-line output to a max number of lines
  with a "[and N more lines...]" suffix
- `truncate_id()` shortens tool IDs longer than 12 chars (first 8 +
  ellipsis + last 4)

`print(Chat)` now passes `max_lines = 5` so tool results are truncated
when reviewing conversation history. `maybe_echo_tool()` is simplified
to use `truncate_lines()` instead of inline truncation logic.
Remove cli_rule() header/footer from format(ContentToolResult) in favor
of the same inline "[tool result (id)]:" header used for single-line
results.
format(ContentToolResult) gains tool_style = c("plain", "reprex") and
show = "value" options. The reprex style renders values with italic
#> prefixed lines. print(Chat) and maybe_echo_tool() now use the reprex
style, simplifying maybe_echo_tool() by delegating formatting to
format(). New args use tool_ prefix to avoid conflicts with other
formatting options.
OpenAI can return mcp_call errors as structured objects with `type`
("mcp_tool_execution_error") and `content` (list of content blocks)
instead of a plain string. Parse both forms to extract the error text.
maybe_echo_tool() was hardcoding "tool call" for all tool requests.
Now checks for ContentMcpToolRequest and uses "mcp tool call" instead.
Users no longer need to pass require_approval = "never" explicitly.
The default is set in as_json(ProviderOpenAI, McpConnector) and can
still be overridden via ... in mcp_connector().
gadenbuie added 6 commits May 19, 2026 17:04
Remove "Bearer " prefix from OpenAI credentials example and document
which provider-specific field the credentials callback maps to:
authorization_token (Anthropic) or authorization (OpenAI).
Rewrite server-side MCP description to focus on user-facing behavior
rather than API internals. Put mcp_connector() on its own line in
examples and connect "tools" with register_tool() in the description.
… type

- Remove stale browser() call from value_turn(ProviderOpenAI) fallback
- Reword mcp_approval_request error since require_approval now defaults
  to "never"
- Use S7 union type (class_function | NULL) for McpConnector credentials
  property instead of class_any
ContentMcpToolResult@request now carries the actual tool name and
arguments instead of empty stubs. For OpenAI, these come directly from
the mcp_call output block. For Anthropic, a linking pass after parsing
matches mcp_tool_result blocks to their mcp_tool_use by tool_use_id.
Anthropic MCP tool results can contain image content blocks alongside
text. These are now extracted as ContentImageInline objects and added
to the turn contents. Text extraction now filters for text blocks only
instead of pulling $text from all block types.
Change the extra args test to use require_approval = "always" instead
of "never" to prove that user-supplied values override the default.
@gadenbuie gadenbuie changed the title feat(chat_anthropic): handle mcp_tool_use and mcp_tool_result content types Add mcp_connector() for provider-hosted MCP server connections May 19, 2026
@hadley

hadley commented May 20, 2026

Copy link
Copy Markdown
Member

Do you mind pointing me to the docs for context? I hadn't heard of these remote MCPs before.

@gadenbuie

Copy link
Copy Markdown
Collaborator Author

Do you mind pointing me to the docs for context? I hadn't heard of these remote MCPs before.

I just added a section to the PR description

@klin333

klin333 commented May 27, 2026

Copy link
Copy Markdown

i had to make an extra commit to https://github.com/klin333/ellmer/tree/feature/mcp-tool-content-types

it was needed to fix this crash:

x1 <- chat$chat("hello") # works
x2 <- chat$chat("Use Snowflake to list the databases visible to the MCP role.") # works
x3 <- chat$chat("hello") # crash
# Error in `req_perform_connection()` at ellmer/R/httr2.R:55:5:
# ! HTTP 400 Bad Request.
# ℹ messages.3.content.1.mcp_tool_use.input: Input should be an object [invalid_request_error]

@klin333

klin333 commented Jun 10, 2026

Copy link
Copy Markdown

hi all, any chance this can be merged soon?

@gadenbuie

Copy link
Copy Markdown
Collaborator Author

i had to make an extra commit to klin333/ellmer@feature/mcp-tool-content-types

@klin333 can you clarify: was the change you made related to this PR or to solve a separate issue? Can you also please include a complete reproducible example?

@klin333

klin333 commented Jun 11, 2026

Copy link
Copy Markdown

Hi @gadenbuie, thank you for looking into this.

While on commit 47cd0b9:

devtools::load_all()

snowflake_mcp_url <- "xxx"
access_token <- "yyy"

chat <- ellmer::chat_anthropic(
  model = "claude-sonnet-4-6",
  echo = "all",
  system_prompt = "You are a careful Snowflake data assistant."
)

chat$register_tool(
  ellmer::mcp_connector(
    url = snowflake_mcp_url,
    name = "snowflake",
    credentials = function() access_token
  )
)

x1 <- chat$chat("hello") # works
x2 <- chat$chat("Use Snowflake to list the databases visible to the current role.") # works
x3 <- chat$chat("hello") # crash
# Error in `req_perform_connection()` at ellmer/R/httr2.R:55:5:
# ! HTTP 400 Bad Request.
# ℹ messages.3.content.1.mcp_tool_use.input: Input should be an object [invalid_request_error]
# Run `rlang::last_trace()` to see where the error occurred.
> sessionInfo()
R version 4.5.2 (2025-10-31 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=English_Australia.utf8  LC_CTYPE=English_Australia.utf8    LC_MONETARY=English_Australia.utf8
[4] LC_NUMERIC=C                       LC_TIME=English_Australia.utf8    

time zone: Australia/Sydney
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] ellmer_0.4.1.9000 testthat_3.3.2   

loaded via a namespace (and not attached):
 [1] vctrs_0.6.5       cli_3.6.5         rlang_1.1.6       otel_0.2.0        purrr_1.1.0       pkgload_1.4.1     promises_1.5.0   
 [8] S7_0.2.1          coro_1.1.0        jsonlite_2.0.0    glue_1.8.0        rprojroot_2.1.1   pkgbuild_1.4.8    brio_1.1.5       
[15] rappdirs_0.3.3    tibble_3.3.0      ellipsis_0.3.2    fastmap_1.2.0     lifecycle_1.0.4   httr2_1.2.2       memoise_2.0.1    
[22] compiler_4.5.2    fs_1.6.6          sessioninfo_1.2.3 pkgconfig_2.0.3   Rcpp_1.1.1        rstudioapi_0.17.1 later_1.4.5      
[29] R6_2.6.1          curl_7.0.0        pillar_1.11.1     usethis_3.2.1     magrittr_2.0.4    withr_3.0.2       tools_4.5.2      
[36] devtools_2.4.6    remotes_2.5.0     cachem_1.1.0      desc_1.4.3    

When on commit 983f025, the above no longer crashes

…turns

Anthropic streaming can deliver mcp_tool_use input as a JSON string rather
than a parsed object. Parse it before storing so the field serializes as an
object when replayed in later turns.

OpenAI mcp_call output was gaining a synthetic `input` field in its stored
json, which the API rejects on replay. Use the original output directly.

Reported-by: klin333
Closes #991 (comment)

@gadenbuie gadenbuie left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the detailed reprex and for tracking down the root cause @klin333. I've updated the PR to reparse the input and to fix a similar bug in the OpenAI implementation.

Note that for now, I'm not taking the extra_args$tools merging fix. It's not directly needed to fix the problem with follow-up message after an MCP tool use round. Providing tool definitions through extra_args is very much an edge case and I think it's reasonable for ellmer to require users to commit to manual definitions if opting out of the typical $register_tool() workflow. But we can revisit that if you have a concrete use case where you need both approaches to coexist (in a new issue, please).

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.

4 participants