MCP tools
Instead of defining every tool by hand with AgentTool.fromFunction, agents can load their tools from
Model Context Protocol (MCP) servers. The mcp module integrates
chimp’s MCP client: it discovers the server’s tools (following
tools/list pagination), converts each tool’s JSON Schema, and executes calls remotely via tools/call.
The module is available for Scala 3 only (chimp is Scala 3 only):
"com.softwaremill.sttp.ai" %% "mcp" % "0.8.0"
First create and initialize a chimp McpClient using any of its transports — the stdio transport launches
the server as a subprocess and is synchronous (F = Identity); the HTTP transport works with any sttp
Backend[F]. Then McpTools.fromClient turns the server’s tools into regular agent tools:
import chimp.client.McpClient
import chimp.client.transport.ClientStdioTransport
import chimp.protocol.Implementation
import sttp.ai.core.agent.mcp.McpTools
import sttp.ai.openai.OpenAI
import sttp.ai.openai.agent.OpenAIAgent
import sttp.client4.DefaultSyncBackend
import sttp.monad.{IdentityMonad, MonadError}
import sttp.shared.Identity
given MonadError[Identity] = IdentityMonad
// launches the MCP server as a subprocess, speaking JSON-RPC over stdin/stdout
val transport = ClientStdioTransport(List("npx", "-y", "@modelcontextprotocol/server-everything"))
val client = McpClient[Identity](transport, Implementation("sttp-ai-agent", "1.0.0"))
val backend = DefaultSyncBackend()
try
val mcpTools = McpTools.fromClient(client, namePrefix = Some("mcp"))
val agent = OpenAIAgent
.synchronous(OpenAI.fromEnv, "gpt-4o-mini")
.maxIterations(10)
.tools(mcpTools)
.build
val result = agent.run("Add 2 and 3 using the available tools")(backend)
println(result.finalAnswer)
finally
backend.close()
client.close()
The same tools work with the Claude backend — here’s the equivalent, complete example:
import chimp.client.McpClient
import chimp.client.transport.ClientStdioTransport
import chimp.protocol.Implementation
import sttp.ai.claude.agent.ClaudeAgent
import sttp.ai.claude.config.ClaudeConfig
import sttp.ai.claude.models.ClaudeModel
import sttp.ai.core.agent.mcp.McpTools
import sttp.client4.DefaultSyncBackend
import sttp.monad.{IdentityMonad, MonadError}
import sttp.shared.Identity
given MonadError[Identity] = IdentityMonad
val claudeTransport = ClientStdioTransport(List("npx", "-y", "@modelcontextprotocol/server-everything"))
val claudeClient = McpClient[Identity](claudeTransport, Implementation("sttp-ai-agent", "1.0.0"))
val claudeBackend = DefaultSyncBackend()
try
val mcpTools = McpTools.fromClient(claudeClient, namePrefix = Some("mcp"))
val agent = ClaudeAgent
.synchronous(ClaudeConfig.fromEnv, ClaudeModel.ClaudeHaiku4_5.value)
.maxIterations(10)
.tools(mcpTools)
.build
val result = agent.run("Add 2 and 3 using the available tools")(claudeBackend)
println(result.finalAnswer)
finally
claudeBackend.close()
claudeClient.close()
Lifecycle
You own the client: keep it open while the agent runs, and close it afterwards.
The tool list is a snapshot taken by
fromClient;tools/list_changednotifications are not observed.
Tool names
MCP allows names with dots, slashes, non-ASCII characters, and up to 128 characters. OpenAI’s function calling
requires ^[a-zA-Z0-9_-]{1,64}$ and rejects the whole request otherwise, so fromClient adapts names in two steps:
namePrefix(optional) is applied first: withSome("mcp"), a server tooladdis exposed asmcp_add. Use it to keep tools from different sources — other MCP servers, or manually defined tools — distinct from each other.Sanitization then rewrites the (possibly prefixed) name to
[A-Za-z0-9_-], truncated to 64 characters. The server always receives the tool’s original, unprefixed, unsanitized name intools/call.
Duplicate detection. If two tools from the same fromClient call end up with the same exposed name — a
sanitization collision, or a server reusing one name for genuinely different tools — fromClient fails with
McpToolConversionException naming every collision, instead of silently routing calls to the wrong tool. An MCP
server re-listing the exact same tool across pages is deduplicated instead (even if its free-form _meta field
varies); a genuinely different definition under the same name still fails. namePrefix cannot fix a collision
reported this way — it is applied identically to every tool in one fromClient call, so it can never separate
two names that already collide; rename one of them on the server instead.
This check does not extend across multiple fromClient calls or to manually defined tools: an agent looks tools
up by name, so if you combine tools from several sources without keeping their exposed names distinct yourself
(with namePrefix), the one loaded last silently shadows any earlier tool with the same name — no error is
raised.
fromClient also fails with McpToolConversionException if a tool’s input schema is not valid JSON Schema and
cannot be decoded, naming the offending tool.
Results and errors
Results are rendered as text: text content blocks are joined with newlines, other block types as compact JSON.
Results the server marks as errors are returned to the LLM prefixed with
Tool execution failed:.Transport failures surface as exceptions, subject to the agent’s configured
ExceptionHandler.Before a tool call reaches the server, arguments that are JSON
nullare dropped for parameters the server’s schema does not list asrequired(the server then sees them as simply missing, not explicitlynull); nulls forrequiredparameters — including ones strict-mode normalization made nullable — are left in place.
Backend caveats
The OpenAI agent backend registers tools with
strict: truefunction calling by default, normalizing schemas to strict-mode rules:additionalProperties: falseon objects, all properties listed asrequired, and originally-optional properties made nullable (the model passesnullfor them instead of omitting them). If a schema uses JSON Schema features strict mode cannot accept, the API rejects it at request time — passstrictTools = falseto the builder as an escape hatch:OpenAIAgent.synchronous(OpenAI.fromEnv, "gpt-4o-mini", strictTools = false)
The Claude agent backend passes the server’s original tool schema JSON through untouched — nested objects, arrays, enums,
requiredlists, and any other JSON Schema keywords are all forwarded as received. The only modification is adding a top-level"type": "object"when the schema omits it (Anthropic requiresinput_schema.type == "object", but MCP allows tools to omittype, e.g.{}for a no-argument tool).