From 39ad96e7731837e7ab96503022118e210bd8a147 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Wed, 23 Sep 2026 19:40:09 +0200 Subject: [PATCH 1/4] 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/4] 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 9332d40ca767abc04f5385cb42c9f7cc94a94125 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Wed, 23 Sep 2026 19:55:05 +0200 Subject: [PATCH 3/4] feat(T5): MRTR (multi round trip requests) types (#243) Add the 2026-07-28 multi-round-trip request types: - InputRequest / InputResponse enums (sampling, roots, elicitation), discriminated by method / structural fields. - InputRequiredResult carrying pending InputRequests plus requestState and the modern result envelope (resultType defaults to incomplete). - ResultType gains the `incomplete` case. - ElicitParams becomes an enum with Form and Url variants (mode distinguishes them; optional on Form for 2025-11-25 back-compat). - CallToolParams / GetPromptParams / ReadResourceParams carry inputResponses and requestState so clients can resume a paused call. Schema conformance tests for the new modern defs; client-conformance elicitation handler updated for the ElicitParams enum. Co-Authored-By: Claude Opus 4.8 --- .../scala/chimp/conformance/client/Main.scala | 6 +- .../scala/chimp/protocol/Elicitation.scala | 50 +++++++++++++++-- core/src/main/scala/chimp/protocol/Mrtr.scala | 56 +++++++++++++++++++ .../main/scala/chimp/protocol/Prompts.scala | 2 + .../main/scala/chimp/protocol/Resources.scala | 7 ++- .../src/main/scala/chimp/protocol/Tools.scala | 2 + .../scala/chimp/protocol/Versioning.scala | 10 +++- .../Schema20251125ConformanceSpec.scala | 14 ++--- .../Schema20260728ConformanceSpec.scala | 53 ++++++++++++++++++ 9 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 core/src/main/scala/chimp/protocol/Mrtr.scala diff --git a/client-conformance/src/main/scala/chimp/conformance/client/Main.scala b/client-conformance/src/main/scala/chimp/conformance/client/Main.scala index 3cb98329..93003db5 100644 --- a/client-conformance/src/main/scala/chimp/conformance/client/Main.scala +++ b/client-conformance/src/main/scala/chimp/conformance/client/Main.scala @@ -67,7 +67,11 @@ object Main: case "elicitation-sep1034-client-defaults" => supervised: val oxTransport = OxClientHttpTransport(backend, serverUrl, protocolVersion) - val handler = (req: ElicitRequest) => ElicitResult(ElicitAction.Accept, Some(defaultsFrom(req.params.requestedSchema))) + val handler = (req: ElicitRequest) => + val requestedSchema = req.params match + case ElicitParams.Form(_, schema, _, _) => schema + case _: ElicitParams.Url => Json.obj() + ElicitResult(ElicitAction.Accept, Some(defaultsFrom(requestedSchema))) val client = McpClient.bidirectional[Identity]( oxTransport, clientInfo, diff --git a/core/src/main/scala/chimp/protocol/Elicitation.scala b/core/src/main/scala/chimp/protocol/Elicitation.scala index 56d4af6a..8bf36cab 100644 --- a/core/src/main/scala/chimp/protocol/Elicitation.scala +++ b/core/src/main/scala/chimp/protocol/Elicitation.scala @@ -1,5 +1,6 @@ package chimp.protocol +import io.circe.syntax.* import io.circe.{Codec, Decoder, Encoder, Json} enum ElicitAction: @@ -16,11 +17,50 @@ object ElicitAction: case "cancel" => Right(ElicitAction.Cancel) case other => Left(s"Unknown elicitation action: $other") -final case class ElicitParams( - message: String, - requestedSchema: Json, - _meta: Option[Map[String, Json]] = None -) derives Codec +/** Params of an `elicitation/create` request (2026-07-28): either a `Form` (a JSON schema to fill in) or a `Url` (open a URL to complete + * the interaction out of band). `mode` distinguishes them on the wire; it is optional on `Form` for backward compatibility with + * 2025-11-25. + */ +enum ElicitParams: + case Form(message: String, requestedSchema: Json, mode: Option[String] = None, _meta: Option[Map[String, Json]] = None) + case Url(message: String, url: String, mode: String = "url", _meta: Option[Map[String, Json]] = None) + +object ElicitParams: + given Encoder[ElicitParams] = Encoder.instance: + case Form(message, requestedSchema, mode, meta) => + Json + .obj( + "message" -> Json.fromString(message), + "requestedSchema" -> requestedSchema, + "mode" -> mode.asJson, + "_meta" -> meta.asJson + ) + .dropNullValues + case Url(message, url, mode, meta) => + Json + .obj( + "message" -> Json.fromString(message), + "url" -> Json.fromString(url), + "mode" -> Json.fromString(mode), + "_meta" -> meta.asJson + ) + .dropNullValues + + given Decoder[ElicitParams] = Decoder.instance: c => + if c.downField("url").succeeded then + for + message <- c.downField("message").as[String] + url <- c.downField("url").as[String] + mode <- c.downField("mode").as[Option[String]] + meta <- c.downField("_meta").as[Option[Map[String, Json]]] + yield Url(message, url, mode.getOrElse("url"), meta) + else + for + message <- c.downField("message").as[String] + requestedSchema <- c.downField("requestedSchema").as[Json] + mode <- c.downField("mode").as[Option[String]] + meta <- c.downField("_meta").as[Option[Map[String, Json]]] + yield Form(message, requestedSchema, mode, meta) final case class ElicitRequest(method: String = "elicitation/create", params: ElicitParams) derives Codec diff --git a/core/src/main/scala/chimp/protocol/Mrtr.scala b/core/src/main/scala/chimp/protocol/Mrtr.scala new file mode 100644 index 00000000..a85fcc47 --- /dev/null +++ b/core/src/main/scala/chimp/protocol/Mrtr.scala @@ -0,0 +1,56 @@ +package chimp.protocol + +import io.circe.syntax.* +import io.circe.{Decoder, DecodingFailure, Encoder, HCursor, Json} + +/** A server-to-client request embedded in an `input_required` tool result (MRTR, 2026-07-28): a sampling, roots or elicitation request. On + * the wire it is the bare request object, discriminated by its `method`. + */ +enum InputRequest: + case Sampling(request: CreateMessageRequest) + case Roots(request: ListRootsRequest) + case Elicit(request: ElicitRequest) + +object InputRequest: + given Encoder[InputRequest] = Encoder.instance: + case Sampling(request) => request.asJson + case Roots(request) => request.asJson + case Elicit(request) => request.asJson + + given Decoder[InputRequest] = Decoder.instance: (c: HCursor) => + c.downField("method") + .as[String] + .flatMap: + case "sampling/createMessage" => c.as[CreateMessageRequest].map(Sampling(_)) + case "roots/list" => c.as[ListRootsRequest].map(Roots(_)) + case "elicitation/create" => c.as[ElicitRequest].map(Elicit(_)) + case other => Left(DecodingFailure(s"Unknown InputRequest method: $other", c.history)) + +/** A client-to-server response to an [[InputRequest]] (MRTR): a sampling, roots or elicitation result. Discriminated structurally + * (elicitation by `action`, roots by `roots`, otherwise sampling). + */ +enum InputResponse: + case Sampling(result: CreateMessageResult) + case Roots(result: ListRootsResult) + case Elicit(result: ElicitResult) + +object InputResponse: + given Encoder[InputResponse] = Encoder.instance: + case Sampling(result) => result.asJson + case Roots(result) => result.asJson + case Elicit(result) => result.asJson + + given Decoder[InputResponse] = Decoder.instance: (c: HCursor) => + if c.downField("action").succeeded then c.as[ElicitResult].map(Elicit(_)) + else if c.downField("roots").succeeded then c.as[ListRootsResult].map(Roots(_)) + else c.as[CreateMessageResult].map(Sampling(_)) + +/** Result of a tool call that needs client input before it can finish (MRTR): the pending [[InputRequest]]s keyed by an opaque key, the + * opaque `requestState` the client echoes back on its response, and the modern result envelope fields. + */ +final case class InputRequiredResult( + inputRequests: Map[String, InputRequest], + requestState: Option[String] = None, + resultType: ResultType = ResultType.Incomplete, + _meta: Option[Map[String, Json]] = None +) derives io.circe.Codec diff --git a/core/src/main/scala/chimp/protocol/Prompts.scala b/core/src/main/scala/chimp/protocol/Prompts.scala index 259821eb..86782a43 100644 --- a/core/src/main/scala/chimp/protocol/Prompts.scala +++ b/core/src/main/scala/chimp/protocol/Prompts.scala @@ -43,6 +43,8 @@ final case class ListPromptsResult( final case class GetPromptParams( name: String, arguments: Option[Map[String, String]] = None, + inputResponses: Option[Map[String, InputResponse]] = None, + requestState: Option[String] = None, _meta: Option[Map[String, Json]] = None ) derives Codec final case class GetPromptRequest(method: String = "prompts/get", params: GetPromptParams) derives Codec diff --git a/core/src/main/scala/chimp/protocol/Resources.scala b/core/src/main/scala/chimp/protocol/Resources.scala index 807de03d..fc40125e 100644 --- a/core/src/main/scala/chimp/protocol/Resources.scala +++ b/core/src/main/scala/chimp/protocol/Resources.scala @@ -88,7 +88,12 @@ final case class ListResourceTemplatesResult( _meta: Option[Map[String, Json]] = None ) derives Codec -final case class ReadResourceParams(uri: String, _meta: Option[Map[String, Json]] = None) derives Codec +final case class ReadResourceParams( + uri: String, + inputResponses: Option[Map[String, InputResponse]] = None, + requestState: Option[String] = None, + _meta: Option[Map[String, Json]] = None +) derives Codec final case class ReadResourceRequest(method: String = "resources/read", params: ReadResourceParams) derives Codec final case class ReadResourceResult(contents: List[ResourceContents], _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 3873a1cd..100d56b0 100644 --- a/core/src/main/scala/chimp/protocol/Tools.scala +++ b/core/src/main/scala/chimp/protocol/Tools.scala @@ -171,6 +171,8 @@ object ToolContent: final case class CallToolParams( name: String, arguments: Json, + inputResponses: Option[Map[String, InputResponse]] = None, + requestState: Option[String] = None, _meta: Option[Map[String, Json]] = None ) derives Codec diff --git a/core/src/main/scala/chimp/protocol/Versioning.scala b/core/src/main/scala/chimp/protocol/Versioning.scala index 55ccf33b..a5eac0e2 100644 --- a/core/src/main/scala/chimp/protocol/Versioning.scala +++ b/core/src/main/scala/chimp/protocol/Versioning.scala @@ -27,15 +27,19 @@ object ProtocolMeta: data = Some(Json.obj("requested" -> requested.asJson, "supported" -> supported.asJson)) ) -/** The type of a modern (2026-07-28+) result. A closed set on the wire; only `complete` exists today. */ +/** The type of a modern (2026-07-28+) result: `complete` for a finished result, `incomplete` for one that needs another round trip (MRTR + * input-required). + */ enum ResultType(val name: String): case Complete extends ResultType("complete") + case Incomplete extends ResultType("incomplete") object ResultType: given Encoder[ResultType] = Encoder.instance(resultType => Json.fromString(resultType.name)) given Decoder[ResultType] = Decoder.decodeString.emap: - case "complete" => Right(Complete) - case other => Left(s"Unknown result type: $other") + case "complete" => Right(Complete) + case "incomplete" => Right(Incomplete) + case other => Left(s"Unknown result type: $other") /** Whether a cached response may be shared across authorization contexts (`Public`) or not (`Private`). */ enum CacheScope: diff --git a/core/src/test/scala/chimp/protocol/Schema20251125ConformanceSpec.scala b/core/src/test/scala/chimp/protocol/Schema20251125ConformanceSpec.scala index 85260e78..a2e2b1d1 100644 --- a/core/src/test/scala/chimp/protocol/Schema20251125ConformanceSpec.scala +++ b/core/src/test/scala/chimp/protocol/Schema20251125ConformanceSpec.scala @@ -205,16 +205,14 @@ class Schema20251125ConformanceSpec extends SchemaConformance: // --- Elicitation --- it should "produce ElicitRequestParams (form variant) that match the spec schema" in: - validate( - "ElicitRequestParams", - ElicitParams( - message = "what is your name?", - requestedSchema = io.circe.Json.obj( - "type" -> "object".asJson, - "properties" -> io.circe.Json.obj("name" -> io.circe.Json.obj("type" -> "string".asJson)) - ) + val params: ElicitParams = ElicitParams.Form( + message = "what is your name?", + requestedSchema = io.circe.Json.obj( + "type" -> "object".asJson, + "properties" -> io.circe.Json.obj("name" -> io.circe.Json.obj("type" -> "string".asJson)) ) ) + validate("ElicitRequestParams", params) it should "produce ElicitResult that match the spec schema" in: validate( diff --git a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala index 2243d18b..1c27984f 100644 --- a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala +++ b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala @@ -129,3 +129,56 @@ class Schema20260728ConformanceSpec extends SchemaConformance: 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) + + // T5: MRTR / input-required types + private val sampleInputResponses: Map[String, InputResponse] = + Map("approve" -> InputResponse.Elicit(ElicitResult(action = ElicitAction.Accept, content = Some(Map("ok" -> true.asJson))))) + private val requestMeta: Map[String, io.circe.Json] = + Map(ProtocolMeta.ProtocolVersionKey -> "2026-07-28".asJson, ProtocolMeta.ClientCapabilities -> io.circe.Json.obj()) + + it should "produce ElicitRequestParams (url variant) that match the spec schema" in: + val params: ElicitParams = ElicitParams.Url(message = "authorize here", url = "https://example.com/consent") + validate("ElicitRequestParams", params) + + it should "produce an InputRequiredResult that matches the spec schema" in: + val request: InputRequest = InputRequest.Elicit( + ElicitRequest(params = + ElicitParams.Form( + message = "your name?", + requestedSchema = io.circe.Json.obj("type" -> "object".asJson, "properties" -> io.circe.Json.obj()) + ) + ) + ) + validate( + "InputRequiredResult", + InputRequiredResult(inputRequests = Map("name" -> request), requestState = Some("state-1")) + ) + + it should "produce CallToolRequestParams carrying inputResponses and requestState that match the spec schema" in: + validate( + "CallToolRequestParams", + CallToolParams( + name = "review", + arguments = io.circe.Json.obj(), + inputResponses = Some(sampleInputResponses), + requestState = Some("state-1"), + _meta = Some(requestMeta) + ) + ) + + it should "produce GetPromptRequestParams carrying inputResponses that match the spec schema" in: + validate( + "GetPromptRequestParams", + GetPromptParams(name = "greet", inputResponses = Some(sampleInputResponses), requestState = Some("s"), _meta = Some(requestMeta)) + ) + + it should "produce ReadResourceRequestParams carrying inputResponses that match the spec schema" in: + validate( + "ReadResourceRequestParams", + ReadResourceParams( + uri = "file:///x", + inputResponses = Some(sampleInputResponses), + requestState = Some("s"), + _meta = Some(requestMeta) + ) + ) From 02047c76b48582511f32143659dd8a35df82ffba Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Wed, 23 Sep 2026 15:08:13 +0200 Subject: [PATCH 4/4] feat(T8): modern request validation and HTTP error statuses (#246) Enforce the 2026-07-28 request rules in McpHandler and surface them with the right HTTP status: - missing _meta.protocolVersion / _meta.clientCapabilities -> -32602, HTTP 400 - MCP-Protocol-Version header vs _meta mismatch -> -32020 HeaderMismatch, 400 - unsupported declared version -> -32022, 400 - removed (initialize, ping, logging/setLevel, resources/{,un}subscribe) and unknown methods in the modern era -> -32601, HTTP 404 - SEP-2243 standard request headers: Mcp-Method must equal the body method and, for tools/call, Mcp-Name must equal params.name (HTTP only, via the Host header) McpResponse gains a JsonError(status, json) case; the streaming HTTP transport now validates the request before opening a stream and returns modern validation errors as plain errors with their own status instead of a 200 SSE stream. Add JSONRPCErrorCodes HeaderMismatch (-32020) and MissingRequiredClientCapability (-32021). Server conformance 2026-07-28: 100 -> 111 checks passing, no regressions. Still open on #246: notifications/message logLevel gating, request facts via server context, and the -32021 capability check with the test_missing_capability / test_logging_tool fixtures. Co-Authored-By: Claude Opus 4.8 --- .../main/scala/chimp/protocol/JsonRpc.scala | 2 + .../scala/chimp/protocol/Versioning.scala | 2 + .../chimp/server/ox/OxMcpServerHttpSpec.scala | 4 +- .../server/zio/ZioMcpServerHttpSpec.scala | 2 +- .../main/scala/chimp/server/McpHandler.scala | 133 ++++++++++++++++-- .../ServerStreamingHttpTransport.scala | 19 ++- .../scala/chimp/server/McpHandlerSpec.scala | 41 +++++- 7 files changed, 180 insertions(+), 23 deletions(-) diff --git a/core/src/main/scala/chimp/protocol/JsonRpc.scala b/core/src/main/scala/chimp/protocol/JsonRpc.scala index b9e3790c..00c1dcde 100644 --- a/core/src/main/scala/chimp/protocol/JsonRpc.scala +++ b/core/src/main/scala/chimp/protocol/JsonRpc.scala @@ -67,4 +67,6 @@ enum JSONRPCErrorCodes(val code: Int): case InternalError extends JSONRPCErrorCodes(-32603) case InvocationError extends JSONRPCErrorCodes(-32000) case ResourceNotFound extends JSONRPCErrorCodes(-32002) + case HeaderMismatch extends JSONRPCErrorCodes(-32020) + case MissingRequiredClientCapability extends JSONRPCErrorCodes(-32021) case UnsupportedProtocolVersion extends JSONRPCErrorCodes(-32022) diff --git a/core/src/main/scala/chimp/protocol/Versioning.scala b/core/src/main/scala/chimp/protocol/Versioning.scala index a5eac0e2..9a5cacb4 100644 --- a/core/src/main/scala/chimp/protocol/Versioning.scala +++ b/core/src/main/scala/chimp/protocol/Versioning.scala @@ -13,6 +13,8 @@ private given Codec[FiniteDuration] = * and capabilities travel with each request instead of an `initialize` handshake. */ object ProtocolMeta: + /** HTTP header carrying the protocol version on modern (2026-07-28+) requests; must agree with the `_meta` version. */ + val ProtocolVersionHeader: String = "MCP-Protocol-Version" val ProtocolVersionKey: String = "io.modelcontextprotocol/protocolVersion" val ClientInfo: String = "io.modelcontextprotocol/clientInfo" val ClientCapabilities: String = "io.modelcontextprotocol/clientCapabilities" diff --git a/server-streaming/server-ox/src/test/scala/chimp/server/ox/OxMcpServerHttpSpec.scala b/server-streaming/server-ox/src/test/scala/chimp/server/ox/OxMcpServerHttpSpec.scala index 724be68b..b2e49976 100644 --- a/server-streaming/server-ox/src/test/scala/chimp/server/ox/OxMcpServerHttpSpec.scala +++ b/server-streaming/server-ox/src/test/scala/chimp/server/ox/OxMcpServerHttpSpec.scala @@ -45,7 +45,7 @@ class OxMcpServerHttpSpec withHttpServer(OxServerHttpTransport(List("mcp")).serve(server)): (port, backend, ox) => given Ox = ox val transport = - OxClientHttpTransport(backend, uri"http://localhost:$port/mcp", ProtocolVersion.Latest, ClientTransport.defaultTimeout) + OxClientHttpTransport(backend, uri"http://localhost:$port/mcp", ProtocolVersion.LatestLegacy, ClientTransport.defaultTimeout) try test(McpClient.bidirectional(transport, clientInfo)) finally transport.close() @@ -72,7 +72,7 @@ class OxMcpServerHttpSpec val transport = OxClientHttpTransport( backend, uri"http://localhost:$port/mcp", - ProtocolVersion.Latest, + ProtocolVersion.LatestLegacy, ClientTransport.defaultTimeout, headers = List(Header.authorization("Bearer", token)) ) diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala index 8bd995a8..d91876ba 100644 --- a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala @@ -74,7 +74,7 @@ class ZioMcpServerHttpSpec .scoped( backend, uri"http://localhost:$port/mcp", - ProtocolVersion.Latest, + ProtocolVersion.LatestLegacy, ClientTransport.defaultTimeout, headers = headers ) diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index cf77c319..3575cfe2 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -12,19 +12,23 @@ import sttp.tapir.docs.apispec.schema.TapirSchemaToJsonSchema enum McpResponse: case JsonResponse(json: Json) + case JsonError(status: StatusCode, json: Json) case EmptyAcceptResponse def statusCode: StatusCode = this match - case JsonResponse(_) => StatusCode.Ok - case EmptyAcceptResponse => StatusCode.Accepted + case JsonResponse(_) => StatusCode.Ok + case JsonError(status, _) => status + case EmptyAcceptResponse => StatusCode.Accepted def body: Option[Json] = this match case JsonResponse(json) => Some(json) + case JsonError(_, json) => Some(json) case EmptyAcceptResponse => None def withNullsDroppedDeep: McpResponse = this match - case JsonResponse(json) => JsonResponse(json.deepDropNullValues) - case EmptyAcceptResponse => this + case JsonResponse(json) => JsonResponse(json.deepDropNullValues) + case JsonError(status, json) => JsonError(status, json.deepDropNullValues) + case EmptyAcceptResponse => this private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): private val logger = LoggerFactory.getLogger(classOf[McpHandler[?, ?]]) @@ -74,14 +78,9 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD case Left(err) => jsonResponse(protocolError(RequestId("null"), JSONRPCErrorCodes.ParseError.code, s"Parse error: ${err.message}")).unit case Right(JSONRPCMessage.Request(_, method, params: Option[Json], id)) => - val requestedVersion = params.flatMap(_.hcursor.downField("_meta").downField(ProtocolMeta.ProtocolVersionKey).as[String].toOption) - requestedVersion match - case Some(version) if ProtocolVersion.from(version).isEmpty => - jsonResponse( - JSONRPCMessage.Error(id = id, error = ProtocolMeta.unsupportedVersionError(version, ProtocolVersion.supported.map(_.name))) - ).unit - case _ => - dispatch(method, params, id, headers, makeContext, requestedVersion.flatMap(ProtocolVersion.from).exists(_.isModern)) + requestRejection(method, params, headers, id) match + case Some(rejection) => rejection.unit + case None => dispatch(method, params, id, headers, makeContext, isModernRequest(method, params, headers)) case Right(notification: JSONRPCMessage.Notification) => logger.debug(s"Received notification: ${notification.method}") McpResponse.EmptyAcceptResponse.unit @@ -153,6 +152,116 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD private def jsonResponse(message: JSONRPCMessage): McpResponse = McpResponse.JsonResponse(message.asJson) + // request methods a 2026-07-28 client may call (server/discover is handled separately as the era-agnostic bootstrap) + private val modernMethods: Set[String] = + Set( + "tools/list", + "tools/call", + "resources/list", + "resources/templates/list", + "resources/read", + "prompts/list", + "prompts/get", + "completion/complete" + ) + + private def jsonError(id: RequestId, status: StatusCode, code: Int, message: String, data: Option[Json] = None): McpResponse = + logger.debug(s"Modern request rejected (id=$id, http=${status.code}, code=$code): $message") + val envelope: JSONRPCMessage = JSONRPCMessage.Error(id = id, error = JSONRPCErrorObject(code = code, message = message, data = data)) + McpResponse.JsonError(status, envelope.asJson) + + /** Whether the request follows the modern (2026-07-28) rules: it declares a version in `_meta` or via a modern `MCP-Protocol-Version` + * header. `server/discover` is the era-agnostic bootstrap and is never treated as modern here. + */ + private def isModernRequest(method: String, params: Option[Json], headers: Seq[Header]): Boolean = + val metaVersion = params.flatMap(_.hcursor.downField("_meta").downField(ProtocolMeta.ProtocolVersionKey).as[String].toOption) + val headerVersion = headers.collectFirst { case h if h.name.equalsIgnoreCase(ProtocolMeta.ProtocolVersionHeader) => h.value.trim } + method != "server/discover" && (metaVersion.isDefined || headerVersion.flatMap(ProtocolVersion.from).exists(_.isModern)) + + /** The rejection to send for an invalid request, or `None` if it may be dispatched. Runs no tool logic, so a transport can call it before + * deciding how to respond (a modern validation error is a plain-JSON HTTP error, not an SSE stream). + */ + def requestRejection(method: String, params: Option[Json], headers: Seq[Header], id: RequestId): Option[McpResponse] = + val meta = params.flatMap(_.hcursor.downField("_meta").focus) + val metaVersion = meta.flatMap(_.hcursor.get[String](ProtocolMeta.ProtocolVersionKey).toOption) + val headerVersion = headers.collectFirst { case h if h.name.equalsIgnoreCase(ProtocolMeta.ProtocolVersionHeader) => h.value.trim } + if isModernRequest(method, params, headers) then modernRejection(method, params, meta, metaVersion, headerVersion, headers, id) + else + metaVersion match + case Some(version) if ProtocolVersion.from(version).isEmpty => + Some( + jsonResponse( + JSONRPCMessage.Error(id = id, error = ProtocolMeta.unsupportedVersionError(version, ProtocolVersion.supported.map(_.name))) + ) + ) + case _ => None + + /** Parses `request` and returns the rejection for an invalid (modern) request, or `None`; runs no tool logic. */ + def validateRequest(request: Json, headers: Seq[Header]): Option[McpResponse] = + request.as[JSONRPCMessage] match + case Right(JSONRPCMessage.Request(_, method, params: Option[Json], id)) => requestRejection(method, params, headers, id) + case _ => None + + /** Validates a modern (2026-07-28) request; `Some` is the rejection to send, `None` means it may be dispatched. */ + private def modernRejection( + method: String, + params: Option[Json], + meta: Option[Json], + metaVersion: Option[String], + headerVersion: Option[String], + headers: Seq[Header], + id: RequestId + ): Option[McpResponse] = + val declared = metaVersion.orElse(headerVersion) + if declared.exists(version => ProtocolVersion.from(version).isEmpty) then + val envelope: JSONRPCMessage = + JSONRPCMessage.Error(id = id, error = ProtocolMeta.unsupportedVersionError(declared.get, ProtocolVersion.supported.map(_.name))) + Some(McpResponse.JsonError(StatusCode.BadRequest, envelope.asJson)) + else if headerVersion.isDefined && metaVersion.isDefined && headerVersion != metaVersion then + Some(jsonError(id, StatusCode.BadRequest, JSONRPCErrorCodes.HeaderMismatch.code, "MCP-Protocol-Version header does not match _meta")) + else if metaVersion.isEmpty then + Some( + jsonError(id, StatusCode.BadRequest, JSONRPCErrorCodes.InvalidParams.code, s"Missing ${ProtocolMeta.ProtocolVersionKey} in _meta") + ) + else if meta.flatMap(_.hcursor.downField(ProtocolMeta.ClientCapabilities).focus).isEmpty then + Some( + jsonError(id, StatusCode.BadRequest, JSONRPCErrorCodes.InvalidParams.code, s"Missing ${ProtocolMeta.ClientCapabilities} in _meta") + ) + else if !modernMethods.contains(method) then + Some(jsonError(id, StatusCode.NotFound, JSONRPCErrorCodes.MethodNotFound.code, s"Method not found: $method")) + else if headers.exists(_.name.equalsIgnoreCase("Host")) then + // SEP-2243 standard request headers only apply over HTTP (identified by the Host header); stdio carries no such headers + headerValidationError(method, params, headers, id) + else None + + /** SEP-2243 standard request headers: `Mcp-Method` must equal the body method, and for `tools/call` `Mcp-Name` must equal `params.name` + * (both case-sensitive, surrounding whitespace ignored). A missing or mismatched header is a 400. + */ + private def headerValidationError(method: String, params: Option[Json], headers: Seq[Header], id: RequestId): Option[McpResponse] = + def header(name: String): Option[String] = headers.collectFirst { case h if h.name.equalsIgnoreCase(name) => h.value.trim } + if !header("Mcp-Method").contains(method) then + Some( + jsonError( + id, + StatusCode.BadRequest, + JSONRPCErrorCodes.InvalidParams.code, + "Mcp-Method header missing or does not match the request method" + ) + ) + else if method == "tools/call" then + val bodyName = params.flatMap(_.hcursor.get[String]("name").toOption) + if bodyName.exists(name => !header("Mcp-Name").contains(name)) then + Some( + jsonError( + id, + StatusCode.BadRequest, + JSONRPCErrorCodes.InvalidParams.code, + "Mcp-Name header missing or does not match params.name" + ) + ) + else None + else None + private val serverImplementation: Implementation = Implementation(server.name, server.version) private val serverInfoJson: Json = serverImplementation.asJson private val serverInfoMeta: Json = Json.obj(ProtocolMeta.ServerInfo -> serverInfoJson) diff --git a/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala b/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala index 8ae75034..8e4c1861 100644 --- a/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala +++ b/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala @@ -4,7 +4,7 @@ import chimp.protocol.ProgressToken import chimp.server.* import io.circe.Json import sttp.capabilities.Streams -import sttp.model.Header +import sttp.model.{Header, StatusCode} import sttp.monad.MonadError import sttp.monad.syntax.* import sttp.tapir.* @@ -43,10 +43,17 @@ abstract class ServerStreamingHttpTransport[F[_], Caps](path: List[String]) exte me => { (input: (Seq[Header], Json)) => val (headers, json) = input given MonadError[F] = me - respondWithEventStream(server.originCheck, headers, this) { sink => - val makeContext: Option[ProgressToken] => StreamingServerContext[F] = - token => SinkStreamingServerContext(sink, token) - handler.handleJsonRpc(json, headers, makeContext).map(_.body) - }.map(Right(_)) + if !originAllowed(server.originCheck, headers) then me.unit(Right((StatusCode.Forbidden, emptyStream))) + else + handler.validateRequest(json, headers) match + // a modern validation error is an immediate plain error carrying its own HTTP status, not a 200 SSE stream + case Some(rejection) => + eventStream(_ => me.unit(rejection.body)).map(events => Right((rejection.statusCode, events))) + case None => + eventStream { sink => + val makeContext: Option[ProgressToken] => StreamingServerContext[F] = + token => SinkStreamingServerContext(sink, token) + handler.handleJsonRpc(json, headers, makeContext).map(_.body) + }.map(events => Right((StatusCode.Ok, events))) } ) diff --git a/server/src/test/scala/chimp/server/McpHandlerSpec.scala b/server/src/test/scala/chimp/server/McpHandlerSpec.scala index 9ae61415..f53f94a0 100644 --- a/server/src/test/scala/chimp/server/McpHandlerSpec.scala +++ b/server/src/test/scala/chimp/server/McpHandlerSpec.scala @@ -83,7 +83,8 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: private def extractJsonFromResponse(response: McpResponse): Json = response match case McpResponse.JsonResponse(json) => json - case McpResponse.EmptyAcceptResponse => fail("Expected JsonResponse but got EmptyAcceptResponse") + case McpResponse.JsonError(_, json) => json + case McpResponse.EmptyAcceptResponse => fail("Expected a body but got EmptyAcceptResponse") "McpHandler" should "respond to initialize" in: val req: JSONRPCMessage = Request(method = "initialize", id = RequestId("1")) @@ -126,13 +127,49 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "process a request declaring a supported modern protocol version" in: - val params = Json.obj("_meta" -> Json.obj(ProtocolMeta.ProtocolVersionKey -> Json.fromString("2026-07-28"))) + val params = Json.obj( + "_meta" -> Json.obj( + ProtocolMeta.ProtocolVersionKey -> Json.fromString("2026-07-28"), + ProtocolMeta.ClientCapabilities -> Json.obj() + ) + ) val req: JSONRPCMessage = Request(method = "tools/list", params = Some(params), id = RequestId("m")) val respJson = extractJsonFromResponse(handler.handleJsonRpc(req.asJson, Seq.empty)) respJson.as[JSONRPCMessage].getOrElse(fail("decode")) match case Response(_, _, result) => result.as[ListToolsResponse].isRight shouldBe true case _ => fail("Expected Response") + it should "reject a modern request whose _meta omits clientCapabilities with -32602 and HTTP 400" in: + val params = Json.obj("_meta" -> Json.obj(ProtocolMeta.ProtocolVersionKey -> Json.fromString("2026-07-28"))) + val req: JSONRPCMessage = Request(method = "tools/list", params = Some(params), id = RequestId("mc")) + val response = handler.handleJsonRpc(req.asJson, Seq.empty) + response.statusCode shouldBe sttp.model.StatusCode.BadRequest + extractJsonFromResponse(response).as[JSONRPCMessage].getOrElse(fail("decode")) match + case Error(_, _, err) => err.code shouldBe JSONRPCErrorCodes.InvalidParams.code + case _ => fail("Expected Error") + + it should "reject a modern request with a header/_meta version mismatch with -32020 and HTTP 400" in: + val params = Json.obj( + "_meta" -> Json.obj(ProtocolMeta.ProtocolVersionKey -> Json.fromString("2026-07-28"), ProtocolMeta.ClientCapabilities -> Json.obj()) + ) + val req: JSONRPCMessage = Request(method = "tools/list", params = Some(params), id = RequestId("hm")) + val response = handler.handleJsonRpc(req.asJson, Seq(Header(ProtocolMeta.ProtocolVersionHeader, "2025-11-25"))) + response.statusCode shouldBe sttp.model.StatusCode.BadRequest + extractJsonFromResponse(response).as[JSONRPCMessage].getOrElse(fail("decode")) match + case Error(_, _, err) => err.code shouldBe JSONRPCErrorCodes.HeaderMismatch.code + case _ => fail("Expected Error") + + it should "reject a modern removed method (initialize) with -32601 and HTTP 404" in: + val params = Json.obj( + "_meta" -> Json.obj(ProtocolMeta.ProtocolVersionKey -> Json.fromString("2026-07-28"), ProtocolMeta.ClientCapabilities -> Json.obj()) + ) + val req: JSONRPCMessage = Request(method = "initialize", params = Some(params), id = RequestId("ri")) + val response = handler.handleJsonRpc(req.asJson, Seq.empty) + response.statusCode shouldBe sttp.model.StatusCode.NotFound + extractJsonFromResponse(response).as[JSONRPCMessage].getOrElse(fail("decode")) match + case Error(_, _, err) => err.code shouldBe JSONRPCErrorCodes.MethodNotFound.code + case _ => fail("Expected Error") + it should "serve a classic initialize with no modern protocol version rather than rejecting it with -32022" in: val req: JSONRPCMessage = Request(method = "initialize", id = RequestId("legacy-init")) val respJson = extractJsonFromResponse(handler.handleJsonRpc(req.asJson, Seq.empty))