Skip to content
Closed
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)
7 changes: 6 additions & 1 deletion core/src/main/scala/chimp/protocol/ProtocolVersion.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ object ProtocolVersion:
val Latest: ProtocolVersion = V2026_07_28
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)

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

extension (version: ProtocolVersion) def >=(other: ProtocolVersion): Boolean = version.ordinal >= other.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))
17 changes: 17 additions & 0 deletions core/src/test/scala/chimp/protocol/DiscoverResultSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package chimp.protocol

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

import scala.concurrent.duration.*

class DiscoverResultSpec extends AnyFlatSpec with Matchers:

it should "parse supportedVersions into known (Right) and unrecognised (Left)" in:
val discover = DiscoverResult(
supportedVersions = List("2026-07-28", "1900-01-01"),
capabilities = ServerCapabilities(),
ttlMs = 0.millis,
cacheScope = CacheScope.Private
)
discover.getSupportedVersions shouldBe List(Right(ProtocolVersion.V2026_07_28), Left("1900-01-01"))
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package chimp.protocol

/** Schema conformance for the modern 2026-07-28 revision.
*
* Seeded with datatypes shared across revisions so the per-version harness runs against the 2026-07-28 schema; it grows with the
* revision-specific defs (result type, cache hints, discover) as those datatypes are added in later tasks.
import io.circe.syntax.*

import scala.concurrent.duration.*

/** Schema conformance for the modern 2026-07-28 revision: shared datatypes plus the revision-specific defs (result type, cache hints,
* discover, error codes).
*/
class Schema20260728ConformanceSpec extends SchemaConformance:

Expand All @@ -14,3 +16,21 @@ class Schema20260728ConformanceSpec extends SchemaConformance:

it should "produce TextContent that matches the spec schema" in:
validate("TextContent", ToolContent.Text(text = "hi"))

it should "produce a DiscoverResult that matches the spec schema" in:
validate(
"DiscoverResult",
DiscoverResult(
supportedVersions = List("2026-07-28", "2025-11-25"),
capabilities = ServerCapabilities(tools = Some(ServerToolsCapability(listChanged = Some(false)))),
ttlMs = 0.millis,
cacheScope = CacheScope.Private,
instructions = Some("welcome"),
_meta = Some(Map(ProtocolMeta.ServerInfo -> Implementation(name = "chimp", version = "1.0").asJson.deepDropNullValues))
)
)

it should "produce an UnsupportedProtocolVersionError envelope that matches the spec schema" in:
val msg: JSONRPCMessage =
JSONRPCMessage.Error(id = RequestId(1), error = ProtocolMeta.unsupportedVersionError("1900-01-01", List("2026-07-28", "2025-11-25")))
validate("UnsupportedProtocolVersionError", msg)
6 changes: 6 additions & 0 deletions docs/server/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ val server = StreamingMcpServer[Identity]().addStreamingTool(work)
```

Server-wide capabilities are enabled by registering a handler — only what you wire up is advertised: `.withCompletion`, `.withLoggingLevel`, `.withSubscriptions`.

## Protocol versions

chimp is **dual-era**: it speaks the legacy handshake revisions (`2025-11-25` and earlier, negotiated via `initialize`) and the modern `2026-07-28` revision, where each request carries its protocol version in `_meta` under `io.modelcontextprotocol/protocolVersion` instead of a handshake.

The server answers `server/discover` automatically with its supported versions, capabilities and identity (`serverInfo` in `_meta`), so a client can learn them in one request without connecting. A request that declares a protocol version the server does not support is rejected with an `UnsupportedProtocolVersion` error (`-32022`) listing the supported versions, per the [versioning compatibility matrix](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#compatibility-matrix). No configuration is required for any of this.
106 changes: 67 additions & 39 deletions server/src/main/scala/chimp/server/McpHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -73,69 +73,97 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD
case Left(err) =>
jsonResponse(protocolError(RequestId("null"), JSONRPCErrorCodes.ParseError.code, s"Parse error: ${err.message}")).unit
case Right(JSONRPCMessage.Request(_, method, params: Option[Json], id)) =>
method match
case "initialize" =>
jsonResponse(handleInitialize(params, id)).unit
case "ping" =>
jsonResponse(JSONRPCMessage.Response(id = id, result = Json.obj())).unit
case "tools/list" =>
jsonResponse(JSONRPCMessage.Response(id = id, result = ListToolsResponse(toolDefinitions).asJson)).unit
case "tools/call" =>
handleToolsCall(params, id, headers, makeContext).map(jsonResponse)
case "resources/list" if hasResources =>
jsonResponse(JSONRPCMessage.Response(id = id, result = ListResourcesResult(server.resources.map(_.definition)).asJson)).unit
case "resources/templates/list" if hasResources =>
val requestMeta = params.flatMap(_.hcursor.downField("_meta").as[Map[String, Json]].toOption)
// a modern request declares its version in _meta; reject an unsupported one with -32022 so the client can retry
ProtocolMeta.requestedVersion(requestMeta) match
case Some(version) if ProtocolVersion.from(version).isEmpty =>
jsonResponse(
JSONRPCMessage.Response(id = id, result = ListResourceTemplatesResult(server.resourceTemplates.map(_.definition)).asJson)
JSONRPCMessage.Error(id = id, error = ProtocolMeta.unsupportedVersionError(version, ProtocolVersion.supported.map(_.name)))
).unit
case "resources/read" if hasResources =>
handleResourcesRead(params, id, headers).map(jsonResponse)
case "resources/subscribe" if server.subscriptions.isDefined =>
handleSubscribe(params, id, subscribe = true).map(jsonResponse)
case "resources/unsubscribe" if server.subscriptions.isDefined =>
handleSubscribe(params, id, subscribe = false).map(jsonResponse)
case "prompts/list" if server.prompts.nonEmpty =>
jsonResponse(JSONRPCMessage.Response(id = id, result = ListPromptsResult(server.prompts.map(_.definition)).asJson)).unit
case "prompts/get" if server.prompts.nonEmpty =>
handlePromptsGet(params, id, headers).map(jsonResponse)
case "completion/complete" if server.completion.isDefined =>
handleComplete(params, id).map(jsonResponse)
case "logging/setLevel" if server.loggingLevel.isDefined =>
handleSetLoggingLevel(params, id).map(jsonResponse)
case other =>
jsonResponse(protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown method: $other")).unit
case _ =>
dispatch(method, params, id, headers, makeContext)
case Right(notification: JSONRPCMessage.Notification) =>
logger.debug(s"Received notification: ${notification.method}")
McpResponse.EmptyAcceptResponse.unit
case Right(_) =>
jsonResponse(protocolError(RequestId("null"), JSONRPCErrorCodes.InvalidRequest.code, "Invalid request type")).unit
end doHandleJsonRpc

private def dispatch(method: String, params: Option[Json], id: RequestId, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(
using MonadError[F]
): F[McpResponse] =
method match
case "initialize" =>
jsonResponse(handleInitialize(params, id)).unit
case "server/discover" =>
jsonResponse(handleDiscover(id)).unit
case "ping" =>
jsonResponse(JSONRPCMessage.Response(id = id, result = Json.obj())).unit
case "tools/list" =>
jsonResponse(JSONRPCMessage.Response(id = id, result = ListToolsResponse(toolDefinitions).asJson)).unit
case "tools/call" =>
handleToolsCall(params, id, headers, makeContext).map(jsonResponse)
case "resources/list" if hasResources =>
jsonResponse(JSONRPCMessage.Response(id = id, result = ListResourcesResult(server.resources.map(_.definition)).asJson)).unit
case "resources/templates/list" if hasResources =>
jsonResponse(
JSONRPCMessage.Response(id = id, result = ListResourceTemplatesResult(server.resourceTemplates.map(_.definition)).asJson)
).unit
case "resources/read" if hasResources =>
handleResourcesRead(params, id, headers).map(jsonResponse)
case "resources/subscribe" if server.subscriptions.isDefined =>
handleSubscribe(params, id, subscribe = true).map(jsonResponse)
case "resources/unsubscribe" if server.subscriptions.isDefined =>
handleSubscribe(params, id, subscribe = false).map(jsonResponse)
case "prompts/list" if server.prompts.nonEmpty =>
jsonResponse(JSONRPCMessage.Response(id = id, result = ListPromptsResult(server.prompts.map(_.definition)).asJson)).unit
case "prompts/get" if server.prompts.nonEmpty =>
handlePromptsGet(params, id, headers).map(jsonResponse)
case "completion/complete" if server.completion.isDefined =>
handleComplete(params, id).map(jsonResponse)
case "logging/setLevel" if server.loggingLevel.isDefined =>
handleSetLoggingLevel(params, id).map(jsonResponse)
case other =>
jsonResponse(protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown method: $other")).unit

private def protocolError(id: RequestId, code: Int, message: String, data: Option[Json] = None): JSONRPCMessage.Error =
logger.debug(s"Protocol error (id=$id, code=$code): $message")
JSONRPCMessage.Error(id = id, error = JSONRPCErrorObject(code = code, message = message, data = data))

private def jsonResponse(message: JSONRPCMessage): McpResponse = McpResponse.JsonResponse(message.asJson)

private def serverCapabilities: ServerCapabilities = ServerCapabilities(
logging = Option.when(server.loggingLevel.isDefined)(Json.obj()),
completions = Option.when(server.completion.isDefined)(Json.obj()),
prompts = Option.when(server.prompts.nonEmpty)(ServerPromptsCapability(listChanged = Some(false))),
resources =
Option.when(hasResources)(ServerResourcesCapability(subscribe = Some(server.subscriptions.isDefined), listChanged = Some(false))),
tools = Option.when(server.tools.nonEmpty)(ServerToolsCapability(listChanged = Some(false)))
)

private def handleInitialize(params: Option[Json], id: RequestId): JSONRPCMessage.Response =
val requested = params.flatMap(_.hcursor.downField("protocolVersion").as[String].toOption)
val negotiated = requested.map(ProtocolVersion.negotiate).getOrElse(ProtocolVersion.Latest)
val capabilities = ServerCapabilities(
logging = Option.when(server.loggingLevel.isDefined)(Json.obj()),
completions = Option.when(server.completion.isDefined)(Json.obj()),
prompts = Option.when(server.prompts.nonEmpty)(ServerPromptsCapability(listChanged = Some(false))),
resources =
Option.when(hasResources)(ServerResourcesCapability(subscribe = Some(server.subscriptions.isDefined), listChanged = Some(false))),
tools = Option.when(server.tools.nonEmpty)(ServerToolsCapability(listChanged = Some(false)))
)
val negotiated = requested.map(ProtocolVersion.negotiate).getOrElse(ProtocolVersion.LatestLegacy)
val result = InitializeResult(
protocolVersion = negotiated.name,
capabilities = capabilities,
capabilities = serverCapabilities,
serverInfo = Implementation(server.name, server.version),
instructions = server.instructions
)
JSONRPCMessage.Response(id = id, result = result.asJson)

// server/discover (2026-07-28): advertise supported versions, capabilities and identity without a handshake
private def handleDiscover(id: RequestId): JSONRPCMessage.Response =
val result = DiscoverResult(
supportedVersions = ProtocolVersion.supported.map(_.name),
capabilities = serverCapabilities,
ttlMs = scala.concurrent.duration.Duration.Zero,
cacheScope = CacheScope.Private,
instructions = server.instructions,
_meta = Some(Map(ProtocolMeta.ServerInfo -> Implementation(server.name, server.version).asJson))
)
JSONRPCMessage.Response(id = id, result = result.asJson)

private def handleToolsCall(params: Option[Json], id: RequestId, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using
MonadError[F]
): F[JSONRPCMessage] =
Expand Down
Loading