feat: Add output schema support to MCP tools (#153)
This commit is contained in:
parent
4c3e5e61c7
commit
c0938a19bd
9 changed files with 605 additions and 55 deletions
|
|
@ -21,11 +21,12 @@ use Mcp\Schema\ToolAnnotations;
|
|||
class McpTool
|
||||
{
|
||||
/**
|
||||
* @param string|null $name The name of the tool (defaults to the method name)
|
||||
* @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
|
||||
* @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
|
||||
* @param ?Icon[] $icons Optional list of icon URLs representing the tool
|
||||
* @param ?array<string, mixed> $meta Optional metadata
|
||||
* @param string|null $name The name of the tool (defaults to the method name)
|
||||
* @param string|null $description The description of the tool (defaults to the DocBlock/inferred)
|
||||
* @param ToolAnnotations|null $annotations Optional annotations describing tool behavior
|
||||
* @param ?Icon[] $icons Optional list of icon URLs representing the tool
|
||||
* @param ?array<string, mixed> $meta Optional metadata
|
||||
* @param array<string, mixed> $outputSchema Optional JSON Schema object for defining the expected output structure
|
||||
*/
|
||||
public function __construct(
|
||||
public ?string $name = null,
|
||||
|
|
@ -33,6 +34,7 @@ class McpTool
|
|||
public ?ToolAnnotations $annotations = null,
|
||||
public ?array $icons = null,
|
||||
public ?array $meta = null,
|
||||
public ?array $outputSchema = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ class Discoverer
|
|||
$name = $instance->name ?? ('__invoke' === $methodName ? $classShortName : $methodName);
|
||||
$description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null;
|
||||
$inputSchema = $this->schemaGenerator->generate($method);
|
||||
$outputSchema = $this->schemaGenerator->generateOutputSchema($method);
|
||||
$tool = new Tool(
|
||||
$name,
|
||||
$inputSchema,
|
||||
|
|
@ -229,6 +230,7 @@ class Discoverer
|
|||
$instance->annotations,
|
||||
$instance->icons,
|
||||
$instance->meta,
|
||||
$outputSchema,
|
||||
);
|
||||
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
|
||||
++$discoveredCount['tools'];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
namespace Mcp\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Capability\Attribute\Schema;
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Server\RequestContext;
|
||||
|
|
@ -81,6 +82,27 @@ class SchemaGenerator
|
|||
return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a JSON Schema object (as a PHP array) for a method's or function's return type.
|
||||
*
|
||||
* Only returns an outputSchema if explicitly provided in the McpTool attribute.
|
||||
* Per MCP spec, outputSchema should only be present when explicitly provided.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
|
||||
{
|
||||
// Only return outputSchema if explicitly provided in McpTool attribute
|
||||
$mcpToolAttrs = $reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF);
|
||||
if ($mcpToolAttrs) {
|
||||
$mcpToolInstance = $mcpToolAttrs[0]->newInstance();
|
||||
|
||||
return $mcpToolInstance->outputSchema;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts method-level or function-level Schema attribute.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ final class ArrayLoader implements LoaderInterface
|
|||
* description: ?string,
|
||||
* annotations: ?ToolAnnotations,
|
||||
* icons: ?Icon[],
|
||||
* meta: ?array<string, mixed>
|
||||
* meta: ?array<string, mixed>,
|
||||
* outputSchema: ?array<string, mixed>
|
||||
* }[] $tools
|
||||
* @param array{
|
||||
* handler: Handler,
|
||||
|
|
@ -117,6 +118,7 @@ final class ArrayLoader implements LoaderInterface
|
|||
annotations: $data['annotations'] ?? null,
|
||||
icons: $data['icons'] ?? null,
|
||||
meta: $data['meta'] ?? null,
|
||||
outputSchema: $data['outputSchema'] ?? null,
|
||||
);
|
||||
$registry->registerTool($tool, $data['handler'], true);
|
||||
|
||||
|
|
|
|||
|
|
@ -111,4 +111,33 @@ class ToolReference extends ElementReference
|
|||
|
||||
return [new TextContent($jsonResult)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts structured content from a tool result using the output schema.
|
||||
*
|
||||
* @param mixed $toolExecutionResult the raw value returned by the tool's PHP method
|
||||
*
|
||||
* @return array<string, mixed>|null the structured content, or null if not extractable
|
||||
*
|
||||
* @throws \JsonException if JSON encoding fails for non-Content array/object results
|
||||
*/
|
||||
public function extractStructuredContent(mixed $toolExecutionResult): ?array
|
||||
{
|
||||
if (\is_array($toolExecutionResult)) {
|
||||
return $toolExecutionResult;
|
||||
}
|
||||
|
||||
if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) {
|
||||
$jsonResult = json_encode(
|
||||
$toolExecutionResult,
|
||||
\JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE
|
||||
);
|
||||
|
||||
return json_decode(
|
||||
$jsonResult, true, 512, \JSON_THROW_ON_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ class CallToolResult implements ResultInterface
|
|||
* content: array<mixed>,
|
||||
* isError?: bool,
|
||||
* _meta?: array<string, mixed>,
|
||||
* structuredContent?: array<string, mixed>
|
||||
* } $data
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
|
|
|
|||
|
|
@ -24,13 +24,21 @@ use Mcp\Exception\InvalidArgumentException;
|
|||
* properties: array<string, mixed>,
|
||||
* required: string[]|null
|
||||
* }
|
||||
* @phpstan-type ToolOutputSchema array{
|
||||
* type: 'object',
|
||||
* properties?: array<string, mixed>,
|
||||
* required?: string[]|null,
|
||||
* additionalProperties?: bool|array<string, mixed>,
|
||||
* description?: string
|
||||
* }
|
||||
* @phpstan-type ToolData array{
|
||||
* name: string,
|
||||
* inputSchema: ToolInputSchema,
|
||||
* description?: string|null,
|
||||
* annotations?: ToolAnnotationsData,
|
||||
* icons?: IconData[],
|
||||
* _meta?: array<string, mixed>
|
||||
* _meta?: array<string, mixed>,
|
||||
* outputSchema?: ToolOutputSchema
|
||||
* }
|
||||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
|
|
@ -38,14 +46,15 @@ use Mcp\Exception\InvalidArgumentException;
|
|||
class Tool implements \JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param string $name the name of the tool
|
||||
* @param ?string $description A human-readable description of the tool.
|
||||
* This can be used by clients to improve the LLM's understanding of
|
||||
* available tools. It can be thought of like a "hint" to the model.
|
||||
* @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool
|
||||
* @param ?ToolAnnotations $annotations optional additional tool information
|
||||
* @param ?Icon[] $icons optional icons representing the tool
|
||||
* @param ?array<string, mixed> $meta Optional metadata
|
||||
* @param string $name the name of the tool
|
||||
* @param ?string $description A human-readable description of the tool.
|
||||
* This can be used by clients to improve the LLM's understanding of
|
||||
* available tools. It can be thought of like a "hint" to the model.
|
||||
* @param ToolInputSchema $inputSchema a JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool
|
||||
* @param ?ToolAnnotations $annotations optional additional tool information
|
||||
* @param ?Icon[] $icons optional icons representing the tool
|
||||
* @param ?array<string, mixed> $meta Optional metadata
|
||||
* @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
|
|
@ -54,6 +63,7 @@ class Tool implements \JsonSerializable
|
|||
public readonly ?ToolAnnotations $annotations,
|
||||
public readonly ?array $icons = null,
|
||||
public readonly ?array $meta = null,
|
||||
public readonly ?array $outputSchema = null,
|
||||
) {
|
||||
if (!isset($inputSchema['type']) || 'object' !== $inputSchema['type']) {
|
||||
throw new InvalidArgumentException('Tool inputSchema must be a JSON Schema of type "object".');
|
||||
|
|
@ -78,13 +88,23 @@ class Tool implements \JsonSerializable
|
|||
$data['inputSchema']['properties'] = new \stdClass();
|
||||
}
|
||||
|
||||
if (isset($data['outputSchema']) && \is_array($data['outputSchema'])) {
|
||||
if (!isset($data['outputSchema']['type']) || 'object' !== $data['outputSchema']['type']) {
|
||||
throw new InvalidArgumentException('Tool outputSchema must be of type "object".');
|
||||
}
|
||||
if (isset($data['outputSchema']['properties']) && \is_array($data['outputSchema']['properties']) && empty($data['outputSchema']['properties'])) {
|
||||
$data['outputSchema']['properties'] = new \stdClass();
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
$data['name'],
|
||||
$data['inputSchema'],
|
||||
isset($data['description']) && \is_string($data['description']) ? $data['description'] : null,
|
||||
isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null,
|
||||
isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null,
|
||||
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null
|
||||
isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null,
|
||||
isset($data['outputSchema']) && \is_array($data['outputSchema']) ? $data['outputSchema'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +115,8 @@ class Tool implements \JsonSerializable
|
|||
* description?: string,
|
||||
* annotations?: ToolAnnotations,
|
||||
* icons?: Icon[],
|
||||
* _meta?: array<string, mixed>
|
||||
* _meta?: array<string, mixed>,
|
||||
* outputSchema?: ToolOutputSchema
|
||||
* }
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
|
|
@ -116,6 +137,9 @@ class Tool implements \JsonSerializable
|
|||
if (null !== $this->meta) {
|
||||
$data['_meta'] = $this->meta;
|
||||
}
|
||||
if (null !== $this->outputSchema) {
|
||||
$data['outputSchema'] = $this->outputSchema;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ final class Builder
|
|||
* description: ?string,
|
||||
* annotations: ?ToolAnnotations,
|
||||
* icons: ?Icon[],
|
||||
* meta: ?array<string, mixed>
|
||||
* meta: ?array<string, mixed>,
|
||||
* outputSchema: ?array<string, mixed>,
|
||||
* }[]
|
||||
*/
|
||||
private array $tools = [];
|
||||
|
|
@ -330,6 +331,7 @@ final class Builder
|
|||
* @param array<string, mixed>|null $inputSchema
|
||||
* @param ?Icon[] $icons
|
||||
* @param array<string, mixed>|null $meta
|
||||
* @param array<string, mixed>|null $outputSchema
|
||||
*/
|
||||
public function addTool(
|
||||
callable|array|string $handler,
|
||||
|
|
@ -339,6 +341,7 @@ final class Builder
|
|||
?array $inputSchema = null,
|
||||
?array $icons = null,
|
||||
?array $meta = null,
|
||||
?array $outputSchema = null,
|
||||
): self {
|
||||
$this->tools[] = compact(
|
||||
'handler',
|
||||
|
|
@ -348,6 +351,7 @@ final class Builder
|
|||
'inputSchema',
|
||||
'icons',
|
||||
'meta',
|
||||
'outputSchema',
|
||||
);
|
||||
|
||||
return $this;
|
||||
|
|
|
|||
|
|
@ -65,13 +65,16 @@ final class CallToolHandler implements RequestHandlerInterface
|
|||
|
||||
$result = $this->referenceHandler->handle($reference, $arguments);
|
||||
|
||||
$structuredContent = null;
|
||||
if (!$result instanceof CallToolResult) {
|
||||
$result = new CallToolResult($reference->formatResult($result));
|
||||
$structuredContent = $reference->extractStructuredContent($result);
|
||||
$result = new CallToolResult($reference->formatResult($result), structuredContent: $structuredContent);
|
||||
}
|
||||
|
||||
$this->logger->debug('Tool executed successfully', [
|
||||
'name' => $toolName,
|
||||
'result_type' => \gettype($result),
|
||||
'structured_content' => $structuredContent,
|
||||
]);
|
||||
|
||||
return new Response($request->getId(), $result);
|
||||
|
|
|
|||
Loading…
Reference in a new issue