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
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)
10 changes: 10 additions & 0 deletions core/src/main/scala/chimp/protocol/ProtocolVersion.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@ import io.circe.{Decoder, Encoder, Json}
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")

/** Whether this is a "modern" (per-request `_meta`, no `initialize` handshake) revision, as opposed to a legacy handshake one. */
def isModern: Boolean = this match
case V2025_06_18 | V2025_11_25 => false
case V2026_07_28 => true

object ProtocolVersion:
/** Latest legacy revision, proposed during the `initialize` handshake. Modern revisions are selected per-request, not here. */
val Latest: 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)

Expand Down
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