Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 17 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
}
Expand Down
13 changes: 8 additions & 5 deletions client-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
|-----------------|--------------|-----------|---------------------------------------|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
File renamed without changes.
44 changes: 42 additions & 2 deletions conformance-baseline-2026-07-28.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
50 changes: 45 additions & 5 deletions core/src/main/scala/chimp/protocol/Elicitation.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package chimp.protocol

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

enum ElicitAction:
Expand All @@ -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

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
22 changes: 22 additions & 0 deletions core/src/main/scala/chimp/protocol/Metadata.scala
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions core/src/main/scala/chimp/protocol/Mrtr.scala
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 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 All @@ -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
Expand All @@ -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
11 changes: 10 additions & 1 deletion 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 Expand Up @@ -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

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