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
30 changes: 29 additions & 1 deletion examples/stdio-env-variables/EnvToolHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,35 @@ 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 (only in debug mode)',
],
'processed_input_length' => [
'type' => 'integer',
'description' => 'The length of the processed input (only in production mode)',
],
'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
8 changes: 5 additions & 3 deletions src/Capability/Attribute/McpTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@
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 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 array<string, mixed>|null $outputSchema Optional JSON Schema object (as a PHP array) defining the expected output structure
*/
public function __construct(
public ?string $name = null,
public ?string $description = null,
public ?ToolAnnotations $annotations = null,
public ?array $outputSchema = null,
) {
}
}
3 changes: 2 additions & 1 deletion src/Capability/Discovery/Discoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ 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);
$tool = new Tool($name, $inputSchema, $description, $instance->annotations);
$outputSchema = $instance->outputSchema ?? $this->schemaGenerator->generateOutputSchema($method);
$tool = new Tool($name, $inputSchema, $description, $instance->annotations, $outputSchema);
$tools[$name] = new ToolReference($tool, [$className, $methodName], false);
++$discoveredCount['tools'];
break;
Expand Down
47 changes: 47 additions & 0 deletions src/Capability/Discovery/DocBlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,51 @@ public function getParamTypeString(?Param $paramTag): ?string

return null;
}

/**
* Gets the return type string from a Return tag.
*/
public function getReturnTypeString(?DocBlock $docBlock): ?string
{
if (!$docBlock) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always like to be explicit

Suggested change
if (!$docBlock) {
if (null === $docBlock) {

return null;
}

$returnTags = $docBlock->getTagsByName('return');
if (empty($returnTags)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and try to avoid empty:

Suggested change
if (empty($returnTags)) {
if ([] === $returnTags) {

return null;
}

$returnTag = $returnTags[0];
if (method_exists($returnTag, 'getType') && $returnTag->getType()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we check for TagWithType instead of using method_exists?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and should be inverted to:

if (!$returnTag instanceof TagWithType) {
    return null;
}

it's better to continue with the style of early exits - like you did in the beginning of the method

$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 (!$docBlock) {
return null;
}

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

$returnTag = $returnTags[0];
$description = method_exists($returnTag, 'getDescription')
? trim((string) $returnTag->getDescription())
: '';

return $description ?: null;
}
Comment on lines +168 to +185
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar comments would apply to this method like with getReturnTypeString

}
62 changes: 62 additions & 0 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,36 @@ 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.
*
* @return array<string, mixed>|null
*/
public function generateOutputSchema(\ReflectionMethod|\ReflectionFunction $reflection): ?array
{
$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);
}

Comment on lines +82 to +111
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add tests for this method

/**
* Extracts method-level or function-level Schema attribute.
*
Expand Down Expand Up @@ -784,4 +814,36 @@ 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' => ['type' => 'mixed'],
];
} else {
// Handle other types - wrap in object for MCP compatibility
$mappedType = $this->mapSimpleTypeToJsonSchema($returnTypeString);
$schema = [
'type' => 'object',
'properties' => [
'result' => ['type' => $mappedType],
],
'required' => ['result'],
];
}

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

return $schema;
}
}
40 changes: 33 additions & 7 deletions src/Schema/Tool.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,34 +23,46 @@
* properties: array<string, mixed>,
* required: string[]|null
* }
* @phpstan-type ToolOutputSchema array{
* type: 'object',
* properties: array<string, mixed>,
* required: string[]|null
* }
* @phpstan-type ToolData array{
* name: string,
* inputSchema: ToolInputSchema,
* description?: string|null,
* annotations?: ToolAnnotationsData,
* outputSchema?: ToolOutputSchema,
* }
*
* @author Kyrian Obikwelu <[email protected]>
*/
class Tool implements \JsonSerializable
{
/**
* @param string $name the name of the tool
* @param string|null $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|null $annotations optional additional tool information
* @param string $name the name of the tool
* @param string|null $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|null $annotations optional additional tool information
* @param ToolOutputSchema|null $outputSchema optional JSON Schema object (as a PHP array) defining the expected output structure
*/
public function __construct(
public readonly string $name,
public readonly array $inputSchema,
public readonly ?string $description,
public readonly ?ToolAnnotations $annotations,
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".');
}

if (null !== $outputSchema && (!isset($outputSchema['type']) || 'object' !== $outputSchema['type'])) {
throw new InvalidArgumentException('Tool outputSchema must be a JSON Schema of type "object" or null.');
}
}

/**
Expand All @@ -71,11 +83,21 @@ public static function fromArray(array $data): self
$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['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null,
$data['outputSchema']
);
}

Expand All @@ -85,6 +107,7 @@ public static function fromArray(array $data): self
* inputSchema: ToolInputSchema,
* description?: string,
* annotations?: ToolAnnotations,
* outputSchema?: ToolOutputSchema,
* }
*/
public function jsonSerialize(): array
Expand All @@ -99,6 +122,9 @@ public function jsonSerialize(): array
if (null !== $this->annotations) {
$data['annotations'] = $this->annotations;
}
if (null !== $this->outputSchema) {
$data['outputSchema'] = $this->outputSchema;
}

return $data;
}
Expand Down
7 changes: 5 additions & 2 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,15 +269,17 @@ public function setDiscovery(
*
* @param Handler $handler
* @param array<string, mixed>|null $inputSchema
* @param array<string, mixed>|null $outputSchema
*/
public function addTool(
callable|array|string $handler,
?string $name = null,
?string $description = null,
?ToolAnnotations $annotations = null,
?array $inputSchema = null,
?array $outputSchema = null,
): self {
$this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema');
$this->tools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema', 'outputSchema');

return $this;
}
Expand Down Expand Up @@ -433,8 +435,9 @@ private function registerCapabilities(
}

$inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection);
$outputSchema = $data['outputSchema'] ?? $schemaGenerator->generateOutputSchema($reflection);

$tool = new Tool($name, $inputSchema, $description, $data['annotations']);
$tool = new Tool($name, $inputSchema, $description, $data['annotations'], $outputSchema);
$registry->registerTool($tool, $data['handler'], true);

$handlerDesc = $this->getHandlerDescription($data['handler']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@
"required": [
"text"
]
},
"outputSchema": {
"type": "object",
"properties": {
"result": {
"type": "string"
}
},
"required": [
"result"
],
"description": "the echoed text"
}
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
"b",
"operation"
]
},
"outputSchema": {
"type": "object",
"properties": {
"result": {
"type": "object"
}
},
"required": [
"result"
],
"description": "the result of the calculation, or an error message string"
}
},
{
Expand All @@ -44,6 +56,13 @@
"setting",
"value"
]
},
"outputSchema": {
"type": "object",
"additionalProperties": {
"type": "mixed"
},
"description": "success message or error"
}
}
]
Expand Down
Loading