diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e733400b..0af5f333 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: run: sbt -v test - name: Client conformance tests (2025-11-25) run: sbt -v "clientConformance/conformance client --requirements 2025-11-25" + - name: Client conformance tests (2026-07-28) + run: sbt -v "clientConformance/conformance client --requirements 2026-07-28" - name: Server conformance tests (2025-11-25) run: sbt -v "serverConformance/conformance server --requirements 2025-11-25" - name: Server conformance tests (2026-07-28) diff --git a/build.sbt b/build.sbt index fdd2a057..e2d71c3e 100644 --- a/build.sbt +++ b/build.sbt @@ -251,7 +251,7 @@ lazy val serverConformance = (project in file("server-conformance")) val baseline = specVersion .map(v => rootDir / s"conformance-baseline-$v.yml") .filter(_.exists) - .getOrElse(rootDir / "conformance-baseline.yml") + .getOrElse(rootDir / "conformance-baseline-2025-11-25.yml") .getAbsolutePath val log = streams.value.log @@ -327,8 +327,23 @@ lazy val clientConformance = (project in file("client-conformance")) val baseDir = baseDirectory.value val rootDir = (LocalRootProject / baseDirectory).value val wrapper = (baseDir / "bin" / "chimp-conformance-client").getAbsolutePath + val specVersion = args + .sliding(2) + .collectFirst { + case Seq("--requirements", v) => v + case Seq("--spec-version", v) => v + } + .orElse(args.collectFirst { + case a if a.startsWith("--requirements=") => a.stripPrefix("--requirements=") + case a if a.startsWith("--spec-version=") => a.stripPrefix("--spec-version=") + }) + val baseline = specVersion + .map(v => rootDir / s"conformance-baseline-$v.yml") + .filter(_.exists) + .getOrElse(rootDir / "conformance-baseline-2025-11-25.yml") + .getAbsolutePath val cmd = List("npx", s"@modelcontextprotocol/conformance@$conformanceHarnessV") ++ args ++ - List("--command", wrapper, "--expected-failures", (rootDir / "conformance-baseline.yml").getAbsolutePath) + List("--command", wrapper, "--expected-failures", baseline) val rc = Process(cmd, rootDir, "CHIMP_CLIENT_CONFORMANCE_JAR" -> jar.getAbsolutePath).! if (rc != 0) sys.error(s"conformance harness exited with code $rc") } diff --git a/client-conformance/README.md b/client-conformance/README.md index 4e0250eb..525d1a3f 100644 --- a/client-conformance/README.md +++ b/client-conformance/README.md @@ -22,17 +22,18 @@ The sbt task pins the harness to the version set in `conformanceHarnessV` in [`b ## How to run -Run the full requirement set: +Run a full requirement set (CI runs both revisions): ```bash sbt 'clientConformance/conformance client --requirements 2025-11-25' +sbt 'clientConformance/conformance client --requirements 2026-07-28' ``` -Run one scenario during development: +Run one scenario during development (pass the spec version): ```bash sbt 'clientConformance/conformance client --scenario initialize --spec-version 2025-11-25' -sbt 'clientConformance/conformance client --scenario tools_call --spec-version 2025-11-25' +sbt 'clientConformance/conformance client --scenario tools_call --spec-version 2026-07-28' ``` The sbt task downloads `@modelcontextprotocol/conformance` with npm; `npx` must be available on the PATH. @@ -44,8 +45,10 @@ with `McpClient`, return exit code 0 on success. Once it passes, remove the entr ## The baseline file -[`conformance-baseline.yml`](../conformance-baseline.yml) lists scenarios that are known to fail today. The harness -uses it like this: +Each spec revision keeps its own baseline, selected from the run's `--requirements` (or `--spec-version`): +[`conformance-baseline-2025-11-25.yml`](../conformance-baseline-2025-11-25.yml) and +[`conformance-baseline-2026-07-28.yml`](../conformance-baseline-2026-07-28.yml). Each file lists the scenarios (in its +`server:` and `client:` sections) known to fail today. The harness uses the chosen file like this: | Scenario result | In baseline? | Exit code | Meaning | |-----------------|--------------|-----------|---------------------------------------| 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/conformance-baseline.yml b/conformance-baseline-2025-11-25.yml similarity index 100% rename from conformance-baseline.yml rename to conformance-baseline-2025-11-25.yml diff --git a/conformance-baseline-2026-07-28.yml b/conformance-baseline-2026-07-28.yml index d6bdc93a..5c0d8b66 100644 --- a/conformance-baseline-2026-07-28.yml +++ b/conformance-baseline-2026-07-28.yml @@ -1,6 +1,6 @@ -# Expected server-conformance failures for spec version 2026-07-28. +# Expected conformance failures for spec version 2026-07-28 (server and client). # These need protocol features that land in later tickets; remove each entry once its -# scenario passes. See server-conformance/README.md for how the baseline is used. +# scenario passes. See server-conformance/README.md and client-conformance/README.md. server: # multi-round-trip requests (tasks) — T5, https://github.com/softwaremill/chimp/issues/243 - tasks-capability-negotiation @@ -31,3 +31,43 @@ server: - sep-2164-resource-not-found - input-required-result-missing-input-response - input-required-result-ignore-extra-params +client: + # OAuth client flows — chimp's client has no full OAuth support yet + - auth/authorization-server-migration + - auth/basic-cimd + - auth/client-credentials-basic + - auth/client-credentials-jwt + - auth/dpop + - auth/dpop-nonce + - auth/enterprise-managed-authorization + - auth/iss-normalized + - auth/iss-not-advertised + - auth/iss-supported + - auth/iss-supported-missing + - auth/iss-unexpected + - auth/iss-wrong-issuer + - auth/metadata-default + - auth/metadata-issuer-mismatch + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/offline-access-not-supported + - auth/offline-access-scope + - auth/pre-registration + - auth/scope-from-scopes-supported + - auth/scope-from-www-authenticate + - auth/scope-omitted-when-undefined + - auth/scope-retry-limit + - auth/scope-step-up + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-none + - auth/token-endpoint-auth-post + - auth/wif-jwt-bearer + # modern (2026-07-28) client era: stateless lifecycle, request _meta, HTTP headers — T10-T12, #248/#249/#250 + - tools_call + - http-custom-headers + - http-invalid-tool-headers + - json-schema-2020-12-preservation + - json-schema-ref-no-deref + - request-metadata + - sep-2322-client-request-state 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/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/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 8db10a3f..86782a43 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 @@ -42,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 @@ -53,4 +56,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..fc40125e 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 @@ -84,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/Subscriptions.scala b/core/src/main/scala/chimp/protocol/Subscriptions.scala new file mode 100644 index 00000000..4a8992c8 --- /dev/null +++ b/core/src/main/scala/chimp/protocol/Subscriptions.scala @@ -0,0 +1,34 @@ +package chimp.protocol + +import io.circe.{Codec, Json} + +/** The notification types a client opts into on a `subscriptions/listen` stream (2026-07-28). Each type is opt-in: the server MUST NOT send + * a type the client has not requested. `resourceSubscriptions` replaces the former `resources/subscribe` RPC. + */ +final case class SubscriptionFilter( + promptsListChanged: Option[Boolean] = None, + resourceSubscriptions: Option[List[String]] = None, + resourcesListChanged: Option[Boolean] = None, + toolsListChanged: Option[Boolean] = None +) derives Codec + +/** Params of a `subscriptions/listen` request: the notification filter the client opts into on the stream. */ +final case class SubscriptionsListenParams(notifications: SubscriptionFilter, _meta: Option[Map[String, Json]] = None) derives Codec + +final case class SubscriptionsListenRequest(method: String = "subscriptions/listen", params: SubscriptionsListenParams) derives Codec + +/** Result of `subscriptions/listen`, sent only when the server tears the subscription down gracefully (e.g. on shutdown); the body is + * otherwise empty. The subscription id travels in `_meta` under `io.modelcontextprotocol/subscriptionId`. + */ +final case class SubscriptionsListenResult(resultType: ResultType = ResultType.Complete, _meta: Option[Map[String, Json]] = None) + derives Codec + +/** Params of the `notifications/subscriptions/acknowledged` notification: the subset of requested notification types the server will honor. + */ +final case class SubscriptionsAcknowledgedParams(notifications: SubscriptionFilter, _meta: Option[Map[String, Json]] = None) derives Codec + +/** The `notifications/subscriptions/acknowledged` notification the server sends first on a listen stream. */ +final case class SubscriptionsAcknowledgedNotification( + method: String = "notifications/subscriptions/acknowledged", + params: SubscriptionsAcknowledgedParams +) derives Codec diff --git a/core/src/main/scala/chimp/protocol/Tools.scala b/core/src/main/scala/chimp/protocol/Tools.scala index 49349425..100d56b0 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,32 +128,51 @@ 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)) 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 3ee7283f..a5eac0e2 100644 --- a/core/src/main/scala/chimp/protocol/Versioning.scala +++ b/core/src/main/scala/chimp/protocol/Versioning.scala @@ -17,6 +17,7 @@ object ProtocolMeta: val ClientInfo: String = "io.modelcontextprotocol/clientInfo" val ClientCapabilities: String = "io.modelcontextprotocol/clientCapabilities" val ServerInfo: String = "io.modelcontextprotocol/serverInfo" + val SubscriptionId: String = "io.modelcontextprotocol/subscriptionId" /** An `UnsupportedProtocolVersion` error (`-32022`) naming the versions the server supports, so the client can retry with one of them. */ def unsupportedVersionError(requested: String, supported: List[String]): JSONRPCErrorObject = @@ -26,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 7ed65d17..1c27984f 100644 --- a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala +++ b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala @@ -34,3 +34,151 @@ class Schema20260728ConformanceSpec extends SchemaConformance: val msg: JSONRPCMessage = JSONRPCMessage.Error(id = RequestId(1), error = ProtocolMeta.unsupportedVersionError("1900-01-01", List("2026-07-28", "2025-11-25"))) validate("UnsupportedProtocolVersionError", msg) + + it should "produce a SubscriptionFilter that matches the spec schema" in: + validate( + "SubscriptionFilter", + SubscriptionFilter(resourceSubscriptions = Some(List("file:///x")), resourcesListChanged = Some(true), toolsListChanged = Some(false)) + ) + + it should "produce SubscriptionsListenRequestParams that match the spec schema" in: + validate( + "SubscriptionsListenRequestParams", + SubscriptionsListenParams( + notifications = SubscriptionFilter(resourcesListChanged = Some(true)), + _meta = Some(Map(ProtocolMeta.ProtocolVersionKey -> "2026-07-28".asJson, ProtocolMeta.ClientCapabilities -> io.circe.Json.obj())) + ) + ) + + it should "produce a SubscriptionsListenResult that matches the spec schema" in: + validate( + "SubscriptionsListenResult", + SubscriptionsListenResult(_meta = + Some( + Map( + ProtocolMeta.ServerInfo -> Implementation(name = "chimp", version = "1.0").asJson.deepDropNullValues, + ProtocolMeta.SubscriptionId -> "sub-1".asJson + ) + ) + ) + ) + + it should "produce SubscriptionsAcknowledgedNotificationParams that match the spec schema" in: + validate( + "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) + + // 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) + ) + ) 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 089cc5bc..15cac373 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() diff --git a/examples/src/main/scala/examples/both/serverAndClient.scala b/examples/src/main/scala/examples/both/serverAndClient.scala index db459219..b339cacf 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 faf7c8bf..04f86378 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 790a9026..a1a11996 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-conformance/README.md b/server-conformance/README.md index b0ed7282..c44ea505 100644 --- a/server-conformance/README.md +++ b/server-conformance/README.md @@ -17,17 +17,18 @@ The sbt task pins the harness to the version set in `conformanceHarnessV` in [`b ## How to run -Run the full requirement set: +Run a full requirement set (CI runs both revisions): ```bash sbt 'serverConformance/conformance server --requirements 2025-11-25' +sbt 'serverConformance/conformance server --requirements 2026-07-28' ``` -Run one scenario during development: +Run one scenario during development (pass the spec version): ```bash sbt 'serverConformance/conformance server --scenario ping --spec-version 2025-11-25' -sbt 'serverConformance/conformance server --scenario server-initialize --spec-version 2025-11-25' +sbt 'serverConformance/conformance server --scenario server-discover --spec-version 2026-07-28' ``` The sbt task downloads `@modelcontextprotocol/conformance` with npm; `npx` must be available on the PATH. @@ -41,10 +42,10 @@ and what result shape it wants. Remove the corresponding entry from the baseline ## The baseline file -[`conformance-baseline.yml`](../conformance-baseline.yml) lists scenarios that are known to fail today. When a -`conformance-baseline-.yml` exists for the run's spec version (e.g. -[`conformance-baseline-2026-07-28.yml`](../conformance-baseline-2026-07-28.yml) for `--requirements 2026-07-28`), the sbt -task uses it instead — each spec revision keeps its own baseline. The harness uses the chosen file like this: +Each spec revision keeps its own baseline, selected from the run's `--requirements` (or `--spec-version`): +[`conformance-baseline-2025-11-25.yml`](../conformance-baseline-2025-11-25.yml) and +[`conformance-baseline-2026-07-28.yml`](../conformance-baseline-2026-07-28.yml). Each file lists the scenarios (in its +`server:` and `client:` sections) known to fail today. The harness uses the chosen file like this: | Scenario result | In baseline? | Exit code | Meaning | |-----------------|--------------|-----------|---------------------------------------| diff --git a/server/src/main/scala/chimp/server/Prompt.scala b/server/src/main/scala/chimp/server/Prompt.scala index 6e420819..6b89067c 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))) @@ -41,7 +45,7 @@ case class PartialPrompt( handleWithHeaders((args, _) => logic(args)) 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 60ec2d29..55ce076a 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]]]): ServerResource[F] = ServerResource(definition, logic) @@ -52,7 +60,7 @@ case class PartialResource( def handle(logic: () => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = handleWithHeaders(_ => logic()) - 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 @@ -65,7 +73,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)) @@ -79,6 +89,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]]] @@ -96,7 +112,7 @@ case class PartialResourceTemplate( handleWithHeaders((vars, uri, _) => logic(vars, uri)) 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