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
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ object OxClientHttpTransport:
def apply(
backend: SyncBackend,
uri: Uri,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
protocolVersion: ProtocolVersion = ProtocolVersion.LatestLegacy,
timeout: FiniteDuration = ClientTransport.defaultTimeout,
headers: Seq[Header] = Nil
)(using Ox): OxClientHttpTransport =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ object PekkoClientHttpTransport:
def apply(
backend: StreamBackend[Future, PekkoStreams],
uri: Uri,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
protocolVersion: ProtocolVersion = ProtocolVersion.LatestLegacy,
timeout: FiniteDuration = ClientTransport.defaultTimeout,
reconnectSettings: RestartSettings = defaultReconnectSettings,
headers: Seq[Header] = Nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ class PekkoMcpClientHttpIntegrationSpec
override def usingBidirectionalTransport[A](b: StreamBackend[Future, PekkoStreams], uri: Uri, timeout: FiniteDuration)(
use: ClientBidirectionalTransport[Future] => Future[A]
): Future[A] =
val transport = PekkoClientHttpTransport(b, uri, ProtocolVersion.Latest, timeout)
val transport = PekkoClientHttpTransport(b, uri, ProtocolVersion.LatestLegacy, timeout)
monad.ensure2(use(transport), transport.close())
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ object ZioClientHttpTransport:
def apply(
backend: StreamBackend[Task, ZioStreams],
uri: Uri,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
protocolVersion: ProtocolVersion = ProtocolVersion.LatestLegacy,
timeout: FiniteDuration = ClientTransport.defaultTimeout,
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule,
headers: Seq[Header] = Nil
Expand Down Expand Up @@ -306,7 +306,7 @@ object ZioClientHttpTransport:
def scoped(
backend: StreamBackend[Task, ZioStreams],
uri: Uri,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
protocolVersion: ProtocolVersion = ProtocolVersion.LatestLegacy,
timeout: FiniteDuration = ClientTransport.defaultTimeout,
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule,
headers: Seq[Header] = Nil
Expand All @@ -316,7 +316,7 @@ object ZioClientHttpTransport:
def layer(
backend: StreamBackend[Task, ZioStreams],
uri: Uri,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
protocolVersion: ProtocolVersion = ProtocolVersion.LatestLegacy,
timeout: FiniteDuration = ClientTransport.defaultTimeout,
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule,
headers: Seq[Header] = Nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ class ZioMcpClientHttpIntegrationSpec extends McpClientStreamingHttpIntegrationS
override def usingBidirectionalTransport[A](b: StreamBackend[Task, ZioStreams], uri: Uri, timeout: FiniteDuration)(
use: ClientBidirectionalTransport[Task] => Task[A]
): Task[A] =
ZIO.scoped(ZioClientHttpTransport.scoped(b, uri, ProtocolVersion.Latest, timeout).flatMap(use))
ZIO.scoped(ZioClientHttpTransport.scoped(b, uri, ProtocolVersion.LatestLegacy, timeout).flatMap(use))
4 changes: 2 additions & 2 deletions client/src/main/scala/chimp/client/McpClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ object McpClient:
def apply[F[_]](
transport: ClientTransport[F],
clientInfo: Implementation,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest
protocolVersion: ProtocolVersion = ProtocolVersion.LatestLegacy
): F[McpClient[F]] =
McpClientImpl.create(transport, clientInfo, protocolVersion)

Expand All @@ -154,6 +154,6 @@ object McpClient:
rootsHandler: Option[() => F[ListRootsResult]] = None,
samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None,
elicitationHandler: Option[ElicitRequest => F[ElicitResult]] = None,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest
protocolVersion: ProtocolVersion = ProtocolVersion.LatestLegacy
): F[BidirectionalMcpClient[F]] =
McpClientImpl.createBidirectional(transport, clientInfo, protocolVersion, rootsHandler, samplingHandler, elicitationHandler)
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import scala.util.chaining.*
final class ClientHttpTransport[F[_]](
backend: Backend[F],
uri: Uri,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
protocolVersion: ProtocolVersion = ProtocolVersion.LatestLegacy,
headers: Seq[Header] = Nil
) extends ClientTransport[F]:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,4 @@ class ClientHttpTransportSpec extends AnyFlatSpec with Matchers:

val request = backend.allInteractions.map { case (request, _) => request }.head
request.headers.filter(_.is("Accept")) shouldBe List(Header("Accept", "application/json, text/event-stream"))
request.header("MCP-Protocol-Version") shouldBe Some(ProtocolVersion.Latest.name)
request.header("MCP-Protocol-Version") shouldBe Some(ProtocolVersion.LatestLegacy.name)
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@ abstract class McpClientHttpIntegrationSpec[F[_], B]
toFuture(
usingBackend: backend =>
usingTransport(backend, mcpEverythingContainer.mcpUri): transport =>
McpClient(transport, clientInfo, ProtocolVersion.Latest).flatMap: client =>
McpClient(transport, clientInfo, ProtocolVersion.LatestLegacy).flatMap: client =>
test(client).flatMap(assertion => client.close().map(_ => assertion))
)
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ abstract class McpClientStdioIntegrationSpec[F[_]]
toFuture(
usingTransport(everythingServerCommand, ClientTransport.defaultTimeout): transport =>
McpClient
.bidirectional[F](transport, clientInfo, rootsHandler, samplingHandler, elicitationHandler, ProtocolVersion.Latest)
.bidirectional[F](transport, clientInfo, rootsHandler, samplingHandler, elicitationHandler, ProtocolVersion.LatestLegacy)
.flatMap: client =>
test(client).flatMap(assertion => client.close().map(_ => assertion))
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ abstract class McpClientStreamingHttpIntegrationSpec[F[_], B]
usingBackend: backend =>
usingBidirectionalTransport(backend, mcpEverythingContainer.mcpUri, ClientTransport.defaultTimeout): transport =>
McpClient
.bidirectional[F](transport, clientInfo, rootsHandler, samplingHandler, elicitationHandler, ProtocolVersion.Latest)
.bidirectional[F](transport, clientInfo, rootsHandler, samplingHandler, elicitationHandler, ProtocolVersion.LatestLegacy)
.flatMap: client =>
test(client).flatMap(assertion => client.close().map(_ => assertion))
)
Expand All @@ -57,7 +57,7 @@ abstract class McpClientStreamingHttpIntegrationSpec[F[_], B]
usingBackend: backend =>
usingBidirectionalTransport(backend, proxyContainer.mcpUri, timeout): transport =>
McpClient
.bidirectional[F](transport, clientInfo, None, samplingHandler, None, ProtocolVersion.Latest)
.bidirectional[F](transport, clientInfo, None, samplingHandler, None, ProtocolVersion.LatestLegacy)
.flatMap: client =>
test(proxyContainer, client).flatMap(assertion => client.close().map(_ => assertion))
)
1 change: 1 addition & 0 deletions core/src/main/scala/chimp/protocol/JsonRpc.scala
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ enum JSONRPCErrorCodes(val code: Int):
case InternalError extends JSONRPCErrorCodes(-32603)
case InvocationError extends JSONRPCErrorCodes(-32000)
case ResourceNotFound extends JSONRPCErrorCodes(-32002)
case UnsupportedProtocolVersion extends JSONRPCErrorCodes(-32022)
21 changes: 19 additions & 2 deletions core/src/main/scala/chimp/protocol/ProtocolVersion.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,32 @@ package chimp.protocol

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

// cases are declared oldest-to-newest, so `ordinal` gives release order and code can branch with `version >= V2026_07_28`
enum ProtocolVersion(val name: String):
case V2025_06_18 extends ProtocolVersion("2025-06-18")
case V2025_11_25 extends ProtocolVersion("2025-11-25")
case V2026_07_28 extends ProtocolVersion("2026-07-28")

object ProtocolVersion:
val Latest: ProtocolVersion = V2025_11_25
/** Newest protocol revision chimp knows about. */
val Latest: ProtocolVersion = V2026_07_28

/** Newest legacy (handshake-based) revision; the `initialize` negotiation answers this. */
val LatestLegacy: ProtocolVersion = V2025_11_25

/** All revisions the server supports, newest first; reported by `server/discover`. */
val supported: List[ProtocolVersion] = List(V2026_07_28, V2025_11_25, V2025_06_18)

def from(s: String): Option[ProtocolVersion] = values.find(_.name == s)
def negotiate(requested: String): ProtocolVersion = from(requested).getOrElse(Latest)

/** Negotiates the legacy `initialize` handshake: the requested revision if known, otherwise the newest legacy one. */
def negotiate(requested: String): ProtocolVersion = from(requested).getOrElse(LatestLegacy)

given Ordering[ProtocolVersion] = Ordering.by(_.ordinal)

extension (version: ProtocolVersion)
def >=(other: ProtocolVersion): Boolean = version.ordinal >= other.ordinal
def isModern: Boolean = version >= V2026_07_28

given Encoder[ProtocolVersion] = Encoder.instance(v => Json.fromString(v.name))
given Decoder[ProtocolVersion] = Decoder.decodeString.emap(s => from(s).toRight(s"Unsupported protocol version: $s"))
60 changes: 60 additions & 0 deletions core/src/main/scala/chimp/protocol/Versioning.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package chimp.protocol

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

import scala.concurrent.duration.{DurationLong, FiniteDuration}

// the wire encodes ttlMs as an integer number of milliseconds; file-private so it does not leak into the wider protocol scope
private given Codec[FiniteDuration] =
Codec.from(Decoder.decodeLong.map(_.millis), Encoder.encodeLong.contramap(_.toMillis))

/** Reserved `_meta` keys carrying the per-request / per-response protocol fields of modern (2026-07-28+) revisions, where version, identity
* and capabilities travel with each request instead of an `initialize` handshake.
*/
object ProtocolMeta:
val ProtocolVersion: String = "io.modelcontextprotocol/protocolVersion"
val ClientInfo: String = "io.modelcontextprotocol/clientInfo"
val ClientCapabilities: String = "io.modelcontextprotocol/clientCapabilities"
val ServerInfo: String = "io.modelcontextprotocol/serverInfo"

/** The protocol version a modern request declares in its `_meta`, if any. Its absence marks a legacy (handshake-based) request. */
def requestedVersion(meta: Option[Map[String, Json]]): Option[String] =
meta.flatMap(_.get(ProtocolVersion)).flatMap(_.asString)

/** 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 =
JSONRPCErrorObject(
code = JSONRPCErrorCodes.UnsupportedProtocolVersion.code,
message = "Unsupported protocol version",
data = Some(Json.obj("requested" -> requested.asJson, "supported" -> supported.asJson))
)

/** Whether a cached `server/discover` response may be shared across authorization contexts (`Public`) or not (`Private`). */
enum CacheScope:
case Private, Public

object CacheScope:
given Encoder[CacheScope] = Encoder.instance(scope => Json.fromString(scope.toString.toLowerCase))
given Decoder[CacheScope] = Decoder.decodeString.emap:
case "private" => Right(Private)
case "public" => Right(Public)
case other => Left(s"Unknown cache scope: $other")

/** Result of `server/discover` (2026-07-28): the server's supported protocol versions, capabilities and identity, learned without a
* handshake. `serverInfo` travels in `_meta` under [[ProtocolMeta.ServerInfo]].
*/
final case class DiscoverResult(
supportedVersions: List[String],
capabilities: ServerCapabilities,
ttlMs: FiniteDuration,
cacheScope: CacheScope,
instructions: Option[String] = None,
resultType: String = "complete",
_meta: Option[Map[String, Json]] = None
) derives Codec:
/** [[supportedVersions]] parsed: `Right` for a version this build knows as a [[ProtocolVersion]], `Left` with the raw string for one it
* does not recognise (e.g. a newer revision). Kept lazy of the wire so an unknown version never fails decoding.
*/
def getSupportedVersions: List[Either[String, ProtocolVersion]] =
supportedVersions.map(version => ProtocolVersion.from(version).toRight(version))
Loading
Loading