Skip to content

Commit f533f60

Browse files
[Agent] Enable remote MCP tools for agents
1 parent 150db7e commit f533f60

File tree

56 files changed

+2881
-54
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+2881
-54
lines changed

.claude/settings.local.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"disabledMcpjsonServers": [
3+
"openapi-generator"
4+
]
5+
}

PR.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
## [Agent] Enable remote MCP tools for agents
2+
3+
This PR adds a **proof-of-concept** MCP client implementation allowing Symfony AI agents to connect to remote MCP servers and use their tools.
4+
5+
> ⚠️ **This is a POC** - The implementation is functional but not optimized for production use. It serves as a testing ground while waiting for the official [PHP SDK](https://github.com/modelcontextprotocol/php-sdk) to implement its client component (currently on their roadmap). Once available, this implementation should be migrated to use it.
6+
7+
8+
### How it works
9+
10+
#### 1. Transports
11+
12+
Three transport implementations for different MCP server types:
13+
14+
| Transport | Use case |
15+
|-----------|----------|
16+
| `SseTransport` | Gradio/HuggingFace Spaces (Server-Sent Events) |
17+
| `HttpTransport` | Streamable HTTP |
18+
| `StdioTransport` | Local process via stdin/stdout |
19+
20+
#### 2. McpToolbox
21+
22+
Adapts MCP tools to Symfony AI's `ToolboxInterface`:
23+
- Converts MCP tool definitions to `Tool` objects
24+
- Executes tools via `McpClient::callTool()`
25+
- Converts MCP content types to Platform types:
26+
27+
| MCP Type | Platform Type |
28+
|----------|---------------|
29+
| `TextContent` | `Text` |
30+
| `ImageContent` | `Image` (base64 decoded) |
31+
| `AudioContent` | `Audio` (base64 decoded) |
32+
| `EmbeddedResource` | `Text` / `Image` / `Audio` / `File` |
33+
34+
#### 3. ChainToolbox
35+
36+
Combines multiple `ToolboxInterface` into one, enabling agents to use tools from different sources (local + multiple MCPs).
37+
38+
#### 4. ToolCallMessage changes
39+
40+
`ToolCallMessage` now supports multiple content types (not just string):
41+
```php
42+
// Before
43+
new ToolCallMessage($toolCall, 'text result');
44+
45+
// After
46+
new ToolCallMessage($toolCall, new Text('...'), new Image($data, 'image/png'));
47+
```
48+
49+
This enables MCP tools to return images/audio that get displayed in chat.
50+
51+
#### 5. Bundle Integration
52+
53+
```yaml
54+
ai:
55+
mcp:
56+
my_mcp:
57+
transport: sse # or http, stdio
58+
url: 'https://example.com/mcp/sse'
59+
tools:
60+
- 'tool_name' # Optional: filter exposed tools
61+
```
62+
63+
Creates services:
64+
- `ai.mcp.client.{name}` - The MCP client
65+
- `ai.mcp.toolbox.{name}` - The toolbox (use this in agent config)
66+
67+
Use in agent:
68+
```yaml
69+
ai:
70+
agent:
71+
my_agent:
72+
tools:
73+
- 'ai.mcp.toolbox.my_mcp'
74+
```
75+
76+
---
77+
78+
### Breaking Changes
79+
80+
This PR introduces breaking changes to enable multimodal tool results:
81+
82+
#### 1. `ToolCallMessage::getContent()` return type changed
83+
84+
```php
85+
// Before
86+
public function getContent(): string
87+
88+
// After
89+
public function getContent(): array // ContentInterface[]
90+
```
91+
92+
**Migration**: Use `$message->asText()` to get the text content as a string.
93+
94+
#### 2. `ToolResultConverter::convert()` return type changed
95+
96+
```php
97+
// Before
98+
public function convert(ToolResult $toolResult): ?string
99+
100+
// After
101+
public function convert(ToolResult $toolResult): array // ContentInterface[]
102+
```
103+
104+
#### 3. `ToolCallMessage` constructor signature changed
105+
106+
```php
107+
// Before
108+
new ToolCallMessage($toolCall, 'content string')
109+
110+
// After (variadic)
111+
new ToolCallMessage($toolCall, 'string', $image, $audio, ...)
112+
new ToolCallMessage($toolCall, new Text('...'), new Image(...))
113+
```
114+
115+
Passing a single string still works but internally converts to `Text` content.
116+
117+
---
118+
119+
### Demo
120+
121+
`Timeline` bot combining two MCP servers:
122+
1. **Live City MCP** → fetches news for a city
123+
2. **Graphify** → generates a timeline diagram from the news
124+
125+
User asks about a city → Agent fetches news → Agent generates timeline → Image displayed in chat.
126+
127+
```yaml
128+
ai:
129+
mcp:
130+
graphify:
131+
transport: sse
132+
url: 'https://agents-mcp-hackathon-graphify.hf.space/gradio_api/mcp/sse'
133+
tools:
134+
- 'Graphify_generate_timeline_diagram'
135+
city:
136+
transport: sse
137+
url: 'https://kingabzpro-live-city-mcp.hf.space/gradio_api/mcp/sse'
138+
tools:
139+
- 'live_city_mcp_get_city_news'
140+
agent:
141+
timeline:
142+
platform: 'ai.platform.openai'
143+
model: 'gpt-4o-mini'
144+
prompt: |
145+
You are a news timeline generator. When the user asks about a city:
146+
1) First use live_city_mcp_get_city_news to fetch news for that city
147+
2) Then use Graphify_generate_timeline_diagram with this JSON format:
148+
{"title": "News from [City]", "events_per_row": 3, "events": [{"id": "1", "label": "Short title", "date": "2024-12-13"}]}
149+
tools:
150+
- 'ai.mcp.toolbox.graphify'
151+
- 'ai.mcp.toolbox.city'
152+
```
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
import { getComponent } from '@symfony/ux-live-component';
3+
4+
export default class extends Controller {
5+
async initialize() {
6+
this.component = await getComponent(this.element);
7+
this.scrollToBottom();
8+
9+
const input = document.getElementById('chat-message');
10+
input.addEventListener('keypress', (event) => {
11+
if (event.key === 'Enter') {
12+
this.submitMessage();
13+
}
14+
});
15+
input.focus();
16+
17+
const resetButton = document.getElementById('chat-reset');
18+
resetButton.addEventListener('click', (event) => {
19+
this.component.action('reset');
20+
});
21+
22+
const submitButton = document.getElementById('chat-submit');
23+
submitButton.addEventListener('click', (event) => {
24+
this.submitMessage();
25+
});
26+
27+
this.component.on('loading.state:started', (e,r) => {
28+
if (r.actions.includes('reset')) {
29+
return;
30+
}
31+
document.getElementById('welcome')?.remove();
32+
document.getElementById('loading-message').removeAttribute('class');
33+
this.scrollToBottom();
34+
});
35+
36+
this.component.on('loading.state:finished', () => {
37+
document.getElementById('loading-message').setAttribute('class', 'd-none');
38+
});
39+
40+
this.component.on('render:finished', () => {
41+
this.scrollToBottom();
42+
});
43+
};
44+
45+
submitMessage() {
46+
const input = document.getElementById('chat-message');
47+
const message = input.value;
48+
document
49+
.getElementById('loading-message')
50+
.getElementsByClassName('user-message')[0].innerHTML = message;
51+
this.component.action('submit', { message });
52+
input.value = '';
53+
}
54+
55+
scrollToBottom() {
56+
const chatBody = document.getElementById('chat-body');
57+
chatBody.scrollTop = chatBody.scrollHeight;
58+
}
59+
}

demo/assets/styles/app.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,7 @@ body {
6363
}
6464
}
6565
}
66+
67+
.timeline .bot-message img {
68+
max-width: 500px;
69+
}

demo/config/packages/ai.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ ai:
44
api_key: '%env(OPENAI_API_KEY)%'
55
huggingface:
66
api_key: '%env(HUGGINGFACE_API_KEY)%'
7+
mcp:
8+
graphify:
9+
transport: sse
10+
url: 'https://agents-mcp-hackathon-graphify.hf.space/gradio_api/mcp/sse'
11+
tools:
12+
- 'Graphify_generate_timeline_diagram'
13+
city:
14+
transport: sse
15+
url: 'https://kingabzpro-live-city-mcp.hf.space/gradio_api/mcp/sse'
16+
tools:
17+
- 'live_city_mcp_get_city_news'
718
agent:
819
blog:
920
platform: 'ai.platform.openai'
@@ -75,6 +86,17 @@ ai:
7586
model: 'gpt-4o-mini'
7687
prompt: 'You are a helpful general assistant. Assist users with any questions or tasks they may have.'
7788
tools: false
89+
timeline:
90+
platform: 'ai.platform.openai'
91+
model: 'gpt-4o-mini'
92+
prompt: |
93+
You are a news timeline generator. When the user asks about a city:
94+
1) First use live_city_mcp_get_city_news to fetch news for that city
95+
2) Then use Graphify_generate_timeline_diagram with this JSON format:
96+
{"title": "News from [City]", "events_per_row": 3, "events": [{"id": "1", "label": "Short title", "date": "2024-12-13"}]}
97+
tools:
98+
- 'ai.mcp.toolbox.graphify'
99+
- 'ai.mcp.toolbox.city'
78100
multi_agent:
79101
support:
80102
orchestrator: 'orchestrator'

demo/config/routes.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ youtube:
6363
template: 'chat.html.twig'
6464
context: { chat: 'youtube' }
6565

66+
timeline:
67+
path: '/timeline'
68+
controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController'
69+
defaults:
70+
template: 'chat.html.twig'
71+
context: { chat: 'timeline' }
72+
6673
# Load MCP routes conditionally based on configuration
6774
_mcp:
6875
resource: .

demo/public/.user.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
max_execution_time = 120

demo/src/Timeline/Chat.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace App\Timeline;
13+
14+
use Symfony\AI\Agent\AgentInterface;
15+
use Symfony\AI\Platform\Message\Message;
16+
use Symfony\AI\Platform\Message\MessageBag;
17+
use Symfony\AI\Platform\Result\TextResult;
18+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
19+
use Symfony\Component\HttpFoundation\RequestStack;
20+
21+
/**
22+
* @author Camille Islasse <[email protected]>
23+
*/
24+
final class Chat
25+
{
26+
private const SESSION_KEY = 'timeline-chat';
27+
28+
public function __construct(
29+
private readonly RequestStack $requestStack,
30+
#[Autowire(service: 'ai.agent.timeline')]
31+
private readonly AgentInterface $agent,
32+
) {
33+
}
34+
35+
public function loadMessages(): MessageBag
36+
{
37+
return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag());
38+
}
39+
40+
public function submitMessage(string $message): void
41+
{
42+
$messages = $this->loadMessages();
43+
44+
$messages->add(Message::ofUser($message));
45+
$result = $this->agent->call($messages);
46+
47+
\assert($result instanceof TextResult);
48+
49+
$messages->add(Message::ofAssistant($result->getContent()));
50+
51+
$this->saveMessages($messages);
52+
}
53+
54+
public function reset(): void
55+
{
56+
$this->requestStack->getSession()->remove(self::SESSION_KEY);
57+
}
58+
59+
private function saveMessages(MessageBag $messages): void
60+
{
61+
$this->requestStack->getSession()->set(self::SESSION_KEY, $messages);
62+
}
63+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace App\Timeline;
13+
14+
use Symfony\AI\Platform\Message\MessageInterface;
15+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
16+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
17+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
18+
use Symfony\UX\LiveComponent\DefaultActionTrait;
19+
20+
/**
21+
* @author Camille Islasse <[email protected]>
22+
*/
23+
#[AsLiveComponent('timeline')]
24+
final class TwigComponent
25+
{
26+
use DefaultActionTrait;
27+
28+
public function __construct(
29+
private readonly Chat $timeline,
30+
) {
31+
}
32+
33+
/**
34+
* @return MessageInterface[]
35+
*/
36+
public function getMessages(): array
37+
{
38+
return $this->timeline->loadMessages()->withoutSystemMessage()->getMessages();
39+
}
40+
41+
#[LiveAction]
42+
public function submit(#[LiveArg] string $message): void
43+
{
44+
$this->timeline->submitMessage($message);
45+
}
46+
47+
#[LiveAction]
48+
public function reset(): void
49+
{
50+
$this->timeline->reset();
51+
}
52+
}

0 commit comments

Comments
 (0)