From 60aef3f4132ff5e68cf13286554db42ea20539ff Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Mon, 14 Sep 2026 16:48:01 +0200 Subject: [PATCH] feat: dual-era datatypes and server handling for 2026-07-28 Stacked on top of https://github.com/softwaremill/chimp/pull/238 (T1 + T2). Carries the parts moved out of that PR at review request: T4 (#242) - request metadata, result type, cache hints, error codes, discover types: - Versioning.scala: ProtocolMeta, CacheScope, DiscoverResult (+ getSupportedVersions) - JSONRPCErrorCodes.UnsupportedProtocolVersion (-32022) - ProtocolVersion.supported / isModern - DiscoverResultSpec + DiscoverResult / UnsupportedProtocolVersionError schema conformance T8 (#246) - serve 2026-07-28 requests next to legacy requests: - McpHandler: server/discover, per-request modern-version validation, dual-era dispatch - McpHandlerSpec: discover, -32022, modern and legacy-path coverage - docs/server/capabilities.md Co-Authored-By: Claude Opus 4.8 --- .../main/scala/chimp/protocol/JsonRpc.scala | 1 + .../chimp/protocol/ProtocolVersion.scala | 7 +- .../scala/chimp/protocol/Versioning.scala | 60 ++++++++++ .../chimp/protocol/DiscoverResultSpec.scala | 17 +++ .../Schema20260728ConformanceSpec.scala | 28 ++++- docs/server/capabilities.md | 6 + .../main/scala/chimp/server/McpHandler.scala | 106 +++++++++++------- .../scala/chimp/server/McpHandlerSpec.scala | 51 ++++++++- 8 files changed, 229 insertions(+), 47 deletions(-) create mode 100644 core/src/main/scala/chimp/protocol/Versioning.scala create mode 100644 core/src/test/scala/chimp/protocol/DiscoverResultSpec.scala diff --git a/core/src/main/scala/chimp/protocol/JsonRpc.scala b/core/src/main/scala/chimp/protocol/JsonRpc.scala index 95f04d39..b9e3790c 100644 --- a/core/src/main/scala/chimp/protocol/JsonRpc.scala +++ b/core/src/main/scala/chimp/protocol/JsonRpc.scala @@ -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) diff --git a/core/src/main/scala/chimp/protocol/ProtocolVersion.scala b/core/src/main/scala/chimp/protocol/ProtocolVersion.scala index c15e1fc7..4af78633 100644 --- a/core/src/main/scala/chimp/protocol/ProtocolVersion.scala +++ b/core/src/main/scala/chimp/protocol/ProtocolVersion.scala @@ -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")) diff --git a/core/src/main/scala/chimp/protocol/Versioning.scala b/core/src/main/scala/chimp/protocol/Versioning.scala new file mode 100644 index 00000000..ce005680 --- /dev/null +++ b/core/src/main/scala/chimp/protocol/Versioning.scala @@ -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)) diff --git a/core/src/test/scala/chimp/protocol/DiscoverResultSpec.scala b/core/src/test/scala/chimp/protocol/DiscoverResultSpec.scala new file mode 100644 index 00000000..bf58b43e --- /dev/null +++ b/core/src/test/scala/chimp/protocol/DiscoverResultSpec.scala @@ -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")) diff --git a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala index 50bf4f3e..7ed65d17 100644 --- a/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala +++ b/core/src/test/scala/chimp/protocol/Schema20260728ConformanceSpec.scala @@ -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: @@ -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) diff --git a/docs/server/capabilities.md b/docs/server/capabilities.md index f4f34cfd..a6538463 100644 --- a/docs/server/capabilities.md +++ b/docs/server/capabilities.md @@ -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. diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index 585dcee2..f50dbaaf 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -73,37 +73,15 @@ 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 @@ -111,31 +89,81 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD 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] = diff --git a/server/src/test/scala/chimp/server/McpHandlerSpec.scala b/server/src/test/scala/chimp/server/McpHandlerSpec.scala index d4295751..44bc7b9b 100644 --- a/server/src/test/scala/chimp/server/McpHandlerSpec.scala +++ b/server/src/test/scala/chimp/server/McpHandlerSpec.scala @@ -86,9 +86,7 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case McpResponse.EmptyAcceptResponse => fail("Expected JsonResponse but got EmptyAcceptResponse") "McpHandler" should "respond to initialize" in: - // an explicit legacy protocolVersion is required now that ProtocolVersion.Latest advances past the legacy era - val req: JSONRPCMessage = - Request(method = "initialize", params = Some(Json.obj("protocolVersion" -> "2025-11-25".asJson)), id = RequestId("1")) + val req: JSONRPCMessage = Request(method = "initialize", id = RequestId("1")) val json = req.asJson val response = handler.handleJsonRpc(json, Seq.empty) @@ -105,6 +103,53 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: // nulls should be dropped respJson.hcursor.downField("result").downField("instructions").focus shouldBe None + it should "respond to server/discover with supported versions, capabilities and identity" in: + val req: JSONRPCMessage = Request(method = "server/discover", id = RequestId("d")) + val respJson = extractJsonFromResponse(handler.handleJsonRpc(req.asJson, Seq.empty)) + respJson.as[JSONRPCMessage].getOrElse(fail("decode")) match + case Response(_, _, result) => + val discover = result.as[DiscoverResult].getOrElse(fail("Failed to decode DiscoverResult")) + discover.supportedVersions should contain("2026-07-28") + discover.capabilities.tools.isDefined shouldBe true + discover.resultType shouldBe "complete" + discover._meta.flatMap(_.get(ProtocolMeta.ServerInfo)).isDefined shouldBe true + case _ => fail("Expected Response") + + it should "reject a request declaring an unsupported protocol version with -32022" in: + val params = Json.obj("_meta" -> Json.obj(ProtocolMeta.ProtocolVersion -> Json.fromString("1900-01-01"))) + val req: JSONRPCMessage = Request(method = "tools/list", params = Some(params), id = RequestId("v")) + val respJson = extractJsonFromResponse(handler.handleJsonRpc(req.asJson, Seq.empty)) + respJson.as[JSONRPCMessage].getOrElse(fail("decode")) match + case Error(_, _, err) => + err.code shouldBe JSONRPCErrorCodes.UnsupportedProtocolVersion.code + err.data.flatMap(_.hcursor.downField("supported").as[List[String]].toOption).getOrElse(Nil) should contain("2026-07-28") + case _ => fail("Expected Error") + + it should "process a request declaring a supported modern protocol version" in: + val params = Json.obj("_meta" -> Json.obj(ProtocolMeta.ProtocolVersion -> Json.fromString("2026-07-28"))) + val req: JSONRPCMessage = Request(method = "tools/list", params = Some(params), id = RequestId("m")) + val respJson = extractJsonFromResponse(handler.handleJsonRpc(req.asJson, Seq.empty)) + respJson.as[JSONRPCMessage].getOrElse(fail("decode")) match + case Response(_, _, result) => result.as[ListToolsResponse].isRight shouldBe true + case _ => fail("Expected Response") + + // legacy negotiation path: a stock official-SDK client sends a classic initialize with no modern _meta; a dual-era server MUST serve it + // via the legacy handshake, not reject it with UnsupportedProtocolVersion (-32022) + it should "serve a classic initialize with no modern protocol version rather than rejecting it with -32022" in: + val req: JSONRPCMessage = Request(method = "initialize", id = RequestId("legacy-init")) + val respJson = extractJsonFromResponse(handler.handleJsonRpc(req.asJson, Seq.empty)) + respJson.as[JSONRPCMessage].getOrElse(fail("decode")) match + case Response(_, _, result) => result.as[InitializeResult].isRight shouldBe true + case other => fail(s"expected InitializeResult, got $other") + + it should "treat a request whose _meta omits the protocol version as legacy (no -32022)" in: + val params = Json.obj("_meta" -> Json.obj("progressToken" -> Json.fromString("p"))) + val req: JSONRPCMessage = Request(method = "tools/list", params = Some(params), id = RequestId("legacy-call")) + val respJson = extractJsonFromResponse(handler.handleJsonRpc(req.asJson, Seq.empty)) + respJson.as[JSONRPCMessage].getOrElse(fail("decode")) match + case Response(_, _, result) => result.as[ListToolsResponse].isRight shouldBe true + case other => fail(s"expected ListToolsResponse, got $other") + it should "list available tools" in: val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("2")) val json = req.asJson