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
4 changes: 4 additions & 0 deletions docs/server/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 20 additions & 15 deletions server/src/main/scala/chimp/server/McpHandler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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" =>
Expand Down Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions server/src/test/scala/chimp/server/McpHandlerSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading