Add mcp_connector() for provider-hosted MCP server connections#991
Add mcp_connector() for provider-hosted MCP server connections#991gadenbuie wants to merge 39 commits into
mcp_connector() for provider-hosted MCP server connections#991Conversation
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.
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().
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.
mcp_tool_use and mcp_tool_result content typesmcp_connector() for provider-hosted MCP server connections
|
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 |
|
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: |
|
hi all, any chance this can be merged soon? |
@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? |
|
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.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
left a comment
There was a problem hiding this comment.
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).
Closes #985 (supersedes).
Summary
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.ContentMcpToolRequest,ContentMcpToolResult, andContentMcpListToolscontent classes that inherit from existing tool content types, so MCP tool calls integrate naturally with conversation display, echo, and streaming.chat()and yielded during streaming whenyield_as_content = TRUE.chat()and printing aChatobject 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 withtool_to avoid collisions.Examples
Anthropic
OpenAI
Manual configuration without
mcp_connector()Anthropic
OpenAI
Documentation and resources
Anthropic
mcp-client-2025-11-20mcp_serversbody field; tools usetype: "mcp_toolset"mcp_tool_useandmcp_tool_resultcontent blockstext,image, anddocumenttypesauthorization_tokenfield on the server config entrymcp_toolset:default_configobject withenabledanddefer_loadingbooleans applied to all tools;configsnamed object with per-tool overrides keyed by tool name, each with its ownenabled/defer_loading. Together these enable allow-listing (disable all by default, enable specific tools) or deny-listing individual tools.cache_controlonmcp_toolset: Prompt caching breakpoint on the toolset entry itself, similar tocache_controlon other tool types.OpenAI
type: "mcp"tool entrymcp_calloutput blocks (request + result combined); tool discovery usesmcp_list_toolsoutput blocksrequire_approvalsupport ("never"by default in ellmer);mcp_approval_requesttriggers an informative errorauthorizationfield on the tool entryconnector_id(e.g.connector_gmail,connector_google_drive,connector_dropbox) that use OAuth tokens instead of an arbitrary server URL. Ourmcp_connector()only supportsserver_url.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.require_approvalis not"never", the API returns anmcp_approval_requestoutput and expects anmcp_approval_responseinput in a follow-up request. We currently error instead of surfacing the approval request to the user.