From 39ad96e7731837e7ab96503022118e210bd8a147 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Wed, 23 Sep 2026 19:40:09 +0200 Subject: [PATCH 1/5] feat(T7): update types present in both schema versions (#245) Add the 2026-07-28 shared metadata: - new Icon and Annotations types - icons on Implementation, ToolDefinition, Resource, ResourceTemplate, Prompt, ResourceLink - annotations and _meta on content blocks (Text/Image/Audio/ResourceContent) and Resource/ResourceTemplate - Implementation.websiteUrl/description, PromptReference.title, ResourceLink.title/size - server DSL builders (icons/annotations) on the resource, resource-template and prompt builders - schema conformance tests for the new fields against the 2026-07-28 schema All additive (optional, default None) so the 2025-11-25 wire is unchanged. Tool-builder `.icons(...)` deferred (threads through the multi-step Tool[I,O] chain); ToolDefinition still carries icons at the protocol level. Co-Authored-By: Claude Opus 4.8 --- .../main/scala/chimp/protocol/Lifecycle.scala | 9 +- .../main/scala/chimp/protocol/Metadata.scala | 22 +++ .../main/scala/chimp/protocol/Prompts.scala | 3 +- .../main/scala/chimp/protocol/Resources.scala | 4 + .../src/main/scala/chimp/protocol/Tools.scala | 133 +++++++++++++----- .../Schema20260728ConformanceSpec.scala | 61 ++++++++ .../scala/examples/both/serverAndClient.scala | 2 +- .../both/streamingServerAndClient.scala | 2 +- .../examples/client/everythingClient.scala | 8 +- .../src/main/scala/chimp/server/Prompt.scala | 10 +- .../main/scala/chimp/server/Resource.scala | 26 +++- .../scala/chimp/server/McpHandlerSpec.scala | 4 +- .../scala/chimp/server/McpServerTests.scala | 4 +- 13 files changed, 232 insertions(+), 56 deletions(-) create mode 100644 core/src/main/scala/chimp/protocol/Metadata.scala diff --git a/core/src/main/scala/chimp/protocol/Lifecycle.scala b/core/src/main/scala/chimp/protocol/Lifecycle.scala index ed859a1f..31bd5d58 100644 --- a/core/src/main/scala/chimp/protocol/Lifecycle.scala +++ b/core/src/main/scala/chimp/protocol/Lifecycle.scala @@ -2,7 +2,14 @@ package chimp.protocol import io.circe.{Codec, Json} -final case class Implementation(name: String, version: String, title: Option[String] = None) derives Codec +final case class Implementation( + name: String, + version: String, + title: Option[String] = None, + icons: Option[List[Icon]] = None, + websiteUrl: Option[String] = None, + description: Option[String] = None +) derives Codec final case class ClientRootsCapability(listChanged: Option[Boolean] = None) derives Codec diff --git a/core/src/main/scala/chimp/protocol/Metadata.scala b/core/src/main/scala/chimp/protocol/Metadata.scala new file mode 100644 index 00000000..2f9debce --- /dev/null +++ b/core/src/main/scala/chimp/protocol/Metadata.scala @@ -0,0 +1,22 @@ +package chimp.protocol + +import io.circe.Codec + +/** An icon for a resource, tool, prompt, or implementation (2026-07-28). `src` is a URI (http(s) or data:); the other fields are optional + * hints. Present on `icons` fields across the shared types. + */ +final case class Icon( + src: String, + mimeType: Option[String] = None, + sizes: Option[List[String]] = None, + theme: Option[String] = None +) derives Codec + +/** Optional annotations describing how an object or content should be used (2026-07-28): its intended `audience`, a `priority` (0.0-1.0), + * and a `lastModified` ISO-8601 timestamp. + */ +final case class Annotations( + audience: Option[List[Role]] = None, + lastModified: Option[String] = None, + priority: Option[Double] = None +) derives Codec diff --git a/core/src/main/scala/chimp/protocol/Prompts.scala b/core/src/main/scala/chimp/protocol/Prompts.scala index 8db10a3f..259821eb 100644 --- a/core/src/main/scala/chimp/protocol/Prompts.scala +++ b/core/src/main/scala/chimp/protocol/Prompts.scala @@ -26,6 +26,7 @@ final case class Prompt( title: Option[String] = None, description: Option[String] = None, arguments: Option[List[PromptArgument]] = None, + icons: Option[List[Icon]] = None, _meta: Option[Map[String, Json]] = None ) derives Codec @@ -53,4 +54,4 @@ final case class GetPromptResult( final case class PromptListChangedNotification(method: String = "notifications/prompts/list_changed") derives Codec -final case class PromptReference(`type`: String = "ref/prompt", name: String) derives Codec +final case class PromptReference(`type`: String = "ref/prompt", name: String, title: Option[String] = None) derives Codec diff --git a/core/src/main/scala/chimp/protocol/Resources.scala b/core/src/main/scala/chimp/protocol/Resources.scala index 59901de4..807de03d 100644 --- a/core/src/main/scala/chimp/protocol/Resources.scala +++ b/core/src/main/scala/chimp/protocol/Resources.scala @@ -10,6 +10,8 @@ final case class Resource( description: Option[String] = None, mimeType: Option[String] = None, size: Option[Long] = None, + icons: Option[List[Icon]] = None, + annotations: Option[Annotations] = None, _meta: Option[Map[String, Json]] = None ) derives Codec @@ -19,6 +21,8 @@ final case class ResourceTemplate( title: Option[String] = None, description: Option[String] = None, mimeType: Option[String] = None, + icons: Option[List[Icon]] = None, + annotations: Option[Annotations] = None, _meta: Option[Map[String, Json]] = None ) derives Codec diff --git a/core/src/main/scala/chimp/protocol/Tools.scala b/core/src/main/scala/chimp/protocol/Tools.scala index 49349425..3873a1cd 100644 --- a/core/src/main/scala/chimp/protocol/Tools.scala +++ b/core/src/main/scala/chimp/protocol/Tools.scala @@ -18,6 +18,7 @@ final case class ToolDefinition( outputSchema: Option[Json] = None, title: Option[String] = None, annotations: Option[ToolAnnotations] = None, + icons: Option[List[Icon]] = None, _meta: Option[Map[String, Json]] = None ) derives Codec @@ -32,50 +33,93 @@ final case class ListToolsResponse( ) derives Codec enum ToolContent: - case Text(`type`: String = "text", text: String) - case Image(`type`: String = "image", data: String, mimeType: String) - case Audio(`type`: String = "audio", data: String, mimeType: String) - case ResourceContent(`type`: String = "resource", resource: ResourceContents) + case Text(`type`: String = "text", text: String, annotations: Option[Annotations] = None, _meta: Option[Map[String, Json]] = None) + case Image( + `type`: String = "image", + data: String, + mimeType: String, + annotations: Option[Annotations] = None, + _meta: Option[Map[String, Json]] = None + ) + case Audio( + `type`: String = "audio", + data: String, + mimeType: String, + annotations: Option[Annotations] = None, + _meta: Option[Map[String, Json]] = None + ) + case ResourceContent( + `type`: String = "resource", + resource: ResourceContents, + annotations: Option[Annotations] = None, + _meta: Option[Map[String, Json]] = None + ) case ResourceLink( `type`: String = "resource_link", uri: String, name: Option[String] = None, + title: Option[String] = None, description: Option[String] = None, - mimeType: Option[String] = None + mimeType: Option[String] = None, + size: Option[Long] = None, + icons: Option[List[Icon]] = None, + annotations: Option[Annotations] = None, + _meta: Option[Map[String, Json]] = None ) object ToolContent: given Encoder[ToolContent] = Encoder.instance: - case Text(_, text) => - Json.obj( - "type" -> Json.fromString("text"), - "text" -> Json.fromString(text) - ) - case Image(_, data, mimeType) => - Json.obj( - "type" -> Json.fromString("image"), - "data" -> Json.fromString(data), - "mimeType" -> Json.fromString(mimeType) - ) - case Audio(_, data, mimeType) => - Json.obj( - "type" -> Json.fromString("audio"), - "data" -> Json.fromString(data), - "mimeType" -> Json.fromString(mimeType) - ) - case ResourceContent(_, resource) => - Json.obj( - "type" -> Json.fromString("resource"), - "resource" -> resource.asJson - ) - case ResourceLink(_, uri, name, description, mimeType) => + case Text(_, text, annotations, meta) => + Json + .obj( + "type" -> Json.fromString("text"), + "text" -> Json.fromString(text), + "annotations" -> annotations.asJson, + "_meta" -> meta.asJson + ) + .dropNullValues + case Image(_, data, mimeType, annotations, meta) => + Json + .obj( + "type" -> Json.fromString("image"), + "data" -> Json.fromString(data), + "mimeType" -> Json.fromString(mimeType), + "annotations" -> annotations.asJson, + "_meta" -> meta.asJson + ) + .dropNullValues + case Audio(_, data, mimeType, annotations, meta) => + Json + .obj( + "type" -> Json.fromString("audio"), + "data" -> Json.fromString(data), + "mimeType" -> Json.fromString(mimeType), + "annotations" -> annotations.asJson, + "_meta" -> meta.asJson + ) + .dropNullValues + case ResourceContent(_, resource, annotations, meta) => + Json + .obj( + "type" -> Json.fromString("resource"), + "resource" -> resource.asJson, + "annotations" -> annotations.asJson, + "_meta" -> meta.asJson + ) + .dropNullValues + case ResourceLink(_, uri, name, title, description, mimeType, size, icons, annotations, meta) => Json .obj( "type" -> Json.fromString("resource_link"), "uri" -> Json.fromString(uri), - "name" -> name.map(Json.fromString).getOrElse(Json.Null), - "description" -> description.map(Json.fromString).getOrElse(Json.Null), - "mimeType" -> mimeType.map(Json.fromString).getOrElse(Json.Null) + "name" -> name.asJson, + "title" -> title.asJson, + "description" -> description.asJson, + "mimeType" -> mimeType.asJson, + "size" -> size.asJson, + "icons" -> icons.asJson, + "annotations" -> annotations.asJson, + "_meta" -> meta.asJson ) .dropNullValues @@ -84,26 +128,43 @@ object ToolContent: .as[String] .flatMap: case "text" => - c.downField("text").as[String].map(Text("text", _)) + for + text <- c.downField("text").as[String] + annotations <- c.downField("annotations").as[Option[Annotations]] + meta <- c.downField("_meta").as[Option[Map[String, Json]]] + yield Text("text", text, annotations, meta) case "image" => for data <- c.downField("data").as[String] mimeType <- c.downField("mimeType").as[String] - yield Image("image", data, mimeType) + annotations <- c.downField("annotations").as[Option[Annotations]] + meta <- c.downField("_meta").as[Option[Map[String, Json]]] + yield Image("image", data, mimeType, annotations, meta) case "audio" => for data <- c.downField("data").as[String] mimeType <- c.downField("mimeType").as[String] - yield Audio("audio", data, mimeType) + annotations <- c.downField("annotations").as[Option[Annotations]] + meta <- c.downField("_meta").as[Option[Map[String, Json]]] + yield Audio("audio", data, mimeType, annotations, meta) case "resource" => - c.downField("resource").as[ResourceContents].map(ResourceContent("resource", _)) + for + resource <- c.downField("resource").as[ResourceContents] + annotations <- c.downField("annotations").as[Option[Annotations]] + meta <- c.downField("_meta").as[Option[Map[String, Json]]] + yield ResourceContent("resource", resource, annotations, meta) case "resource_link" => for uri <- c.downField("uri").as[String] name <- c.downField("name").as[Option[String]] + title <- c.downField("title").as[Option[String]] description <- c.downField("description").as[Option[String]] mimeType <- c.downField("mimeType").as[Option[String]] - yield ResourceLink("resource_link", uri, name, description, mimeType) + size <- c.downField("size").as[Option[Long]] + icons <- c.downField("icons").as[Option[List[Icon]]] + annotations <- c.downField("annotations").as[Option[Annotations]] + meta <- c.downField("_meta").as[Option[Map[String, Json]]] + yield ResourceLink("resource_link", uri, name, title, description, mimeType, size, icons, annotations, meta) case other => Left(DecodingFailure(s"Unknown ToolContent type: $other", c.history)) diff --git a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala index d06d66c5..2243d18b 100644 --- a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala +++ b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala @@ -68,3 +68,64 @@ class Schema20260728ConformanceSpec extends SchemaConformance: "SubscriptionsAcknowledgedNotificationParams", SubscriptionsAcknowledgedParams(notifications = SubscriptionFilter(promptsListChanged = Some(true))) ) + + // T7: types present in both revisions gain icons, annotations, _meta and other shared metadata + private val sampleIcon = + Icon(src = "https://example.com/icon.png", mimeType = Some("image/png"), sizes = Some(List("48x48")), theme = Some("dark")) + + it should "produce an Icon that matches the spec schema" in: + validate("Icon", sampleIcon) + + it should "produce Annotations that match the spec schema" in: + validate( + "Annotations", + Annotations(audience = Some(List(Role.User, Role.Assistant)), lastModified = Some("2026-01-12T15:00:58Z"), priority = Some(0.5)) + ) + + it should "produce an Implementation with icons, websiteUrl and description that matches the spec schema" in: + validate( + "Implementation", + Implementation( + "chimp", + "1.0", + title = Some("Chimp"), + icons = Some(List(sampleIcon)), + websiteUrl = Some("https://example.com"), + description = Some("test") + ) + ) + + it should "produce a Tool with icons that matches the spec schema" in: + validate( + "Tool", + ToolDefinition(name = "add", inputSchema = io.circe.Json.obj("type" -> "object".asJson), icons = Some(List(sampleIcon))) + ) + + it should "produce a Resource with icons and annotations that matches the spec schema" in: + validate( + "Resource", + Resource(uri = "file:///x", name = "x", icons = Some(List(sampleIcon)), annotations = Some(Annotations(priority = Some(0.3)))) + ) + + it should "produce a Prompt with icons that matches the spec schema" in: + validate("Prompt", Prompt(name = "greet", icons = Some(List(sampleIcon)))) + + it should "produce a PromptReference with title that matches the spec schema" in: + validate("PromptReference", PromptReference(name = "greet", title = Some("Greet"))) + + it should "produce a ResourceLink with title, size, icons and annotations that matches the spec schema" in: + val link: ToolContent = ToolContent.ResourceLink( + uri = "file:///x", + name = Some("x"), + title = Some("X"), + description = Some("a link"), + mimeType = Some("text/plain"), + size = Some(10L), + icons = Some(List(sampleIcon)), + annotations = Some(Annotations(priority = Some(0.1))) + ) + validate("ResourceLink", link) + + it should "produce TextContent with annotations that matches the spec schema" in: + val text: ToolContent = ToolContent.Text(text = "hi", annotations = Some(Annotations(audience = Some(List(Role.Assistant))))) + validate("TextContent", text) diff --git a/examples/src/main/scala/examples/both/serverAndClient.scala b/examples/src/main/scala/examples/both/serverAndClient.scala index 6ed3b28f..94cdd5af 100644 --- a/examples/src/main/scala/examples/both/serverAndClient.scala +++ b/examples/src/main/scala/examples/both/serverAndClient.scala @@ -33,7 +33,7 @@ case class AddInput(a: Int, b: Int) derives Codec, Schema val tools = client.listTools() println(s"tools: ${tools.tools.map(_.name).mkString(", ")}") val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => text }.foreach(text => println(s"2 + 3 = $text")) + result.content.collect { case ToolContent.Text(_, text, _, _) => text }.foreach(text => println(s"2 + 3 = $text")) client.close() finally backend.close() finally binding.stop() diff --git a/examples/src/main/scala/examples/both/streamingServerAndClient.scala b/examples/src/main/scala/examples/both/streamingServerAndClient.scala index 7f76df7d..66f247d0 100644 --- a/examples/src/main/scala/examples/both/streamingServerAndClient.scala +++ b/examples/src/main/scala/examples/both/streamingServerAndClient.scala @@ -43,7 +43,7 @@ case class NoInput() derives Codec, Schema case ServerNotification.LoggingMessage(params) => println(s"notification: ${params.data}") case _ => () val result = client.callTool("noisy", Json.obj()) - result.content.collect { case ToolContent.Text(_, text) => text }.foreach(text => println(s"result: $text")) + result.content.collect { case ToolContent.Text(_, text, _, _) => text }.foreach(text => println(s"result: $text")) client.close() finally backend.close() finally binding.stop() diff --git a/examples/src/main/scala/examples/client/everythingClient.scala b/examples/src/main/scala/examples/client/everythingClient.scala index 4a6be438..fa857ce9 100644 --- a/examples/src/main/scala/examples/client/everythingClient.scala +++ b/examples/src/main/scala/examples/client/everythingClient.scala @@ -34,14 +34,14 @@ import sttp.shared.Identity println("\n--- calling echo ---") val echoResult = client.callTool("echo", Json.obj("message" -> Json.fromString("hello chimp"))) echoResult.content.foreach: - case ToolContent.Text(_, text) => println(text) - case other => println(s"(non-text content: $other)") + case ToolContent.Text(_, text, _, _) => println(text) + case other => println(s"(non-text content: $other)") println("\n--- calling get-sum ---") val sumResult = client.callTool("get-sum", Json.obj("a" -> Json.fromInt(7), "b" -> Json.fromInt(35))) sumResult.content.foreach: - case ToolContent.Text(_, text) => println(text) - case other => println(s"(non-text content: $other)") + case ToolContent.Text(_, text, _, _) => println(text) + case other => println(s"(non-text content: $other)") finally client.close() backend.close() diff --git a/server/src/main/scala/chimp/server/Prompt.scala b/server/src/main/scala/chimp/server/Prompt.scala index 8bbbc8c5..fa3264ca 100644 --- a/server/src/main/scala/chimp/server/Prompt.scala +++ b/server/src/main/scala/chimp/server/Prompt.scala @@ -1,6 +1,6 @@ package chimp.server -import chimp.protocol.{GetPromptResult, Prompt, PromptArgument} +import chimp.protocol.{GetPromptResult, Icon, Prompt, PromptArgument} import sttp.model.Header import sttp.shared.Identity @@ -12,7 +12,8 @@ case class PartialPrompt( name: String, title: Option[String] = None, description: Option[String] = None, - arguments: List[PromptArgument] = Nil + arguments: List[PromptArgument] = Nil, + icons: Option[List[Icon]] = None ): def title(value: String): PartialPrompt = copy(title = Some(value)) @@ -20,6 +21,9 @@ case class PartialPrompt( def description(value: String): PartialPrompt = copy(description = Some(value)) + def icons(value: List[Icon]): PartialPrompt = + copy(icons = Some(value)) + /** Declares a single argument the prompt accepts. */ def argument(name: String, description: Option[String] = None, required: Boolean = false): PartialPrompt = copy(arguments = arguments :+ PromptArgument(name, description, required = Some(required))) @@ -57,7 +61,7 @@ case class PartialPrompt( securedServerLogic[Identity, P]((args, principal, _) => logic(args, principal)) private def definition: Prompt = - Prompt(name, title, description, Option.when(arguments.nonEmpty)(arguments)) + Prompt(name, title, description, Option.when(arguments.nonEmpty)(arguments), icons = icons) end PartialPrompt diff --git a/server/src/main/scala/chimp/server/Resource.scala b/server/src/main/scala/chimp/server/Resource.scala index ec37ce84..ad645a61 100644 --- a/server/src/main/scala/chimp/server/Resource.scala +++ b/server/src/main/scala/chimp/server/Resource.scala @@ -1,6 +1,6 @@ package chimp.server -import chimp.protocol.{Resource, ResourceContents, ResourceTemplate} +import chimp.protocol.{Annotations, Icon, Resource, ResourceContents, ResourceTemplate} import sttp.model.Header import sttp.shared.Identity @@ -23,7 +23,9 @@ case class PartialResource( title: Option[String] = None, description: Option[String] = None, mimeType: Option[String] = None, - size: Option[Long] = None + size: Option[Long] = None, + icons: Option[List[Icon]] = None, + annotations: Option[Annotations] = None ): def name(value: String): PartialResource = copy(name = Some(value)) @@ -40,6 +42,12 @@ case class PartialResource( def size(value: Long): PartialResource = copy(size = Some(value)) + def icons(value: List[Icon]): PartialResource = + copy(icons = Some(value)) + + def annotations(value: Annotations): PartialResource = + copy(annotations = Some(value)) + /** Attaches effectful logic, with access to the request headers, producing the resource's contents (or an error). */ def serverLogic[F[_]]( logic: Seq[Header] => F[Either[ResourceError, List[ResourceContents]]] @@ -68,7 +76,7 @@ case class PartialResource( ): ServerResource[Identity, SecuredServerContext[Identity, P]] = securedServerLogic[Identity, P]((principal, _) => logic(principal)) - private def definition: Resource = Resource(uri, name.getOrElse(uri), title, description, mimeType, size) + private def definition: Resource = Resource(uri, name.getOrElse(uri), title, description, mimeType, size, icons, annotations) end PartialResource @@ -84,7 +92,9 @@ case class PartialResourceTemplate( name: Option[String] = None, title: Option[String] = None, description: Option[String] = None, - mimeType: Option[String] = None + mimeType: Option[String] = None, + icons: Option[List[Icon]] = None, + annotations: Option[Annotations] = None ): def name(value: String): PartialResourceTemplate = copy(name = Some(value)) @@ -98,6 +108,12 @@ case class PartialResourceTemplate( def mimeType(value: String): PartialResourceTemplate = copy(mimeType = Some(value)) + def icons(value: List[Icon]): PartialResourceTemplate = + copy(icons = Some(value)) + + def annotations(value: Annotations): PartialResourceTemplate = + copy(annotations = Some(value)) + /** Attaches effectful logic reading a matched URI; receives the extracted variables, the full URI, and the request headers. */ def serverLogic[F[_]]( logic: (Map[String, String], String, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] @@ -133,7 +149,7 @@ case class PartialResourceTemplate( securedServerLogic[Identity, P]((vars, uri, principal, _) => logic(vars, uri, principal)) private def definition: ResourceTemplate = - ResourceTemplate(uriTemplate, name.getOrElse(uriTemplate), title, description, mimeType) + ResourceTemplate(uriTemplate, name.getOrElse(uriTemplate), title, description, mimeType, icons, annotations) end PartialResourceTemplate diff --git a/server/src/test/scala/chimp/server/McpHandlerSpec.scala b/server/src/test/scala/chimp/server/McpHandlerSpec.scala index 151fa0ba..9ae61415 100644 --- a/server/src/test/scala/chimp/server/McpHandlerSpec.scala +++ b/server/src/test/scala/chimp/server/McpHandlerSpec.scala @@ -572,8 +572,8 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: featureResult("prompts/get", Some(params), "p2") match case Response(_, _, result) => result.as[GetPromptResult].getOrElse(fail("result")).messages.head.content match - case ToolContent.Text(_, text) => text should include("World") - case _ => fail("Expected text content") + case ToolContent.Text(_, text, _, _) => text should include("World") + case _ => fail("Expected text content") case _ => fail("Expected Response") it should "return completion values" in: diff --git a/server/src/test/scala/chimp/server/McpServerTests.scala b/server/src/test/scala/chimp/server/McpServerTests.scala index 58ef0735..b275149f 100644 --- a/server/src/test/scala/chimp/server/McpServerTests.scala +++ b/server/src/test/scala/chimp/server/McpServerTests.scala @@ -74,8 +74,8 @@ trait McpServerTests[F[_]] extends AsyncFlatSpec with Matchers: .getPrompt("greet", Map("name" -> "World")) .map: result => result.messages.head.content match - case ToolContent.Text(_, text) => text should include("World") - case other => fail(s"expected text content, got $other") + case ToolContent.Text(_, text, _, _) => text should include("World") + case other => fail(s"expected text content, got $other") it should "return completion suggestions" in withServer(sampleServer): client => client From 6f405bf5297c21a9b86e35209e45cd5d4c12beb3 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Wed, 23 Sep 2026 20:03:58 +0200 Subject: [PATCH 2/5] fix(T7): update client doc snippets for the ToolContent.Text arity change The mdoc client examples/quickstart matched ToolContent.Text with two patterns; T7 added annotations and _meta. Switch to a type pattern so the snippets survive further field additions. Co-Authored-By: Claude Opus 4.8 --- docs/client/examples.md | 4 ++-- docs/client/quickstart.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/client/examples.md b/docs/client/examples.md index c253e1a6..a89d7b51 100644 --- a/docs/client/examples.md +++ b/docs/client/examples.md @@ -20,7 +20,7 @@ object HttpClient: val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) + result.content.collect { case t: ToolContent.Text => t.text }.foreach(println) client.close() backend.close() @@ -43,7 +43,7 @@ object StdioClient: val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) + result.content.collect { case t: ToolContent.Text => t.text }.foreach(println) client.close() ``` diff --git a/docs/client/quickstart.md b/docs/client/quickstart.md index d8026fba..1e65c609 100644 --- a/docs/client/quickstart.md +++ b/docs/client/quickstart.md @@ -28,7 +28,7 @@ object QuickstartClient: val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) + result.content.collect { case t: ToolContent.Text => t.text }.foreach(println) client.close() backend.close() From bf50facb806678b03d76cefe7496fe6e39a9244e Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 22 Oct 2026 00:24:37 +0200 Subject: [PATCH 3/5] fix(T7): type Icon.theme and Annotations.priority as closed domains Both schemas declare Icon.theme as the closed enum ["dark","light"] and bound Annotations.priority to 0.0..1.0. Modelling them as Option[String] and Option[Double] let invalid values compile and reach the wire. - Icon.theme is now an IconTheme enum (Dark/Light) with a hand-written codec, matching Role/LoggingLevel/CacheScope and the other closed string sets in chimp.protocol. - Annotations.priority is now an opaque Priority bounded to 0.0..1.0, rejecting out-of-range values on construction and decode. Doing this pre-1.0 avoids a later String->enum / Double->refined widening that would break source compatibility on these public types. Co-Authored-By: Claude Opus 4.8 --- .../main/scala/chimp/protocol/Metadata.scala | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/core/src/main/scala/chimp/protocol/Metadata.scala b/core/src/main/scala/chimp/protocol/Metadata.scala index 2f9debce..a02e28f9 100644 --- a/core/src/main/scala/chimp/protocol/Metadata.scala +++ b/core/src/main/scala/chimp/protocol/Metadata.scala @@ -1,22 +1,57 @@ package chimp.protocol -import io.circe.Codec +import io.circe.{Codec, Decoder, Encoder, Json} -/** An icon for a resource, tool, prompt, or implementation (2026-07-28). `src` is a URI (http(s) or data:); the other fields are optional - * hints. Present on `icons` fields across the shared types. +/** The visual theme an [[Icon]] is designed for. Mirrors the closed `["dark", "light"]` string enum declared in both the 2025-11-25 and + * 2026-07-28 MCP schemas, so an invalid value cannot be put on the wire. + */ +enum IconTheme: + case Dark, Light + +object IconTheme: + given Encoder[IconTheme] = Encoder.instance: + case IconTheme.Dark => Json.fromString("dark") + case IconTheme.Light => Json.fromString("light") + given Decoder[IconTheme] = Decoder.decodeString.emap: + case "dark" => Right(IconTheme.Dark) + case "light" => Right(IconTheme.Light) + case other => Left(s"Unknown icon theme: $other") + +/** A relative-importance hint bounded to the schema-mandated 0.0..1.0 range. Constructing or decoding a value outside that range fails, + * which a plain `Double` would silently allow onto the wire. + */ +opaque type Priority = Double +object Priority: + /** Builds a [[Priority]] when `value` is within 0.0..1.0, otherwise returns an error message. */ + def from(value: Double): Either[String, Priority] = + if value >= 0.0 && value <= 1.0 then Right(value) + else Left(s"priority must be within 0.0..1.0, got $value") + + /** Unchecked constructor for in-range literals; throws [[IllegalArgumentException]] for an out-of-range value. */ + def apply(value: Double): Priority = from(value).fold(msg => throw IllegalArgumentException(msg), identity) + + def unapply(priority: Priority): Option[Double] = Some(priority) + + extension (priority: Priority) def value: Double = priority + + given Encoder[Priority] = Encoder.encodeDouble + given Decoder[Priority] = Decoder.decodeDouble.emap(from) + +/** An icon for a resource, tool, prompt, or implementation. `src` is a URI (http(s) or data:); the other fields are optional hints. Present + * on `icons` fields across the shared types. */ final case class Icon( src: String, mimeType: Option[String] = None, sizes: Option[List[String]] = None, - theme: Option[String] = None + theme: Option[IconTheme] = None ) derives Codec -/** Optional annotations describing how an object or content should be used (2026-07-28): its intended `audience`, a `priority` (0.0-1.0), - * and a `lastModified` ISO-8601 timestamp. +/** Optional annotations describing how an object or content should be used: its intended `audience`, a `priority` (0.0..1.0) and a + * `lastModified` ISO-8601 timestamp. */ final case class Annotations( audience: Option[List[Role]] = None, lastModified: Option[String] = None, - priority: Option[Double] = None + priority: Option[Priority] = None ) derives Codec From 9143cb5f7b72f68c41a2b849120882ed013aae30 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 22 Oct 2026 00:24:37 +0200 Subject: [PATCH 4/5] test(T7): run shared-metadata conformance under both schema revisions Icon, Annotations, Implementation, Tool, Resource, Prompt, PromptReference, ResourceLink and TextContent are byte-identical in the 2025-11-25 and 2026-07-28 schemas, and every field T7 adds already exists in 2025-11-25. With the nine cases living only in the 2026-07-28 spec, a regression that breaks the older revision was invisible (AGENTS.md rule 2). Move them into the shared SchemaConformance trait so both concrete specs validate them. Co-Authored-By: Claude Opus 4.8 --- .../Schema20260728ConformanceSpec.scala | 61 ---------------- .../chimp/protocol/SchemaConformance.scala | 71 +++++++++++++++++++ 2 files changed, 71 insertions(+), 61 deletions(-) diff --git a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala index 2243d18b..d06d66c5 100644 --- a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala +++ b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala @@ -68,64 +68,3 @@ class Schema20260728ConformanceSpec extends SchemaConformance: "SubscriptionsAcknowledgedNotificationParams", SubscriptionsAcknowledgedParams(notifications = SubscriptionFilter(promptsListChanged = Some(true))) ) - - // T7: types present in both revisions gain icons, annotations, _meta and other shared metadata - private val sampleIcon = - Icon(src = "https://example.com/icon.png", mimeType = Some("image/png"), sizes = Some(List("48x48")), theme = Some("dark")) - - it should "produce an Icon that matches the spec schema" in: - validate("Icon", sampleIcon) - - it should "produce Annotations that match the spec schema" in: - validate( - "Annotations", - Annotations(audience = Some(List(Role.User, Role.Assistant)), lastModified = Some("2026-01-12T15:00:58Z"), priority = Some(0.5)) - ) - - it should "produce an Implementation with icons, websiteUrl and description that matches the spec schema" in: - validate( - "Implementation", - Implementation( - "chimp", - "1.0", - title = Some("Chimp"), - icons = Some(List(sampleIcon)), - websiteUrl = Some("https://example.com"), - description = Some("test") - ) - ) - - it should "produce a Tool with icons that matches the spec schema" in: - validate( - "Tool", - ToolDefinition(name = "add", inputSchema = io.circe.Json.obj("type" -> "object".asJson), icons = Some(List(sampleIcon))) - ) - - it should "produce a Resource with icons and annotations that matches the spec schema" in: - validate( - "Resource", - Resource(uri = "file:///x", name = "x", icons = Some(List(sampleIcon)), annotations = Some(Annotations(priority = Some(0.3)))) - ) - - it should "produce a Prompt with icons that matches the spec schema" in: - validate("Prompt", Prompt(name = "greet", icons = Some(List(sampleIcon)))) - - it should "produce a PromptReference with title that matches the spec schema" in: - validate("PromptReference", PromptReference(name = "greet", title = Some("Greet"))) - - it should "produce a ResourceLink with title, size, icons and annotations that matches the spec schema" in: - val link: ToolContent = ToolContent.ResourceLink( - uri = "file:///x", - name = Some("x"), - title = Some("X"), - description = Some("a link"), - mimeType = Some("text/plain"), - size = Some(10L), - icons = Some(List(sampleIcon)), - annotations = Some(Annotations(priority = Some(0.1))) - ) - validate("ResourceLink", link) - - it should "produce TextContent with annotations that matches the spec schema" in: - val text: ToolContent = ToolContent.Text(text = "hi", annotations = Some(Annotations(audience = Some(List(Role.Assistant))))) - validate("TextContent", text) diff --git a/core/src/test/scala/chimp/protocol/SchemaConformance.scala b/core/src/test/scala/chimp/protocol/SchemaConformance.scala index ed3810e0..59c6a7cb 100644 --- a/core/src/test/scala/chimp/protocol/SchemaConformance.scala +++ b/core/src/test/scala/chimp/protocol/SchemaConformance.scala @@ -47,3 +47,74 @@ trait SchemaConformance extends AnyFlatSpec with Matchers: decoded shouldBe value case Left(err) => fail(s"Decode round-trip failed for $defName:\nencoded: $encodedStr\nerror: ${err.getMessage}") + + // Shared metadata types (icons, annotations, _meta, title) are byte-identical across the 2025-11-25 and 2026-07-28 schemas, so both + // concrete specs validate them. A field regression that breaks the older revision must not be invisible (AGENTS.md rule 2). + private val sampleIcon = + Icon(src = "https://example.com/icon.png", mimeType = Some("image/png"), sizes = Some(List("48x48")), theme = Some(IconTheme.Dark)) + + it should "produce an Icon that matches the spec schema" in: + validate("Icon", sampleIcon) + + it should "produce Annotations that match the spec schema" in: + validate( + "Annotations", + Annotations( + audience = Some(List(Role.User, Role.Assistant)), + lastModified = Some("2026-01-12T15:00:58Z"), + priority = Some(Priority(0.5)) + ) + ) + + it should "produce an Implementation with icons, websiteUrl and description that matches the spec schema" in: + validate( + "Implementation", + Implementation( + "chimp", + "1.0", + title = Some("Chimp"), + icons = Some(List(sampleIcon)), + websiteUrl = Some("https://example.com"), + description = Some("test") + ) + ) + + it should "produce a Tool with icons that matches the spec schema" in: + validate( + "Tool", + ToolDefinition(name = "add", inputSchema = io.circe.Json.obj("type" -> "object".asJson), icons = Some(List(sampleIcon))) + ) + + it should "produce a Resource with icons and annotations that matches the spec schema" in: + validate( + "Resource", + Resource( + uri = "file:///x", + name = "x", + icons = Some(List(sampleIcon)), + annotations = Some(Annotations(priority = Some(Priority(0.3)))) + ) + ) + + it should "produce a Prompt with icons that matches the spec schema" in: + validate("Prompt", Prompt(name = "greet", icons = Some(List(sampleIcon)))) + + it should "produce a PromptReference with title that matches the spec schema" in: + validate("PromptReference", PromptReference(name = "greet", title = Some("Greet"))) + + it should "produce a ResourceLink with title, size, icons and annotations that matches the spec schema" in: + val link: ToolContent = ToolContent.ResourceLink( + uri = "file:///x", + name = Some("x"), + title = Some("X"), + description = Some("a link"), + mimeType = Some("text/plain"), + size = Some(10L), + icons = Some(List(sampleIcon)), + annotations = Some(Annotations(priority = Some(Priority(0.1)))) + ) + validate("ResourceLink", link) + + it should "produce TextContent with annotations that matches the spec schema" in: + val text: ToolContent = ToolContent.Text(text = "hi", annotations = Some(Annotations(audience = Some(List(Role.Assistant))))) + validate("TextContent", text) From 9d879170abe7b563c941456a7d950fca156b7b62 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 22 Oct 2026 00:24:37 +0200 Subject: [PATCH 5/5] docs(T7): fix README ToolContent.Text pattern for the new arity README.md matched ToolContent.Text with two patterns on three lines; T7 added annotations and _meta, so the arity is now four. The README is not mdoc-compiled (mdocIn := file("docs")), so this was missed. Switch to the type pattern `case t: ToolContent.Text => t.text`, the same form used in docs/, so the snippets survive further field additions. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aa69e8be..9c465bf0 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ import io.circe.Json val client = McpClient(transport, Implementation("my-client", "0.1.0")) val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - val _ = result.content.collect { case ToolContent.Text(_, text) => println(text) } + val _ = result.content.collect { case t: ToolContent.Text => println(t.text) } client.close() backend.close() @@ -96,7 +96,7 @@ import io.circe.Json val client = McpClient(transport, Implementation("my-client", "0.1.0")) val result = client.callTool("echo", Json.obj("message" -> Json.fromString("hello"))) - val _ = result.content.collect { case ToolContent.Text(_, text) => println(text) } + val _ = result.content.collect { case t: ToolContent.Text => println(t.text) } client.close() ``` @@ -160,7 +160,7 @@ import sttp.shared.Identity case _ => () val result = client.callTool("work", Json.obj("steps" -> Json.fromInt(3))) - val _ = result.content.collect { case ToolContent.Text(_, text) => println(text) } + val _ = result.content.collect { case t: ToolContent.Text => println(t.text) } client.close() backend.close()