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..db39909 100644 --- a/config/cortex.php +++ b/config/cortex.php @@ -193,6 +193,42 @@ ], ], + /* + |-------------------------------------------------------------------------- + | 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', + ], + + // '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'), + // ], + // ], + ], + /* |-------------------------------------------------------------------------- | Prompt Factories @@ -218,9 +254,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 @@ + $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/Mcp/McpServerManager.php b/src/Mcp/McpServerManager.php new file mode 100644 index 0000000..0d5f3db --- /dev/null +++ b/src/Mcp/McpServerManager.php @@ -0,0 +1,54 @@ +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..e5ed8b6 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -4,9 +4,26 @@ 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\JsonRpc\Results\GetPromptResult; +use PhpMcp\Client\Model\Definitions\PromptDefinition; +use PhpMcp\Client\Model\Definitions\PromptArgumentDefinition; /** * @link https://modelcontextprotocol.io/docs/concepts/prompts @@ -14,12 +31,116 @@ 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([]); + 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); + } + + $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(); + } 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); + } + + return $prompt; + } + + /** + * Get the compiled prompt for the given prompt definition. + */ + protected function getCompiledPrompt(PromptDefinition $prompt): GetPromptResult + { + if ($prompt->isTemplate()) { + $arguments = collect($prompt->arguments)->mapWithKeys(fn(PromptArgumentDefinition $argument) => [ + $argument->name => Str::of($argument->name)->prepend('{')->append('}')->toString(), + ])->toArray(); + + return $this->client->getPrompt($prompt->name, $arguments); + } + + return $this->client->getPrompt($prompt->name); + } + + /** + * 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')), + '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); } } 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/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/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..838cdcd --- /dev/null +++ b/src/Tools/McpTool.php @@ -0,0 +1,112 @@ +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); + } + + $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 it has properties). + 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->server)); + } + + 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/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/Prompts/Factories/McpPromptFactoryTest.php b/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php new file mode 100644 index 0000000..8b1e418 --- /dev/null +++ b/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php @@ -0,0 +1,154 @@ +shouldReceive('initialize')->once(); + $mockClient->shouldReceive('listPrompts')->once()->andReturn([$promptDefinition]); + $mockClient->shouldReceive('getPrompt')->with('simple_prompt')->once()->andReturn($compiledPrompt); + $mockClient->shouldReceive('disconnect')->once(); + + $factory = new McpPromptFactory($mockClient); + + $prompt = $factory->make('simple_prompt'); + + expect($prompt)->toBeInstanceOf(ChatPromptTemplate::class); + 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'); +}); diff --git a/tests/Unit/Tools/McpToolTest.php b/tests/Unit/Tools/McpToolTest.php new file mode 100644 index 0000000..cfcdb98 --- /dev/null +++ b/tests/Unit/Tools/McpToolTest.php @@ -0,0 +1,66 @@ +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(); + +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();