Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
4f9df3d
feat: Add output schema support to MCP tools
bigdevlarry Oct 9, 2025
e2243bc
[Server] Remove dead resource template handler code (#102)
chr-hertel Oct 10, 2025
2dbd411
[Server] Simplify server entrypoint with run-based transport lifecycl…
CodeWithKyrian Oct 10, 2025
709a815
Add possibility to set custom ServerCapabilities. (#95)
ineersa Oct 11, 2025
4486776
[Server] Remove redundant Builder.setServerCapabilities method (#107)
Adebayo120 Oct 12, 2025
9ff6516
Add happy cases for functional tests of example 1 (#91)
chr-hertel Oct 12, 2025
54a5d14
[Server] Refactor protocol pipeline to separate request and notificat…
CodeWithKyrian Oct 12, 2025
6100ffc
Missing Mcp-Protocol-Version to access control (#110)
soyuka Oct 20, 2025
d347c84
StructuredContent call tool result, added ability to return error fro…
ineersa Oct 24, 2025
e4a82f7
refactor: registry loader (#111)
soyuka Oct 24, 2025
e5a4cb5
Actually use cache across example runs (#116)
chr-hertel Oct 26, 2025
0963d24
feat: add PSR-17 factory auto-discovery to HTTP transport (#119)
CodeWithKyrian Oct 26, 2025
f1b471a
[Server] Add Bidirectional Client Communication Support (#109)
CodeWithKyrian Oct 26, 2025
42b5261
fix: because the cached discoveryState does not use ->setDiscoverySta…
okxaas Oct 26, 2025
48a19b9
[Server] Make CORS headers configurable in StreamableHttpTransport (#…
CodeWithKyrian Oct 26, 2025
7190bee
Add more inspector tests for functionality tests of STDIO examples (#…
chr-hertel Oct 27, 2025
f41ea86
feat(session): implement PSR-16 cache based session storage (#114)
lvluoyue Oct 27, 2025
55f8608
fix: extract and pass URI template variables to resource handlers (#123)
CodeWithKyrian Oct 27, 2025
8885d29
fix(server): add completion handler to builder (#126)
chr-hertel Oct 30, 2025
ca18caf
[Server] Fix: standardize error handling across handlers with MCP spe…
CodeWithKyrian Oct 30, 2025
757b959
[Server] Feat: Add comprehensive HTTP inspector tests with improved f…
CodeWithKyrian Oct 30, 2025
67cc641
fix(server): consistent arg name in session store impl (#127)
chr-hertel Oct 31, 2025
fb3c1e6
Enable servers to send sampling messages to clients (#101)
chr-hertel Nov 1, 2025
ff26b4f
refactor: transport agnostic examples (#131)
chr-hertel Nov 2, 2025
9be9e38
ability to append loaders to server builder (#132)
soyuka Nov 4, 2025
85549cb
session save return type (#133)
soyuka Nov 4, 2025
09596c0
Ability to set custom protocol version (#117)
ineersa Nov 7, 2025
d3a0791
Roll out meta properties in attributes and more schema classes (#129)
kaipiyann Nov 8, 2025
ed16e6b
[Server] Remove dead resource template handler code (#102)
chr-hertel Oct 10, 2025
654a111
[Server] Simplify server entrypoint with run-based transport lifecycl…
CodeWithKyrian Oct 10, 2025
49a3aea
Add possibility to set custom ServerCapabilities. (#95)
ineersa Oct 11, 2025
dad0f50
[Server] Remove redundant Builder.setServerCapabilities method (#107)
Adebayo120 Oct 12, 2025
fe64e80
Add happy cases for functional tests of example 1 (#91)
chr-hertel Oct 12, 2025
a8612f6
[Server] Refactor protocol pipeline to separate request and notificat…
CodeWithKyrian Oct 12, 2025
8f825f3
Missing Mcp-Protocol-Version to access control (#110)
soyuka Oct 20, 2025
e148515
Address DocBlockParser comments
bigdevlarry Oct 20, 2025
660f150
Rename $_meta properties to $meta (#134)
chr-hertel Nov 9, 2025
5804ba9
Add support for icons and website url (#141)
chr-hertel Nov 9, 2025
f08705a
Address DocBlockParser comments
bigdevlarry Nov 10, 2025
773c3a2
resolved conflicts
bigdevlarry Nov 10, 2025
8779b6d
Address DocBlockParser comments
bigdevlarry Oct 20, 2025
7fa4aec
Address DocBlockParser comments
bigdevlarry Nov 10, 2025
e6a3fe1
feat: Add output schema support to MCP tools
bigdevlarry Nov 10, 2025
c526b4f
resolved conflict
bigdevlarry Nov 10, 2025
2060906
feat: Add output schema support to MCP tools
bigdevlarry Nov 10, 2025
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 169 to 187
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 83 to 113
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