Skip to content

Commit 2039652

Browse files
authored
feat: MCP tools and prompt factory (#7)
* feat: MCP Client and prompt factory * updates and tests * add mcp tool * toolkit
1 parent 89856c3 commit 2039652

File tree

18 files changed

+633
-81
lines changed

18 files changed

+633
-81
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: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,42 @@
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+
],
215+
216+
// 'tavily' => [
217+
// 'transport' => 'http',
218+
// 'url' => 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'),
219+
// ],
220+
221+
// 'tavily' => [
222+
// 'transport' => 'stdio',
223+
// 'command' => 'npx',
224+
// 'args' => [
225+
// '-y',
226+
// 'mcp-remote',
227+
// 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'),
228+
// ],
229+
// ],
230+
],
231+
196232
/*
197233
|--------------------------------------------------------------------------
198234
| Prompt Factories
@@ -218,9 +254,10 @@
218254
],
219255
],
220256

221-
// 'mcp' => [
222-
// 'base_uri' => env('MCP_BASE_URI', 'http://localhost:3000'),
223-
// ],
257+
'mcp' => [
258+
/** References an MCP server defined above. */
259+
'server' => env('CORTEX_MCP_PROMPT_SERVER', 'local_http'),
260+
],
224261
],
225262

226263
/*

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/LLM/Contracts/Tool.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public function format(): array;
3737
*
3838
* @param ToolCall|array<int, mixed> $arguments
3939
*/
40-
public function invoke(ToolCall|array $arguments): mixed;
40+
public function invoke(ToolCall|array $arguments = []): mixed;
4141

4242
/**
4343
* Invoke the tool as a tool message.

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: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,143 @@
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\JsonRpc\Results\GetPromptResult;
25+
use PhpMcp\Client\Model\Definitions\PromptDefinition;
26+
use PhpMcp\Client\Model\Definitions\PromptArgumentDefinition;
1027

1128
/**
1229
* @link https://modelcontextprotocol.io/docs/concepts/prompts
1330
*/
1431
class McpPromptFactory implements PromptFactory
1532
{
1633
public function __construct(
17-
protected string $baseUri,
34+
protected Client $client,
1835
) {}
1936

2037
public function make(string $name, array $config = []): PromptTemplate
2138
{
22-
// TODO: Implement
23-
return new ChatPromptTemplate([]);
39+
try {
40+
$this->client->initialize();
41+
$promptDefinition = $this->getPromptDefinition($name);
42+
$compiledPrompt = $this->getCompiledPrompt($promptDefinition);
43+
} finally {
44+
$this->client->disconnect();
45+
}
46+
47+
$builder = new ChatPromptBuilder();
48+
49+
if ($promptDefinition->isTemplate()) {
50+
$builder->inputSchema($this->buildInputSchema($promptDefinition));
51+
}
52+
53+
return $builder->messages($this->buildMessages($compiledPrompt))->build();
54+
}
55+
56+
/**
57+
* Builds an input schema for the given prompt definition.
58+
*/
59+
protected function buildInputSchema(PromptDefinition $prompt): ObjectSchema
60+
{
61+
$inputSchema = new ObjectSchema($prompt->name);
62+
63+
if ($prompt->description !== null) {
64+
$inputSchema->description($prompt->description);
65+
}
66+
67+
$properties = [];
68+
69+
foreach ($prompt->arguments as $argument) {
70+
$property = SchemaFactory::string($argument->name)
71+
->description($argument->description);
72+
73+
if ($argument->required) {
74+
$property = $property->required();
75+
}
76+
77+
$properties[] = $property;
78+
}
79+
80+
return $inputSchema->properties(...$properties);
81+
}
82+
83+
/**
84+
* Get the prompt definition for the given name.
85+
*
86+
* @throws \Cortex\Exceptions\PromptException
87+
*/
88+
protected function getPromptDefinition(string $name): PromptDefinition
89+
{
90+
try {
91+
/** @var array<array-key, \PhpMcp\Client\Model\Definitions\PromptDefinition> $prompts */
92+
$prompts = $this->client->listPrompts();
93+
} catch (Throwable $e) {
94+
throw new PromptException('Failed to list prompts: ' . $e->getMessage(), previous: $e);
95+
}
96+
97+
$prompt = collect($prompts)->firstWhere('name', $name);
98+
99+
if ($prompt === null) {
100+
throw new PromptException('Prompt not found: ' . $name);
101+
}
102+
103+
return $prompt;
104+
}
105+
106+
/**
107+
* Get the compiled prompt for the given prompt definition.
108+
*/
109+
protected function getCompiledPrompt(PromptDefinition $prompt): GetPromptResult
110+
{
111+
if ($prompt->isTemplate()) {
112+
$arguments = collect($prompt->arguments)->mapWithKeys(fn(PromptArgumentDefinition $argument) => [
113+
$argument->name => Str::of($argument->name)->prepend('{')->append('}')->toString(),
114+
])->toArray();
115+
116+
return $this->client->getPrompt($prompt->name, $arguments);
117+
}
118+
119+
return $this->client->getPrompt($prompt->name);
120+
}
121+
122+
/**
123+
* Build the messages from a given compiled prompt.
124+
*
125+
* @return array<array-key, \Cortex\LLM\Contracts\Message>
126+
*/
127+
protected function buildMessages(GetPromptResult $compiledPrompt): array
128+
{
129+
return array_map(function (PromptMessage $message): Message {
130+
$contentArray = $message->content->toArray();
131+
$content = match ($message->content->getType()) {
132+
'text' => new TextContent(Arr::get($contentArray, 'text')),
133+
'image' => new ImageContent(Arr::get($contentArray, 'data'), Arr::get($contentArray, 'mimeType')),
134+
'audio' => new AudioContent(Arr::get($contentArray, 'url'), Arr::get($contentArray, 'mimeType')),
135+
// "resource" type not supported for now
136+
default => throw new PromptException('Unsupported content type: ' . $message->content->getType()),
137+
};
138+
139+
return match ($message->role) {
140+
'user' => new UserMessage([$content]),
141+
'assistant' => new AssistantMessage([$content]),
142+
default => throw new PromptException('Unsupported role: ' . $message->role),
143+
};
144+
}, $compiledPrompt->messages);
24145
}
25146
}

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/Support/helpers.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
* Helper function to create a chat prompt builder.
2020
*
2121
* @param MessageCollection|array<int, \Cortex\LLM\Contracts\Message>|string|null $messages
22+
*
23+
* @return ($messages is null ? \Cortex\Prompts\Prompt : ($messages is string ? \Cortex\Prompts\Builders\TextPromptBuilder : \Cortex\Prompts\Builders\ChatPromptBuilder))
2224
*/
2325
function prompt(MessageCollection|array|string|null $messages): Prompt|PromptBuilder
2426
{

src/Tools/ClosureTool.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public function schema(): ObjectSchema
4545
/**
4646
* @param ToolCall|array<string, mixed> $toolCall
4747
*/
48-
public function invoke(ToolCall|array $toolCall): mixed
48+
public function invoke(ToolCall|array $toolCall = []): mixed
4949
{
5050
// Get the arguments from the given tool call.
5151
$arguments = $this->getArguments($toolCall);

0 commit comments

Comments
 (0)