From 9f13e8df2eec12dd6b9d43ed89b49c4a8a103170 Mon Sep 17 00:00:00 2001 From: camilleislasse Date: Sat, 13 Dec 2025 11:38:08 +0100 Subject: [PATCH] [Agent] Enable remote MCP tools for agents --- .../assets/controllers/timeline_controller.js | 59 ++++ demo/assets/styles/app.css | 4 + demo/config/packages/ai.yaml | 22 ++ demo/config/routes.yaml | 7 + demo/src/Timeline/Chat.php | 63 ++++ demo/src/Timeline/TwigComponent.php | 52 ++++ demo/templates/_message.html.twig | 21 +- demo/templates/components/timeline.html.twig | 30 ++ demo/templates/index.html.twig | 21 ++ splitsh.json | 1 + src/agent/src/Bridge/Mcp/.gitattributes | 3 + .../Mcp/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../Bridge/Mcp/.github/close-pull-request.yml | 20 ++ src/agent/src/Bridge/Mcp/.gitignore | 5 + src/agent/src/Bridge/Mcp/CHANGELOG.md | 9 + .../Mcp/Exception/ConnectionException.php | 21 ++ .../src/Bridge/Mcp/Exception/McpException.php | 21 ++ .../Mcp/Exception/ProtocolException.php | 38 +++ .../Bridge/Mcp/Exception/TimeoutException.php | 21 ++ src/agent/src/Bridge/Mcp/LICENSE | 19 ++ src/agent/src/Bridge/Mcp/McpClient.php | 289 +++++++++++++++++ src/agent/src/Bridge/Mcp/McpToolbox.php | 217 +++++++++++++ src/agent/src/Bridge/Mcp/README.md | 105 +++++++ .../src/Bridge/Mcp/Tests/McpClientTest.php | 123 ++++++++ .../Mcp/Transport/AbstractHttpTransport.php | 133 ++++++++ .../Bridge/Mcp/Transport/HttpTransport.php | 71 +++++ .../src/Bridge/Mcp/Transport/SseTransport.php | 293 ++++++++++++++++++ .../Bridge/Mcp/Transport/StdioTransport.php | 196 ++++++++++++ .../Mcp/Transport/TransportInterface.php | 49 +++ src/agent/src/Bridge/Mcp/composer.json | 55 ++++ src/agent/src/Bridge/Mcp/phpunit.xml.dist | 28 ++ src/agent/src/Toolbox/AgentProcessor.php | 2 +- src/agent/src/Toolbox/ChainToolbox.php | 84 +++++ src/agent/src/Toolbox/ToolResultConverter.php | 35 ++- .../tests/Toolbox/ToolResultConverterTest.php | 29 +- src/ai-bundle/config/options.php | 51 +++ src/ai-bundle/src/AiBundle.php | 71 +++++ .../ToolboxCompilerPass.php | 118 +++++++ .../src/Profiler/TraceableToolbox.php | 21 ++ .../templates/data_collector.html.twig | 2 +- .../Contract/ToolCallMessageNormalizer.php | 9 +- .../Contract/ToolCallMessageNormalizer.php | 60 +++- .../Contract/ToolCallMessageNormalizer.php | 54 +++- .../Gpt/Message/ToolCallMessageNormalizer.php | 58 +++- .../Contract/ToolCallMessageNormalizer.php | 55 +++- .../Message/ToolCallMessageNormalizer.php | 36 ++- src/platform/src/Message/Message.php | 4 +- src/platform/src/Message/ToolCallMessage.php | 61 +++- .../Gpt/Message/MessageBagNormalizerTest.php | 2 +- .../Message/ToolCallMessageNormalizerTest.php | 2 +- .../Message/ToolCallMessageNormalizerTest.php | 12 +- src/platform/tests/Message/MessageTest.php | 4 +- .../tests/Message/ToolCallMessageTest.php | 3 +- 53 files changed, 2723 insertions(+), 54 deletions(-) create mode 100644 demo/assets/controllers/timeline_controller.js create mode 100644 demo/src/Timeline/Chat.php create mode 100644 demo/src/Timeline/TwigComponent.php create mode 100644 demo/templates/components/timeline.html.twig create mode 100644 src/agent/src/Bridge/Mcp/.gitattributes create mode 100644 src/agent/src/Bridge/Mcp/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/agent/src/Bridge/Mcp/.github/close-pull-request.yml create mode 100644 src/agent/src/Bridge/Mcp/.gitignore create mode 100644 src/agent/src/Bridge/Mcp/CHANGELOG.md create mode 100644 src/agent/src/Bridge/Mcp/Exception/ConnectionException.php create mode 100644 src/agent/src/Bridge/Mcp/Exception/McpException.php create mode 100644 src/agent/src/Bridge/Mcp/Exception/ProtocolException.php create mode 100644 src/agent/src/Bridge/Mcp/Exception/TimeoutException.php create mode 100644 src/agent/src/Bridge/Mcp/LICENSE create mode 100644 src/agent/src/Bridge/Mcp/McpClient.php create mode 100644 src/agent/src/Bridge/Mcp/McpToolbox.php create mode 100644 src/agent/src/Bridge/Mcp/README.md create mode 100644 src/agent/src/Bridge/Mcp/Tests/McpClientTest.php create mode 100644 src/agent/src/Bridge/Mcp/Transport/AbstractHttpTransport.php create mode 100644 src/agent/src/Bridge/Mcp/Transport/HttpTransport.php create mode 100644 src/agent/src/Bridge/Mcp/Transport/SseTransport.php create mode 100644 src/agent/src/Bridge/Mcp/Transport/StdioTransport.php create mode 100644 src/agent/src/Bridge/Mcp/Transport/TransportInterface.php create mode 100644 src/agent/src/Bridge/Mcp/composer.json create mode 100644 src/agent/src/Bridge/Mcp/phpunit.xml.dist create mode 100644 src/agent/src/Toolbox/ChainToolbox.php create mode 100644 src/ai-bundle/src/DependencyInjection/ToolboxCompilerPass.php diff --git a/demo/assets/controllers/timeline_controller.js b/demo/assets/controllers/timeline_controller.js new file mode 100644 index 000000000..76e293b68 --- /dev/null +++ b/demo/assets/controllers/timeline_controller.js @@ -0,0 +1,59 @@ +import { Controller } from '@hotwired/stimulus'; +import { getComponent } from '@symfony/ux-live-component'; + +export default class extends Controller { + async initialize() { + this.component = await getComponent(this.element); + this.scrollToBottom(); + + const input = document.getElementById('chat-message'); + input.addEventListener('keypress', (event) => { + if (event.key === 'Enter') { + this.submitMessage(); + } + }); + input.focus(); + + const resetButton = document.getElementById('chat-reset'); + resetButton.addEventListener('click', (event) => { + this.component.action('reset'); + }); + + const submitButton = document.getElementById('chat-submit'); + submitButton.addEventListener('click', (event) => { + this.submitMessage(); + }); + + this.component.on('loading.state:started', (e,r) => { + if (r.actions.includes('reset')) { + return; + } + document.getElementById('welcome')?.remove(); + document.getElementById('loading-message').removeAttribute('class'); + this.scrollToBottom(); + }); + + this.component.on('loading.state:finished', () => { + document.getElementById('loading-message').setAttribute('class', 'd-none'); + }); + + this.component.on('render:finished', () => { + this.scrollToBottom(); + }); + }; + + submitMessage() { + const input = document.getElementById('chat-message'); + const message = input.value; + document + .getElementById('loading-message') + .getElementsByClassName('user-message')[0].innerHTML = message; + this.component.action('submit', { message }); + input.value = ''; + } + + scrollToBottom() { + const chatBody = document.getElementById('chat-body'); + chatBody.scrollTop = chatBody.scrollHeight; + } +} \ No newline at end of file diff --git a/demo/assets/styles/app.css b/demo/assets/styles/app.css index f6151b1d8..a5ea5db59 100644 --- a/demo/assets/styles/app.css +++ b/demo/assets/styles/app.css @@ -63,3 +63,7 @@ body { } } } + +.timeline .bot-message img { + max-width: 500px; +} diff --git a/demo/config/packages/ai.yaml b/demo/config/packages/ai.yaml index a34557c8e..aa8ab5df6 100644 --- a/demo/config/packages/ai.yaml +++ b/demo/config/packages/ai.yaml @@ -4,6 +4,17 @@ ai: api_key: '%env(OPENAI_API_KEY)%' huggingface: api_key: '%env(HUGGINGFACE_API_KEY)%' + mcp: + graphify: + transport: sse + url: 'https://agents-mcp-hackathon-graphify.hf.space/gradio_api/mcp/sse' + tools: + - 'Graphify_generate_timeline_diagram' + city: + transport: sse + url: 'https://kingabzpro-live-city-mcp.hf.space/gradio_api/mcp/sse' + tools: + - 'live_city_mcp_get_city_news' agent: blog: platform: 'ai.platform.openai' @@ -75,6 +86,17 @@ ai: model: 'gpt-4o-mini' prompt: 'You are a helpful general assistant. Assist users with any questions or tasks they may have.' tools: false + timeline: + platform: 'ai.platform.openai' + model: 'gpt-4o-mini' + prompt: | + You are a news timeline generator. When the user asks about a city: + 1) First use live_city_mcp_get_city_news to fetch news for that city + 2) Then use Graphify_generate_timeline_diagram with this JSON format: + {"title": "News from [City]", "events_per_row": 3, "events": [{"id": "1", "label": "Short title", "date": "2024-12-13"}]} + tools: + - 'ai.mcp.toolbox.graphify' + - 'ai.mcp.toolbox.city' multi_agent: support: orchestrator: 'orchestrator' diff --git a/demo/config/routes.yaml b/demo/config/routes.yaml index e921dc88a..0f701c51f 100644 --- a/demo/config/routes.yaml +++ b/demo/config/routes.yaml @@ -63,6 +63,13 @@ youtube: template: 'chat.html.twig' context: { chat: 'youtube' } +timeline: + path: '/timeline' + controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController' + defaults: + template: 'chat.html.twig' + context: { chat: 'timeline' } + # Load MCP routes conditionally based on configuration _mcp: resource: . diff --git a/demo/src/Timeline/Chat.php b/demo/src/Timeline/Chat.php new file mode 100644 index 000000000..783bac426 --- /dev/null +++ b/demo/src/Timeline/Chat.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Timeline; + +use Symfony\AI\Agent\AgentInterface; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @author Camille Islasse + */ +final class Chat +{ + private const SESSION_KEY = 'timeline-chat'; + + public function __construct( + private readonly RequestStack $requestStack, + #[Autowire(service: 'ai.agent.timeline')] + private readonly AgentInterface $agent, + ) { + } + + public function loadMessages(): MessageBag + { + return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag()); + } + + public function submitMessage(string $message): void + { + $messages = $this->loadMessages(); + + $messages->add(Message::ofUser($message)); + $result = $this->agent->call($messages); + + \assert($result instanceof TextResult); + + $messages->add(Message::ofAssistant($result->getContent())); + + $this->saveMessages($messages); + } + + public function reset(): void + { + $this->requestStack->getSession()->remove(self::SESSION_KEY); + } + + private function saveMessages(MessageBag $messages): void + { + $this->requestStack->getSession()->set(self::SESSION_KEY, $messages); + } +} diff --git a/demo/src/Timeline/TwigComponent.php b/demo/src/Timeline/TwigComponent.php new file mode 100644 index 000000000..abd6838a3 --- /dev/null +++ b/demo/src/Timeline/TwigComponent.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Timeline; + +use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveArg; +use Symfony\UX\LiveComponent\DefaultActionTrait; + +/** + * @author Camille Islasse + */ +#[AsLiveComponent('timeline')] +final class TwigComponent +{ + use DefaultActionTrait; + + public function __construct( + private readonly Chat $timeline, + ) { + } + + /** + * @return MessageInterface[] + */ + public function getMessages(): array + { + return $this->timeline->loadMessages()->withoutSystemMessage()->getMessages(); + } + + #[LiveAction] + public function submit(#[LiveArg] string $message): void + { + $this->timeline->submitMessage($message); + } + + #[LiveAction] + public function reset(): void + { + $this->timeline->reset(); + } +} diff --git a/demo/templates/_message.html.twig b/demo/templates/_message.html.twig index 36779db36..9e45e3a1a 100644 --- a/demo/templates/_message.html.twig +++ b/demo/templates/_message.html.twig @@ -1,6 +1,8 @@ {% if message.role.value == 'assistant' %} {{ _self.bot(message.content, latest: latest) }} -{% else %} +{% elseif message.role.value == 'tool' and message.hasImageContent() %} + {{ _self.toolResult(message.content) }} +{% elseif message.role.value == 'user' %} {{ _self.user(message.content) }} {% endif %} @@ -51,3 +53,20 @@ {% endmacro %} + +{% macro toolResult(content) %} +
+
+ {{ ux_icon('mdi:tools', { height: '45px', width: '45px' }) }} +
+
+ {% for item in content %} + {% if item.format is defined and item.format starts with 'image/' %} +
+ +
+ {% endif %} + {% endfor %} +
+
+{% endmacro %} diff --git a/demo/templates/components/timeline.html.twig b/demo/templates/components/timeline.html.twig new file mode 100644 index 000000000..74a832312 --- /dev/null +++ b/demo/templates/components/timeline.html.twig @@ -0,0 +1,30 @@ +{% import "_message.html.twig" as message %} + +
+
+ {{ ux_icon('mdi:timeline', { height: '32px', width: '32px' }) }} + Timeline Bot + +
+
+ {% for message in this.messages %} + {% include '_message.html.twig' with { message, latest: loop.last } %} + {% else %} +
+ {{ ux_icon('mdi:timeline', { height: '200px', width: '200px' }) }} +

Generate news timelines with AI using MCP

+ Try: "Show me the latest news from Berlin" +
+ {% endfor %} +
+ {{ message.user([{text:''}]) }} + {{ message.bot('The Timeline Bot is generating your timeline ...', true) }} +
+
+ +
\ No newline at end of file diff --git a/demo/templates/index.html.twig b/demo/templates/index.html.twig index 92a331aad..0effd6039 100644 --- a/demo/templates/index.html.twig +++ b/demo/templates/index.html.twig @@ -168,5 +168,26 @@ +
+
+
+
+ {{ ux_icon('mdi:timeline', { height: '150px', width: '150px' }) }} +
+
+
Timeline Bot
+

Generate news timelines using MCP client integration.

+ Try Timeline Bot +
+ {# Profiler route only available in dev #} + {% if 'dev' == app.environment %} + + {% endif %} +
+
+
{% endblock %} diff --git a/splitsh.json b/splitsh.json index 485d84dad..831215b86 100644 --- a/splitsh.json +++ b/splitsh.json @@ -15,6 +15,7 @@ "ai-tavily-tool": "src/agent/src/Bridge/Tavily", "ai-youtube-tool": "src/agent/src/Bridge/Youtube", "ai-wikipedia-tool": "src/agent/src/Bridge/Wikipedia", + "ai-mcp-tool": "src/agent/src/Bridge/Mcp", "ai-bundle": "src/ai-bundle", "ai-chat": "src/chat", "mcp-bundle": "src/mcp-bundle", diff --git a/src/agent/src/Bridge/Mcp/.gitattributes b/src/agent/src/Bridge/Mcp/.gitattributes new file mode 100644 index 000000000..350365844 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore \ No newline at end of file diff --git a/src/agent/src/Bridge/Mcp/.github/PULL_REQUEST_TEMPLATE.md b/src/agent/src/Bridge/Mcp/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..55e9c1e26 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/ai + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! \ No newline at end of file diff --git a/src/agent/src/Bridge/Mcp/.github/close-pull-request.yml b/src/agent/src/Bridge/Mcp/.github/close-pull-request.yml new file mode 100644 index 000000000..487ca74fc --- /dev/null +++ b/src/agent/src/Bridge/Mcp/.github/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ai + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! \ No newline at end of file diff --git a/src/agent/src/Bridge/Mcp/.gitignore b/src/agent/src/Bridge/Mcp/.gitignore new file mode 100644 index 000000000..481f71a2c --- /dev/null +++ b/src/agent/src/Bridge/Mcp/.gitignore @@ -0,0 +1,5 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache +.phpunit.cache/ \ No newline at end of file diff --git a/src/agent/src/Bridge/Mcp/CHANGELOG.md b/src/agent/src/Bridge/Mcp/CHANGELOG.md new file mode 100644 index 000000000..e2a14f7e6 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/CHANGELOG.md @@ -0,0 +1,9 @@ +CHANGELOG +========= + +0.1 +--- + +* Initial release with MCP client support +* HTTP, SSE, and Stdio transports +* McpToolbox implementing ToolboxInterface \ No newline at end of file diff --git a/src/agent/src/Bridge/Mcp/Exception/ConnectionException.php b/src/agent/src/Bridge/Mcp/Exception/ConnectionException.php new file mode 100644 index 000000000..d224f44e8 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Exception/ConnectionException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Exception; + +/** + * Exception thrown when connection to MCP server fails. + * + * @author Camille Islasse + */ +class ConnectionException extends McpException +{ +} diff --git a/src/agent/src/Bridge/Mcp/Exception/McpException.php b/src/agent/src/Bridge/Mcp/Exception/McpException.php new file mode 100644 index 000000000..746a38960 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Exception/McpException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Exception; + +use Symfony\AI\Agent\Exception\ExceptionInterface; + +/** + * @author Camille Islasse + */ +class McpException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/agent/src/Bridge/Mcp/Exception/ProtocolException.php b/src/agent/src/Bridge/Mcp/Exception/ProtocolException.php new file mode 100644 index 000000000..72f284c56 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Exception/ProtocolException.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Exception; + +/** + * Exception thrown when MCP server returns a JSON-RPC error. + * + * @author Camille Islasse + */ +class ProtocolException extends McpException +{ + public function __construct( + private readonly int $errorCode, + string $message, + private readonly mixed $errorData = null, + ) { + parent::__construct(\sprintf('MCP protocol error %d: %s', $errorCode, $message)); + } + + public function getErrorCode(): int + { + return $this->errorCode; + } + + public function getErrorData(): mixed + { + return $this->errorData; + } +} diff --git a/src/agent/src/Bridge/Mcp/Exception/TimeoutException.php b/src/agent/src/Bridge/Mcp/Exception/TimeoutException.php new file mode 100644 index 000000000..1ebdb38df --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Exception/TimeoutException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Exception; + +/** + * Exception thrown when MCP server does not respond within the timeout. + * + * @author Camille Islasse + */ +class TimeoutException extends ConnectionException +{ +} diff --git a/src/agent/src/Bridge/Mcp/LICENSE b/src/agent/src/Bridge/Mcp/LICENSE new file mode 100644 index 000000000..d69ac7f2b --- /dev/null +++ b/src/agent/src/Bridge/Mcp/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/src/agent/src/Bridge/Mcp/McpClient.php b/src/agent/src/Bridge/Mcp/McpClient.php new file mode 100644 index 000000000..665eb02a5 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/McpClient.php @@ -0,0 +1,289 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp; + +use Mcp\Schema\Prompt; +use Mcp\Schema\Resource; +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\GetPromptResult; +use Mcp\Schema\Result\InitializeResult; +use Mcp\Schema\Result\ListPromptsResult; +use Mcp\Schema\Result\ListResourcesResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Schema\Result\ReadResourceResult; +use Mcp\Schema\ServerCapabilities; +use Mcp\Schema\Tool; +use Symfony\AI\Agent\Bridge\Mcp\Exception\McpException; +use Symfony\AI\Agent\Bridge\Mcp\Exception\ProtocolException; +use Symfony\AI\Agent\Bridge\Mcp\Transport\TransportInterface; + +/** + * Client for communicating with MCP (Model Context Protocol) servers. + * + * This client handles the MCP protocol including initialization handshake, + * tool listing and execution, resource access, and prompt retrieval. + * + * @author Camille Islasse + */ +final class McpClient +{ + private const PROTOCOL_VERSION = '2024-11-05'; + + private int $requestId = 0; + private bool $initialized = false; + private ?InitializeResult $initializeResult = null; + + public function __construct( + private readonly TransportInterface $transport, + private readonly string $clientName = 'symfony-ai', + private readonly string $clientVersion = '1.0.0', + ) { + } + + public function __destruct() + { + if ($this->initialized) { + $this->close(); + } + } + + /** + * Initialize the connection to the MCP server. + * + * This performs the MCP handshake: + * 1. Send initialize request with client capabilities + * 2. Receive server capabilities + * 3. Send initialized notification + */ + public function initialize(): InitializeResult + { + if ($this->initialized && null !== $this->initializeResult) { + return $this->initializeResult; + } + + $this->transport->connect(); + + $response = $this->request('initialize', [ + 'protocolVersion' => self::PROTOCOL_VERSION, + 'capabilities' => new \stdClass(), + 'clientInfo' => [ + 'name' => $this->clientName, + 'version' => $this->clientVersion, + ], + ]); + + $this->initializeResult = InitializeResult::fromArray($response['result'] ?? []); + + // Send initialized notification + $this->notify('notifications/initialized'); + + $this->initialized = true; + + return $this->initializeResult; + } + + /** + * Close the connection to the MCP server. + */ + public function close(): void + { + $this->transport->disconnect(); + $this->initialized = false; + $this->initializeResult = null; + } + + /** + * Get the server capabilities returned during initialization. + */ + public function getServerCapabilities(): ?ServerCapabilities + { + return $this->initializeResult?->capabilities; + } + + /** + * List available tools from the MCP server. + * + * @return list + */ + public function listTools(): array + { + $this->ensureInitialized(); + + return $this->listPaginated('tools/list', fn (array $data) => ListToolsResult::fromArray($data), 'tools'); + } + + /** + * Call a tool on the MCP server. + * + * @param array $arguments The tool arguments + */ + public function callTool(string $name, array $arguments = []): CallToolResult + { + $this->ensureInitialized(); + + // Filter out null values from arguments + $arguments = array_filter($arguments, fn ($value) => null !== $value); + + $response = $this->request('tools/call', [ + 'name' => $name, + 'arguments' => [] === $arguments ? new \stdClass() : $arguments, + ]); + + return CallToolResult::fromArray($response['result'] ?? ['content' => []]); + } + + /** + * List available resources from the MCP server. + * + * @return list<\Mcp\Schema\Resource> + */ + public function listResources(): array + { + $this->ensureInitialized(); + + return $this->listPaginated('resources/list', fn (array $data) => ListResourcesResult::fromArray($data), 'resources'); + } + + /** + * Read a resource from the MCP server. + */ + public function readResource(string $uri): ReadResourceResult + { + $this->ensureInitialized(); + + $response = $this->request('resources/read', [ + 'uri' => $uri, + ]); + + return ReadResourceResult::fromArray($response['result'] ?? ['contents' => []]); + } + + /** + * List available prompts from the MCP server. + * + * @return list + */ + public function listPrompts(): array + { + $this->ensureInitialized(); + + return $this->listPaginated('prompts/list', fn (array $data) => ListPromptsResult::fromArray($data), 'prompts'); + } + + /** + * Get a prompt from the MCP server. + * + * @param array $arguments The prompt arguments + */ + public function getPrompt(string $name, array $arguments = []): GetPromptResult + { + $this->ensureInitialized(); + + $response = $this->request('prompts/get', [ + 'name' => $name, + 'arguments' => [] === $arguments ? new \stdClass() : $arguments, + ]); + + return GetPromptResult::fromArray($response['result'] ?? ['messages' => []]); + } + + /** + * Ping the MCP server. + */ + public function ping(): void + { + $this->ensureInitialized(); + + $this->request('ping'); + } + + /** + * Generic paginated list method to reduce duplication. + * + * @template T + * + * @param callable(array): T $resultFactory + * + * @return array + */ + private function listPaginated(string $method, callable $resultFactory, string $itemsKey): array + { + $items = []; + $cursor = null; + + do { + $params = []; + if (null !== $cursor) { + $params['cursor'] = $cursor; + } + + $response = $this->request($method, $params); + $result = $resultFactory($response['result'] ?? [$itemsKey => []]); + + foreach ($result->$itemsKey as $item) { + $items[] = $item; + } + + $cursor = $result->nextCursor; + } while (null !== $cursor); + + return $items; + } + + /** + * @param array $params + * + * @return array + */ + private function request(string $method, array $params = []): array + { + $id = ++$this->requestId; + + $data = [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'method' => $method, + 'params' => [] === $params ? new \stdClass() : $params, + ]; + + $response = $this->transport->request($data); + + if (isset($response['id']) && $response['id'] !== $id) { + throw new McpException(\sprintf('Response ID mismatch: expected %d, got "%s".', $id, $response['id'])); + } + + if (isset($response['error'])) { + $error = $response['error']; + throw new ProtocolException($error['code'] ?? 0, $error['message'] ?? 'Unknown error', $error['data'] ?? null); + } + + return $response; + } + + private function notify(string $method): void + { + $data = [ + 'jsonrpc' => '2.0', + 'method' => $method, + 'params' => new \stdClass(), + ]; + + $this->transport->notify($data); + } + + private function ensureInitialized(): void + { + if (!$this->initialized) { + $this->initialize(); + } + } +} diff --git a/src/agent/src/Bridge/Mcp/McpToolbox.php b/src/agent/src/Bridge/Mcp/McpToolbox.php new file mode 100644 index 000000000..e296b100a --- /dev/null +++ b/src/agent/src/Bridge/Mcp/McpToolbox.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp; + +use Mcp\Schema\Content\AudioContent; +use Mcp\Schema\Content\BlobResourceContents; +use Mcp\Schema\Content\EmbeddedResource; +use Mcp\Schema\Content\ImageContent; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\TextResourceContents; +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Tool as McpTool; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\AI\Agent\Bridge\Mcp\Exception\McpException; +use Symfony\AI\Agent\Toolbox\Exception\ToolExecutionException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\AI\Agent\Toolbox\ToolResult; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Result\ToolCall; +use Symfony\AI\Platform\Tool\ExecutionReference; +use Symfony\AI\Platform\Tool\Tool; + +/** + * Toolbox that exposes tools from an MCP server. + * + * This adapter allows using tools from a remote MCP server + * as if they were local Symfony AI tools. + * + * @author Camille Islasse + */ +final class McpToolbox implements ToolboxInterface +{ + /** @var Tool[]|null */ + private ?array $tools = null; + + /** @var array|null */ + private ?array $mcpToolsIndex = null; + + /** + * @param string[]|null $allowedTools List of tool names to expose (null = all tools) + */ + public function __construct( + private readonly McpClient $client, + private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?array $allowedTools = null, + ) { + } + + public function getTools(): array + { + if (null !== $this->tools) { + return $this->tools; + } + + $mcpTools = $this->client->listTools(); + $this->tools = []; + $this->mcpToolsIndex = []; + + foreach ($mcpTools as $mcpTool) { + // Filter by allowed tools if specified + if (null !== $this->allowedTools && !\in_array($mcpTool->name, $this->allowedTools, true)) { + continue; + } + + $this->tools[] = $this->convertMcpTool($mcpTool); + $this->mcpToolsIndex[$mcpTool->name] = $mcpTool; + } + + return $this->tools; + } + + public function execute(ToolCall $toolCall): ToolResult + { + $this->ensureToolExists($toolCall); + + try { + $result = $this->client->callTool($toolCall->getName(), $toolCall->getArguments()); + $content = $this->convertMcpContent($result); + + return new ToolResult($toolCall, $content); + } catch (McpException $e) { + throw ToolExecutionException::executionFailed($toolCall, $e); + } + } + + /** + * Clear the cached tools list. + * + * Call this method to refresh the tools from the MCP server. + */ + public function refresh(): void + { + $this->tools = null; + $this->mcpToolsIndex = null; + } + + /** + * Convert an MCP tool definition to a Symfony AI Tool. + */ + private function convertMcpTool(McpTool $mcpTool): Tool + { + // Create a placeholder ExecutionReference since MCP tools are executed remotely + $reference = new ExecutionReference(self::class, $mcpTool->name); + + $schema = $mcpTool->inputSchema; + $parameters = [ + 'type' => 'object', + 'properties' => $schema['properties'] ?? [], + 'required' => $schema['required'] ?? [], + 'additionalProperties' => false, + ]; + + return new Tool( + $reference, + $mcpTool->name, + $mcpTool->description ?? '', + $parameters, + ); + } + + /** + * Convert MCP CallToolResult to Platform ContentInterface[]. + * + * MCP returns content as an array of typed content blocks (TextContent, ImageContent, etc.). + * This method converts them to Symfony AI Platform content types. + * + * @return ContentInterface[] + */ + private function convertMcpContent(CallToolResult $result): array + { + $contents = []; + + foreach ($result->content as $content) { + $converted = match (true) { + $content instanceof TextContent => new Text($result->isError ? 'Error: '.$content->text : $content->text), + $content instanceof ImageContent => new Image($this->decodeBase64($content->data), $content->mimeType), + $content instanceof AudioContent => new Audio($this->decodeBase64($content->data), $content->mimeType), + $content instanceof EmbeddedResource => $this->convertEmbeddedResource($content), + default => $this->handleUnknownContent($content), + }; + + if (null !== $converted) { + $contents[] = $converted; + } + } + + return $contents; + } + + /** + * Convert an MCP EmbeddedResource to the appropriate Platform ContentInterface. + */ + private function convertEmbeddedResource(EmbeddedResource $resource): ContentInterface + { + $resourceContent = $resource->resource; + + if ($resourceContent instanceof TextResourceContents) { + return new Text($resourceContent->text); + } + + // BlobResourceContents - detect type from mimeType + /** @var BlobResourceContents $resourceContent */ + $mimeType = $resourceContent->mimeType ?? 'application/octet-stream'; + $data = $this->decodeBase64($resourceContent->blob); + + return match (true) { + str_starts_with($mimeType, 'image/') => new Image($data, $mimeType), + str_starts_with($mimeType, 'audio/') => new Audio($data, $mimeType), + default => new File($data, $mimeType), + }; + } + + private function handleUnknownContent(mixed $content): null + { + $this->logger->warning('Unknown MCP content type received: {type}', [ + 'type' => $content::class, + ]); + + return null; + } + + private function decodeBase64(string $data): string + { + $decoded = base64_decode($data, true); + + if (false === $decoded) { + throw new McpException('Invalid base64 data received from MCP server.'); + } + + return $decoded; + } + + private function ensureToolExists(ToolCall $toolCall): void + { + // Ensure tools are loaded + $this->getTools(); + + if (!isset($this->mcpToolsIndex[$toolCall->getName()])) { + throw ToolNotFoundException::notFoundForToolCall($toolCall); + } + } +} diff --git a/src/agent/src/Bridge/Mcp/README.md b/src/agent/src/Bridge/Mcp/README.md new file mode 100644 index 000000000..5bea153c2 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/README.md @@ -0,0 +1,105 @@ +MCP Bridge for Symfony AI +========================= + +This bridge provides MCP (Model Context Protocol) client support for Symfony AI, +allowing you to connect to MCP servers and use their tools within your AI agents. + +> **Note:** The [official MCP PHP SDK](https://github.com/modelcontextprotocol/php-sdk) is planning +> to add a client component. Once available, this bridge may switch to using the official client +> instead of its own transport implementation. + +Installation +------------ + +```bash +composer require symfony/ai-mcp-tool +``` + +Usage +----- + +### With Symfony AI Bundle + +Configure MCP servers in your `config/packages/ai.yaml`: + +```yaml +ai: + mcp: + my_server: + transport: stdio + command: npx + args: ['@modelcontextprotocol/server-filesystem', '/tmp'] + + remote_server: + transport: sse + url: 'https://example.com/sse' + headers: + Authorization: 'Bearer %env(MCP_API_KEY)%' +``` + +### Standalone Usage + +```php +use Symfony\AI\Agent\Bridge\Mcp\McpClient; +use Symfony\AI\Agent\Bridge\Mcp\McpToolbox; +use Symfony\AI\Agent\Bridge\Mcp\Transport\StdioTransport; + +// Create transport +$transport = new StdioTransport('npx', ['@modelcontextprotocol/server-filesystem', '/tmp']); + +// Create client and toolbox +$client = new McpClient($transport); +$toolbox = new McpToolbox($client); + +// Get available tools +$tools = $toolbox->getTools(); + +// Use with an agent +$agent = new Agent($platform, $toolbox); +``` + +Transports +---------- + +### StdioTransport + +For local MCP servers that communicate via stdin/stdout: + +```php +$transport = new StdioTransport( + command: 'npx', + args: ['@modelcontextprotocol/server-filesystem', '/tmp'], + env: ['NODE_ENV' => 'production'], + timeout: 30 +); +``` + +### HttpTransport + +For remote MCP servers using the Streamable HTTP protocol: + +```php +$transport = new HttpTransport( + httpClient: $httpClient, + url: 'https://example.com/mcp', + headers: ['Authorization' => 'Bearer token'] +); +``` + +### SseTransport + +For remote MCP servers using Server-Sent Events: + +```php +$transport = new SseTransport( + httpClient: $httpClient, + url: 'https://example.com/sse', + headers: ['Authorization' => 'Bearer token'] +); +``` + +Resources +--------- + +* [MCP Specification](https://modelcontextprotocol.io/) +* [Symfony AI Documentation](https://symfony.com/doc/current/ai.html) \ No newline at end of file diff --git a/src/agent/src/Bridge/Mcp/Tests/McpClientTest.php b/src/agent/src/Bridge/Mcp/Tests/McpClientTest.php new file mode 100644 index 000000000..f8124a1e4 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Tests/McpClientTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Bridge\Mcp\McpClient; +use Symfony\AI\Agent\Bridge\Mcp\Transport\TransportInterface; + +/** + * @author Camille Islasse + */ +final class McpClientTest extends TestCase +{ + public function testInitialize() + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once())->method('connect'); + $transport->expects($this->once())->method('request')->willReturn([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => [], + 'serverInfo' => [ + 'name' => 'test-server', + 'version' => '1.0.0', + ], + ], + ]); + $transport->expects($this->once())->method('notify'); + + $client = new McpClient($transport); + $result = $client->initialize(); + + $this->assertSame('test-server', $result->serverInfo->name); + $this->assertSame('1.0.0', $result->serverInfo->version); + } + + public function testListTools() + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('request')->willReturnOnConsecutiveCalls( + // initialize response + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0'], + ], + ], + // tools/list response + [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [ + 'tools' => [ + [ + 'name' => 'test_tool', + 'description' => 'A test tool', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string'], + ], + 'required' => ['query'], + ], + ], + ], + ], + ] + ); + + $client = new McpClient($transport); + $tools = $client->listTools(); + + $this->assertCount(1, $tools); + $this->assertSame('test_tool', $tools[0]->name); + $this->assertSame('A test tool', $tools[0]->description); + } + + public function testCallTool() + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('request')->willReturnOnConsecutiveCalls( + // initialize response + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0'], + ], + ], + // tools/call response + [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [ + 'content' => [ + ['type' => 'text', 'text' => 'Tool result'], + ], + ], + ] + ); + + $client = new McpClient($transport); + $result = $client->callTool('test_tool', ['query' => 'test']); + + $this->assertCount(1, $result->content); + } +} diff --git a/src/agent/src/Bridge/Mcp/Transport/AbstractHttpTransport.php b/src/agent/src/Bridge/Mcp/Transport/AbstractHttpTransport.php new file mode 100644 index 000000000..c88f35277 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Transport/AbstractHttpTransport.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Transport; + +use Symfony\AI\Agent\Bridge\Mcp\Exception\ConnectionException; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Abstract base class for HTTP-based MCP transports. + * + * @author Camille Islasse + */ +abstract class AbstractHttpTransport implements TransportInterface +{ + protected ?string $sessionId = null; + + /** + * @param string $url The MCP server URL + * @param array $headers Additional HTTP headers (e.g., Authorization) + * @param int $timeout Timeout in seconds for requests + */ + public function __construct( + protected readonly HttpClientInterface $httpClient, + protected readonly string $url, + protected readonly array $headers = [], + protected readonly int $timeout = 30, + ) { + } + + public function connect(): void + { + // HTTP is stateless, nothing to do here + // Session ID will be set from server response if needed + } + + public function request(array $data): array + { + $headers = $this->buildHeaders(); + + try { + $response = $this->httpClient->request('POST', $this->url, [ + 'headers' => $headers, + 'json' => $data, + ]); + + $statusCode = $response->getStatusCode(); + if ($statusCode >= 400) { + throw new ConnectionException(\sprintf('MCP server returned HTTP %d: "%s".', $statusCode, $response->getContent(false))); + } + + // Capture session ID from response headers + $responseHeaders = $response->getHeaders(); + if (isset($responseHeaders['mcp-session-id'][0])) { + $this->sessionId = $responseHeaders['mcp-session-id'][0]; + } + + return $this->parseResponse($response->getContent()); + } catch (ConnectionException $e) { + throw $e; + } catch (\Throwable $e) { + throw new ConnectionException(\sprintf('Failed to communicate with MCP server: "%s".', $e->getMessage()), 0, $e); + } + } + + public function notify(array $data): void + { + $headers = $this->buildHeaders(); + + try { + $this->httpClient->request('POST', $this->url, [ + 'headers' => $headers, + 'json' => $data, + ]); + } catch (\Throwable) { + // Notifications are fire-and-forget + } + } + + public function disconnect(): void + { + if (null === $this->sessionId) { + return; + } + + // Send DELETE request to end session + try { + $headers = array_merge([ + 'Mcp-Session-Id' => $this->sessionId, + ], $this->headers); + + $this->httpClient->request('DELETE', $this->url, [ + 'headers' => $headers, + ]); + } catch (\Throwable) { + // Ignore errors on disconnect + } + + $this->sessionId = null; + } + + /** + * Parse the response content. + * + * @return array + */ + abstract protected function parseResponse(string $content): array; + + /** + * @return array + */ + protected function buildHeaders(): array + { + $headers = array_merge([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + ], $this->headers); + + if (null !== $this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + + return $headers; + } +} diff --git a/src/agent/src/Bridge/Mcp/Transport/HttpTransport.php b/src/agent/src/Bridge/Mcp/Transport/HttpTransport.php new file mode 100644 index 000000000..c8f8520d8 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Transport/HttpTransport.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Transport; + +use Symfony\AI\Agent\Bridge\Mcp\Exception\ConnectionException; + +/** + * Transport for communicating with MCP servers via HTTP. + * + * This transport sends JSON-RPC messages over HTTP POST requests. + * Supports authorization headers for OAuth/API key authentication. + * + * @author Camille Islasse + */ +final class HttpTransport extends AbstractHttpTransport +{ + /** + * Parse response - handles both JSON and SSE (Streamable HTTP) formats. + * + * @return array + */ + protected function parseResponse(string $content): array + { + // First try to parse as plain JSON + $decoded = json_decode($content, true); + if (null !== $decoded && \is_array($decoded) && isset($decoded['jsonrpc'])) { + return $decoded; + } + + // Otherwise parse as SSE (Streamable HTTP format) + $lines = explode("\n", $content); + $eventData = null; + + foreach ($lines as $line) { + $line = trim($line); + + if (str_starts_with($line, 'data: ')) { + $data = substr($line, 6); + + // Skip ping events + if ('ping' === $data) { + continue; + } + + $decoded = json_decode($data, true); + if (null !== $decoded && isset($decoded['jsonrpc'])) { + // Return the first JSON-RPC message (response or error) + if (isset($decoded['id']) || isset($decoded['error'])) { + return $decoded; + } + $eventData = $decoded; + } + } + } + + if (null !== $eventData) { + return $eventData; + } + + throw new ConnectionException('No valid JSON-RPC response found in HTTP response.'); + } +} diff --git a/src/agent/src/Bridge/Mcp/Transport/SseTransport.php b/src/agent/src/Bridge/Mcp/Transport/SseTransport.php new file mode 100644 index 000000000..4cb3fbbef --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Transport/SseTransport.php @@ -0,0 +1,293 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Transport; + +use Symfony\AI\Agent\Bridge\Mcp\Exception\ConnectionException; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * Transport for communicating with MCP servers via SSE (Server-Sent Events). + * + * This transport implements the legacy MCP SSE protocol: + * 1. Connect to /sse endpoint via GET to establish SSE connection + * 2. Receive endpoint event with message URL and session ID + * 3. Send requests via POST to the message endpoint + * 4. Receive responses via the SSE connection + * + * @author Camille Islasse + */ +final class SseTransport implements TransportInterface +{ + private ?string $messageEndpoint = null; + private ?string $baseUrl = null; + private ?ResponseInterface $sseConnection = null; + private string $sseBuffer = ''; + + /** + * @param string $url The MCP server SSE URL (e.g., https://example.com/sse) + * @param array $headers Additional HTTP headers (e.g., Authorization) + * @param int $timeout Timeout in seconds for waiting for responses + */ + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly string $url, + private readonly array $headers = [], + private readonly int $timeout = 30, + ) { + } + + public function connect(): void + { + // Parse base URL for relative endpoint resolution + $parsedUrl = parse_url($this->url); + $this->baseUrl = \sprintf('%s://%s', $parsedUrl['scheme'] ?? 'https', $parsedUrl['host'] ?? ''); + if (isset($parsedUrl['port'])) { + $this->baseUrl .= ':'.$parsedUrl['port']; + } + + try { + // Open SSE connection (kept alive for receiving responses) + $this->sseConnection = $this->httpClient->request('GET', $this->url, [ + 'headers' => array_merge([ + 'Accept' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + ], $this->headers), + 'timeout' => 300, // Long timeout for SSE + ]); + + $statusCode = $this->sseConnection->getStatusCode(); + if ($statusCode >= 400) { + throw new ConnectionException(\sprintf('Failed to connect to SSE endpoint: HTTP %d', $statusCode)); + } + + // Read the endpoint event + $this->messageEndpoint = $this->waitForEndpoint(); + + if (null === $this->messageEndpoint) { + throw new ConnectionException('No endpoint received from SSE connection.'); + } + } catch (ConnectionException $e) { + throw $e; + } catch (\Throwable $e) { + throw new ConnectionException(\sprintf('Failed to establish SSE connection: "%s".', $e->getMessage()), 0, $e); + } + } + + public function request(array $data): array + { + if (null === $this->messageEndpoint || null === $this->sseConnection) { + $this->connect(); + } + + $url = $this->resolveEndpointUrl($this->messageEndpoint); + $requestId = $data['id'] ?? null; + + try { + // Send POST request (fire and forget - response comes via SSE) + $postResponse = $this->httpClient->request('POST', $url, [ + 'headers' => array_merge([ + 'Content-Type' => 'application/json', + ], $this->headers), + 'json' => $data, + ]); + + $statusCode = $postResponse->getStatusCode(); + // 200, 202 Accepted are both valid + if ($statusCode >= 400) { + throw new ConnectionException(\sprintf('MCP server returned HTTP %d: "%s".', $statusCode, $postResponse->getContent(false))); + } + + // Wait for response on SSE connection + return $this->waitForResponse($requestId); + } catch (ConnectionException $e) { + throw $e; + } catch (\Throwable $e) { + throw new ConnectionException(\sprintf('Failed to send request to MCP server: "%s".', $e->getMessage()), 0, $e); + } + } + + public function notify(array $data): void + { + if (null === $this->messageEndpoint) { + $this->connect(); + } + + $url = $this->resolveEndpointUrl($this->messageEndpoint); + + try { + $this->httpClient->request('POST', $url, [ + 'headers' => array_merge([ + 'Content-Type' => 'application/json', + ], $this->headers), + 'json' => $data, + ]); + } catch (\Throwable) { + // Notifications are fire-and-forget + } + } + + public function disconnect(): void + { + $this->sseConnection = null; + $this->messageEndpoint = null; + $this->sseBuffer = ''; + } + + /** + * Wait for the endpoint event on SSE connection. + */ + private function waitForEndpoint(): ?string + { + $start = time(); + + foreach ($this->httpClient->stream($this->sseConnection, $this->timeout) as $chunk) { + if ($chunk->isTimeout()) { + if (time() - $start > $this->timeout) { + break; + } + continue; + } + + $this->sseBuffer .= $chunk->getContent(); + + // Parse SSE events from buffer + $endpoint = $this->parseEndpointFromBuffer(); + if (null !== $endpoint) { + return $endpoint; + } + } + + return null; + } + + /** + * Wait for a JSON-RPC response matching the request ID. + * + * @return array + */ + private function waitForResponse(?int $requestId): array + { + $start = time(); + + foreach ($this->httpClient->stream($this->sseConnection, $this->timeout) as $chunk) { + if ($chunk->isTimeout()) { + if (time() - $start > $this->timeout) { + throw new ConnectionException('Timeout waiting for SSE response.'); + } + continue; + } + + $this->sseBuffer .= $chunk->getContent(); + + // Try to parse a response from buffer + $response = $this->parseResponseFromBuffer($requestId); + if (null !== $response) { + return $response; + } + } + + throw new ConnectionException('SSE connection closed without response.'); + } + + /** + * Parse endpoint from SSE buffer. + */ + private function parseEndpointFromBuffer(): ?string + { + // Look for complete endpoint event + if (!str_contains($this->sseBuffer, "\n\n") && !str_contains($this->sseBuffer, "\r\n\r\n")) { + return null; + } + + $lines = preg_split('/\r?\n/', $this->sseBuffer); + $currentEvent = null; + + foreach ($lines as $i => $line) { + if (str_starts_with($line, 'event: ')) { + $currentEvent = trim(substr($line, 7)); + } elseif (str_starts_with($line, 'data: ') && 'endpoint' === $currentEvent) { + $endpoint = trim(substr($line, 6)); + // Remove processed lines from buffer + $this->sseBuffer = implode("\n", \array_slice($lines, $i + 1)); + + return $endpoint; + } + } + + return null; + } + + /** + * Parse JSON-RPC response from SSE buffer. + * + * @return array|null + */ + private function parseResponseFromBuffer(?int $requestId): ?array + { + // Look for complete events (double newline) + while (preg_match('/data: (.+?)(?:\r?\n\r?\n|\r?\nevent:)/s', $this->sseBuffer, $matches, \PREG_OFFSET_CAPTURE)) { + $data = trim($matches[1][0]); + $endPos = $matches[0][1] + \strlen($matches[0][0]); + + // Handle case where we matched "event:" - don't consume it + if (str_ends_with($matches[0][0], 'event:')) { + $endPos -= 6; + } + + // Remove processed data from buffer + $this->sseBuffer = substr($this->sseBuffer, $endPos); + + // Skip ping + if ('ping' === $data) { + continue; + } + + $decoded = json_decode($data, true); + if (null !== $decoded && isset($decoded['jsonrpc'])) { + // Match by ID or return any response/error + if (null === $requestId || (isset($decoded['id']) && $decoded['id'] === $requestId) || isset($decoded['error'])) { + return $decoded; + } + } + } + + // Also check if buffer ends with a complete data line + if (preg_match('/data: (.+?)$/s', $this->sseBuffer, $matches)) { + $data = trim($matches[1]); + if ('ping' !== $data) { + $decoded = json_decode($data, true); + if (null !== $decoded && isset($decoded['jsonrpc'])) { + if (null === $requestId || (isset($decoded['id']) && $decoded['id'] === $requestId) || isset($decoded['error'])) { + $this->sseBuffer = ''; + + return $decoded; + } + } + } + } + + return null; + } + + /** + * Resolve a potentially relative endpoint URL to an absolute URL. + */ + private function resolveEndpointUrl(string $endpoint): string + { + if (str_starts_with($endpoint, 'http://') || str_starts_with($endpoint, 'https://')) { + return $endpoint; + } + + return $this->baseUrl.$endpoint; + } +} diff --git a/src/agent/src/Bridge/Mcp/Transport/StdioTransport.php b/src/agent/src/Bridge/Mcp/Transport/StdioTransport.php new file mode 100644 index 000000000..cb446f6c4 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Transport/StdioTransport.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Transport; + +use Symfony\AI\Agent\Bridge\Mcp\Exception\ConnectionException; +use Symfony\AI\Agent\Bridge\Mcp\Exception\McpException; + +/** + * Transport for communicating with MCP servers via stdio. + * + * This transport spawns a local process and communicates via stdin/stdout + * using JSON-RPC messages. + * + * @author Camille Islasse + */ +final class StdioTransport implements TransportInterface +{ + /** @var resource|null */ + private $process; + + /** @var array */ + private array $pipes = []; + + /** + * @param string $command The command to execute (e.g., 'npx', 'php', 'python') + * @param list $args Command arguments (e.g., ['@modelcontextprotocol/server-filesystem', '/tmp']) + * @param array $env Additional environment variables + */ + public function __construct( + private readonly string $command, + private readonly array $args = [], + private readonly array $env = [], + private readonly int $timeout = 30, + ) { + } + + public function __destruct() + { + $this->disconnect(); + } + + public function connect(): void + { + if (null !== $this->process) { + return; + } + + $commandLine = $this->command; + if ([] !== $this->args) { + $commandLine .= ' '.implode(' ', array_map('escapeshellarg', $this->args)); + } + + $descriptorSpec = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $env = array_merge(getenv(), $this->env); + + $this->process = proc_open($commandLine, $descriptorSpec, $this->pipes, null, $env); + + if (!\is_resource($this->process)) { + throw new ConnectionException(\sprintf('Failed to start MCP server process: "%s".', $commandLine)); + } + + // Set stdout to non-blocking for reading + stream_set_blocking($this->pipes[1], false); + } + + public function request(array $data): array + { + $this->send($data); + + return $this->receive(); + } + + public function notify(array $data): void + { + $this->send($data); + } + + public function disconnect(): void + { + if (null === $this->process) { + return; + } + + // Close pipes + foreach ($this->pipes as $pipe) { + if (\is_resource($pipe)) { + fclose($pipe); + } + } + + // Terminate process + if (\is_resource($this->process)) { + proc_terminate($this->process); + proc_close($this->process); + } + + $this->process = null; + $this->pipes = []; + } + + /** + * @param array $data + */ + private function send(array $data): void + { + if (null === $this->process || !\is_resource($this->process)) { + throw new McpException('Cannot send data: MCP server process is not running.'); + } + + $json = json_encode($data, \JSON_THROW_ON_ERROR); + $written = fwrite($this->pipes[0], $json."\n"); + + if (false === $written || $written !== \strlen($json) + 1) { + throw new McpException('Failed to write data to MCP server process.'); + } + + fflush($this->pipes[0]); + } + + /** + * @return array + */ + private function receive(): array + { + if (null === $this->process || !\is_resource($this->process)) { + throw new McpException('Cannot receive data: MCP server process is not running.'); + } + + $buffer = ''; + $startTime = time(); + + while (true) { + // Check timeout + if ((time() - $startTime) >= $this->timeout) { + $stderr = stream_get_contents($this->pipes[2]); + $errorMsg = '' !== $stderr ? \sprintf(' Stderr: %s', $stderr) : ''; + + throw new McpException(\sprintf('Timeout waiting for MCP server response.%s', $errorMsg)); + } + + // Wait for data using stream_select (1 second timeout per iteration) + $read = [$this->pipes[1]]; + $write = $except = null; + $ready = stream_select($read, $write, $except, 1); + + if (false === $ready) { + throw new McpException('stream_select() failed while waiting for MCP server response.'); + } + + if (0 === $ready) { + // Timeout on this iteration, continue waiting + continue; + } + + $chunk = fread($this->pipes[1], 4096); + + if (false !== $chunk && '' !== $chunk) { + $buffer .= $chunk; + + // MCP sends one JSON message per line + $lines = explode("\n", $buffer); + $buffer = array_pop($lines); // Keep incomplete line in buffer + + foreach ($lines as $line) { + $line = trim($line); + if ('' === $line) { + continue; + } + + $decoded = json_decode($line, true); + if (null !== $decoded) { + // Skip notifications (no 'id' field) - we want responses + if (isset($decoded['id'])) { + return $decoded; + } + // Notification received, continue waiting for response + } + } + } + } + } +} diff --git a/src/agent/src/Bridge/Mcp/Transport/TransportInterface.php b/src/agent/src/Bridge/Mcp/Transport/TransportInterface.php new file mode 100644 index 000000000..31749b595 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/Transport/TransportInterface.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Mcp\Transport; + +/** + * Interface for MCP transport implementations. + * + * Transports handle the low-level communication with MCP servers, + * whether via stdio (local processes) or HTTP (remote servers). + * + * @author Camille Islasse + */ +interface TransportInterface +{ + /** + * Establish connection to the MCP server. + */ + public function connect(): void; + + /** + * Send a request and receive the response. + * + * @param array $data JSON-RPC message to send + * + * @return array JSON-RPC response + */ + public function request(array $data): array; + + /** + * Send a notification (no response expected). + * + * @param array $data JSON-RPC notification to send + */ + public function notify(array $data): void; + + /** + * Close the connection to the MCP server. + */ + public function disconnect(): void; +} diff --git a/src/agent/src/Bridge/Mcp/composer.json b/src/agent/src/Bridge/Mcp/composer.json new file mode 100644 index 000000000..beb006737 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/composer.json @@ -0,0 +1,55 @@ +{ + "name": "symfony/ai-mcp-tool", + "description": "MCP (Model Context Protocol) client tool for Symfony AI.", + "license": "MIT", + "type": "symfony-ai-tool", + "keywords": [ + "ai", + "mcp", + "model-context-protocol", + "agent", + "tool" + ], + "authors": [ + { + "name": "Camille Islasse", + "email": "guziweb@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "mcp/sdk": "^0.1", + "symfony/ai-agent": "@dev", + "symfony/http-client": "^7.3|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.13" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfony\\AI\\Agent\\Bridge\\Mcp\\": "" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\AI\\Agent\\Bridge\\Mcp\\Tests\\": "Tests/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.x-dev" + }, + "thanks": { + "name": "symfony/ai", + "url": "https://github.com/symfony/ai" + } + } +} \ No newline at end of file diff --git a/src/agent/src/Bridge/Mcp/phpunit.xml.dist b/src/agent/src/Bridge/Mcp/phpunit.xml.dist new file mode 100644 index 000000000..87a7e7116 --- /dev/null +++ b/src/agent/src/Bridge/Mcp/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + Tests + + + + + + . + + + Tests + vendor + + + \ No newline at end of file diff --git a/src/agent/src/Toolbox/AgentProcessor.php b/src/agent/src/Toolbox/AgentProcessor.php index 213783de2..b73750cc8 100644 --- a/src/agent/src/Toolbox/AgentProcessor.php +++ b/src/agent/src/Toolbox/AgentProcessor.php @@ -120,7 +120,7 @@ private function handleToolCallsCallback(Output $output, ToolCallResult $result, $results = []; foreach ($toolCalls as $toolCall) { $results[] = $toolResult = $this->toolbox->execute($toolCall); - $messages->add(Message::ofToolCall($toolCall, $this->resultConverter->convert($toolResult))); + $messages->add(Message::ofToolCall($toolCall, ...$this->resultConverter->convert($toolResult))); array_push($this->sources, ...$toolResult->getSources()); } diff --git a/src/agent/src/Toolbox/ChainToolbox.php b/src/agent/src/Toolbox/ChainToolbox.php new file mode 100644 index 000000000..6c8e6ac30 --- /dev/null +++ b/src/agent/src/Toolbox/ChainToolbox.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Toolbox; + +use Symfony\AI\Agent\Exception\LogicException; +use Symfony\AI\Agent\Toolbox\Exception\ToolNotFoundException; +use Symfony\AI\Platform\Result\ToolCall; + +/** + * Combines multiple toolboxes into a single toolbox. + * + * This allows using tools from multiple sources (local tools, MCP servers, etc.) + * as if they were a single toolbox. + * + * @author Camille Islasse + */ +final class ChainToolbox implements ToolboxInterface +{ + /** @var ToolboxInterface[] */ + private readonly array $toolboxes; + + /** @var array|null */ + private ?array $toolIndex = null; + + /** + * @param ToolboxInterface[] $toolboxes + */ + public function __construct(array $toolboxes) + { + $this->toolboxes = $toolboxes; + } + + public function getTools(): array + { + $tools = []; + $this->toolIndex = []; + + foreach ($this->toolboxes as $toolbox) { + foreach ($toolbox->getTools() as $tool) { + $toolName = $tool->getName(); + + if (isset($this->toolIndex[$toolName])) { + throw new LogicException(\sprintf('Tool "%s" is already registered in another toolbox.', $toolName)); + } + + $tools[] = $tool; + $this->toolIndex[$toolName] = $toolbox; + } + } + + return $tools; + } + + public function execute(ToolCall $toolCall): ToolResult + { + $toolbox = $this->findToolbox($toolCall); + + return $toolbox->execute($toolCall); + } + + private function findToolbox(ToolCall $toolCall): ToolboxInterface + { + if (null === $this->toolIndex) { + $this->getTools(); + } + + $toolName = $toolCall->getName(); + + if (isset($this->toolIndex[$toolName])) { + return $this->toolIndex[$toolName]; + } + + throw ToolNotFoundException::notFoundForToolCall($toolCall); + } +} diff --git a/src/agent/src/Toolbox/ToolResultConverter.php b/src/agent/src/Toolbox/ToolResultConverter.php index e585f3b7b..041839162 100644 --- a/src/agent/src/Toolbox/ToolResultConverter.php +++ b/src/agent/src/Toolbox/ToolResultConverter.php @@ -12,6 +12,8 @@ namespace Symfony\AI\Agent\Toolbox; use Symfony\AI\Agent\Exception\RuntimeException; +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\AI\Platform\Message\Content\Text; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; @@ -31,24 +33,49 @@ public function __construct( } /** + * @return ContentInterface[] + * * @throws RuntimeException */ - public function convert(ToolResult $toolResult): ?string + public function convert(ToolResult $toolResult): array { $result = $toolResult->getResult(); - if (null === $result || \is_string($result)) { + if (null === $result) { + return []; + } + + // Already ContentInterface[] - pass through + if (\is_array($result) && $this->isContentArray($result)) { return $result; } + if (\is_string($result)) { + return [new Text($result)]; + } + if ($result instanceof \Stringable) { - return (string) $result; + return [new Text((string) $result)]; } try { - return $this->serializer->serialize($result, 'json'); + return [new Text($this->serializer->serialize($result, 'json'))]; } catch (SerializerExceptionInterface $e) { throw new RuntimeException('Cannot serialize the tool result.', previous: $e); } } + + /** + * @param array $array + */ + private function isContentArray(array $array): bool + { + foreach ($array as $item) { + if (!$item instanceof ContentInterface) { + return false; + } + } + + return true; + } } diff --git a/src/agent/tests/Toolbox/ToolResultConverterTest.php b/src/agent/tests/Toolbox/ToolResultConverterTest.php index fa8615b53..7d0269019 100644 --- a/src/agent/tests/Toolbox/ToolResultConverterTest.php +++ b/src/agent/tests/Toolbox/ToolResultConverterTest.php @@ -16,32 +16,34 @@ use Symfony\AI\Agent\Toolbox\ToolResult; use Symfony\AI\Agent\Toolbox\ToolResultConverter; use Symfony\AI\Fixtures\StructuredOutput\UserWithConstructor; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Result\ToolCall; final class ToolResultConverterTest extends TestCase { #[DataProvider('provideResults')] - public function testConvert(mixed $result, ?string $expected) + public function testConvert(mixed $result, array $expected) { $toolResult = new ToolResult(new ToolCall('123456789', 'tool_name'), $result); $converter = new ToolResultConverter(); - $this->assertSame($expected, $converter->convert($toolResult)); + $this->assertEquals($expected, $converter->convert($toolResult)); } public static function provideResults(): \Generator { - yield 'null' => [null, null]; + yield 'null' => [null, []]; - yield 'integer' => [42, '42']; + yield 'integer' => [42, [new Text('42')]]; - yield 'float' => [42.42, '42.42']; + yield 'float' => [42.42, [new Text('42.42')]]; - yield 'array' => [['key' => 'value'], '{"key":"value"}']; + yield 'array' => [['key' => 'value'], [new Text('{"key":"value"}')]]; - yield 'string' => ['plain string', 'plain string']; + yield 'string' => ['plain string', [new Text('plain string')]]; - yield 'datetime' => [new \DateTimeImmutable('2021-07-31 12:34:56'), '"2021-07-31T12:34:56+00:00"']; + yield 'datetime' => [new \DateTimeImmutable('2021-07-31 12:34:56'), [new Text('"2021-07-31T12:34:56+00:00"')]]; yield 'stringable' => [ new class implements \Stringable { @@ -50,7 +52,7 @@ public function __toString(): string return 'stringable'; } }, - 'stringable', + [new Text('stringable')], ]; yield 'json_serializable' => [ @@ -60,7 +62,7 @@ public function jsonSerialize(): array return ['key' => 'value']; } }, - '{"key":"value"}', + [new Text('{"key":"value"}')], ]; yield 'object' => [ @@ -71,7 +73,12 @@ public function jsonSerialize(): array isActive: true, age: 18, ), - '{"id":123,"name":"John Doe","createdAt":"2021-07-31T12:34:56+00:00","isActive":true,"age":18}', + [new Text('{"id":123,"name":"John Doe","createdAt":"2021-07-31T12:34:56+00:00","isActive":true,"age":18}')], + ]; + + yield 'content_interface_array' => [ + [new Text('hello'), new Image('data', 'image/png')], + [new Text('hello'), new Image('data', 'image/png')], ]; } } diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 2510a963a..60b833789 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -1216,6 +1216,57 @@ ->end() ->end() ->end() + ->arrayNode('mcp') + ->info('MCP (Model Context Protocol) server connections') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->enumNode('transport') + ->info('Transport type: http, sse, or stdio') + ->values(['http', 'sse', 'stdio']) + ->isRequired() + ->end() + ->stringNode('url') + ->info('URL for HTTP/SSE transport') + ->end() + ->stringNode('command') + ->info('Command for stdio transport (e.g., "npx", "php", "python")') + ->end() + ->arrayNode('args') + ->info('Arguments for stdio command') + ->scalarPrototype()->end() + ->end() + ->arrayNode('env') + ->info('Environment variables for stdio transport') + ->scalarPrototype()->end() + ->end() + ->arrayNode('headers') + ->info('HTTP headers for HTTP/SSE transport') + ->scalarPrototype()->end() + ->end() + ->stringNode('http_client') + ->defaultValue('http_client') + ->info('Service ID of the HTTP client to use (for HTTP/SSE)') + ->end() + ->integerNode('timeout') + ->defaultValue(30) + ->info('Timeout in seconds (for stdio)') + ->end() + ->arrayNode('tools') + ->info('List of tool names to expose (empty = all tools)') + ->scalarPrototype()->end() + ->end() + ->end() + ->validate() + ->ifTrue(static fn (array $v): bool => \in_array($v['transport'], ['http', 'sse'], true) && empty($v['url'])) + ->thenInvalid('URL is required for HTTP and SSE transports.') + ->end() + ->validate() + ->ifTrue(static fn (array $v): bool => 'stdio' === $v['transport'] && empty($v['command'])) + ->thenInvalid('Command is required for stdio transport.') + ->end() + ->end() + ->end() ->end() ->validate() ->ifTrue(function ($v) { diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index f7d879523..845c321bb 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -17,6 +17,11 @@ use Symfony\AI\Agent\AgentInterface; use Symfony\AI\Agent\Attribute\AsInputProcessor; use Symfony\AI\Agent\Attribute\AsOutputProcessor; +use Symfony\AI\Agent\Bridge\Mcp\McpClient; +use Symfony\AI\Agent\Bridge\Mcp\McpToolbox; +use Symfony\AI\Agent\Bridge\Mcp\Transport\HttpTransport; +use Symfony\AI\Agent\Bridge\Mcp\Transport\SseTransport; +use Symfony\AI\Agent\Bridge\Mcp\Transport\StdioTransport; use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor; use Symfony\AI\Agent\InputProcessorInterface; use Symfony\AI\Agent\Memory\MemoryInputProcessor; @@ -30,6 +35,7 @@ use Symfony\AI\Agent\Toolbox\ToolFactory\ChainFactory; use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory; use Symfony\AI\AiBundle\DependencyInjection\ProcessorCompilerPass; +use Symfony\AI\AiBundle\DependencyInjection\ToolboxCompilerPass; use Symfony\AI\AiBundle\Exception\InvalidArgumentException; use Symfony\AI\AiBundle\Profiler\TraceableChat; use Symfony\AI\AiBundle\Profiler\TraceableMessageStore; @@ -142,6 +148,7 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass(new ProcessorCompilerPass()); + $container->addCompilerPass(new ToolboxCompilerPass()); } public function configure(DefinitionConfigurator $definition): void @@ -279,6 +286,10 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $builder->setAlias(RetrieverInterface::class, 'ai.retriever.'.$retrieverName); } + foreach ($config['mcp'] ?? [] as $mcpName => $mcp) { + $this->processMcpConfig($mcpName, $mcp, $builder); + } + $builder->registerAttributeForAutoconfiguration(AsTool::class, static function (ChildDefinition $definition, AsTool $attribute): void { $definition->addTag('ai.tool', [ 'name' => $attribute->name, @@ -2086,4 +2097,64 @@ private function processModelConfig(string $platformName, array $models, Contain $definition = $builder->getDefinition($modelCatalogId); $definition->setArguments([$additionalModels]); } + + /** + * Process MCP server configuration. + * + * Creates transport, client, and toolbox services for each MCP server. + * + * @param array $config + */ + private function processMcpConfig(string $name, array $config, ContainerBuilder $container): void + { + $transportId = 'ai.mcp.transport.'.$name; + $clientId = 'ai.mcp.client.'.$name; + $toolboxId = 'ai.mcp.toolbox.'.$name; + + // Create transport based on type + $transportDefinition = match ($config['transport']) { + 'http' => (new Definition(HttpTransport::class)) + ->setArguments([ + new Reference($config['http_client']), + $config['url'], + $config['headers'] ?? [], + $config['timeout'], + ]), + 'sse' => (new Definition(SseTransport::class)) + ->setArguments([ + new Reference($config['http_client']), + $config['url'], + $config['headers'] ?? [], + $config['timeout'], + ]), + 'stdio' => (new Definition(StdioTransport::class)) + ->setArguments([ + $config['command'], + $config['args'] ?? [], + $config['env'] ?? [], + $config['timeout'], + ]), + default => throw new InvalidArgumentException(\sprintf('Unknown MCP transport type "%s".', $config['transport'])), + }; + $transportDefinition->setLazy(true); + $container->setDefinition($transportId, $transportDefinition); + + // Create MCP client + $clientDefinition = (new Definition(McpClient::class)) + ->setArguments([new Reference($transportId)]) + ->setLazy(true); + $container->setDefinition($clientId, $clientDefinition); + + // Create MCP toolbox + $allowedTools = \is_array($config['tools']) && [] !== $config['tools'] ? $config['tools'] : null; + $toolboxDefinition = (new Definition(McpToolbox::class)) + ->setArguments([ + new Reference($clientId), + new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + $allowedTools, + ]) + ->setLazy(true) + ->addTag('ai.mcp.toolbox', ['name' => $name]); + $container->setDefinition($toolboxId, $toolboxDefinition); + } } diff --git a/src/ai-bundle/src/DependencyInjection/ToolboxCompilerPass.php b/src/ai-bundle/src/DependencyInjection/ToolboxCompilerPass.php new file mode 100644 index 000000000..8e25865dc --- /dev/null +++ b/src/ai-bundle/src/DependencyInjection/ToolboxCompilerPass.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\DependencyInjection; + +use Symfony\AI\Agent\Toolbox\ChainToolbox; +use Symfony\AI\Agent\Toolbox\ToolboxInterface; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Compiler pass that handles ToolboxInterface services in toolbox configurations. + * + * When a service implementing ToolboxInterface is configured as a tool, this pass + * extracts it and wraps the toolbox with a ChainToolbox that combines both + * regular tools and external toolboxes. + * + * @author Camille Islasse + */ +final class ToolboxCompilerPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds('ai.toolbox') as $serviceId => $tags) { + $this->processToolbox($container, $serviceId); + } + } + + private function processToolbox(ContainerBuilder $container, string $serviceId): void + { + $definition = $container->getDefinition($serviceId); + + // Get the first argument which contains the tools/toolboxes references + // For ChildDefinition, we need to check if argument 0 has been replaced + try { + $toolsArgument = $definition->getArgument(0); + } catch (\OutOfBoundsException) { + return; + } + + if (!\is_array($toolsArgument)) { + return; + } + + $tools = []; + $externalToolboxes = []; + + foreach ($toolsArgument as $reference) { + if (!$reference instanceof Reference) { + $tools[] = $reference; + continue; + } + + $refId = (string) $reference; + + // Check if the referenced service implements ToolboxInterface + if ($this->isToolboxInterface($container, $refId)) { + $externalToolboxes[] = $reference; + } else { + $tools[] = $reference; + } + } + + // If no external toolboxes found, nothing to do + if ([] === $externalToolboxes) { + return; + } + + $chainServiceId = $serviceId.'.chain_wrapper'; + $toolboxes = $externalToolboxes; + + if ([] !== $tools) { + if ($definition instanceof ChildDefinition) { + $definition->replaceArgument(0, $tools); + } else { + $definition->setArgument(0, $tools); + } + array_unshift($toolboxes, new Reference($chainServiceId.'.inner')); + } + + $chainDefinition = (new Definition(ChainToolbox::class)) + ->setDecoratedService($serviceId, null, 100) + ->setArguments([$toolboxes]); + + $container->setDefinition($chainServiceId, $chainDefinition); + } + + private function isToolboxInterface(ContainerBuilder $container, string $serviceId): bool + { + if (!$container->hasDefinition($serviceId)) { + return false; + } + + $definition = $container->getDefinition($serviceId); + $class = $definition->getClass(); + + if (null === $class) { + return false; + } + + if (!class_exists($class)) { + return false; + } + + return is_subclass_of($class, ToolboxInterface::class); + } +} diff --git a/src/ai-bundle/src/Profiler/TraceableToolbox.php b/src/ai-bundle/src/Profiler/TraceableToolbox.php index d1dd10481..02e230626 100644 --- a/src/ai-bundle/src/Profiler/TraceableToolbox.php +++ b/src/ai-bundle/src/Profiler/TraceableToolbox.php @@ -25,6 +25,8 @@ final class TraceableToolbox implements ToolboxInterface */ public array $calls = []; + private bool $toolsLoaded = false; + public function __construct( private readonly ToolboxInterface $toolbox, ) { @@ -32,6 +34,25 @@ public function __construct( public function getTools(): array { + $this->toolsLoaded = true; + + return $this->toolbox->getTools(); + } + + /** + * Get tools only if already loaded (lazy-safe for profiling). + * + * This prevents triggering remote MCP connections during profiler + * data collection if the toolbox wasn't actually used during the request. + * + * @return array<\Symfony\AI\Platform\Tool\Tool> + */ + public function getToolsIfLoaded(): array + { + if (!$this->toolsLoaded) { + return []; + } + return $this->toolbox->getTools(); } diff --git a/src/ai-bundle/templates/data_collector.html.twig b/src/ai-bundle/templates/data_collector.html.twig index f1ad742bb..e07e87c92 100644 --- a/src/ai-bundle/templates/data_collector.html.twig +++ b/src/ai-bundle/templates/data_collector.html.twig @@ -114,7 +114,7 @@ {{ _self.tool_calls(message.toolCalls) }} {% elseif 'tool' == message.role.value %} Result of tool call with ID {{ message.toolCall.id }}
- {{ message.content|nl2br }} + {{ message.asText|nl2br }} {% elseif 'user' == message.role.value %} {% for item in message.content %} {% if item.text is defined %} diff --git a/src/platform/src/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php b/src/platform/src/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php index a0a9b8786..781b00d2b 100644 --- a/src/platform/src/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php +++ b/src/platform/src/Bridge/Anthropic/Contract/ToolCallMessageNormalizer.php @@ -33,19 +33,24 @@ final class ToolCallMessageNormalizer extends ModelContractNormalizer implements * content: list>, * }> * } */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { + $content = []; + foreach ($data->getContent() as $item) { + $content[] = $this->normalizer->normalize($item, $format, $context); + } + return [ 'role' => 'user', 'content' => [ [ 'type' => 'tool_result', 'tool_use_id' => $data->getToolCall()->getId(), - 'content' => $data->getContent(), + 'content' => $content, ], ], ]; diff --git a/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php b/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php index 513a89244..f0f2bd1ff 100644 --- a/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php +++ b/src/platform/src/Bridge/Bedrock/Nova/Contract/ToolCallMessageNormalizer.php @@ -13,11 +13,16 @@ use Symfony\AI\Platform\Bridge\Bedrock\Nova\Nova; use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Message\ToolCallMessage; use Symfony\AI\Platform\Model; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use function Symfony\Component\String\u; + /** * @author Christopher Hertel */ @@ -33,20 +38,22 @@ final class ToolCallMessageNormalizer extends ModelContractNormalizer implements * content: array, + * content: array, * } * }> * } */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { + $resultContent = $this->buildContent($data); + return [ 'role' => 'user', 'content' => [ [ 'toolResult' => [ 'toolUseId' => $data->getToolCall()->getId(), - 'content' => [['json' => $data->getContent()]], + 'content' => $resultContent, ], ], ], @@ -62,4 +69,53 @@ protected function supportsModel(Model $model): bool { return $model instanceof Nova; } + + /** + * @return array + */ + private function buildContent(ToolCallMessage $data): array + { + $contents = $data->getContent(); + + // Check if we have only text content + $hasMultimodal = false; + foreach ($contents as $content) { + if (!$content instanceof Text) { + $hasMultimodal = true; + break; + } + } + + if (!$hasMultimodal) { + // Text-only: use JSON format + return [['json' => $data->asText() ?? '']]; + } + + // Multimodal content: build content array + $result = []; + foreach ($contents as $content) { + if ($content instanceof Text) { + $result[] = ['json' => $content->getText()]; + } elseif ($content instanceof Image) { + $result[] = [ + 'image' => [ + 'format' => u($content->getFormat())->replace('image/', '')->replace('jpg', 'jpeg')->toString(), + 'source' => ['bytes' => $content->asBase64()], + ], + ]; + } elseif ($content instanceof File) { + // File includes Audio, PDF, and other binary types + $format = u($content->getFormat())->after('/')->toString(); + $result[] = [ + 'document' => [ + 'format' => $format, + 'name' => 'document', + 'source' => ['bytes' => $content->asBase64()], + ], + ]; + } + } + + return $result; + } } diff --git a/src/platform/src/Bridge/Gemini/Contract/ToolCallMessageNormalizer.php b/src/platform/src/Bridge/Gemini/Contract/ToolCallMessageNormalizer.php index 31bcc39e6..d2fe1fd9d 100644 --- a/src/platform/src/Bridge/Gemini/Contract/ToolCallMessageNormalizer.php +++ b/src/platform/src/Bridge/Gemini/Contract/ToolCallMessageNormalizer.php @@ -13,6 +13,8 @@ use Symfony\AI\Platform\Bridge\Gemini\Gemini; use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Message\ToolCallMessage; use Symfony\AI\Platform\Model; @@ -34,16 +36,13 @@ final class ToolCallMessageNormalizer extends ModelContractNormalizer */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { - $resultContent = json_validate($data->getContent()) - ? json_decode($data->getContent(), true) : $data->getContent(); + $response = $this->buildResponse($data); return [[ 'functionResponse' => array_filter([ 'id' => $data->getToolCall()->getId(), 'name' => $data->getToolCall()->getName(), - 'response' => \is_array($resultContent) ? $resultContent : [ - 'rawResponse' => $resultContent, // Gemini expects the response to be an object, but not everyone uses objects as their responses. - ], + 'response' => $response, ]), ]]; } @@ -57,4 +56,49 @@ protected function supportsModel(Model $model): bool { return $model instanceof Gemini; } + + /** + * @return array + */ + private function buildResponse(ToolCallMessage $data): array + { + $contents = $data->getContent(); + + // Check if we have multimodal content + $hasMultimodal = false; + foreach ($contents as $content) { + if ($content instanceof File) { + $hasMultimodal = true; + break; + } + } + + if (!$hasMultimodal) { + // Text-only: use the original JSON parsing logic + $textContent = $data->asText() ?? ''; + $resultContent = json_validate($textContent) + ? json_decode($textContent, true) : $textContent; + + return \is_array($resultContent) ? $resultContent : [ + 'rawResponse' => $resultContent, + ]; + } + + // Multimodal content: build parts array + $parts = []; + foreach ($contents as $content) { + if ($content instanceof Text) { + $parts[] = ['text' => $content->getText()]; + } elseif ($content instanceof File) { + $parts[] = [ + 'inline_data' => [ + 'mime_type' => $content->getFormat(), + 'data' => $content->asBase64(), + ], + ]; + } + } + + return ['parts' => $parts]; + } } diff --git a/src/platform/src/Bridge/OpenAi/Contract/Gpt/Message/ToolCallMessageNormalizer.php b/src/platform/src/Bridge/OpenAi/Contract/Gpt/Message/ToolCallMessageNormalizer.php index 85a2e1fec..88d89fb01 100644 --- a/src/platform/src/Bridge/OpenAi/Contract/Gpt/Message/ToolCallMessageNormalizer.php +++ b/src/platform/src/Bridge/OpenAi/Contract/Gpt/Message/ToolCallMessageNormalizer.php @@ -13,6 +13,10 @@ use Symfony\AI\Platform\Bridge\OpenAi\Gpt; use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Message\ToolCallMessage; use Symfony\AI\Platform\Model; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; @@ -30,15 +34,51 @@ final class ToolCallMessageNormalizer extends ModelContractNormalizer * @return array{ * type: 'function_call_output', * call_id: string, - * output: string + * output: string|list> * } */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { + $contents = $data->getContent(); + + if ($this->isTextOnly($contents)) { + return [ + 'type' => 'function_call_output', + 'call_id' => $data->getToolCall()->getId(), + 'output' => $data->asText() ?? '', + ]; + } + + // Multimodal content: build output array with input_* types + $output = []; + foreach ($contents as $content) { + if ($content instanceof Text) { + $output[] = [ + 'type' => 'input_text', + 'text' => $content->getText(), + ]; + } elseif ($content instanceof Image) { + $output[] = [ + 'type' => 'input_image', + 'image_url' => $content->asDataUrl(), + ]; + } elseif ($content instanceof ImageUrl) { + $output[] = [ + 'type' => 'input_image', + 'image_url' => $content->getUrl(), + ]; + } elseif ($content instanceof File) { + $output[] = [ + 'type' => 'input_file', + 'file_data' => $content->asDataUrl(), + ]; + } + } + return [ 'type' => 'function_call_output', 'call_id' => $data->getToolCall()->getId(), - 'output' => $data->getContent(), + 'output' => $output, ]; } @@ -51,4 +91,18 @@ protected function supportsModel(Model $model): bool { return $model instanceof Gpt; } + + /** + * @param array $contents + */ + private function isTextOnly(array $contents): bool + { + foreach ($contents as $content) { + if (!$content instanceof Text) { + return false; + } + } + + return true; + } } diff --git a/src/platform/src/Bridge/VertexAi/Contract/ToolCallMessageNormalizer.php b/src/platform/src/Bridge/VertexAi/Contract/ToolCallMessageNormalizer.php index f21566b74..8ab9cc142 100644 --- a/src/platform/src/Bridge/VertexAi/Contract/ToolCallMessageNormalizer.php +++ b/src/platform/src/Bridge/VertexAi/Contract/ToolCallMessageNormalizer.php @@ -13,6 +13,8 @@ use Symfony\AI\Platform\Bridge\VertexAi\Gemini\Model; use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Message\ToolCallMessage; use Symfony\AI\Platform\Model as BaseModel; @@ -35,14 +37,12 @@ final class ToolCallMessageNormalizer extends ModelContractNormalizer */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { - $resultContent = json_validate($data->getContent()) ? json_decode($data->getContent(), true, 512, \JSON_THROW_ON_ERROR) : $data->getContent(); + $response = $this->buildResponse($data); return [[ 'functionResponse' => array_filter([ 'name' => $data->getToolCall()->getName(), - 'response' => \is_array($resultContent) ? $resultContent : [ - 'rawResponse' => $resultContent, - ], + 'response' => $response, ]), ]]; } @@ -56,4 +56,51 @@ protected function supportsModel(BaseModel $model): bool { return $model instanceof Model; } + + /** + * @return array + * + * @throws \JsonException + */ + private function buildResponse(ToolCallMessage $data): array + { + $contents = $data->getContent(); + + // Check if we have multimodal content + $hasMultimodal = false; + foreach ($contents as $content) { + if ($content instanceof File) { + $hasMultimodal = true; + break; + } + } + + if (!$hasMultimodal) { + // Text-only: use the original JSON parsing logic + $textContent = $data->asText() ?? ''; + $resultContent = json_validate($textContent) + ? json_decode($textContent, true, 512, \JSON_THROW_ON_ERROR) : $textContent; + + return \is_array($resultContent) ? $resultContent : [ + 'rawResponse' => $resultContent, + ]; + } + + // Multimodal content: build parts array + $parts = []; + foreach ($contents as $content) { + if ($content instanceof Text) { + $parts[] = ['text' => $content->getText()]; + } elseif ($content instanceof File) { + $parts[] = [ + 'inline_data' => [ + 'mime_type' => $content->getFormat(), + 'data' => $content->asBase64(), + ], + ]; + } + } + + return ['parts' => $parts]; + } } diff --git a/src/platform/src/Contract/Normalizer/Message/ToolCallMessageNormalizer.php b/src/platform/src/Contract/Normalizer/Message/ToolCallMessageNormalizer.php index e75fa4990..2346b4766 100644 --- a/src/platform/src/Contract/Normalizer/Message/ToolCallMessageNormalizer.php +++ b/src/platform/src/Contract/Normalizer/Message/ToolCallMessageNormalizer.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Contract\Normalizer\Message; +use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Message\ToolCallMessage; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; @@ -40,16 +41,47 @@ public function getSupportedTypes(?string $format): array * * @return array{ * role: 'tool', - * content: string, + * content: string|list>, * tool_call_id: string, * } */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { + $contents = $data->getContent(); + + // If only text content, use simple string format for backwards compatibility + if ($this->isTextOnly($contents)) { + return [ + 'role' => $data->getRole()->value, + 'content' => $data->asText() ?? '', + 'tool_call_id' => $data->getToolCall()->getId(), + ]; + } + + // Multimodal content: normalize each content item + $normalizedContent = []; + foreach ($contents as $content) { + $normalizedContent[] = $this->normalizer->normalize($content, $format, $context); + } + return [ 'role' => $data->getRole()->value, - 'content' => $this->normalizer->normalize($data->getContent(), $format, $context), + 'content' => $normalizedContent, 'tool_call_id' => $data->getToolCall()->getId(), ]; } + + /** + * @param array $contents + */ + private function isTextOnly(array $contents): bool + { + foreach ($contents as $content) { + if (!$content instanceof Text) { + return false; + } + } + + return true; + } } diff --git a/src/platform/src/Message/Message.php b/src/platform/src/Message/Message.php index fc3db2fd8..c62ce78fe 100644 --- a/src/platform/src/Message/Message.php +++ b/src/platform/src/Message/Message.php @@ -57,8 +57,8 @@ public static function ofUser(\Stringable|string|ContentInterface ...$content): return new UserMessage(...$content); } - public static function ofToolCall(ToolCall $toolCall, string $content): ToolCallMessage + public static function ofToolCall(ToolCall $toolCall, ContentInterface|string ...$content): ToolCallMessage { - return new ToolCallMessage($toolCall, $content); + return new ToolCallMessage($toolCall, ...$content); } } diff --git a/src/platform/src/Message/ToolCallMessage.php b/src/platform/src/Message/ToolCallMessage.php index 348c8cc76..c0fed66e9 100644 --- a/src/platform/src/Message/ToolCallMessage.php +++ b/src/platform/src/Message/ToolCallMessage.php @@ -11,6 +11,10 @@ namespace Symfony\AI\Platform\Message; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\Text; use Symfony\AI\Platform\Metadata\MetadataAwareTrait; use Symfony\AI\Platform\Result\ToolCall; use Symfony\Component\Uid\Uuid; @@ -23,10 +27,19 @@ final class ToolCallMessage implements MessageInterface use IdentifierAwareTrait; use MetadataAwareTrait; + /** + * @var ContentInterface[] + */ + private readonly array $content; + public function __construct( private readonly ToolCall $toolCall, - private readonly string $content, + ContentInterface|string ...$content, ) { + $this->content = array_map( + static fn (ContentInterface|string $c): ContentInterface => \is_string($c) ? new Text($c) : $c, + $content, + ); $this->id = Uuid::v7(); } @@ -40,8 +53,52 @@ public function getToolCall(): ToolCall return $this->toolCall; } - public function getContent(): string + /** + * @return ContentInterface[] + */ + public function getContent(): array { return $this->content; } + + public function hasImageContent(): bool + { + foreach ($this->content as $content) { + if ($content instanceof Image) { + return true; + } + } + + return false; + } + + public function hasAudioContent(): bool + { + foreach ($this->content as $content) { + if ($content instanceof Audio) { + return true; + } + } + + return false; + } + + /** + * Get all text content as a single string. + */ + public function asText(): ?string + { + $texts = []; + foreach ($this->content as $content) { + if ($content instanceof Text) { + $texts[] = $content->getText(); + } + } + + if ([] === $texts) { + return null; + } + + return implode("\n", $texts); + } } diff --git a/src/platform/tests/Bridge/OpenAi/Contract/Gpt/Message/MessageBagNormalizerTest.php b/src/platform/tests/Bridge/OpenAi/Contract/Gpt/Message/MessageBagNormalizerTest.php index 019220c8c..77409bd3f 100644 --- a/src/platform/tests/Bridge/OpenAi/Contract/Gpt/Message/MessageBagNormalizerTest.php +++ b/src/platform/tests/Bridge/OpenAi/Contract/Gpt/Message/MessageBagNormalizerTest.php @@ -77,7 +77,7 @@ public static function normalizeProvider(): \Generator [ 'type' => 'function_call_output', 'call_id' => $toolCallMessage->getToolCall()->getId(), - 'output' => $toolCallMessage->getContent(), + 'output' => $toolCallMessage->asText(), ], ]]; diff --git a/src/platform/tests/Bridge/OpenAi/Contract/Gpt/Message/ToolCallMessageNormalizerTest.php b/src/platform/tests/Bridge/OpenAi/Contract/Gpt/Message/ToolCallMessageNormalizerTest.php index 4bb69a131..802e72810 100644 --- a/src/platform/tests/Bridge/OpenAi/Contract/Gpt/Message/ToolCallMessageNormalizerTest.php +++ b/src/platform/tests/Bridge/OpenAi/Contract/Gpt/Message/ToolCallMessageNormalizerTest.php @@ -33,7 +33,7 @@ public function testNormalize() $this->assertEquals([ 'type' => 'function_call_output', 'call_id' => $toolCall->getId(), - 'output' => $toolCallMessage->getContent(), + 'output' => $toolCallMessage->asText(), ], $actual); } diff --git a/src/platform/tests/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php b/src/platform/tests/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php index 079ba18fe..a0ad12876 100644 --- a/src/platform/tests/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php +++ b/src/platform/tests/Contract/Normalizer/Message/ToolCallMessageNormalizerTest.php @@ -15,7 +15,6 @@ use Symfony\AI\Platform\Contract\Normalizer\Message\ToolCallMessageNormalizer; use Symfony\AI\Platform\Message\ToolCallMessage; use Symfony\AI\Platform\Result\ToolCall; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; final class ToolCallMessageNormalizerTest extends TestCase { @@ -43,19 +42,10 @@ public function testNormalize() { $toolCall = new ToolCall('tool_call_123', 'get_weather', ['location' => 'Paris']); $message = new ToolCallMessage($toolCall, 'Weather data for Paris'); - $expectedContent = 'Normalized weather data for Paris'; - - $innerNormalizer = $this->createMock(NormalizerInterface::class); - $innerNormalizer->expects($this->once()) - ->method('normalize') - ->with($message->getContent(), null, []) - ->willReturn($expectedContent); - - $this->normalizer->setNormalizer($innerNormalizer); $expected = [ 'role' => 'tool', - 'content' => $expectedContent, + 'content' => 'Weather data for Paris', 'tool_call_id' => 'tool_call_123', ]; diff --git a/src/platform/tests/Message/MessageTest.php b/src/platform/tests/Message/MessageTest.php index b31a6b238..28ed5e5c2 100644 --- a/src/platform/tests/Message/MessageTest.php +++ b/src/platform/tests/Message/MessageTest.php @@ -119,7 +119,9 @@ public function testCreateToolCallMessage() $toolCall = new ToolCall('call_123456', 'my_tool', ['foo' => 'bar']); $message = Message::ofToolCall($toolCall, 'Foo bar.'); - $this->assertSame('Foo bar.', $message->getContent()); + $this->assertSame('Foo bar.', $message->asText()); + $this->assertCount(1, $message->getContent()); + $this->assertInstanceOf(Text::class, $message->getContent()[0]); $this->assertSame($toolCall, $message->getToolCall()); } } diff --git a/src/platform/tests/Message/ToolCallMessageTest.php b/src/platform/tests/Message/ToolCallMessageTest.php index eccf06bc4..2d71681e9 100644 --- a/src/platform/tests/Message/ToolCallMessageTest.php +++ b/src/platform/tests/Message/ToolCallMessageTest.php @@ -29,7 +29,8 @@ public function testConstructionIsPossible() $obj = new ToolCallMessage($toolCall, 'bar'); $this->assertSame($toolCall, $obj->getToolCall()); - $this->assertSame('bar', $obj->getContent()); + $this->assertSame('bar', $obj->asText()); + $this->assertCount(1, $obj->getContent()); } public function testMessageHasUid()