Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
```
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion core/src/main/scala/chimp/protocol/Lifecycle.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 57 additions & 0 deletions core/src/main/scala/chimp/protocol/Metadata.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package chimp.protocol

import io.circe.{Codec, Decoder, Encoder, Json}

/** 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[IconTheme] = None
) derives Codec

/** 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[Priority] = None
) derives Codec
3 changes: 2 additions & 1 deletion core/src/main/scala/chimp/protocol/Prompts.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions core/src/main/scala/chimp/protocol/Resources.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
133 changes: 97 additions & 36 deletions core/src/main/scala/chimp/protocol/Tools.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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))

Expand Down
71 changes: 71 additions & 0 deletions core/src/test/scala/chimp/protocol/SchemaConformance.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading