From a0bc0cfcfb8eacef8af24a3be53b0507be70eb70 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Fri, 28 Aug 2026 00:55:58 +0200 Subject: [PATCH] feat: answer server/discover for connection-less capability discovery The chimp server is already stateless: it stores no session, issues no Mcp-Session-Id, and does not require an initialize handshake before other methods. Add support for the `server/discover` method so a client or proxy can learn the server's capabilities up front without negotiating a session; it returns the same serverInfo, capabilities and protocol version as initialize. This aligns with the 2026-07-28 stateless architecture direction. Tracks #163. Co-Authored-By: Claude Opus 4.8 --- docs/server/capabilities.md | 4 +++ .../main/scala/chimp/server/McpHandler.scala | 35 +++++++++++-------- .../scala/chimp/server/McpHandlerSpec.scala | 13 +++++++ 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/server/capabilities.md b/docs/server/capabilities.md index f4f34cf..73cfa80 100644 --- a/docs/server/capabilities.md +++ b/docs/server/capabilities.md @@ -29,3 +29,7 @@ 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`. + +## Discovery + +The chimp server is stateless: every request is handled on its own, no session is stored, and no `initialize` handshake is required before other methods. A client (or a proxy) can learn the server's capabilities up front with a `server/discover` request, which returns the same `serverInfo`, `capabilities` and `protocolVersion` as `initialize`, without negotiating a session. diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index 585dcee..d985bcd 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -76,6 +76,8 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD method match case "initialize" => jsonResponse(handleInitialize(params, id)).unit + case "server/discover" => + jsonResponse(JSONRPCMessage.Response(id = id, result = discoveryResult(ProtocolVersion.Latest).asJson)).unit case "ping" => jsonResponse(JSONRPCMessage.Response(id = id, result = Json.obj())).unit case "tools/list" => @@ -117,24 +119,27 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD private def jsonResponse(message: JSONRPCMessage): McpResponse = McpResponse.JsonResponse(message.asJson) - 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 result = InitializeResult( - protocolVersion = negotiated.name, - capabilities = capabilities, + 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 discoveryResult(protocolVersion: ProtocolVersion): InitializeResult = + InitializeResult( + protocolVersion = protocolVersion.name, + capabilities = serverCapabilities, serverInfo = Implementation(server.name, server.version), instructions = server.instructions ) - JSONRPCMessage.Response(id = id, result = result.asJson) + + 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) + JSONRPCMessage.Response(id = id, result = discoveryResult(negotiated).asJson) private def handleToolsCall(params: Option[Json], id: RequestId, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using MonadError[F] diff --git a/server/src/test/scala/chimp/server/McpHandlerSpec.scala b/server/src/test/scala/chimp/server/McpHandlerSpec.scala index 610cb28..567394a 100644 --- a/server/src/test/scala/chimp/server/McpHandlerSpec.scala +++ b/server/src/test/scala/chimp/server/McpHandlerSpec.scala @@ -103,6 +103,19 @@ 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 without an initialize handshake" in: + val req: JSONRPCMessage = Request(method = "server/discover", id = RequestId("disc")) + val response = handler.handleJsonRpc(req.asJson, Seq.empty) + val respJson = extractJsonFromResponse(response) + + respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) match + case Response(_, _, result) => + val resultObj = result.as[InitializeResult].getOrElse(fail("Failed to decode result")) + resultObj.protocolVersion shouldBe ProtocolVersion.Latest.name + resultObj.capabilities.tools.isDefined shouldBe true + resultObj.serverInfo.name should include("Chimp MCP server") + case _ => fail("Expected Response") + it should "list available tools" in: val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("2")) val json = req.asJson