Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion examples/env-variables/EnvToolHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,31 @@ final class EnvToolHandler
*
* @return array<string, string|int> the result, varying by APP_MODE
*/
#[McpTool(name: 'process_data_by_mode')]
#[McpTool(
name: 'process_data_by_mode',
outputSchema: [
'type' => 'object',
'properties' => [
'mode' => [
'type' => 'string',
'description' => 'The processing mode used',
],
'processed_input' => [
'type' => 'string',
'description' => 'The processed input data',
],
'original_input' => [
'type' => 'string',
'description' => 'The original input data (only in default mode)',
],
'message' => [
'type' => 'string',
'description' => 'A descriptive message about the processing',
],
],
'required' => ['mode', 'message'],
]
)]
public function processData(string $input): array
{
$appMode = getenv('APP_MODE'); // Read from environment
Expand Down
12 changes: 7 additions & 5 deletions src/Capability/Attribute/McpTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@
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,
public ?string $description = null,
public ?ToolAnnotations $annotations = null,
public ?array $icons = null,
public ?array $meta = null,
public ?array $outputSchema = null,
) {
}
}
2 changes: 2 additions & 0 deletions src/Capability/Discovery/Discoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,15 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
$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,
$description,
$instance->annotations,
$instance->icons,
$instance->meta,
$outputSchema,
);
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
++$discoveredCount['tools'];
Expand Down
50 changes: 50 additions & 0 deletions src/Capability/Discovery/DocBlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\DocBlock\Tags\TagWithType;
use phpDocumentor\Reflection\DocBlockFactory;
use phpDocumentor\Reflection\DocBlockFactoryInterface;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -136,4 +137,53 @@ public function getParamTypeString(?Param $paramTag): ?string

return null;
}

/**
* Gets the return type string from a Return tag.
*/
public function getReturnTypeString(?DocBlock $docBlock): ?string
{
if (null === $docBlock) {
return null;
}

$returnTags = $docBlock->getTagsByName('return');
if ([] === $returnTags) {
return null;
}

$returnTag = $returnTags[0];
if (!$returnTag instanceof TagWithType) {
return null;
}

$typeFromTag = trim((string) $returnTag->getType());
if (!empty($typeFromTag)) {
return ltrim($typeFromTag, '\\');
}

return null;
}

/**
* Gets the return type description from a Return tag.
*/
public function getReturnDescription(?DocBlock $docBlock): ?string
{
if (null === $docBlock) {
return null;
}

$returnTags = $docBlock->getTagsByName('return');
if ([] === $returnTags) {
return null;
}

$returnTag = $returnTags[0];
if (!$returnTag instanceof TagWithType) {
return null;
}

return trim((string) $returnTag->getDescription()) ?: null;
}
}
80 changes: 80 additions & 0 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Mcp\Capability\Discovery;

use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Server\ClientGateway;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
Expand Down Expand Up @@ -80,6 +81,47 @@ public function generate(\ReflectionMethod|\ReflectionFunction $reflection): arr
return $this->buildSchemaFromParameters($parametersInfo, $methodSchema);
}

/**
* Generates a JSON Schema object (as a PHP array) for a method's or function's return type.
*
* Checks for explicit outputSchema in McpTool attribute first, then auto-generates from return type.
*
* @return array<string, mixed>|null
*/
public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
{
// Check if McpTool attribute has explicit outputSchema
$mcpToolAttrs = $reflection->getAttributes(McpTool::class, \ReflectionAttribute::IS_INSTANCEOF);
if (!empty($mcpToolAttrs)) {
$mcpToolInstance = $mcpToolAttrs[0]->newInstance();
if (null !== $mcpToolInstance->outputSchema) {
return $mcpToolInstance->outputSchema;
}
}

$docComment = $reflection->getDocComment() ?: null;
$docBlock = $this->docBlockParser->parseDocBlock($docComment);

$docBlockReturnType = $this->docBlockParser->getReturnTypeString($docBlock);
$returnDescription = $this->docBlockParser->getReturnDescription($docBlock);

$reflectionReturnType = $reflection->getReturnType();
$reflectionReturnTypeString = $reflectionReturnType
? $this->getTypeStringFromReflection($reflectionReturnType, $reflectionReturnType->allowsNull())
: null;

// Use DocBlock with generics, otherwise reflection, otherwise DocBlock
$returnTypeString = ($docBlockReturnType && str_contains($docBlockReturnType, '<'))
? $docBlockReturnType
: ($reflectionReturnTypeString ?: $docBlockReturnType);

if (!$returnTypeString || 'void' === strtolower($returnTypeString)) {
return null;
}

return $this->buildOutputSchemaFromType($returnTypeString, $returnDescription);
}

/**
* Extracts method-level or function-level Schema attribute.
*
Expand Down Expand Up @@ -794,4 +836,42 @@ private function mapSimpleTypeToJsonSchema(string $type): string
default => \in_array(strtolower($type), ['datetime', 'datetimeinterface']) ? 'string' : 'object',
};
}

/**
* Builds an output schema from a return type string.
*
* @return array<string, mixed>
*/
private function buildOutputSchemaFromType(string $returnTypeString, ?string $description): array
{
// Handle array types - treat as object with additionalProperties
if (str_contains($returnTypeString, 'array')) {
$schema = [
'type' => 'object',
'additionalProperties' => true,
];
} else {
// Use mapPhpTypeToJsonSchemaType to handle union types and nullable types
$mappedTypes = $this->mapPhpTypeToJsonSchemaType($returnTypeString);

$nonNullTypes = array_filter($mappedTypes, fn ($type) => 'null' !== $type);

// If it's a union type use the array directly, or use the first (and only) type
$typeSchema = \count($nonNullTypes) > 1 ? array_values($nonNullTypes) : ($nonNullTypes[0] ?? 'object');

$schema = [
'type' => 'object',
'properties' => [
'result' => ['type' => $typeSchema],
],
'required' => ['result'],
];
}

if ($description) {
$schema['description'] = $description;
}

return $schema;
}
}
62 changes: 62 additions & 0 deletions src/Capability/Registry/ToolReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,66 @@ public function formatResult(mixed $toolExecutionResult): array

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
*/
public function extractStructuredContent(mixed $toolExecutionResult): ?array
{
$outputSchema = $this->tool->outputSchema;
if (null === $outputSchema) {
return null;
}

// If outputSchema has properties.result, wrap in result key
if (isset($outputSchema['properties']['result'])) {
return ['result' => $this->normalizeValue($toolExecutionResult)];
}

if (isset($outputSchema['additionalProperties'])) {
if (\is_array($toolExecutionResult)) {
// Check if it's a numeric array (list)
if (array_is_list($toolExecutionResult)) {
// Wrap list in "object" schema
return ['items' => $toolExecutionResult];
}

return $toolExecutionResult;
}

if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) {
return $this->normalizeValue($toolExecutionResult);
}
}

if (isset($outputSchema['properties'])) {
if (\is_array($toolExecutionResult)) {
return $toolExecutionResult;
}

if (\is_object($toolExecutionResult) && !($toolExecutionResult instanceof Content)) {
return $this->normalizeValue($toolExecutionResult);
}
}

return null;
}

/**
* Convert objects to arrays for a normalized structured content.
*
* @throws \JsonException if JSON encoding fails for non-Content array/object results
*/
private function normalizeValue(mixed $value): mixed
{
if (\is_object($value) && !($value instanceof Content)) {
return json_decode(json_encode($value, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
}

return $value;
}
}
10 changes: 6 additions & 4 deletions src/Schema/Result/CallToolResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,13 @@ public function __construct(
/**
* Create a new CallToolResult with success status.
*
* @param Content[] $content The content of the tool result
* @param array<string, mixed>|null $meta Optional metadata
* @param Content[] $content The content of the tool result
* @param array<string, mixed>|null $meta Optional metadata
* @param array<string, mixed>|null $structuredContent Optional structured content matching the tool's outputSchema
*/
public static function success(array $content, ?array $meta = null): self
public static function success(array $content, ?array $meta = null, ?array $structuredContent = null): self
{
return new self($content, false, null, $meta);
return new self($content, false, $meta, $structuredContent);
}

/**
Expand All @@ -83,6 +84,7 @@ public static function error(array $content, ?array $meta = null): self
* content: array<mixed>,
* isError?: bool,
* _meta?: array<string, mixed>,
* structuredContent?: array<string, mixed>
* } $data
*/
public static function fromArray(array $data): self
Expand Down
Loading