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
6 changes: 5 additions & 1 deletion examples/client/stdio_elicitation.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ public function __invoke(ElicitRequest $request): ElicitResult
{
echo "\n[ELICIT] {$request->message}\n";

if (null === $request->requestedSchema) {
return new ElicitResult(ElicitAction::Decline);
}

$content = [];
foreach ($request->requestedSchema->properties as $name => $definition) {
$default = $this->defaultFor($definition);
Expand Down Expand Up @@ -78,7 +82,7 @@ private function defaultFor(object $definition): mixed

private function labelFor(AbstractSchemaDefinition $definition): string
{
return $definition->title;
return $definition->title ?? '';
}

private function cast(object $definition, string $input): mixed
Expand Down
2 changes: 1 addition & 1 deletion examples/server/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function transport(): TransportInterface

function shutdown(ResponseInterface|int $result): never
{
if ('cli' === \PHP_SAPI) {
if (is_int($result)) {
exit($result);
}

Expand Down
7 changes: 6 additions & 1 deletion examples/server/mcp-apps/WeatherApp.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ public function getWeatherApp(): TextResourceContents
prefersBorder: true,
);

$html = file_get_contents(__DIR__.'/weather-app.html');
if (false === $html) {
throw new \RuntimeException('Could not read the weather app template.');
}

return new TextResourceContents(
uri: 'ui://weather-app',
mimeType: McpApps::MIME_TYPE,
text: file_get_contents(__DIR__.'/weather-app.html'),
text: $html,
meta: ['ui' => $contentMeta],
);
}
Expand Down
2 changes: 1 addition & 1 deletion examples/server/oauth-microsoft/McpElements.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public function listEmails(int $count = 5): array
'id' => 'msg_'.uniqid(),
'subject' => "Sample Email #{$i}",
'from' => "sender{$i}@example.com",
'receivedDateTime' => date('c', strtotime("-{$i} hours")),
'receivedDateTime' => date('c', strtotime("-{$i} hours") ?: time()),
], range(1, $count)),
];
}
Expand Down
2 changes: 1 addition & 1 deletion phpstan.dist.neon
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ includes:
- phpstan-baseline.neon

parameters:
level: 6
level: 8
paths:
- examples/
- src/
Expand Down
8 changes: 5 additions & 3 deletions src/Capability/Attribute/CompletionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
class CompletionProvider
{
/**
* @param class-string<ProviderInterface>|ProviderInterface|null $provider if a class-string, it will be resolved
* from the container at the point of use
* @param ?array<int, int|float|string> $values a list of values to use for completion
* @param class-string<ProviderInterface>|null $providerClass a provider class to resolve from the container at the point of use
* @param class-string<ProviderInterface>|ProviderInterface|null $provider if a class-string, it will be resolved
* from the container at the point of use
* @param ?array<int, int|float|string> $values a list of values to use for completion
* @param class-string|null $enum an enum class whose cases are used for completion
*/
public function __construct(
public ?string $providerClass = null,
Expand Down
8 changes: 4 additions & 4 deletions src/Capability/Attribute/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@
* minLength?: int,
* maxLength?: int,
* pattern?: string,
* minimum?: int,
* maximum?: int,
* exclusiveMinimum?: int,
* exclusiveMaximum?: int,
* minimum?: int|float,
* maximum?: int|float,
* exclusiveMinimum?: bool,
* exclusiveMaximum?: bool,
* multipleOf?: int|float,
* items?: array<string, mixed>,
* minItems?: int,
Expand Down
13 changes: 9 additions & 4 deletions src/Capability/Completion/ListCompletionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@
class ListCompletionProvider implements ProviderInterface
{
/**
* @param string[] $values
* @var string[]
*/
public function __construct(
private array $values,
) {
private array $values;

/**
* @param array<int|float|string> $values
*/
public function __construct(array $values)
{
$this->values = array_values(array_map(strval(...), $values));
}

public function getCompletions(string $currentValue): array
Expand Down
146 changes: 67 additions & 79 deletions src/Capability/Discovery/Discoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@
*/
final class Discoverer implements DiscovererInterface
{
private readonly DocBlockParser $docBlockParser;
private readonly SchemaGeneratorInterface $schemaGenerator;

public function __construct(
private readonly LoggerInterface $logger = new NullLogger(),
private ?DocBlockParser $docBlockParser = null,
private ?SchemaGeneratorInterface $schemaGenerator = null,
?DocBlockParser $docBlockParser = null,
?SchemaGeneratorInterface $schemaGenerator = null,
) {
if (!class_exists(Finder::class)) {
throw new RuntimeException('File-based discovery requires symfony/finder. Run: composer require symfony/finder');
Expand Down Expand Up @@ -223,76 +226,65 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
try {
$instance = $attribute->newInstance();

switch ($attributeClassName) {
case McpTool::class:
$name = ElementMetadataResolver::resolveName($method, $instance->name);
$description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser);
$inputSchema = $this->schemaGenerator->generate($method);
$outputSchema = $this->schemaGenerator->generateOutputSchema($method);
$tool = new Tool(
name: $name,
title: $instance->title,
inputSchema: $inputSchema,
description: $description,
annotations: $instance->annotations,
icons: $instance->icons,
meta: $instance->meta,
outputSchema: $outputSchema,
);
$tools[$name] = new ToolReference($tool, [$className, $methodName]);
++$discoveredCount['tools'];
break;

case McpResource::class:
$name = ElementMetadataResolver::resolveName($method, $instance->name);
$description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser);
$resource = new ResourceDefinition(
$instance->uri,
$name,
$instance->title,
$description,
$instance->mimeType,
$instance->annotations,
$instance->size,
$instance->icons,
$instance->meta,
);
$resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName]);

++$discoveredCount['resources'];
break;

case McpPrompt::class:
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
$name = ElementMetadataResolver::resolveName($method, $instance->name);
$description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser);
$arguments = [];
$paramTags = $this->docBlockParser->getParamTags($docBlock);
foreach ($method->getParameters() as $param) {
$reflectionType = $param->getType();
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
continue;
}
$paramTag = $paramTags['$'.$param->getName()] ?? null;
$arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable());
if ($instance instanceof McpTool) {
$name = ElementMetadataResolver::resolveName($method, $instance->name);
$description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser);
$inputSchema = $this->schemaGenerator->generate($method);
$outputSchema = $this->schemaGenerator->generateOutputSchema($method);
$tool = new Tool(
name: $name,
title: $instance->title,
inputSchema: $inputSchema,
description: $description,
annotations: $instance->annotations,
icons: $instance->icons,
meta: $instance->meta,
outputSchema: $outputSchema,
);
$tools[$name] = new ToolReference($tool, [$className, $methodName]);
++$discoveredCount['tools'];
} elseif ($instance instanceof McpResource) {
$name = ElementMetadataResolver::resolveName($method, $instance->name);
$description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser);
$resource = new ResourceDefinition(
$instance->uri,
$name,
$instance->title,
$description,
$instance->mimeType,
$instance->annotations,
$instance->size,
$instance->icons,
$instance->meta,
);
$resources[$instance->uri] = new ResourceReference($resource, [$className, $methodName]);

++$discoveredCount['resources'];
} elseif ($instance instanceof McpPrompt) {
$docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null);
$name = ElementMetadataResolver::resolveName($method, $instance->name);
$description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser);
$arguments = [];
$paramTags = $this->docBlockParser->getParamTags($docBlock);
foreach ($method->getParameters() as $param) {
$reflectionType = $param->getType();
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
continue;
}
$prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta);
$completionProviders = $this->getCompletionProviders($method);
$prompts[$name] = new PromptReference($prompt, [$className, $methodName], $completionProviders);
++$discoveredCount['prompts'];
break;

case McpResourceTemplate::class:
$name = ElementMetadataResolver::resolveName($method, $instance->name);
$description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser);
$mimeType = $instance->mimeType;
$annotations = $instance->annotations;
$meta = $instance->meta ?? null;
$resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $instance->title, $description, $mimeType, $annotations, $meta);
$completionProviders = $this->getCompletionProviders($method);
$resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders);
++$discoveredCount['resourceTemplates'];
break;
$paramTag = $paramTags['$'.$param->getName()] ?? null;
$arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable());
}
$prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta);
$completionProviders = $this->getCompletionProviders($method);
$prompts[$name] = new PromptReference($prompt, [$className, $methodName], $completionProviders);
++$discoveredCount['prompts'];
} elseif ($instance instanceof McpResourceTemplate) {
$name = ElementMetadataResolver::resolveName($method, $instance->name);
$description = ElementMetadataResolver::resolveDescription($method, $instance->description, $this->docBlockParser);
$resourceTemplate = new ResourceTemplate($instance->uriTemplate, $name, $instance->title, $description, $instance->mimeType, $instance->annotations, $instance->meta);
$completionProviders = $this->getCompletionProviders($method);
$resourceTemplates[$instance->uriTemplate] = new ResourceTemplateReference($resourceTemplate, [$className, $methodName], $completionProviders);
++$discoveredCount['resourceTemplates'];
}
} catch (ExceptionInterface $e) {
$this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", [
Expand All @@ -308,7 +300,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun
}

/**
* @return array<string, string|ProviderInterface>
* @return array<string, class-string<ProviderInterface>|ProviderInterface>
*/
private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array
{
Expand All @@ -326,7 +318,7 @@ private function getCompletionProviders(\ReflectionMethod $reflectionMethod): ar
if ($attributeInstance->provider) {
$completionProviders[$param->getName()] = $attributeInstance->provider;
} elseif ($attributeInstance->providerClass) {
$completionProviders[$param->getName()] = $attributeInstance->provider;
$completionProviders[$param->getName()] = $attributeInstance->providerClass;
} elseif ($attributeInstance->values) {
$completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values);
} elseif ($attributeInstance->enum) {
Expand Down Expand Up @@ -433,17 +425,13 @@ private function getClassFromFile(SplFileInfo $file): ?string
}

foreach ($potentialClasses as $potentialClass) {
if (class_exists($potentialClass, true)) {
if (class_exists($potentialClass, true) || interface_exists($potentialClass, true) || trait_exists($potentialClass, true)) {
return $potentialClass;
}
}

if (!empty($potentialClasses)) {
if (!class_exists($potentialClasses[0], false)) {
$this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $file->getPathname(), 'type' => $potentialClasses[0]]);
}

return $potentialClasses[0];
$this->logger->debug('getClassFromFile found no loadable type. Are you sure this class has been autoloaded?', ['file' => $file->getPathname(), 'type' => $potentialClasses[0]]);
}

return null;
Expand Down
17 changes: 7 additions & 10 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@
* enum?: array<int, int|float|string|null>,
* items?: array<string, mixed>,
* }
* @phpstan-type VariadicParameterSchema array{
* type: 'array',
* items?: array<string, mixed>,
* description?: string,
* parameter_schema?: array<string, mixed>
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/
Expand Down Expand Up @@ -328,7 +322,7 @@ private function buildInferredParameterSchema(array $paramInfo): array
*
* @param ParameterInfo $paramInfo
*
* @return VariadicParameterSchema
* @return array<string, mixed>
*/
private function buildVariadicParameterSchema(array $paramInfo): array
{
Expand Down Expand Up @@ -536,7 +530,8 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl

$paramName = $rp->getName();
if (\in_array(strtolower($paramName), ['_session', '_request'], true)) {
throw new InvalidArgumentException(\sprintf('Handler method "%s::%s" has parameter named "%s" which is not allowed. Please change the name of that parameter.', $reflection->class, $reflection->name, $paramName));
$handlerName = $reflection instanceof \ReflectionMethod ? $reflection->class.'::'.$reflection->name : $reflection->name;
throw new InvalidArgumentException(\sprintf('Handler "%s" has parameter named "%s" which is not allowed. Please change the name of that parameter.', $handlerName, $paramName));
}
$paramTag = $paramTags['$'.$paramName] ?? null;

Expand Down Expand Up @@ -693,8 +688,10 @@ private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativ
// Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string'
if (str_contains($typeString, '\\')) {
$parts = preg_split('/([|&])/', $typeString, -1, \PREG_SPLIT_DELIM_CAPTURE);
$processedParts = array_map(static fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts);
$typeString = implode('', $processedParts);
if (false !== $parts) {
$processedParts = array_map(static fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts);
$typeString = implode('', $processedParts);
}
}

return $typeString ?: 'mixed';
Expand Down
10 changes: 5 additions & 5 deletions src/Capability/Discovery/SchemaGeneratorInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@

namespace Mcp\Capability\Discovery;

use Mcp\Schema\Tool;

/**
* Provides JSON Schema generation for reflected elements.
*
* @phpstan-import-type ToolInputSchema from Tool
*
* @author Antoine Bluchet <soyuka@gmail.com>
*/
interface SchemaGeneratorInterface
Expand All @@ -24,11 +28,7 @@ interface SchemaGeneratorInterface
* The returned schema must be a valid JSON Schema object (type: 'object')
* with properties corresponding to a tool's parameters.
*
* @return array{
* type: 'object',
* properties: array<string, mixed>|object,
* required?: string[]
* }
* @return ToolInputSchema
*/
public function generate(\Reflector $reflection): array;

Expand Down
Loading