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
11 changes: 4 additions & 7 deletions examples/server/conformance/Elements.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@

namespace Mcp\Example\Server\Conformance;

use Mcp\Schema\Content\Content;
use Mcp\Schema\Content\EmbeddedResource;
use Mcp\Schema\Content\ImageContent;
use Mcp\Schema\Content\PromptMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Content\TextResourceContents;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Server\Protocol;
use Mcp\Server\RequestContext;

Expand All @@ -28,20 +28,17 @@ final class Elements
// Sample base64 encoded minimal WAV file for testing
public const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=';

/**
* @return Content[]
*/
public function toolMultipleTypes(): array
public function toolMultipleTypes(): CallToolResult
{
return [
return new CallToolResult([
new TextContent('Multiple content types test:'),
new ImageContent(self::TEST_IMAGE_BASE64, 'image/png'),
EmbeddedResource::fromText(
'test://mixed-content-resource',
'{ "test" = "data", "value" = 123 }',
'application/json',
),
];
]);
}

public function toolWithLogging(RequestContext $context): string
Expand Down
26 changes: 25 additions & 1 deletion examples/server/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
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\Exception\InvalidArgumentException;
use Mcp\Server\RequestContext;
Expand Down Expand Up @@ -81,6 +82,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
29 changes: 29 additions & 0 deletions src/Capability/Registry/ToolReference.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,33 @@ 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
*
* @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;
}
}
1 change: 1 addition & 0 deletions src/Schema/Result/CallToolResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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',
* 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();
}
}

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
5 changes: 4 additions & 1 deletion src/Server/Handler/Request/CallToolHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,16 @@ public function handle(Request $request, SessionInterface $session): Response|Er

$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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,18 @@
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Project Deadline\\\" scheduled successfully for \\\"2024-12-15\\\".\",\n \"event_details\": {\n \"title\": \"Project Deadline\",\n \"date\": \"2024-12-15\",\n \"type\": \"reminder\",\n \"time\": \"All day\",\n \"priority\": \"Normal\",\n \"attendees\": [],\n \"invites_will_be_sent\": false\n }\n}"
}
],
"structuredContent": {
"success": true,
"message": "Event \"Project Deadline\" scheduled successfully for \"2024-12-15\".",
"event_details": {
"title": "Project Deadline",
"date": "2024-12-15",
"type": "reminder",
"time": "All day",
"priority": "Normal",
"attendees": [],
"invites_will_be_sent": false
}
},
"isError": false
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,20 @@
"text": "{\n \"success\": true,\n \"message\": \"Event \\\"Client Call\\\" scheduled successfully for \\\"2024-12-02\\\".\",\n \"event_details\": {\n \"title\": \"Client Call\",\n \"date\": \"2024-12-02\",\n \"type\": \"call\",\n \"time\": \"14:30\",\n \"priority\": \"Normal\",\n \"attendees\": [\n \"[email protected]\"\n ],\n \"invites_will_be_sent\": false\n }\n}"
}
],
"structuredContent": {
"success": true,
"message": "Event \"Client Call\" scheduled successfully for \"2024-12-02\".",
"event_details": {
"title": "Client Call",
"date": "2024-12-02",
"type": "call",
"time": "14:30",
"priority": "Normal",
"attendees": [
"[email protected]"
],
"invites_will_be_sent": false
}
},
"isError": false
}
Loading