From 54fcd603795ea86950728bb376abcb58aa4a4a95 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Fri, 15 Aug 2025 09:18:42 +0100 Subject: [PATCH 1/4] feat: MCP Client and prompt factory --- composer.json | 1 + config/cortex.php | 29 ++++- src/CortexServiceProvider.php | 4 + src/Facades/McpServer.php | 20 ++++ src/Mcp/McpServerManager.php | 54 ++++++++++ src/Prompts/Factories/McpPromptFactory.php | 102 +++++++++++++++++- src/Prompts/PromptFactoryManager.php | 4 +- src/Prompts/Templates/ChatPromptTemplate.php | 7 +- .../Factories/McpPromptFactoryTest.php | 29 +++++ 9 files changed, 237 insertions(+), 13 deletions(-) create mode 100644 src/Facades/McpServer.php create mode 100644 src/Mcp/McpServerManager.php create mode 100644 tests/Unit/Prompts/Factories/McpPromptFactoryTest.php diff --git a/composer.json b/composer.json index e7974d5..4c0b7e3 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "illuminate/collections": "^11.23", "mozex/anthropic-php": "^1.1", "openai-php/client": "^0.15", + "php-mcp/client": "^1.0", "psr-discovery/cache-implementations": "^1.2", "psr-discovery/event-dispatcher-implementations": "^1.1", "react/async": "^4.3", diff --git a/config/cortex.php b/config/cortex.php index ec662ef..3c530a7 100644 --- a/config/cortex.php +++ b/config/cortex.php @@ -193,6 +193,28 @@ ], ], + /* + |-------------------------------------------------------------------------- + | MCP Servers + |-------------------------------------------------------------------------- + | + | Specify any MCP servers that you wish to use. + | + */ + 'mcp_servers' => [ + 'default' => env('CORTEX_DEFAULT_MCP_SERVER', 'local_http'), + + /* + * Example from https://github.com/modelcontextprotocol/servers/tree/main/src/everything + * Feel free to remove this and add something actually useful! + */ + 'local_http' => [ + 'transport' => 'http', + 'url' => 'http://localhost:3001/sse', + 'headers' => null, + ], + ], + /* |-------------------------------------------------------------------------- | Prompt Factories @@ -218,9 +240,10 @@ ], ], - // 'mcp' => [ - // 'base_uri' => env('MCP_BASE_URI', 'http://localhost:3000'), - // ], + 'mcp' => [ + /** References an MCP server defined above. */ + 'server' => env('CORTEX_MCP_PROMPT_SERVER', 'local_http'), + ], ], /* diff --git a/src/CortexServiceProvider.php b/src/CortexServiceProvider.php index b0aa236..40cd4bd 100644 --- a/src/CortexServiceProvider.php +++ b/src/CortexServiceProvider.php @@ -6,6 +6,7 @@ use Cortex\LLM\LLMManager; use Cortex\LLM\Contracts\LLM; +use Cortex\Mcp\McpServerManager; use Cortex\ModelInfo\ModelInfoFactory; use Spatie\LaravelPackageTools\Package; use Cortex\Embeddings\EmbeddingsManager; @@ -32,6 +33,9 @@ public function packageRegistered(): void $this->app->alias('cortex.embeddings', EmbeddingsManager::class); $this->app->bind(Embeddings::class, fn(Container $app) => $app->make('cortex.embeddings')->driver()); + $this->app->singleton('cortex.mcp_server', fn(Container $app): McpServerManager => new McpServerManager($app)); + $this->app->alias('cortex.mcp_server', McpServerManager::class); + $this->app->singleton('cortex.prompt_factory', fn(Container $app): PromptFactoryManager => new PromptFactoryManager($app)); $this->app->alias('cortex.prompt_factory', PromptFactoryManager::class); $this->app->bind(PromptFactory::class, fn(Container $app) => $app->make('cortex.prompt_factory')->driver()); diff --git a/src/Facades/McpServer.php b/src/Facades/McpServer.php new file mode 100644 index 0000000..62b2c4b --- /dev/null +++ b/src/Facades/McpServer.php @@ -0,0 +1,20 @@ +config->get('cortex.mcp_servers.default'); + } + + #[Override] + protected function createDriver($driver): Client // @pest-ignore-type + { + $config = $this->config->get('cortex.mcp_servers.' . $driver); + + if ($config === null) { + throw new InvalidArgumentException(sprintf('Driver [%s] not supported.', $driver)); + } + + $serverConfig = ServerConfig::fromArray($driver, [ + 'transport' => Arr::get($config, 'transport'), + 'url' => Arr::get($config, 'url'), + 'command' => Arr::get($config, 'command'), + 'args' => Arr::get($config, 'args'), + 'headers' => Arr::get($config, 'headers'), + 'sessionId' => Arr::get($config, 'sessionId'), + 'timeout' => Arr::get($config, 'timeout'), + 'env' => Arr::get($config, 'env'), + ]); + + $clientBuilder = Client::make() + ->withClientInfo('cortex-php', '1.0.0') + ->withServerConfig($serverConfig); + + // if ($config['cache'] ?? false) { + // $clientBuilder->withCache( + // $this->container->make('cache')->store($config['cache']['store'] ?? null), + // $config['cache']['ttl'] ?? 3600, + // ); + // } + + return $clientBuilder->build(); + } +} diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php index ddc953c..14839ee 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -4,9 +4,25 @@ namespace Cortex\Prompts\Factories; +use Throwable; +use PhpMcp\Client\Client; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use Cortex\LLM\Contracts\Message; +use Cortex\JsonSchema\SchemaFactory; +use Cortex\Exceptions\PromptException; +use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\LLM\Data\Messages\UserMessage; use Cortex\Prompts\Contracts\PromptFactory; use Cortex\Prompts\Contracts\PromptTemplate; -use Cortex\Prompts\Templates\ChatPromptTemplate; +use Cortex\LLM\Data\Messages\AssistantMessage; +use Cortex\Prompts\Builders\ChatPromptBuilder; +use PhpMcp\Client\Model\Content\PromptMessage; +use Cortex\LLM\Data\Messages\Content\TextContent; +use Cortex\LLM\Data\Messages\Content\AudioContent; +use Cortex\LLM\Data\Messages\Content\ImageContent; +use PhpMcp\Client\Model\Definitions\PromptDefinition; +use PhpMcp\Client\Model\Definitions\PromptArgumentDefinition; /** * @link https://modelcontextprotocol.io/docs/concepts/prompts @@ -14,12 +30,90 @@ class McpPromptFactory implements PromptFactory { public function __construct( - protected string $baseUri, + protected Client $client, ) {} public function make(string $name, array $config = []): PromptTemplate { - // TODO: Implement - return new ChatPromptTemplate([]); + $this->client->initialize(); + + // TODO: I might need to proceed with the idea of using objects as prompt content. + // This is due to the MCP prompt being compiled at runtime via getPrompt. + + try { + /** @var array $prompts */ + $prompts = $this->client->listPrompts(); + } catch (Throwable $e) { + throw new PromptException('Failed to list prompts: ' . $e->getMessage(), previous: $e); + } + + $prompt = collect($prompts)->firstWhere('name', $name); + + if ($prompt === null) { + throw new PromptException('Prompt not found: ' . $name); + } + + $builder = new ChatPromptBuilder(); + + if ($prompt->isTemplate()) { + $builder->inputSchema($this->buildInputSchema($prompt)); + + $arguments = collect($prompt->arguments)->mapWithKeys(fn(PromptArgumentDefinition $argument) => [ + $argument->name => Str::of($argument->name)->prepend('{')->append('}')->toString(), + ])->toArray(); + + $compiledPrompt = $this->client->getPrompt($name, $arguments); + } else { + $compiledPrompt = $this->client->getPrompt($name); + } + + // TODO: make sure this is in finally block. + $this->client->disconnect(); + + $messages = array_map(function (PromptMessage $message): Message { + $contentArray = $message->content->toArray(); + $content = match ($message->content->getType()) { + 'text' => new TextContent(Arr::get($contentArray, 'text')), + 'image' => new ImageContent(Arr::get($contentArray, 'data'), Arr::get($contentArray, 'mimeType')), + 'audio' => new AudioContent(Arr::get($contentArray, 'url'), Arr::get($contentArray, 'mimeType')), + // "resource" type not supported for now + default => throw new PromptException('Unsupported content type: ' . $message->content->getType()), + }; + + return match ($message->role) { + 'user' => new UserMessage([$content]), + 'assistant' => new AssistantMessage([$content]), + default => throw new PromptException('Unsupported role: ' . $message->role), + }; + }, $compiledPrompt->messages); + + return $builder->messages($messages)->build(); + } + + /** + * Builds an input schema for the given prompt definition. + */ + protected function buildInputSchema(PromptDefinition $prompt): ObjectSchema + { + $inputSchema = new ObjectSchema($prompt->name); + + if ($prompt->description !== null) { + $inputSchema->description($prompt->description); + } + + $properties = []; + + foreach ($prompt->arguments as $argument) { + $property = SchemaFactory::string($argument->name) + ->description($argument->description); + + if ($argument->required) { + $property = $property->required(); + } + + $properties[] = $property; + } + + return $inputSchema->properties(...$properties); } } diff --git a/src/Prompts/PromptFactoryManager.php b/src/Prompts/PromptFactoryManager.php index 8ba1667..cccff33 100644 --- a/src/Prompts/PromptFactoryManager.php +++ b/src/Prompts/PromptFactoryManager.php @@ -31,11 +31,11 @@ public function createLangfuseDriver(): LangfusePromptFactory public function createMcpDriver(): McpPromptFactory { - /** @var array{base_uri?: string} $config */ + /** @var array{server?: string} $config */ $config = $this->config->get('cortex.prompt_factory.mcp'); return new McpPromptFactory( - $config['base_uri'] ?? 'http://localhost:3000', + $this->container->make('cortex.mcp_server')->driver($config['server'] ?? null), ); } } diff --git a/src/Prompts/Templates/ChatPromptTemplate.php b/src/Prompts/Templates/ChatPromptTemplate.php index b7d4451..9d79fa9 100644 --- a/src/Prompts/Templates/ChatPromptTemplate.php +++ b/src/Prompts/Templates/ChatPromptTemplate.php @@ -8,7 +8,6 @@ use Cortex\Support\Utils; use Illuminate\Support\Collection; use Cortex\JsonSchema\SchemaFactory; -use Cortex\Exceptions\PromptException; use Cortex\JsonSchema\Enums\SchemaType; use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\UnionSchema; @@ -32,9 +31,9 @@ public function __construct( ) { $this->messages = Utils::toMessageCollection($messages); - if ($this->messages->isEmpty()) { - throw new PromptException('Messages cannot be empty.'); - } + // if ($this->messages->isEmpty()) { + // throw new PromptException('Messages cannot be empty.'); + // } } public function format(?array $variables = null): MessageCollection diff --git a/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php b/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php new file mode 100644 index 0000000..7f11573 --- /dev/null +++ b/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php @@ -0,0 +1,29 @@ +make('simple_prompt'); + $prompt = $factory->make('complex_prompt'); + + dd($prompt); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + // expect($prompt->messages)->toHaveCount(3); + // expect($prompt->messages[0])->toBeInstanceOf(SystemMessage::class); + // expect($prompt->messages[0]->content)->toBe('You are a helpful assistant.'); + // expect($prompt->messages[1])->toBeInstanceOf(UserMessage::class); + // expect($prompt->messages[1]->content)->toBe('What is the capital of {{country}}?'); + // expect($prompt->messages[2])->toBeInstanceOf(MessagePlaceholder::class); + // expect($prompt->messages[2]->name)->toBe('history'); +})->todo('Look at how to mock MCP server'); From 34464f1918cba579ec07e52f2e681a097683685c Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Fri, 15 Aug 2025 23:13:11 +0100 Subject: [PATCH 2/4] updates and tests --- src/Prompts/Factories/McpPromptFactory.php | 109 +++++++----- src/Prompts/Templates/ChatPromptTemplate.php | 7 +- .../Factories/McpPromptFactoryTest.php | 155 ++++++++++++++++-- 3 files changed, 212 insertions(+), 59 deletions(-) diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php index 14839ee..e5ed8b6 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -21,6 +21,7 @@ use Cortex\LLM\Data\Messages\Content\TextContent; use Cortex\LLM\Data\Messages\Content\AudioContent; use Cortex\LLM\Data\Messages\Content\ImageContent; +use PhpMcp\Client\JsonRpc\Results\GetPromptResult; use PhpMcp\Client\Model\Definitions\PromptDefinition; use PhpMcp\Client\Model\Definitions\PromptArgumentDefinition; @@ -35,11 +36,57 @@ public function __construct( public function make(string $name, array $config = []): PromptTemplate { - $this->client->initialize(); + try { + $this->client->initialize(); + $promptDefinition = $this->getPromptDefinition($name); + $compiledPrompt = $this->getCompiledPrompt($promptDefinition); + } finally { + $this->client->disconnect(); + } + + $builder = new ChatPromptBuilder(); + + if ($promptDefinition->isTemplate()) { + $builder->inputSchema($this->buildInputSchema($promptDefinition)); + } + + return $builder->messages($this->buildMessages($compiledPrompt))->build(); + } + + /** + * Builds an input schema for the given prompt definition. + */ + protected function buildInputSchema(PromptDefinition $prompt): ObjectSchema + { + $inputSchema = new ObjectSchema($prompt->name); + + if ($prompt->description !== null) { + $inputSchema->description($prompt->description); + } - // TODO: I might need to proceed with the idea of using objects as prompt content. - // This is due to the MCP prompt being compiled at runtime via getPrompt. + $properties = []; + + foreach ($prompt->arguments as $argument) { + $property = SchemaFactory::string($argument->name) + ->description($argument->description); + if ($argument->required) { + $property = $property->required(); + } + + $properties[] = $property; + } + + return $inputSchema->properties(...$properties); + } + + /** + * Get the prompt definition for the given name. + * + * @throws \Cortex\Exceptions\PromptException + */ + protected function getPromptDefinition(string $name): PromptDefinition + { try { /** @var array $prompts */ $prompts = $this->client->listPrompts(); @@ -53,24 +100,33 @@ public function make(string $name, array $config = []): PromptTemplate throw new PromptException('Prompt not found: ' . $name); } - $builder = new ChatPromptBuilder(); + return $prompt; + } + /** + * Get the compiled prompt for the given prompt definition. + */ + protected function getCompiledPrompt(PromptDefinition $prompt): GetPromptResult + { if ($prompt->isTemplate()) { - $builder->inputSchema($this->buildInputSchema($prompt)); - $arguments = collect($prompt->arguments)->mapWithKeys(fn(PromptArgumentDefinition $argument) => [ $argument->name => Str::of($argument->name)->prepend('{')->append('}')->toString(), ])->toArray(); - $compiledPrompt = $this->client->getPrompt($name, $arguments); - } else { - $compiledPrompt = $this->client->getPrompt($name); + return $this->client->getPrompt($prompt->name, $arguments); } - // TODO: make sure this is in finally block. - $this->client->disconnect(); + return $this->client->getPrompt($prompt->name); + } - $messages = array_map(function (PromptMessage $message): Message { + /** + * Build the messages from a given compiled prompt. + * + * @return array + */ + protected function buildMessages(GetPromptResult $compiledPrompt): array + { + return array_map(function (PromptMessage $message): Message { $contentArray = $message->content->toArray(); $content = match ($message->content->getType()) { 'text' => new TextContent(Arr::get($contentArray, 'text')), @@ -86,34 +142,5 @@ public function make(string $name, array $config = []): PromptTemplate default => throw new PromptException('Unsupported role: ' . $message->role), }; }, $compiledPrompt->messages); - - return $builder->messages($messages)->build(); - } - - /** - * Builds an input schema for the given prompt definition. - */ - protected function buildInputSchema(PromptDefinition $prompt): ObjectSchema - { - $inputSchema = new ObjectSchema($prompt->name); - - if ($prompt->description !== null) { - $inputSchema->description($prompt->description); - } - - $properties = []; - - foreach ($prompt->arguments as $argument) { - $property = SchemaFactory::string($argument->name) - ->description($argument->description); - - if ($argument->required) { - $property = $property->required(); - } - - $properties[] = $property; - } - - return $inputSchema->properties(...$properties); } } diff --git a/src/Prompts/Templates/ChatPromptTemplate.php b/src/Prompts/Templates/ChatPromptTemplate.php index 9d79fa9..b7d4451 100644 --- a/src/Prompts/Templates/ChatPromptTemplate.php +++ b/src/Prompts/Templates/ChatPromptTemplate.php @@ -8,6 +8,7 @@ use Cortex\Support\Utils; use Illuminate\Support\Collection; use Cortex\JsonSchema\SchemaFactory; +use Cortex\Exceptions\PromptException; use Cortex\JsonSchema\Enums\SchemaType; use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\UnionSchema; @@ -31,9 +32,9 @@ public function __construct( ) { $this->messages = Utils::toMessageCollection($messages); - // if ($this->messages->isEmpty()) { - // throw new PromptException('Messages cannot be empty.'); - // } + if ($this->messages->isEmpty()) { + throw new PromptException('Messages cannot be empty.'); + } } public function format(?array $variables = null): MessageCollection diff --git a/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php b/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php index 7f11573..8b1e418 100644 --- a/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php +++ b/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php @@ -4,26 +4,151 @@ namespace Cortex\Tests\Unit\Prompts\Factories; -use Cortex\Facades\McpServer; +use Mockery; +use Exception; +use PhpMcp\Client\Client; +use Cortex\Exceptions\PromptException; +use Cortex\LLM\Data\Messages\UserMessage; +use Cortex\Prompts\Contracts\PromptTemplate; +use PhpMcp\Client\Model\Content\TextContent; +use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\Prompts\Factories\McpPromptFactory; +use PhpMcp\Client\Model\Content\PromptMessage; use Cortex\Prompts\Templates\ChatPromptTemplate; +use PhpMcp\Client\JsonRpc\Results\GetPromptResult; +use PhpMcp\Client\Model\Definitions\PromptDefinition; +use PhpMcp\Client\Model\Definitions\PromptArgumentDefinition; +use Cortex\LLM\Data\Messages\Content\TextContent as CortexTextContent; -test('it can create a chat prompt template from a SSE MCP server', function (): void { - $factory = new McpPromptFactory( - McpServer::driver('local_http'), +test('it can create a chat prompt template without arguments', function (): void { + $promptDefinition = new PromptDefinition( + name: 'simple_prompt', + description: 'A simple test prompt', + arguments: [], ); - // $prompt = $factory->make('simple_prompt'); - $prompt = $factory->make('complex_prompt'); + $compiledPrompt = new GetPromptResult( + messages: [ + new PromptMessage( + role: 'user', + content: new TextContent('How much wood would a woodchuck chuck if a woodchuck could chuck wood?'), + ), + ], + description: 'Test prompt result', + ); + + $mockClient = Mockery::mock(Client::class); + + $mockClient->shouldReceive('initialize')->once(); + $mockClient->shouldReceive('listPrompts')->once()->andReturn([$promptDefinition]); + $mockClient->shouldReceive('getPrompt')->with('simple_prompt')->once()->andReturn($compiledPrompt); + $mockClient->shouldReceive('disconnect')->once(); - dd($prompt); + $factory = new McpPromptFactory($mockClient); + + $prompt = $factory->make('simple_prompt'); expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); - // expect($prompt->messages)->toHaveCount(3); - // expect($prompt->messages[0])->toBeInstanceOf(SystemMessage::class); - // expect($prompt->messages[0]->content)->toBe('You are a helpful assistant.'); - // expect($prompt->messages[1])->toBeInstanceOf(UserMessage::class); - // expect($prompt->messages[1]->content)->toBe('What is the capital of {{country}}?'); - // expect($prompt->messages[2])->toBeInstanceOf(MessagePlaceholder::class); - // expect($prompt->messages[2]->name)->toBe('history'); -})->todo('Look at how to mock MCP server'); + expect($prompt->messages)->toHaveCount(1); + expect($prompt->messages[0])->toBeInstanceOf(UserMessage::class); + expect($prompt->messages[0]->content[0])->toBeInstanceOf(CortexTextContent::class); + expect($prompt->messages[0]->content[0]->text)->toBe('How much wood would a woodchuck chuck if a woodchuck could chuck wood?'); +}); + +test('it can create a chat prompt template with template arguments', function (): void { + $promptDefinition = new PromptDefinition( + name: 'complex_prompt', + description: 'A template prompt with arguments', + arguments: [ + new PromptArgumentDefinition( + name: 'country', + description: 'The country to ask about', + required: true, + ), + ], + ); + + $getPromptResult = new GetPromptResult( + messages: [ + new PromptMessage( + role: 'user', + content: new TextContent('What is the capital of {country}?'), + ), + new PromptMessage( + role: 'assistant', + content: new TextContent('Paris'), + ), + new PromptMessage( + role: 'user', + content: new TextContent('What is the population of that city?'), + ), + ], + description: 'Complex template prompt result', + ); + + $mockClient = Mockery::mock(Client::class); + + $mockClient->shouldReceive('initialize')->once(); + $mockClient->shouldReceive('listPrompts')->once()->andReturn([$promptDefinition]); + $mockClient->shouldReceive('getPrompt') + ->with('complex_prompt', [ + 'country' => '{country}', + ]) + ->once() + ->andReturn($getPromptResult); + $mockClient->shouldReceive('disconnect')->once(); + + $factory = new McpPromptFactory($mockClient); + + $prompt = $factory->make('complex_prompt'); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + expect($prompt->messages)->toHaveCount(3); + expect($prompt->messages[0])->toBeInstanceOf(UserMessage::class); + expect($prompt->messages[0]->content[0]->text)->toBe('What is the capital of {country}?'); + expect($prompt->messages[1])->toBeInstanceOf(AssistantMessage::class); + expect($prompt->messages[1]->content[0]->text)->toBe('Paris'); + expect($prompt->messages[2])->toBeInstanceOf(UserMessage::class); + expect($prompt->messages[2]->content[0]->text)->toBe('What is the population of that city?'); + + expect($prompt->inputSchema)->not()->toBeNull(); + expect($prompt->inputSchema->toArray())->toBe([ + 'type' => 'object', + '$schema' => 'http://json-schema.org/draft-07/schema#', + 'title' => 'complex_prompt', + 'description' => 'A template prompt with arguments', + 'properties' => [ + 'country' => [ + 'type' => 'string', + 'description' => 'The country to ask about', + ], + ], + 'required' => ['country'], + ]); +}); + +test('it throws exception when prompt is not found', function (): void { + $mockClient = Mockery::mock(Client::class); + + $mockClient->shouldReceive('initialize')->once(); + $mockClient->shouldReceive('listPrompts')->once()->andReturn([]); + $mockClient->shouldReceive('disconnect')->once(); + + $factory = new McpPromptFactory($mockClient); + + expect(fn(): PromptTemplate => $factory->make('non_existent_prompt')) + ->toThrow(PromptException::class, 'Prompt not found: non_existent_prompt'); +}); + +test('it throws exception when MCP client fails', function (): void { + $mockClient = Mockery::mock(Client::class); + + $mockClient->shouldReceive('initialize')->once(); + $mockClient->shouldReceive('listPrompts')->once()->andThrow(new Exception('MCP server connection failed')); + $mockClient->shouldReceive('disconnect')->once(); + + $factory = new McpPromptFactory($mockClient); + + expect(fn(): PromptTemplate => $factory->make('any_prompt')) + ->toThrow(PromptException::class, 'Failed to list prompts: MCP server connection failed'); +}); From 4e728dad6818797685c1993b80d0afa7fadffa00 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Sun, 17 Aug 2025 00:17:15 +0100 Subject: [PATCH 3/4] add mcp tool --- config/cortex.php | 16 ++++- src/LLM/Contracts/Tool.php | 2 +- src/Tools/ClosureTool.php | 2 +- src/Tools/GoogleSerper.php | 2 +- src/Tools/McpTool.php | 107 +++++++++++++++++++++++++++++++ src/Tools/SchemaTool.php | 2 +- src/Tools/TavilySearch.php | 2 +- tests/Unit/Tools/McpToolTest.php | 53 +++++++++++++++ 8 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 src/Tools/McpTool.php create mode 100644 tests/Unit/Tools/McpToolTest.php diff --git a/config/cortex.php b/config/cortex.php index 3c530a7..db39909 100644 --- a/config/cortex.php +++ b/config/cortex.php @@ -211,8 +211,22 @@ 'local_http' => [ 'transport' => 'http', 'url' => 'http://localhost:3001/sse', - 'headers' => null, ], + + // 'tavily' => [ + // 'transport' => 'http', + // 'url' => 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'), + // ], + + // 'tavily' => [ + // 'transport' => 'stdio', + // 'command' => 'npx', + // 'args' => [ + // '-y', + // 'mcp-remote', + // 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'), + // ], + // ], ], /* diff --git a/src/LLM/Contracts/Tool.php b/src/LLM/Contracts/Tool.php index ec07b16..1504f35 100644 --- a/src/LLM/Contracts/Tool.php +++ b/src/LLM/Contracts/Tool.php @@ -37,7 +37,7 @@ public function format(): array; * * @param ToolCall|array $arguments */ - public function invoke(ToolCall|array $arguments): mixed; + public function invoke(ToolCall|array $arguments = []): mixed; /** * Invoke the tool as a tool message. diff --git a/src/Tools/ClosureTool.php b/src/Tools/ClosureTool.php index d0e97d7..4e1d4d8 100644 --- a/src/Tools/ClosureTool.php +++ b/src/Tools/ClosureTool.php @@ -45,7 +45,7 @@ public function schema(): ObjectSchema /** * @param ToolCall|array $toolCall */ - public function invoke(ToolCall|array $toolCall): mixed + public function invoke(ToolCall|array $toolCall = []): mixed { // Get the arguments from the given tool call. $arguments = $this->getArguments($toolCall); diff --git a/src/Tools/GoogleSerper.php b/src/Tools/GoogleSerper.php index 66c4ab8..ca07393 100644 --- a/src/Tools/GoogleSerper.php +++ b/src/Tools/GoogleSerper.php @@ -38,7 +38,7 @@ public function schema(): ObjectSchema /** * @param ToolCall|array $toolCall */ - public function invoke(ToolCall|array $toolCall): string + public function invoke(ToolCall|array $toolCall = []): string { $arguments = $this->getArguments($toolCall); $searchQuery = $arguments['query']; diff --git a/src/Tools/McpTool.php b/src/Tools/McpTool.php new file mode 100644 index 0000000..88952b9 --- /dev/null +++ b/src/Tools/McpTool.php @@ -0,0 +1,107 @@ +client = McpServer::driver($this->mcpServer); + $toolDefinition = $this->getToolDefinition(); + $this->description = $toolDefinition->description ?? ''; + $schema = SchemaFactory::fromJson($toolDefinition->inputSchema); + + if (! $schema instanceof ObjectSchema) { + throw new GenericException(sprintf('Schema for tool %s is not an object', $this->name)); + } + + if ($toolDefinition->description !== null) { + $schema->description($toolDefinition->description); + } + + $this->schema = $schema; + } + + public function name(): string + { + return $this->name; + } + + public function description(): string + { + return $this->description; + } + + public function schema(): ObjectSchema + { + return $this->schema; + } + + /** + * @param ToolCall|array $toolCall + */ + public function invoke(ToolCall|array $toolCall = []): mixed + { + try { + $this->client->initialize(); + + // Get the arguments from the given tool call. + $arguments = $this->getArguments($toolCall); + + // Ensure arguments are valid as per the tool's schema. + if ($this->schema->getPropertyKeys() !== []) { + $this->schema->validate($arguments); + } + + $result = $this->client->callTool($this->name, $arguments); + } finally { + $this->client->disconnect(); + } + + // Only support text content for now. + $textContent = collect($result->content) + ->filter(fn(ContentInterface $item): bool => $item instanceof TextContent) + ->first(); + + return $textContent?->text; + } + + protected function getToolDefinition(): ToolDefinition + { + $this->client->initialize(); + $tools = $this->client->listTools(); + + $tool = collect($tools)->firstWhere('name', $this->name); + + if ($tool === null) { + throw new GenericException(sprintf('Tool %s not found in MCP server %s', $this->name, $this->mcpServer)); + } + + return $tool; + } + + public function __destruct() + { + $this->client->disconnect(); + } +} diff --git a/src/Tools/SchemaTool.php b/src/Tools/SchemaTool.php index 277f3e1..ba9ead8 100644 --- a/src/Tools/SchemaTool.php +++ b/src/Tools/SchemaTool.php @@ -38,7 +38,7 @@ public function schema(): ObjectSchema /** * @param ToolCall|array $toolCall */ - public function invoke(ToolCall|array $toolCall): mixed + public function invoke(ToolCall|array $toolCall = []): mixed { throw new GenericException( 'The Schema tool does not support invocation. It is only used for structured output.', diff --git a/src/Tools/TavilySearch.php b/src/Tools/TavilySearch.php index ea45ee5..513db32 100644 --- a/src/Tools/TavilySearch.php +++ b/src/Tools/TavilySearch.php @@ -38,7 +38,7 @@ public function schema(): ObjectSchema /** * @param ToolCall|array $toolCall */ - public function invoke(ToolCall|array $toolCall): string + public function invoke(ToolCall|array $toolCall = []): string { $arguments = $this->getArguments($toolCall); $searchQuery = $arguments['query']; diff --git a/tests/Unit/Tools/McpToolTest.php b/tests/Unit/Tools/McpToolTest.php new file mode 100644 index 0000000..523ac1c --- /dev/null +++ b/tests/Unit/Tools/McpToolTest.php @@ -0,0 +1,53 @@ +schema()->toArray()); + // dump($tool->description()); + $result = $tool->invoke([ + 'message' => 'foo', + ]); + + dd($result); + + // dd(json_decode($result, true)); + + // expect($tool->schema())->toBeInstanceOf(Schema::class); + // expect($tool->name())->toBe('multiply'); + // expect($tool->description())->toBe('Multiply two numbers'); + // expect($tool->invoke([ + // 'a' => 2, + // 'b' => 2, + // ]))->toBe(4); + + // expect($tool->format())->toBe([ + // 'name' => 'multiply', + // 'description' => 'Multiply two numbers', + // 'parameters' => [ + // 'type' => 'object', + // 'properties' => [ + // 'a' => [ + // 'type' => 'integer', + // 'description' => 'The first number', + // ], + // 'b' => [ + // 'type' => 'integer', + // 'description' => 'The second number', + // ], + // ], + // 'required' => ['a', 'b'], + // ], + // ]); +})->todo(); From eb77acac841a973a4e853d195c9a44b1eb82e5e5 Mon Sep 17 00:00:00 2001 From: Sean Tymon Date: Tue, 2 Sep 2025 22:04:56 +0100 Subject: [PATCH 4/4] toolkit --- src/Support/helpers.php | 2 + src/Tools/McpTool.php | 19 +++--- src/Tools/ToolKits/McpToolKit.php | 48 ++++++++++++++++ tests/Unit/Experimental/PlaygroundTest.php | 67 ---------------------- tests/Unit/Tools/McpToolTest.php | 15 ++++- 5 files changed, 76 insertions(+), 75 deletions(-) create mode 100644 src/Tools/ToolKits/McpToolKit.php diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 81186ec..4c8a46d 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -19,6 +19,8 @@ * Helper function to create a chat prompt builder. * * @param MessageCollection|array|string|null $messages + * + * @return ($messages is null ? \Cortex\Prompts\Prompt : ($messages is string ? \Cortex\Prompts\Builders\TextPromptBuilder : \Cortex\Prompts\Builders\ChatPromptBuilder)) */ function prompt(MessageCollection|array|string|null $messages): Prompt|PromptBuilder { diff --git a/src/Tools/McpTool.php b/src/Tools/McpTool.php index 88952b9..838cdcd 100644 --- a/src/Tools/McpTool.php +++ b/src/Tools/McpTool.php @@ -24,17 +24,22 @@ class McpTool extends AbstractTool public function __construct( protected string $name, - protected ?string $mcpServer = null, + protected ?string $server = null, + protected ?ToolDefinition $toolDefinition = null, ) { - $this->client = McpServer::driver($this->mcpServer); - $toolDefinition = $this->getToolDefinition(); - $this->description = $toolDefinition->description ?? ''; - $schema = SchemaFactory::fromJson($toolDefinition->inputSchema); + $this->client = McpServer::driver($this->server); + + // If a tool definition is provided then we don't need to connect to the MCP server yet. + $this->toolDefinition ??= $this->getToolDefinition(); + + $schema = SchemaFactory::from($this->toolDefinition->inputSchema); if (! $schema instanceof ObjectSchema) { throw new GenericException(sprintf('Schema for tool %s is not an object', $this->name)); } + $this->description = $this->toolDefinition->description ?? ''; + if ($toolDefinition->description !== null) { $schema->description($toolDefinition->description); } @@ -68,7 +73,7 @@ public function invoke(ToolCall|array $toolCall = []): mixed // Get the arguments from the given tool call. $arguments = $this->getArguments($toolCall); - // Ensure arguments are valid as per the tool's schema. + // Ensure arguments are valid as per the tool's schema (if it has properties). if ($this->schema->getPropertyKeys() !== []) { $this->schema->validate($arguments); } @@ -94,7 +99,7 @@ protected function getToolDefinition(): ToolDefinition $tool = collect($tools)->firstWhere('name', $this->name); if ($tool === null) { - throw new GenericException(sprintf('Tool %s not found in MCP server %s', $this->name, $this->mcpServer)); + throw new GenericException(sprintf('Tool [%s] not found in MCP server [%s]', $this->name, $this->server)); } return $tool; diff --git a/src/Tools/ToolKits/McpToolKit.php b/src/Tools/ToolKits/McpToolKit.php new file mode 100644 index 0000000..f5eef72 --- /dev/null +++ b/src/Tools/ToolKits/McpToolKit.php @@ -0,0 +1,48 @@ +client = McpServer::driver($this->server); + } + + /** + * @return array + */ + public function getTools(): array + { + try { + $this->client->initialize(); + $tools = $this->client->listTools(); + + return collect($tools) + ->map(fn(ToolDefinition $toolDefinition): McpTool => new McpTool( + $toolDefinition->name, + $this->server, + $toolDefinition, + )) + ->all(); + + } finally { + $this->client->disconnect(); + } + } + + public function __destruct() + { + $this->client->disconnect(); + } +} diff --git a/tests/Unit/Experimental/PlaygroundTest.php b/tests/Unit/Experimental/PlaygroundTest.php index f2623cc..55c1a4e 100644 --- a/tests/Unit/Experimental/PlaygroundTest.php +++ b/tests/Unit/Experimental/PlaygroundTest.php @@ -5,18 +5,11 @@ use Cortex\Cortex; use Cortex\Pipeline; use Cortex\Facades\LLM; -use Cortex\Facades\Image; -use Cortex\Attributes\Tool; -use Cortex\Chat\GenericChat; use Cortex\Facades\ModelInfo; -use Cortex\Memory\ChatMemory; -use Cortex\Facades\Embeddings; use Cortex\Events\ChatModelEnd; -use Cortex\Facades\VectorStore; use Cortex\Tasks\Enums\TaskType; use Cortex\Events\ChatModelStart; use Cortex\JsonSchema\SchemaFactory; -use Cortex\Memory\ChatSummaryMemory; use Illuminate\Support\Facades\Event; use Cortex\JsonSchema\Types\StringSchema; use Cortex\LLM\Data\Messages\UserMessage; @@ -100,14 +93,6 @@ dd('done'); })->skip(); -test('image playground', function (): void { - $generator = Image::driver(); - - $result = $generator->invoke('A beautiful landscape with a river and mountains'); - - dd($result); -})->skip(); - test('piping tasks with structured output', function (): void { Event::listen(ChatModelStart::class, function (ChatModelStart $event): void { dump($event->parameters); @@ -249,58 +234,6 @@ dd($result); })->skip(); -test('chat', function (): void { - $llm = LLM::provider('openai')->withModel('gpt-4o-mini'); - - $add = #[Tool(name: 'add', description: 'Add two numbers')] - function (int $a, int $b): int { - return $a + $b; - }; - - $chat = new GenericChat($llm, new ChatMemory(), tools: [$add]); - - $result = $chat->sendMessage(new UserMessage('Hello, my name is Sean, how are you?')); - dump('Result 1: ' . $result->text()); - - $result = $chat->sendMessage(new UserMessage('What is 2 + 2?')); - dump('Result 2: ' . $result->text()); - - $result = $chat->sendMessage(new UserMessage('What is my name?')); - dump('Result 3: ' . $result->text()); - - dd($chat->getMessages()); -})->skip(); - -test('chat summary memory', function (): void { - $summaryLLM = LLM::provider('ollama')->withModel('llama3.2'); - $chatLLM = LLM::provider('ollama')->withModel('llama3.1'); - - $chat = new GenericChat($chatLLM, new ChatSummaryMemory($summaryLLM)); - - $result = $chat->sendMessage(new UserMessage('Hello, my name is Sean, how are you?')); - dump('Result 1: ' . $result->text()); - - $result = $chat->sendMessage(new UserMessage('My favourite food is pizza')); - dump('Result 2: ' . $result->text()); - - $result = $chat->sendMessage(new UserMessage('What is my favourite food?')); - dump('Result 3: ' . $result->text()); -})->skip(); - -test('qdrant', function (): void { - $result = Embeddings::driver('ollama')->invoke([ - "The State of the Union Address (sometimes abbreviated to SOTU) is an annual message delivered by the president of the United States to a joint session of the United States Congress near the beginning of most calendar years on the current condition of the nation.[3][4] The State of the Union Address generally includes reports on the nation's budget, economy, news, agenda, progress, achievements and the president's priorities and legislative proposals", - "The address fulfills the requirement in Article II, Section 3, Clause 1 of the U.S. Constitution for the president to periodically \"give to the Congress Information of the State of the Union, and recommend to their Consideration such Measures as he shall judge necessary and expedient.\" During most of the country's first century, the president primarily submitted only a written report to Congress. After 1913, Woodrow Wilson, the 28th U.S. president, began the regular practice of delivering the address to Congress in person as a way to rally support for the president's agenda, while also submitting a more detailed report.[3] With the advent of radio and television, the address is now broadcast live in all United States time zones on many networks.", - 'The speech is generally held in January or February, and an invitation to the president is extended to use the chamber of the House by the speaker of the House. Starting in 1981, Ronald Reagan, the 40th U.S. president, began the practice of newly inaugurated presidents delivering an address to Congress in the first year of their term but not designating that speech an official "State of the Union".', - ]); - $qdrant = VectorStore::driver('qdrant'); - $qdrant->ensureCollectionExists('test', $result->size); - - // foreach ($result->embeddings as $embedding) { - // $qdrant->add('test', $embedding); - // } -})->skip(); - test('parallel group 1', function (): void { $dogJoke = task('tell_a_dog_joke', TaskType::Structured) ->system('You are a comedian.') diff --git a/tests/Unit/Tools/McpToolTest.php b/tests/Unit/Tools/McpToolTest.php index 523ac1c..cfcdb98 100644 --- a/tests/Unit/Tools/McpToolTest.php +++ b/tests/Unit/Tools/McpToolTest.php @@ -5,11 +5,12 @@ namespace Cortex\Tests\Unit\Tools; use Cortex\Tools\McpTool; +use Cortex\Tools\ToolKits\McpToolKit; test('it can create a schema from an MCP tool', function (): void { // $tool = new McpTool( // name: 'tavily_search', - // mcpServer: 'tavily', + // server: 'tavily', // ); $tool = new McpTool('echo'); @@ -51,3 +52,15 @@ // ], // ]); })->todo(); + +test('it can create an MCP tool kit', function (): void { + $kit = new McpToolKit(); + + $tools = collect($kit->getTools()); + + dd($tools->map(fn(McpTool $tool): array => [ + 'name' => $tool->name(), + // 'description' => $tool->description(), + 'schema' => $tool->schema()->toArray(), + ])->all()); +})->todo();