feat: Add output schema support to MCP tools (#153)
This commit is contained in:
parent
4c3e5e61c7
commit
c0938a19bd
6 changed files with 605 additions and 55 deletions
|
|
@ -30,14 +30,15 @@ class McpToolTest extends TestCase
|
|||
$this->assertSame($description, $attribute->description);
|
||||
}
|
||||
|
||||
public function testInstantiatesWithNullValuesForNameAndDescription(): void
|
||||
public function testInstantiatesWithNullValuesForNameDescriptionAndOutputSchema(): void
|
||||
{
|
||||
// Arrange & Act
|
||||
$attribute = new McpTool(name: null, description: null);
|
||||
$attribute = new McpTool(name: null, description: null, outputSchema: null);
|
||||
|
||||
// Assert
|
||||
$this->assertNull($attribute->name);
|
||||
$this->assertNull($attribute->description);
|
||||
$this->assertNull($attribute->outputSchema);
|
||||
}
|
||||
|
||||
public function testInstantiatesWithMissingOptionalArguments(): void
|
||||
|
|
@ -48,5 +49,31 @@ class McpToolTest extends TestCase
|
|||
// Assert
|
||||
$this->assertNull($attribute->name);
|
||||
$this->assertNull($attribute->description);
|
||||
$this->assertNull($attribute->outputSchema);
|
||||
}
|
||||
|
||||
public function testInstantiatesWithOutputSchema(): void
|
||||
{
|
||||
// Arrange
|
||||
$name = 'test-tool-name';
|
||||
$description = 'This is a test description.';
|
||||
$outputSchema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'result' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The result of the operation',
|
||||
],
|
||||
],
|
||||
'required' => ['result'],
|
||||
];
|
||||
|
||||
// Act
|
||||
$attribute = new McpTool(name: $name, description: $description, outputSchema: $outputSchema);
|
||||
|
||||
// Assert
|
||||
$this->assertSame($name, $attribute->name);
|
||||
$this->assertSame($description, $attribute->description);
|
||||
$this->assertSame($outputSchema, $attribute->outputSchema);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ use phpDocumentor\Reflection\DocBlock;
|
|||
use phpDocumentor\Reflection\DocBlock\Tags\Deprecated;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\See;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\Throws;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DocBlockParserTest extends TestCase
|
||||
|
|
@ -109,12 +108,6 @@ class DocBlockParserTest extends TestCase
|
|||
|
||||
$this->assertInstanceOf(DocBlock::class, $docBlock);
|
||||
|
||||
$throwsTags = $docBlock->getTagsByName('throws');
|
||||
$this->assertCount(1, $throwsTags);
|
||||
$this->assertInstanceOf(Throws::class, $throwsTags[0]);
|
||||
$this->assertEquals('\\RuntimeException', (string) $throwsTags[0]->getType());
|
||||
$this->assertEquals('if processing fails', $throwsTags[0]->getDescription()->render());
|
||||
|
||||
$deprecatedTags = $docBlock->getTagsByName('deprecated');
|
||||
$this->assertCount(1, $deprecatedTags);
|
||||
$this->assertInstanceOf(Deprecated::class, $deprecatedTags[0]);
|
||||
|
|
|
|||
|
|
@ -70,8 +70,6 @@ class DocBlockTestFixture
|
|||
*
|
||||
* @return bool status of the operation
|
||||
*
|
||||
* @throws \RuntimeException if processing fails
|
||||
*
|
||||
* @deprecated use newMethod() instead
|
||||
* @see DocBlockTestFixture::newMethod()
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Capability\Attribute\Schema;
|
||||
use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum;
|
||||
use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum;
|
||||
|
|
@ -337,6 +338,12 @@ class SchemaGeneratorFixture
|
|||
/**
|
||||
* Complex nested Schema constraints.
|
||||
*/
|
||||
#[McpTool(
|
||||
outputSchema: [
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
]
|
||||
)]
|
||||
public function complexNestedSchema(
|
||||
#[Schema(
|
||||
type: 'object',
|
||||
|
|
@ -399,6 +406,12 @@ class SchemaGeneratorFixture
|
|||
* Method with no parameters but Schema description.
|
||||
*/
|
||||
#[Schema(description: 'Gets server status. Takes no arguments.', properties: [])]
|
||||
#[McpTool(
|
||||
outputSchema: [
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
]
|
||||
)]
|
||||
public function noParamsWithSchema(): array
|
||||
{
|
||||
return ['status' => 'OK'];
|
||||
|
|
@ -424,4 +437,20 @@ class SchemaGeneratorFixture
|
|||
public function withParameterNamedRequest(string $_request): void
|
||||
{
|
||||
}
|
||||
|
||||
// ===== OUTPUT SCHEMA FIXTURES =====
|
||||
#[McpTool(
|
||||
outputSchema: [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'message' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['message'],
|
||||
'description' => 'The result of the operation',
|
||||
]
|
||||
)]
|
||||
public function returnWithExplicitOutputSchema(): array
|
||||
{
|
||||
return ['message' => 'result'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -346,4 +346,45 @@ final class SchemaGeneratorTest extends TestCase
|
|||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->schemaGenerator->generate($method);
|
||||
}
|
||||
|
||||
public function testGenerateOutputSchemaReturnsNullForVoidReturnType(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams');
|
||||
$schema = $this->schemaGenerator->generateOutputSchema($method);
|
||||
$this->assertNull($schema);
|
||||
}
|
||||
|
||||
public function testGenerateOutputSchemaWithReturnDescription(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'returnWithExplicitOutputSchema');
|
||||
$schema = $this->schemaGenerator->generateOutputSchema($method);
|
||||
$this->assertEquals([
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'message' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['message'],
|
||||
'description' => 'The result of the operation',
|
||||
], $schema);
|
||||
}
|
||||
|
||||
public function testGenerateOutputSchemaForArrayReturnType(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema');
|
||||
$schema = $this->schemaGenerator->generateOutputSchema($method);
|
||||
$this->assertEquals([
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
], $schema);
|
||||
}
|
||||
|
||||
public function testGenerateOutputSchemaForComplexNestedSchema(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema');
|
||||
$schema = $this->schemaGenerator->generateOutputSchema($method);
|
||||
$this->assertEquals([
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
], $schema);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -527,7 +527,91 @@ class RegistryTest extends TestCase
|
|||
$this->assertEquals('second', ($toolRef->handler)());
|
||||
}
|
||||
|
||||
private function createValidTool(string $name): Tool
|
||||
public function testExtractStructuredContentReturnsNullWhenOutputSchemaIsNull(): void
|
||||
{
|
||||
$tool = $this->createValidTool('test_tool', null);
|
||||
$this->registry->registerTool($tool, fn () => 'result');
|
||||
|
||||
$toolRef = $this->registry->getTool('test_tool');
|
||||
$this->assertNull($toolRef->extractStructuredContent('result'));
|
||||
}
|
||||
|
||||
public function testExtractStructuredContentReturnsArrayMatchingSchema(): void
|
||||
{
|
||||
$tool = $this->createValidTool('test_tool', [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'param' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['param'],
|
||||
]);
|
||||
$this->registry->registerTool($tool, fn () => [
|
||||
'param' => 'test',
|
||||
]);
|
||||
|
||||
$toolRef = $this->registry->getTool('test_tool');
|
||||
$this->assertEquals([
|
||||
'param' => 'test',
|
||||
], $toolRef->extractStructuredContent([
|
||||
'param' => 'test',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testExtractStructuredContentReturnsArrayDirectlyForAdditionalProperties(): void
|
||||
{
|
||||
$tool = $this->createValidTool('test_tool', [
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
]);
|
||||
$this->registry->registerTool($tool, fn () => ['success' => true, 'message' => 'done']);
|
||||
|
||||
$toolRef = $this->registry->getTool('test_tool');
|
||||
$this->assertEquals(['success' => true, 'message' => 'done'], $toolRef->extractStructuredContent(['success' => true, 'message' => 'done']));
|
||||
}
|
||||
|
||||
public function testExtractStructuredContentReturnsArrayDirectlyForArrayOutputSchema(): void
|
||||
{
|
||||
// Arrange
|
||||
$outputSchema = [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'foo' => [
|
||||
'type' => 'string',
|
||||
'description' => 'A static value',
|
||||
],
|
||||
],
|
||||
'required' => ['foo'],
|
||||
],
|
||||
];
|
||||
|
||||
$tool = $this->createValidTool('list_static_data', $outputSchema);
|
||||
$toolReturnValue = [
|
||||
['foo' => 'bar'],
|
||||
['foo' => 'bar'],
|
||||
['foo' => 'bar'],
|
||||
['foo' => 'bar'],
|
||||
];
|
||||
|
||||
$this->registry->registerTool($tool, fn () => $toolReturnValue);
|
||||
|
||||
// Act
|
||||
$toolRef = $this->registry->getTool('list_static_data');
|
||||
$structuredContent = $toolRef->extractStructuredContent($toolReturnValue);
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($structuredContent);
|
||||
$this->assertCount(4, $structuredContent);
|
||||
$this->assertEquals([
|
||||
['foo' => 'bar'],
|
||||
['foo' => 'bar'],
|
||||
['foo' => 'bar'],
|
||||
['foo' => 'bar'],
|
||||
], $structuredContent);
|
||||
}
|
||||
|
||||
private function createValidTool(string $name, ?array $outputSchema = null): Tool
|
||||
{
|
||||
return new Tool(
|
||||
name: $name,
|
||||
|
|
@ -540,6 +624,9 @@ class RegistryTest extends TestCase
|
|||
],
|
||||
description: "Test tool: {$name}",
|
||||
annotations: null,
|
||||
icons: null,
|
||||
meta: null,
|
||||
outputSchema: $outputSchema
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue