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
6 changes: 6 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ parameters:
identifier: return.type
count: 1
path: src/Schema/Result/ReadResourceResult.php

-
message: '#^Method Mcp\\Tests\\Unit\\Capability\\Discovery\\DocBlockTestFixture\:\:methodWithMultipleTags\(\) has RuntimeException in PHPDoc @throws tag but it''s not thrown\.$#'
identifier: throws.unusedType
count: 1
path: tests/Unit/Capability/Discovery/DocBlockTestFixture.php
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
23 changes: 23 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,28 @@ 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.
*
* 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 (!empty($mcpToolAttrs)) {
$mcpToolInstance = $mcpToolAttrs[0]->newInstance();
if (null !== $mcpToolInstance->outputSchema) {
return $mcpToolInstance->outputSchema;
}
}

return null;
}

/**
* Extracts method-level or function-level Schema attribute.
*
Expand Down
44 changes: 44 additions & 0 deletions src/Capability/Registry/ToolReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,48 @@ 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 (\is_array($toolExecutionResult)) {
if (array_is_list($toolExecutionResult) && isset($outputSchema['additionalProperties'])) {
// Wrap list in "object" schema for additionalProperties
return ['items' => $toolExecutionResult];
}

return $toolExecutionResult;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't we leave that to implementations of the handler? I'm not sure we should alter the content at any point in the sdk.


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;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure to understand this already happens in formatResult if our goal is to return a structured content maybe that we should add a condition above?

}
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);
Copy link
Contributor

Choose a reason for hiding this comment

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

not sure to why this is needed content can be a structured content since #93 or am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Missed that PR. We can use the existing structure. Thanks for flagging

}

/**
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
46 changes: 35 additions & 11 deletions src/Schema/Tool.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,37 @@
* properties: array<string, mixed>,
* required: string[]|null
* }
* @phpstan-type ToolOutputSchema array{
* type: 'object',
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* type: 'object',
* type: 'object',

Can't the schema be a list? object type is not specified in the spec for the output.

* 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 <[email protected]>
*/
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,
Expand All @@ -54,6 +63,7 @@ public function __construct(
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".');
Expand All @@ -78,13 +88,23 @@ 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();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I advise against validating this here we should leave that to the user discretion, it'll make the sdk more portable and subject to specification changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was following the validation pattern we've for the inputSchema setup above. Also, if we don't add validation for outputSchema, how can we have a structured output format? Users might use a structure we don't support, don't you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I'm actually not a huge fan of the above inputSchema implementation either :).

I think it's the responsability of another abstraction to do this, and its also part of a bigger discussion around json schema and validation (I suggested already that these should not be coded into the php-sdk as they're quite complicated subjects).


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,
);
}

Expand All @@ -95,7 +115,8 @@ public static function fromArray(array $data): self
* description?: string,
* annotations?: ToolAnnotations,
* icons?: Icon[],
* _meta?: array<string, mixed>
* _meta?: array<string, mixed>,
* outputSchema?: ToolOutputSchema
* }
*/
public function jsonSerialize(): array
Expand All @@ -116,6 +137,9 @@ public function jsonSerialize(): array
if (null !== $this->meta) {
$data['_meta'] = $this->meta;
}
if (null !== $this->outputSchema) {
$data['outputSchema'] = $this->outputSchema;
}

return $data;
}
Expand Down
6 changes: 5 additions & 1 deletion src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ final class Builder
* description: ?string,
* annotations: ?ToolAnnotations,
* icons: ?Icon[],
* meta: ?array<string, mixed>
* meta: ?array<string, mixed>,
* output: ?array<string, mixed>,
* }[]
*/
private array $tools = [];
Expand Down Expand Up @@ -330,6 +331,7 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self
* @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,
Expand All @@ -339,6 +341,7 @@ public function addTool(
?array $inputSchema = null,
?array $icons = null,
?array $meta = null,
?array $outputSchema = null,
): self {
$this->tools[] = compact(
'handler',
Expand All @@ -348,6 +351,7 @@ public function addTool(
'inputSchema',
'icons',
'meta',
'outputSchema',
);

return $this;
Expand Down
13 changes: 10 additions & 3 deletions src/Server/Handler/Request/CallToolHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,22 @@ public function handle(Request $request, SessionInterface $session): Response|Er

$arguments['_session'] = $session;

$result = $this->referenceHandler->handle($reference, $arguments);
$rawResult = $this->referenceHandler->handle($reference, $arguments);

if (!$result instanceof CallToolResult) {
$result = new CallToolResult($reference->formatResult($result));
$structuredContent = null;
if (null !== $reference->tool->outputSchema && !$rawResult instanceof CallToolResult) {
$structuredContent = $reference->extractStructuredContent($rawResult);
}

$result = $rawResult;
if (!$rawResult instanceof CallToolResult) {
$result = new CallToolResult($reference->formatResult($rawResult), structuredContent: $structuredContent);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd try not to change this part of the code, I understand we want to comply with the spec that says:

Servers MUST provide structured results that conform to this schema.

If possible I think it should be handled in the formatResult.

My personal opinion would be to throw if the handler doesn't return a structured content.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, we're trying to comply with what the spec says here. I also think it reads better having it here otherwise, @chr-hertel feels otherwise

}

$this->logger->debug('Tool executed successfully', [
'name' => $toolName,
'result_type' => \gettype($result),
'structured_content' => $structuredContent,
]);

return new Response($request->getId(), $result);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"content": [
{
"type": "text",
"text": "{\n \"result\": 50,\n \"operation\": \"10 multiply 5\",\n \"precision\": 2,\n \"within_bounds\": true\n}"
}
],
"isError": false
"content": [
{
"type": "text",
"text": "{\n \"result\": 50,\n \"operation\": \"10 multiply 5\",\n \"precision\": 2,\n \"within_bounds\": true\n}"
}
],
"isError": false
Copy link
Contributor

Choose a reason for hiding this comment

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

this should probably not change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just indentation, no code change

Copy link
Contributor

Choose a reason for hiding this comment

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

I know but why as we didn't change anything to the json serialization this is weird to me and pollutes the diff

}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"content": [
{
"type": "text",
"text": "{\n \"original\": \"Hello World Test\",\n \"formatted\": \"HELLO WORLD TEST\",\n \"length\": 16,\n \"format_applied\": \"uppercase\"\n}"
}
],
"isError": false
"content": [
{
"type": "text",
"text": "{\n \"original\": \"Hello World Test\",\n \"formatted\": \"HELLO WORLD TEST\",\n \"length\": 16,\n \"format_applied\": \"uppercase\"\n}"
}
],
"isError": false
}
Copy link
Contributor

Choose a reason for hiding this comment

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

this should probably not change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just indentation, no code change

Loading
Loading