From d9affe922165e0888e7e66d5bb6d476de223c51a Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 11 Apr 2026 16:56:17 +0200 Subject: [PATCH] Add `title` field to Prompt for MCP spec compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP 2025-11-25 specification defines an optional `title` property on Prompt for human-readable display in UI contexts, distinct from the programmatic `name` identifier. This was missing from the SDK. BC Break: Builder::addPrompt() signature changed — $title parameter added between $name and $description. Callers using positional arguments for $description must switch to named arguments. Closes #276 Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 ++ docs/server-builder.md | 1 + src/Capability/Attribute/McpPrompt.php | 2 ++ src/Capability/Discovery/Discoverer.php | 2 +- src/Capability/Registry/Loader/ArrayLoader.php | 1 + src/Schema/Prompt.php | 8 ++++++++ src/Server/Builder.php | 3 ++- tests/Conformance/server.php | 8 ++++---- 8 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c882ab7..f714aab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add client component for building MCP clients * Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators) * Add elicitation enum schema types per SEP-1330: `TitledEnumSchemaDefinition`, `MultiSelectEnumSchemaDefinition`, `TitledMultiSelectEnumSchemaDefinition` +* Add optional `title` field to `Prompt` and `McpPrompt` for MCP spec compliance +* **BC Break**: `Builder::addPrompt()` signature changed — `$title` parameter added between `$name` and `$description`. Callers using positional arguments for `$description` must switch to named arguments. 0.4.0 ----- diff --git a/docs/server-builder.md b/docs/server-builder.md index c149bf14..3ec22ebb 100644 --- a/docs/server-builder.md +++ b/docs/server-builder.md @@ -352,6 +352,7 @@ $server = Server::builder() - `handler` (callable|string): The prompt handler - `name` (string|null): Optional prompt name +- `title` (string|null): Optional human-readable title for display in UI - `description` (string|null): Optional prompt description - `icons` (Icon[]|null): Optional array of icons for the prompt diff --git a/src/Capability/Attribute/McpPrompt.php b/src/Capability/Attribute/McpPrompt.php index edb163ba..cd47fe2c 100644 --- a/src/Capability/Attribute/McpPrompt.php +++ b/src/Capability/Attribute/McpPrompt.php @@ -24,12 +24,14 @@ class McpPrompt { /** * @param ?string $name overrides the prompt name (defaults to method name) + * @param ?string $title Optional human-readable title for display in UI * @param ?string $description Optional description of the prompt. Defaults to method DocBlock summary. * @param ?Icon[] $icons Optional list of icon URLs representing the prompt * @param ?array $meta Optional metadata */ public function __construct( public ?string $name = null, + public ?string $title = null, public ?string $description = null, public ?array $icons = null, public ?array $meta = null, diff --git a/src/Capability/Discovery/Discoverer.php b/src/Capability/Discovery/Discoverer.php index 3b9ed3a9..8f67fe17 100644 --- a/src/Capability/Discovery/Discoverer.php +++ b/src/Capability/Discovery/Discoverer.php @@ -271,7 +271,7 @@ private function processMethod(\ReflectionMethod $method, array &$discoveredCoun $paramTag = $paramTags['$'.$param->getName()] ?? null; $arguments[] = new PromptArgument($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, !$param->isOptional() && !$param->isDefaultValueAvailable()); } - $prompt = new Prompt($name, $description, $arguments, $instance->icons, $instance->meta); + $prompt = new Prompt($name, $instance->title, $description, $arguments, $instance->icons, $instance->meta); $completionProviders = $this->getCompletionProviders($method); $prompts[$name] = new PromptReference($prompt, [$className, $methodName], false, $completionProviders); ++$discoveredCount['prompts']; diff --git a/src/Capability/Registry/Loader/ArrayLoader.php b/src/Capability/Registry/Loader/ArrayLoader.php index 7be19b64..d9a337e0 100644 --- a/src/Capability/Registry/Loader/ArrayLoader.php +++ b/src/Capability/Registry/Loader/ArrayLoader.php @@ -252,6 +252,7 @@ public function load(RegistryInterface $registry): void } $prompt = new Prompt( name: $name, + title: $data['title'] ?? null, description: $description, arguments: $arguments, icons: $data['icons'] ?? null, diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index 45e37f01..68ae773f 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -21,6 +21,7 @@ * * @phpstan-type PromptData array{ * name: string, + * title?: string, * description?: string, * arguments?: PromptArgumentData[], * icons?: IconData[], @@ -33,6 +34,7 @@ class Prompt implements \JsonSerializable { /** * @param string $name the name of the prompt or prompt template + * @param ?string $title Optional human-readable title for display in UI * @param ?string $description an optional description of what this prompt provides * @param ?PromptArgument[] $arguments A list of arguments for templating. Null if not a template. * @param ?Icon[] $icons optional icons representing the prompt @@ -40,6 +42,7 @@ class Prompt implements \JsonSerializable */ public function __construct( public readonly string $name, + public readonly ?string $title = null, public readonly ?string $description = null, public readonly ?array $arguments = null, public readonly ?array $icons = null, @@ -73,6 +76,7 @@ public static function fromArray(array $data): self return new self( name: $data['name'], + title: $data['title'] ?? null, description: $data['description'] ?? null, arguments: $arguments, icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, @@ -83,6 +87,7 @@ public static function fromArray(array $data): self /** * @return array{ * name: string, + * title?: string, * description?: string, * arguments?: array, * icons?: Icon[], @@ -92,6 +97,9 @@ public static function fromArray(array $data): self public function jsonSerialize(): array { $data = ['name' => $this->name]; + if (null !== $this->title) { + $data['title'] = $this->title; + } if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/src/Server/Builder.php b/src/Server/Builder.php index be0866cd..903ec95e 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -472,11 +472,12 @@ public function addResourceTemplate( public function addPrompt( \Closure|array|string $handler, ?string $name = null, + ?string $title = null, ?string $description = null, ?array $icons = null, ?array $meta = null, ): self { - $this->prompts[] = compact('handler', 'name', 'description', 'icons', 'meta'); + $this->prompts[] = compact('handler', 'name', 'title', 'description', 'icons', 'meta'); return $this; } diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index 35855f65..471d5d83 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -58,10 +58,10 @@ ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') ->addResource(static fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that can be watched') // Prompts - ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], 'test_simple_prompt', 'A simple prompt without arguments') - ->addPrompt([Elements::class, 'promptWithArguments'], 'test_prompt_with_arguments', 'A prompt with required arguments') - ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], 'test_prompt_with_embedded_resource', 'A prompt that includes an embedded resource') - ->addPrompt([Elements::class, 'promptWithImage'], 'test_prompt_with_image', 'A prompt that includes image content') + ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], name: 'test_simple_prompt', description: 'A simple prompt without arguments') + ->addPrompt([Elements::class, 'promptWithArguments'], name: 'test_prompt_with_arguments', description: 'A prompt with required arguments') + ->addPrompt([Elements::class, 'promptWithEmbeddedResource'], name: 'test_prompt_with_embedded_resource', description: 'A prompt that includes an embedded resource') + ->addPrompt([Elements::class, 'promptWithImage'], name: 'test_prompt_with_image', description: 'A prompt that includes image content') ->build(); $response = $server->run($transport);