Skip to content

Commit 54fcd60

Browse files
committed
feat: MCP Client and prompt factory
1 parent 89856c3 commit 54fcd60

File tree

9 files changed

+237
-13
lines changed

9 files changed

+237
-13
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"illuminate/collections": "^11.23",
2424
"mozex/anthropic-php": "^1.1",
2525
"openai-php/client": "^0.15",
26+
"php-mcp/client": "^1.0",
2627
"psr-discovery/cache-implementations": "^1.2",
2728
"psr-discovery/event-dispatcher-implementations": "^1.1",
2829
"react/async": "^4.3",

config/cortex.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,28 @@
193193
],
194194
],
195195

196+
/*
197+
|--------------------------------------------------------------------------
198+
| MCP Servers
199+
|--------------------------------------------------------------------------
200+
|
201+
| Specify any MCP servers that you wish to use.
202+
|
203+
*/
204+
'mcp_servers' => [
205+
'default' => env('CORTEX_DEFAULT_MCP_SERVER', 'local_http'),
206+
207+
/*
208+
* Example from https://github.com/modelcontextprotocol/servers/tree/main/src/everything
209+
* Feel free to remove this and add something actually useful!
210+
*/
211+
'local_http' => [
212+
'transport' => 'http',
213+
'url' => 'http://localhost:3001/sse',
214+
'headers' => null,
215+
],
216+
],
217+
196218
/*
197219
|--------------------------------------------------------------------------
198220
| Prompt Factories
@@ -218,9 +240,10 @@
218240
],
219241
],
220242

221-
// 'mcp' => [
222-
// 'base_uri' => env('MCP_BASE_URI', 'http://localhost:3000'),
223-
// ],
243+
'mcp' => [
244+
/** References an MCP server defined above. */
245+
'server' => env('CORTEX_MCP_PROMPT_SERVER', 'local_http'),
246+
],
224247
],
225248

226249
/*

src/CortexServiceProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Cortex\LLM\LLMManager;
88
use Cortex\LLM\Contracts\LLM;
9+
use Cortex\Mcp\McpServerManager;
910
use Cortex\ModelInfo\ModelInfoFactory;
1011
use Spatie\LaravelPackageTools\Package;
1112
use Cortex\Embeddings\EmbeddingsManager;
@@ -32,6 +33,9 @@ public function packageRegistered(): void
3233
$this->app->alias('cortex.embeddings', EmbeddingsManager::class);
3334
$this->app->bind(Embeddings::class, fn(Container $app) => $app->make('cortex.embeddings')->driver());
3435

36+
$this->app->singleton('cortex.mcp_server', fn(Container $app): McpServerManager => new McpServerManager($app));
37+
$this->app->alias('cortex.mcp_server', McpServerManager::class);
38+
3539
$this->app->singleton('cortex.prompt_factory', fn(Container $app): PromptFactoryManager => new PromptFactoryManager($app));
3640
$this->app->alias('cortex.prompt_factory', PromptFactoryManager::class);
3741
$this->app->bind(PromptFactory::class, fn(Container $app) => $app->make('cortex.prompt_factory')->driver());

src/Facades/McpServer.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cortex\Facades;
6+
7+
use Illuminate\Support\Facades\Facade;
8+
9+
/**
10+
* @method static \PhpMcp\Client\Client driver(string $driver = null)
11+
*
12+
* @see \Cortex\Mcp\McpServerManager
13+
*/
14+
class McpServer extends Facade
15+
{
16+
protected static function getFacadeAccessor(): string
17+
{
18+
return 'cortex.mcp_server';
19+
}
20+
}

src/Mcp/McpServerManager.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cortex\Mcp;
6+
7+
use Override;
8+
use PhpMcp\Client\Client;
9+
use Illuminate\Support\Arr;
10+
use InvalidArgumentException;
11+
use Illuminate\Support\Manager;
12+
use PhpMcp\Client\ServerConfig;
13+
14+
class McpServerManager extends Manager
15+
{
16+
public function getDefaultDriver(): string
17+
{
18+
return $this->config->get('cortex.mcp_servers.default');
19+
}
20+
21+
#[Override]
22+
protected function createDriver($driver): Client // @pest-ignore-type
23+
{
24+
$config = $this->config->get('cortex.mcp_servers.' . $driver);
25+
26+
if ($config === null) {
27+
throw new InvalidArgumentException(sprintf('Driver [%s] not supported.', $driver));
28+
}
29+
30+
$serverConfig = ServerConfig::fromArray($driver, [
31+
'transport' => Arr::get($config, 'transport'),
32+
'url' => Arr::get($config, 'url'),
33+
'command' => Arr::get($config, 'command'),
34+
'args' => Arr::get($config, 'args'),
35+
'headers' => Arr::get($config, 'headers'),
36+
'sessionId' => Arr::get($config, 'sessionId'),
37+
'timeout' => Arr::get($config, 'timeout'),
38+
'env' => Arr::get($config, 'env'),
39+
]);
40+
41+
$clientBuilder = Client::make()
42+
->withClientInfo('cortex-php', '1.0.0')
43+
->withServerConfig($serverConfig);
44+
45+
// if ($config['cache'] ?? false) {
46+
// $clientBuilder->withCache(
47+
// $this->container->make('cache')->store($config['cache']['store'] ?? null),
48+
// $config['cache']['ttl'] ?? 3600,
49+
// );
50+
// }
51+
52+
return $clientBuilder->build();
53+
}
54+
}

src/Prompts/Factories/McpPromptFactory.php

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,116 @@
44

55
namespace Cortex\Prompts\Factories;
66

7+
use Throwable;
8+
use PhpMcp\Client\Client;
9+
use Illuminate\Support\Arr;
10+
use Illuminate\Support\Str;
11+
use Cortex\LLM\Contracts\Message;
12+
use Cortex\JsonSchema\SchemaFactory;
13+
use Cortex\Exceptions\PromptException;
14+
use Cortex\JsonSchema\Types\ObjectSchema;
15+
use Cortex\LLM\Data\Messages\UserMessage;
716
use Cortex\Prompts\Contracts\PromptFactory;
817
use Cortex\Prompts\Contracts\PromptTemplate;
9-
use Cortex\Prompts\Templates\ChatPromptTemplate;
18+
use Cortex\LLM\Data\Messages\AssistantMessage;
19+
use Cortex\Prompts\Builders\ChatPromptBuilder;
20+
use PhpMcp\Client\Model\Content\PromptMessage;
21+
use Cortex\LLM\Data\Messages\Content\TextContent;
22+
use Cortex\LLM\Data\Messages\Content\AudioContent;
23+
use Cortex\LLM\Data\Messages\Content\ImageContent;
24+
use PhpMcp\Client\Model\Definitions\PromptDefinition;
25+
use PhpMcp\Client\Model\Definitions\PromptArgumentDefinition;
1026

1127
/**
1228
* @link https://modelcontextprotocol.io/docs/concepts/prompts
1329
*/
1430
class McpPromptFactory implements PromptFactory
1531
{
1632
public function __construct(
17-
protected string $baseUri,
33+
protected Client $client,
1834
) {}
1935

2036
public function make(string $name, array $config = []): PromptTemplate
2137
{
22-
// TODO: Implement
23-
return new ChatPromptTemplate([]);
38+
$this->client->initialize();
39+
40+
// TODO: I might need to proceed with the idea of using objects as prompt content.
41+
// This is due to the MCP prompt being compiled at runtime via getPrompt.
42+
43+
try {
44+
/** @var array<array-key, \PhpMcp\Client\Model\Definitions\PromptDefinition> $prompts */
45+
$prompts = $this->client->listPrompts();
46+
} catch (Throwable $e) {
47+
throw new PromptException('Failed to list prompts: ' . $e->getMessage(), previous: $e);
48+
}
49+
50+
$prompt = collect($prompts)->firstWhere('name', $name);
51+
52+
if ($prompt === null) {
53+
throw new PromptException('Prompt not found: ' . $name);
54+
}
55+
56+
$builder = new ChatPromptBuilder();
57+
58+
if ($prompt->isTemplate()) {
59+
$builder->inputSchema($this->buildInputSchema($prompt));
60+
61+
$arguments = collect($prompt->arguments)->mapWithKeys(fn(PromptArgumentDefinition $argument) => [
62+
$argument->name => Str::of($argument->name)->prepend('{')->append('}')->toString(),
63+
])->toArray();
64+
65+
$compiledPrompt = $this->client->getPrompt($name, $arguments);
66+
} else {
67+
$compiledPrompt = $this->client->getPrompt($name);
68+
}
69+
70+
// TODO: make sure this is in finally block.
71+
$this->client->disconnect();
72+
73+
$messages = array_map(function (PromptMessage $message): Message {
74+
$contentArray = $message->content->toArray();
75+
$content = match ($message->content->getType()) {
76+
'text' => new TextContent(Arr::get($contentArray, 'text')),
77+
'image' => new ImageContent(Arr::get($contentArray, 'data'), Arr::get($contentArray, 'mimeType')),
78+
'audio' => new AudioContent(Arr::get($contentArray, 'url'), Arr::get($contentArray, 'mimeType')),
79+
// "resource" type not supported for now
80+
default => throw new PromptException('Unsupported content type: ' . $message->content->getType()),
81+
};
82+
83+
return match ($message->role) {
84+
'user' => new UserMessage([$content]),
85+
'assistant' => new AssistantMessage([$content]),
86+
default => throw new PromptException('Unsupported role: ' . $message->role),
87+
};
88+
}, $compiledPrompt->messages);
89+
90+
return $builder->messages($messages)->build();
91+
}
92+
93+
/**
94+
* Builds an input schema for the given prompt definition.
95+
*/
96+
protected function buildInputSchema(PromptDefinition $prompt): ObjectSchema
97+
{
98+
$inputSchema = new ObjectSchema($prompt->name);
99+
100+
if ($prompt->description !== null) {
101+
$inputSchema->description($prompt->description);
102+
}
103+
104+
$properties = [];
105+
106+
foreach ($prompt->arguments as $argument) {
107+
$property = SchemaFactory::string($argument->name)
108+
->description($argument->description);
109+
110+
if ($argument->required) {
111+
$property = $property->required();
112+
}
113+
114+
$properties[] = $property;
115+
}
116+
117+
return $inputSchema->properties(...$properties);
24118
}
25119
}

src/Prompts/PromptFactoryManager.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ public function createLangfuseDriver(): LangfusePromptFactory
3131

3232
public function createMcpDriver(): McpPromptFactory
3333
{
34-
/** @var array{base_uri?: string} $config */
34+
/** @var array{server?: string} $config */
3535
$config = $this->config->get('cortex.prompt_factory.mcp');
3636

3737
return new McpPromptFactory(
38-
$config['base_uri'] ?? 'http://localhost:3000',
38+
$this->container->make('cortex.mcp_server')->driver($config['server'] ?? null),
3939
);
4040
}
4141
}

src/Prompts/Templates/ChatPromptTemplate.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
use Cortex\Support\Utils;
99
use Illuminate\Support\Collection;
1010
use Cortex\JsonSchema\SchemaFactory;
11-
use Cortex\Exceptions\PromptException;
1211
use Cortex\JsonSchema\Enums\SchemaType;
1312
use Cortex\Prompts\Data\PromptMetadata;
1413
use Cortex\JsonSchema\Types\UnionSchema;
@@ -32,9 +31,9 @@ public function __construct(
3231
) {
3332
$this->messages = Utils::toMessageCollection($messages);
3433

35-
if ($this->messages->isEmpty()) {
36-
throw new PromptException('Messages cannot be empty.');
37-
}
34+
// if ($this->messages->isEmpty()) {
35+
// throw new PromptException('Messages cannot be empty.');
36+
// }
3837
}
3938

4039
public function format(?array $variables = null): MessageCollection
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cortex\Tests\Unit\Prompts\Factories;
6+
7+
use Cortex\Facades\McpServer;
8+
use Cortex\Prompts\Factories\McpPromptFactory;
9+
use Cortex\Prompts\Templates\ChatPromptTemplate;
10+
11+
test('it can create a chat prompt template from a SSE MCP server', function (): void {
12+
$factory = new McpPromptFactory(
13+
McpServer::driver('local_http'),
14+
);
15+
16+
// $prompt = $factory->make('simple_prompt');
17+
$prompt = $factory->make('complex_prompt');
18+
19+
dd($prompt);
20+
21+
expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class);
22+
// expect($prompt->messages)->toHaveCount(3);
23+
// expect($prompt->messages[0])->toBeInstanceOf(SystemMessage::class);
24+
// expect($prompt->messages[0]->content)->toBe('You are a helpful assistant.');
25+
// expect($prompt->messages[1])->toBeInstanceOf(UserMessage::class);
26+
// expect($prompt->messages[1]->content)->toBe('What is the capital of {{country}}?');
27+
// expect($prompt->messages[2])->toBeInstanceOf(MessagePlaceholder::class);
28+
// expect($prompt->messages[2]->name)->toBe('history');
29+
})->todo('Look at how to mock MCP server');

0 commit comments

Comments
 (0)