From 5b60dae634de365fa722548ca19f08a9cd157df4 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Fri, 5 Jun 2026 13:48:54 +0200 Subject: [PATCH 01/20] feat: rich tool result set and server context --- .../{mcpEndpoint.scala => McpEndpoint.scala} | 7 +- .../scala/chimp/server/ServerContext.scala | 24 ++++ server/src/main/scala/chimp/server/Tool.scala | 110 ++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) rename server/src/main/scala/chimp/server/{mcpEndpoint.scala => McpEndpoint.scala} (85%) create mode 100644 server/src/main/scala/chimp/server/ServerContext.scala create mode 100644 server/src/main/scala/chimp/server/Tool.scala diff --git a/server/src/main/scala/chimp/server/mcpEndpoint.scala b/server/src/main/scala/chimp/server/McpEndpoint.scala similarity index 85% rename from server/src/main/scala/chimp/server/mcpEndpoint.scala rename to server/src/main/scala/chimp/server/McpEndpoint.scala index caef462..1a538c5 100644 --- a/server/src/main/scala/chimp/server/mcpEndpoint.scala +++ b/server/src/main/scala/chimp/server/McpEndpoint.scala @@ -1,7 +1,6 @@ package chimp.server import io.circe.Json -import org.slf4j.LoggerFactory import sttp.monad.MonadError import sttp.monad.syntax.* import sttp.tapir.* @@ -9,8 +8,6 @@ import sttp.tapir.json.circe.* import sttp.tapir.server.ServerEndpoint import sttp.model.Header -private val logger = LoggerFactory.getLogger(classOf[McpHandler[_]]) - /** Creates a Tapir endpoint description, which will handle MCP HTTP server requests, using the provided tools. * * @param tools @@ -19,10 +16,10 @@ private val logger = LoggerFactory.getLogger(classOf[McpHandler[_]]) * The path components at which to expose the MCP server. * * @tparam F - * The effect type. Might be `Identity` for a endpoints with synchronous logic. + * The effect type. Might be `Identity` for endpoints with synchronous logic. */ def mcpEndpoint[F[_]]( - tools: List[ServerTool[?, F]], + tools: List[ServerTool[?, F, ServerContext[F]]], path: List[String], name: String = "Chimp MCP server", version: String = "1.0.0", diff --git a/server/src/main/scala/chimp/server/ServerContext.scala b/server/src/main/scala/chimp/server/ServerContext.scala new file mode 100644 index 0000000..19c4ed4 --- /dev/null +++ b/server/src/main/scala/chimp/server/ServerContext.scala @@ -0,0 +1,24 @@ +package chimp.server + +import chimp.protocol.{CreateMessageParams, CreateMessageResult, ElicitParams, ElicitResult, LoggingLevel} +import io.circe.Json +import sttp.monad.MonadError + +trait ServerContext[F[_]]: + def isCancelled: F[Boolean] + def onCancel(action: F[Unit]): F[Unit] + +object ServerContext: + def noOp[F[_]](using m: MonadError[F]): ServerContext[F] = new ServerContext[F]: + def isCancelled: F[Boolean] = m.unit(false) + def onCancel(action: F[Unit]): F[Unit] = m.unit(()) + +/** Adds the server→client interactions that require a live streaming connection: emitting progress and log notifications, and issuing + * sampling and elicitation requests. Tool logic using these is accepted only by the streaming endpoint; the request/response endpoint + * rejects it at compile time. + */ +trait StreamingServerContext[F[_]] extends ServerContext[F]: + def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] + def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] + def sample(params: CreateMessageParams): F[CreateMessageResult] + def elicit(params: ElicitParams): F[ElicitResult] diff --git a/server/src/main/scala/chimp/server/Tool.scala b/server/src/main/scala/chimp/server/Tool.scala new file mode 100644 index 0000000..0499fd0 --- /dev/null +++ b/server/src/main/scala/chimp/server/Tool.scala @@ -0,0 +1,110 @@ +package chimp.server + +import chimp.protocol.{ResourceContents, ToolContent} +import io.circe.syntax.* +import io.circe.{Decoder, Encoder, Json} +import sttp.model.Header +import sttp.shared.Identity +import sttp.tapir.Schema + +case class ToolAnnotations( + title: Option[String] = None, + readOnlyHint: Option[Boolean] = None, + destructiveHint: Option[Boolean] = None, + idempotentHint: Option[Boolean] = None, + openWorldHint: Option[Boolean] = None +) + +/** The result of a tool invocation: a list of content items (text, image, audio, embedded resource, …), an optional structured payload, and + * a flag marking the result as an error. + */ +case class ToolResult( + content: List[ToolContent], + structuredContent: Option[Json] = None, + isError: Boolean = false +): + def asError: ToolResult = copy(isError = true) + def withStructured(json: Json): ToolResult = copy(structuredContent = Some(json)) + +object ToolResult: + def text(text: String): ToolResult = ToolResult(List(ToolContent.Text(text = text))) + def error(message: String): ToolResult = ToolResult(List(ToolContent.Text(text = message)), isError = true) + def image(data: String, mimeType: String): ToolResult = ToolResult(List(ToolContent.Image(data = data, mimeType = mimeType))) + def audio(data: String, mimeType: String): ToolResult = ToolResult(List(ToolContent.Audio(data = data, mimeType = mimeType))) + def embedded(resource: ResourceContents): ToolResult = ToolResult(List(ToolContent.ResourceContent(resource = resource))) + def content(content: ToolContent*): ToolResult = ToolResult(content.toList) + def structured[A: Encoder](value: A): ToolResult = ToolResult(Nil, structuredContent = Some(value.asJson)) + def fromEither(result: Either[String, String]): ToolResult = result.fold(error, text) + +/** A tool's input schema, either derived from a Tapir [[Schema]] or supplied directly as raw JSON Schema (for dialects Tapir cannot express, + * e.g. JSON Schema 2020-12 with `additionalProperties: false`). + */ +enum ToolSchema: + case Derived(schema: Schema[?]) + case Raw(json: Json) + +private val ToolNameRegex = "^[A-Za-z0-9_./-]+$".r + +/** Creates a new MCP tool description with the given name. The name must match `^[A-Za-z0-9_./-]+$` and be 1–64 characters long. */ +def tool(name: String): PartialTool = + require(name.length >= 1 && name.length <= 64, s"Tool name must be 1..64 characters long, got ${name.length}: $name") + require(ToolNameRegex.matches(name), s"Tool name must match ${ToolNameRegex.regex}, got: $name") + PartialTool(name) + +/** Describes a tool before the input is specified. */ +case class PartialTool( + name: String, + description: Option[String] = None, + annotations: Option[ToolAnnotations] = None +): + def description(desc: String): PartialTool = copy(description = Some(desc)) + def withAnnotations(ann: ToolAnnotations): PartialTool = copy(annotations = Some(ann)) + + /** Specify the input type for the tool, providing both a Tapir Schema and a Circe Decoder. */ + def input[I: Schema: Decoder]: Tool[I] = Tool[I](name, description, ToolSchema.Derived(summon[Schema[I]]), summon[Decoder[I]], annotations) + + /** Specify the tool's input schema directly as raw JSON Schema. The tool receives its arguments as raw [[Json]]. */ + def inputJson(schema: Json): Tool[Json] = Tool[Json](name, description, ToolSchema.Raw(schema), summon[Decoder[Json]], annotations) + +/** Describes a tool after the input is specified. */ +case class Tool[I]( + name: String, + description: Option[String], + inputSchema: ToolSchema, + inputDecoder: Decoder[I], + annotations: Option[ToolAnnotations] +): + /** Combine the tool description with the server logic, executed when the tool is invoked. The logic, given the input, a request-scoped + * [[ServerContext]], and the request headers, returns a [[ToolResult]] in the F-effect. + */ + def serverLogic[F[_]](logic: (I, ServerContext[F], Seq[Header]) => F[ToolResult]): ServerTool[I, F, ServerContext[F]] = + ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) + + /** Like [[serverLogic]], but the logic receives a [[StreamingServerContext]], so it may report progress, log, and issue sampling and + * elicitation requests. Tools defined this way are accepted only by the streaming endpoint. + */ + def streamingServerLogic[F[_]]( + logic: (I, StreamingServerContext[F], Seq[Header]) => F[ToolResult] + ): ServerTool[I, F, StreamingServerContext[F]] = + ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) + + /** Combine the tool description with synchronous server logic that also receives the request headers. */ + def handleWithHeaders(logic: (I, Seq[Header]) => ToolResult): ServerTool[I, Identity, ServerContext[Identity]] = + ServerTool(name, description, inputSchema, inputDecoder, annotations, (i, _, headers) => logic(i, headers)) + + /** Combine the tool description with synchronous server logic. Same as [[handleWithHeaders]], but without access to the headers. */ + def handle(logic: I => ToolResult): ServerTool[I, Identity, ServerContext[Identity]] = + handleWithHeaders((i, _) => logic(i)) + +/** A tool that can be executed by the MCP server. The context type `C` records which server capabilities the logic requires: a tool needing + * only the base [[ServerContext]] runs on any server, while one needing a [[StreamingServerContext]] is rejected by the request/response + * endpoint at compile time. + */ +case class ServerTool[I, F[_], -C <: ServerContext[F]]( + name: String, + description: Option[String], + inputSchema: ToolSchema, + inputDecoder: Decoder[I], + annotations: Option[ToolAnnotations], + logic: (I, C, Seq[Header]) => F[ToolResult] +) From fed397b876cd784ff2a28d0a04ede82e7d8a234f Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 8 Jun 2026 10:59:29 +0200 Subject: [PATCH 02/20] feat: remove old mcp endpoint creation --- README.md | 4 +- docs/server/quickstart.md | 4 +- docs/server/tools.md | 4 +- docs/server/zio.md | 4 +- .../scala/examples/server/AdderMcpZio.scala | 6 +- .../main/scala/examples/server/adderMcp.scala | 4 +- .../examples/server/adderWithAuthMcp.scala | 4 +- .../scala/examples/server/twoToolsMcp.scala | 8 +- .../scala/examples/server/weatherMcp.scala | 11 +- .../scala/chimp/conformance/server/Main.scala | 8 +- .../main/scala/chimp/server/McpEndpoint.scala | 20 +- .../main/scala/chimp/server/McpHandler.scala | 210 +++++++++++------- .../main/scala/chimp/server/McpServer.scala | 52 +++++ .../src/main/scala/chimp/server/Prompt.scala | 34 +++ .../main/scala/chimp/server/Resource.scala | 101 +++++++++ server/src/main/scala/chimp/server/Tool.scala | 7 +- server/src/main/scala/chimp/server/tool.scala | 76 ------- .../scala/chimp/server/McpHandlerSpec.scala | 133 ++++++++++- 18 files changed, 478 insertions(+), 212 deletions(-) create mode 100644 server/src/main/scala/chimp/server/McpServer.scala create mode 100644 server/src/main/scala/chimp/server/Prompt.scala create mode 100644 server/src/main/scala/chimp/server/Resource.scala delete mode 100644 server/src/main/scala/chimp/server/tool.scala diff --git a/README.md b/README.md index 0f71222..d113f56 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ case class AdderInput(a: Int, b: Int) derives Codec, Schema @main def server(): Unit = val adder = tool("adder").description("Adds two numbers").input[AdderInput] - .handle(i => Right(s"Result: ${i.a + i.b}")) + .handle(i => ToolResult.text(s"Result: ${i.a + i.b}")) - NettySyncServer().port(8080).addEndpoint(mcpEndpoint(List(adder), List("mcp"))).startAndWait() + NettySyncServer().port(8080).addEndpoint(McpServer(tools = List(adder)).endpoint(List("mcp"))).startAndWait() ``` Connect and invoke the tool as an MCP client: diff --git a/docs/server/quickstart.md b/docs/server/quickstart.md index 4fce543..c1532da 100644 --- a/docs/server/quickstart.md +++ b/docs/server/quickstart.md @@ -27,10 +27,10 @@ case class AdderInput(a: Int, b: Int) derives io.circe.Codec, Schema val adderTool = tool("adder").description("Adds two numbers").input[AdderInput] // combine the tool description with the server-side logic - val adderServerTool = adderTool.handle(i => Right(s"The result is ${i.a + i.b}")) + val adderServerTool = adderTool.handle(i => ToolResult.text(s"The result is ${i.a + i.b}")) // create the MCP server endpoint; it will be available at http://localhost:8080/mcp - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) // start the server NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() diff --git a/docs/server/tools.md b/docs/server/tools.md index db323ea..a17c7d8 100644 --- a/docs/server/tools.md +++ b/docs/server/tools.md @@ -3,8 +3,8 @@ - Use `tool(name)` to start defining a tool. - Add a description and annotations for metadata and hints. - Specify the input type (must have a Circe `Codec` and Tapir `Schema`). -- Provide the server logic as a function from input to `Either[String, String]` (or a generic effect type). +- Provide the server logic as a function from input to `ToolResult` (or a generic effect type). - Use `handle` to connect the tool definition with the server logic when the use of headers is not required. - Use `handleWithHeaders` to connect the tool definition with the server logic when headers are required. -- Create a Tapir endpoint by providing your tools to `mcpEndpoint`. +- Assemble your tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. - Start an HTTP server using your preferred Tapir server interpreter. diff --git a/docs/server/zio.md b/docs/server/zio.md index ccd5d9b..91f1fd4 100644 --- a/docs/server/zio.md +++ b/docs/server/zio.md @@ -3,6 +3,6 @@ When using ZIO, you might have to explicitly state the effect type that you are using, as the Tapir-ZIO integration requires a `RIO[R, A]` effect (which is an alias for `ZIO[R, Throwable, A]`), for example: ```scala -val myServerTool = myTool.serverLogic[[X] =>> RIO[Any, X]]: (input, headers) => - ZIO.succeed(???) +val myServerTool = myTool.serverLogic[[X] =>> RIO[Any, X]]: (input, ctx, headers) => + ZIO.succeed(ToolResult.text(???)) ``` diff --git a/examples/src/main/scala/examples/server/AdderMcpZio.scala b/examples/src/main/scala/examples/server/AdderMcpZio.scala index 82acf73..c47839b 100644 --- a/examples/src/main/scala/examples/server/AdderMcpZio.scala +++ b/examples/src/main/scala/examples/server/AdderMcpZio.scala @@ -23,10 +23,10 @@ object Main extends ZIOAppDefault: // note that here we need to explicitly state the effect type, as the Tapir-ZIO integration requires a `RIO[R, A]` // effect (with the error channel fixed to `Throwable`) - val adderServerTool = adderTool.serverLogic[[X] =>> RIO[Any, X]]: (input, _) => - ZIO.succeed(Right(s"The result is ${input.a + input.b}")) + val adderServerTool = adderTool.serverLogic[[X] =>> RIO[Any, X]]: (input, _, _) => + ZIO.succeed(ToolResult.text(s"The result is ${input.a + input.b}")) - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) val routes = ZioHttpInterpreter().toHttp(mcpServerEndpoint) diff --git a/examples/src/main/scala/examples/server/adderMcp.scala b/examples/src/main/scala/examples/server/adderMcp.scala index bb20ab0..ed239ee 100644 --- a/examples/src/main/scala/examples/server/adderMcp.scala +++ b/examples/src/main/scala/examples/server/adderMcp.scala @@ -19,9 +19,9 @@ case class Input(a: Int, b: Int) derives Codec, Schema def logic(i: Input): Either[String, String] = Right(s"The result is ${i.a + i.b}") - val adderServerTool = adderTool.handle(logic) + val adderServerTool = adderTool.handle(i => ToolResult.fromEither(logic(i))) - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) NettySyncServer() .port(8080) diff --git a/examples/src/main/scala/examples/server/adderWithAuthMcp.scala b/examples/src/main/scala/examples/server/adderWithAuthMcp.scala index 1a30311..6042c34 100644 --- a/examples/src/main/scala/examples/server/adderWithAuthMcp.scala +++ b/examples/src/main/scala/examples/server/adderWithAuthMcp.scala @@ -23,9 +23,9 @@ import sttp.tapir.server.netty.sync.NettySyncServer headers.find(_.name == "test_header").map(t => s"token: ${t.value} (header name used: ${t.name})").getOrElse("no token provided") Right(s"The result is ${i.a + i.b} ($tokenMsg)") - val adderServerTool = adderTool.handleWithHeaders(logic) + val adderServerTool = adderTool.handleWithHeaders((i, headers) => ToolResult.fromEither(logic(i, headers))) - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) NettySyncServer() .port(8080) diff --git a/examples/src/main/scala/examples/server/twoToolsMcp.scala b/examples/src/main/scala/examples/server/twoToolsMcp.scala index 4a717e2..49e1411 100644 --- a/examples/src/main/scala/examples/server/twoToolsMcp.scala +++ b/examples/src/main/scala/examples/server/twoToolsMcp.scala @@ -18,16 +18,16 @@ case class IsFibonacciInput(n: Int) derives Codec, Schema .input[IsPrimeInput] .handle(i => if i.n <= 0 - then Left("Only positive numbers can be prime-checked") - else Right(isPrimeWithDescription(i.n)) + then ToolResult.error("Only positive numbers can be prime-checked") + else ToolResult.text(isPrimeWithDescription(i.n)) ) val isFibonacci = tool("isFibonacci") .description("Checks if a number is a Fibonacci number") .input[IsFibonacciInput] - .handle(i => Right(isFibonacciWithDescription(i.n))) + .handle(i => ToolResult.text(isFibonacciWithDescription(i.n))) - val mcpServerEndpoint = mcpEndpoint(List(isPrimeTool, isFibonacci), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(isPrimeTool, isFibonacci)).endpoint(List("mcp")) NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() private def smallestDivisor(n: Int): Int = diff --git a/examples/src/main/scala/examples/server/weatherMcp.scala b/examples/src/main/scala/examples/server/weatherMcp.scala index 2ff7008..dbfab4f 100644 --- a/examples/src/main/scala/examples/server/weatherMcp.scala +++ b/examples/src/main/scala/examples/server/weatherMcp.scala @@ -30,12 +30,13 @@ case class OpenMeteoResponse(current_weather: OpenMeteoCurrentWeather) derives C .description("Checks the weather in the given city") .input[WeatherInput] .handle: input => - either: - val geocodeResult = geocodeCity(input.city, sttpBackend).ok() - val weatherResult = fetchWeather(geocodeResult.lat, geocodeResult.lon, sttpBackend).ok() - weatherDescription(geocodeResult.display_name, weatherResult.temperature, weatherResult.weathercode) + ToolResult.fromEither: + either: + val geocodeResult = geocodeCity(input.city, sttpBackend).ok() + val weatherResult = fetchWeather(geocodeResult.lat, geocodeResult.lon, sttpBackend).ok() + weatherDescription(geocodeResult.display_name, weatherResult.temperature, weatherResult.weathercode) - val mcpServerEndpoint = mcpEndpoint(List(weatherTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(weatherTool)).endpoint(List("mcp")) NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() /** Maps Open-Meteo weather codes to human-readable descriptions. */ diff --git a/server-conformance/src/main/scala/chimp/conformance/server/Main.scala b/server-conformance/src/main/scala/chimp/conformance/server/Main.scala index 64255f5..9574ac1 100644 --- a/server-conformance/src/main/scala/chimp/conformance/server/Main.scala +++ b/server-conformance/src/main/scala/chimp/conformance/server/Main.scala @@ -14,17 +14,17 @@ object Main: private val addNumbers = tool("add_numbers") .description("Adds two numbers and returns the result as text") .input[AddNumbersInput] - .handle(in => Right((in.a + in.b).toString)) + .handle(in => ToolResult.text((in.a + in.b).toString)) private val simpleText = tool("test_simple_text") .description("Returns a fixed text string") .input[NoInput] - .handle(_ => Right("This is a simple text response for testing")) + .handle(_ => ToolResult.text("This is a simple text response for testing")) private val errorTool = tool("test_error_handling") .description("Always returns an error result") .input[NoInput] - .handle(_ => Left("This tool intentionally returns an error for testing")) + .handle(_ => ToolResult.error("This tool intentionally returns an error for testing")) private val tools = List(addNumbers, simpleText, errorTool) @@ -34,7 +34,7 @@ object Main: .orElse(sys.env.get("CHIMP_CONFORMANCE_PORT").map(_.toInt)) .getOrElse(0) - val endpoint = mcpEndpoint(tools, List("mcp"), name = "chimp-conformance-server", version = "0.1.0") + val endpoint = McpServer(name = "chimp-conformance-server", version = "0.1.0", tools = tools).endpoint(List("mcp")) supervised: val binding = NettySyncServer().port(requestedPort).addEndpoint(endpoint).start() diff --git a/server/src/main/scala/chimp/server/McpEndpoint.scala b/server/src/main/scala/chimp/server/McpEndpoint.scala index 1a538c5..fd652ef 100644 --- a/server/src/main/scala/chimp/server/McpEndpoint.scala +++ b/server/src/main/scala/chimp/server/McpEndpoint.scala @@ -8,24 +8,8 @@ import sttp.tapir.json.circe.* import sttp.tapir.server.ServerEndpoint import sttp.model.Header -/** Creates a Tapir endpoint description, which will handle MCP HTTP server requests, using the provided tools. - * - * @param tools - * The list of tools to expose. - * @param path - * The path components at which to expose the MCP server. - * - * @tparam F - * The effect type. Might be `Identity` for endpoints with synchronous logic. - */ -def mcpEndpoint[F[_]]( - tools: List[ServerTool[?, F, ServerContext[F]]], - path: List[String], - name: String = "Chimp MCP server", - version: String = "1.0.0", - showJsonSchemaMetadata: Boolean = true -): ServerEndpoint[Any, F] = - val mcpHandler = new McpHandler(tools, name, version, showJsonSchemaMetadata) +private[server] def buildEndpoint[F[_]](server: McpServer[F], path: List[String]): ServerEndpoint[Any, F] = + val mcpHandler = new McpHandler(server) val e = infallibleEndpoint.post .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) .in(extractFromRequest(_.headers)) diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index c83ffe3..c7e0f17 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -30,68 +30,65 @@ enum McpResponse: case JsonResponse(json) => JsonResponse(json.deepDropNullValues) case EmptyAcceptResponse => this -/** The MCP server handles JSON-RPC requests for tool listing, invocation, and initialization. - * - * @param tools - * The list of available server tools. - * @param name - * The server name (for protocol reporting). - * @param version - * The server version (for protocol reporting). - * @param showJsonSchemaMetadata - * Whether to include JSON Schema metadata (such as $schema) in the tool input schemas. Some agents do not recognize it, so it can be - * disabled. - */ -class McpHandler[F[_]]( - tools: List[ServerTool[?, F]], - name: String, - version: String, - showJsonSchemaMetadata: Boolean -): +/** Handles MCP JSON-RPC requests for a single [[McpServer]] definition: lifecycle, tools, resources, prompts, completion, and logging. */ +class McpHandler[F[_]](server: McpServer[F]): private val logger = LoggerFactory.getLogger(classOf[McpHandler[_]]) - private val toolsByName = tools.map(t => t.name -> t).toMap - - /** Converts a ServerTool to its protocol definition. */ - private def toolToDefinition(tool: ServerTool[?, F]): ToolDefinition = - val jsonSchema = - val base = TapirSchemaToJsonSchema(tool.inputSchema, markOptionsAsNullable = false) - if showJsonSchemaMetadata then base - else base.copy($schema = None) - - val json = jsonSchema.asJson + private val toolsByName = server.tools.map(t => t.name -> t).toMap + private val promptsByName = server.prompts.map(p => p.definition.name -> p).toMap + private val resourcesByUri = server.resources.map(r => r.definition.uri -> r).toMap + private val hasResources = server.resources.nonEmpty || server.resourceTemplates.nonEmpty + + private def toolToDefinition(tool: ServerTool[?, F, ServerContext[F]]): ToolDefinition = + val jsonSchema = tool.inputSchema match + case ToolSchema.Derived(schema) => + val base = TapirSchemaToJsonSchema(schema, markOptionsAsNullable = false) + (if server.showJsonSchemaMetadata then base else base.copy($schema = None)).asJson + case ToolSchema.Raw(json) => json ToolDefinition( name = tool.name, description = tool.description, - inputSchema = json, + inputSchema = jsonSchema, annotations = tool.annotations .map(a => ToolAnnotations(a.title, a.readOnlyHint, a.destructiveHint, a.idempotentHint, a.openWorldHint)) ) - private val toolDefs: List[ToolDefinition] = tools.map(toolToDefinition) + private val toolDefs: List[ToolDefinition] = server.tools.map(toolToDefinition) - private def protocolError(id: RequestId, code: Int, message: String): JSONRPCMessage.Error = + 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)) + JSONRPCMessage.Error(id = id, error = JSONRPCErrorObject(code = code, message = message, data = data)) + + private def jsonResponse(message: JSONRPCMessage): McpResponse = McpResponse.JsonResponse(message.asJson) + + private def emptyResult(id: RequestId): JSONRPCMessage = JSONRPCMessage.Response(id = id, result = Json.obj()) + + private def decodeParams[P: Decoder](params: Option[Json], id: RequestId)(f: P => F[JSONRPCMessage])(using + MonadError[F] + ): F[JSONRPCMessage] = + params.flatMap(_.as[P].toOption) match + case Some(p) => f(p) + case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Invalid or missing params").unit 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(tools = Some(ServerToolsCapability(listChanged = Some(false)))) - val result = - InitializeResult(protocolVersion = negotiated.name, capabilities = capabilities, serverInfo = Implementation(name, version)) + val capabilities = ServerCapabilities( + logging = Option.when(server.setLevel.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, + serverInfo = Implementation(server.name, server.version), + instructions = server.instructions + ) JSONRPCMessage.Response(id = id, result = result.asJson) - /** Handles the 'tools/list' JSON-RPC method, returning the list of available tools. */ - private def handleToolsList(id: RequestId): JSONRPCMessage.Response = - JSONRPCMessage.Response(id = id, result = ListToolsResponse(toolDefs).asJson) - - /** Handles the 'tools/call' JSON-RPC method. Attempts to decode the tool name and arguments, then dispatches to the tool logic. Provides - * detailed error messages for decode failures. - */ - private def handleToolsCall(params: Option[io.circe.Json], id: RequestId, headers: Seq[Header])(using - MonadError[F] - ): F[JSONRPCMessage] = - // Extract tool name and arguments in a functional, idiomatic way + private def handleToolsCall(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] = val toolNameOpt = params.flatMap(_.hcursor.downField("name").as[String].toOption) val args = params.flatMap(_.hcursor.downField("arguments").focus).getOrElse(Json.obj()) toolNameOpt match @@ -111,56 +108,111 @@ class McpHandler[F[_]]( case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Missing tool name").unit - /** Handles a successfully decoded tool input, dispatching to the tool's logic. */ - private def handleDecodedInput[T](tool: ServerTool[T, F], decodedInput: T, id: RequestId, headers: Seq[Header])(using + private def handleDecodedInput[T](tool: ServerTool[T, F, ServerContext[F]], decodedInput: T, id: RequestId, headers: Seq[Header])(using MonadError[F] ): F[JSONRPCMessage] = tool - .logic(decodedInput, headers) - .map: - case Right(result) => - val callResult = CallToolResult( - content = List(ToolContent.Text(text = result)), - isError = false - ) - JSONRPCMessage.Response(id = id, result = callResult.asJson) - case Left(errorMsg) => - val callResult = CallToolResult( - content = List(ToolContent.Text(text = errorMsg)), - isError = true - ) - JSONRPCMessage.Response(id = id, result = callResult.asJson) - - /** Handles a JSON-RPC request, dispatching to the appropriate handler. Logs requests and responses. */ + .logic(decodedInput, ServerContext.noOp[F], headers) + .map: result => + val callResult = CallToolResult( + content = result.content, + structuredContent = result.structuredContent, + isError = result.isError + ) + JSONRPCMessage.Response(id = id, result = callResult.asJson) + + private def readResponse(id: RequestId, uri: String)(result: Either[ResourceError, List[ResourceContents]]): JSONRPCMessage = + result match + case Right(contents) => JSONRPCMessage.Response(id = id, result = ReadResourceResult(contents).asJson) + case Left(error) => + protocolError( + id, + JSONRPCErrorCodes.InvalidParams.code, + error.message, + error.uri.orElse(Some(uri)).map(u => Json.obj("uri" -> Json.fromString(u))) + ) + + private def handleResourcesRead(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = + decodeParams[ReadResourceParams](params, id): p => + resourcesByUri.get(p.uri) match + case Some(resource) => resource.read().map(readResponse(id, p.uri)) + case None => + val templateMatch = server.resourceTemplates.iterator + .map(t => (t, t.matcher.matchUri(p.uri))) + .collectFirst { case (t, Some(vars)) => (t, vars) } + templateMatch match + case Some((template, vars)) => template.read(vars, p.uri).map(readResponse(id, p.uri)) + case None => + protocolError( + id, + JSONRPCErrorCodes.InvalidParams.code, + s"Resource not found: ${p.uri}", + Some(Json.obj("uri" -> Json.fromString(p.uri))) + ).unit + + private def handleSubscribe(params: Option[Json], id: RequestId, subscribe: Boolean)(using MonadError[F]): F[JSONRPCMessage] = + val subs = server.subscriptions.get + if subscribe then decodeParams[SubscribeParams](params, id)(p => subs.onSubscribe(p).map(_ => emptyResult(id))) + else decodeParams[UnsubscribeParams](params, id)(p => subs.onUnsubscribe(p).map(_ => emptyResult(id))) + + private def handlePromptsGet(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] = + decodeParams[GetPromptParams](params, id): p => + promptsByName.get(p.name) match + case Some(prompt) => + prompt.logic(p.arguments.getOrElse(Map.empty), headers).map(result => JSONRPCMessage.Response(id = id, result = result.asJson)) + case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, s"Unknown prompt: ${p.name}").unit + + private def handleComplete(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = + val handler = server.completion.get + decodeParams[CompleteParams](params, id): p => + handler(p.ref, p.argument, p.context).map(completion => JSONRPCMessage.Response(id = id, result = CompleteResult(completion).asJson)) + + private def handleSetLevel(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = + val handler = server.setLevel.get + decodeParams[SetLevelParams](params, id)(p => handler(p.level).map(_ => emptyResult(id))) + private def doHandleJsonRpc(request: Json, headers: Seq[Header])(using MonadError[F]): F[McpResponse] = request.as[JSONRPCMessage] match case Left(err) => val errorResponse = protocolError(RequestId("null"), JSONRPCErrorCodes.ParseError.code, s"Parse error: ${err.message}") - McpResponse.JsonResponse((errorResponse: JSONRPCMessage).asJson).unit - case Right(JSONRPCMessage.Request(_, method, params: Option[io.circe.Json], id)) => + jsonResponse(errorResponse).unit + case Right(JSONRPCMessage.Request(_, method, params: Option[Json], id)) => method match case "tools/list" => - val response = handleToolsList(id) - McpResponse.JsonResponse((response: JSONRPCMessage).asJson).unit + jsonResponse(JSONRPCMessage.Response(id = id, result = ListToolsResponse(toolDefs).asJson)).unit case "tools/call" => - handleToolsCall(params, id, headers).map { response => - McpResponse.JsonResponse((response: JSONRPCMessage).asJson) - } + handleToolsCall(params, id, headers).map(jsonResponse) case "initialize" => - val response = handleInitialize(params, id) - McpResponse.JsonResponse((response: JSONRPCMessage).asJson).unit + jsonResponse(handleInitialize(params, id)).unit case "ping" => - val response = JSONRPCMessage.Response(id = id, result = Json.obj()) - McpResponse.JsonResponse((response: JSONRPCMessage).asJson).unit + jsonResponse(JSONRPCMessage.Response(id = id, result = Json.obj())).unit + 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).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.setLevel.isDefined => + handleSetLevel(params, id).map(jsonResponse) case other => - val errorResponse = protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown method: $other") - McpResponse.JsonResponse((errorResponse: JSONRPCMessage).asJson).unit + jsonResponse(protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown method: $other")).unit case Right(notification: JSONRPCMessage.Notification) => logger.debug(s"Received notification: ${notification.method}") McpResponse.EmptyAcceptResponse.unit case Right(_) => - val errorResponse = protocolError(RequestId("null"), JSONRPCErrorCodes.InvalidRequest.code, "Invalid request type") - McpResponse.JsonResponse((errorResponse: JSONRPCMessage).asJson).unit + jsonResponse(protocolError(RequestId("null"), JSONRPCErrorCodes.InvalidRequest.code, "Invalid request type")).unit end doHandleJsonRpc def handleJsonRpc(request: Json, headers: Seq[Header])(using MonadError[F]): F[McpResponse] = diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala new file mode 100644 index 0000000..7ed3a9c --- /dev/null +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -0,0 +1,52 @@ +package chimp.server + +import chimp.protocol.{CompleteArgument, CompleteContext, CompleteRef, Completion, LoggingLevel, SubscribeParams, UnsubscribeParams} +import sttp.tapir.server.ServerEndpoint + +/** Logic producing completion suggestions for an argument of a prompt or resource-template reference. */ +type CompletionHandler[F[_]] = (CompleteRef, CompleteArgument, Option[CompleteContext]) => F[Completion] + +/** Logic invoked when the client sets the minimum logging level via `logging/setLevel`. */ +type SetLevelHandler[F[_]] = LoggingLevel => F[Unit] + +/** Logic invoked when the client subscribes to / unsubscribes from updates for a resource URI. */ +case class ResourceSubscriptions[F[_]]( + onSubscribe: SubscribeParams => F[Unit], + onUnsubscribe: UnsubscribeParams => F[Unit] +) + +/** An MCP server definition: the features it exposes and the metadata it reports. Build one up with the `add*`/`with*` methods, then turn + * it into a Tapir endpoint with [[endpoint]]. Server capabilities are derived from which features are registered. + */ +case class McpServer[F[_]]( + name: String = "Chimp MCP server", + version: String = "1.0.0", + instructions: Option[String] = None, + showJsonSchemaMetadata: Boolean = true, + tools: List[ServerTool[?, F, ServerContext[F]]] = Nil, + prompts: List[ServerPrompt[F]] = Nil, + resources: List[ServerResource[F]] = Nil, + resourceTemplates: List[ServerResourceTemplate[F]] = Nil, + completion: Option[CompletionHandler[F]] = None, + setLevel: Option[SetLevelHandler[F]] = None, + subscriptions: Option[ResourceSubscriptions[F]] = None +): + def name(value: String): McpServer[F] = copy(name = value) + def version(value: String): McpServer[F] = copy(version = value) + def instructions(value: String): McpServer[F] = copy(instructions = Some(value)) + def withJsonSchemaMetadata(value: Boolean): McpServer[F] = copy(showJsonSchemaMetadata = value) + + def addTool(t: ServerTool[?, F, ServerContext[F]]): McpServer[F] = copy(tools = tools :+ t) + def addTools(ts: ServerTool[?, F, ServerContext[F]]*): McpServer[F] = copy(tools = tools ++ ts) + def addPrompt(p: ServerPrompt[F]): McpServer[F] = copy(prompts = prompts :+ p) + def addPrompts(ps: ServerPrompt[F]*): McpServer[F] = copy(prompts = prompts ++ ps) + def addResource(r: ServerResource[F]): McpServer[F] = copy(resources = resources :+ r) + def addResources(rs: ServerResource[F]*): McpServer[F] = copy(resources = resources ++ rs) + def addResourceTemplate(rt: ServerResourceTemplate[F]): McpServer[F] = copy(resourceTemplates = resourceTemplates :+ rt) + def addResourceTemplates(rts: ServerResourceTemplate[F]*): McpServer[F] = copy(resourceTemplates = resourceTemplates ++ rts) + def withCompletion(handler: CompletionHandler[F]): McpServer[F] = copy(completion = Some(handler)) + def withLogging(handler: SetLevelHandler[F]): McpServer[F] = copy(setLevel = Some(handler)) + def withSubscriptions(handler: ResourceSubscriptions[F]): McpServer[F] = copy(subscriptions = Some(handler)) + + /** Build the Tapir endpoint serving this MCP server at the given path. */ + def endpoint(path: List[String]): ServerEndpoint[Any, F] = buildEndpoint(this, path) diff --git a/server/src/main/scala/chimp/server/Prompt.scala b/server/src/main/scala/chimp/server/Prompt.scala new file mode 100644 index 0000000..af3bccb --- /dev/null +++ b/server/src/main/scala/chimp/server/Prompt.scala @@ -0,0 +1,34 @@ +package chimp.server + +import chimp.protocol.{GetPromptResult, Prompt, PromptArgument} +import sttp.model.Header +import sttp.shared.Identity + +/** Creates a new MCP prompt description with the given name. */ +def prompt(name: String): PartialPrompt = PartialPrompt(name) + +/** Describes a prompt before its logic is specified. */ +case class PartialPrompt( + name: String, + title: Option[String] = None, + description: Option[String] = None, + arguments: List[PromptArgument] = Nil +): + def title(value: String): PartialPrompt = copy(title = Some(value)) + def description(value: String): PartialPrompt = copy(description = Some(value)) + def argument(name: String, description: Option[String] = None, required: Boolean = false): PartialPrompt = + copy(arguments = arguments :+ PromptArgument(name, description, required = Some(required))) + def arguments(args: PromptArgument*): PartialPrompt = copy(arguments = arguments ++ args) + + /** Combine the prompt description with logic that, given the resolved arguments, produces the prompt messages in the F-effect. */ + def get[F[_]](logic: Map[String, String] => F[GetPromptResult]): ServerPrompt[F] = + ServerPrompt(definition, (args, _) => logic(args)) + + /** Same as [[get]], but with synchronous logic. */ + def handle(logic: Map[String, String] => GetPromptResult): ServerPrompt[Identity] = + ServerPrompt(definition, (args, _) => logic(args)) + + private def definition: Prompt = Prompt(name, title, description, Option.when(arguments.nonEmpty)(arguments)) + +/** A prompt that can be retrieved from the MCP server. */ +case class ServerPrompt[F[_]](definition: Prompt, logic: (Map[String, String], Seq[Header]) => F[GetPromptResult]) diff --git a/server/src/main/scala/chimp/server/Resource.scala b/server/src/main/scala/chimp/server/Resource.scala new file mode 100644 index 0000000..d0c792d --- /dev/null +++ b/server/src/main/scala/chimp/server/Resource.scala @@ -0,0 +1,101 @@ + +package chimp.server + +import chimp.protocol.{Resource, ResourceContents, ResourceTemplate} +import sttp.shared.Identity + +import java.util.regex.Pattern +import scala.util.matching.Regex + +/** A failure to read a resource. Surfaced to the client as a JSON-RPC `-32602` error; the optional `uri` is included in the error `data`. + */ +case class ResourceError(message: String, uri: Option[String] = None) + +/** Creates a new static MCP resource description for the given URI. */ +def resource(uri: String): PartialResource = PartialResource(uri) + +/** Creates a new MCP resource template description for the given RFC-6570 level-1 URI template (e.g. `test://item/{id}`). */ +def resourceTemplate(uriTemplate: String): PartialResourceTemplate = PartialResourceTemplate(uriTemplate) + +/** Describes a static resource before its read logic is specified. */ +case class PartialResource( + uri: String, + name: Option[String] = None, + title: Option[String] = None, + description: Option[String] = None, + mimeType: Option[String] = None, + size: Option[Long] = None +): + def name(value: String): PartialResource = copy(name = Some(value)) + def title(value: String): PartialResource = copy(title = Some(value)) + def description(value: String): PartialResource = copy(description = Some(value)) + def mimeType(value: String): PartialResource = copy(mimeType = Some(value)) + def size(value: Long): PartialResource = copy(size = Some(value)) + + /** Combine the resource description with logic that reads its contents in the F-effect. */ + def read[F[_]](logic: () => F[Either[ResourceError, List[ResourceContents]]]): ServerResource[F] = + ServerResource(definition, logic) + + /** Same as [[read]], but with synchronous logic. */ + def handle(logic: () => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = + ServerResource(definition, logic) + + private def definition: Resource = Resource(uri, name.getOrElse(uri), title, description, mimeType, size) + +/** A static resource that can be read from the MCP server. */ +case class ServerResource[F[_]](definition: Resource, read: () => F[Either[ResourceError, List[ResourceContents]]]) + +/** Describes a resource template before its read logic is specified. */ +case class PartialResourceTemplate( + uriTemplate: String, + name: Option[String] = None, + title: Option[String] = None, + description: Option[String] = None, + mimeType: Option[String] = None +): + def name(value: String): PartialResourceTemplate = copy(name = Some(value)) + def title(value: String): PartialResourceTemplate = copy(title = Some(value)) + def description(value: String): PartialResourceTemplate = copy(description = Some(value)) + def mimeType(value: String): PartialResourceTemplate = copy(mimeType = Some(value)) + + /** Combine the template with logic that reads contents for a concrete URI, given the extracted template variables and the matched URI. */ + def read[F[_]]( + logic: (Map[String, String], String) => F[Either[ResourceError, List[ResourceContents]]] + ): ServerResourceTemplate[F] = + ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), logic) + + /** Same as [[read]], but with synchronous logic. */ + def handle( + logic: (Map[String, String], String) => Either[ResourceError, List[ResourceContents]] + ): ServerResourceTemplate[Identity] = + ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), logic) + + private def definition: ResourceTemplate = ResourceTemplate(uriTemplate, name.getOrElse(uriTemplate), title, description, mimeType) + +/** A resource template that can be read from the MCP server. */ +case class ServerResourceTemplate[F[_]]( + definition: ResourceTemplate, + matcher: UriTemplate, + read: (Map[String, String], String) => F[Either[ResourceError, List[ResourceContents]]] +) + +/** A compiled RFC-6570 level-1 URI template. `{var}` segments match a single path segment and are extracted by name. */ +final class UriTemplate private (regex: Regex, names: List[String]): + def matchUri(uri: String): Option[Map[String, String]] = + regex.findFirstMatchIn(uri).map(m => names.zipWithIndex.map((n, i) => n -> m.group(i + 1)).toMap) + +object UriTemplate: + private val VarPattern: Regex = "\\{([^}]+)\\}".r + + def compile(template: String): UriTemplate = + val names = scala.collection.mutable.ListBuffer.empty[String] + val regex = new StringBuilder("^") + var last = 0 + for m <- VarPattern.findAllMatchIn(template) do + regex.append(Pattern.quote(template.substring(last, m.start))) + regex.append("([^/]+)") + names += m.group(1) + last = m.end + regex.append(Pattern.quote(template.substring(last))) + regex.append("$") + new UriTemplate(regex.toString.r, names.toList) diff --git a/server/src/main/scala/chimp/server/Tool.scala b/server/src/main/scala/chimp/server/Tool.scala index 0499fd0..0599fcf 100644 --- a/server/src/main/scala/chimp/server/Tool.scala +++ b/server/src/main/scala/chimp/server/Tool.scala @@ -36,8 +36,8 @@ object ToolResult: def structured[A: Encoder](value: A): ToolResult = ToolResult(Nil, structuredContent = Some(value.asJson)) def fromEither(result: Either[String, String]): ToolResult = result.fold(error, text) -/** A tool's input schema, either derived from a Tapir [[Schema]] or supplied directly as raw JSON Schema (for dialects Tapir cannot express, - * e.g. JSON Schema 2020-12 with `additionalProperties: false`). +/** A tool's input schema, either derived from a Tapir [[Schema]] or supplied directly as raw JSON Schema (for dialects Tapir cannot + * express, e.g. JSON Schema 2020-12 with `additionalProperties: false`). */ enum ToolSchema: case Derived(schema: Schema[?]) @@ -61,7 +61,8 @@ case class PartialTool( def withAnnotations(ann: ToolAnnotations): PartialTool = copy(annotations = Some(ann)) /** Specify the input type for the tool, providing both a Tapir Schema and a Circe Decoder. */ - def input[I: Schema: Decoder]: Tool[I] = Tool[I](name, description, ToolSchema.Derived(summon[Schema[I]]), summon[Decoder[I]], annotations) + def input[I: Schema: Decoder]: Tool[I] = + Tool[I](name, description, ToolSchema.Derived(summon[Schema[I]]), summon[Decoder[I]], annotations) /** Specify the tool's input schema directly as raw JSON Schema. The tool receives its arguments as raw [[Json]]. */ def inputJson(schema: Json): Tool[Json] = Tool[Json](name, description, ToolSchema.Raw(schema), summon[Decoder[Json]], annotations) diff --git a/server/src/main/scala/chimp/server/tool.scala b/server/src/main/scala/chimp/server/tool.scala deleted file mode 100644 index 691a319..0000000 --- a/server/src/main/scala/chimp/server/tool.scala +++ /dev/null @@ -1,76 +0,0 @@ -package chimp.server - -import sttp.tapir.Schema -import io.circe.Decoder -import sttp.model.Header -import sttp.shared.Identity - -case class ToolAnnotations( - title: Option[String] = None, - readOnlyHint: Option[Boolean] = None, - destructiveHint: Option[Boolean] = None, - idempotentHint: Option[Boolean] = None, - openWorldHint: Option[Boolean] = None -) - -/** Describes a tool before the input is specified. */ -case class PartialTool( - name: String, - description: Option[String] = None, - annotations: Option[ToolAnnotations] = None -): - def description(desc: String): PartialTool = copy(description = Some(desc)) - def withAnnotations(ann: ToolAnnotations): PartialTool = copy(annotations = Some(ann)) - - /** Specify the input type for the tool, providing both a Tapir Schema and a Circe Decoder. */ - def input[I: Schema: Decoder]: Tool[I] = Tool[I](name, description, summon[Schema[I]], summon[Decoder[I]], annotations) - -private val ToolNameRegex = "^[A-Za-z0-9_./-]+$".r - -/** Creates a new MCP tool description with the given name. The name must match `^[A-Za-z0-9_./-]+$` and be 1–64 characters long. */ -def tool(name: String): PartialTool = - require(name.length >= 1 && name.length <= 64, s"Tool name must be 1..64 characters long, got ${name.length}: $name") - require(ToolNameRegex.matches(name), s"Tool name must match ${ToolNameRegex.regex}, got: $name") - PartialTool(name) - -// - -/** Describes a tool after the input is specified. */ -case class Tool[I]( - name: String, - description: Option[String], - inputSchema: Schema[I], - inputDecoder: Decoder[I], - annotations: Option[ToolAnnotations] -): - /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, - * should return either a tool execution error (`Left`), or a successful textual result (`Right`), using the F-effect. - */ - def serverLogic[F[_]](logic: (I, Seq[Header]) => F[Either[String, String]]): ServerTool[I, F] = - ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) - - /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, - * should return either a tool execution error (`Left`), or a successful textual result (`Right`). - * - * Same as [[serverLogic]], but using the identity "effect". - */ - def handleWithHeaders(logic: (I, Seq[Header]) => Either[String, String]): ServerTool[I, Identity] = - ServerTool(name, description, inputSchema, inputDecoder, annotations, (i, t) => logic(i, t)) - - /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, - * should return either a tool execution error (`Left`), or a successful textual result (`Right`). - * - * Same as [[handleWithHeaders]], but using no headers. - */ - def handle(logic: I => Either[String, String]): ServerTool[I, Identity] = - handleWithHeaders((i, _) => logic(i)) - -/** A tool that can be executed by the MCP server. */ -case class ServerTool[I, F[_]]( - name: String, - description: Option[String], - inputSchema: Schema[I], - inputDecoder: Decoder[I], - annotations: Option[ToolAnnotations], - logic: (I, Seq[Header]) => F[Either[String, String]] -) diff --git a/server/src/test/scala/chimp/server/McpHandlerSpec.scala b/server/src/test/scala/chimp/server/McpHandlerSpec.scala index 330b652..0d0e9b5 100644 --- a/server/src/test/scala/chimp/server/McpHandlerSpec.scala +++ b/server/src/test/scala/chimp/server/McpHandlerSpec.scala @@ -24,17 +24,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: val echoTool = tool("echo") .description("Echoes the input message.") .input[EchoInput] - .handle(in => Right(in.message)) + .handle(in => ToolResult.text(in.message)) val addTool = tool("add") .description("Adds two numbers.") .input[AddInput] - .handle(in => Right((in.a + in.b).toString)) + .handle(in => ToolResult.text((in.a + in.b).toString)) val errorTool = tool("fail") .description("Always fails.") .input[EchoInput] - .handle(_ => Left("Intentional failure")) + .handle(_ => ToolResult.error("Intentional failure")) // Tool that echoes the header's value for testing case class HeaderEchoInput(dummy: String) derives Schema, Codec @@ -42,16 +42,44 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: .description("Echoes the header value if present.") .input[HeaderEchoInput] .handleWithHeaders { (_, headers) => - if headers.isEmpty then Right("no header") + if headers.isEmpty then ToolResult.text("no header") else - Right( + ToolResult.text( headers .map(header => s"header name: ${header.name}, header value: ${header.value}") .mkString(", ") ) } - val handler = McpHandler(List(echoTool, addTool, errorTool, headerEchoTool), "Chimp MCP server", "1.0.0", true) + val handler = McpHandler(McpServer(name = "Chimp MCP server", tools = List(echoTool, addTool, errorTool, headerEchoTool))) + + // Feature fixtures (resources, prompts, completion, logging) + private val textResource = resource("test://text") + .name("text") + .mimeType("text/plain") + .handle(() => Right(List(ResourceContents.Text(uri = "test://text", text = "hello text", mimeType = Some("text/plain"))))) + + private val itemTemplate = resourceTemplate("test://item/{id}") + .name("item") + .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) + + private val greetPrompt = prompt("greet") + .description("Greets by name") + .argument("name", required = true) + .handle(args => + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello ${args.getOrElse("name", "?")}")))) + ) + + private val levelRef = new java.util.concurrent.atomic.AtomicReference(Option.empty[LoggingLevel]) + + private val featuresServer = McpServer[Identity](name = "Features") + .addResource(textResource) + .addResourceTemplate(itemTemplate) + .addPrompt(greetPrompt) + .withCompletion((_, _, _) => Completion(values = List("Alice", "Bob"))) + .withLogging(level => levelRef.set(Some(level))) + + private val featuresHandler = McpHandler(featuresServer) def parseJson(str: String): Json = parse(str).getOrElse(throw new RuntimeException("Invalid JSON")) @@ -337,9 +365,9 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: val optionalTool = tool("optionalTest") .description("Test tool with optional fields.") .input[OptionalFieldInput] - .handle(_ => Right("ok")) + .handle(_ => ToolResult.text("ok")) - val handlerWithOptional = McpHandler(List(optionalTool), "Test", "1.0.0", true) + val handlerWithOptional = McpHandler(McpServer(name = "Test", tools = List(optionalTool))) val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("opt1")) val json = req.asJson @@ -375,3 +403,92 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: requiredFields should contain("requiredField") requiredFields should not contain "optionalField" case _ => fail("Expected Response") + + private def featureResult(method: String, params: Option[Json], id: String): JSONRPCMessage = + val req: JSONRPCMessage = Request(method = method, params = params, id = RequestId(id)) + val response = featuresHandler.handleJsonRpc(req.asJson, Seq.empty) + extractJsonFromResponse(response).as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) + + it should "list resources" in: + featureResult("resources/list", None, "r1") match + case Response(_, _, result) => + result.as[ListResourcesResult].getOrElse(fail("result")).resources.map(_.uri) shouldBe List("test://text") + case _ => fail("Expected Response") + + it should "read a static text resource" in: + val params = Json.obj("uri" -> Json.fromString("test://text")) + featureResult("resources/read", Some(params), "r2") match + case Response(_, _, result) => + result.as[ReadResourceResult].getOrElse(fail("result")).contents shouldBe + List(ResourceContents.Text(uri = "test://text", text = "hello text", mimeType = Some("text/plain"))) + case _ => fail("Expected Response") + + it should "read a templated resource, substituting variables" in: + val params = Json.obj("uri" -> Json.fromString("test://item/42")) + featureResult("resources/read", Some(params), "r3") match + case Response(_, _, result) => + val contents = result.as[ReadResourceResult].getOrElse(fail("result")).contents + contents.head match + case ResourceContents.Text(uri, text, _, _) => + uri shouldBe "test://item/42" + text should include("42") + case _ => fail("Expected text contents") + case _ => fail("Expected Response") + + it should "return a -32602 error with data.uri for an unknown resource (sep-2164)" in: + val params = Json.obj("uri" -> Json.fromString("test://missing")) + featureResult("resources/read", Some(params), "r4") match + case Error(_, _, error) => + error.code shouldBe InvalidParams.code + error.data.flatMap(_.hcursor.downField("uri").as[String].toOption) shouldBe Some("test://missing") + case _ => fail("Expected Error") + + it should "list prompts" in: + featureResult("prompts/list", None, "p1") match + case Response(_, _, result) => + result.as[ListPromptsResult].getOrElse(fail("result")).prompts.map(_.name) shouldBe List("greet") + case _ => fail("Expected Response") + + it should "get a prompt, substituting arguments" in: + val params = Json.obj("name" -> Json.fromString("greet"), "arguments" -> Json.obj("name" -> Json.fromString("World"))) + featureResult("prompts/get", Some(params), "p2") match + case Response(_, _, result) => + result.as[GetPromptResult].getOrElse(fail("result")).messages.head.content match + case ToolContent.Text(_, text) => text should include("World") + case _ => fail("Expected text content") + case _ => fail("Expected Response") + + it should "return completion values" in: + val params = Json.obj( + "ref" -> Json.obj("type" -> Json.fromString("ref/prompt"), "name" -> Json.fromString("greet")), + "argument" -> Json.obj("name" -> Json.fromString("name"), "value" -> Json.fromString("A")) + ) + featureResult("completion/complete", Some(params), "c1") match + case Response(_, _, result) => + result.as[CompleteResult].getOrElse(fail("result")).completion.values shouldBe List("Alice", "Bob") + case _ => fail("Expected Response") + + it should "set the logging level and return an empty result" in: + val params = Json.obj("level" -> Json.fromString("info")) + featureResult("logging/setLevel", Some(params), "l1") match + case Response(_, _, result) => result shouldBe Json.obj() + case _ => fail("Expected Response") + levelRef.get() shouldBe Some(LoggingLevel.Info) + + it should "advertise capabilities derived from registered features" in: + featureResult("initialize", None, "i1") match + case Response(_, _, result) => + val caps = result.as[InitializeResult].getOrElse(fail("result")).capabilities + caps.resources.flatMap(_.subscribe) shouldBe Some(false) + caps.prompts.isDefined shouldBe true + caps.completions.isDefined shouldBe true + caps.logging.isDefined shouldBe true + caps.tools shouldBe None + case _ => fail("Expected Response") + + it should "return MethodNotFound for a feature method that is not configured" in: + // featuresServer has no subscriptions handler, so resources/subscribe is not available + val params = Json.obj("uri" -> Json.fromString("test://text")) + featureResult("resources/subscribe", Some(params), "n1") match + case Error(_, _, error) => error.code shouldBe MethodNotFound.code + case _ => fail("Expected Error") From 474c927ec14d77de38ce09f5fa78225f722d39db Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Mon, 15 Jun 2026 16:14:43 +0200 Subject: [PATCH 03/20] feat: DNS rebinding prevention, tests --- conformance-baseline.yml | 25 +-- .../src/main/resources/sample.png | Bin 0 -> 165 bytes .../src/main/resources/sample.wav | Bin 0 -> 3244 bytes .../scala/chimp/conformance/server/Main.scala | 144 +++++++++++++++++- .../main/scala/chimp/server/McpEndpoint.scala | 45 +++++- .../main/scala/chimp/server/McpServer.scala | 2 + .../scala/chimp/server/OriginCheckSpec.scala | 28 ++++ 7 files changed, 217 insertions(+), 27 deletions(-) create mode 100644 server-conformance/src/main/resources/sample.png create mode 100644 server-conformance/src/main/resources/sample.wav create mode 100644 server/src/test/scala/chimp/server/OriginCheckSpec.scala diff --git a/conformance-baseline.yml b/conformance-baseline.yml index 2eb1a2e..a0b0211 100644 --- a/conformance-baseline.yml +++ b/conformance-baseline.yml @@ -1,32 +1,17 @@ server: - - tools-call-image - - tools-call-audio - - tools-call-mixed-content - - tools-call-embedded-resource + # Streaming / bidirectional — Phase 2/3 followup - tools-call-with-progress - tools-call-with-logging - tools-call-sampling - tools-call-elicitation - - json-schema-2020-12 - elicitation-sep1034-defaults - elicitation-sep1330-enums - - resources-list - - resources-read-text - - resources-read-binary - - resources-templates-read - - resources-subscribe - - resources-unsubscribe - - sep-2164-resource-not-found - - prompts-list - - prompts-get-simple - - prompts-get-with-args - - prompts-get-embedded-resource - - prompts-get-with-image - - logging-set-level - - completion-complete + # SSE polish — Phase 4 followup - server-sse-polling - server-sse-multiple-streams - - dns-rebinding-protection + # Gated to an older/draft protocol version (out of scope — latest protocol only) + - json-schema-2020-12 + - sep-2164-resource-not-found client: - elicitation-sep1034-client-defaults - sse-retry diff --git a/server-conformance/src/main/resources/sample.png b/server-conformance/src/main/resources/sample.png new file mode 100644 index 0000000000000000000000000000000000000000..07801cf270bc47544721720a3c60dd5a040d6b57 GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1SGw4HSYka98VX=kcwNPXB|Zu0y&%l7fZBi zxK##rIEgGeBR@Tc?;MYH#*W5=&TV|gr}d)p5qR literal 0 HcmV?d00001 diff --git a/server-conformance/src/main/resources/sample.wav b/server-conformance/src/main/resources/sample.wav new file mode 100644 index 0000000000000000000000000000000000000000..fac21def2c16c155a5afdbe96e865961f4474449 GIT binary patch literal 3244 zcmWIYbaPw6!@v;k80MOmTcRMqz`(!=gbwly3=MV+3``6H3@M2vi48zCKpEaj30}qR zs&7@7DW*s&@tH6xf2nve^?v)U7dKDc-Tw5zhv|Q}aZMCEByXr{rM6E=O*&FwE_2DZ z>{lNiDBfOo^Y9(!CzIY8{7&GQED|o)qLQe#S^2C?y$~bY`5&ckvK~FSJ?rL!+gXoF z-<Pm` z`R99{w}fv+-JA35-p4cl?s9jCXDd{yN~oDBc}h9(XEJ$x&3|e4;QXy)H=XX@db0d| z@}C~gsiFe%_f>+_Y?c2?=Lue5S@1pg^~#4ex98vVy<_~i@$LVghV0$K(_|G?($o}G zrpa~-8?yiZ+4$D@vG1MvH*0RMe3<)s!S@R+d4hkXZIy%6?yCsMPZjOqO#ZX{{jDcX zcaPmXf6MMc{!6c~nM@A+o>FE?5^9yI*$N%vce&5}yZ3R)oszSsWz|EJOT h7>$q7{4tt8M$4 ToolResult.error("This tool intentionally returns an error for testing")) - private val tools = List(addNumbers, simpleText, errorTool) + private val imageTool = tool("test_image_content") + .description("Returns image content") + .input[NoInput] + .handle(_ => ToolResult.image(pngData, "image/png")) + + private val audioTool = tool("test_audio_content") + .description("Returns audio content") + .input[NoInput] + .handle(_ => ToolResult.audio(wavData, "audio/wav")) + + private val mixedTool = tool("test_multiple_content_types") + .description("Returns text, image and embedded-resource content") + .input[NoInput] + .handle(_ => + ToolResult.content( + ToolContent.Text(text = "Here is some mixed content"), + ToolContent.Image(data = pngData, mimeType = "image/png"), + ToolContent.ResourceContent(resource = ResourceContents.Text(uri = "test://embedded", text = "embedded", mimeType = Some("text/plain"))) + ) + ) + + private val embeddedResourceTool = tool("test_embedded_resource") + .description("Returns an embedded resource") + .input[NoInput] + .handle(_ => + ToolResult.embedded(ResourceContents.Text(uri = "test://embedded", text = "embedded resource content", mimeType = Some("text/plain"))) + ) + + private val jsonSchema2020: Json = parse( + """{ + | "$schema": "https://json-schema.org/draft/2020-12/schema", + | "type": "object", + | "$defs": { + | "address": { + | "$anchor": "addressDef", + | "type": "object", + | "properties": { + | "street": { "type": "string" }, + | "city": { "type": "string" } + | } + | } + | }, + | "properties": { + | "name": { "type": "string" }, + | "address": { "$ref": "#/$defs/address" }, + | "contactMethod": { "type": "string", "enum": ["phone", "email"] }, + | "phone": { "type": "string" }, + | "email": { "type": "string" } + | }, + | "allOf": [ + | { "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] } + | ], + | "if": { + | "properties": { "contactMethod": { "const": "phone" } }, + | "required": ["contactMethod"] + | }, + | "then": { "required": ["phone"] }, + | "else": { "required": ["email"] }, + | "additionalProperties": false + |}""".stripMargin + ).toOption.get + + private val jsonSchemaTool = tool("json_schema_2020_12_tool") + .description("Advertises a JSON Schema 2020-12 input schema") + .inputJson(jsonSchema2020) + .handle(_ => ToolResult.text("ok")) + + // ── resources ── + private val staticText = resource("test://static-text") + .name("static-text") + .mimeType("text/plain") + .handle(() => Right(List(ResourceContents.Text(uri = "test://static-text", text = "Hello, text resource!", mimeType = Some("text/plain"))))) + + private val staticBinary = resource("test://static-binary") + .name("static-binary") + .mimeType("image/png") + .handle(() => Right(List(ResourceContents.Blob(uri = "test://static-binary", blob = pngData, mimeType = Some("image/png"))))) + + private val dataTemplate = resourceTemplate("test://template/{id}/data") + .name("data-template") + .mimeType("text/plain") + .handle((vars, uri) => + Right(List(ResourceContents.Text(uri = uri, text = s"data for ${vars.getOrElse("id", "?")}", mimeType = Some("text/plain")))) + ) + + // ── prompts ── + private val simplePrompt = prompt("test_simple_prompt") + .description("A simple prompt") + .handle(_ => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = "This is a simple prompt."))))) + + private val argsPrompt = prompt("test_prompt_with_arguments") + .description("A prompt with arguments") + .argument("arg1", required = true) + .argument("arg2", required = true) + .handle: args => + val text = s"arg1=${args.getOrElse("arg1", "")}, arg2=${args.getOrElse("arg2", "")}" + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = text)))) + + private val embeddedResourcePrompt = prompt("test_prompt_with_embedded_resource") + .description("A prompt embedding a resource") + .argument("resourceUri", required = true) + .handle: args => + val uri = args.getOrElse("resourceUri", "test://example-resource") + GetPromptResult(messages = + List( + PromptMessage( + Role.User, + ToolContent.ResourceContent(resource = ResourceContents.Text(uri = uri, text = "embedded resource content", mimeType = Some("text/plain"))) + ) + ) + ) + + private val imagePrompt = prompt("test_prompt_with_image") + .description("A prompt with an image") + .handle(_ => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Image(data = pngData, mimeType = "image/png"))))) + + private val server = McpServer[Identity](name = "chimp-conformance-server", version = "0.1.0") + .addTools(addNumbers, simpleText, errorTool, imageTool, audioTool, mixedTool, embeddedResourceTool, jsonSchemaTool) + .addResources(staticText, staticBinary) + .addResourceTemplate(dataTemplate) + .addPrompts(simplePrompt, argsPrompt, embeddedResourcePrompt, imagePrompt) + .withCompletion((_, _, _) => Completion(values = List("alpha", "beta"))) + .withLogging(_ => ()) + .withSubscriptions(ResourceSubscriptions[Identity](_ => (), _ => ())) def main(args: Array[String]): Unit = val requestedPort = args @@ -34,7 +172,7 @@ object Main: .orElse(sys.env.get("CHIMP_CONFORMANCE_PORT").map(_.toInt)) .getOrElse(0) - val endpoint = McpServer(name = "chimp-conformance-server", version = "0.1.0", tools = tools).endpoint(List("mcp")) + val endpoint = server.endpoint(List("mcp")) supervised: val binding = NettySyncServer().port(requestedPort).addEndpoint(endpoint).start() diff --git a/server/src/main/scala/chimp/server/McpEndpoint.scala b/server/src/main/scala/chimp/server/McpEndpoint.scala index fd652ef..110a454 100644 --- a/server/src/main/scala/chimp/server/McpEndpoint.scala +++ b/server/src/main/scala/chimp/server/McpEndpoint.scala @@ -6,7 +6,40 @@ import sttp.monad.syntax.* import sttp.tapir.* import sttp.tapir.json.circe.* import sttp.tapir.server.ServerEndpoint -import sttp.model.Header +import sttp.model.{Header, StatusCode} + +/** DNS-rebinding protection: validates the request's `Host` and `Origin` headers against an allow-list of host names. A present header whose + * host is not allowed is rejected with `403 Forbidden`; absent headers are accepted (non-browser clients may omit `Origin`). + * + * @param allowedHosts + * Allowed host names (without port; IPv6 addresses bracketed, e.g. `[::1]`). + * @param enabled + * When `false`, all requests pass (use behind a trusted proxy / TLS with authentication). + */ +case class OriginCheck(allowedHosts: Set[String], enabled: Boolean = true): + def validate(host: Option[String], origin: Option[String]): Boolean = + if !enabled then true + else host.forall(allowed) && origin.forall(allowed) + + private def allowed(headerValue: String): Boolean = allowedHosts.contains(OriginCheck.hostName(headerValue)) + +object OriginCheck: + val localhostHosts: Set[String] = Set("localhost", "127.0.0.1", "[::1]", "::1") + + /** Allows only localhost host names. The default for chimp servers. */ + val localhostOnly: OriginCheck = OriginCheck(localhostHosts) + + /** Disables the check entirely. */ + val disabled: OriginCheck = OriginCheck(Set.empty, enabled = false) + + private def hostName(headerValue: String): String = + val trimmed = headerValue.trim + val schemeIdx = trimmed.indexOf("://") + val authority = if schemeIdx >= 0 then trimmed.substring(schemeIdx + 3) else trimmed + if authority.startsWith("[") then + val close = authority.indexOf("]") + if close >= 0 then authority.substring(0, close + 1) else "" + else authority.takeWhile(_ != ':') private[server] def buildEndpoint[F[_]](server: McpServer[F], path: List[String]): ServerEndpoint[Any, F] = val mcpHandler = new McpHandler(server) @@ -22,8 +55,12 @@ private[server] def buildEndpoint[F[_]](server: McpServer[F], path: List[String] me => { (input: (Seq[Header], Json)) => val (headers, json) = input given MonadError[F] = me - mcpHandler - .handleJsonRpc(json, headers) - .map(response => Right((response.statusCode, response.body))) + val host = headers.find(_.name.equalsIgnoreCase("Host")).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase("Origin")).map(_.value) + if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, None))) + else + mcpHandler + .handleJsonRpc(json, headers) + .map(response => Right((response.statusCode, response.body))) } ) diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala index 7ed3a9c..0942749 100644 --- a/server/src/main/scala/chimp/server/McpServer.scala +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -23,6 +23,7 @@ case class McpServer[F[_]]( version: String = "1.0.0", instructions: Option[String] = None, showJsonSchemaMetadata: Boolean = true, + originCheck: OriginCheck = OriginCheck.localhostOnly, tools: List[ServerTool[?, F, ServerContext[F]]] = Nil, prompts: List[ServerPrompt[F]] = Nil, resources: List[ServerResource[F]] = Nil, @@ -35,6 +36,7 @@ case class McpServer[F[_]]( def version(value: String): McpServer[F] = copy(version = value) def instructions(value: String): McpServer[F] = copy(instructions = Some(value)) def withJsonSchemaMetadata(value: Boolean): McpServer[F] = copy(showJsonSchemaMetadata = value) + def withOriginCheck(value: OriginCheck): McpServer[F] = copy(originCheck = value) def addTool(t: ServerTool[?, F, ServerContext[F]]): McpServer[F] = copy(tools = tools :+ t) def addTools(ts: ServerTool[?, F, ServerContext[F]]*): McpServer[F] = copy(tools = tools ++ ts) diff --git a/server/src/test/scala/chimp/server/OriginCheckSpec.scala b/server/src/test/scala/chimp/server/OriginCheckSpec.scala new file mode 100644 index 0000000..70baf04 --- /dev/null +++ b/server/src/test/scala/chimp/server/OriginCheckSpec.scala @@ -0,0 +1,28 @@ +package chimp.server + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class OriginCheckSpec extends AnyFlatSpec with Matchers: + private val check = OriginCheck.localhostOnly + + "OriginCheck.localhostOnly" should "allow localhost Host and Origin" in: + check.validate(Some("localhost:8080"), Some("http://localhost:8080")) shouldBe true + + it should "allow 127.0.0.1 with a port" in: + check.validate(Some("127.0.0.1:8080"), None) shouldBe true + + it should "allow a bracketed IPv6 loopback Host and Origin" in: + check.validate(Some("[::1]:8080"), Some("http://[::1]:8080")) shouldBe true + + it should "allow requests with no Host or Origin" in: + check.validate(None, None) shouldBe true + + it should "reject a non-localhost Host" in: + check.validate(Some("evil.example.com"), None) shouldBe false + + it should "reject a non-localhost Origin even when Host is localhost" in: + check.validate(Some("localhost:8080"), Some("http://evil.example.com")) shouldBe false + + "A disabled OriginCheck" should "allow any host" in: + OriginCheck.disabled.validate(Some("evil.example.com"), Some("http://evil.example.com")) shouldBe true From 5987ecdaa138ff6466dc9628c6ff00bcf9b3cced Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 16 Jun 2026 12:59:56 +0200 Subject: [PATCH 04/20] feat: server streaming abstractions and first implementation for ZIO --- build.sbt | 24 +++- .../chimp/server/zio/ZioMcpStreaming.scala | 45 +++++++ .../zio/ZioStreamingMcpServerSpec.scala | 40 ++++++ .../scala/chimp/server/zio/ZioToFuture.scala | 19 +++ .../main/scala/chimp/server/McpEndpoint.scala | 58 ++++----- .../main/scala/chimp/server/McpHandler.scala | 115 +++++++++--------- .../main/scala/chimp/server/McpServer.scala | 78 +++++++++++- .../scala/chimp/server/McpStreaming.scala | 32 +++++ .../chimp/server/McpStreamingEndpoint.scala | 46 +++++++ .../scala/chimp/server/OutboundSink.scala | 9 ++ .../scala/chimp/server/ServerContext.scala | 32 ++++- .../scala/chimp/server/McpServerTests.scala | 74 +++++++++++ .../server/StreamingMcpServerTests.scala | 55 +++++++++ .../chimp/server/SyncHttpMcpServerSpec.scala | 29 +++++ .../scala/chimp/server/SyncToFuture.scala | 11 ++ .../test/scala/chimp/server/ToFuture.scala | 19 +++ 16 files changed, 589 insertions(+), 97 deletions(-) create mode 100644 server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpStreaming.scala create mode 100644 server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioStreamingMcpServerSpec.scala create mode 100644 server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioToFuture.scala create mode 100644 server/src/main/scala/chimp/server/McpStreaming.scala create mode 100644 server/src/main/scala/chimp/server/McpStreamingEndpoint.scala create mode 100644 server/src/main/scala/chimp/server/OutboundSink.scala create mode 100644 server/src/test/scala/chimp/server/McpServerTests.scala create mode 100644 server/src/test/scala/chimp/server/StreamingMcpServerTests.scala create mode 100644 server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala create mode 100644 server/src/test/scala/chimp/server/SyncToFuture.scala create mode 100644 server/src/test/scala/chimp/server/ToFuture.scala diff --git a/build.sbt b/build.sbt index 70cde3f..856ed0b 100644 --- a/build.sbt +++ b/build.sbt @@ -10,6 +10,7 @@ val tapirV = "1.13.19" val sttpClientV = "4.0.25" val zioV = "2.1.26" val zioProcessV = "0.8.0" +val zioHttpV = "3.8.0" val testcontainersScalaV = "0.41.8" lazy val verifyExamplesCompileUsingScalaCli = taskKey[Unit]("Verify that each example compiles using Scala CLI") @@ -35,7 +36,7 @@ val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test lazy val root = (project in file(".")) .settings(commonSettings: _*) .settings(publishArtifact := false, name := "chimp") - .aggregate(core, server, client, clientZio, examples, serverConformance, clientConformance) + .aggregate(core, server, serverZio, client, clientZio, examples, serverConformance, clientConformance) val conformance = inputKey[Unit]("Run the MCP conformance harness via npx, extra args are passed through") @@ -62,10 +63,27 @@ lazy val server: Project = (project in file("server")) "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirV, "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirV, "com.softwaremill.sttp.tapir" %% "tapir-apispec-docs" % tapirV, - "com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10" + "com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10", + "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV % Test, + "com.softwaremill.sttp.client4" %% "core" % sttpClientV % Test ) ) - .dependsOn(core) + .dependsOn(core, client % "test->compile") + +lazy val serverZio: Project = (project in file("server-streaming/server-zio")) + .settings(commonSettings: _*) + .settings( + name := "chimp-server-zio", + libraryDependencies ++= Seq( + scalaTest, + "dev.zio" %% "zio" % zioV, + "dev.zio" %% "zio-streams" % zioV, + "com.softwaremill.sttp.tapir" %% "tapir-zio" % tapirV, + "com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirV, + "dev.zio" %% "zio-http" % zioHttpV + ) + ) + .dependsOn(server % "compile->compile;test->test", clientZio % "test->compile") lazy val client: Project = (project in file("client")) .settings(commonSettings: _*) diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpStreaming.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpStreaming.scala new file mode 100644 index 0000000..64fca78 --- /dev/null +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpStreaming.scala @@ -0,0 +1,45 @@ +package chimp.server.zio + +import chimp.protocol.JSONRPCMessage +import chimp.server.{McpStreaming, OutboundSink} +import io.circe.Json +import io.circe.syntax.* +import sttp.capabilities.zio.ZioStreams +import sttp.model.sse.ServerSentEvent +import sttp.tapir.* +import sttp.tapir.ztapir.ZioServerSentEvents +import zio.stream.{Stream, ZStream} +import zio.{Queue, Task, ZIO} + +import java.nio.charset.StandardCharsets + +/** ZIO/zio-http implementation of [[McpStreaming]]. Each request's server→client messages are buffered through a `Queue` and drained as a + * chunked `text/event-stream` response, so events flush to the client as the tool logic produces them. + */ +object ZioMcpStreaming extends McpStreaming[Task, ZioStreams]: + val streams: ZioStreams = ZioStreams + type EventStream = Stream[Throwable, ServerSentEvent] + + val sseBody: StreamBodyIO[Stream[Throwable, Byte], EventStream, ZioStreams] = + streamTextBody(ZioStreams)(CodecFormat.TextEventStream(), Some(StandardCharsets.UTF_8)) + .map(ZioServerSentEvents.parseBytesToSSE)(ZioServerSentEvents.serialiseSSEToBytes) + + val emptyEvents: EventStream = ZStream.empty + + def eventStream(handle: OutboundSink[Task] => Task[Option[Json]]): Task[EventStream] = + ZIO.succeed { + ZStream.unwrap { + for + queue <- Queue.unbounded[Option[ServerSentEvent]] + sink = new OutboundSink[Task]: + def send(message: JSONRPCMessage): Task[Unit] = + queue.offer(Some(ServerSentEvent(data = Some(message.asJson.noSpaces)))).unit + _ <- handle(sink) + .flatMap { finalBody => + ZIO.foreachDiscard(finalBody)(json => queue.offer(Some(ServerSentEvent(data = Some(json.noSpaces))))) *> queue.offer(None) + } + .catchAllCause(_ => queue.offer(None).unit) + .forkDaemon + yield ZStream.fromQueue(queue).takeWhile(_.isDefined).collectSome + } + } diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioStreamingMcpServerSpec.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioStreamingMcpServerSpec.scala new file mode 100644 index 0000000..322d2e4 --- /dev/null +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioStreamingMcpServerSpec.scala @@ -0,0 +1,40 @@ +package chimp.server.zio + +import chimp.client.transport.Transport +import chimp.client.transport.zio.ZioStreamingHttpTransport +import chimp.client.{BidirectionalMcpClient, McpClient} +import chimp.protocol.{Implementation, ProtocolVersion} +import chimp.server.{McpServer, McpServerTests, StreamingMcpServer, StreamingMcpServerTests} +import org.scalatest.Assertion +import sttp.client4.* +import sttp.client4.httpclient.zio.HttpClientZioBackend +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.http.Server +import zio.{Scope, Task, ZIO} + +import scala.concurrent.Future + +/** Runs the generic server tests — including the streaming ones — against a zio-http server hosting chimp's SSE endpoint, driven by the chimp + * ZIO streaming client. + */ +class ZioStreamingMcpServerSpec extends McpServerTests[Task] with StreamingMcpServerTests[Task] with ZioToFuture: + private val clientInfo = Implementation("chimp-server-test", "0.0.1") + + override protected def withServer(server: McpServer[Task])(test: McpClient[Task] => Task[Assertion]): Future[Assertion] = + withStreamingServer(server.streaming)(test) + + override protected def withStreamingServer( + server: StreamingMcpServer[Task] + )(test: BidirectionalMcpClient[Task] => Task[Assertion]): Future[Assertion] = + toFuture: + val routes = ZioHttpInterpreter().toHttp(server.streamingEndpoint(List("mcp"), ZioMcpStreaming)) + ZIO.scoped: + (for + port <- Server.install(routes) + result <- HttpClientZioBackend().flatMap: backend => + ZioStreamingHttpTransport + .scoped(backend, uri"http://localhost:$port/mcp", ProtocolVersion.Latest, Transport.defaultTimeout) + .flatMap(transport => McpClient.bidirectional(transport, clientInfo)) + .flatMap(client => test(client)) + .ensuring(backend.close().ignore) + yield result).provideSome[Scope](Server.defaultWithPort(0)) diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioToFuture.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioToFuture.scala new file mode 100644 index 0000000..e7a3173 --- /dev/null +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioToFuture.scala @@ -0,0 +1,19 @@ +package chimp.server.zio + +import sttp.client4.impl.zio.RIOMonadAsyncError +import sttp.monad.MonadError +import zio.{Duration, Runtime, Task, Unsafe, ZIO} + +import scala.concurrent.Future + +trait ZioToFuture extends chimp.server.ToFuture[Task]: + override given monad: MonadError[Task] = new RIOMonadAsyncError[Any] + + private val runtime: Runtime[Any] = Runtime.default + + override def toFuture[A](fa: Task[A]): Future[A] = + Unsafe.unsafe { implicit u => + runtime.unsafe.runToFuture(fa) + } + + override def sleep(millis: Long): Task[Unit] = ZIO.sleep(Duration.fromMillis(millis)) diff --git a/server/src/main/scala/chimp/server/McpEndpoint.scala b/server/src/main/scala/chimp/server/McpEndpoint.scala index 110a454..0b79107 100644 --- a/server/src/main/scala/chimp/server/McpEndpoint.scala +++ b/server/src/main/scala/chimp/server/McpEndpoint.scala @@ -1,15 +1,38 @@ package chimp.server import io.circe.Json +import sttp.model.{Header, HeaderNames, StatusCode} import sttp.monad.MonadError import sttp.monad.syntax.* import sttp.tapir.* import sttp.tapir.json.circe.* import sttp.tapir.server.ServerEndpoint -import sttp.model.{Header, StatusCode} -/** DNS-rebinding protection: validates the request's `Host` and `Origin` headers against an allow-list of host names. A present header whose - * host is not allowed is rejected with `403 Forbidden`; absent headers are accepted (non-browser clients may omit `Origin`). +private[server] def buildEndpoint[F[_]](server: McpServer[F], path: List[String]): ServerEndpoint[Any, F] = + val mcpHandler = new McpHandler(server) + val endpoint = infallibleEndpoint.post + .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) + .in(extractFromRequest(_.headers)) + .in(jsonBody[Json]) + .out(statusCode) + .out(jsonBody[Option[Json]]) + + ServerEndpoint.public( + endpoint, + me => { (input: (Seq[Header], Json)) => + val (headers, json) = input + given MonadError[F] = me + val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) + if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, None))) + else + mcpHandler + .handleJsonRpc(json, headers) + .map(response => Right((response.statusCode, response.body))) + } + ) + +/** DNS-rebinding protection: validates the request's `Host` and `Origin` headers against an allow-list of host names. * * @param allowedHosts * Allowed host names (without port; IPv6 addresses bracketed, e.g. `[::1]`). @@ -24,12 +47,9 @@ case class OriginCheck(allowedHosts: Set[String], enabled: Boolean = true): private def allowed(headerValue: String): Boolean = allowedHosts.contains(OriginCheck.hostName(headerValue)) object OriginCheck: - val localhostHosts: Set[String] = Set("localhost", "127.0.0.1", "[::1]", "::1") + private val localhostHosts: Set[String] = Set("localhost", "127.0.0.1", "[::1]", "::1") - /** Allows only localhost host names. The default for chimp servers. */ val localhostOnly: OriginCheck = OriginCheck(localhostHosts) - - /** Disables the check entirely. */ val disabled: OriginCheck = OriginCheck(Set.empty, enabled = false) private def hostName(headerValue: String): String = @@ -40,27 +60,3 @@ object OriginCheck: val close = authority.indexOf("]") if close >= 0 then authority.substring(0, close + 1) else "" else authority.takeWhile(_ != ':') - -private[server] def buildEndpoint[F[_]](server: McpServer[F], path: List[String]): ServerEndpoint[Any, F] = - val mcpHandler = new McpHandler(server) - val e = infallibleEndpoint.post - .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) - .in(extractFromRequest(_.headers)) - .in(jsonBody[Json]) - .out(statusCode) - .out(jsonBody[Option[Json]]) - - ServerEndpoint.public( - e, - me => { (input: (Seq[Header], Json)) => - val (headers, json) = input - given MonadError[F] = me - val host = headers.find(_.name.equalsIgnoreCase("Host")).map(_.value) - val origin = headers.find(_.name.equalsIgnoreCase("Origin")).map(_.value) - if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, None))) - else - mcpHandler - .handleJsonRpc(json, headers) - .map(response => Right((response.statusCode, response.body))) - } - ) diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index c7e0f17..f1878b8 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -10,12 +10,8 @@ import sttp.monad.MonadError import sttp.monad.syntax.* import sttp.tapir.docs.apispec.schema.TapirSchemaToJsonSchema -/** Represents different types of HTTP responses for JSON-RPC requests */ enum McpResponse: - /** Response with JSON body (for requests and errors) */ case JsonResponse(json: Json) - - /** Response with no body (for notifications) */ case EmptyAcceptResponse def statusCode: StatusCode = this match @@ -30,15 +26,15 @@ enum McpResponse: case JsonResponse(json) => JsonResponse(json.deepDropNullValues) case EmptyAcceptResponse => this -/** Handles MCP JSON-RPC requests for a single [[McpServer]] definition: lifecycle, tools, resources, prompts, completion, and logging. */ -class McpHandler[F[_]](server: McpServer[F]): - private val logger = LoggerFactory.getLogger(classOf[McpHandler[_]]) +class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): + private val logger = LoggerFactory.getLogger(classOf[McpHandler[?, ?]]) private val toolsByName = server.tools.map(t => t.name -> t).toMap private val promptsByName = server.prompts.map(p => p.definition.name -> p).toMap private val resourcesByUri = server.resources.map(r => r.definition.uri -> r).toMap private val hasResources = server.resources.nonEmpty || server.resourceTemplates.nonEmpty + private val toolDefinitions = server.tools.map(toolToDefinition) - private def toolToDefinition(tool: ServerTool[?, F, ServerContext[F]]): ToolDefinition = + private def toolToDefinition(tool: ServerTool[?, F, C]): ToolDefinition = val jsonSchema = tool.inputSchema match case ToolSchema.Derived(schema) => val base = TapirSchemaToJsonSchema(schema, markOptionsAsNullable = false) @@ -52,8 +48,6 @@ class McpHandler[F[_]](server: McpServer[F]): .map(a => ToolAnnotations(a.title, a.readOnlyHint, a.destructiveHint, a.idempotentHint, a.openWorldHint)) ) - private val toolDefs: List[ToolDefinition] = server.tools.map(toolToDefinition) - 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)) @@ -88,43 +82,63 @@ class McpHandler[F[_]](server: McpServer[F]): ) JSONRPCMessage.Response(id = id, result = result.asJson) - private def handleToolsCall(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] = - val toolNameOpt = params.flatMap(_.hcursor.downField("name").as[String].toOption) + private def handleToolsCall(params: Option[Json], id: RequestId, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using + MonadError[F] + ): F[JSONRPCMessage] = + val name = params.flatMap(_.hcursor.downField("name").as[String].toOption) val args = params.flatMap(_.hcursor.downField("arguments").focus).getOrElse(Json.obj()) - toolNameOpt match - case Some(toolName) => - toolsByName.get(toolName) match + val progressToken = params.flatMap(_.hcursor.downField("_meta").downField("progressToken").as[ProgressToken].toOption) + name match + case Some(name) => + toolsByName.get(name) match case Some(tool) => - def inputSnippet = args.noSpaces.take(200) tool.inputDecoder.decodeJson(args) match - case Right(decodedInput) => handleDecodedInput(tool, decodedInput, id, headers) + case Right(input) => + val context = makeContext(progressToken) + tool + .logic(input, context, headers) + .map: result => + JSONRPCMessage.Response( + id = id, + result = CallToolResult( + content = result.content, + structuredContent = result.structuredContent, + isError = result.isError + ).asJson + ) case Left(decodingError) => + val snippet = args.noSpaces.take(200) protocolError( id, JSONRPCErrorCodes.InvalidParams.code, - s"Invalid arguments: ${decodingError.getMessage}. Input: $inputSnippet" + s"Invalid arguments: ${decodingError.getMessage}. Input: $snippet" ).unit - case None => protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown tool: $toolName").unit + case None => protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown tool: $name").unit case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Missing tool name").unit - private def handleDecodedInput[T](tool: ServerTool[T, F, ServerContext[F]], decodedInput: T, id: RequestId, headers: Seq[Header])(using - MonadError[F] - ): F[JSONRPCMessage] = - tool - .logic(decodedInput, ServerContext.noOp[F], headers) - .map: result => - val callResult = CallToolResult( - content = result.content, - structuredContent = result.structuredContent, - isError = result.isError - ) - JSONRPCMessage.Response(id = id, result = callResult.asJson) + private def handleResourcesRead(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = + decodeParams[ReadResourceParams](params, id): params => + resourcesByUri.get(params.uri) match + case Some(resource) => resource.read().map(readResponse(id, params.uri)) + case None => + val templateMatch = server.resourceTemplates.iterator + .map(template => (template, template.matcher.matchUri(params.uri))) + .collectFirst { case (template, Some(vars)) => (template, vars) } + templateMatch match + case Some((template, vars)) => template.read(vars, params.uri).map(readResponse(id, params.uri)) + case None => + protocolError( + id, + JSONRPCErrorCodes.InvalidParams.code, + s"Resource not found: ${params.uri}", + Some(Json.obj("uri" -> Json.fromString(params.uri))) + ).unit private def readResponse(id: RequestId, uri: String)(result: Either[ResourceError, List[ResourceContents]]): JSONRPCMessage = result match case Right(contents) => JSONRPCMessage.Response(id = id, result = ReadResourceResult(contents).asJson) - case Left(error) => + case Left(error) => protocolError( id, JSONRPCErrorCodes.InvalidParams.code, @@ -132,24 +146,6 @@ class McpHandler[F[_]](server: McpServer[F]): error.uri.orElse(Some(uri)).map(u => Json.obj("uri" -> Json.fromString(u))) ) - private def handleResourcesRead(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = - decodeParams[ReadResourceParams](params, id): p => - resourcesByUri.get(p.uri) match - case Some(resource) => resource.read().map(readResponse(id, p.uri)) - case None => - val templateMatch = server.resourceTemplates.iterator - .map(t => (t, t.matcher.matchUri(p.uri))) - .collectFirst { case (t, Some(vars)) => (t, vars) } - templateMatch match - case Some((template, vars)) => template.read(vars, p.uri).map(readResponse(id, p.uri)) - case None => - protocolError( - id, - JSONRPCErrorCodes.InvalidParams.code, - s"Resource not found: ${p.uri}", - Some(Json.obj("uri" -> Json.fromString(p.uri))) - ).unit - private def handleSubscribe(params: Option[Json], id: RequestId, subscribe: Boolean)(using MonadError[F]): F[JSONRPCMessage] = val subs = server.subscriptions.get if subscribe then decodeParams[SubscribeParams](params, id)(p => subs.onSubscribe(p).map(_ => emptyResult(id))) @@ -171,21 +167,23 @@ class McpHandler[F[_]](server: McpServer[F]): val handler = server.setLevel.get decodeParams[SetLevelParams](params, id)(p => handler(p.level).map(_ => emptyResult(id))) - private def doHandleJsonRpc(request: Json, headers: Seq[Header])(using MonadError[F]): F[McpResponse] = + private def doHandleJsonRpc(request: Json, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using + MonadError[F] + ): F[McpResponse] = request.as[JSONRPCMessage] match case Left(err) => val errorResponse = protocolError(RequestId("null"), JSONRPCErrorCodes.ParseError.code, s"Parse error: ${err.message}") jsonResponse(errorResponse).unit case Right(JSONRPCMessage.Request(_, method, params: Option[Json], id)) => method match - case "tools/list" => - jsonResponse(JSONRPCMessage.Response(id = id, result = ListToolsResponse(toolDefs).asJson)).unit - case "tools/call" => - handleToolsCall(params, id, headers).map(jsonResponse) 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 => @@ -215,7 +213,10 @@ class McpHandler[F[_]](server: McpServer[F]): jsonResponse(protocolError(RequestId("null"), JSONRPCErrorCodes.InvalidRequest.code, "Invalid request type")).unit end doHandleJsonRpc - def handleJsonRpc(request: Json, headers: Seq[Header])(using MonadError[F]): F[McpResponse] = - doHandleJsonRpc(request, headers).map: response => + def handleJsonRpc(request: Json, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using MonadError[F]): F[McpResponse] = + doHandleJsonRpc(request, headers, makeContext).map: response => logger.debug(s"Request: $request, response: ${response.statusCode}, body: ${response.body}") response.withNullsDroppedDeep + + def handleJsonRpc(request: Json, headers: Seq[Header])(using m: MonadError[F], ev: ServerContext[F] <:< C): F[McpResponse] = + handleJsonRpc(request, headers, _ => ev(ServerContext.noop[F])) diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala index 0942749..c1fb83b 100644 --- a/server/src/main/scala/chimp/server/McpServer.scala +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -15,8 +15,25 @@ case class ResourceSubscriptions[F[_]]( onUnsubscribe: UnsubscribeParams => F[Unit] ) -/** An MCP server definition: the features it exposes and the metadata it reports. Build one up with the `add*`/`with*` methods, then turn - * it into a Tapir endpoint with [[endpoint]]. Server capabilities are derived from which features are registered. +/** The features and metadata of an MCP server, shared by the request/response [[McpServer]] (tools needing only a base [[ServerContext]]) and + * the [[StreamingMcpServer]] (tools needing a [[StreamingServerContext]]). `C` is the context the registered tools require. + */ +sealed trait McpServerDef[F[_], C <: ServerContext[F]]: + def name: String + def version: String + def instructions: Option[String] + def showJsonSchemaMetadata: Boolean + def originCheck: OriginCheck + def tools: List[ServerTool[?, F, C]] + def prompts: List[ServerPrompt[F]] + def resources: List[ServerResource[F]] + def resourceTemplates: List[ServerResourceTemplate[F]] + def completion: Option[CompletionHandler[F]] + def setLevel: Option[SetLevelHandler[F]] + def subscriptions: Option[ResourceSubscriptions[F]] + +/** An MCP server definition: the features it exposes and the metadata it reports. Build one up with the `add*`/`with*` methods, then turn it + * into a Tapir endpoint with [[endpoint]]. Server capabilities are derived from which features are registered. */ case class McpServer[F[_]]( name: String = "Chimp MCP server", @@ -31,7 +48,7 @@ case class McpServer[F[_]]( completion: Option[CompletionHandler[F]] = None, setLevel: Option[SetLevelHandler[F]] = None, subscriptions: Option[ResourceSubscriptions[F]] = None -): +) extends McpServerDef[F, ServerContext[F]]: def name(value: String): McpServer[F] = copy(name = value) def version(value: String): McpServer[F] = copy(version = value) def instructions(value: String): McpServer[F] = copy(instructions = Some(value)) @@ -50,5 +67,58 @@ case class McpServer[F[_]]( def withLogging(handler: SetLevelHandler[F]): McpServer[F] = copy(setLevel = Some(handler)) def withSubscriptions(handler: ResourceSubscriptions[F]): McpServer[F] = copy(subscriptions = Some(handler)) - /** Build the Tapir endpoint serving this MCP server at the given path. */ + /** Build the request/response Tapir endpoint serving this MCP server at the given path. */ def endpoint(path: List[String]): ServerEndpoint[Any, F] = buildEndpoint(this, path) + + /** Promote this server to a [[StreamingMcpServer]], on which streaming-only tools can additionally be registered and which serves SSE + * responses. The already-registered tools carry over (a base-context tool runs unchanged on the streaming endpoint). + */ + def streaming: StreamingMcpServer[F] = + StreamingMcpServer(name, version, instructions, showJsonSchemaMetadata, originCheck, tools, prompts, resources, resourceTemplates, + completion, setLevel, subscriptions) + +/** An MCP server that serves Server-Sent-Event responses and accepts streaming-only tools (those using a [[StreamingServerContext]] to + * report progress, log, or — later — sample/elicit). Materialized into a Tapir endpoint via [[streamingEndpoint]] using a per-effect + * [[McpStreaming]] implementation. + */ +case class StreamingMcpServer[F[_]]( + name: String = "Chimp MCP server", + version: String = "1.0.0", + instructions: Option[String] = None, + showJsonSchemaMetadata: Boolean = true, + originCheck: OriginCheck = OriginCheck.localhostOnly, + tools: List[ServerTool[?, F, StreamingServerContext[F]]] = Nil, + prompts: List[ServerPrompt[F]] = Nil, + resources: List[ServerResource[F]] = Nil, + resourceTemplates: List[ServerResourceTemplate[F]] = Nil, + completion: Option[CompletionHandler[F]] = None, + setLevel: Option[SetLevelHandler[F]] = None, + subscriptions: Option[ResourceSubscriptions[F]] = None +) extends McpServerDef[F, StreamingServerContext[F]]: + def name(value: String): StreamingMcpServer[F] = copy(name = value) + def version(value: String): StreamingMcpServer[F] = copy(version = value) + def instructions(value: String): StreamingMcpServer[F] = copy(instructions = Some(value)) + def withJsonSchemaMetadata(value: Boolean): StreamingMcpServer[F] = copy(showJsonSchemaMetadata = value) + def withOriginCheck(value: OriginCheck): StreamingMcpServer[F] = copy(originCheck = value) + + /** Add a base-context tool (it runs unchanged, without using streaming features). */ + def addTool(t: ServerTool[?, F, ServerContext[F]]): StreamingMcpServer[F] = copy(tools = tools :+ t) + def addTools(ts: ServerTool[?, F, ServerContext[F]]*): StreamingMcpServer[F] = copy(tools = tools ++ ts) + + /** Add a streaming-only tool (one whose logic uses the [[StreamingServerContext]]). */ + def addStreamingTool(t: ServerTool[?, F, StreamingServerContext[F]]): StreamingMcpServer[F] = copy(tools = tools :+ t) + def addStreamingTools(ts: ServerTool[?, F, StreamingServerContext[F]]*): StreamingMcpServer[F] = copy(tools = tools ++ ts) + + def addPrompt(p: ServerPrompt[F]): StreamingMcpServer[F] = copy(prompts = prompts :+ p) + def addPrompts(ps: ServerPrompt[F]*): StreamingMcpServer[F] = copy(prompts = prompts ++ ps) + def addResource(r: ServerResource[F]): StreamingMcpServer[F] = copy(resources = resources :+ r) + def addResources(rs: ServerResource[F]*): StreamingMcpServer[F] = copy(resources = resources ++ rs) + def addResourceTemplate(rt: ServerResourceTemplate[F]): StreamingMcpServer[F] = copy(resourceTemplates = resourceTemplates :+ rt) + def addResourceTemplates(rts: ServerResourceTemplate[F]*): StreamingMcpServer[F] = copy(resourceTemplates = resourceTemplates ++ rts) + def withCompletion(handler: CompletionHandler[F]): StreamingMcpServer[F] = copy(completion = Some(handler)) + def withLogging(handler: SetLevelHandler[F]): StreamingMcpServer[F] = copy(setLevel = Some(handler)) + def withSubscriptions(handler: ResourceSubscriptions[F]): StreamingMcpServer[F] = copy(subscriptions = Some(handler)) + + /** Build the SSE-capable Tapir endpoint serving this MCP server at the given path, using the given per-effect streaming implementation. */ + def streamingEndpoint[S](path: List[String], streaming: McpStreaming[F, S]): ServerEndpoint[S, F] = + buildStreamingEndpoint(this, streaming, path) diff --git a/server/src/main/scala/chimp/server/McpStreaming.scala b/server/src/main/scala/chimp/server/McpStreaming.scala new file mode 100644 index 0000000..ebce0cb --- /dev/null +++ b/server/src/main/scala/chimp/server/McpStreaming.scala @@ -0,0 +1,32 @@ +package chimp.server + +import io.circe.Json +import sttp.capabilities.Streams +import sttp.tapir.StreamBodyIO + +/** The per-effect primitive needed to serve MCP over Server-Sent Events. A concrete implementation supplies the Tapir SSE response body and + * a way to turn a request handler — which may emit server→client messages through an [[OutboundSink]] while running — into the effect's + * native stream of [[ServerSentEvent]]s. Concrete implementations live in effect-specific modules (e.g. `chimp-server-zio`). + * + * @tparam F + * the effect type + * @tparam S + * the Tapir/sttp streaming capability (e.g. `ZioStreams`) + */ +abstract class McpStreaming[F[_], S]: + val streams: Streams[S] + + /** The effect's native stream of Server-Sent Events (e.g. `zio.stream.Stream[Throwable, ServerSentEvent]`). */ + type EventStream + + /** The Tapir output body describing an SSE (`text/event-stream`) response carrying [[EventStream]]. */ + def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] + + /** An empty event stream, used for non-2xx responses (e.g. a rejected request). */ + def emptyEvents: EventStream + + /** Run `handle` — which receives an [[OutboundSink]] to push notifications onto while it works and returns the final JSON-RPC response + * body (or `None` for a notification) — and produce the SSE event stream: each pushed message becomes an event, followed by the final + * response event, after which the stream completes. + */ + def eventStream(handle: OutboundSink[F] => F[Option[Json]]): F[EventStream] diff --git a/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala b/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala new file mode 100644 index 0000000..5f54581 --- /dev/null +++ b/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala @@ -0,0 +1,46 @@ +package chimp.server + +import chimp.protocol.ProgressToken +import io.circe.Json +import sttp.monad.MonadError +import sttp.monad.syntax.* +import sttp.tapir.* +import sttp.tapir.json.circe.* +import sttp.tapir.server.ServerEndpoint +import sttp.model.{Header, StatusCode} + +/** Builds the SSE-capable endpoint: a POST that responds with `text/event-stream`. Progress and log notifications emitted by the tool logic + * during the call are streamed as events, followed by the final JSON-RPC response event. A request rejected by the [[OriginCheck]] gets a + * `403` with an empty stream. + */ +private[server] def buildStreamingEndpoint[F[_], S]( + server: StreamingMcpServer[F], + streaming: McpStreaming[F, S], + path: List[String] +): ServerEndpoint[S, F] = + val handler = new McpHandler[F, StreamingServerContext[F]](server) + val e = infallibleEndpoint.post + .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) + .in(extractFromRequest(_.headers)) + .in(jsonBody[Json]) + .out(statusCode) + .out(streaming.sseBody) + + ServerEndpoint.public( + e, + me => { (input: (Seq[Header], Json)) => + val (headers, json) = input + given MonadError[F] = me + val host = headers.find(_.name.equalsIgnoreCase("Host")).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase("Origin")).map(_.value) + if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, streaming.emptyEvents))) + else + streaming + .eventStream { sink => + val makeContext: Option[ProgressToken] => StreamingServerContext[F] = + token => SinkStreamingServerContext(sink, token) + handler.handleJsonRpc(json, headers, makeContext).map(_.body) + } + .map(events => Right((StatusCode.Ok, events))) + } + ) diff --git a/server/src/main/scala/chimp/server/OutboundSink.scala b/server/src/main/scala/chimp/server/OutboundSink.scala new file mode 100644 index 0000000..1d43af4 --- /dev/null +++ b/server/src/main/scala/chimp/server/OutboundSink.scala @@ -0,0 +1,9 @@ +package chimp.server + +import chimp.protocol.JSONRPCMessage + +/** A write-end for server→client messages emitted while a request is being handled on a streaming endpoint (notifications, and — later — + * server-initiated requests). The concrete realization is provided by a per-effect [[McpStreaming]] implementation. + */ +trait OutboundSink[F[_]]: + def send(message: JSONRPCMessage): F[Unit] diff --git a/server/src/main/scala/chimp/server/ServerContext.scala b/server/src/main/scala/chimp/server/ServerContext.scala index 19c4ed4..521896e 100644 --- a/server/src/main/scala/chimp/server/ServerContext.scala +++ b/server/src/main/scala/chimp/server/ServerContext.scala @@ -1,7 +1,8 @@ package chimp.server -import chimp.protocol.{CreateMessageParams, CreateMessageResult, ElicitParams, ElicitResult, LoggingLevel} +import chimp.protocol.* import io.circe.Json +import io.circe.syntax.* import sttp.monad.MonadError trait ServerContext[F[_]]: @@ -9,7 +10,7 @@ trait ServerContext[F[_]]: def onCancel(action: F[Unit]): F[Unit] object ServerContext: - def noOp[F[_]](using m: MonadError[F]): ServerContext[F] = new ServerContext[F]: + def noop[F[_]](using m: MonadError[F]): ServerContext[F] = new ServerContext[F]: def isCancelled: F[Boolean] = m.unit(false) def onCancel(action: F[Unit]): F[Unit] = m.unit(()) @@ -22,3 +23,30 @@ trait StreamingServerContext[F[_]] extends ServerContext[F]: def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] def sample(params: CreateMessageParams): F[CreateMessageResult] def elicit(params: ElicitParams): F[ElicitResult] + +/** A [[StreamingServerContext]] backed by an [[OutboundSink]]: progress and log calls are emitted as JSON-RPC notifications on the request's + * SSE stream. `progressToken` is the token carried by the originating request's `_meta`, if any. Sampling and elicitation are not yet + * supported (Phase 3). + */ +private[server] final class SinkStreamingServerContext[F[_]](sink: OutboundSink[F], progressToken: Option[ProgressToken])(using + m: MonadError[F] +) extends StreamingServerContext[F]: + def isCancelled: F[Boolean] = m.unit(false) + def onCancel(action: F[Unit]): F[Unit] = m.unit(()) + + def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] = + progressToken match + case Some(token) => + sink.send( + JSONRPCMessage.Notification(method = "notifications/progress", params = Some(ProgressParams(token, progress, total, message).asJson)) + ) + case None => m.unit(()) + + def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] = + sink.send(JSONRPCMessage.Notification(method = "notifications/message", params = Some(LoggingMessageParams(level, data, logger).asJson))) + + def sample(params: CreateMessageParams): F[CreateMessageResult] = + m.error(UnsupportedOperationException("server→client sampling is not yet supported")) + + def elicit(params: ElicitParams): F[ElicitResult] = + m.error(UnsupportedOperationException("server→client elicitation is not yet supported")) diff --git a/server/src/test/scala/chimp/server/McpServerTests.scala b/server/src/test/scala/chimp/server/McpServerTests.scala new file mode 100644 index 0000000..a7b1c4f --- /dev/null +++ b/server/src/test/scala/chimp/server/McpServerTests.scala @@ -0,0 +1,74 @@ +package chimp.server + +import chimp.client.McpClient +import chimp.protocol.* +import io.circe.{Codec, Json} +import org.scalatest.Assertion +import org.scalatest.flatspec.AsyncFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.monad.syntax.* +import sttp.tapir.Schema + +import scala.concurrent.Future + +/** Backend-agnostic tests exercising an MCP server over a real transport, driven by the chimp client. A concrete spec provides [[withServer]] + * (host the server on some Tapir backend, connect a client) and a [[ToFuture]] for the effect. These cover the request/response surface and + * run against any backend. + */ +trait McpServerTests[F[_]] extends AsyncFlatSpec with Matchers: + this: ToFuture[F] => + + protected def withServer(server: McpServer[F])(test: McpClient[F] => F[Assertion]): Future[Assertion] + + private case class EchoInput(message: String) derives Codec, Schema + + protected def sampleServer: McpServer[F] = + McpServer[F]() + .addTool( + tool("echo") + .description("Echoes a message") + .input[EchoInput] + .serverLogic[F]((in, _, _) => monad.unit(ToolResult.text(in.message))) + ) + .addResource( + resource("test://greeting") + .mimeType("text/plain") + .read[F](() => monad.unit(Right(List(ResourceContents.Text(uri = "test://greeting", text = "hello", mimeType = Some("text/plain")))))) + ) + .addPrompt( + prompt("greet") + .argument("name", required = true) + .get[F](args => monad.unit(GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hi ${args.getOrElse("name", "?")}")))))) + ) + .withCompletion((_, _, _) => monad.unit(Completion(values = List("alpha", "beta")))) + + "an MCP server" should "list its tools" in withServer(sampleServer): client => + client.listTools().map(_.tools.map(_.name) should contain("echo")) + + it should "advertise capabilities for the registered features" in withServer(sampleServer): client => + monad.unit: + client.serverCapabilities.tools shouldBe defined + client.serverCapabilities.resources shouldBe defined + client.serverCapabilities.prompts shouldBe defined + client.serverCapabilities.completions shouldBe defined + + it should "execute a tool call" in withServer(sampleServer): client => + client.callTool("echo", Json.obj("message" -> Json.fromString("hi"))).map: result => + result.isError shouldBe false + result.content shouldBe List(ToolContent.Text("text", "hi")) + + it should "read a resource" in withServer(sampleServer): client => + client.readResource("test://greeting").map: result => + result.contents.head match + case ResourceContents.Text(_, text, _, _) => text shouldBe "hello" + case other => fail(s"expected text contents, got $other") + + it should "get a prompt with arguments" in withServer(sampleServer): client => + client.getPrompt("greet", Map("name" -> "World")).map: result => + result.messages.head.content match + case ToolContent.Text(_, text) => text should include("World") + case other => fail(s"expected text content, got $other") + + it should "return completion suggestions" in withServer(sampleServer): client => + client.complete(CompleteRef.Prompt(PromptReference(name = "greet")), CompleteArgument("name", "W")).map: result => + result.completion.values shouldBe List("alpha", "beta") diff --git a/server/src/test/scala/chimp/server/StreamingMcpServerTests.scala b/server/src/test/scala/chimp/server/StreamingMcpServerTests.scala new file mode 100644 index 0000000..83e23c5 --- /dev/null +++ b/server/src/test/scala/chimp/server/StreamingMcpServerTests.scala @@ -0,0 +1,55 @@ +package chimp.server + +import chimp.client.BidirectionalMcpClient +import chimp.client.notifications.ServerNotification +import chimp.protocol.* +import io.circe.{Codec, Json} +import org.scalatest.Assertion +import org.scalatest.flatspec.AsyncFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.monad.syntax.* +import sttp.tapir.Schema + +import java.util.concurrent.ConcurrentLinkedQueue +import scala.concurrent.Future +import scala.jdk.CollectionConverters.* + +/** Tests for server→client streaming behavior: notifications emitted by tool logic mid-call must reach the client over the open SSE stream + * before the call's final result. Run only by streaming-capable backends. + */ +trait StreamingMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers: + this: ToFuture[F] => + + protected def withStreamingServer(server: StreamingMcpServer[F])(test: BidirectionalMcpClient[F] => F[Assertion]): Future[Assertion] + + private case class NoInput() derives Codec, Schema + + protected def streamingServer: StreamingMcpServer[F] = + StreamingMcpServer[F]() + .withLogging(_ => monad.unit(())) + .addStreamingTool( + tool("noisy") + .description("Logs several messages, then returns") + .input[NoInput] + .streamingServerLogic[F]: (_, ctx, _) => + ctx + .log(LoggingLevel.Info, Json.fromString("one")) + .flatMap(_ => ctx.log(LoggingLevel.Info, Json.fromString("two"))) + .flatMap(_ => ctx.log(LoggingLevel.Info, Json.fromString("three"))) + .map(_ => ToolResult.text("done")) + ) + + "a streaming MCP server" should "deliver log notifications emitted during a tool call" in + withStreamingServer(streamingServer): client => + val messages = ConcurrentLinkedQueue[Json]() + val listener: ServerNotification => F[Unit] = { + case ServerNotification.LoggingMessage(params) => messages.add(params.data); monad.unit(()) + case _ => monad.unit(()) + } + client + .onServerNotification(n => listener(n)) + .flatMap(_ => client.callTool("noisy", Json.obj())) + .flatMap: result => + waitUntil(messages.size >= 3).map: _ => + result.content shouldBe List(ToolContent.Text("text", "done")) + messages.asScala.toList shouldBe List(Json.fromString("one"), Json.fromString("two"), Json.fromString("three")) diff --git a/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala b/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala new file mode 100644 index 0000000..191e6a7 --- /dev/null +++ b/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala @@ -0,0 +1,29 @@ +package chimp.server + +import chimp.client.McpClient +import chimp.client.transport.HttpTransport +import chimp.protocol.Implementation +import org.scalatest.Assertion +import ox.supervised +import sttp.client4.* +import sttp.shared.Identity +import sttp.tapir.server.netty.sync.NettySyncServer + +import scala.concurrent.Future + +/** Runs the generic server tests against a [[NettySyncServer]] (Identity effect), driven by the chimp synchronous HTTP client. */ +class SyncHttpMcpServerSpec extends McpServerTests[Identity] with SyncToFuture: + private val clientInfo = Implementation("chimp-server-test", "0.0.1") + + override protected def withServer(server: McpServer[Identity])(test: McpClient[Identity] => Identity[Assertion]): Future[Assertion] = + toFuture: + supervised: + val binding = NettySyncServer().port(0).addEndpoint(server.endpoint(List("mcp"))).start() + try + val backend = DefaultSyncBackend() + try + val transport = HttpTransport[Identity](backend, uri"http://localhost:${binding.port}/mcp") + try test(McpClient(transport, clientInfo)) + finally transport.close() + finally backend.close() + finally binding.stop() diff --git a/server/src/test/scala/chimp/server/SyncToFuture.scala b/server/src/test/scala/chimp/server/SyncToFuture.scala new file mode 100644 index 0000000..7f09677 --- /dev/null +++ b/server/src/test/scala/chimp/server/SyncToFuture.scala @@ -0,0 +1,11 @@ +package chimp.server + +import sttp.monad.{IdentityMonad, MonadError} +import sttp.shared.Identity + +import scala.concurrent.Future + +trait SyncToFuture extends ToFuture[Identity]: + override given monad: MonadError[Identity] = IdentityMonad + override def toFuture[A](fa: Identity[A]): Future[A] = Future.successful(fa) + override def sleep(millis: Long): Identity[Unit] = Thread.sleep(millis) diff --git a/server/src/test/scala/chimp/server/ToFuture.scala b/server/src/test/scala/chimp/server/ToFuture.scala new file mode 100644 index 0000000..d9c17b8 --- /dev/null +++ b/server/src/test/scala/chimp/server/ToFuture.scala @@ -0,0 +1,19 @@ +package chimp.server + +import sttp.monad.MonadError +import sttp.monad.syntax.* + +import scala.concurrent.Future + +/** Bridges an effect `F` to `scala.concurrent.Future` so the same effect-polymorphic test bodies can run under `AsyncFlatSpec` for any + * effect (e.g. `Identity`, `Task`). Mirrors the client test suite's `ToFuture`. + */ +trait ToFuture[F[_]]: + given monad: MonadError[F] + def toFuture[A](fa: F[A]): Future[A] + def sleep(millis: Long): F[Unit] + + /** Re-checks `condition` (which is expected to flip via a side effect) until it holds or attempts run out. */ + def waitUntil(condition: => Boolean, attempts: Int = 100, intervalMs: Long = 20): F[Unit] = + if condition || attempts <= 0 then monad.unit(()) + else sleep(intervalMs).flatMap(_ => waitUntil(condition, attempts - 1, intervalMs)) From 465d9d05b656eee280233d1c340f101fdaa90bea Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 16 Jun 2026 13:35:06 +0200 Subject: [PATCH 05/20] refactor: general refactoring and renames --- ...=> ZioMcpClientStdioIntegrationSpec.scala} | 4 +- ...pClientStreamingHttpIntegrationSpec.scala} | 6 +- ... => McpClientBidirectionalHttpTests.scala} | 4 +- ...cala => McpClientBidirectionalTests.scala} | 2 +- ...ala => McpClientHttpIntegrationSpec.scala} | 4 +- ...la => McpClientStdioIntegrationSpec.scala} | 4 +- ...pClientStreamingHttpIntegrationSpec.scala} | 12 +- ...ner.scala => McpEverythingContainer.scala} | 2 +- ...iner.scala => McpToxiproxyContainer.scala} | 2 +- .../integration/SyncHttpIntegrationSpec.scala | 18 -- .../SyncStdioIntegrationSpec.scala | 15 -- .../scala/chimp/conformance/server/Main.scala | 34 +-- ...ming.scala => ZioMcpServerStreaming.scala} | 12 +- ....scala => ZioMcpServerStreamingSpec.scala} | 9 +- .../main/scala/chimp/server/McpEndpoint.scala | 29 --- .../main/scala/chimp/server/McpHandler.scala | 205 ++++++++++-------- .../main/scala/chimp/server/McpServer.scala | 190 ++++++++++------ .../chimp/server/McpServerStreaming.scala | 16 ++ .../scala/chimp/server/McpStreaming.scala | 32 --- .../chimp/server/McpStreamingEndpoint.scala | 16 +- .../main/scala/chimp/server/OriginCheck.scala | 23 ++ .../scala/chimp/server/OutboundSink.scala | 9 - .../src/main/scala/chimp/server/Prompt.scala | 26 +-- .../main/scala/chimp/server/Resource.scala | 68 +++--- .../scala/chimp/server/ServerContext.scala | 21 +- server/src/main/scala/chimp/server/Tool.scala | 32 +-- .../scala/chimp/server/McpHandlerSpec.scala | 100 ++++----- ...ts.scala => McpServerStreamingTests.scala} | 19 +- .../scala/chimp/server/McpServerTests.scala | 48 ++-- .../chimp/server/SyncHttpMcpServerSpec.scala | 1 - .../test/scala/chimp/server/ToFuture.scala | 4 - 31 files changed, 462 insertions(+), 505 deletions(-) rename client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/{ZioStdioIntegrationSpec.scala => ZioMcpClientStdioIntegrationSpec.scala} (70%) rename client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/{ZioStreamingHttpIntegrationSpec.scala => ZioMcpClientStreamingHttpIntegrationSpec.scala} (78%) rename client/src/test/scala/chimp/client/integration/{BidirectionalHttpMcpClientTests.scala => McpClientBidirectionalHttpTests.scala} (96%) rename client/src/test/scala/chimp/client/integration/{BidirectionalMcpClientTests.scala => McpClientBidirectionalTests.scala} (98%) rename client/src/test/scala/chimp/client/integration/{HttpIntegrationSpec.scala => McpClientHttpIntegrationSpec.scala} (89%) rename client/src/test/scala/chimp/client/integration/{StdioIntegrationSpec.scala => McpClientStdioIntegrationSpec.scala} (96%) rename client/src/test/scala/chimp/client/integration/{StreamingHttpIntegrationSpec.scala => McpClientStreamingHttpIntegrationSpec.scala} (85%) rename client/src/test/scala/chimp/client/integration/{MCPEverythingContainer.scala => McpEverythingContainer.scala} (92%) rename client/src/test/scala/chimp/client/integration/{MCPProxyContainer.scala => McpToxiproxyContainer.scala} (93%) delete mode 100644 client/src/test/scala/chimp/client/integration/SyncHttpIntegrationSpec.scala delete mode 100644 client/src/test/scala/chimp/client/integration/SyncStdioIntegrationSpec.scala rename server-streaming/server-zio/src/main/scala/chimp/server/zio/{ZioMcpStreaming.scala => ZioMcpServerStreaming.scala} (71%) rename server-streaming/server-zio/src/test/scala/chimp/server/zio/{ZioStreamingMcpServerSpec.scala => ZioMcpServerStreamingSpec.scala} (78%) create mode 100644 server/src/main/scala/chimp/server/McpServerStreaming.scala delete mode 100644 server/src/main/scala/chimp/server/McpStreaming.scala create mode 100644 server/src/main/scala/chimp/server/OriginCheck.scala delete mode 100644 server/src/main/scala/chimp/server/OutboundSink.scala rename server/src/test/scala/chimp/server/{StreamingMcpServerTests.scala => McpServerStreamingTests.scala} (72%) diff --git a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStdioIntegrationSpec.scala b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala similarity index 70% rename from client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStdioIntegrationSpec.scala rename to client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala index bd65da3..0c814ee 100644 --- a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStdioIntegrationSpec.scala +++ b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala @@ -1,12 +1,12 @@ package chimp.client.transport.zio -import chimp.client.integration.StdioIntegrationSpec +import chimp.client.integration.McpClientStdioIntegrationSpec import chimp.client.transport.BidirectionalTransport import zio.{Task, ZIO} import scala.concurrent.duration.FiniteDuration -class ZioStdioIntegrationSpec extends StdioIntegrationSpec[Task] with ZioToFuture: +class ZioMcpClientStdioIntegrationSpec extends McpClientStdioIntegrationSpec[Task] with ZioToFuture: override def usingTransport[A](command: List[String], timeout: FiniteDuration)(use: BidirectionalTransport[Task] => Task[A]): Task[A] = ZIO.scoped(ZioStreamingStdioTransport.scoped(command, timeout = timeout).flatMap(use)) diff --git a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStreamingHttpIntegrationSpec.scala b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStreamingHttpIntegrationSpec.scala similarity index 78% rename from client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStreamingHttpIntegrationSpec.scala rename to client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStreamingHttpIntegrationSpec.scala index 9621987..4971661 100644 --- a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStreamingHttpIntegrationSpec.scala +++ b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStreamingHttpIntegrationSpec.scala @@ -1,6 +1,6 @@ package chimp.client.transport.zio -import chimp.client.integration.StreamingHttpIntegrationSpec +import chimp.client.integration.McpClientStreamingHttpIntegrationSpec import chimp.client.transport.BidirectionalTransport import chimp.protocol.ProtocolVersion import sttp.capabilities.zio.ZioStreams @@ -11,7 +11,9 @@ import zio.{Task, ZIO} import scala.concurrent.duration.FiniteDuration -class ZioStreamingHttpIntegrationSpec extends StreamingHttpIntegrationSpec[Task, StreamBackend[Task, ZioStreams]] with ZioToFuture: +class ZioMcpClientStreamingHttpIntegrationSpec + extends McpClientStreamingHttpIntegrationSpec[Task, StreamBackend[Task, ZioStreams]] + with ZioToFuture: override def usingBackend[A](use: StreamBackend[Task, ZioStreams] => Task[A]): Task[A] = HttpClientZioBackend().flatMap: b => diff --git a/client/src/test/scala/chimp/client/integration/BidirectionalHttpMcpClientTests.scala b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala similarity index 96% rename from client/src/test/scala/chimp/client/integration/BidirectionalHttpMcpClientTests.scala rename to client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala index fc9fdb4..b40ca6a 100644 --- a/client/src/test/scala/chimp/client/integration/BidirectionalHttpMcpClientTests.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala @@ -14,13 +14,13 @@ import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReferenc import scala.concurrent.Future import scala.concurrent.duration.{DurationInt, FiniteDuration} -trait BidirectionalHttpMcpClientTests[F[_]] extends AsyncFlatSpec with Matchers: +trait McpClientBidirectionalHttpTests[F[_]] extends AsyncFlatSpec with Matchers: this: ToFuture[F] => protected def withProxiedBidirectionalClient( samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None, timeout: FiniteDuration = Transport.defaultTimeout - )(test: (MCPProxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] + )(test: (McpToxiproxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] "GET SSE stream" should "resume delivering notifications after the underlying connection is cut" in: val logCount = AtomicInteger(0) diff --git a/client/src/test/scala/chimp/client/integration/BidirectionalMcpClientTests.scala b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalTests.scala similarity index 98% rename from client/src/test/scala/chimp/client/integration/BidirectionalMcpClientTests.scala rename to client/src/test/scala/chimp/client/integration/McpClientBidirectionalTests.scala index 61e9fb2..e24d938 100644 --- a/client/src/test/scala/chimp/client/integration/BidirectionalMcpClientTests.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalTests.scala @@ -12,7 +12,7 @@ import sttp.monad.syntax.* import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} import scala.concurrent.Future -trait BidirectionalMcpClientTests[F[_]] extends AsyncFlatSpec with Matchers: +trait McpClientBidirectionalTests[F[_]] extends AsyncFlatSpec with Matchers: this: ToFuture[F] => protected def withBidirectionalClient( diff --git a/client/src/test/scala/chimp/client/integration/HttpIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala similarity index 89% rename from client/src/test/scala/chimp/client/integration/HttpIntegrationSpec.scala rename to client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala index 9212599..00f8021 100644 --- a/client/src/test/scala/chimp/client/integration/HttpIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala @@ -12,7 +12,7 @@ import sttp.monad.syntax.* import scala.concurrent.Future -abstract class HttpIntegrationSpec[F[_], B] +abstract class McpClientHttpIntegrationSpec[F[_], B] extends AsyncFlatSpec with Matchers with BeforeAndAfterAll @@ -21,7 +21,7 @@ abstract class HttpIntegrationSpec[F[_], B] this: ToFuture[F] => protected val network: Network = Network.newNetwork() - protected val mcpEverythingContainer: MCPEverythingContainer = new MCPEverythingContainer(network = Some(network)) + protected val mcpEverythingContainer: McpEverythingContainer = new McpEverythingContainer(network = Some(network)) override def beforeAll(): Unit = super.beforeAll() diff --git a/client/src/test/scala/chimp/client/integration/StdioIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala similarity index 96% rename from client/src/test/scala/chimp/client/integration/StdioIntegrationSpec.scala rename to client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala index fd4f57c..2f14d32 100644 --- a/client/src/test/scala/chimp/client/integration/StdioIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala @@ -12,12 +12,12 @@ import java.util.concurrent.atomic.AtomicReference import scala.concurrent.Future import scala.concurrent.duration.{DurationInt, FiniteDuration} -abstract class StdioIntegrationSpec[F[_]] +abstract class McpClientStdioIntegrationSpec[F[_]] extends AsyncFlatSpec with Matchers with IntegrationSpec with McpClientTests[F] - with BidirectionalMcpClientTests[F]: + with McpClientBidirectionalTests[F]: this: ToFuture[F] => protected val everythingServerCommand: List[String] = diff --git a/client/src/test/scala/chimp/client/integration/StreamingHttpIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala similarity index 85% rename from client/src/test/scala/chimp/client/integration/StreamingHttpIntegrationSpec.scala rename to client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala index 4f72d80..86601d3 100644 --- a/client/src/test/scala/chimp/client/integration/StreamingHttpIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala @@ -10,13 +10,13 @@ import sttp.monad.syntax.* import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration -abstract class StreamingHttpIntegrationSpec[F[_], B] - extends HttpIntegrationSpec[F, B] - with BidirectionalMcpClientTests[F] - with BidirectionalHttpMcpClientTests[F]: +abstract class McpClientStreamingHttpIntegrationSpec[F[_], B] + extends McpClientHttpIntegrationSpec[F, B] + with McpClientBidirectionalTests[F] + with McpClientBidirectionalHttpTests[F]: this: ToFuture[F] => - private val proxyContainer: MCPProxyContainer = new MCPProxyContainer(network, mcpEverythingContainer.alias, 3001) + private val proxyContainer: McpToxiproxyContainer = new McpToxiproxyContainer(network, mcpEverythingContainer.alias, 3001) override def beforeAll(): Unit = super.beforeAll() @@ -50,7 +50,7 @@ abstract class StreamingHttpIntegrationSpec[F[_], B] override protected def withProxiedBidirectionalClient( samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None, timeout: FiniteDuration = Transport.defaultTimeout - )(test: (MCPProxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] = + )(test: (McpToxiproxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] = proxyContainer.restoreConnections() proxyContainer.clearToxics() toFuture( diff --git a/client/src/test/scala/chimp/client/integration/MCPEverythingContainer.scala b/client/src/test/scala/chimp/client/integration/McpEverythingContainer.scala similarity index 92% rename from client/src/test/scala/chimp/client/integration/MCPEverythingContainer.scala rename to client/src/test/scala/chimp/client/integration/McpEverythingContainer.scala index cf0fd9b..32d61b6 100644 --- a/client/src/test/scala/chimp/client/integration/MCPEverythingContainer.scala +++ b/client/src/test/scala/chimp/client/integration/McpEverythingContainer.scala @@ -7,7 +7,7 @@ import sttp.model.Uri import java.time.Duration -class MCPEverythingContainer(network: Option[Network] = None, networkAlias: String = "everything") +class McpEverythingContainer(network: Option[Network] = None, networkAlias: String = "everything") extends GenericContainer( dockerImage = "node:24-alpine", exposedPorts = Seq(3001), diff --git a/client/src/test/scala/chimp/client/integration/MCPProxyContainer.scala b/client/src/test/scala/chimp/client/integration/McpToxiproxyContainer.scala similarity index 93% rename from client/src/test/scala/chimp/client/integration/MCPProxyContainer.scala rename to client/src/test/scala/chimp/client/integration/McpToxiproxyContainer.scala index 9f7c098..c7c6963 100644 --- a/client/src/test/scala/chimp/client/integration/MCPProxyContainer.scala +++ b/client/src/test/scala/chimp/client/integration/McpToxiproxyContainer.scala @@ -8,7 +8,7 @@ import sttp.model.Uri import scala.jdk.CollectionConverters.* -class MCPProxyContainer(network: Network, upstreamAlias: String, upstreamPort: Int) +class McpToxiproxyContainer(network: Network, upstreamAlias: String, upstreamPort: Int) extends ToxiproxyContainer(DockerImageName.parse("ghcr.io/shopify/toxiproxy:2.12.0")): container.withNetwork(network) diff --git a/client/src/test/scala/chimp/client/integration/SyncHttpIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/SyncHttpIntegrationSpec.scala deleted file mode 100644 index 1360777..0000000 --- a/client/src/test/scala/chimp/client/integration/SyncHttpIntegrationSpec.scala +++ /dev/null @@ -1,18 +0,0 @@ -package chimp.client.integration - -import chimp.client.transport.{HttpTransport, Transport} -import sttp.client4.{Backend, DefaultSyncBackend} -import sttp.model.Uri -import sttp.shared.Identity - -class SyncHttpIntegrationSpec extends HttpIntegrationSpec[Identity, Backend[Identity]] with SyncToFuture: - - override def usingBackend[A](use: Backend[Identity] => Identity[A]): Identity[A] = - val backend = DefaultSyncBackend() - try use(backend) - finally backend.close() - - override def usingTransport[A](backend: Backend[Identity], uri: Uri)(use: Transport[Identity] => Identity[A]): Identity[A] = - val transport = HttpTransport[Identity](backend, uri) - try use(transport) - finally transport.close() diff --git a/client/src/test/scala/chimp/client/integration/SyncStdioIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/SyncStdioIntegrationSpec.scala deleted file mode 100644 index b69820e..0000000 --- a/client/src/test/scala/chimp/client/integration/SyncStdioIntegrationSpec.scala +++ /dev/null @@ -1,15 +0,0 @@ -package chimp.client.integration - -import chimp.client.transport.{BidirectionalTransport, StdioTransport} -import sttp.shared.Identity - -import scala.concurrent.duration.FiniteDuration - -class SyncStdioIntegrationSpec extends StdioIntegrationSpec[Identity] with SyncToFuture: - - override def usingTransport[A](command: List[String], timeout: FiniteDuration)( - use: BidirectionalTransport[Identity] => Identity[A] - ): Identity[A] = - val transport = StdioTransport(command, timeout = timeout) - try use(transport) - finally transport.close() diff --git a/server-conformance/src/main/scala/chimp/conformance/server/Main.scala b/server-conformance/src/main/scala/chimp/conformance/server/Main.scala index 940bf94..87bcece 100644 --- a/server-conformance/src/main/scala/chimp/conformance/server/Main.scala +++ b/server-conformance/src/main/scala/chimp/conformance/server/Main.scala @@ -4,18 +4,18 @@ import chimp.protocol.* import chimp.server.* import io.circe.parser.parse import io.circe.{Codec, Json} -import java.util.Base64 import ox.supervised import sttp.shared.Identity import sttp.tapir.Schema import sttp.tapir.server.netty.sync.NettySyncServer +import java.util.Base64 + object Main: private case class AddNumbersInput(a: Double, b: Double) derives Codec, Schema private case class NoInput() derives Codec, Schema - // Real PNG and WAV fixtures, bundled as classpath resources and base64-encoded at startup. private def base64Resource(path: String): String = val stream = getClass.getResourceAsStream(path) require(stream != null, s"conformance fixture resource not found on the classpath: $path") @@ -25,7 +25,6 @@ object Main: private val pngData = base64Resource("/sample.png") private val wavData = base64Resource("/sample.wav") - // ── tools ── private val addNumbers = tool("add_numbers") .description("Adds two numbers and returns the result as text") .input[AddNumbersInput] @@ -58,7 +57,8 @@ object Main: ToolResult.content( ToolContent.Text(text = "Here is some mixed content"), ToolContent.Image(data = pngData, mimeType = "image/png"), - ToolContent.ResourceContent(resource = ResourceContents.Text(uri = "test://embedded", text = "embedded", mimeType = Some("text/plain"))) + ToolContent + .ResourceContent(resource = ResourceContents.Text(uri = "test://embedded", text = "embedded", mimeType = Some("text/plain"))) ) ) @@ -108,11 +108,12 @@ object Main: .inputJson(jsonSchema2020) .handle(_ => ToolResult.text("ok")) - // ── resources ── private val staticText = resource("test://static-text") .name("static-text") .mimeType("text/plain") - .handle(() => Right(List(ResourceContents.Text(uri = "test://static-text", text = "Hello, text resource!", mimeType = Some("text/plain"))))) + .handle(() => + Right(List(ResourceContents.Text(uri = "test://static-text", text = "Hello, text resource!", mimeType = Some("text/plain")))) + ) private val staticBinary = resource("test://static-binary") .name("static-binary") @@ -122,40 +123,45 @@ object Main: private val dataTemplate = resourceTemplate("test://template/{id}/data") .name("data-template") .mimeType("text/plain") - .handle((vars, uri) => + .serverLogic[Identity]((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"data for ${vars.getOrElse("id", "?")}", mimeType = Some("text/plain")))) ) - // ── prompts ── private val simplePrompt = prompt("test_simple_prompt") .description("A simple prompt") - .handle(_ => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = "This is a simple prompt."))))) + .serverLogic[Identity](_ => + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = "This is a simple prompt.")))) + ) private val argsPrompt = prompt("test_prompt_with_arguments") .description("A prompt with arguments") .argument("arg1", required = true) .argument("arg2", required = true) - .handle: args => + .serverLogic[Identity]: args => val text = s"arg1=${args.getOrElse("arg1", "")}, arg2=${args.getOrElse("arg2", "")}" GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = text)))) private val embeddedResourcePrompt = prompt("test_prompt_with_embedded_resource") .description("A prompt embedding a resource") .argument("resourceUri", required = true) - .handle: args => + .serverLogic[Identity]: args => val uri = args.getOrElse("resourceUri", "test://example-resource") GetPromptResult(messages = List( PromptMessage( Role.User, - ToolContent.ResourceContent(resource = ResourceContents.Text(uri = uri, text = "embedded resource content", mimeType = Some("text/plain"))) + ToolContent.ResourceContent(resource = + ResourceContents.Text(uri = uri, text = "embedded resource content", mimeType = Some("text/plain")) + ) ) ) ) private val imagePrompt = prompt("test_prompt_with_image") .description("A prompt with an image") - .handle(_ => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Image(data = pngData, mimeType = "image/png"))))) + .serverLogic[Identity](_ => + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Image(data = pngData, mimeType = "image/png")))) + ) private val server = McpServer[Identity](name = "chimp-conformance-server", version = "0.1.0") .addTools(addNumbers, simpleText, errorTool, imageTool, audioTool, mixedTool, embeddedResourceTool, jsonSchemaTool) @@ -163,7 +169,7 @@ object Main: .addResourceTemplate(dataTemplate) .addPrompts(simplePrompt, argsPrompt, embeddedResourcePrompt, imagePrompt) .withCompletion((_, _, _) => Completion(values = List("alpha", "beta"))) - .withLogging(_ => ()) + .withLoggingLevel(_ => ()) .withSubscriptions(ResourceSubscriptions[Identity](_ => (), _ => ())) def main(args: Array[String]): Unit = diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpStreaming.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala similarity index 71% rename from server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpStreaming.scala rename to server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala index 64fca78..0ee1072 100644 --- a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpStreaming.scala +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala @@ -1,7 +1,7 @@ package chimp.server.zio import chimp.protocol.JSONRPCMessage -import chimp.server.{McpStreaming, OutboundSink} +import chimp.server.{McpServerStreaming, OutboundSink} import io.circe.Json import io.circe.syntax.* import sttp.capabilities.zio.ZioStreams @@ -13,11 +13,9 @@ import zio.{Queue, Task, ZIO} import java.nio.charset.StandardCharsets -/** ZIO/zio-http implementation of [[McpStreaming]]. Each request's server→client messages are buffered through a `Queue` and drained as a - * chunked `text/event-stream` response, so events flush to the client as the tool logic produces them. - */ -object ZioMcpStreaming extends McpStreaming[Task, ZioStreams]: +object ZioMcpServerStreaming extends McpServerStreaming[Task, ZioStreams]: val streams: ZioStreams = ZioStreams + type EventStream = Stream[Throwable, ServerSentEvent] val sseBody: StreamBodyIO[Stream[Throwable, Byte], EventStream, ZioStreams] = @@ -35,8 +33,8 @@ object ZioMcpStreaming extends McpStreaming[Task, ZioStreams]: def send(message: JSONRPCMessage): Task[Unit] = queue.offer(Some(ServerSentEvent(data = Some(message.asJson.noSpaces)))).unit _ <- handle(sink) - .flatMap { finalBody => - ZIO.foreachDiscard(finalBody)(json => queue.offer(Some(ServerSentEvent(data = Some(json.noSpaces))))) *> queue.offer(None) + .flatMap { message => + ZIO.foreachDiscard(message)(message => queue.offer(Some(ServerSentEvent(data = Some(message.noSpaces))))) *> queue.offer(None) } .catchAllCause(_ => queue.offer(None).unit) .forkDaemon diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioStreamingMcpServerSpec.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala similarity index 78% rename from server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioStreamingMcpServerSpec.scala rename to server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala index 322d2e4..f1f6072 100644 --- a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioStreamingMcpServerSpec.scala +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala @@ -4,7 +4,7 @@ import chimp.client.transport.Transport import chimp.client.transport.zio.ZioStreamingHttpTransport import chimp.client.{BidirectionalMcpClient, McpClient} import chimp.protocol.{Implementation, ProtocolVersion} -import chimp.server.{McpServer, McpServerTests, StreamingMcpServer, StreamingMcpServerTests} +import chimp.server.{McpServer, McpServerStreamingTests, McpServerTests, StreamingMcpServer} import org.scalatest.Assertion import sttp.client4.* import sttp.client4.httpclient.zio.HttpClientZioBackend @@ -14,10 +14,7 @@ import zio.{Scope, Task, ZIO} import scala.concurrent.Future -/** Runs the generic server tests — including the streaming ones — against a zio-http server hosting chimp's SSE endpoint, driven by the chimp - * ZIO streaming client. - */ -class ZioStreamingMcpServerSpec extends McpServerTests[Task] with StreamingMcpServerTests[Task] with ZioToFuture: +class ZioMcpServerStreamingSpec extends McpServerTests[Task] with McpServerStreamingTests[Task] with ZioToFuture: private val clientInfo = Implementation("chimp-server-test", "0.0.1") override protected def withServer(server: McpServer[Task])(test: McpClient[Task] => Task[Assertion]): Future[Assertion] = @@ -27,7 +24,7 @@ class ZioStreamingMcpServerSpec extends McpServerTests[Task] with StreamingMcpSe server: StreamingMcpServer[Task] )(test: BidirectionalMcpClient[Task] => Task[Assertion]): Future[Assertion] = toFuture: - val routes = ZioHttpInterpreter().toHttp(server.streamingEndpoint(List("mcp"), ZioMcpStreaming)) + val routes = ZioHttpInterpreter().toHttp(server.streamingEndpoint(List("mcp"), ZioMcpServerStreaming)) ZIO.scoped: (for port <- Server.install(routes) diff --git a/server/src/main/scala/chimp/server/McpEndpoint.scala b/server/src/main/scala/chimp/server/McpEndpoint.scala index 0b79107..53b2f4f 100644 --- a/server/src/main/scala/chimp/server/McpEndpoint.scala +++ b/server/src/main/scala/chimp/server/McpEndpoint.scala @@ -31,32 +31,3 @@ private[server] def buildEndpoint[F[_]](server: McpServer[F], path: List[String] .map(response => Right((response.statusCode, response.body))) } ) - -/** DNS-rebinding protection: validates the request's `Host` and `Origin` headers against an allow-list of host names. - * - * @param allowedHosts - * Allowed host names (without port; IPv6 addresses bracketed, e.g. `[::1]`). - * @param enabled - * When `false`, all requests pass (use behind a trusted proxy / TLS with authentication). - */ -case class OriginCheck(allowedHosts: Set[String], enabled: Boolean = true): - def validate(host: Option[String], origin: Option[String]): Boolean = - if !enabled then true - else host.forall(allowed) && origin.forall(allowed) - - private def allowed(headerValue: String): Boolean = allowedHosts.contains(OriginCheck.hostName(headerValue)) - -object OriginCheck: - private val localhostHosts: Set[String] = Set("localhost", "127.0.0.1", "[::1]", "::1") - - val localhostOnly: OriginCheck = OriginCheck(localhostHosts) - val disabled: OriginCheck = OriginCheck(Set.empty, enabled = false) - - private def hostName(headerValue: String): String = - val trimmed = headerValue.trim - val schemeIdx = trimmed.indexOf("://") - val authority = if schemeIdx >= 0 then trimmed.substring(schemeIdx + 3) else trimmed - if authority.startsWith("[") then - val close = authority.indexOf("]") - if close >= 0 then authority.substring(0, close + 1) else "" - else authority.takeWhile(_ != ':') diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index f1878b8..e3476d9 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -26,11 +26,11 @@ enum McpResponse: case JsonResponse(json) => JsonResponse(json.deepDropNullValues) case EmptyAcceptResponse => this -class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): +private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): private val logger = LoggerFactory.getLogger(classOf[McpHandler[?, ?]]) - private val toolsByName = server.tools.map(t => t.name -> t).toMap - private val promptsByName = server.prompts.map(p => p.definition.name -> p).toMap - private val resourcesByUri = server.resources.map(r => r.definition.uri -> r).toMap + private val toolsByName = server.tools.map(tool => tool.name -> tool).toMap + private val promptsByName = server.prompts.map(prompt => prompt.definition.name -> prompt).toMap + private val resourcesByUri = server.resources.map(resource => resource.definition.uri -> resource).toMap private val hasResources = server.resources.nonEmpty || server.resourceTemplates.nonEmpty private val toolDefinitions = server.tools.map(toolToDefinition) @@ -45,29 +45,81 @@ class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): description = tool.description, inputSchema = jsonSchema, annotations = tool.annotations - .map(a => ToolAnnotations(a.title, a.readOnlyHint, a.destructiveHint, a.idempotentHint, a.openWorldHint)) + .map(annotation => + ToolAnnotations( + annotation.title, + annotation.readOnlyHint, + annotation.destructiveHint, + annotation.idempotentHint, + annotation.openWorldHint + ) + ) ) + def handleJsonRpc(request: Json, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using MonadError[F]): F[McpResponse] = + doHandleJsonRpc(request, headers, makeContext).map: response => + logger.debug(s"Request: $request, response: ${response.statusCode}, body: ${response.body}") + response.withNullsDroppedDeep + + def handleJsonRpc(request: Json, headers: Seq[Header])(using m: MonadError[F], ev: ServerContext[F] <:< C): F[McpResponse] = + handleJsonRpc(request, headers, _ => ev(ServerContext.noop[F])) + + private def doHandleJsonRpc(request: Json, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using + MonadError[F] + ): F[McpResponse] = + request.as[JSONRPCMessage] match + 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 => + jsonResponse( + JSONRPCMessage.Response(id = id, result = ListResourceTemplatesResult(server.resourceTemplates.map(_.definition)).asJson) + ).unit + case "resources/read" if hasResources => + handleResourcesRead(params, id).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 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 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 emptyResult(id: RequestId): JSONRPCMessage = JSONRPCMessage.Response(id = id, result = Json.obj()) - - private def decodeParams[P: Decoder](params: Option[Json], id: RequestId)(f: P => F[JSONRPCMessage])(using - MonadError[F] - ): F[JSONRPCMessage] = - params.flatMap(_.as[P].toOption) match - case Some(p) => f(p) - case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Invalid or missing params").unit - 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.setLevel.isDefined)(Json.obj()), + 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 = @@ -86,28 +138,21 @@ class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): MonadError[F] ): F[JSONRPCMessage] = val name = params.flatMap(_.hcursor.downField("name").as[String].toOption) - val args = params.flatMap(_.hcursor.downField("arguments").focus).getOrElse(Json.obj()) + val arguments = params.flatMap(_.hcursor.downField("arguments").focus).getOrElse(Json.obj()) val progressToken = params.flatMap(_.hcursor.downField("_meta").downField("progressToken").as[ProgressToken].toOption) name match case Some(name) => toolsByName.get(name) match case Some(tool) => - tool.inputDecoder.decodeJson(args) match + tool.inputDecoder.decodeJson(arguments) match case Right(input) => val context = makeContext(progressToken) tool .logic(input, context, headers) .map: result => - JSONRPCMessage.Response( - id = id, - result = CallToolResult( - content = result.content, - structuredContent = result.structuredContent, - isError = result.isError - ).asJson - ) + toolCallResponse(id, result) case Left(decodingError) => - val snippet = args.noSpaces.take(200) + val snippet = arguments.noSpaces.take(200) protocolError( id, JSONRPCErrorCodes.InvalidParams.code, @@ -117,16 +162,26 @@ class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Missing tool name").unit + private def toolCallResponse(id: RequestId, result: ToolResult): JSONRPCMessage = + JSONRPCMessage.Response( + id = id, + result = CallToolResult( + content = result.content, + structuredContent = result.structuredContent, + isError = result.isError + ).asJson + ) + private def handleResourcesRead(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = decodeParams[ReadResourceParams](params, id): params => resourcesByUri.get(params.uri) match - case Some(resource) => resource.read().map(readResponse(id, params.uri)) + case Some(resource) => resource.read().map(resourceReadResponse(id, params.uri)) case None => val templateMatch = server.resourceTemplates.iterator .map(template => (template, template.matcher.matchUri(params.uri))) .collectFirst { case (template, Some(vars)) => (template, vars) } templateMatch match - case Some((template, vars)) => template.read(vars, params.uri).map(readResponse(id, params.uri)) + case Some((template, vars)) => template.read(vars, params.uri).map(resourceReadResponse(id, params.uri)) case None => protocolError( id, @@ -135,88 +190,46 @@ class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): Some(Json.obj("uri" -> Json.fromString(params.uri))) ).unit - private def readResponse(id: RequestId, uri: String)(result: Either[ResourceError, List[ResourceContents]]): JSONRPCMessage = + private def decodeParams[P: Decoder](params: Option[Json], id: RequestId)(f: P => F[JSONRPCMessage])(using + MonadError[F] + ): F[JSONRPCMessage] = + params.flatMap(_.as[P].toOption) match + case Some(params) => f(params) + case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Invalid or missing params").unit + + private def resourceReadResponse(id: RequestId, uri: String)(result: Either[ResourceError, List[ResourceContents]]): JSONRPCMessage = result match case Right(contents) => JSONRPCMessage.Response(id = id, result = ReadResourceResult(contents).asJson) - case Left(error) => + case Left(error) => protocolError( id, JSONRPCErrorCodes.InvalidParams.code, error.message, - error.uri.orElse(Some(uri)).map(u => Json.obj("uri" -> Json.fromString(u))) + error.uri.orElse(Some(uri)).map(uri => Json.obj("uri" -> Json.fromString(uri))) ) private def handleSubscribe(params: Option[Json], id: RequestId, subscribe: Boolean)(using MonadError[F]): F[JSONRPCMessage] = val subs = server.subscriptions.get - if subscribe then decodeParams[SubscribeParams](params, id)(p => subs.onSubscribe(p).map(_ => emptyResult(id))) - else decodeParams[UnsubscribeParams](params, id)(p => subs.onUnsubscribe(p).map(_ => emptyResult(id))) + if subscribe then decodeParams[SubscribeParams](params, id)(params => subs.onSubscribe(params).map(_ => emptyResult(id))) + else decodeParams[UnsubscribeParams](params, id)(params => subs.onUnsubscribe(params).map(_ => emptyResult(id))) + + private def emptyResult(id: RequestId): JSONRPCMessage = JSONRPCMessage.Response(id = id, result = Json.obj()) private def handlePromptsGet(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] = - decodeParams[GetPromptParams](params, id): p => - promptsByName.get(p.name) match + decodeParams[GetPromptParams](params, id): params => + promptsByName.get(params.name) match case Some(prompt) => - prompt.logic(p.arguments.getOrElse(Map.empty), headers).map(result => JSONRPCMessage.Response(id = id, result = result.asJson)) - case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, s"Unknown prompt: ${p.name}").unit + prompt + .logic(params.arguments.getOrElse(Map.empty), headers) + .map(result => JSONRPCMessage.Response(id = id, result = result.asJson)) + case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, s"Unknown prompt: ${params.name}").unit private def handleComplete(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = val handler = server.completion.get - decodeParams[CompleteParams](params, id): p => - handler(p.ref, p.argument, p.context).map(completion => JSONRPCMessage.Response(id = id, result = CompleteResult(completion).asJson)) - - private def handleSetLevel(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = - val handler = server.setLevel.get - decodeParams[SetLevelParams](params, id)(p => handler(p.level).map(_ => emptyResult(id))) - - private def doHandleJsonRpc(request: Json, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using - MonadError[F] - ): F[McpResponse] = - request.as[JSONRPCMessage] match - case Left(err) => - val errorResponse = protocolError(RequestId("null"), JSONRPCErrorCodes.ParseError.code, s"Parse error: ${err.message}") - jsonResponse(errorResponse).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 => - jsonResponse( - JSONRPCMessage.Response(id = id, result = ListResourceTemplatesResult(server.resourceTemplates.map(_.definition)).asJson) - ).unit - case "resources/read" if hasResources => - handleResourcesRead(params, id).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.setLevel.isDefined => - handleSetLevel(params, id).map(jsonResponse) - case other => - jsonResponse(protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown method: $other")).unit - 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 + decodeParams[CompleteParams](params, id): params => + handler(params.ref, params.argument, params.context) + .map(completion => JSONRPCMessage.Response(id = id, result = CompleteResult(completion).asJson)) - def handleJsonRpc(request: Json, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using MonadError[F]): F[McpResponse] = - doHandleJsonRpc(request, headers, makeContext).map: response => - logger.debug(s"Request: $request, response: ${response.statusCode}, body: ${response.body}") - response.withNullsDroppedDeep - - def handleJsonRpc(request: Json, headers: Seq[Header])(using m: MonadError[F], ev: ServerContext[F] <:< C): F[McpResponse] = - handleJsonRpc(request, headers, _ => ev(ServerContext.noop[F])) + private def handleSetLoggingLevel(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = + val handler = server.loggingLevel.get + decodeParams[SetLevelParams](params, id)(params => handler(params.level).map(_ => emptyResult(id))) diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala index c1fb83b..0b55efa 100644 --- a/server/src/main/scala/chimp/server/McpServer.scala +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -1,23 +1,17 @@ package chimp.server -import chimp.protocol.{CompleteArgument, CompleteContext, CompleteRef, Completion, LoggingLevel, SubscribeParams, UnsubscribeParams} +import chimp.protocol.* import sttp.tapir.server.ServerEndpoint -/** Logic producing completion suggestions for an argument of a prompt or resource-template reference. */ type CompletionHandler[F[_]] = (CompleteRef, CompleteArgument, Option[CompleteContext]) => F[Completion] -/** Logic invoked when the client sets the minimum logging level via `logging/setLevel`. */ -type SetLevelHandler[F[_]] = LoggingLevel => F[Unit] +type SetLoggingLevelHandler[F[_]] = LoggingLevel => F[Unit] -/** Logic invoked when the client subscribes to / unsubscribes from updates for a resource URI. */ case class ResourceSubscriptions[F[_]]( onSubscribe: SubscribeParams => F[Unit], onUnsubscribe: UnsubscribeParams => F[Unit] ) -/** The features and metadata of an MCP server, shared by the request/response [[McpServer]] (tools needing only a base [[ServerContext]]) and - * the [[StreamingMcpServer]] (tools needing a [[StreamingServerContext]]). `C` is the context the registered tools require. - */ sealed trait McpServerDef[F[_], C <: ServerContext[F]]: def name: String def version: String @@ -29,12 +23,9 @@ sealed trait McpServerDef[F[_], C <: ServerContext[F]]: def resources: List[ServerResource[F]] def resourceTemplates: List[ServerResourceTemplate[F]] def completion: Option[CompletionHandler[F]] - def setLevel: Option[SetLevelHandler[F]] + def loggingLevel: Option[SetLoggingLevelHandler[F]] def subscriptions: Option[ResourceSubscriptions[F]] -/** An MCP server definition: the features it exposes and the metadata it reports. Build one up with the `add*`/`with*` methods, then turn it - * into a Tapir endpoint with [[endpoint]]. Server capabilities are derived from which features are registered. - */ case class McpServer[F[_]]( name: String = "Chimp MCP server", version: String = "1.0.0", @@ -46,41 +37,75 @@ case class McpServer[F[_]]( resources: List[ServerResource[F]] = Nil, resourceTemplates: List[ServerResourceTemplate[F]] = Nil, completion: Option[CompletionHandler[F]] = None, - setLevel: Option[SetLevelHandler[F]] = None, + loggingLevel: Option[SetLoggingLevelHandler[F]] = None, subscriptions: Option[ResourceSubscriptions[F]] = None ) extends McpServerDef[F, ServerContext[F]]: - def name(value: String): McpServer[F] = copy(name = value) - def version(value: String): McpServer[F] = copy(version = value) - def instructions(value: String): McpServer[F] = copy(instructions = Some(value)) - def withJsonSchemaMetadata(value: Boolean): McpServer[F] = copy(showJsonSchemaMetadata = value) - def withOriginCheck(value: OriginCheck): McpServer[F] = copy(originCheck = value) - - def addTool(t: ServerTool[?, F, ServerContext[F]]): McpServer[F] = copy(tools = tools :+ t) - def addTools(ts: ServerTool[?, F, ServerContext[F]]*): McpServer[F] = copy(tools = tools ++ ts) - def addPrompt(p: ServerPrompt[F]): McpServer[F] = copy(prompts = prompts :+ p) - def addPrompts(ps: ServerPrompt[F]*): McpServer[F] = copy(prompts = prompts ++ ps) - def addResource(r: ServerResource[F]): McpServer[F] = copy(resources = resources :+ r) - def addResources(rs: ServerResource[F]*): McpServer[F] = copy(resources = resources ++ rs) - def addResourceTemplate(rt: ServerResourceTemplate[F]): McpServer[F] = copy(resourceTemplates = resourceTemplates :+ rt) - def addResourceTemplates(rts: ServerResourceTemplate[F]*): McpServer[F] = copy(resourceTemplates = resourceTemplates ++ rts) - def withCompletion(handler: CompletionHandler[F]): McpServer[F] = copy(completion = Some(handler)) - def withLogging(handler: SetLevelHandler[F]): McpServer[F] = copy(setLevel = Some(handler)) - def withSubscriptions(handler: ResourceSubscriptions[F]): McpServer[F] = copy(subscriptions = Some(handler)) - - /** Build the request/response Tapir endpoint serving this MCP server at the given path. */ + def name(value: String): McpServer[F] = + copy(name = value) + + def version(value: String): McpServer[F] = + copy(version = value) + + def instructions(value: String): McpServer[F] = + copy(instructions = Some(value)) + + def withJsonSchemaMetadata(value: Boolean): McpServer[F] = + copy(showJsonSchemaMetadata = value) + + def withOriginCheck(value: OriginCheck): McpServer[F] = + copy(originCheck = value) + + def addTool(tool: ServerTool[?, F, ServerContext[F]]): McpServer[F] = + copy(tools = tools :+ tool) + + def addTools(tools: ServerTool[?, F, ServerContext[F]]*): McpServer[F] = + copy(tools = this.tools ++ tools) + + def addPrompt(prompt: ServerPrompt[F]): McpServer[F] = + copy(prompts = prompts :+ prompt) + + def addPrompts(prompts: ServerPrompt[F]*): McpServer[F] = + copy(prompts = this.prompts ++ prompts) + + def addResource(resource: ServerResource[F]): McpServer[F] = + copy(resources = resources :+ resource) + + def addResources(resources: ServerResource[F]*): McpServer[F] = + copy(resources = this.resources ++ resources) + + def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): McpServer[F] = + copy(resourceTemplates = resourceTemplates :+ resourceTemplate) + + def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): McpServer[F] = + copy(resourceTemplates = this.resourceTemplates ++ resourceTemplates) + + def withCompletion(handler: CompletionHandler[F]): McpServer[F] = + copy(completion = Some(handler)) + + def withLoggingLevel(handler: SetLoggingLevelHandler[F]): McpServer[F] = + copy(loggingLevel = Some(handler)) + + def withSubscriptions(handler: ResourceSubscriptions[F]): McpServer[F] = + copy(subscriptions = Some(handler)) + def endpoint(path: List[String]): ServerEndpoint[Any, F] = buildEndpoint(this, path) - /** Promote this server to a [[StreamingMcpServer]], on which streaming-only tools can additionally be registered and which serves SSE - * responses. The already-registered tools carry over (a base-context tool runs unchanged on the streaming endpoint). - */ def streaming: StreamingMcpServer[F] = - StreamingMcpServer(name, version, instructions, showJsonSchemaMetadata, originCheck, tools, prompts, resources, resourceTemplates, - completion, setLevel, subscriptions) + StreamingMcpServer( + name, + version, + instructions, + showJsonSchemaMetadata, + originCheck, + tools, + prompts, + resources, + resourceTemplates, + completion, + loggingLevel, + subscriptions + ) -/** An MCP server that serves Server-Sent-Event responses and accepts streaming-only tools (those using a [[StreamingServerContext]] to - * report progress, log, or — later — sample/elicit). Materialized into a Tapir endpoint via [[streamingEndpoint]] using a per-effect - * [[McpStreaming]] implementation. - */ case class StreamingMcpServer[F[_]]( name: String = "Chimp MCP server", version: String = "1.0.0", @@ -92,33 +117,62 @@ case class StreamingMcpServer[F[_]]( resources: List[ServerResource[F]] = Nil, resourceTemplates: List[ServerResourceTemplate[F]] = Nil, completion: Option[CompletionHandler[F]] = None, - setLevel: Option[SetLevelHandler[F]] = None, + loggingLevel: Option[SetLoggingLevelHandler[F]] = None, subscriptions: Option[ResourceSubscriptions[F]] = None ) extends McpServerDef[F, StreamingServerContext[F]]: - def name(value: String): StreamingMcpServer[F] = copy(name = value) - def version(value: String): StreamingMcpServer[F] = copy(version = value) - def instructions(value: String): StreamingMcpServer[F] = copy(instructions = Some(value)) - def withJsonSchemaMetadata(value: Boolean): StreamingMcpServer[F] = copy(showJsonSchemaMetadata = value) - def withOriginCheck(value: OriginCheck): StreamingMcpServer[F] = copy(originCheck = value) - - /** Add a base-context tool (it runs unchanged, without using streaming features). */ - def addTool(t: ServerTool[?, F, ServerContext[F]]): StreamingMcpServer[F] = copy(tools = tools :+ t) - def addTools(ts: ServerTool[?, F, ServerContext[F]]*): StreamingMcpServer[F] = copy(tools = tools ++ ts) - - /** Add a streaming-only tool (one whose logic uses the [[StreamingServerContext]]). */ - def addStreamingTool(t: ServerTool[?, F, StreamingServerContext[F]]): StreamingMcpServer[F] = copy(tools = tools :+ t) - def addStreamingTools(ts: ServerTool[?, F, StreamingServerContext[F]]*): StreamingMcpServer[F] = copy(tools = tools ++ ts) - - def addPrompt(p: ServerPrompt[F]): StreamingMcpServer[F] = copy(prompts = prompts :+ p) - def addPrompts(ps: ServerPrompt[F]*): StreamingMcpServer[F] = copy(prompts = prompts ++ ps) - def addResource(r: ServerResource[F]): StreamingMcpServer[F] = copy(resources = resources :+ r) - def addResources(rs: ServerResource[F]*): StreamingMcpServer[F] = copy(resources = resources ++ rs) - def addResourceTemplate(rt: ServerResourceTemplate[F]): StreamingMcpServer[F] = copy(resourceTemplates = resourceTemplates :+ rt) - def addResourceTemplates(rts: ServerResourceTemplate[F]*): StreamingMcpServer[F] = copy(resourceTemplates = resourceTemplates ++ rts) - def withCompletion(handler: CompletionHandler[F]): StreamingMcpServer[F] = copy(completion = Some(handler)) - def withLogging(handler: SetLevelHandler[F]): StreamingMcpServer[F] = copy(setLevel = Some(handler)) - def withSubscriptions(handler: ResourceSubscriptions[F]): StreamingMcpServer[F] = copy(subscriptions = Some(handler)) - - /** Build the SSE-capable Tapir endpoint serving this MCP server at the given path, using the given per-effect streaming implementation. */ - def streamingEndpoint[S](path: List[String], streaming: McpStreaming[F, S]): ServerEndpoint[S, F] = + def name(value: String): StreamingMcpServer[F] = + copy(name = value) + + def version(value: String): StreamingMcpServer[F] = + copy(version = value) + + def instructions(value: String): StreamingMcpServer[F] = + copy(instructions = Some(value)) + + def withJsonSchemaMetadata(value: Boolean): StreamingMcpServer[F] = + copy(showJsonSchemaMetadata = value) + + def withOriginCheck(value: OriginCheck): StreamingMcpServer[F] = + copy(originCheck = value) + + def addTool(tool: ServerTool[?, F, ServerContext[F]]): StreamingMcpServer[F] = + copy(tools = tools :+ tool) + + def addTools(tools: ServerTool[?, F, ServerContext[F]]*): StreamingMcpServer[F] = + copy(tools = this.tools ++ tools) + + def addStreamingTool(tool: ServerTool[?, F, StreamingServerContext[F]]): StreamingMcpServer[F] = + copy(tools = tools :+ tool) + + def addStreamingTools(tools: ServerTool[?, F, StreamingServerContext[F]]*): StreamingMcpServer[F] = + copy(tools = this.tools ++ tools) + + def addPrompt(prompt: ServerPrompt[F]): StreamingMcpServer[F] = + copy(prompts = prompts :+ prompt) + + def addPrompts(prompts: ServerPrompt[F]*): StreamingMcpServer[F] = + copy(prompts = this.prompts ++ prompts) + + def addResource(resource: ServerResource[F]): StreamingMcpServer[F] = + copy(resources = resources :+ resource) + + def addResources(resources: ServerResource[F]*): StreamingMcpServer[F] = + copy(resources = this.resources ++ resources) + + def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): StreamingMcpServer[F] = + copy(resourceTemplates = resourceTemplates :+ resourceTemplate) + + def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): StreamingMcpServer[F] = + copy(resourceTemplates = this.resourceTemplates ++ resourceTemplates) + + def withCompletion(handler: CompletionHandler[F]): StreamingMcpServer[F] = + copy(completion = Some(handler)) + + def withLoggingLevel(handler: SetLoggingLevelHandler[F]): StreamingMcpServer[F] = + copy(loggingLevel = Some(handler)) + + def withSubscriptions(handler: ResourceSubscriptions[F]): StreamingMcpServer[F] = + copy(subscriptions = Some(handler)) + + def streamingEndpoint[S](path: List[String], streaming: McpServerStreaming[F, S]): ServerEndpoint[S, F] = buildStreamingEndpoint(this, streaming, path) diff --git a/server/src/main/scala/chimp/server/McpServerStreaming.scala b/server/src/main/scala/chimp/server/McpServerStreaming.scala new file mode 100644 index 0000000..347fd46 --- /dev/null +++ b/server/src/main/scala/chimp/server/McpServerStreaming.scala @@ -0,0 +1,16 @@ +package chimp.server + +import chimp.protocol.JSONRPCMessage +import io.circe.Json +import sttp.capabilities.Streams +import sttp.tapir.StreamBodyIO + +abstract class McpServerStreaming[F[_], S]: + val streams: Streams[S] + type EventStream + def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] + def emptyEvents: EventStream + def eventStream(handle: OutboundSink[F] => F[Option[Json]]): F[EventStream] + +trait OutboundSink[F[_]]: + def send(message: JSONRPCMessage): F[Unit] diff --git a/server/src/main/scala/chimp/server/McpStreaming.scala b/server/src/main/scala/chimp/server/McpStreaming.scala deleted file mode 100644 index ebce0cb..0000000 --- a/server/src/main/scala/chimp/server/McpStreaming.scala +++ /dev/null @@ -1,32 +0,0 @@ -package chimp.server - -import io.circe.Json -import sttp.capabilities.Streams -import sttp.tapir.StreamBodyIO - -/** The per-effect primitive needed to serve MCP over Server-Sent Events. A concrete implementation supplies the Tapir SSE response body and - * a way to turn a request handler — which may emit server→client messages through an [[OutboundSink]] while running — into the effect's - * native stream of [[ServerSentEvent]]s. Concrete implementations live in effect-specific modules (e.g. `chimp-server-zio`). - * - * @tparam F - * the effect type - * @tparam S - * the Tapir/sttp streaming capability (e.g. `ZioStreams`) - */ -abstract class McpStreaming[F[_], S]: - val streams: Streams[S] - - /** The effect's native stream of Server-Sent Events (e.g. `zio.stream.Stream[Throwable, ServerSentEvent]`). */ - type EventStream - - /** The Tapir output body describing an SSE (`text/event-stream`) response carrying [[EventStream]]. */ - def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] - - /** An empty event stream, used for non-2xx responses (e.g. a rejected request). */ - def emptyEvents: EventStream - - /** Run `handle` — which receives an [[OutboundSink]] to push notifications onto while it works and returns the final JSON-RPC response - * body (or `None` for a notification) — and produce the SSE event stream: each pushed message becomes an event, followed by the final - * response event, after which the stream completes. - */ - def eventStream(handle: OutboundSink[F] => F[Option[Json]]): F[EventStream] diff --git a/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala b/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala index 5f54581..5b07be7 100644 --- a/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala +++ b/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala @@ -2,24 +2,20 @@ package chimp.server import chimp.protocol.ProgressToken import io.circe.Json +import sttp.model.{Header, HeaderNames, StatusCode} import sttp.monad.MonadError import sttp.monad.syntax.* import sttp.tapir.* import sttp.tapir.json.circe.* import sttp.tapir.server.ServerEndpoint -import sttp.model.{Header, StatusCode} -/** Builds the SSE-capable endpoint: a POST that responds with `text/event-stream`. Progress and log notifications emitted by the tool logic - * during the call are streamed as events, followed by the final JSON-RPC response event. A request rejected by the [[OriginCheck]] gets a - * `403` with an empty stream. - */ private[server] def buildStreamingEndpoint[F[_], S]( server: StreamingMcpServer[F], - streaming: McpStreaming[F, S], + streaming: McpServerStreaming[F, S], path: List[String] ): ServerEndpoint[S, F] = val handler = new McpHandler[F, StreamingServerContext[F]](server) - val e = infallibleEndpoint.post + val endpoint = infallibleEndpoint.post .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) .in(extractFromRequest(_.headers)) .in(jsonBody[Json]) @@ -27,12 +23,12 @@ private[server] def buildStreamingEndpoint[F[_], S]( .out(streaming.sseBody) ServerEndpoint.public( - e, + endpoint, me => { (input: (Seq[Header], Json)) => val (headers, json) = input given MonadError[F] = me - val host = headers.find(_.name.equalsIgnoreCase("Host")).map(_.value) - val origin = headers.find(_.name.equalsIgnoreCase("Origin")).map(_.value) + val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, streaming.emptyEvents))) else streaming diff --git a/server/src/main/scala/chimp/server/OriginCheck.scala b/server/src/main/scala/chimp/server/OriginCheck.scala new file mode 100644 index 0000000..8321086 --- /dev/null +++ b/server/src/main/scala/chimp/server/OriginCheck.scala @@ -0,0 +1,23 @@ +package chimp.server + +case class OriginCheck(allowedHosts: Set[String], enabled: Boolean = true): + def validate(host: Option[String], origin: Option[String]): Boolean = + if !enabled then true + else host.forall(allowed) && origin.forall(allowed) + + private def allowed(headerValue: String): Boolean = allowedHosts.contains(OriginCheck.hostName(headerValue)) + +object OriginCheck: + private val localhostHosts: Set[String] = Set("localhost", "127.0.0.1", "[::1]", "::1") + + val localhostOnly: OriginCheck = OriginCheck(localhostHosts) + val disabled: OriginCheck = OriginCheck(Set.empty, enabled = false) + + private def hostName(headerValue: String): String = + val trimmed = headerValue.trim + val schemeIdx = trimmed.indexOf("://") + val authority = if schemeIdx >= 0 then trimmed.substring(schemeIdx + 3) else trimmed + if authority.startsWith("[") then + val close = authority.indexOf("]") + if close >= 0 then authority.substring(0, close + 1) else "" + else authority.takeWhile(_ != ':') diff --git a/server/src/main/scala/chimp/server/OutboundSink.scala b/server/src/main/scala/chimp/server/OutboundSink.scala deleted file mode 100644 index 1d43af4..0000000 --- a/server/src/main/scala/chimp/server/OutboundSink.scala +++ /dev/null @@ -1,9 +0,0 @@ -package chimp.server - -import chimp.protocol.JSONRPCMessage - -/** A write-end for server→client messages emitted while a request is being handled on a streaming endpoint (notifications, and — later — - * server-initiated requests). The concrete realization is provided by a per-effect [[McpStreaming]] implementation. - */ -trait OutboundSink[F[_]]: - def send(message: JSONRPCMessage): F[Unit] diff --git a/server/src/main/scala/chimp/server/Prompt.scala b/server/src/main/scala/chimp/server/Prompt.scala index af3bccb..15963dd 100644 --- a/server/src/main/scala/chimp/server/Prompt.scala +++ b/server/src/main/scala/chimp/server/Prompt.scala @@ -2,33 +2,33 @@ package chimp.server import chimp.protocol.{GetPromptResult, Prompt, PromptArgument} import sttp.model.Header -import sttp.shared.Identity -/** Creates a new MCP prompt description with the given name. */ def prompt(name: String): PartialPrompt = PartialPrompt(name) -/** Describes a prompt before its logic is specified. */ case class PartialPrompt( name: String, title: Option[String] = None, description: Option[String] = None, arguments: List[PromptArgument] = Nil ): - def title(value: String): PartialPrompt = copy(title = Some(value)) - def description(value: String): PartialPrompt = copy(description = Some(value)) + def title(value: String): PartialPrompt = + copy(title = Some(value)) + + def description(value: String): PartialPrompt = + copy(description = Some(value)) + def argument(name: String, description: Option[String] = None, required: Boolean = false): PartialPrompt = copy(arguments = arguments :+ PromptArgument(name, description, required = Some(required))) - def arguments(args: PromptArgument*): PartialPrompt = copy(arguments = arguments ++ args) - /** Combine the prompt description with logic that, given the resolved arguments, produces the prompt messages in the F-effect. */ - def get[F[_]](logic: Map[String, String] => F[GetPromptResult]): ServerPrompt[F] = - ServerPrompt(definition, (args, _) => logic(args)) + def arguments(args: PromptArgument*): PartialPrompt = + copy(arguments = arguments ++ args) - /** Same as [[get]], but with synchronous logic. */ - def handle(logic: Map[String, String] => GetPromptResult): ServerPrompt[Identity] = + def serverLogic[F[_]](logic: Map[String, String] => F[GetPromptResult]): ServerPrompt[F] = ServerPrompt(definition, (args, _) => logic(args)) - private def definition: Prompt = Prompt(name, title, description, Option.when(arguments.nonEmpty)(arguments)) + private def definition: Prompt = + Prompt(name, title, description, Option.when(arguments.nonEmpty)(arguments)) + +end PartialPrompt -/** A prompt that can be retrieved from the MCP server. */ case class ServerPrompt[F[_]](definition: Prompt, logic: (Map[String, String], Seq[Header]) => F[GetPromptResult]) diff --git a/server/src/main/scala/chimp/server/Resource.scala b/server/src/main/scala/chimp/server/Resource.scala index d0c792d..2d95c4b 100644 --- a/server/src/main/scala/chimp/server/Resource.scala +++ b/server/src/main/scala/chimp/server/Resource.scala @@ -1,4 +1,3 @@ - package chimp.server import chimp.protocol.{Resource, ResourceContents, ResourceTemplate} @@ -7,17 +6,12 @@ import sttp.shared.Identity import java.util.regex.Pattern import scala.util.matching.Regex -/** A failure to read a resource. Surfaced to the client as a JSON-RPC `-32602` error; the optional `uri` is included in the error `data`. - */ case class ResourceError(message: String, uri: Option[String] = None) -/** Creates a new static MCP resource description for the given URI. */ def resource(uri: String): PartialResource = PartialResource(uri) -/** Creates a new MCP resource template description for the given RFC-6570 level-1 URI template (e.g. `test://item/{id}`). */ def resourceTemplate(uriTemplate: String): PartialResourceTemplate = PartialResourceTemplate(uriTemplate) -/** Describes a static resource before its read logic is specified. */ case class PartialResource( uri: String, name: Option[String] = None, @@ -26,26 +20,33 @@ case class PartialResource( mimeType: Option[String] = None, size: Option[Long] = None ): - def name(value: String): PartialResource = copy(name = Some(value)) - def title(value: String): PartialResource = copy(title = Some(value)) - def description(value: String): PartialResource = copy(description = Some(value)) - def mimeType(value: String): PartialResource = copy(mimeType = Some(value)) - def size(value: Long): PartialResource = copy(size = Some(value)) + def name(value: String): PartialResource = + copy(name = Some(value)) + + def title(value: String): PartialResource = + copy(title = Some(value)) + + def description(value: String): PartialResource = + copy(description = Some(value)) + + def mimeType(value: String): PartialResource = + copy(mimeType = Some(value)) + + def size(value: Long): PartialResource = + copy(size = Some(value)) - /** Combine the resource description with logic that reads its contents in the F-effect. */ def read[F[_]](logic: () => F[Either[ResourceError, List[ResourceContents]]]): ServerResource[F] = ServerResource(definition, logic) - /** Same as [[read]], but with synchronous logic. */ def handle(logic: () => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = ServerResource(definition, logic) private def definition: Resource = Resource(uri, name.getOrElse(uri), title, description, mimeType, size) -/** A static resource that can be read from the MCP server. */ +end PartialResource + case class ServerResource[F[_]](definition: Resource, read: () => F[Either[ResourceError, List[ResourceContents]]]) -/** Describes a resource template before its read logic is specified. */ case class PartialResourceTemplate( uriTemplate: String, name: Option[String] = None, @@ -53,33 +54,34 @@ case class PartialResourceTemplate( description: Option[String] = None, mimeType: Option[String] = None ): - def name(value: String): PartialResourceTemplate = copy(name = Some(value)) - def title(value: String): PartialResourceTemplate = copy(title = Some(value)) - def description(value: String): PartialResourceTemplate = copy(description = Some(value)) - def mimeType(value: String): PartialResourceTemplate = copy(mimeType = Some(value)) + def name(value: String): PartialResourceTemplate = + copy(name = Some(value)) + + def title(value: String): PartialResourceTemplate = + copy(title = Some(value)) + + def description(value: String): PartialResourceTemplate = + copy(description = Some(value)) - /** Combine the template with logic that reads contents for a concrete URI, given the extracted template variables and the matched URI. */ - def read[F[_]]( + def mimeType(value: String): PartialResourceTemplate = + copy(mimeType = Some(value)) + + def serverLogic[F[_]]( logic: (Map[String, String], String) => F[Either[ResourceError, List[ResourceContents]]] ): ServerResourceTemplate[F] = ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), logic) - /** Same as [[read]], but with synchronous logic. */ - def handle( - logic: (Map[String, String], String) => Either[ResourceError, List[ResourceContents]] - ): ServerResourceTemplate[Identity] = - ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), logic) + private def definition: ResourceTemplate = + ResourceTemplate(uriTemplate, name.getOrElse(uriTemplate), title, description, mimeType) - private def definition: ResourceTemplate = ResourceTemplate(uriTemplate, name.getOrElse(uriTemplate), title, description, mimeType) +end PartialResourceTemplate -/** A resource template that can be read from the MCP server. */ case class ServerResourceTemplate[F[_]]( definition: ResourceTemplate, matcher: UriTemplate, read: (Map[String, String], String) => F[Either[ResourceError, List[ResourceContents]]] ) -/** A compiled RFC-6570 level-1 URI template. `{var}` segments match a single path segment and are extracted by name. */ final class UriTemplate private (regex: Regex, names: List[String]): def matchUri(uri: String): Option[Map[String, String]] = regex.findFirstMatchIn(uri).map(m => names.zipWithIndex.map((n, i) => n -> m.group(i + 1)).toMap) @@ -91,11 +93,11 @@ object UriTemplate: val names = scala.collection.mutable.ListBuffer.empty[String] val regex = new StringBuilder("^") var last = 0 - for m <- VarPattern.findAllMatchIn(template) do - regex.append(Pattern.quote(template.substring(last, m.start))) + for `match` <- VarPattern.findAllMatchIn(template) do + regex.append(Pattern.quote(template.substring(last, `match`.start))) regex.append("([^/]+)") - names += m.group(1) - last = m.end + names += `match`.group(1) + last = `match`.end regex.append(Pattern.quote(template.substring(last))) regex.append("$") new UriTemplate(regex.toString.r, names.toList) diff --git a/server/src/main/scala/chimp/server/ServerContext.scala b/server/src/main/scala/chimp/server/ServerContext.scala index 521896e..6d325a6 100644 --- a/server/src/main/scala/chimp/server/ServerContext.scala +++ b/server/src/main/scala/chimp/server/ServerContext.scala @@ -14,20 +14,12 @@ object ServerContext: def isCancelled: F[Boolean] = m.unit(false) def onCancel(action: F[Unit]): F[Unit] = m.unit(()) -/** Adds the server→client interactions that require a live streaming connection: emitting progress and log notifications, and issuing - * sampling and elicitation requests. Tool logic using these is accepted only by the streaming endpoint; the request/response endpoint - * rejects it at compile time. - */ trait StreamingServerContext[F[_]] extends ServerContext[F]: def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] def sample(params: CreateMessageParams): F[CreateMessageResult] def elicit(params: ElicitParams): F[ElicitResult] -/** A [[StreamingServerContext]] backed by an [[OutboundSink]]: progress and log calls are emitted as JSON-RPC notifications on the request's - * SSE stream. `progressToken` is the token carried by the originating request's `_meta`, if any. Sampling and elicitation are not yet - * supported (Phase 3). - */ private[server] final class SinkStreamingServerContext[F[_]](sink: OutboundSink[F], progressToken: Option[ProgressToken])(using m: MonadError[F] ) extends StreamingServerContext[F]: @@ -38,15 +30,20 @@ private[server] final class SinkStreamingServerContext[F[_]](sink: OutboundSink[ progressToken match case Some(token) => sink.send( - JSONRPCMessage.Notification(method = "notifications/progress", params = Some(ProgressParams(token, progress, total, message).asJson)) + JSONRPCMessage.Notification( + method = "notifications/progress", + params = Some(ProgressParams(token, progress, total, message).asJson) + ) ) case None => m.unit(()) def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] = - sink.send(JSONRPCMessage.Notification(method = "notifications/message", params = Some(LoggingMessageParams(level, data, logger).asJson))) + sink.send( + JSONRPCMessage.Notification(method = "notifications/message", params = Some(LoggingMessageParams(level, data, logger).asJson)) + ) def sample(params: CreateMessageParams): F[CreateMessageResult] = - m.error(UnsupportedOperationException("server→client sampling is not yet supported")) + m.error(UnsupportedOperationException("server client sampling is not yet supported")) def elicit(params: ElicitParams): F[ElicitResult] = - m.error(UnsupportedOperationException("server→client elicitation is not yet supported")) + m.error(UnsupportedOperationException("server client elicitation is not yet supported")) diff --git a/server/src/main/scala/chimp/server/Tool.scala b/server/src/main/scala/chimp/server/Tool.scala index 0599fcf..766555d 100644 --- a/server/src/main/scala/chimp/server/Tool.scala +++ b/server/src/main/scala/chimp/server/Tool.scala @@ -15,9 +15,6 @@ case class ToolAnnotations( openWorldHint: Option[Boolean] = None ) -/** The result of a tool invocation: a list of content items (text, image, audio, embedded resource, …), an optional structured payload, and - * a flag marking the result as an error. - */ case class ToolResult( content: List[ToolContent], structuredContent: Option[Json] = None, @@ -36,38 +33,33 @@ object ToolResult: def structured[A: Encoder](value: A): ToolResult = ToolResult(Nil, structuredContent = Some(value.asJson)) def fromEither(result: Either[String, String]): ToolResult = result.fold(error, text) -/** A tool's input schema, either derived from a Tapir [[Schema]] or supplied directly as raw JSON Schema (for dialects Tapir cannot - * express, e.g. JSON Schema 2020-12 with `additionalProperties: false`). - */ enum ToolSchema: case Derived(schema: Schema[?]) case Raw(json: Json) private val ToolNameRegex = "^[A-Za-z0-9_./-]+$".r -/** Creates a new MCP tool description with the given name. The name must match `^[A-Za-z0-9_./-]+$` and be 1–64 characters long. */ def tool(name: String): PartialTool = - require(name.length >= 1 && name.length <= 64, s"Tool name must be 1..64 characters long, got ${name.length}: $name") + require(name.nonEmpty && name.length <= 64, s"Tool name must be 1..64 characters long, got ${name.length}: $name") require(ToolNameRegex.matches(name), s"Tool name must match ${ToolNameRegex.regex}, got: $name") PartialTool(name) -/** Describes a tool before the input is specified. */ case class PartialTool( name: String, description: Option[String] = None, annotations: Option[ToolAnnotations] = None ): - def description(desc: String): PartialTool = copy(description = Some(desc)) - def withAnnotations(ann: ToolAnnotations): PartialTool = copy(annotations = Some(ann)) + def description(desc: String): PartialTool = + copy(description = Some(desc)) + + def withAnnotations(ann: ToolAnnotations): PartialTool = + copy(annotations = Some(ann)) - /** Specify the input type for the tool, providing both a Tapir Schema and a Circe Decoder. */ def input[I: Schema: Decoder]: Tool[I] = Tool[I](name, description, ToolSchema.Derived(summon[Schema[I]]), summon[Decoder[I]], annotations) - /** Specify the tool's input schema directly as raw JSON Schema. The tool receives its arguments as raw [[Json]]. */ def inputJson(schema: Json): Tool[Json] = Tool[Json](name, description, ToolSchema.Raw(schema), summon[Decoder[Json]], annotations) -/** Describes a tool after the input is specified. */ case class Tool[I]( name: String, description: Option[String], @@ -75,32 +67,20 @@ case class Tool[I]( inputDecoder: Decoder[I], annotations: Option[ToolAnnotations] ): - /** Combine the tool description with the server logic, executed when the tool is invoked. The logic, given the input, a request-scoped - * [[ServerContext]], and the request headers, returns a [[ToolResult]] in the F-effect. - */ def serverLogic[F[_]](logic: (I, ServerContext[F], Seq[Header]) => F[ToolResult]): ServerTool[I, F, ServerContext[F]] = ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) - /** Like [[serverLogic]], but the logic receives a [[StreamingServerContext]], so it may report progress, log, and issue sampling and - * elicitation requests. Tools defined this way are accepted only by the streaming endpoint. - */ def streamingServerLogic[F[_]]( logic: (I, StreamingServerContext[F], Seq[Header]) => F[ToolResult] ): ServerTool[I, F, StreamingServerContext[F]] = ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) - /** Combine the tool description with synchronous server logic that also receives the request headers. */ def handleWithHeaders(logic: (I, Seq[Header]) => ToolResult): ServerTool[I, Identity, ServerContext[Identity]] = ServerTool(name, description, inputSchema, inputDecoder, annotations, (i, _, headers) => logic(i, headers)) - /** Combine the tool description with synchronous server logic. Same as [[handleWithHeaders]], but without access to the headers. */ def handle(logic: I => ToolResult): ServerTool[I, Identity, ServerContext[Identity]] = handleWithHeaders((i, _) => logic(i)) -/** A tool that can be executed by the MCP server. The context type `C` records which server capabilities the logic requires: a tool needing - * only the base [[ServerContext]] runs on any server, while one needing a [[StreamingServerContext]] is rejected by the request/response - * endpoint at compile time. - */ case class ServerTool[I, F[_], -C <: ServerContext[F]]( name: String, description: Option[String], diff --git a/server/src/test/scala/chimp/server/McpHandlerSpec.scala b/server/src/test/scala/chimp/server/McpHandlerSpec.scala index 0d0e9b5..562a3a6 100644 --- a/server/src/test/scala/chimp/server/McpHandlerSpec.scala +++ b/server/src/test/scala/chimp/server/McpHandlerSpec.scala @@ -16,11 +16,9 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: import JSONRPCMessage.* import chimp.protocol.JSONRPCErrorCodes.* - // Simple test input types case class EchoInput(message: String) derives Schema, Codec case class AddInput(a: Int, b: Int) derives Schema, Codec - // Test tools val echoTool = tool("echo") .description("Echoes the input message.") .input[EchoInput] @@ -36,11 +34,9 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: .input[EchoInput] .handle(_ => ToolResult.error("Intentional failure")) - // Tool that echoes the header's value for testing - case class HeaderEchoInput(dummy: String) derives Schema, Codec private val headerEchoTool = tool("headerEcho") .description("Echoes the header value if present.") - .input[HeaderEchoInput] + .input[EchoInput] .handleWithHeaders { (_, headers) => if headers.isEmpty then ToolResult.text("no header") else @@ -51,9 +47,8 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: ) } - val handler = McpHandler(McpServer(name = "Chimp MCP server", tools = List(echoTool, addTool, errorTool, headerEchoTool))) + private val handler = McpHandler(McpServer(tools = List(echoTool, addTool, errorTool, headerEchoTool))) - // Feature fixtures (resources, prompts, completion, logging) private val textResource = resource("test://text") .name("text") .mimeType("text/plain") @@ -61,12 +56,12 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: private val itemTemplate = resourceTemplate("test://item/{id}") .name("item") - .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) + .serverLogic[Identity]((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) private val greetPrompt = prompt("greet") .description("Greets by name") .argument("name", required = true) - .handle(args => + .serverLogic[Identity](args => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello ${args.getOrElse("name", "?")}")))) ) @@ -77,7 +72,7 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: .addResourceTemplate(itemTemplate) .addPrompt(greetPrompt) .withCompletion((_, _, _) => Completion(values = List("Alice", "Bob"))) - .withLogging(level => levelRef.set(Some(level))) + .withLoggingLevel(level => levelRef.set(Some(level))) private val featuresHandler = McpHandler(featuresServer) @@ -85,20 +80,18 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: given MonadError[Identity] = IdentityMonad - // Helper function to extract JSON from McpResponse for testing private def extractJsonFromResponse(response: McpResponse): Json = response match case McpResponse.JsonResponse(json) => json case McpResponse.EmptyAcceptResponse => fail("Expected JsonResponse but got EmptyAcceptResponse") "McpHandler" should "respond to initialize" in: - // Given val req: JSONRPCMessage = Request(method = "initialize", id = RequestId("1")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[InitializeResult].getOrElse(fail("Failed to decode result")) @@ -110,14 +103,13 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: respJson.hcursor.downField("result").downField("instructions").focus shouldBe None it should "list available tools" in: - // Given val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("2")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[ListToolsResponse].getOrElse(fail("Failed to decode result")) @@ -125,18 +117,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "call a tool successfully (echo)" in: - // Given val params = Json.obj( "name" -> Json.fromString("echo"), "arguments" -> Json.obj("message" -> Json.fromString("hello")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("3")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -146,18 +137,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "call a tool successfully (add)" in: - // Given val params = Json.obj( "name" -> Json.fromString("add"), "arguments" -> Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3)) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("4")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -166,38 +156,31 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "accept notifications and return EmptyAcceptResponse" in: - // Given val req: JSONRPCMessage = Notification(method = "notifications/initialized") val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) - // Then - // Notifications should return EmptyAcceptResponse to indicate no body should be sent response shouldBe McpResponse.EmptyAcceptResponse it should "accept different notification types and return EmptyAcceptResponse" in: - // Given val req: JSONRPCMessage = Notification(method = "notifications/tools/list_changed") val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) - // Then - // All notifications should return EmptyAcceptResponse to indicate no body should be sent response shouldBe McpResponse.EmptyAcceptResponse it should "return an error for unknown tool" in: - // Given val params = Json.obj( "name" -> Json.fromString("unknown"), "arguments" -> Json.obj("foo" -> Json.fromString("bar")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("5")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe MethodNotFound.code @@ -205,18 +188,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "return an error for invalid arguments" in: - // Given val params = Json.obj( "name" -> Json.fromString("add"), "arguments" -> Json.obj("a" -> Json.fromString("notAnInt"), "b" -> Json.fromInt(3)) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("6")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe InvalidParams.code @@ -224,17 +206,16 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "return an error when required fields are missing (no arguments object)" in: - // Given val params = Json.obj( "name" -> Json.fromString("add") ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("7")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe InvalidParams.code @@ -242,18 +223,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "return an error for missing tool name" in: - // Given val params = Json.obj( // missing 'name' "arguments" -> Json.obj("message" -> Json.fromString("hello")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("8")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe InvalidParams.code @@ -261,18 +241,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "return an error for tool logic failure" in: - // Given val params = Json.obj( "name" -> Json.fromString("fail"), "arguments" -> Json.obj("message" -> Json.fromString("test")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("9")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -281,14 +260,13 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "return an error for unknown method" in: - // Given val req: JSONRPCMessage = Request(method = "not/a/real/method", id = RequestId("10")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe MethodNotFound.code @@ -296,18 +274,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "call a tool with a header and receive the header's value in the response" in: - // Given val params = Json.obj( "name" -> Json.fromString("headerEcho"), - "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) + "arguments" -> Json.obj("message" -> Json.fromString("irrelevant")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header1")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq(Header("header-name", "my-secret-header"))) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -316,10 +293,9 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "call a tool with a header and receive multiple header's values in the response" in: - // Given val params = Json.obj( "name" -> Json.fromString("headerEcho"), - "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) + "arguments" -> Json.obj("message" -> Json.fromString("irrelevant")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header1")) val json = req.asJson @@ -340,18 +316,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "call a tool without a header value and receive 'no header' in the response" in: - // Given val params = Json.obj( "name" -> Json.fromString("headerEcho"), - "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) + "arguments" -> Json.obj("message" -> Json.fromString("irrelevant")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header2")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -360,22 +335,21 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "not use type arrays for optional fields in JSON schema" in: - // Given - a tool with optional fields case class OptionalFieldInput(requiredField: String, optionalField: Option[Long]) derives Schema, Codec val optionalTool = tool("optionalTest") .description("Test tool with optional fields.") .input[OptionalFieldInput] .handle(_ => ToolResult.text("ok")) - val handlerWithOptional = McpHandler(McpServer(name = "Test", tools = List(optionalTool))) + val handlerWithOptional = McpHandler(McpServer(tools = List(optionalTool))) val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("opt1")) val json = req.asJson - // When + val response = handlerWithOptional.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[ListToolsResponse].getOrElse(fail("Failed to decode result")) diff --git a/server/src/test/scala/chimp/server/StreamingMcpServerTests.scala b/server/src/test/scala/chimp/server/McpServerStreamingTests.scala similarity index 72% rename from server/src/test/scala/chimp/server/StreamingMcpServerTests.scala rename to server/src/test/scala/chimp/server/McpServerStreamingTests.scala index 83e23c5..da568d6 100644 --- a/server/src/test/scala/chimp/server/StreamingMcpServerTests.scala +++ b/server/src/test/scala/chimp/server/McpServerStreamingTests.scala @@ -14,10 +14,7 @@ import java.util.concurrent.ConcurrentLinkedQueue import scala.concurrent.Future import scala.jdk.CollectionConverters.* -/** Tests for server→client streaming behavior: notifications emitted by tool logic mid-call must reach the client over the open SSE stream - * before the call's final result. Run only by streaming-capable backends. - */ -trait StreamingMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers: +trait McpServerStreamingTests[F[_]] extends AsyncFlatSpec with Matchers: this: ToFuture[F] => protected def withStreamingServer(server: StreamingMcpServer[F])(test: BidirectionalMcpClient[F] => F[Assertion]): Future[Assertion] @@ -26,17 +23,17 @@ trait StreamingMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers: protected def streamingServer: StreamingMcpServer[F] = StreamingMcpServer[F]() - .withLogging(_ => monad.unit(())) + .withLoggingLevel(_ => monad.unit(())) .addStreamingTool( tool("noisy") .description("Logs several messages, then returns") .input[NoInput] .streamingServerLogic[F]: (_, ctx, _) => - ctx - .log(LoggingLevel.Info, Json.fromString("one")) - .flatMap(_ => ctx.log(LoggingLevel.Info, Json.fromString("two"))) - .flatMap(_ => ctx.log(LoggingLevel.Info, Json.fromString("three"))) - .map(_ => ToolResult.text("done")) + for + _ <- ctx.log(LoggingLevel.Info, Json.fromString("one")) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("two")) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("three")) + yield ToolResult.text("done") ) "a streaming MCP server" should "deliver log notifications emitted during a tool call" in @@ -47,7 +44,7 @@ trait StreamingMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers: case _ => monad.unit(()) } client - .onServerNotification(n => listener(n)) + .onServerNotification(notification => listener(notification)) .flatMap(_ => client.callTool("noisy", Json.obj())) .flatMap: result => waitUntil(messages.size >= 3).map: _ => diff --git a/server/src/test/scala/chimp/server/McpServerTests.scala b/server/src/test/scala/chimp/server/McpServerTests.scala index a7b1c4f..bcd0fb8 100644 --- a/server/src/test/scala/chimp/server/McpServerTests.scala +++ b/server/src/test/scala/chimp/server/McpServerTests.scala @@ -11,10 +11,6 @@ import sttp.tapir.Schema import scala.concurrent.Future -/** Backend-agnostic tests exercising an MCP server over a real transport, driven by the chimp client. A concrete spec provides [[withServer]] - * (host the server on some Tapir backend, connect a client) and a [[ToFuture]] for the effect. These cover the request/response surface and - * run against any backend. - */ trait McpServerTests[F[_]] extends AsyncFlatSpec with Matchers: this: ToFuture[F] => @@ -33,12 +29,18 @@ trait McpServerTests[F[_]] extends AsyncFlatSpec with Matchers: .addResource( resource("test://greeting") .mimeType("text/plain") - .read[F](() => monad.unit(Right(List(ResourceContents.Text(uri = "test://greeting", text = "hello", mimeType = Some("text/plain")))))) + .read[F](() => + monad.unit(Right(List(ResourceContents.Text(uri = "test://greeting", text = "hello", mimeType = Some("text/plain"))))) + ) ) .addPrompt( prompt("greet") .argument("name", required = true) - .get[F](args => monad.unit(GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hi ${args.getOrElse("name", "?")}")))))) + .serverLogic[F](args => + monad.unit( + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hi ${args.getOrElse("name", "?")}")))) + ) + ) ) .withCompletion((_, _, _) => monad.unit(Completion(values = List("alpha", "beta")))) @@ -53,22 +55,30 @@ trait McpServerTests[F[_]] extends AsyncFlatSpec with Matchers: client.serverCapabilities.completions shouldBe defined it should "execute a tool call" in withServer(sampleServer): client => - client.callTool("echo", Json.obj("message" -> Json.fromString("hi"))).map: result => - result.isError shouldBe false - result.content shouldBe List(ToolContent.Text("text", "hi")) + client + .callTool("echo", Json.obj("message" -> Json.fromString("hi"))) + .map: result => + result.isError shouldBe false + result.content shouldBe List(ToolContent.Text("text", "hi")) it should "read a resource" in withServer(sampleServer): client => - client.readResource("test://greeting").map: result => - result.contents.head match - case ResourceContents.Text(_, text, _, _) => text shouldBe "hello" - case other => fail(s"expected text contents, got $other") + client + .readResource("test://greeting") + .map: result => + result.contents.head match + case ResourceContents.Text(_, text, _, _) => text shouldBe "hello" + case other => fail(s"expected text contents, got $other") it should "get a prompt with arguments" in withServer(sampleServer): client => - client.getPrompt("greet", Map("name" -> "World")).map: result => - result.messages.head.content match - case ToolContent.Text(_, text) => text should include("World") - case other => fail(s"expected text content, got $other") + client + .getPrompt("greet", Map("name" -> "World")) + .map: result => + result.messages.head.content match + case ToolContent.Text(_, text) => text should include("World") + case other => fail(s"expected text content, got $other") it should "return completion suggestions" in withServer(sampleServer): client => - client.complete(CompleteRef.Prompt(PromptReference(name = "greet")), CompleteArgument("name", "W")).map: result => - result.completion.values shouldBe List("alpha", "beta") + client + .complete(CompleteRef.Prompt(PromptReference(name = "greet")), CompleteArgument("name", "W")) + .map: result => + result.completion.values shouldBe List("alpha", "beta") diff --git a/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala b/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala index 191e6a7..f5a9dfc 100644 --- a/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala +++ b/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala @@ -11,7 +11,6 @@ import sttp.tapir.server.netty.sync.NettySyncServer import scala.concurrent.Future -/** Runs the generic server tests against a [[NettySyncServer]] (Identity effect), driven by the chimp synchronous HTTP client. */ class SyncHttpMcpServerSpec extends McpServerTests[Identity] with SyncToFuture: private val clientInfo = Implementation("chimp-server-test", "0.0.1") diff --git a/server/src/test/scala/chimp/server/ToFuture.scala b/server/src/test/scala/chimp/server/ToFuture.scala index d9c17b8..6faec17 100644 --- a/server/src/test/scala/chimp/server/ToFuture.scala +++ b/server/src/test/scala/chimp/server/ToFuture.scala @@ -5,15 +5,11 @@ import sttp.monad.syntax.* import scala.concurrent.Future -/** Bridges an effect `F` to `scala.concurrent.Future` so the same effect-polymorphic test bodies can run under `AsyncFlatSpec` for any - * effect (e.g. `Identity`, `Task`). Mirrors the client test suite's `ToFuture`. - */ trait ToFuture[F[_]]: given monad: MonadError[F] def toFuture[A](fa: F[A]): Future[A] def sleep(millis: Long): F[Unit] - /** Re-checks `condition` (which is expected to flip via a side effect) until it holds or attempts run out. */ def waitUntil(condition: => Boolean, attempts: Int = 100, intervalMs: Long = 20): F[Unit] = if condition || attempts <= 0 then monad.unit(()) else sleep(intervalMs).flatMap(_ => waitUntil(condition, attempts - 1, intervalMs)) From 954a938993cc6b732912924bd425ee8de6873b91 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 16 Jun 2026 19:24:52 +0200 Subject: [PATCH 06/20] refactor: clear close semantics for zio server streaming --- .../server/zio/ZioMcpServerStreaming.scala | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala index 0ee1072..8bf7726 100644 --- a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala @@ -28,16 +28,19 @@ object ZioMcpServerStreaming extends McpServerStreaming[Task, ZioStreams]: ZIO.succeed { ZStream.unwrap { for - queue <- Queue.unbounded[Option[ServerSentEvent]] + queue <- Queue.unbounded[Outbound] sink = new OutboundSink[Task]: def send(message: JSONRPCMessage): Task[Unit] = - queue.offer(Some(ServerSentEvent(data = Some(message.asJson.noSpaces)))).unit + queue.offer(Outbound.Message(message.asJson)).unit _ <- handle(sink) - .flatMap { message => - ZIO.foreachDiscard(message)(message => queue.offer(Some(ServerSentEvent(data = Some(message.noSpaces))))) *> queue.offer(None) - } - .catchAllCause(_ => queue.offer(None).unit) + .flatMap(response => ZIO.foreachDiscard(response)(json => queue.offer(Outbound.Message(json)))) + .ensuring(queue.offer(Outbound.Close)) + .catchAllCause(_ => ZIO.unit) .forkDaemon - yield ZStream.fromQueue(queue).takeWhile(_.isDefined).collectSome + yield ZStream.fromQueue(queue).collectWhile { case Outbound.Message(json) => ServerSentEvent(data = Some(json.noSpaces)) } } } + + private enum Outbound: + case Message(json: Json) + case Close From ee618406d78bed293185a15ece5cd1806fc49803 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Wed, 17 Jun 2026 18:34:42 +0200 Subject: [PATCH 07/20] refactor: rename --- .../src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala | 2 +- server/src/main/scala/chimp/server/McpServerStreaming.scala | 2 +- server/src/main/scala/chimp/server/McpStreamingEndpoint.scala | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala index 8bf7726..39339f7 100644 --- a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala @@ -22,7 +22,7 @@ object ZioMcpServerStreaming extends McpServerStreaming[Task, ZioStreams]: streamTextBody(ZioStreams)(CodecFormat.TextEventStream(), Some(StandardCharsets.UTF_8)) .map(ZioServerSentEvents.parseBytesToSSE)(ZioServerSentEvents.serialiseSSEToBytes) - val emptyEvents: EventStream = ZStream.empty + val emptyStream: EventStream = ZStream.empty def eventStream(handle: OutboundSink[Task] => Task[Option[Json]]): Task[EventStream] = ZIO.succeed { diff --git a/server/src/main/scala/chimp/server/McpServerStreaming.scala b/server/src/main/scala/chimp/server/McpServerStreaming.scala index 347fd46..890abf3 100644 --- a/server/src/main/scala/chimp/server/McpServerStreaming.scala +++ b/server/src/main/scala/chimp/server/McpServerStreaming.scala @@ -9,7 +9,7 @@ abstract class McpServerStreaming[F[_], S]: val streams: Streams[S] type EventStream def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] - def emptyEvents: EventStream + def emptyStream: EventStream def eventStream(handle: OutboundSink[F] => F[Option[Json]]): F[EventStream] trait OutboundSink[F[_]]: diff --git a/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala b/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala index 5b07be7..270cd69 100644 --- a/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala +++ b/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala @@ -29,7 +29,7 @@ private[server] def buildStreamingEndpoint[F[_], S]( given MonadError[F] = me val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) - if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, streaming.emptyEvents))) + if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, streaming.emptyStream))) else streaming .eventStream { sink => From fa484c49a356bdc5bc89a42abff766359fab231f Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Wed, 17 Jun 2026 18:55:15 +0200 Subject: [PATCH 08/20] feat: very basic scala doc for public API, inline logic handlers across server methods --- .../scala/chimp/conformance/server/Main.scala | 14 +++---- .../main/scala/chimp/server/McpHandler.scala | 8 ++-- .../src/main/scala/chimp/server/Prompt.scala | 19 +++++++++- .../main/scala/chimp/server/Resource.scala | 38 ++++++++++++++++--- server/src/main/scala/chimp/server/Tool.scala | 15 ++++++++ .../scala/chimp/server/McpHandlerSpec.scala | 4 +- .../scala/chimp/server/McpServerTests.scala | 4 +- 7 files changed, 78 insertions(+), 24 deletions(-) diff --git a/server-conformance/src/main/scala/chimp/conformance/server/Main.scala b/server-conformance/src/main/scala/chimp/conformance/server/Main.scala index 87bcece..eaa3c9c 100644 --- a/server-conformance/src/main/scala/chimp/conformance/server/Main.scala +++ b/server-conformance/src/main/scala/chimp/conformance/server/Main.scala @@ -123,28 +123,26 @@ object Main: private val dataTemplate = resourceTemplate("test://template/{id}/data") .name("data-template") .mimeType("text/plain") - .serverLogic[Identity]((vars, uri) => + .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"data for ${vars.getOrElse("id", "?")}", mimeType = Some("text/plain")))) ) private val simplePrompt = prompt("test_simple_prompt") .description("A simple prompt") - .serverLogic[Identity](_ => - GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = "This is a simple prompt.")))) - ) + .handle(_ => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = "This is a simple prompt."))))) private val argsPrompt = prompt("test_prompt_with_arguments") .description("A prompt with arguments") .argument("arg1", required = true) .argument("arg2", required = true) - .serverLogic[Identity]: args => + .handle: args => val text = s"arg1=${args.getOrElse("arg1", "")}, arg2=${args.getOrElse("arg2", "")}" GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = text)))) private val embeddedResourcePrompt = prompt("test_prompt_with_embedded_resource") .description("A prompt embedding a resource") .argument("resourceUri", required = true) - .serverLogic[Identity]: args => + .handle: args => val uri = args.getOrElse("resourceUri", "test://example-resource") GetPromptResult(messages = List( @@ -159,9 +157,7 @@ object Main: private val imagePrompt = prompt("test_prompt_with_image") .description("A prompt with an image") - .serverLogic[Identity](_ => - GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Image(data = pngData, mimeType = "image/png")))) - ) + .handle(_ => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Image(data = pngData, mimeType = "image/png"))))) private val server = McpServer[Identity](name = "chimp-conformance-server", version = "0.1.0") .addTools(addNumbers, simpleText, errorTool, imageTool, audioTool, mixedTool, embeddedResourceTool, jsonSchemaTool) diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index e3476d9..8de5c5d 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -87,7 +87,7 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD JSONRPCMessage.Response(id = id, result = ListResourceTemplatesResult(server.resourceTemplates.map(_.definition)).asJson) ).unit case "resources/read" if hasResources => - handleResourcesRead(params, id).map(jsonResponse) + 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 => @@ -172,16 +172,16 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD ).asJson ) - private def handleResourcesRead(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = + private def handleResourcesRead(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] = decodeParams[ReadResourceParams](params, id): params => resourcesByUri.get(params.uri) match - case Some(resource) => resource.read().map(resourceReadResponse(id, params.uri)) + case Some(resource) => resource.read(headers).map(resourceReadResponse(id, params.uri)) case None => val templateMatch = server.resourceTemplates.iterator .map(template => (template, template.matcher.matchUri(params.uri))) .collectFirst { case (template, Some(vars)) => (template, vars) } templateMatch match - case Some((template, vars)) => template.read(vars, params.uri).map(resourceReadResponse(id, params.uri)) + case Some((template, vars)) => template.read(vars, params.uri, headers).map(resourceReadResponse(id, params.uri)) case None => protocolError( id, diff --git a/server/src/main/scala/chimp/server/Prompt.scala b/server/src/main/scala/chimp/server/Prompt.scala index 15963dd..6e42081 100644 --- a/server/src/main/scala/chimp/server/Prompt.scala +++ b/server/src/main/scala/chimp/server/Prompt.scala @@ -2,9 +2,12 @@ package chimp.server import chimp.protocol.{GetPromptResult, Prompt, PromptArgument} import sttp.model.Header +import sttp.shared.Identity +/** Starts defining a prompt with the given name. */ def prompt(name: String): PartialPrompt = PartialPrompt(name) +/** A prompt being defined, before its logic is attached. */ case class PartialPrompt( name: String, title: Option[String] = None, @@ -17,18 +20,30 @@ case class PartialPrompt( def description(value: String): PartialPrompt = copy(description = Some(value)) + /** Declares a single argument the prompt accepts. */ def argument(name: String, description: Option[String] = None, required: Boolean = false): PartialPrompt = copy(arguments = arguments :+ PromptArgument(name, description, required = Some(required))) + /** Declares multiple arguments the prompt accepts. */ def arguments(args: PromptArgument*): PartialPrompt = copy(arguments = arguments ++ args) - def serverLogic[F[_]](logic: Map[String, String] => F[GetPromptResult]): ServerPrompt[F] = - ServerPrompt(definition, (args, _) => logic(args)) + /** Attaches effectful logic, with access to the request headers, producing the prompt's messages. */ + def serverLogic[F[_]](logic: (Map[String, String], Seq[Header]) => F[GetPromptResult]): ServerPrompt[F] = + ServerPrompt(definition, logic) + + /** Attaches synchronous logic that also receives the request headers. */ + def handleWithHeaders(logic: (Map[String, String], Seq[Header]) => GetPromptResult): ServerPrompt[Identity] = + ServerPrompt(definition, logic) + + /** Attaches synchronous logic over just the supplied argument values. */ + def handle(logic: Map[String, String] => GetPromptResult): ServerPrompt[Identity] = + handleWithHeaders((args, _) => logic(args)) private def definition: Prompt = Prompt(name, title, description, Option.when(arguments.nonEmpty)(arguments)) end PartialPrompt +/** A fully-defined prompt: its metadata plus the logic producing its messages. */ case class ServerPrompt[F[_]](definition: Prompt, logic: (Map[String, String], Seq[Header]) => F[GetPromptResult]) diff --git a/server/src/main/scala/chimp/server/Resource.scala b/server/src/main/scala/chimp/server/Resource.scala index 2d95c4b..60ec2d2 100644 --- a/server/src/main/scala/chimp/server/Resource.scala +++ b/server/src/main/scala/chimp/server/Resource.scala @@ -1,17 +1,22 @@ package chimp.server import chimp.protocol.{Resource, ResourceContents, ResourceTemplate} +import sttp.model.Header import sttp.shared.Identity import java.util.regex.Pattern import scala.util.matching.Regex +/** An error returned when reading a resource fails, optionally naming the offending URI. */ case class ResourceError(message: String, uri: Option[String] = None) +/** Starts defining a resource served at the given fixed URI. */ def resource(uri: String): PartialResource = PartialResource(uri) +/** Starts defining a resource template whose URI carries `{variable}` placeholders. */ def resourceTemplate(uriTemplate: String): PartialResourceTemplate = PartialResourceTemplate(uriTemplate) +/** A resource being defined, before its read logic is attached. */ case class PartialResource( uri: String, name: Option[String] = None, @@ -35,18 +40,26 @@ case class PartialResource( def size(value: Long): PartialResource = copy(size = Some(value)) - def read[F[_]](logic: () => F[Either[ResourceError, List[ResourceContents]]]): ServerResource[F] = + /** Attaches effectful logic, with access to the request headers, producing the resource's contents (or an error). */ + def serverLogic[F[_]](logic: Seq[Header] => F[Either[ResourceError, List[ResourceContents]]]): ServerResource[F] = ServerResource(definition, logic) - def handle(logic: () => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = + /** Attaches synchronous logic that also receives the request headers. */ + def handleWithHeaders(logic: Seq[Header] => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = ServerResource(definition, logic) + /** Attaches synchronous logic producing the resource's contents (or an error). */ + def handle(logic: () => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = + handleWithHeaders(_ => logic()) + private def definition: Resource = Resource(uri, name.getOrElse(uri), title, description, mimeType, size) end PartialResource -case class ServerResource[F[_]](definition: Resource, read: () => F[Either[ResourceError, List[ResourceContents]]]) +/** A fully-defined resource: its metadata plus the logic reading its contents. */ +case class ServerResource[F[_]](definition: Resource, read: Seq[Header] => F[Either[ResourceError, List[ResourceContents]]]) +/** A resource template being defined, before its read logic is attached. */ case class PartialResourceTemplate( uriTemplate: String, name: Option[String] = None, @@ -66,29 +79,44 @@ case class PartialResourceTemplate( def mimeType(value: String): PartialResourceTemplate = copy(mimeType = Some(value)) + /** Attaches effectful logic reading a matched URI; receives the extracted variables, the full URI, and the request headers. */ def serverLogic[F[_]]( - logic: (Map[String, String], String) => F[Either[ResourceError, List[ResourceContents]]] + logic: (Map[String, String], String, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] ): ServerResourceTemplate[F] = ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), logic) + /** Attaches synchronous logic that also receives the request headers. */ + def handleWithHeaders( + logic: (Map[String, String], String, Seq[Header]) => Either[ResourceError, List[ResourceContents]] + ): ServerResourceTemplate[Identity] = + ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), logic) + + /** Attaches synchronous logic receiving the extracted variables and the full URI. */ + def handle(logic: (Map[String, String], String) => Either[ResourceError, List[ResourceContents]]): ServerResourceTemplate[Identity] = + handleWithHeaders((vars, uri, _) => logic(vars, uri)) + private def definition: ResourceTemplate = ResourceTemplate(uriTemplate, name.getOrElse(uriTemplate), title, description, mimeType) end PartialResourceTemplate +/** A fully-defined resource template: its metadata, a compiled URI matcher, and the logic reading matched URIs. */ case class ServerResourceTemplate[F[_]]( definition: ResourceTemplate, matcher: UriTemplate, - read: (Map[String, String], String) => F[Either[ResourceError, List[ResourceContents]]] + read: (Map[String, String], String, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] ) +/** A compiled URI template that matches concrete URIs and extracts their `{variable}` values. */ final class UriTemplate private (regex: Regex, names: List[String]): + /** Returns the extracted variables if `uri` matches, or `None` otherwise. */ def matchUri(uri: String): Option[Map[String, String]] = regex.findFirstMatchIn(uri).map(m => names.zipWithIndex.map((n, i) => n -> m.group(i + 1)).toMap) object UriTemplate: private val VarPattern: Regex = "\\{([^}]+)\\}".r + /** Compiles a `{variable}` URI template into a matcher; each variable matches one path segment. */ def compile(template: String): UriTemplate = val names = scala.collection.mutable.ListBuffer.empty[String] val regex = new StringBuilder("^") diff --git a/server/src/main/scala/chimp/server/Tool.scala b/server/src/main/scala/chimp/server/Tool.scala index 766555d..e6bec1a 100644 --- a/server/src/main/scala/chimp/server/Tool.scala +++ b/server/src/main/scala/chimp/server/Tool.scala @@ -7,6 +7,7 @@ import sttp.model.Header import sttp.shared.Identity import sttp.tapir.Schema +/** Optional behavioral hints about a tool, surfaced to clients. */ case class ToolAnnotations( title: Option[String] = None, readOnlyHint: Option[Boolean] = None, @@ -15,6 +16,7 @@ case class ToolAnnotations( openWorldHint: Option[Boolean] = None ) +/** The result of a tool call: content blocks, optional structured output, and whether the call failed. */ case class ToolResult( content: List[ToolContent], structuredContent: Option[Json] = None, @@ -23,6 +25,7 @@ case class ToolResult( def asError: ToolResult = copy(isError = true) def withStructured(json: Json): ToolResult = copy(structuredContent = Some(json)) +/** Constructors for the common [[ToolResult]] shapes. */ object ToolResult: def text(text: String): ToolResult = ToolResult(List(ToolContent.Text(text = text))) def error(message: String): ToolResult = ToolResult(List(ToolContent.Text(text = message)), isError = true) @@ -33,17 +36,21 @@ object ToolResult: def structured[A: Encoder](value: A): ToolResult = ToolResult(Nil, structuredContent = Some(value.asJson)) def fromEither(result: Either[String, String]): ToolResult = result.fold(error, text) +/** A tool's input schema: either derived from a Scala type or supplied as raw JSON Schema. */ enum ToolSchema: case Derived(schema: Schema[?]) case Raw(json: Json) +/** https://modelcontextprotocol.io/seps/986-specify-format-for-tool-names */ private val ToolNameRegex = "^[A-Za-z0-9_./-]+$".r +/** Starts defining a tool with the given unique name */ def tool(name: String): PartialTool = require(name.nonEmpty && name.length <= 64, s"Tool name must be 1..64 characters long, got ${name.length}: $name") require(ToolNameRegex.matches(name), s"Tool name must match ${ToolNameRegex.regex}, got: $name") PartialTool(name) +/** A tool being defined, before its input type is fixed. */ case class PartialTool( name: String, description: Option[String] = None, @@ -55,11 +62,14 @@ case class PartialTool( def withAnnotations(ann: ToolAnnotations): PartialTool = copy(annotations = Some(ann)) + /** Fixes the input type, deriving its JSON Schema and decoder from the given instances. */ def input[I: Schema: Decoder]: Tool[I] = Tool[I](name, description, ToolSchema.Derived(summon[Schema[I]]), summon[Decoder[I]], annotations) + /** Fixes the input as raw JSON, validated against the given JSON Schema. */ def inputJson(schema: Json): Tool[Json] = Tool[Json](name, description, ToolSchema.Raw(schema), summon[Decoder[Json]], annotations) +/** A tool with a known input type `I`, ready to be given its handling logic. */ case class Tool[I]( name: String, description: Option[String], @@ -67,20 +77,25 @@ case class Tool[I]( inputDecoder: Decoder[I], annotations: Option[ToolAnnotations] ): + /** Attaches effectful logic with access to the base [[ServerContext]]. */ def serverLogic[F[_]](logic: (I, ServerContext[F], Seq[Header]) => F[ToolResult]): ServerTool[I, F, ServerContext[F]] = ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) + /** Attaches effectful logic with access to the [[StreamingServerContext]]; usable only on a streaming server. */ def streamingServerLogic[F[_]]( logic: (I, StreamingServerContext[F], Seq[Header]) => F[ToolResult] ): ServerTool[I, F, StreamingServerContext[F]] = ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) + /** Attaches synchronous logic that also receives the request headers. */ def handleWithHeaders(logic: (I, Seq[Header]) => ToolResult): ServerTool[I, Identity, ServerContext[Identity]] = ServerTool(name, description, inputSchema, inputDecoder, annotations, (i, _, headers) => logic(i, headers)) + /** Attaches synchronous logic over just the decoded input. */ def handle(logic: I => ToolResult): ServerTool[I, Identity, ServerContext[Identity]] = handleWithHeaders((i, _) => logic(i)) +/** A fully-defined tool: its metadata plus the logic handling a call, in effect `F` with context `C`. */ case class ServerTool[I, F[_], -C <: ServerContext[F]]( name: String, description: Option[String], diff --git a/server/src/test/scala/chimp/server/McpHandlerSpec.scala b/server/src/test/scala/chimp/server/McpHandlerSpec.scala index 562a3a6..5ceb8e2 100644 --- a/server/src/test/scala/chimp/server/McpHandlerSpec.scala +++ b/server/src/test/scala/chimp/server/McpHandlerSpec.scala @@ -56,12 +56,12 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: private val itemTemplate = resourceTemplate("test://item/{id}") .name("item") - .serverLogic[Identity]((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) + .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) private val greetPrompt = prompt("greet") .description("Greets by name") .argument("name", required = true) - .serverLogic[Identity](args => + .handle(args => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello ${args.getOrElse("name", "?")}")))) ) diff --git a/server/src/test/scala/chimp/server/McpServerTests.scala b/server/src/test/scala/chimp/server/McpServerTests.scala index bcd0fb8..bacf15a 100644 --- a/server/src/test/scala/chimp/server/McpServerTests.scala +++ b/server/src/test/scala/chimp/server/McpServerTests.scala @@ -29,14 +29,14 @@ trait McpServerTests[F[_]] extends AsyncFlatSpec with Matchers: .addResource( resource("test://greeting") .mimeType("text/plain") - .read[F](() => + .serverLogic[F](_ => monad.unit(Right(List(ResourceContents.Text(uri = "test://greeting", text = "hello", mimeType = Some("text/plain"))))) ) ) .addPrompt( prompt("greet") .argument("name", required = true) - .serverLogic[F](args => + .serverLogic[F]((args, _) => monad.unit( GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hi ${args.getOrElse("name", "?")}")))) ) From 0b0edfeb00df4648064cd60e1611d0d9a42b4f86 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 14:22:47 +0200 Subject: [PATCH 09/20] feat: introduce stdio transport to server, use similar abstraction as for the client --- ... => ZioStreamingHttpServerTransport.scala} | 8 +- .../zio/ZioMcpServerStreamingSpec.scala | 2 +- .../main/scala/chimp/server/McpEndpoint.scala | 33 --------- .../main/scala/chimp/server/McpServer.scala | 6 +- .../chimp/server/McpServerStreaming.scala | 16 ---- .../chimp/server/McpStreamingEndpoint.scala | 42 ----------- .../scala/chimp/server/OutboundSink.scala | 9 +++ .../transport/HttpServerTransport.scala | 41 +++++++++++ .../server/transport/ServerTransport.scala | 15 ++++ .../transport/StdioServerTransport.scala | 51 +++++++++++++ .../StreamingHttpServerTransport.scala | 52 +++++++++++++ .../StreamingStdioServerTransport.scala | 6 ++ .../server/StdioServerTransportSpec.scala | 73 +++++++++++++++++++ 13 files changed, 256 insertions(+), 98 deletions(-) rename server-streaming/server-zio/src/main/scala/chimp/server/zio/{ZioMcpServerStreaming.scala => ZioStreamingHttpServerTransport.scala} (77%) delete mode 100644 server/src/main/scala/chimp/server/McpEndpoint.scala delete mode 100644 server/src/main/scala/chimp/server/McpServerStreaming.scala delete mode 100644 server/src/main/scala/chimp/server/McpStreamingEndpoint.scala create mode 100644 server/src/main/scala/chimp/server/OutboundSink.scala create mode 100644 server/src/main/scala/chimp/server/transport/HttpServerTransport.scala create mode 100644 server/src/main/scala/chimp/server/transport/ServerTransport.scala create mode 100644 server/src/main/scala/chimp/server/transport/StdioServerTransport.scala create mode 100644 server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala create mode 100644 server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala create mode 100644 server/src/test/scala/chimp/server/StdioServerTransportSpec.scala diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala similarity index 77% rename from server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala rename to server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala index 39339f7..3d40d73 100644 --- a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioMcpServerStreaming.scala +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala @@ -1,7 +1,8 @@ package chimp.server.zio import chimp.protocol.JSONRPCMessage -import chimp.server.{McpServerStreaming, OutboundSink} +import chimp.server.OutboundSink +import chimp.server.transport.StreamingHttpServerTransport import io.circe.Json import io.circe.syntax.* import sttp.capabilities.zio.ZioStreams @@ -13,7 +14,10 @@ import zio.{Queue, Task, ZIO} import java.nio.charset.StandardCharsets -object ZioMcpServerStreaming extends McpServerStreaming[Task, ZioStreams]: +/** ZIO implementation of the streaming HTTP server transport: the SSE stream is a `ZStream` of `ServerSentEvent`, and outbound messages + * are interleaved with the final response through an unbounded queue drained by a daemon fiber. + */ +final class ZioStreamingHttpServerTransport(path: List[String]) extends StreamingHttpServerTransport[Task, ZioStreams](path): val streams: ZioStreams = ZioStreams type EventStream = Stream[Throwable, ServerSentEvent] diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala index f1f6072..e8d73da 100644 --- a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala @@ -24,7 +24,7 @@ class ZioMcpServerStreamingSpec extends McpServerTests[Task] with McpServerStrea server: StreamingMcpServer[Task] )(test: BidirectionalMcpClient[Task] => Task[Assertion]): Future[Assertion] = toFuture: - val routes = ZioHttpInterpreter().toHttp(server.streamingEndpoint(List("mcp"), ZioMcpServerStreaming)) + val routes = ZioHttpInterpreter().toHttp(ZioStreamingHttpServerTransport(List("mcp")).serve(server)) ZIO.scoped: (for port <- Server.install(routes) diff --git a/server/src/main/scala/chimp/server/McpEndpoint.scala b/server/src/main/scala/chimp/server/McpEndpoint.scala deleted file mode 100644 index 53b2f4f..0000000 --- a/server/src/main/scala/chimp/server/McpEndpoint.scala +++ /dev/null @@ -1,33 +0,0 @@ -package chimp.server - -import io.circe.Json -import sttp.model.{Header, HeaderNames, StatusCode} -import sttp.monad.MonadError -import sttp.monad.syntax.* -import sttp.tapir.* -import sttp.tapir.json.circe.* -import sttp.tapir.server.ServerEndpoint - -private[server] def buildEndpoint[F[_]](server: McpServer[F], path: List[String]): ServerEndpoint[Any, F] = - val mcpHandler = new McpHandler(server) - val endpoint = infallibleEndpoint.post - .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) - .in(extractFromRequest(_.headers)) - .in(jsonBody[Json]) - .out(statusCode) - .out(jsonBody[Option[Json]]) - - ServerEndpoint.public( - endpoint, - me => { (input: (Seq[Header], Json)) => - val (headers, json) = input - given MonadError[F] = me - val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) - val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) - if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, None))) - else - mcpHandler - .handleJsonRpc(json, headers) - .map(response => Right((response.statusCode, response.body))) - } - ) diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala index 0b55efa..993e7d1 100644 --- a/server/src/main/scala/chimp/server/McpServer.scala +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -1,6 +1,7 @@ package chimp.server import chimp.protocol.* +import chimp.server.transport.HttpServerTransport import sttp.tapir.server.ServerEndpoint type CompletionHandler[F[_]] = (CompleteRef, CompleteArgument, Option[CompleteContext]) => F[Completion] @@ -88,7 +89,7 @@ case class McpServer[F[_]]( def withSubscriptions(handler: ResourceSubscriptions[F]): McpServer[F] = copy(subscriptions = Some(handler)) - def endpoint(path: List[String]): ServerEndpoint[Any, F] = buildEndpoint(this, path) + def endpoint(path: List[String]): ServerEndpoint[Any, F] = HttpServerTransport(path).serve(this) def streaming: StreamingMcpServer[F] = StreamingMcpServer( @@ -173,6 +174,3 @@ case class StreamingMcpServer[F[_]]( def withSubscriptions(handler: ResourceSubscriptions[F]): StreamingMcpServer[F] = copy(subscriptions = Some(handler)) - - def streamingEndpoint[S](path: List[String], streaming: McpServerStreaming[F, S]): ServerEndpoint[S, F] = - buildStreamingEndpoint(this, streaming, path) diff --git a/server/src/main/scala/chimp/server/McpServerStreaming.scala b/server/src/main/scala/chimp/server/McpServerStreaming.scala deleted file mode 100644 index 890abf3..0000000 --- a/server/src/main/scala/chimp/server/McpServerStreaming.scala +++ /dev/null @@ -1,16 +0,0 @@ -package chimp.server - -import chimp.protocol.JSONRPCMessage -import io.circe.Json -import sttp.capabilities.Streams -import sttp.tapir.StreamBodyIO - -abstract class McpServerStreaming[F[_], S]: - val streams: Streams[S] - type EventStream - def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] - def emptyStream: EventStream - def eventStream(handle: OutboundSink[F] => F[Option[Json]]): F[EventStream] - -trait OutboundSink[F[_]]: - def send(message: JSONRPCMessage): F[Unit] diff --git a/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala b/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala deleted file mode 100644 index 270cd69..0000000 --- a/server/src/main/scala/chimp/server/McpStreamingEndpoint.scala +++ /dev/null @@ -1,42 +0,0 @@ -package chimp.server - -import chimp.protocol.ProgressToken -import io.circe.Json -import sttp.model.{Header, HeaderNames, StatusCode} -import sttp.monad.MonadError -import sttp.monad.syntax.* -import sttp.tapir.* -import sttp.tapir.json.circe.* -import sttp.tapir.server.ServerEndpoint - -private[server] def buildStreamingEndpoint[F[_], S]( - server: StreamingMcpServer[F], - streaming: McpServerStreaming[F, S], - path: List[String] -): ServerEndpoint[S, F] = - val handler = new McpHandler[F, StreamingServerContext[F]](server) - val endpoint = infallibleEndpoint.post - .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) - .in(extractFromRequest(_.headers)) - .in(jsonBody[Json]) - .out(statusCode) - .out(streaming.sseBody) - - ServerEndpoint.public( - endpoint, - me => { (input: (Seq[Header], Json)) => - val (headers, json) = input - given MonadError[F] = me - val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) - val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) - if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, streaming.emptyStream))) - else - streaming - .eventStream { sink => - val makeContext: Option[ProgressToken] => StreamingServerContext[F] = - token => SinkStreamingServerContext(sink, token) - handler.handleJsonRpc(json, headers, makeContext).map(_.body) - } - .map(events => Right((StatusCode.Ok, events))) - } - ) diff --git a/server/src/main/scala/chimp/server/OutboundSink.scala b/server/src/main/scala/chimp/server/OutboundSink.scala new file mode 100644 index 0000000..26af436 --- /dev/null +++ b/server/src/main/scala/chimp/server/OutboundSink.scala @@ -0,0 +1,9 @@ +package chimp.server + +import chimp.protocol.JSONRPCMessage + +/** The seam between a [[StreamingServerContext]] and the wire. Server→client messages — notifications (progress, logging) and, later, + * requests (sampling, elicitation) — are emitted by calling [[send]]. Each streaming transport supplies its own implementation. + */ +trait OutboundSink[F[_]]: + def send(message: JSONRPCMessage): F[Unit] diff --git a/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala b/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala new file mode 100644 index 0000000..064e93e --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala @@ -0,0 +1,41 @@ +package chimp.server.transport + +import chimp.server.* +import io.circe.Json +import sttp.model.{Header, HeaderNames, StatusCode} +import sttp.monad.MonadError +import sttp.monad.syntax.* +import sttp.tapir.* +import sttp.tapir.json.circe.* +import sttp.tapir.server.ServerEndpoint + +/** Implementation of unidirectional MCP server using Streamable HTTP. + * Responds to JSON-RPC messages from an MCP client with a single JSON-RPC message response. + * + * @param path + * The MCP endpoint path. + **/ +final case class HttpServerTransport[F[_]](path: List[String]) extends ServerTransport[F, ServerEndpoint[Any, F]]: + def serve(server: McpServer[F]): ServerEndpoint[Any, F] = + val handler = new McpHandler(server) + val endpoint = infallibleEndpoint.post + .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) + .in(extractFromRequest(_.headers)) + .in(jsonBody[Json]) + .out(statusCode) + .out(jsonBody[Option[Json]]) + + ServerEndpoint.public( + endpoint, + me => { (input: (Seq[Header], Json)) => + val (headers, json) = input + given MonadError[F] = me + val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) + if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, None))) + else + handler + .handleJsonRpc(json, headers) + .map(response => Right((response.statusCode, response.body))) + } + ) diff --git a/server/src/main/scala/chimp/server/transport/ServerTransport.scala b/server/src/main/scala/chimp/server/transport/ServerTransport.scala new file mode 100644 index 0000000..c0925e5 --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/ServerTransport.scala @@ -0,0 +1,15 @@ +package chimp.server.transport + +import chimp.server.{McpServer, StreamingMcpServer} + +/** A unidirectional MCP server transport. + * Binds a server definition producing the transport-specific medium - an endpoint for HTTP or runnable loop for stdio. + */ +trait ServerTransport[F[_], A]: + def serve(server: McpServer[F]): A + +/** A bidirectional MCP server transport that can push messages to the clients. + * Binds a server definition producing the transport-specific medium - an streamable endpoint for HTTP or runnable loop for stdio. + */ +trait StreamingServerTransport[F[_], A]: + def serve(server: StreamingMcpServer[F]): A diff --git a/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala b/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala new file mode 100644 index 0000000..530a284 --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala @@ -0,0 +1,51 @@ +package chimp.server.transport + +import chimp.protocol.{JSONRPCMessage, ProgressToken} +import chimp.server.* +import io.circe.parser +import io.circe.syntax.* +import org.slf4j.LoggerFactory +import sttp.monad.{IdentityMonad, MonadError} +import sttp.shared.Identity + +import java.io.{BufferedReader, BufferedWriter, InputStream, InputStreamReader, OutputStream, OutputStreamWriter} +import java.nio.charset.StandardCharsets + +/** A synchronous implementation of MCP server using stdio transport. + * Exchanges line-delimited JSON-RPC messages over its standard input and output. + * + * // TODO describe params + */ +final class StdioServerTransport(in: InputStream = System.in, out: OutputStream = System.out) + extends ServerTransport[Identity, Unit] + with StreamingServerTransport[Identity, Unit]: + + private val log = LoggerFactory.getLogger(classOf[StdioServerTransport]) + + def serve(server: McpServer[Identity]): Unit = serve(server.streaming) + + def serve(server: StreamingMcpServer[Identity]): Unit = + given MonadError[Identity] = IdentityMonad + val handler = new McpHandler[Identity, StreamingServerContext[Identity]](server) + val reader = BufferedReader(InputStreamReader(in, StandardCharsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8)) + + def writeLine(json: io.circe.Json): Unit = + writer.synchronized: + writer.write(json.noSpaces) + writer.newLine() + writer.flush() + + val sink = new OutboundSink[Identity]: + def send(message: JSONRPCMessage): Identity[Unit] = writeLine(message.asJson.deepDropNullValues) + + val makeContext: Option[ProgressToken] => StreamingServerContext[Identity] = + token => SinkStreamingServerContext(sink, token) + + var line = reader.readLine() + while line != null do + if line.nonEmpty then + parser.parse(line) match + case Right(json) => handler.handleJsonRpc(json, Nil, makeContext).body.foreach(writeLine) + case Left(error) => log.warn(s"Failed to parse JSON-RPC line: ${error.getMessage}; raw: $line") + line = reader.readLine() diff --git a/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala b/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala new file mode 100644 index 0000000..0089ec3 --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala @@ -0,0 +1,52 @@ +package chimp.server.transport + +import chimp.protocol.ProgressToken +import chimp.server.* +import io.circe.Json +import sttp.capabilities.Streams +import sttp.model.{Header, HeaderNames, StatusCode} +import sttp.monad.MonadError +import sttp.monad.syntax.* +import sttp.tapir.* +import sttp.tapir.json.circe.* +import sttp.tapir.server.ServerEndpoint + +/** Implementation of bidirectional MCP server using Streamable HTTP. + * Responds to JSON-RPC messages from an MCP client with a Server-Sent-Event stream. + * Messages in the stream are interleaved with the final response on that stream. + * + * @param path + * The MCP endpoint path. + **/ +abstract class StreamingHttpServerTransport[F[_], S](path: List[String]) extends StreamingServerTransport[F, ServerEndpoint[S, F]]: + val streams: Streams[S] + type EventStream + def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] + def emptyStream: EventStream + def eventStream(handle: OutboundSink[F] => F[Option[Json]]): F[EventStream] + + final def serve(server: StreamingMcpServer[F]): ServerEndpoint[S, F] = + val handler = new McpHandler[F, StreamingServerContext[F]](server) + val endpoint = infallibleEndpoint.post + .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) + .in(extractFromRequest(_.headers)) + .in(jsonBody[Json]) + .out(statusCode) + .out(sseBody) + + ServerEndpoint.public( + endpoint, + me => { (input: (Seq[Header], Json)) => + val (headers, json) = input + given MonadError[F] = me + val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) + if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, emptyStream))) + else + eventStream { sink => + val makeContext: Option[ProgressToken] => StreamingServerContext[F] = + token => SinkStreamingServerContext(sink, token) + handler.handleJsonRpc(json, headers, makeContext).map(_.body) + }.map(events => Right((StatusCode.Ok, events))) + } + ) diff --git a/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala b/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala new file mode 100644 index 0000000..5c11b46 --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala @@ -0,0 +1,6 @@ +package chimp.server.transport + +/** Abstract base for streaming stdio MCP server transports that should consume the stdin as an asynchronous stream. Concrete + * implementations live in effect-specific modules. + */ +abstract class StreamingStdioServerTransport[F[_]] extends StreamingServerTransport[F, F[Unit]] diff --git a/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala b/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala new file mode 100644 index 0000000..44e2d4d --- /dev/null +++ b/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala @@ -0,0 +1,73 @@ +package chimp.server + +import chimp.protocol.LoggingLevel +import chimp.server.transport.StdioServerTransport +import io.circe.{Codec, Json, parser} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir.Schema + +import java.io.{BufferedReader, BufferedWriter, InputStreamReader, OutputStreamWriter, PipedInputStream, PipedOutputStream} +import java.nio.charset.StandardCharsets + +class StdioServerTransportSpec extends AnyFlatSpec with Matchers: + private case class EchoInput(message: String) derives Codec, Schema + private case class NoInput() derives Codec, Schema + + private def server: StreamingMcpServer[sttp.shared.Identity] = + StreamingMcpServer[sttp.shared.Identity]() + .withLoggingLevel(_ => ()) + .addTool(tool("echo").input[EchoInput].handle(in => ToolResult.text(in.message))) + .addStreamingTool( + tool("noisy") + .input[NoInput] + .streamingServerLogic[sttp.shared.Identity] { (_, ctx, _) => + ctx.log(LoggingLevel.Info, Json.fromString("one")) + ctx.log(LoggingLevel.Info, Json.fromString("two")) + ctx.log(LoggingLevel.Info, Json.fromString("three")) + ToolResult.text("done") + } + ) + + "a stdio server" should "answer requests and stream notifications over stdin/stdout" in { + val toServer = PipedOutputStream() + val serverIn = PipedInputStream(toServer) + val fromServer = PipedInputStream() + val serverOut = PipedOutputStream(fromServer) + + val thread = Thread(() => StdioServerTransport(serverIn, serverOut).serve(server)) + thread.setDaemon(true) + thread.start() + + val writer = BufferedWriter(OutputStreamWriter(toServer, StandardCharsets.UTF_8)) + val reader = BufferedReader(InputStreamReader(fromServer, StandardCharsets.UTF_8)) + + def send(line: String): Unit = + writer.write(line) + writer.newLine() + writer.flush() + + def readResponse(): Json = parser.parse(reader.readLine()).toOption.get + + try + send("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""") + val init = readResponse() + init.hcursor.downField("id").as[Int] shouldBe Right(1) + init.hcursor.downField("result").downField("serverInfo").downField("name").as[String].isRight shouldBe true + + send("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}""") + val echo = readResponse() + echo.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("hi") + + send("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"noisy","arguments":{}}}""") + val notifications = List(readResponse(), readResponse(), readResponse()) + notifications.map(_.hcursor.downField("method").as[String]) shouldBe List.fill(3)(Right("notifications/message")) + notifications.flatMap(_.hcursor.downField("params").downField("data").as[String].toOption) shouldBe List("one", "two", "three") + + val response = readResponse() + response.hcursor.downField("id").as[Int] shouldBe Right(3) + response.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("done") + finally + writer.close() + thread.join(2000) + } From 5338d611c670a9f19a0cc7d1320e52d2634a71d3 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 14:25:09 +0200 Subject: [PATCH 10/20] refactor: simpler comment --- server/src/main/scala/chimp/server/OutboundSink.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/main/scala/chimp/server/OutboundSink.scala b/server/src/main/scala/chimp/server/OutboundSink.scala index 26af436..2d19feb 100644 --- a/server/src/main/scala/chimp/server/OutboundSink.scala +++ b/server/src/main/scala/chimp/server/OutboundSink.scala @@ -2,8 +2,7 @@ package chimp.server import chimp.protocol.JSONRPCMessage -/** The seam between a [[StreamingServerContext]] and the wire. Server→client messages — notifications (progress, logging) and, later, - * requests (sampling, elicitation) — are emitted by calling [[send]]. Each streaming transport supplies its own implementation. +/** The sink for any server to client interaction. Each streaming transport supplies its own implementation. */ trait OutboundSink[F[_]]: def send(message: JSONRPCMessage): F[Unit] From bef3c355d909a43266ee13e36e9723dd19f293c3 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 14:32:31 +0200 Subject: [PATCH 11/20] refactor: comments --- .../chimp/client/transport/StreamingHttpTransport.scala | 3 +-- .../chimp/client/transport/StreamingStdioTransport.scala | 3 +-- .../server/transport/StreamingHttpServerTransport.scala | 5 ++++- .../server/transport/StreamingStdioServerTransport.scala | 3 +-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala b/client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala index f77dbf9..56c1736 100644 --- a/client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala +++ b/client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala @@ -5,8 +5,7 @@ import sttp.client4.StreamBackend import sttp.model.Uri /** Abstract base for streaming HTTP transports. The extra type parameter `S` carries the streaming capability evidence required by the sttp - * [[sttp.client4.StreamBackend]], which is needed to consume Server-Sent Event responses as an asynchronous stream. Concrete - * implementations live in effect-specific modules. + * [[sttp.client4.StreamBackend]], which is needed to consume Server-Sent Event responses as an asynchronous stream. */ abstract class StreamingHttpTransport[F[_], S]( protected val backend: StreamBackend[F, S], diff --git a/client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala b/client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala index 2021960..e1bc3d1 100644 --- a/client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala +++ b/client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala @@ -3,8 +3,7 @@ package chimp.client.transport import java.io.File import scala.concurrent.duration.FiniteDuration -/** Abstract base for streaming stdio transports that should consume the subprocess's stdout as an asynchronous stream. Concrete - * implementations live in effect-specific modules. +/** Abstract base for streaming stdio transports that should consume the subprocess's stdout as an asynchronous stream. */ abstract class StreamingStdioTransport[F[_]]( protected val command: List[String], diff --git a/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala b/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala index 0089ec3..3ff9e6a 100644 --- a/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala @@ -11,9 +11,12 @@ import sttp.tapir.* import sttp.tapir.json.circe.* import sttp.tapir.server.ServerEndpoint -/** Implementation of bidirectional MCP server using Streamable HTTP. +/** Abstract base for bidirectional MCP server using Streamable HTTP. * Responds to JSON-RPC messages from an MCP client with a Server-Sent-Event stream. * Messages in the stream are interleaved with the final response on that stream. + * + * The extra type parameter `S` carries the streaming capability evidence required by the Tapir + * [[sttp.tapir.server.ServerEndpoint]] to produce asynchronous stream of Server-Sent Events as response. * * @param path * The MCP endpoint path. diff --git a/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala b/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala index 5c11b46..78fa682 100644 --- a/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala @@ -1,6 +1,5 @@ package chimp.server.transport -/** Abstract base for streaming stdio MCP server transports that should consume the stdin as an asynchronous stream. Concrete - * implementations live in effect-specific modules. +/** Abstract base for streaming stdio MCP server transports that should consume the stdin as an asynchronous stream. */ abstract class StreamingStdioServerTransport[F[_]] extends StreamingServerTransport[F, F[Unit]] From db0ddb4d1ba976cf0716d99ac2a0eb3c85a7ab73 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 14:32:53 +0200 Subject: [PATCH 12/20] format --- .../zio/ZioStreamingHttpServerTransport.scala | 4 ++-- .../server/transport/HttpServerTransport.scala | 6 +++--- .../chimp/server/transport/ServerTransport.scala | 8 ++++---- .../server/transport/StdioServerTransport.scala | 4 ++-- .../transport/StreamingHttpServerTransport.scala | 13 ++++++------- .../transport/StreamingStdioServerTransport.scala | 2 +- .../chimp/server/StdioServerTransportSpec.scala | 6 ++++-- 7 files changed, 22 insertions(+), 21 deletions(-) diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala index 3d40d73..3d107a4 100644 --- a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala @@ -14,8 +14,8 @@ import zio.{Queue, Task, ZIO} import java.nio.charset.StandardCharsets -/** ZIO implementation of the streaming HTTP server transport: the SSE stream is a `ZStream` of `ServerSentEvent`, and outbound messages - * are interleaved with the final response through an unbounded queue drained by a daemon fiber. +/** ZIO implementation of the streaming HTTP server transport: the SSE stream is a `ZStream` of `ServerSentEvent`, and outbound messages are + * interleaved with the final response through an unbounded queue drained by a daemon fiber. */ final class ZioStreamingHttpServerTransport(path: List[String]) extends StreamingHttpServerTransport[Task, ZioStreams](path): val streams: ZioStreams = ZioStreams diff --git a/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala b/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala index 064e93e..e9f8f11 100644 --- a/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala @@ -9,12 +9,12 @@ import sttp.tapir.* import sttp.tapir.json.circe.* import sttp.tapir.server.ServerEndpoint -/** Implementation of unidirectional MCP server using Streamable HTTP. - * Responds to JSON-RPC messages from an MCP client with a single JSON-RPC message response. +/** Implementation of unidirectional MCP server using Streamable HTTP. Responds to JSON-RPC messages from an MCP client with a single + * JSON-RPC message response. * * @param path * The MCP endpoint path. - **/ + */ final case class HttpServerTransport[F[_]](path: List[String]) extends ServerTransport[F, ServerEndpoint[Any, F]]: def serve(server: McpServer[F]): ServerEndpoint[Any, F] = val handler = new McpHandler(server) diff --git a/server/src/main/scala/chimp/server/transport/ServerTransport.scala b/server/src/main/scala/chimp/server/transport/ServerTransport.scala index c0925e5..175c085 100644 --- a/server/src/main/scala/chimp/server/transport/ServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/ServerTransport.scala @@ -2,14 +2,14 @@ package chimp.server.transport import chimp.server.{McpServer, StreamingMcpServer} -/** A unidirectional MCP server transport. - * Binds a server definition producing the transport-specific medium - an endpoint for HTTP or runnable loop for stdio. +/** A unidirectional MCP server transport. Binds a server definition producing the transport-specific medium - an endpoint for HTTP or + * runnable loop for stdio. */ trait ServerTransport[F[_], A]: def serve(server: McpServer[F]): A -/** A bidirectional MCP server transport that can push messages to the clients. - * Binds a server definition producing the transport-specific medium - an streamable endpoint for HTTP or runnable loop for stdio. +/** A bidirectional MCP server transport that can push messages to the clients. Binds a server definition producing the transport-specific + * medium - an streamable endpoint for HTTP or runnable loop for stdio. */ trait StreamingServerTransport[F[_], A]: def serve(server: StreamingMcpServer[F]): A diff --git a/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala b/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala index 530a284..0e6df90 100644 --- a/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala @@ -11,8 +11,8 @@ import sttp.shared.Identity import java.io.{BufferedReader, BufferedWriter, InputStream, InputStreamReader, OutputStream, OutputStreamWriter} import java.nio.charset.StandardCharsets -/** A synchronous implementation of MCP server using stdio transport. - * Exchanges line-delimited JSON-RPC messages over its standard input and output. +/** A synchronous implementation of MCP server using stdio transport. Exchanges line-delimited JSON-RPC messages over its standard input and + * output. * * // TODO describe params */ diff --git a/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala b/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala index 3ff9e6a..2a1fb76 100644 --- a/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala @@ -11,16 +11,15 @@ import sttp.tapir.* import sttp.tapir.json.circe.* import sttp.tapir.server.ServerEndpoint -/** Abstract base for bidirectional MCP server using Streamable HTTP. - * Responds to JSON-RPC messages from an MCP client with a Server-Sent-Event stream. - * Messages in the stream are interleaved with the final response on that stream. - * - * The extra type parameter `S` carries the streaming capability evidence required by the Tapir - * [[sttp.tapir.server.ServerEndpoint]] to produce asynchronous stream of Server-Sent Events as response. +/** Abstract base for bidirectional MCP server using Streamable HTTP. Responds to JSON-RPC messages from an MCP client with a + * Server-Sent-Event stream. Messages in the stream are interleaved with the final response on that stream. + * + * The extra type parameter `S` carries the streaming capability evidence required by the Tapir [[sttp.tapir.server.ServerEndpoint]] to + * produce asynchronous stream of Server-Sent Events as response. * * @param path * The MCP endpoint path. - **/ + */ abstract class StreamingHttpServerTransport[F[_], S](path: List[String]) extends StreamingServerTransport[F, ServerEndpoint[S, F]]: val streams: Streams[S] type EventStream diff --git a/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala b/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala index 78fa682..f7f9e87 100644 --- a/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala @@ -1,5 +1,5 @@ package chimp.server.transport /** Abstract base for streaming stdio MCP server transports that should consume the stdin as an asynchronous stream. - */ + */ abstract class StreamingStdioServerTransport[F[_]] extends StreamingServerTransport[F, F[Unit]] diff --git a/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala b/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala index 44e2d4d..b42199e 100644 --- a/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala +++ b/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala @@ -2,7 +2,7 @@ package chimp.server import chimp.protocol.LoggingLevel import chimp.server.transport.StdioServerTransport -import io.circe.{Codec, Json, parser} +import io.circe.{parser, Codec, Json} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sttp.tapir.Schema @@ -50,7 +50,9 @@ class StdioServerTransportSpec extends AnyFlatSpec with Matchers: def readResponse(): Json = parser.parse(reader.readLine()).toOption.get try - send("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""") + send( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""" + ) val init = readResponse() init.hcursor.downField("id").as[Int] shouldBe Right(1) init.hcursor.downField("result").downField("serverInfo").downField("name").as[String].isRight shouldBe true From e7c2f3980105719184057367ae5bfcbc8d58ca1e Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 14:43:36 +0200 Subject: [PATCH 13/20] feat: harness stdio server tests --- .../transport/StdioServerTransport.scala | 5 +- .../server/StdioServerTransportSpec.scala | 66 +++++++++++-------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala b/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala index 0e6df90..6c0b079 100644 --- a/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala @@ -14,7 +14,10 @@ import java.nio.charset.StandardCharsets /** A synchronous implementation of MCP server using stdio transport. Exchanges line-delimited JSON-RPC messages over its standard input and * output. * - * // TODO describe params + * @param in + * Server input stream. + * @param out + * Server output stream. */ final class StdioServerTransport(in: InputStream = System.in, out: OutputStream = System.out) extends ServerTransport[Identity, Unit] diff --git a/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala b/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala index b42199e..6c26ed7 100644 --- a/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala +++ b/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala @@ -1,27 +1,28 @@ package chimp.server -import chimp.protocol.LoggingLevel +import chimp.protocol.{JSONRPCErrorCodes, LoggingLevel} import chimp.server.transport.StdioServerTransport import io.circe.{parser, Codec, Json} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import sttp.shared.Identity import sttp.tapir.Schema -import java.io.{BufferedReader, BufferedWriter, InputStreamReader, OutputStreamWriter, PipedInputStream, PipedOutputStream} +import java.io.* import java.nio.charset.StandardCharsets class StdioServerTransportSpec extends AnyFlatSpec with Matchers: private case class EchoInput(message: String) derives Codec, Schema private case class NoInput() derives Codec, Schema - private def server: StreamingMcpServer[sttp.shared.Identity] = - StreamingMcpServer[sttp.shared.Identity]() + private def server: StreamingMcpServer[Identity] = + StreamingMcpServer[Identity]() .withLoggingLevel(_ => ()) .addTool(tool("echo").input[EchoInput].handle(in => ToolResult.text(in.message))) .addStreamingTool( tool("noisy") .input[NoInput] - .streamingServerLogic[sttp.shared.Identity] { (_, ctx, _) => + .streamingServerLogic[Identity] { (_, ctx, _) => ctx.log(LoggingLevel.Info, Json.fromString("one")) ctx.log(LoggingLevel.Info, Json.fromString("two")) ctx.log(LoggingLevel.Info, Json.fromString("three")) @@ -29,7 +30,7 @@ class StdioServerTransportSpec extends AnyFlatSpec with Matchers: } ) - "a stdio server" should "answer requests and stream notifications over stdin/stdout" in { + private def withStdioServer[A](server: StreamingMcpServer[Identity])(body: (String => Unit, () => Json) => A): A = val toServer = PipedOutputStream() val serverIn = PipedInputStream(toServer) val fromServer = PipedInputStream() @@ -49,27 +50,40 @@ class StdioServerTransportSpec extends AnyFlatSpec with Matchers: def readResponse(): Json = parser.parse(reader.readLine()).toOption.get - try - send( - """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""" - ) - val init = readResponse() - init.hcursor.downField("id").as[Int] shouldBe Right(1) - init.hcursor.downField("result").downField("serverInfo").downField("name").as[String].isRight shouldBe true - - send("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}""") - val echo = readResponse() - echo.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("hi") - - send("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"noisy","arguments":{}}}""") - val notifications = List(readResponse(), readResponse(), readResponse()) - notifications.map(_.hcursor.downField("method").as[String]) shouldBe List.fill(3)(Right("notifications/message")) - notifications.flatMap(_.hcursor.downField("params").downField("data").as[String].toOption) shouldBe List("one", "two", "three") - - val response = readResponse() - response.hcursor.downField("id").as[Int] shouldBe Right(3) - response.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("done") + try body(send, readResponse) finally writer.close() thread.join(2000) + + "a stdio server" should "answer requests and stream notifications over stdin/stdout" in withStdioServer(server) { (send, readResponse) => + send( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""" + ) + val init = readResponse() + init.hcursor.downField("id").as[Int] shouldBe Right(1) + init.hcursor.downField("result").downField("serverInfo").downField("name").as[String].isRight shouldBe true + + send("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}""") + val echo = readResponse() + echo.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("hi") + + send("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"noisy","arguments":{}}}""") + val notifications = List(readResponse(), readResponse(), readResponse()) + notifications.map(_.hcursor.downField("method").as[String]) shouldBe List.fill(3)(Right("notifications/message")) + notifications.flatMap(_.hcursor.downField("params").downField("data").as[String].toOption) shouldBe List("one", "two", "three") + + val response = readResponse() + response.hcursor.downField("id").as[Int] shouldBe Right(3) + response.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("done") + } + + it should "skip notifications and malformed lines, and still report protocol errors" in withStdioServer(server) { (send, readResponse) => + send("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + send("this is not valid json") + send("""{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"missing","arguments":{}}}""") + + val error = readResponse() + error.hcursor.downField("id").as[Int] shouldBe Right(9) + error.hcursor.downField("error").downField("code").as[Int] shouldBe Right(JSONRPCErrorCodes.MethodNotFound.code) + error.hcursor.downField("error").downField("message").as[String].toOption.get should include("missing") } From adcaffecb4dfb754e4ac09b282391c9b1a49cbe7 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 14:59:05 +0200 Subject: [PATCH 14/20] renaming --- .../scala/chimp/conformance/client/Main.scala | 4 +- ...ort.scala => ZioClientHttpTransport.scala} | 40 +++++------ ...rt.scala => ZioClientStdioTransport.scala} | 28 ++++---- ... => ZioMcpClientHttpIntegrationSpec.scala} | 10 ++- .../ZioMcpClientStdioIntegrationSpec.scala | 8 ++- .../main/scala/chimp/client/McpClient.scala | 20 +++--- .../scala/chimp/client/McpClientImpl.scala | 14 ++-- ...nsport.scala => ClientHttpTransport.scala} | 18 ++--- ...sport.scala => ClientStdioTransport.scala} | 12 ++-- ...ala => ClientStreamingHttpTransport.scala} | 4 +- ...la => ClientStreamingStdioTransport.scala} | 6 +- ...{Transport.scala => ClientTransport.scala} | 10 +-- ...ec.scala => ClientHttpTransportSpec.scala} | 10 +-- .../chimp/client/InMemoryTransport.scala | 4 +- .../scala/chimp/client/McpClientSpec.scala | 10 +-- .../McpClientBidirectionalHttpTests.scala | 4 +- .../McpClientHttpIntegrationSpec.scala | 4 +- .../McpClientStdioIntegrationSpec.scala | 6 +- ...cpClientStreamingHttpIntegrationSpec.scala | 12 ++-- .../examples/client/everythingClient.scala | 4 +- generated-docs/out/client/examples.md | 68 ++++++++++--------- generated-docs/out/client/quickstart.md | 16 ++--- ...ort.scala => ZioServerHttpTransport.scala} | 2 +- ...gSpec.scala => ZioMcpServerHttpSpec.scala} | 12 ++-- .../chimp/server/SyncHttpMcpServerSpec.scala | 4 +- 25 files changed, 168 insertions(+), 162 deletions(-) rename client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/{ZioStreamingHttpTransport.scala => ZioClientHttpTransport.scala} (91%) rename client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/{ZioStreamingStdioTransport.scala => ZioClientStdioTransport.scala} (79%) rename client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/{ZioMcpClientStreamingHttpIntegrationSpec.scala => ZioMcpClientHttpIntegrationSpec.scala} (64%) rename client/src/main/scala/chimp/client/transport/{HttpTransport.scala => ClientHttpTransport.scala} (89%) rename client/src/main/scala/chimp/client/transport/{StdioTransport.scala => ClientStdioTransport.scala} (93%) rename client/src/main/scala/chimp/client/transport/{StreamingHttpTransport.scala => ClientStreamingHttpTransport.scala} (83%) rename client/src/main/scala/chimp/client/transport/{StreamingStdioTransport.scala => ClientStreamingStdioTransport.scala} (68%) rename client/src/main/scala/chimp/client/transport/{Transport.scala => ClientTransport.scala} (68%) rename client/src/test/scala/chimp/client/{HttpTransportSpec.scala => ClientHttpTransportSpec.scala} (84%) rename server-streaming/server-zio/src/main/scala/chimp/server/zio/{ZioStreamingHttpServerTransport.scala => ZioServerHttpTransport.scala} (93%) rename server-streaming/server-zio/src/test/scala/chimp/server/zio/{ZioMcpServerStreamingSpec.scala => ZioMcpServerHttpSpec.scala} (76%) diff --git a/client-conformance/src/main/scala/chimp/conformance/client/Main.scala b/client-conformance/src/main/scala/chimp/conformance/client/Main.scala index 3c04629..53b2e56 100644 --- a/client-conformance/src/main/scala/chimp/conformance/client/Main.scala +++ b/client-conformance/src/main/scala/chimp/conformance/client/Main.scala @@ -1,7 +1,7 @@ package chimp.conformance.client import chimp.client.McpClient -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend @@ -28,7 +28,7 @@ object Main: .getOrElse(ProtocolVersion.Latest) val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, serverUrl, protocolVersion) + val transport = ClientHttpTransport[Identity](backend, serverUrl, protocolVersion) val rc: Int = try diff --git a/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingHttpTransport.scala b/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientHttpTransport.scala similarity index 91% rename from client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingHttpTransport.scala rename to client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientHttpTransport.scala index 55f2767..fe3f8aa 100644 --- a/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingHttpTransport.scala +++ b/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientHttpTransport.scala @@ -1,7 +1,7 @@ package chimp.client.transport.zio -import chimp.client.transport.HttpTransport.HttpOutcome -import chimp.client.transport.{HttpTransport, StreamingHttpTransport, Transport} +import chimp.client.transport.ClientHttpTransport.HttpOutcome +import chimp.client.transport.{ClientHttpTransport, ClientStreamingHttpTransport, ClientTransport} import chimp.client.{McpProtocolException, McpSessionNotFoundException} import chimp.protocol.{JSONRPCErrorCodes, JSONRPCErrorObject, JSONRPCMessage, ProtocolVersion, RequestId} import org.slf4j.LoggerFactory @@ -15,7 +15,7 @@ import zio.{Duration, Exit, Promise, Ref, Schedule, Scope, Task, ZIO, ZLayer} import scala.concurrent.duration.FiniteDuration -final class ZioStreamingHttpTransport private ( +final class ZioClientHttpTransport private ( backend: StreamBackend[Task, ZioStreams], uri: Uri, protocolVersion: ProtocolVersion, @@ -28,9 +28,9 @@ final class ZioStreamingHttpTransport private ( incomingRef: Ref[JSONRPCMessage => Task[Unit]], lastEventId: Ref[Option[String]], closingRef: Ref[Boolean] -) extends StreamingHttpTransport[Task, ZioStreams](backend, uri, ZioStreams): +) extends ClientStreamingHttpTransport[Task, ZioStreams](backend, uri, ZioStreams): - private val log = LoggerFactory.getLogger(classOf[ZioStreamingHttpTransport]) + private val log = LoggerFactory.getLogger(classOf[ZioClientHttpTransport]) override given monad: MonadError[Task] = backend.monad @@ -53,7 +53,7 @@ final class ZioStreamingHttpTransport private ( .getAndSet(None) .flatMap: case Some(id) => - HttpTransport + ClientHttpTransport .baseDeleteRequest(uri, protocolVersion, id) .response(asStreamUnsafe(ZioStreams)) .send(backend) @@ -66,7 +66,7 @@ final class ZioStreamingHttpTransport private ( post(request).flatMap: resp => captureSession(resp) *> sessionRef.get.flatMap: session => - HttpTransport.resolveResponse(resp, session) match + ClientHttpTransport.resolveResponse(resp, session) match case Left(err: McpSessionNotFoundException) => sessionRef.set(None) *> ZIO.fail(err) case Left(err) => @@ -87,7 +87,7 @@ final class ZioStreamingHttpTransport private ( post(msg).flatMap: response => captureSession(response) *> sessionRef.get.flatMap: session => - HttpTransport.resolveResponse(response, session) match + ClientHttpTransport.resolveResponse(response, session) match case Left(err: McpSessionNotFoundException) => sessionRef.set(None) *> ZIO.fail(err) case Left(err) => @@ -101,8 +101,8 @@ final class ZioStreamingHttpTransport private ( private def post(msg: JSONRPCMessage): Task[Response[Either[String, Stream[Throwable, Byte]]]] = sessionRef.get.flatMap: session => - HttpTransport - .basePostRequest(uri, protocolVersion, session, Transport.encode(msg)) + ClientHttpTransport + .basePostRequest(uri, protocolVersion, session, ClientTransport.encode(msg)) .response(asStreamUnsafe(ZioStreams)) .send(backend) @@ -123,7 +123,7 @@ final class ZioStreamingHttpTransport private ( case Right(stream) => stream.runDrain.ignore private def decode(body: String): Task[JSONRPCMessage] = - Transport.decode(body) match + ClientTransport.decode(body) match case Right(msg) => ZIO.succeed(msg) case Left(err) => ZIO.fail(McpProtocolException(s"Failed to decode response body: ${err.getMessage}, payload $body")) @@ -164,7 +164,7 @@ final class ZioStreamingHttpTransport private ( private def dispatch(event: ServerSentEvent): Task[Unit] = event.data match case Some(data) if data.nonEmpty => - Transport.decode(data) match + ClientTransport.decode(data) match case Right(msg) => routeMessage(msg) case Left(_) => ZIO.unit case _ => ZIO.unit @@ -257,7 +257,7 @@ final class ZioStreamingHttpTransport private ( error = JSONRPCErrorObject(code = JSONRPCErrorCodes.InvocationError.code, message = "SSE stream ended before response") ) -object ZioStreamingHttpTransport: +object ZioClientHttpTransport: val defaultReconnectSchedule: Schedule[Any, Any, Any] = Schedule.exponential(Duration.fromMillis(100)).jittered || Schedule.spaced(Duration.fromSeconds(30)) @@ -266,9 +266,9 @@ object ZioStreamingHttpTransport: backend: StreamBackend[Task, ZioStreams], uri: Uri, protocolVersion: ProtocolVersion = ProtocolVersion.Latest, - timeout: FiniteDuration = Transport.defaultTimeout, + timeout: FiniteDuration = ClientTransport.defaultTimeout, reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule - ): Task[ZioStreamingHttpTransport] = + ): Task[ZioClientHttpTransport] = for scope <- Scope.make sessionRef <- Ref.make(Option.empty[String]) @@ -277,7 +277,7 @@ object ZioStreamingHttpTransport: incomingRef <- Ref.make[JSONRPCMessage => Task[Unit]](_ => ZIO.unit) lastEventId <- Ref.make(Option.empty[String]) closingRef <- Ref.make(false) - transport = new ZioStreamingHttpTransport( + transport = new ZioClientHttpTransport( backend, uri, protocolVersion, @@ -298,16 +298,16 @@ object ZioStreamingHttpTransport: backend: StreamBackend[Task, ZioStreams], uri: Uri, protocolVersion: ProtocolVersion = ProtocolVersion.Latest, - timeout: FiniteDuration = Transport.defaultTimeout, + timeout: FiniteDuration = ClientTransport.defaultTimeout, reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule - ): ZIO[Scope, Throwable, ZioStreamingHttpTransport] = + ): ZIO[Scope, Throwable, ZioClientHttpTransport] = ZIO.acquireRelease(apply(backend, uri, protocolVersion, timeout, reconnectSchedule))(_.close().ignore) def layer( backend: StreamBackend[Task, ZioStreams], uri: Uri, protocolVersion: ProtocolVersion = ProtocolVersion.Latest, - timeout: FiniteDuration = Transport.defaultTimeout, + timeout: FiniteDuration = ClientTransport.defaultTimeout, reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule - ): ZLayer[Any, Throwable, ZioStreamingHttpTransport] = + ): ZLayer[Any, Throwable, ZioClientHttpTransport] = ZLayer.scoped(scoped(backend, uri, protocolVersion, timeout, reconnectSchedule)) diff --git a/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingStdioTransport.scala b/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientStdioTransport.scala similarity index 79% rename from client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingStdioTransport.scala rename to client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientStdioTransport.scala index adfc597..92ad9e6 100644 --- a/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingStdioTransport.scala +++ b/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientStdioTransport.scala @@ -1,6 +1,6 @@ package chimp.client.transport.zio -import chimp.client.transport.{StreamingStdioTransport, Transport} +import chimp.client.transport.{ClientStreamingStdioTransport, ClientTransport} import chimp.protocol.JSONRPCMessage import org.slf4j.LoggerFactory import sttp.client4.impl.zio.RIOMonadAsyncError @@ -13,7 +13,7 @@ import java.io.File import java.nio.charset.StandardCharsets import scala.concurrent.duration.FiniteDuration -final class ZioStreamingStdioTransport private ( +final class ZioClientStdioTransport private ( command: List[String], env: Map[String, String], workDir: Option[File], @@ -23,9 +23,9 @@ final class ZioStreamingStdioTransport private ( writeQueue: Queue[JSONRPCMessage], pending: ZioPendingRequests, incomingRef: Ref[JSONRPCMessage => Task[Unit]] -) extends StreamingStdioTransport[Task](command, env, workDir): +) extends ClientStreamingStdioTransport[Task](command, env, workDir): - private val log = LoggerFactory.getLogger(classOf[ZioStreamingStdioTransport]) + private val log = LoggerFactory.getLogger(classOf[ZioClientStdioTransport]) override given monad: MonadError[Task] = new RIOMonadAsyncError[Any] @@ -54,7 +54,7 @@ final class ZioStreamingStdioTransport private ( val drain = process.stdout.linesStream .filter(_.nonEmpty) .mapZIO: line => - Transport.decode(line) match + ClientTransport.decode(line) match case Right(msg) => dispatch(msg) case Left(err) => ZIO.succeed(log.warn(s"Failed to parse JSON-RPC line: ${err.getMessage}, raw: $line")) .runDrain @@ -68,14 +68,14 @@ final class ZioStreamingStdioTransport private ( .forkIn(scope) .unit -object ZioStreamingStdioTransport: +object ZioClientStdioTransport: def apply( command: List[String], env: Map[String, String] = Map.empty, workDir: Option[File] = None, - timeout: FiniteDuration = Transport.defaultTimeout - ): Task[ZioStreamingStdioTransport] = + timeout: FiniteDuration = ClientTransport.defaultTimeout + ): Task[ZioClientStdioTransport] = for scope <- Scope.make writeQueue <- Queue.bounded[JSONRPCMessage](256) @@ -83,14 +83,14 @@ object ZioStreamingStdioTransport: incomingRef <- Ref.make[JSONRPCMessage => Task[Unit]](_ => ZIO.unit) stdinBytes = ZStream .fromQueue(writeQueue) - .map(msg => Chunk.fromArray((Transport.encode(msg) + "\n").getBytes(StandardCharsets.UTF_8))) + .map(msg => Chunk.fromArray((ClientTransport.encode(msg) + "\n").getBytes(StandardCharsets.UTF_8))) .flattenChunks baseCmd = Command(command.head, command.tail*) withEnv = if env.isEmpty then baseCmd else baseCmd.env(env) withDir = workDir.fold(withEnv)(withEnv.workingDirectory) cmd = withDir.stdin(ProcessInput.fromStream(stdinBytes, flushChunksEagerly = true)) process <- cmd.run.provideEnvironment(zio.ZEnvironment(scope)) - transport = new ZioStreamingStdioTransport(command, env, workDir, timeout, scope, process, writeQueue, pending, incomingRef) + transport = new ZioClientStdioTransport(command, env, workDir, timeout, scope, process, writeQueue, pending, incomingRef) _ <- transport.startReader _ <- transport.startStderr yield transport @@ -99,14 +99,14 @@ object ZioStreamingStdioTransport: command: List[String], env: Map[String, String] = Map.empty, workDir: Option[File] = None, - timeout: FiniteDuration = Transport.defaultTimeout - ): ZIO[Scope, Throwable, ZioStreamingStdioTransport] = + timeout: FiniteDuration = ClientTransport.defaultTimeout + ): ZIO[Scope, Throwable, ZioClientStdioTransport] = ZIO.acquireRelease(apply(command, env, workDir, timeout))(_.close().ignore) def layer( command: List[String], env: Map[String, String] = Map.empty, workDir: Option[File] = None, - timeout: FiniteDuration = Transport.defaultTimeout - ): ZLayer[Any, Throwable, ZioStreamingStdioTransport] = + timeout: FiniteDuration = ClientTransport.defaultTimeout + ): ZLayer[Any, Throwable, ZioClientStdioTransport] = ZLayer.scoped(scoped(command, env, workDir, timeout)) diff --git a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStreamingHttpIntegrationSpec.scala b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientHttpIntegrationSpec.scala similarity index 64% rename from client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStreamingHttpIntegrationSpec.scala rename to client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientHttpIntegrationSpec.scala index 4971661..eb7997d 100644 --- a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStreamingHttpIntegrationSpec.scala +++ b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientHttpIntegrationSpec.scala @@ -1,7 +1,7 @@ package chimp.client.transport.zio import chimp.client.integration.McpClientStreamingHttpIntegrationSpec -import chimp.client.transport.BidirectionalTransport +import chimp.client.transport.ClientBidirectionalTransport import chimp.protocol.ProtocolVersion import sttp.capabilities.zio.ZioStreams import sttp.client4.StreamBackend @@ -11,15 +11,13 @@ import zio.{Task, ZIO} import scala.concurrent.duration.FiniteDuration -class ZioMcpClientStreamingHttpIntegrationSpec - extends McpClientStreamingHttpIntegrationSpec[Task, StreamBackend[Task, ZioStreams]] - with ZioToFuture: +class ZioMcpClientHttpIntegrationSpec extends McpClientStreamingHttpIntegrationSpec[Task, StreamBackend[Task, ZioStreams]] with ZioToFuture: override def usingBackend[A](use: StreamBackend[Task, ZioStreams] => Task[A]): Task[A] = HttpClientZioBackend().flatMap: b => use(b).ensuring(b.close().orDie) override def usingBidirectionalTransport[A](b: StreamBackend[Task, ZioStreams], uri: Uri, timeout: FiniteDuration)( - use: BidirectionalTransport[Task] => Task[A] + use: ClientBidirectionalTransport[Task] => Task[A] ): Task[A] = - ZIO.scoped(ZioStreamingHttpTransport.scoped(b, uri, ProtocolVersion.Latest, timeout).flatMap(use)) + ZIO.scoped(ZioClientHttpTransport.scoped(b, uri, ProtocolVersion.Latest, timeout).flatMap(use)) diff --git a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala index 0c814ee..3d9dd14 100644 --- a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala +++ b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala @@ -1,12 +1,14 @@ package chimp.client.transport.zio import chimp.client.integration.McpClientStdioIntegrationSpec -import chimp.client.transport.BidirectionalTransport +import chimp.client.transport.ClientBidirectionalTransport import zio.{Task, ZIO} import scala.concurrent.duration.FiniteDuration class ZioMcpClientStdioIntegrationSpec extends McpClientStdioIntegrationSpec[Task] with ZioToFuture: - override def usingTransport[A](command: List[String], timeout: FiniteDuration)(use: BidirectionalTransport[Task] => Task[A]): Task[A] = - ZIO.scoped(ZioStreamingStdioTransport.scoped(command, timeout = timeout).flatMap(use)) + override def usingTransport[A](command: List[String], timeout: FiniteDuration)( + use: ClientBidirectionalTransport[Task] => Task[A] + ): Task[A] = + ZIO.scoped(ZioClientStdioTransport.scoped(command, timeout = timeout).flatMap(use)) diff --git a/client/src/main/scala/chimp/client/McpClient.scala b/client/src/main/scala/chimp/client/McpClient.scala index 39ccb3c..a680742 100644 --- a/client/src/main/scala/chimp/client/McpClient.scala +++ b/client/src/main/scala/chimp/client/McpClient.scala @@ -1,14 +1,14 @@ package chimp.client import chimp.client.notifications.ServerNotificationListener -import chimp.client.transport.{BidirectionalTransport, Transport} +import chimp.client.transport.{ClientBidirectionalTransport, ClientTransport} import chimp.protocol.* import io.circe.Json /** A Model Context Protocol (MCP) client that has completed the initialization handshake with a server. * * Exposes the server's advertised capabilities and identity, and provides methods for sending client-initiated requests and notifications - * over the underlying [[chimp.client.transport.Transport]]. + * over the underlying [[chimp.client.transport.ClientTransport]]. * * For bidirectional interaction (server-initiated requests, resource subscriptions, notification listeners), use * [[BidirectionalMcpClient]] instead. @@ -103,7 +103,7 @@ trait McpClient[F[_]]: */ def sendCancelled(requestId: RequestId, reason: Option[String] = None): F[Unit] -/** An [[McpClient]] used over a [[chimp.client.transport.BidirectionalTransport]], which additionally supports server-initiated +/** An [[McpClient]] used over a [[chimp.client.transport.ClientBidirectionalTransport]], which additionally supports server-initiated * interactions: subscribing to resource updates, notifying the server about changes to the client's roots, and handling notifications * pushed by the server. */ @@ -123,8 +123,8 @@ trait BidirectionalMcpClient[F[_]] extends McpClient[F]: def onServerNotification(listener: ServerNotificationListener[F]): F[Unit] object McpClient: - /** Creates an unidirectional [[McpClient]] over the given [[chimp.client.transport.Transport]] and performs the initialization handshake - * with the server. + /** Creates an unidirectional [[McpClient]] over the given [[chimp.client.transport.ClientTransport]] and performs the initialization + * handshake with the server. * * @param transport * The transport carrying JSON-RPC messages between client and server. @@ -134,15 +134,15 @@ object McpClient: * Protocol version proposed during initialization; defaults to the latest version supported by chimp. */ def apply[F[_]]( - transport: Transport[F], + transport: ClientTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion = ProtocolVersion.Latest ): F[McpClient[F]] = McpClientImpl.create(transport, clientInfo, protocolVersion) - /** Creates a [[BidirectionalMcpClient]] over the given [[chimp.client.transport.BidirectionalTransport]] and performs the initialization - * handshake. The optional handlers determine which client capabilities (roots, sampling, elicitation) are advertised to the server; only - * capabilities backed by a handler are enabled. + /** Creates a [[BidirectionalMcpClient]] over the given [[chimp.client.transport.ClientBidirectionalTransport]] and performs the + * initialization handshake. The optional handlers determine which client capabilities (roots, sampling, elicitation) are advertised to + * the server; only capabilities backed by a handler are enabled. * * @param transport * The bidirectional transport carrying JSON-RPC messages in both directions. @@ -158,7 +158,7 @@ object McpClient: * Protocol version proposed during initialization; defaults to the latest version supported by chimp. */ def bidirectional[F[_]]( - transport: BidirectionalTransport[F], + transport: ClientBidirectionalTransport[F], clientInfo: Implementation, rootsHandler: Option[() => F[ListRootsResult]] = None, samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None, diff --git a/client/src/main/scala/chimp/client/McpClientImpl.scala b/client/src/main/scala/chimp/client/McpClientImpl.scala index 15ad08c..cfcc4ec 100644 --- a/client/src/main/scala/chimp/client/McpClientImpl.scala +++ b/client/src/main/scala/chimp/client/McpClientImpl.scala @@ -2,7 +2,7 @@ package chimp.client import chimp.client.internal.{Correlator, UUIDCorrelator} import chimp.client.notifications.{ServerNotification, ServerNotificationListener} -import chimp.client.transport.{BidirectionalTransport, Transport} +import chimp.client.transport.{ClientBidirectionalTransport, ClientTransport} import chimp.protocol.* import io.circe.syntax.* import io.circe.{Decoder, Json} @@ -13,7 +13,7 @@ import java.util.concurrent.atomic.AtomicReference object McpClientImpl: def create[F[_]]( - transport: Transport[F], + transport: ClientTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion, correlator: Correlator = UUIDCorrelator() @@ -24,7 +24,7 @@ object McpClientImpl: new Impl[F](transport, clientInfo, protocolVersion, clientCapabilities, correlator, initResult) def createBidirectional[F[_]]( - transport: BidirectionalTransport[F], + transport: ClientBidirectionalTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion, rootsHandler: Option[() => F[ListRootsResult]], @@ -57,7 +57,7 @@ object McpClientImpl: ) private def initialize[F[_]]( - transport: Transport[F], + transport: ClientTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion, clientCapabilities: ClientCapabilities, @@ -121,7 +121,7 @@ object McpClientImpl: entries.toMap private def buildIncomingHandler[F[_]]( - transport: BidirectionalTransport[F], + transport: ClientBidirectionalTransport[F], serverInitiatedRequestHandlers: Map[String, Json => F[Json]], serverNotificationListeners: AtomicReference[List[ServerNotificationListener[F]]] )(using monad: MonadError[F]): JSONRPCMessage => F[Unit] = @@ -156,7 +156,7 @@ object McpClientImpl: monad.unit(()) private class Impl[F[_]]( - protected val transport: Transport[F], + protected val transport: ClientTransport[F], protected val clientInfo: Implementation, protected val protocolVersion: ProtocolVersion, protected val clientCapabilities: ClientCapabilities, @@ -249,7 +249,7 @@ object McpClientImpl: transport.send(notification).map(_ => ()) private final class BidirectionalImpl[F[_]]( - bidiTransport: BidirectionalTransport[F], + bidiTransport: ClientBidirectionalTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion, clientCapabilities: ClientCapabilities, diff --git a/client/src/main/scala/chimp/client/transport/HttpTransport.scala b/client/src/main/scala/chimp/client/transport/ClientHttpTransport.scala similarity index 89% rename from client/src/main/scala/chimp/client/transport/HttpTransport.scala rename to client/src/main/scala/chimp/client/transport/ClientHttpTransport.scala index 34ede3b..0610d46 100644 --- a/client/src/main/scala/chimp/client/transport/HttpTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientHttpTransport.scala @@ -1,6 +1,6 @@ package chimp.client.transport -import chimp.client.transport.HttpTransport.HttpOutcome +import chimp.client.transport.ClientHttpTransport.HttpOutcome import chimp.client.{McpAuthorizationException, McpProtocolException, McpSessionNotFoundException, McpTransportException} import chimp.protocol.{JSONRPCMessage, ProtocolVersion} import sttp.client4.{basicRequest, Backend, Request, Response} @@ -21,22 +21,22 @@ import scala.util.chaining.* * @param protocolVersion * Protocol version advertised via the `MCP-Protocol-Version` header; defaults to the latest version supported by chimp. */ -final class HttpTransport[F[_]]( +final class ClientHttpTransport[F[_]]( backend: Backend[F], uri: Uri, protocolVersion: ProtocolVersion = ProtocolVersion.Latest -) extends Transport[F]: +) extends ClientTransport[F]: given monad: MonadError[F] = backend.monad private val sessionId = AtomicReference[Option[String]](None) override def send(msg: JSONRPCMessage): F[Option[JSONRPCMessage]] = - HttpTransport.basePostRequest(uri, protocolVersion, sessionId.get(), Transport.encode(msg)).send(backend).flatMap(interpret) + ClientHttpTransport.basePostRequest(uri, protocolVersion, sessionId.get(), ClientTransport.encode(msg)).send(backend).flatMap(interpret) private def interpret(response: Response[Either[String, String]]): F[Option[JSONRPCMessage]] = response.header("Mcp-Session-Id").foreach(s => sessionId.set(Some(s))) - HttpTransport.resolveResponse(response, sessionId.get()) match + ClientHttpTransport.resolveResponse(response, sessionId.get()) match case Left(error: McpSessionNotFoundException) => sessionId.set(None) monad.error(error) @@ -48,12 +48,12 @@ final class HttpTransport[F[_]]( case Right(body) => val payload = kind match case HttpOutcome.JsonBody => if body.isEmpty then None else Some(body) - case HttpOutcome.SseBody => HttpTransport.extractSingleSseData(body) + case HttpOutcome.SseBody => ClientHttpTransport.extractSingleSseData(body) case HttpOutcome.NoBody => None payload match case None => monad.unit(None) case Some(json) => - Transport.decode(json) match + ClientTransport.decode(json) match case Right(message) => monad.unit(Some(message)) case Left(error) => monad.error(McpProtocolException(s"Failed to decode response body: ${error.getMessage}, payload $json")) @@ -63,9 +63,9 @@ final class HttpTransport[F[_]]( case None => monad.unit(()) case Some(id) => sessionId.set(None) - HttpTransport.baseDeleteRequest(uri, protocolVersion, id).send(backend).map(_ => ()) + ClientHttpTransport.baseDeleteRequest(uri, protocolVersion, id).send(backend).map(_ => ()) -object HttpTransport: +object ClientHttpTransport: enum HttpOutcome: case NoBody case JsonBody diff --git a/client/src/main/scala/chimp/client/transport/StdioTransport.scala b/client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala similarity index 93% rename from client/src/main/scala/chimp/client/transport/StdioTransport.scala rename to client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala index 9ccdc02..92133cc 100644 --- a/client/src/main/scala/chimp/client/transport/StdioTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala @@ -25,14 +25,14 @@ import scala.jdk.CollectionConverters.* * @param timeout * Maximum time to wait for a response to each request before raising an [[chimp.client.McpTimeoutException]]. */ -final class StdioTransport( +final class ClientStdioTransport( command: List[String], env: Map[String, String] = Map.empty, workDir: Option[File] = None, - timeout: FiniteDuration = Transport.defaultTimeout -) extends BidirectionalTransport[Identity]: + timeout: FiniteDuration = ClientTransport.defaultTimeout +) extends ClientBidirectionalTransport[Identity]: - private val log = LoggerFactory.getLogger(classOf[StdioTransport]) + private val log = LoggerFactory.getLogger(classOf[ClientStdioTransport]) given monad: MonadError[Identity] = IdentityMonad @@ -68,7 +68,7 @@ final class StdioTransport( var line: String = reader.readLine() while line != null do if line.nonEmpty then - Transport.decode(line) match + ClientTransport.decode(line) match case Right(msg) => dispatch(msg) case Left(e) => log.warn(s"Failed to parse JSON-RPC line: ${e.getMessage}; raw: $line") line = reader.readLine() @@ -104,7 +104,7 @@ final class StdioTransport( private def writeLine(msg: JSONRPCMessage): Unit = writer.synchronized: - writer.write(Transport.encode(msg)) + writer.write(ClientTransport.encode(msg)) writer.newLine() writer.flush() diff --git a/client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala b/client/src/main/scala/chimp/client/transport/ClientStreamingHttpTransport.scala similarity index 83% rename from client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala rename to client/src/main/scala/chimp/client/transport/ClientStreamingHttpTransport.scala index 56c1736..e53bdba 100644 --- a/client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientStreamingHttpTransport.scala @@ -7,8 +7,8 @@ import sttp.model.Uri /** Abstract base for streaming HTTP transports. The extra type parameter `S` carries the streaming capability evidence required by the sttp * [[sttp.client4.StreamBackend]], which is needed to consume Server-Sent Event responses as an asynchronous stream. */ -abstract class StreamingHttpTransport[F[_], S]( +abstract class ClientStreamingHttpTransport[F[_], S]( protected val backend: StreamBackend[F, S], protected val uri: Uri, protected val streams: Streams[S] -) extends BidirectionalTransport[F] +) extends ClientBidirectionalTransport[F] diff --git a/client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala b/client/src/main/scala/chimp/client/transport/ClientStreamingStdioTransport.scala similarity index 68% rename from client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala rename to client/src/main/scala/chimp/client/transport/ClientStreamingStdioTransport.scala index e1bc3d1..42b9b99 100644 --- a/client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientStreamingStdioTransport.scala @@ -5,9 +5,9 @@ import scala.concurrent.duration.FiniteDuration /** Abstract base for streaming stdio transports that should consume the subprocess's stdout as an asynchronous stream. */ -abstract class StreamingStdioTransport[F[_]]( +abstract class ClientStreamingStdioTransport[F[_]]( protected val command: List[String], protected val env: Map[String, String] = Map.empty, protected val workDir: Option[File] = None, - protected val timeout: FiniteDuration = Transport.defaultTimeout -) extends BidirectionalTransport[F] + protected val timeout: FiniteDuration = ClientTransport.defaultTimeout +) extends ClientBidirectionalTransport[F] diff --git a/client/src/main/scala/chimp/client/transport/Transport.scala b/client/src/main/scala/chimp/client/transport/ClientTransport.scala similarity index 68% rename from client/src/main/scala/chimp/client/transport/Transport.scala rename to client/src/main/scala/chimp/client/transport/ClientTransport.scala index e32e63e..2d18e2a 100644 --- a/client/src/main/scala/chimp/client/transport/Transport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientTransport.scala @@ -10,18 +10,18 @@ import scala.concurrent.duration.{DurationInt, FiniteDuration} /** A unidirectional MCP transport: the client sends a [[chimp.protocol.JSONRPCMessage]] and optionally receives a response back. For * transports that doesn't handle server initiated requests. */ -trait Transport[F[_]]: +trait ClientTransport[F[_]]: given monad: MonadError[F] def send(msg: JSONRPCMessage): F[Option[JSONRPCMessage]] def close(): F[Unit] -/** A bidirectional MCP transport that, in addition to [[Transport.send]] calls, allows the server to push messages to the client. Incoming - * messages are delivered to the registered handler via [[onIncoming]]. Used by [[chimp.client.BidirectionalMcpClient]]. +/** A bidirectional MCP transport that, in addition to [[ClientTransport.send]] calls, allows the server to push messages to the client. + * Incoming messages are delivered to the registered handler via [[onIncoming]]. Used by [[chimp.client.BidirectionalMcpClient]]. */ -trait BidirectionalTransport[F[_]] extends Transport[F]: +trait ClientBidirectionalTransport[F[_]] extends ClientTransport[F]: def onIncoming(handler: JSONRPCMessage => F[Unit]): F[Unit] -object Transport: +object ClientTransport: val defaultTimeout: FiniteDuration = 60.seconds diff --git a/client/src/test/scala/chimp/client/HttpTransportSpec.scala b/client/src/test/scala/chimp/client/ClientHttpTransportSpec.scala similarity index 84% rename from client/src/test/scala/chimp/client/HttpTransportSpec.scala rename to client/src/test/scala/chimp/client/ClientHttpTransportSpec.scala index 54e3328..f0724a0 100644 --- a/client/src/test/scala/chimp/client/HttpTransportSpec.scala +++ b/client/src/test/scala/chimp/client/ClientHttpTransportSpec.scala @@ -1,6 +1,6 @@ package chimp.client -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import io.circe.syntax.* @@ -11,7 +11,7 @@ import sttp.client4.testing.SyncBackendStub import sttp.model.StatusCode import sttp.shared.Identity -class HttpTransportSpec extends AnyFlatSpec with Matchers: +class ClientHttpTransportSpec extends AnyFlatSpec with Matchers: private val mcpUri = sttp.model.Uri.parse("http://localhost/mcp").toOption.get @@ -22,7 +22,7 @@ class HttpTransportSpec extends AnyFlatSpec with Matchers: StatusCode.Ok ) - val transport = HttpTransport[Identity](backend, mcpUri) + val transport = ClientHttpTransport[Identity](backend, mcpUri) val request: JSONRPCMessage = JSONRPCMessage.Request(method = "x", params = None, id = RequestId(1)) transport.send(request) match case Some(JSONRPCMessage.Response(_, _, result)) => Assertions.succeed @@ -30,13 +30,13 @@ class HttpTransportSpec extends AnyFlatSpec with Matchers: it should "return none for 202 Accepted (notification ack)" in: val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust("", StatusCode.Accepted) - val transport = HttpTransport[Identity](backend, mcpUri) + val transport = ClientHttpTransport[Identity](backend, mcpUri) val notification: JSONRPCMessage = JSONRPCMessage.Notification(method = "notifications/initialized") transport.send(notification) shouldBe None it should "fail with McpAuthorizationException on 401" in: val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust("", StatusCode.Unauthorized) - val transport = HttpTransport[Identity](backend, mcpUri) + val transport = ClientHttpTransport[Identity](backend, mcpUri) val request: JSONRPCMessage = JSONRPCMessage.Request(method = "x", params = None, id = RequestId(1)) val ex = intercept[McpAuthorizationException](transport.send(request)) ex.statusCode shouldBe 401 diff --git a/client/src/test/scala/chimp/client/InMemoryTransport.scala b/client/src/test/scala/chimp/client/InMemoryTransport.scala index f3c4e3c..ddcea52 100644 --- a/client/src/test/scala/chimp/client/InMemoryTransport.scala +++ b/client/src/test/scala/chimp/client/InMemoryTransport.scala @@ -1,6 +1,6 @@ package chimp.client -import chimp.client.transport.BidirectionalTransport +import chimp.client.transport.ClientBidirectionalTransport import chimp.protocol.JSONRPCMessage import sttp.monad.{IdentityMonad, MonadError} import sttp.shared.Identity @@ -8,7 +8,7 @@ import sttp.shared.Identity import java.util.concurrent.atomic.AtomicReference import scala.collection.mutable -final class InMemoryTransport extends BidirectionalTransport[Identity]: +final class InMemoryTransport extends ClientBidirectionalTransport[Identity]: given monad: MonadError[Identity] = IdentityMonad private val incomingHandler = AtomicReference[JSONRPCMessage => Identity[Unit]](_ => ()) diff --git a/client/src/test/scala/chimp/client/McpClientSpec.scala b/client/src/test/scala/chimp/client/McpClientSpec.scala index 8b08ca1..63c3077 100644 --- a/client/src/test/scala/chimp/client/McpClientSpec.scala +++ b/client/src/test/scala/chimp/client/McpClientSpec.scala @@ -1,6 +1,6 @@ package chimp.client -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.syntax.* import org.scalatest.flatspec.AnyFlatSpec @@ -30,7 +30,7 @@ class McpClientSpec extends AnyFlatSpec with Matchers: (JSONRPCMessage.Response(id = RequestId(1), result = initResult.asJson): JSONRPCMessage).asJson.noSpaces val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust(responseEnvelope) - val client = McpClient[Identity](HttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) + val client = McpClient[Identity](ClientHttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) client.serverInfo.name shouldBe "test-server" it should "call a tool and decode the result after initialization" in: @@ -53,7 +53,7 @@ class McpClientSpec extends AnyFlatSpec with Matchers: .whenAnyRequest .thenRespondAdjust("", StatusCode.Accepted) - val client = McpClient[Identity](HttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) + val client = McpClient[Identity](ClientHttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) val result = client.callTool("echo", io.circe.Json.obj("message" -> io.circe.Json.fromString("hi"))) result.isError shouldBe false result.content.head shouldBe ToolContent.Text("text", "hi") @@ -67,11 +67,11 @@ class McpClientSpec extends AnyFlatSpec with Matchers: val initEnvelope = (JSONRPCMessage.Response(id = RequestId(1), result = initResult.asJson): JSONRPCMessage).asJson.noSpaces val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust(initEnvelope) - val client = McpClient[Identity](HttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) + val client = McpClient[Identity](ClientHttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) val ex = intercept[McpProtocolException](client.callTool("anything", io.circe.Json.obj())) ex.getMessage should include("Server did not negotiate the capability required for tools/call") it should "fail construction when no initialize response is received" in: val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust("") - val t = HttpTransport[Identity](backend, mcpUri) + val t = ClientHttpTransport[Identity](backend, mcpUri) intercept[McpTransportException](McpClient[Identity](t, clientInfo, ProtocolVersion.Latest)) diff --git a/client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala index b40ca6a..befd94b 100644 --- a/client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala @@ -1,7 +1,7 @@ package chimp.client.integration import chimp.client.notifications.{ServerNotification, ServerNotificationListener} -import chimp.client.transport.Transport +import chimp.client.transport.ClientTransport import chimp.client.{BidirectionalMcpClient, McpTimeoutException} import chimp.protocol.* import io.circe.Json @@ -19,7 +19,7 @@ trait McpClientBidirectionalHttpTests[F[_]] extends AsyncFlatSpec with Matchers: protected def withProxiedBidirectionalClient( samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None, - timeout: FiniteDuration = Transport.defaultTimeout + timeout: FiniteDuration = ClientTransport.defaultTimeout )(test: (McpToxiproxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] "GET SSE stream" should "resume delivering notifications after the underlying connection is cut" in: diff --git a/client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala index 00f8021..0baa655 100644 --- a/client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala @@ -1,7 +1,7 @@ package chimp.client.integration import chimp.client.McpClient -import chimp.client.transport.Transport +import chimp.client.transport.ClientTransport import chimp.protocol.{Implementation, ProtocolVersion} import org.scalatest.flatspec.AsyncFlatSpec import org.scalatest.matchers.should.Matchers @@ -34,7 +34,7 @@ abstract class McpClientHttpIntegrationSpec[F[_], B] finally super.afterAll() def usingBackend[A](use: B => F[A]): F[A] - def usingTransport[A](backend: B, uri: Uri)(use: Transport[F] => F[A]): F[A] + def usingTransport[A](backend: B, uri: Uri)(use: ClientTransport[F] => F[A]): F[A] private val clientInfo = Implementation(name = "chimp-integration", version = "0.0.1") diff --git a/client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala index 2f14d32..f5ff3b9 100644 --- a/client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala @@ -1,6 +1,6 @@ package chimp.client.integration -import chimp.client.transport.{BidirectionalTransport, Transport} +import chimp.client.transport.{ClientBidirectionalTransport, ClientTransport} import chimp.client.{BidirectionalMcpClient, McpClient, McpTimeoutException} import chimp.protocol.* import org.scalatest.Assertion @@ -23,7 +23,7 @@ abstract class McpClientStdioIntegrationSpec[F[_]] protected val everythingServerCommand: List[String] = List("npx", "-y", "@modelcontextprotocol/server-everything@2026.1.26") - def usingTransport[A](command: List[String], timeout: FiniteDuration)(use: BidirectionalTransport[F] => F[A]): F[A] + def usingTransport[A](command: List[String], timeout: FiniteDuration)(use: ClientBidirectionalTransport[F] => F[A]): F[A] private val clientInfo = Implementation(name = "chimp-integration", version = "0.0.1") @@ -36,7 +36,7 @@ abstract class McpClientStdioIntegrationSpec[F[_]] elicitationHandler: Option[ElicitRequest => F[ElicitResult]] = None )(test: BidirectionalMcpClient[F] => F[Assertion]): Future[Assertion] = toFuture( - usingTransport(everythingServerCommand, Transport.defaultTimeout): transport => + usingTransport(everythingServerCommand, ClientTransport.defaultTimeout): transport => McpClient .bidirectional[F](transport, clientInfo, rootsHandler, samplingHandler, elicitationHandler, ProtocolVersion.Latest) .flatMap: client => diff --git a/client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala index 86601d3..e5028e9 100644 --- a/client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala @@ -1,6 +1,6 @@ package chimp.client.integration -import chimp.client.transport.{BidirectionalTransport, Transport} +import chimp.client.transport.{ClientBidirectionalTransport, ClientTransport} import chimp.client.{BidirectionalMcpClient, McpClient} import chimp.protocol.* import org.scalatest.Assertion @@ -26,10 +26,10 @@ abstract class McpClientStreamingHttpIntegrationSpec[F[_], B] try proxyContainer.stop() finally super.afterAll() - def usingBidirectionalTransport[A](b: B, uri: Uri, timeout: FiniteDuration)(use: BidirectionalTransport[F] => F[A]): F[A] + def usingBidirectionalTransport[A](b: B, uri: Uri, timeout: FiniteDuration)(use: ClientBidirectionalTransport[F] => F[A]): F[A] - override def usingTransport[A](backend: B, uri: Uri)(use: Transport[F] => F[A]): F[A] = - usingBidirectionalTransport(backend, uri, Transport.defaultTimeout)(use) + override def usingTransport[A](backend: B, uri: Uri)(use: ClientTransport[F] => F[A]): F[A] = + usingBidirectionalTransport(backend, uri, ClientTransport.defaultTimeout)(use) private val clientInfo = Implementation(name = "chimp-integration", version = "0.0.1") @@ -40,7 +40,7 @@ abstract class McpClientStreamingHttpIntegrationSpec[F[_], B] )(test: BidirectionalMcpClient[F] => F[Assertion]): Future[Assertion] = toFuture( usingBackend: backend => - usingBidirectionalTransport(backend, mcpEverythingContainer.mcpUri, Transport.defaultTimeout): transport => + usingBidirectionalTransport(backend, mcpEverythingContainer.mcpUri, ClientTransport.defaultTimeout): transport => McpClient .bidirectional[F](transport, clientInfo, rootsHandler, samplingHandler, elicitationHandler, ProtocolVersion.Latest) .flatMap: client => @@ -49,7 +49,7 @@ abstract class McpClientStreamingHttpIntegrationSpec[F[_], B] override protected def withProxiedBidirectionalClient( samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None, - timeout: FiniteDuration = Transport.defaultTimeout + timeout: FiniteDuration = ClientTransport.defaultTimeout )(test: (McpToxiproxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] = proxyContainer.restoreConnections() proxyContainer.clearToxics() diff --git a/examples/src/main/scala/examples/client/everythingClient.scala b/examples/src/main/scala/examples/client/everythingClient.scala index 424b6da..1e505d0 100644 --- a/examples/src/main/scala/examples/client/everythingClient.scala +++ b/examples/src/main/scala/examples/client/everythingClient.scala @@ -8,7 +8,7 @@ package examples.client import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend @@ -17,7 +17,7 @@ import sttp.shared.Identity @main def everythingClient(): Unit = val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:3001/mcp") + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:3001/mcp") val client = McpClient[Identity]( transport, clientInfo = Implementation(name = "chimp-everything-client", version = "0.1.0"), diff --git a/generated-docs/out/client/examples.md b/generated-docs/out/client/examples.md index 7a70b9f..7f52fe6 100644 --- a/generated-docs/out/client/examples.md +++ b/generated-docs/out/client/examples.md @@ -9,7 +9,7 @@ A synchronous client over `HttpTransport`, calling a tool: //> using dep com.softwaremill.sttp.client4::core:4.0.23 import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend @@ -17,15 +17,15 @@ import sttp.model.Uri.UriContext import sttp.shared.Identity @main def httpClient(): Unit = - val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:8080/mcp") - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +val backend = DefaultSyncBackend() +val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") +val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } +val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) +result.content.collect { case ToolContent.Text(_, text) => println(text) } - client.close() - backend.close() +client.close() +backend.close() ``` ## STDIO client @@ -36,19 +36,19 @@ A synchronous client that launches a local MCP server as a subprocess over `Stdi //> using dep com.softwaremill.chimp::chimp-client:0.2.0 import chimp.client.* -import chimp.client.transport.StdioTransport +import chimp.client.transport.ClientStdioTransport import chimp.protocol.* import io.circe.Json import sttp.shared.Identity @main def stdioClient(): Unit = - val transport = StdioTransport(command = List("my-mcp-server")) - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +val transport = StdioTransport(command = List("my-mcp-server")) +val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } +val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) +result.content.collect { case ToolContent.Text(_, text) => println(text) } - client.close() +client.close() ``` ## Roots over a ZIO streaming transport @@ -60,29 +60,35 @@ import sttp.shared.Identity //> using dep com.softwaremill.sttp.client4::zio:4.0.23 import chimp.client.* -import chimp.client.transport.zio.ZioStreamingHttpTransport +import chimp.client.transport.zio.ZioClientHttpTransport import chimp.protocol.* import sttp.client4.httpclient.zio.HttpClientZioBackend import sttp.model.Uri.UriContext import zio.* -object RootsClient extends ZIOAppDefault: - def run = - HttpClientZioBackend.scoped().flatMap { backend => - ZIO.scoped { - for - transport <- ZioStreamingHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") - client <- McpClient.bidirectional[Task]( - transport, - clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => - ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) - ) - tools <- client.listTools() - _ <- Console.printLine(s"server exposes ${tools.tools.size} tools") - yield () - } +object RootsClient extends ZIOAppDefault + +: +def run = + HttpClientZioBackend.scoped().flatMap { backend => + ZIO.scoped { + for + transport + <- ZioClientHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") + client + <- McpClient.bidirectional[Task]( + transport, + clientInfo = Implementation("my-client", "0.1.0"), + rootsHandler = Some(() => + ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + ) + tools + <- client.listTools() + _ + <- Console.printLine(s"server exposes ${tools.tools.size} tools") + yield () } + } ``` More runnable examples live in [`examples/`](https://github.com/softwaremill/chimp/tree/master/examples/src/main/scala/examples). diff --git a/generated-docs/out/client/quickstart.md b/generated-docs/out/client/quickstart.md index 538f576..6b0d640 100644 --- a/generated-docs/out/client/quickstart.md +++ b/generated-docs/out/client/quickstart.md @@ -17,7 +17,7 @@ Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable //> using dep com.softwaremill.sttp.client4::core:4.0.23 import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend @@ -25,15 +25,15 @@ import sttp.model.Uri.UriContext import sttp.shared.Identity @main def mcpClient(): Unit = - val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:8080/mcp") - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +val backend = DefaultSyncBackend() +val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") +val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } +val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) +result.content.collect { case ToolContent.Text(_, text) => println(text) } - client.close() - backend.close() +client.close() +backend.close() ``` For streaming transports (e.g. ZIO), also add: diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala similarity index 93% rename from server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala rename to server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala index 3d107a4..2609b06 100644 --- a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioStreamingHttpServerTransport.scala +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala @@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets /** ZIO implementation of the streaming HTTP server transport: the SSE stream is a `ZStream` of `ServerSentEvent`, and outbound messages are * interleaved with the final response through an unbounded queue drained by a daemon fiber. */ -final class ZioStreamingHttpServerTransport(path: List[String]) extends StreamingHttpServerTransport[Task, ZioStreams](path): +final class ZioServerHttpTransport(path: List[String]) extends StreamingHttpServerTransport[Task, ZioStreams](path): val streams: ZioStreams = ZioStreams type EventStream = Stream[Throwable, ServerSentEvent] diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala similarity index 76% rename from server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala rename to server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala index e8d73da..32525b8 100644 --- a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStreamingSpec.scala +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala @@ -1,7 +1,7 @@ package chimp.server.zio -import chimp.client.transport.Transport -import chimp.client.transport.zio.ZioStreamingHttpTransport +import chimp.client.transport.ClientTransport +import chimp.client.transport.zio.ZioClientHttpTransport import chimp.client.{BidirectionalMcpClient, McpClient} import chimp.protocol.{Implementation, ProtocolVersion} import chimp.server.{McpServer, McpServerStreamingTests, McpServerTests, StreamingMcpServer} @@ -14,7 +14,7 @@ import zio.{Scope, Task, ZIO} import scala.concurrent.Future -class ZioMcpServerStreamingSpec extends McpServerTests[Task] with McpServerStreamingTests[Task] with ZioToFuture: +class ZioMcpServerHttpSpec extends McpServerTests[Task] with McpServerStreamingTests[Task] with ZioToFuture: private val clientInfo = Implementation("chimp-server-test", "0.0.1") override protected def withServer(server: McpServer[Task])(test: McpClient[Task] => Task[Assertion]): Future[Assertion] = @@ -24,13 +24,13 @@ class ZioMcpServerStreamingSpec extends McpServerTests[Task] with McpServerStrea server: StreamingMcpServer[Task] )(test: BidirectionalMcpClient[Task] => Task[Assertion]): Future[Assertion] = toFuture: - val routes = ZioHttpInterpreter().toHttp(ZioStreamingHttpServerTransport(List("mcp")).serve(server)) + val routes = ZioHttpInterpreter().toHttp(ZioServerHttpTransport(List("mcp")).serve(server)) ZIO.scoped: (for port <- Server.install(routes) result <- HttpClientZioBackend().flatMap: backend => - ZioStreamingHttpTransport - .scoped(backend, uri"http://localhost:$port/mcp", ProtocolVersion.Latest, Transport.defaultTimeout) + ZioClientHttpTransport + .scoped(backend, uri"http://localhost:$port/mcp", ProtocolVersion.Latest, ClientTransport.defaultTimeout) .flatMap(transport => McpClient.bidirectional(transport, clientInfo)) .flatMap(client => test(client)) .ensuring(backend.close().ignore) diff --git a/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala b/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala index f5a9dfc..8238a81 100644 --- a/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala +++ b/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala @@ -1,7 +1,7 @@ package chimp.server import chimp.client.McpClient -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.Implementation import org.scalatest.Assertion import ox.supervised @@ -21,7 +21,7 @@ class SyncHttpMcpServerSpec extends McpServerTests[Identity] with SyncToFuture: try val backend = DefaultSyncBackend() try - val transport = HttpTransport[Identity](backend, uri"http://localhost:${binding.port}/mcp") + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:${binding.port}/mcp") try test(McpClient(transport, clientInfo)) finally transport.close() finally backend.close() From 3515cb82e726b4e403a0de900e7cc26f16712dbb Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 15:04:16 +0200 Subject: [PATCH 15/20] renaming --- .../scala/chimp/server/zio/ZioServerHttpTransport.scala | 4 ++-- server/src/main/scala/chimp/server/McpServer.scala | 4 ++-- ...{HttpServerTransport.scala => ServerHttpTransport.scala} | 2 +- ...tdioServerTransport.scala => ServerStdioTransport.scala} | 4 ++-- ...erTransport.scala => ServerStreamingHttpTransport.scala} | 2 +- ...rTransport.scala => ServerStreamingStdioTransport.scala} | 2 +- ...erTransportSpec.scala => ServerStdioTransportSpec.scala} | 6 +++--- 7 files changed, 12 insertions(+), 12 deletions(-) rename server/src/main/scala/chimp/server/transport/{HttpServerTransport.scala => ServerHttpTransport.scala} (95%) rename server/src/main/scala/chimp/server/transport/{StdioServerTransport.scala => ServerStdioTransport.scala} (94%) rename server/src/main/scala/chimp/server/transport/{StreamingHttpServerTransport.scala => ServerStreamingHttpTransport.scala} (97%) rename server/src/main/scala/chimp/server/transport/{StreamingStdioServerTransport.scala => ServerStreamingStdioTransport.scala} (74%) rename server/src/test/scala/chimp/server/{StdioServerTransportSpec.scala => ServerStdioTransportSpec.scala} (95%) diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala index 2609b06..9ea300b 100644 --- a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala @@ -2,7 +2,7 @@ package chimp.server.zio import chimp.protocol.JSONRPCMessage import chimp.server.OutboundSink -import chimp.server.transport.StreamingHttpServerTransport +import chimp.server.transport.ServerStreamingHttpTransport import io.circe.Json import io.circe.syntax.* import sttp.capabilities.zio.ZioStreams @@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets /** ZIO implementation of the streaming HTTP server transport: the SSE stream is a `ZStream` of `ServerSentEvent`, and outbound messages are * interleaved with the final response through an unbounded queue drained by a daemon fiber. */ -final class ZioServerHttpTransport(path: List[String]) extends StreamingHttpServerTransport[Task, ZioStreams](path): +final class ZioServerHttpTransport(path: List[String]) extends ServerStreamingHttpTransport[Task, ZioStreams](path): val streams: ZioStreams = ZioStreams type EventStream = Stream[Throwable, ServerSentEvent] diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala index 993e7d1..6ad88e2 100644 --- a/server/src/main/scala/chimp/server/McpServer.scala +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -1,7 +1,7 @@ package chimp.server import chimp.protocol.* -import chimp.server.transport.HttpServerTransport +import chimp.server.transport.ServerHttpTransport import sttp.tapir.server.ServerEndpoint type CompletionHandler[F[_]] = (CompleteRef, CompleteArgument, Option[CompleteContext]) => F[Completion] @@ -89,7 +89,7 @@ case class McpServer[F[_]]( def withSubscriptions(handler: ResourceSubscriptions[F]): McpServer[F] = copy(subscriptions = Some(handler)) - def endpoint(path: List[String]): ServerEndpoint[Any, F] = HttpServerTransport(path).serve(this) + def endpoint(path: List[String]): ServerEndpoint[Any, F] = ServerHttpTransport(path).serve(this) def streaming: StreamingMcpServer[F] = StreamingMcpServer( diff --git a/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala b/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala similarity index 95% rename from server/src/main/scala/chimp/server/transport/HttpServerTransport.scala rename to server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala index e9f8f11..1b91bda 100644 --- a/server/src/main/scala/chimp/server/transport/HttpServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala @@ -15,7 +15,7 @@ import sttp.tapir.server.ServerEndpoint * @param path * The MCP endpoint path. */ -final case class HttpServerTransport[F[_]](path: List[String]) extends ServerTransport[F, ServerEndpoint[Any, F]]: +final case class ServerHttpTransport[F[_]](path: List[String]) extends ServerTransport[F, ServerEndpoint[Any, F]]: def serve(server: McpServer[F]): ServerEndpoint[Any, F] = val handler = new McpHandler(server) val endpoint = infallibleEndpoint.post diff --git a/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala b/server/src/main/scala/chimp/server/transport/ServerStdioTransport.scala similarity index 94% rename from server/src/main/scala/chimp/server/transport/StdioServerTransport.scala rename to server/src/main/scala/chimp/server/transport/ServerStdioTransport.scala index 6c0b079..52cf63c 100644 --- a/server/src/main/scala/chimp/server/transport/StdioServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/ServerStdioTransport.scala @@ -19,11 +19,11 @@ import java.nio.charset.StandardCharsets * @param out * Server output stream. */ -final class StdioServerTransport(in: InputStream = System.in, out: OutputStream = System.out) +final class ServerStdioTransport(in: InputStream = System.in, out: OutputStream = System.out) extends ServerTransport[Identity, Unit] with StreamingServerTransport[Identity, Unit]: - private val log = LoggerFactory.getLogger(classOf[StdioServerTransport]) + private val log = LoggerFactory.getLogger(classOf[ServerStdioTransport]) def serve(server: McpServer[Identity]): Unit = serve(server.streaming) diff --git a/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala b/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala similarity index 97% rename from server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala rename to server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala index 2a1fb76..4dae664 100644 --- a/server/src/main/scala/chimp/server/transport/StreamingHttpServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala @@ -20,7 +20,7 @@ import sttp.tapir.server.ServerEndpoint * @param path * The MCP endpoint path. */ -abstract class StreamingHttpServerTransport[F[_], S](path: List[String]) extends StreamingServerTransport[F, ServerEndpoint[S, F]]: +abstract class ServerStreamingHttpTransport[F[_], S](path: List[String]) extends StreamingServerTransport[F, ServerEndpoint[S, F]]: val streams: Streams[S] type EventStream def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] diff --git a/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala b/server/src/main/scala/chimp/server/transport/ServerStreamingStdioTransport.scala similarity index 74% rename from server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala rename to server/src/main/scala/chimp/server/transport/ServerStreamingStdioTransport.scala index f7f9e87..1bb076d 100644 --- a/server/src/main/scala/chimp/server/transport/StreamingStdioServerTransport.scala +++ b/server/src/main/scala/chimp/server/transport/ServerStreamingStdioTransport.scala @@ -2,4 +2,4 @@ package chimp.server.transport /** Abstract base for streaming stdio MCP server transports that should consume the stdin as an asynchronous stream. */ -abstract class StreamingStdioServerTransport[F[_]] extends StreamingServerTransport[F, F[Unit]] +abstract class ServerStreamingStdioTransport[F[_]] extends StreamingServerTransport[F, F[Unit]] diff --git a/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala b/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala similarity index 95% rename from server/src/test/scala/chimp/server/StdioServerTransportSpec.scala rename to server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala index 6c26ed7..06bfa55 100644 --- a/server/src/test/scala/chimp/server/StdioServerTransportSpec.scala +++ b/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala @@ -1,7 +1,7 @@ package chimp.server import chimp.protocol.{JSONRPCErrorCodes, LoggingLevel} -import chimp.server.transport.StdioServerTransport +import chimp.server.transport.ServerStdioTransport import io.circe.{parser, Codec, Json} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers @@ -11,7 +11,7 @@ import sttp.tapir.Schema import java.io.* import java.nio.charset.StandardCharsets -class StdioServerTransportSpec extends AnyFlatSpec with Matchers: +class ServerStdioTransportSpec extends AnyFlatSpec with Matchers: private case class EchoInput(message: String) derives Codec, Schema private case class NoInput() derives Codec, Schema @@ -36,7 +36,7 @@ class StdioServerTransportSpec extends AnyFlatSpec with Matchers: val fromServer = PipedInputStream() val serverOut = PipedOutputStream(fromServer) - val thread = Thread(() => StdioServerTransport(serverIn, serverOut).serve(server)) + val thread = Thread(() => ServerStdioTransport(serverIn, serverOut).serve(server)) thread.setDaemon(true) thread.start() From 0bfa048a971de9fbbc5cf9afd0ebff1ac856afaf Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 15:36:46 +0200 Subject: [PATCH 16/20] stdio implementation for zio server --- .../server/zio/ZioServerHttpTransport.scala | 3 - .../server/zio/ZioServerStdioTransport.scala | 54 ++++++++++ .../server/zio/ZioMcpServerStdioSpec.scala | 18 ++++ .../server/ServerStdioTransportSpec.scala | 85 +--------------- .../server/ServerStdioTransportTests.scala | 98 +++++++++++++++++++ 5 files changed, 174 insertions(+), 84 deletions(-) create mode 100644 server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerStdioTransport.scala create mode 100644 server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStdioSpec.scala create mode 100644 server/src/test/scala/chimp/server/ServerStdioTransportTests.scala diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala index 9ea300b..fe70d6d 100644 --- a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala @@ -14,9 +14,6 @@ import zio.{Queue, Task, ZIO} import java.nio.charset.StandardCharsets -/** ZIO implementation of the streaming HTTP server transport: the SSE stream is a `ZStream` of `ServerSentEvent`, and outbound messages are - * interleaved with the final response through an unbounded queue drained by a daemon fiber. - */ final class ZioServerHttpTransport(path: List[String]) extends ServerStreamingHttpTransport[Task, ZioStreams](path): val streams: ZioStreams = ZioStreams diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerStdioTransport.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerStdioTransport.scala new file mode 100644 index 0000000..7986de0 --- /dev/null +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerStdioTransport.scala @@ -0,0 +1,54 @@ +package chimp.server.zio + +import chimp.protocol.{JSONRPCMessage, ProgressToken} +import chimp.server.{McpHandler, OutboundSink, SinkStreamingServerContext, StreamingMcpServer, StreamingServerContext} +import chimp.server.transport.ServerStreamingStdioTransport +import io.circe.syntax.* +import io.circe.{parser, Json} +import org.slf4j.LoggerFactory +import sttp.monad.MonadError +import sttp.tapir.ztapir.RIOMonadError +import zio.{Task, ZIO} + +import java.io.{BufferedReader, BufferedWriter, InputStream, InputStreamReader, OutputStream, OutputStreamWriter} +import java.nio.charset.StandardCharsets + +final class ZioServerStdioTransport(in: InputStream = System.in, out: OutputStream = System.out) + extends ServerStreamingStdioTransport[Task]: + private val log = LoggerFactory.getLogger(classOf[ZioServerStdioTransport]) + private given MonadError[Task] = new RIOMonadError[Any] + + def serve(server: StreamingMcpServer[Task]): Task[Unit] = + val handler = new McpHandler[Task, StreamingServerContext[Task]](server) + val reader = BufferedReader(InputStreamReader(in, StandardCharsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8)) + + def writeLine(json: Json): Task[Unit] = + ZIO.attemptBlocking: + writer.write(json.noSpaces) + writer.newLine() + writer.flush() + + val sink = new OutboundSink[Task]: + def send(message: JSONRPCMessage): Task[Unit] = writeLine(message.asJson.deepDropNullValues) + + val makeContext: Option[ProgressToken] => StreamingServerContext[Task] = + token => SinkStreamingServerContext(sink, token) + + def loop: Task[Unit] = + ZIO + .attemptBlocking(Option(reader.readLine())) + .flatMap: + case None => ZIO.unit + case Some(line) => + val handled = + if line.isEmpty then ZIO.unit + else + parser.parse(line) match + case Right(json) => + handler.handleJsonRpc(json, Nil, makeContext).flatMap(response => ZIO.foreachDiscard(response.body)(writeLine)) + case Left(error) => + ZIO.succeed(log.warn(s"Failed to parse JSON-RPC line: ${error.getMessage}; raw: $line")) + handled *> loop + + loop diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStdioSpec.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStdioSpec.scala new file mode 100644 index 0000000..8c7bcd4 --- /dev/null +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStdioSpec.scala @@ -0,0 +1,18 @@ +package chimp.server.zio + +import chimp.server.{ServerStdioTransportTests, StreamingMcpServer} +import zio.{Runtime, Task, Unsafe} + +import java.io.{InputStream, OutputStream} + +class ZioMcpServerStdioSpec extends ServerStdioTransportTests[Task] with ZioToFuture: + private val runtime = Runtime.default + + override protected def runStdioServer(server: StreamingMcpServer[Task], in: InputStream, out: OutputStream): Unit = + val thread = Thread(() => + Unsafe.unsafe { implicit u => + runtime.unsafe.run(ZioServerStdioTransport(in, out).serve(server)).getOrThrowFiberFailure() + } + ) + thread.setDaemon(true) + thread.start() diff --git a/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala b/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala index 06bfa55..df50b51 100644 --- a/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala +++ b/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala @@ -1,89 +1,12 @@ package chimp.server -import chimp.protocol.{JSONRPCErrorCodes, LoggingLevel} import chimp.server.transport.ServerStdioTransport -import io.circe.{parser, Codec, Json} -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers import sttp.shared.Identity -import sttp.tapir.Schema -import java.io.* -import java.nio.charset.StandardCharsets +import java.io.{InputStream, OutputStream} -class ServerStdioTransportSpec extends AnyFlatSpec with Matchers: - private case class EchoInput(message: String) derives Codec, Schema - private case class NoInput() derives Codec, Schema - - private def server: StreamingMcpServer[Identity] = - StreamingMcpServer[Identity]() - .withLoggingLevel(_ => ()) - .addTool(tool("echo").input[EchoInput].handle(in => ToolResult.text(in.message))) - .addStreamingTool( - tool("noisy") - .input[NoInput] - .streamingServerLogic[Identity] { (_, ctx, _) => - ctx.log(LoggingLevel.Info, Json.fromString("one")) - ctx.log(LoggingLevel.Info, Json.fromString("two")) - ctx.log(LoggingLevel.Info, Json.fromString("three")) - ToolResult.text("done") - } - ) - - private def withStdioServer[A](server: StreamingMcpServer[Identity])(body: (String => Unit, () => Json) => A): A = - val toServer = PipedOutputStream() - val serverIn = PipedInputStream(toServer) - val fromServer = PipedInputStream() - val serverOut = PipedOutputStream(fromServer) - - val thread = Thread(() => ServerStdioTransport(serverIn, serverOut).serve(server)) +class ServerStdioTransportSpec extends ServerStdioTransportTests[Identity] with SyncToFuture: + override protected def runStdioServer(server: StreamingMcpServer[Identity], in: InputStream, out: OutputStream): Unit = + val thread = Thread(() => ServerStdioTransport(in, out).serve(server)) thread.setDaemon(true) thread.start() - - val writer = BufferedWriter(OutputStreamWriter(toServer, StandardCharsets.UTF_8)) - val reader = BufferedReader(InputStreamReader(fromServer, StandardCharsets.UTF_8)) - - def send(line: String): Unit = - writer.write(line) - writer.newLine() - writer.flush() - - def readResponse(): Json = parser.parse(reader.readLine()).toOption.get - - try body(send, readResponse) - finally - writer.close() - thread.join(2000) - - "a stdio server" should "answer requests and stream notifications over stdin/stdout" in withStdioServer(server) { (send, readResponse) => - send( - """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""" - ) - val init = readResponse() - init.hcursor.downField("id").as[Int] shouldBe Right(1) - init.hcursor.downField("result").downField("serverInfo").downField("name").as[String].isRight shouldBe true - - send("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}""") - val echo = readResponse() - echo.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("hi") - - send("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"noisy","arguments":{}}}""") - val notifications = List(readResponse(), readResponse(), readResponse()) - notifications.map(_.hcursor.downField("method").as[String]) shouldBe List.fill(3)(Right("notifications/message")) - notifications.flatMap(_.hcursor.downField("params").downField("data").as[String].toOption) shouldBe List("one", "two", "three") - - val response = readResponse() - response.hcursor.downField("id").as[Int] shouldBe Right(3) - response.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("done") - } - - it should "skip notifications and malformed lines, and still report protocol errors" in withStdioServer(server) { (send, readResponse) => - send("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") - send("this is not valid json") - send("""{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"missing","arguments":{}}}""") - - val error = readResponse() - error.hcursor.downField("id").as[Int] shouldBe Right(9) - error.hcursor.downField("error").downField("code").as[Int] shouldBe Right(JSONRPCErrorCodes.MethodNotFound.code) - error.hcursor.downField("error").downField("message").as[String].toOption.get should include("missing") - } diff --git a/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala b/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala new file mode 100644 index 0000000..99317c7 --- /dev/null +++ b/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala @@ -0,0 +1,98 @@ +package chimp.server + +import chimp.protocol.{JSONRPCErrorCodes, LoggingLevel} +import io.circe.{parser, Codec, Json} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.monad.syntax.* +import sttp.tapir.Schema + +import java.io.{ + BufferedReader, + BufferedWriter, + InputStream, + InputStreamReader, + OutputStream, + OutputStreamWriter, + PipedInputStream, + PipedOutputStream +} +import java.nio.charset.StandardCharsets + +trait ServerStdioTransportTests[F[_]] extends AnyFlatSpec with Matchers: + this: ToFuture[F] => + + protected def runStdioServer(server: StreamingMcpServer[F], in: InputStream, out: OutputStream): Unit + + private case class EchoInput(message: String) derives Codec, Schema + private case class NoInput() derives Codec, Schema + + private def server: StreamingMcpServer[F] = + StreamingMcpServer[F]() + .withLoggingLevel(_ => monad.unit(())) + .addTool(tool("echo").input[EchoInput].serverLogic[F]((in, _, _) => monad.unit(ToolResult.text(in.message)))) + .addStreamingTool( + tool("noisy") + .input[NoInput] + .streamingServerLogic[F] { (_, ctx, _) => + for + _ <- ctx.log(LoggingLevel.Info, Json.fromString("one")) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("two")) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("three")) + yield ToolResult.text("done") + } + ) + + private def withStdioServer[A](body: (String => Unit, () => Json) => A): A = + val toServer = PipedOutputStream() + val serverIn = PipedInputStream(toServer) + val fromServer = PipedInputStream() + val serverOut = PipedOutputStream(fromServer) + + runStdioServer(server, serverIn, serverOut) + + val writer = BufferedWriter(OutputStreamWriter(toServer, StandardCharsets.UTF_8)) + val reader = BufferedReader(InputStreamReader(fromServer, StandardCharsets.UTF_8)) + + def send(line: String): Unit = + writer.write(line) + writer.newLine() + writer.flush() + + def readResponse(): Json = parser.parse(reader.readLine()).toOption.get + + try body(send, readResponse) + finally writer.close() + + "a stdio server" should "answer requests and stream notifications over stdin/stdout" in withStdioServer { (send, readResponse) => + send( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""" + ) + val init = readResponse() + init.hcursor.downField("id").as[Int] shouldBe Right(1) + init.hcursor.downField("result").downField("serverInfo").downField("name").as[String].isRight shouldBe true + + send("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}""") + val echo = readResponse() + echo.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("hi") + + send("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"noisy","arguments":{}}}""") + val notifications = List(readResponse(), readResponse(), readResponse()) + notifications.map(_.hcursor.downField("method").as[String]) shouldBe List.fill(3)(Right("notifications/message")) + notifications.flatMap(_.hcursor.downField("params").downField("data").as[String].toOption) shouldBe List("one", "two", "three") + + val response = readResponse() + response.hcursor.downField("id").as[Int] shouldBe Right(3) + response.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("done") + } + + it should "skip notifications and malformed lines, and still report protocol errors" in withStdioServer { (send, readResponse) => + send("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + send("this is not valid json") + send("""{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"missing","arguments":{}}}""") + + val error = readResponse() + error.hcursor.downField("id").as[Int] shouldBe Right(9) + error.hcursor.downField("error").downField("code").as[Int] shouldBe Right(JSONRPCErrorCodes.MethodNotFound.code) + error.hcursor.downField("error").downField("message").as[String].toOption.get should include("missing") + } From 5fcd97ecc0ab9fe4ca8158ece1b9706ff8e96dfb Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 16:39:13 +0200 Subject: [PATCH 17/20] feat: documentation updates --- build.sbt | 3 +- docs/client/capabilities.md | 39 ++++++---- docs/client/examples.md | 61 +++++++-------- docs/client/quickstart.md | 24 +++--- docs/client/transport.md | 34 +++++---- docs/index.md | 7 +- docs/server/capabilities.md | 36 +++++++++ docs/server/examples.md | 91 +++++++++++++++++++++++ docs/server/prompts.md | 23 ++++++ docs/server/protocol.md | 9 --- docs/server/resources.md | 25 +++++++ docs/server/tools.md | 34 +++++++-- docs/server/transport.md | 49 ++++++++++++ docs/server/zio.md | 8 -- generated-docs/out/README.md | 2 +- generated-docs/out/client/capabilities.md | 35 ++++++--- generated-docs/out/client/examples.md | 81 +++++++++----------- generated-docs/out/client/quickstart.md | 24 +++--- generated-docs/out/client/transport.md | 34 +++++---- generated-docs/out/index.md | 7 +- generated-docs/out/server/capabilities.md | 36 +++++++++ generated-docs/out/server/examples.md | 91 +++++++++++++++++++++++ generated-docs/out/server/prompts.md | 23 ++++++ generated-docs/out/server/protocol.md | 9 --- generated-docs/out/server/quickstart.md | 8 +- generated-docs/out/server/resources.md | 25 +++++++ generated-docs/out/server/tools.md | 34 +++++++-- generated-docs/out/server/transport.md | 49 ++++++++++++ generated-docs/out/server/zio.md | 8 -- 29 files changed, 690 insertions(+), 219 deletions(-) create mode 100644 docs/server/capabilities.md create mode 100644 docs/server/examples.md create mode 100644 docs/server/prompts.md delete mode 100644 docs/server/protocol.md create mode 100644 docs/server/resources.md create mode 100644 docs/server/transport.md delete mode 100644 docs/server/zio.md create mode 100644 generated-docs/out/server/capabilities.md create mode 100644 generated-docs/out/server/examples.md create mode 100644 generated-docs/out/server/prompts.md delete mode 100644 generated-docs/out/server/protocol.md create mode 100644 generated-docs/out/server/resources.md create mode 100644 generated-docs/out/server/transport.md delete mode 100644 generated-docs/out/server/zio.md diff --git a/build.sbt b/build.sbt index 856ed0b..85b40f5 100644 --- a/build.sbt +++ b/build.sbt @@ -262,7 +262,8 @@ lazy val docs: Project = (project in file("generated-docs")) ), mdocOut := file("generated-docs/out"), mdocExtraArguments := Seq("--clean-target", "--exclude", ".venv", "--exclude", "_build"), + libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV, publishArtifact := false, name := "docs" ) - .dependsOn(core, server, client, clientZio) + .dependsOn(core, server, serverZio, client, clientZio) diff --git a/docs/client/capabilities.md b/docs/client/capabilities.md index 27464bd..f9e28d3 100644 --- a/docs/client/capabilities.md +++ b/docs/client/capabilities.md @@ -9,26 +9,37 @@ Beyond calling tools, an MCP client can advertise capabilities that let the serv - [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#notifications) — receiving server-pushed events such as resource updates and list changes. ```{note} -All of these require the server to push messages to the client, so they only work over a **bidirectional, streaming transport** (e.g. `ZioStreamingHttpTransport`). They are unavailable on the plain `HttpTransport`. +All of these require the server to push messages to the client, so they only work over a **bidirectional, streaming transport** (e.g. `ZioClientHttpTransport`). They are unavailable on the plain `ClientHttpTransport`. ``` Create the client with `McpClient.bidirectional`, providing a handler for each capability you want to enable — only capabilities backed by a handler are advertised to the server: -```scala -val client = McpClient.bidirectional[Task]( - transport, - clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))), - // samplingHandler = Some(...), - // elicitationHandler = Some(...), -) +```scala mdoc:compile-only +import chimp.client.* +import chimp.client.transport.ClientBidirectionalTransport +import chimp.protocol.* +import zio.* + +def connect(transport: ClientBidirectionalTransport[Task]): Task[BidirectionalMcpClient[Task]] = + McpClient.bidirectional[Task]( + transport, + clientInfo = Implementation("my-client", "0.1.0"), + rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + // samplingHandler = Some(...), + // elicitationHandler = Some(...), + ) ``` Register a listener for server notifications with `onServerNotification`: -```scala -client.onServerNotification { - case ServerNotification.ResourceUpdated(uri) => ZIO.logInfo(s"resource changed: $uri") - case _ => ZIO.unit -} +```scala mdoc:compile-only +import chimp.client.* +import chimp.client.notifications.ServerNotification +import zio.* + +def listen(client: BidirectionalMcpClient[Task]): Task[Unit] = + client.onServerNotification { + case ServerNotification.ResourceUpdated(params) => ZIO.logInfo(s"resource changed: ${params.uri}") + case _ => ZIO.unit + } ``` diff --git a/docs/client/examples.md b/docs/client/examples.md index 7a70b9f..e853e94 100644 --- a/docs/client/examples.md +++ b/docs/client/examples.md @@ -2,65 +2,59 @@ ## HTTP client -A synchronous client over `HttpTransport`, calling a tool: - -```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 -//> using dep com.softwaremill.sttp.client4::core:4.0.23 +A synchronous client over `ClientHttpTransport`, calling a tool: +```scala mdoc:compile-only import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend import sttp.model.Uri.UriContext import sttp.shared.Identity -@main def httpClient(): Unit = - val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:8080/mcp") - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object HttpClient: + def main(args: Array[String]): Unit = + val backend = DefaultSyncBackend() + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() - backend.close() + client.close() + backend.close() ``` ## STDIO client -A synchronous client that launches a local MCP server as a subprocess over `StdioTransport`: - -```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 +A synchronous client that launches a local MCP server as a subprocess over `ClientStdioTransport`: +```scala mdoc:compile-only import chimp.client.* -import chimp.client.transport.StdioTransport +import chimp.client.transport.ClientStdioTransport import chimp.protocol.* import io.circe.Json import sttp.shared.Identity -@main def stdioClient(): Unit = - val transport = StdioTransport(command = List("my-mcp-server")) - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object StdioClient: + def main(args: Array[String]): Unit = + val transport = ClientStdioTransport(command = List("my-mcp-server")) + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() + client.close() ``` ## Roots over a ZIO streaming transport -[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) require a bidirectional, streaming transport — here `ZioStreamingHttpTransport`: - -```scala -//> using dep com.softwaremill.chimp::chimp-client-zio:0.2.0 -//> using dep com.softwaremill.sttp.client4::zio:4.0.23 +[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) require a bidirectional, streaming transport — here `ZioClientHttpTransport`: +```scala mdoc:compile-only import chimp.client.* -import chimp.client.transport.zio.ZioStreamingHttpTransport +import chimp.client.transport.zio.ZioClientHttpTransport import chimp.protocol.* import sttp.client4.httpclient.zio.HttpClientZioBackend import sttp.model.Uri.UriContext @@ -71,12 +65,11 @@ object RootsClient extends ZIOAppDefault: HttpClientZioBackend.scoped().flatMap { backend => ZIO.scoped { for - transport <- ZioStreamingHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") + transport <- ZioClientHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") client <- McpClient.bidirectional[Task]( transport, clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => - ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) ) tools <- client.listTools() _ <- Console.printLine(s"server exposes ${tools.tools.size} tools") diff --git a/docs/client/quickstart.md b/docs/client/quickstart.md index fe13cf9..da2cf0e 100644 --- a/docs/client/quickstart.md +++ b/docs/client/quickstart.md @@ -12,28 +12,26 @@ libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.3.0" Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example that connects to an MCP server over HTTP and invokes a tool: -```scala -//> using dep com.softwaremill.chimp::chimp-client:0.3.0 -//> using dep com.softwaremill.sttp.client4::core:4.0.23 - +```scala mdoc:compile-only import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend import sttp.model.Uri.UriContext import sttp.shared.Identity -@main def mcpClient(): Unit = - val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:8080/mcp") - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object QuickstartClient: + def main(args: Array[String]): Unit = + val backend = DefaultSyncBackend() + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() - backend.close() + client.close() + backend.close() ``` For streaming transports (e.g. ZIO), also add: diff --git a/docs/client/transport.md b/docs/client/transport.md index 64aac4a..c1f8b0c 100644 --- a/docs/client/transport.md +++ b/docs/client/transport.md @@ -2,38 +2,46 @@ A transport carries JSON-RPC messages between the client and the server. There are two families: -- **Unidirectional** (`Transport[F]`) — the client sends a message and optionally gets a response back. Enough for calling tools, listing resources, etc. -- **Bidirectional** (`BidirectionalTransport[F]`) — additionally lets the server push messages to the client (server-initiated requests and notifications). Required for [client capabilities](capabilities.md). +- **Unidirectional** (`ClientTransport[F]`) — the client sends a message and optionally gets a response back. Enough for calling tools, listing resources, etc. +- **Bidirectional** (`ClientBidirectionalTransport[F]`) — additionally lets the server push messages to the client (server-initiated requests and notifications). Required for [client capabilities](capabilities.md). The streaming transports are abstract; their concrete, effect-specific implementations live in separate modules (e.g. ZIO). ```{mermaid} classDiagram - class Transport~F~ { + class ClientTransport~F~ { <> +send(msg) Option~Message~ +close() } - class BidirectionalTransport~F~ { + class ClientBidirectionalTransport~F~ { <> +onIncoming(handler) } - class HttpTransport~F~ - class StdioTransport - class StreamingHttpTransport~F, S~ { + class ClientHttpTransport~F~ + class ClientStdioTransport + class ClientStreamingHttpTransport~F, S~ { <> } - class StreamingStdioTransport~F~ { + class ClientStreamingStdioTransport~F~ { <> } - Transport <|-- BidirectionalTransport - Transport <|-- HttpTransport - BidirectionalTransport <|-- StdioTransport - BidirectionalTransport <|-- StreamingHttpTransport - BidirectionalTransport <|-- StreamingStdioTransport + ClientTransport <|-- ClientBidirectionalTransport + ClientTransport <|-- ClientHttpTransport + ClientBidirectionalTransport <|-- ClientStdioTransport + ClientBidirectionalTransport <|-- ClientStreamingHttpTransport + ClientBidirectionalTransport <|-- ClientStreamingStdioTransport ``` +## Streaming integrations + +The streaming transports have concrete implementations per effect system, in separate modules: + +| Integration | Streaming HTTP | STDIO | +|---|---|---| +| ZIO | `ZioClientHttpTransport` | `ZioClientStdioTransport` | + ## Backends - **HTTP** transports run on any [sttp](https://sttp.softwaremill.com/en/latest/) backend. The streaming HTTP transports additionally require a backend with streaming capability. diff --git a/docs/index.md b/docs/index.md index da56c91..1504059 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,9 +10,12 @@ and [sttp](https://github.com/softwaremill/sttp), supporting the variety of the :caption: Server server/quickstart - server/protocol + server/transport server/tools - server/zio + server/prompts + server/resources + server/capabilities + server/examples .. toctree:: :maxdepth: 2 diff --git a/docs/server/capabilities.md b/docs/server/capabilities.md new file mode 100644 index 0000000..209882b --- /dev/null +++ b/docs/server/capabilities.md @@ -0,0 +1,36 @@ +# Server capabilities + +Tool logic runs with a **server context**. There are two: + +- `ServerContext[F]` — available on every server; exposes cancellation observation (`isCancelled`, `onCancel`). +- `StreamingServerContext[F]` — extends it with server→client interactions emitted while a tool runs: + - `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. + - `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. + - `sample` / `elicit` — server-initiated [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) and [elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) requests (planned). + +```{note} +Pushing to the client requires an open stream, so `StreamingServerContext` is only available over a **streaming transport**. A tool that uses it is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. +``` + +Use `serverLogic` for a tool that only needs the base context, and `streamingServerLogic` for one that pushes to the client: + +```scala mdoc:compile-only +import chimp.server.* +import chimp.protocol.LoggingLevel +import io.circe.{Codec, Json} +import sttp.shared.Identity +import sttp.tapir.* + +case class WorkInput(steps: Int) derives Codec, Schema + +val work = tool("work") + .input[WorkInput] + .streamingServerLogic[Identity]: (_, ctx, _) => + ctx.reportProgress(0.5, total = Some(1.0)) + ctx.log(LoggingLevel.Info, Json.fromString("halfway")) + ToolResult.text("done") + +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`. diff --git a/docs/server/examples.md b/docs/server/examples.md new file mode 100644 index 0000000..a044236 --- /dev/null +++ b/docs/server/examples.md @@ -0,0 +1,91 @@ +# Examples + +Each example builds an `McpServer` (or `StreamingMcpServer`) and serves it over a transport. The sync HTTP example uses `chimp-server`; the ZIO examples additionally use `chimp-server-zio`. + +## HTTP server + +A synchronous server exposed with the Tapir Netty interpreter: + +```scala mdoc:compile-only +import chimp.server.* +import io.circe.Codec +import sttp.tapir.* +import sttp.tapir.server.netty.sync.NettySyncServer + +case class SyncAddInput(a: Int, b: Int) derives Codec, Schema + +object HttpSyncServer: + def main(args: Array[String]): Unit = + val adder = tool("adder").description("Adds two numbers").input[SyncAddInput].handle(in => ToolResult.text(s"The result is ${in.a + in.b}")) + val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) + NettySyncServer().port(8080).addEndpoint(endpoint).startAndWait() +``` + +## HTTP server (ZIO) + +The Tapir-ZIO integration requires a `RIO[R, A]` effect (error channel fixed to `Throwable`), so the effect type is stated explicitly: + +```scala mdoc:compile-only +import chimp.server.{McpServer, ToolResult, tool} +import io.circe.Codec +import sttp.tapir.* +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.{RIO, ZIO, ZIOAppDefault} +import zio.http.Server + +case class ZioAddInput(a: Int, b: Int) derives Codec, Schema + +object HttpZioServer extends ZIOAppDefault: + val adder = tool("adder").description("Adds two numbers").input[ZioAddInput].serverLogic[[X] =>> RIO[Any, X]]: (in, _, _) => + ZIO.succeed(ToolResult.text(s"The result is ${in.a + in.b}")) + val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) + override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) +``` + +## Streaming HTTP server (ZIO) + +A streaming tool that pushes progress and log notifications over SSE while it runs, served with `ZioServerHttpTransport`: + +```scala mdoc:compile-only +import chimp.server.{StreamingMcpServer, ToolResult, tool} +import chimp.server.zio.ZioServerHttpTransport +import chimp.protocol.LoggingLevel +import io.circe.{Codec, Json} +import sttp.tapir.* +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.{Task, ZIO, ZIOAppDefault} +import zio.http.Server + +case class ProgressInput(steps: Int) derives Codec, Schema + +object StreamingZioServer extends ZIOAppDefault: + val work = tool("work").input[ProgressInput].streamingServerLogic[Task]: (_, ctx, _) => + for + _ <- ctx.reportProgress(0.5, total = Some(1.0)) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("halfway")) + yield ToolResult.text("done") + val server = StreamingMcpServer[Task]().withLoggingLevel(_ => ZIO.unit).addStreamingTool(work) + val endpoint = ZioServerHttpTransport(List("mcp")).serve(server) + override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) +``` + +## STDIO server (ZIO) + +A server that exchanges line-delimited JSON-RPC over stdin/stdout, served with `ZioServerStdioTransport`: + +```scala mdoc:compile-only +import chimp.server.{StreamingMcpServer, ToolResult, tool} +import chimp.server.zio.ZioServerStdioTransport +import io.circe.Codec +import sttp.tapir.* +import zio.{Task, ZIO, ZIOAppDefault} + +case class EchoInput(message: String) derives Codec, Schema + +object StdioZioServer extends ZIOAppDefault: + val echo = tool("echo").input[EchoInput].serverLogic[Task]((in, _, _) => ZIO.succeed(ToolResult.text(in.message))) + val server = StreamingMcpServer[Task]().addTool(echo) + override def run = ZioServerStdioTransport().serve(server) +``` + +More runnable examples live in [`examples/`](https://github.com/softwaremill/chimp/tree/master/examples/src/main/scala/examples). diff --git a/docs/server/prompts.md b/docs/server/prompts.md new file mode 100644 index 0000000..254db41 --- /dev/null +++ b/docs/server/prompts.md @@ -0,0 +1,23 @@ +# Prompts + +- Use `prompt(name)` to start defining a [prompt](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts). +- Add a description and declare the arguments it accepts. +- Provide the logic that turns the supplied argument values into a `GetPromptResult`: + - `handle` — synchronous; `handleWithHeaders` — synchronous, with headers; `serverLogic` — effectful. +- Register prompts with `.addPrompt` / `.addPrompts`. + +```scala mdoc:compile-only +import chimp.server.* +import chimp.protocol.{GetPromptResult, PromptMessage, Role, ToolContent} + +val greeting = prompt("greeting") + .description("Greets a person") + .argument("name", required = true) + .handle: args => + val name = args.getOrElse("name", "world") + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello, $name!")))) + +val endpoint = McpServer(prompts = List(greeting)).endpoint(List("mcp")) +``` + +Prompt messages reuse the `ToolContent` content types, so they can embed images (`ToolContent.Image`) and resources (`ToolContent.ResourceContent`) just like tool results. diff --git a/docs/server/protocol.md b/docs/server/protocol.md deleted file mode 100644 index bc9cd7f..0000000 --- a/docs/server/protocol.md +++ /dev/null @@ -1,9 +0,0 @@ -# MCP Protocol - -Chimp implements the HTTP transport of the [MCP protocol](https://modelcontextprotocol.io/specification/2025-03-26) (version **2025-03-26**). Only tools are supported, via the following JSON-RPC commands: - -- Initialization and capabilities negotiation (`initialize`) -- Listing available tools (`tools/list`) -- Invoking a tool (`tools/call`) - -All requests and responses use JSON-RPC 2.0. Tool input schemas are described using JSON Schema, auto-generated from Scala types. diff --git a/docs/server/resources.md b/docs/server/resources.md new file mode 100644 index 0000000..0746cea --- /dev/null +++ b/docs/server/resources.md @@ -0,0 +1,25 @@ +# Resources + +- Use `resource(uri)` for a fixed [resource](https://modelcontextprotocol.io/specification/2025-11-25/server/resources), or `resourceTemplate(uriTemplate)` for a `{variable}` URI template. +- Add metadata: `name`, `title`, `description`, `mimeType`, `size`. +- Provide the read logic, returning `Either[ResourceError, List[ResourceContents]]`: + - `handle` — synchronous; `handleWithHeaders` — synchronous, with headers; `serverLogic` — effectful. +- Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`. + +```scala mdoc:compile-only +import chimp.server.* +import chimp.protocol.ResourceContents + +val readme = resource("file:///readme.txt") + .name("readme") + .mimeType("text/plain") + .handle(() => Right(List(ResourceContents.Text(uri = "file:///readme.txt", text = "Hello!", mimeType = Some("text/plain"))))) + +val item = resourceTemplate("item://{id}") + .name("item") + .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) + +val endpoint = McpServer(resources = List(readme), resourceTemplates = List(item)).endpoint(List("mcp")) +``` + +Returning `Left(ResourceError(...))` (or reading an unknown URI) responds with a JSON-RPC error carrying the offending `uri`. diff --git a/docs/server/tools.md b/docs/server/tools.md index a17c7d8..f2e160e 100644 --- a/docs/server/tools.md +++ b/docs/server/tools.md @@ -1,10 +1,28 @@ -# Defining tools and server logic +# Tools -- Use `tool(name)` to start defining a tool. +- Use `tool(name)` to start defining a [tool](https://modelcontextprotocol.io/specification/2025-11-25/server/tools). - Add a description and annotations for metadata and hints. -- Specify the input type (must have a Circe `Codec` and Tapir `Schema`). -- Provide the server logic as a function from input to `ToolResult` (or a generic effect type). - - Use `handle` to connect the tool definition with the server logic when the use of headers is not required. - - Use `handleWithHeaders` to connect the tool definition with the server logic when headers are required. -- Assemble your tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. -- Start an HTTP server using your preferred Tapir server interpreter. +- Specify the input type (must have a Circe `Codec` and Tapir `Schema`), or use `.inputJson(schema)` for a raw JSON Schema. +- Provide the server logic: + - `handle` — synchronous logic from input to `ToolResult`. + - `handleWithHeaders` — synchronous logic that also receives the request headers. + - `serverLogic` — effectful logic, with access to the [server context](capabilities.md) and headers. +- Assemble tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. + +```scala mdoc:compile-only +import chimp.server.* +import io.circe.Codec +import sttp.tapir.* + +case class AddInput(a: Int, b: Int) derives Codec, Schema + +val adder = tool("adder") + .description("Adds two numbers") + .withAnnotations(ToolAnnotations(idempotentHint = Some(true))) + .input[AddInput] + .handle(in => ToolResult.text(s"The result is ${in.a + in.b}")) + +val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) +``` + +A `ToolResult` can carry text, images, audio, embedded resources and structured output — see its constructors (`text`, `image`, `audio`, `embedded`, `structured`). diff --git a/docs/server/transport.md b/docs/server/transport.md new file mode 100644 index 0000000..e4948ab --- /dev/null +++ b/docs/server/transport.md @@ -0,0 +1,49 @@ +# Transport + +A transport exposes an `McpServer` over a particular medium. `serve(server)` produces the transport-specific artifact `A` — a Tapir `ServerEndpoint` for HTTP, or a runnable loop for stdio. There are two families: + +- **Unidirectional** (`ServerTransport[F, A]`) — request/response only. Enough for tools, resources, prompts, completion. +- **Bidirectional** (`StreamingServerTransport[F, A]`) — additionally lets the server push messages to the client (progress and logging notifications). Required for [streaming server capabilities](capabilities.md). + +The streaming transports are abstract; their concrete, effect-specific implementations live in separate modules (e.g. ZIO). + +```{mermaid} +classDiagram + class ServerTransport~F, A~ { + <> + +serve(server) A + } + class StreamingServerTransport~F, A~ { + <> + +serve(server) A + } + class ServerHttpTransport~F~ + class ServerStdioTransport + class ServerStreamingHttpTransport~F, S~ { + <> + } + class ServerStreamingStdioTransport~F~ { + <> + } + + ServerTransport <|-- ServerHttpTransport + ServerTransport <|-- ServerStdioTransport + StreamingServerTransport <|-- ServerStdioTransport + StreamingServerTransport <|-- ServerStreamingHttpTransport + StreamingServerTransport <|-- ServerStreamingStdioTransport +``` + +`McpServer(...).endpoint(path)` is a shortcut for `ServerHttpTransport(path).serve(...)`. + +## Streaming integrations + +The streaming transports have concrete implementations per effect system, in separate modules: + +| Integration | Streaming HTTP | STDIO | +|---|---|---| +| ZIO | `ZioServerHttpTransport` | `ZioServerStdioTransport` | + +## Medium + +- **HTTP** transports produce a Tapir `ServerEndpoint` that you run on any Tapir server interpreter. The streaming HTTP transport additionally requires an interpreter with streaming capability. +- **STDIO** transports run the read/dispatch/write loop using plain JDK components (synchronous), or an effect's own semantics (e.g. ZIO). diff --git a/docs/server/zio.md b/docs/server/zio.md deleted file mode 100644 index 91f1fd4..0000000 --- a/docs/server/zio.md +++ /dev/null @@ -1,8 +0,0 @@ -# Using with ZIO - -When using ZIO, you might have to explicitly state the effect type that you are using, as the Tapir-ZIO integration requires a `RIO[R, A]` effect (which is an alias for `ZIO[R, Throwable, A]`), for example: - -```scala -val myServerTool = myTool.serverLogic[[X] =>> RIO[Any, X]]: (input, ctx, headers) => - ZIO.succeed(ToolResult.text(???)) -``` diff --git a/generated-docs/out/README.md b/generated-docs/out/README.md index 8bdae51..57016ba 100644 --- a/generated-docs/out/README.md +++ b/generated-docs/out/README.md @@ -34,5 +34,5 @@ Commit both `docs/` (the source) and `generated-docs/` (the mdoc output) — if ## Notes -- `0.1.8+15-aee4bbdd+20260531-1302-SNAPSHOT` and other mdoc variables are **not** substituted in the local watch mode. For a fully-rendered preview, run `sbt docs/mdoc` from the repo root and serve `generated-docs/out/` instead. +- `0.3.0+19-0bfa048a+20260618-1638-SNAPSHOT` and other mdoc variables are **not** substituted in the local watch mode. For a fully-rendered preview, run `sbt docs/mdoc` from the repo root and serve `generated-docs/out/` instead. - Scala code snippets are verified by `sbt compileDocs` (also runs in CI). diff --git a/generated-docs/out/client/capabilities.md b/generated-docs/out/client/capabilities.md index 27464bd..2966673 100644 --- a/generated-docs/out/client/capabilities.md +++ b/generated-docs/out/client/capabilities.md @@ -9,26 +9,37 @@ Beyond calling tools, an MCP client can advertise capabilities that let the serv - [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#notifications) — receiving server-pushed events such as resource updates and list changes. ```{note} -All of these require the server to push messages to the client, so they only work over a **bidirectional, streaming transport** (e.g. `ZioStreamingHttpTransport`). They are unavailable on the plain `HttpTransport`. +All of these require the server to push messages to the client, so they only work over a **bidirectional, streaming transport** (e.g. `ZioClientHttpTransport`). They are unavailable on the plain `ClientHttpTransport`. ``` Create the client with `McpClient.bidirectional`, providing a handler for each capability you want to enable — only capabilities backed by a handler are advertised to the server: ```scala -val client = McpClient.bidirectional[Task]( - transport, - clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))), - // samplingHandler = Some(...), - // elicitationHandler = Some(...), -) +import chimp.client.* +import chimp.client.transport.ClientBidirectionalTransport +import chimp.protocol.* +import zio.* + +def connect(transport: ClientBidirectionalTransport[Task]): Task[BidirectionalMcpClient[Task]] = + McpClient.bidirectional[Task]( + transport, + clientInfo = Implementation("my-client", "0.1.0"), + rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + // samplingHandler = Some(...), + // elicitationHandler = Some(...), + ) ``` Register a listener for server notifications with `onServerNotification`: ```scala -client.onServerNotification { - case ServerNotification.ResourceUpdated(uri) => ZIO.logInfo(s"resource changed: $uri") - case _ => ZIO.unit -} +import chimp.client.* +import chimp.client.notifications.ServerNotification +import zio.* + +def listen(client: BidirectionalMcpClient[Task]): Task[Unit] = + client.onServerNotification { + case ServerNotification.ResourceUpdated(params) => ZIO.logInfo(s"resource changed: ${params.uri}") + case _ => ZIO.unit + } ``` diff --git a/generated-docs/out/client/examples.md b/generated-docs/out/client/examples.md index 7f52fe6..b44541b 100644 --- a/generated-docs/out/client/examples.md +++ b/generated-docs/out/client/examples.md @@ -2,12 +2,9 @@ ## HTTP client -A synchronous client over `HttpTransport`, calling a tool: +A synchronous client over `ClientHttpTransport`, calling a tool: ```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 -//> using dep com.softwaremill.sttp.client4::core:4.0.23 - import chimp.client.* import chimp.client.transport.ClientHttpTransport import chimp.protocol.* @@ -16,49 +13,46 @@ import sttp.client4.DefaultSyncBackend import sttp.model.Uri.UriContext import sttp.shared.Identity -@main def httpClient(): Unit = -val backend = DefaultSyncBackend() -val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") -val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object HttpClient: + def main(args: Array[String]): Unit = + val backend = DefaultSyncBackend() + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) -val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) -result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) -client.close() -backend.close() + client.close() + backend.close() ``` ## STDIO client -A synchronous client that launches a local MCP server as a subprocess over `StdioTransport`: +A synchronous client that launches a local MCP server as a subprocess over `ClientStdioTransport`: ```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 - import chimp.client.* import chimp.client.transport.ClientStdioTransport import chimp.protocol.* import io.circe.Json import sttp.shared.Identity -@main def stdioClient(): Unit = -val transport = StdioTransport(command = List("my-mcp-server")) -val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object StdioClient: + def main(args: Array[String]): Unit = + val transport = ClientStdioTransport(command = List("my-mcp-server")) + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) -val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) -result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) -client.close() + client.close() ``` ## Roots over a ZIO streaming transport -[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) require a bidirectional, streaming transport — here `ZioStreamingHttpTransport`: +[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) require a bidirectional, streaming transport — here `ZioClientHttpTransport`: ```scala -//> using dep com.softwaremill.chimp::chimp-client-zio:0.2.0 -//> using dep com.softwaremill.sttp.client4::zio:4.0.23 - import chimp.client.* import chimp.client.transport.zio.ZioClientHttpTransport import chimp.protocol.* @@ -66,29 +60,22 @@ import sttp.client4.httpclient.zio.HttpClientZioBackend import sttp.model.Uri.UriContext import zio.* -object RootsClient extends ZIOAppDefault - -: -def run = - HttpClientZioBackend.scoped().flatMap { backend => - ZIO.scoped { - for - transport - <- ZioClientHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") - client - <- McpClient.bidirectional[Task]( - transport, - clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => - ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) - ) - tools - <- client.listTools() - _ - <- Console.printLine(s"server exposes ${tools.tools.size} tools") - yield () +object RootsClient extends ZIOAppDefault: + def run = + HttpClientZioBackend.scoped().flatMap { backend => + ZIO.scoped { + for + transport <- ZioClientHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") + client <- McpClient.bidirectional[Task]( + transport, + clientInfo = Implementation("my-client", "0.1.0"), + rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + ) + tools <- client.listTools() + _ <- Console.printLine(s"server exposes ${tools.tools.size} tools") + yield () + } } - } ``` More runnable examples live in [`examples/`](https://github.com/softwaremill/chimp/tree/master/examples/src/main/scala/examples). diff --git a/generated-docs/out/client/quickstart.md b/generated-docs/out/client/quickstart.md index 6b0d640..0a82d8a 100644 --- a/generated-docs/out/client/quickstart.md +++ b/generated-docs/out/client/quickstart.md @@ -5,7 +5,7 @@ Chimp ships an MCP client that connects to any MCP-compliant server. The client Add the dependency to your `build.sbt`: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.2.0" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.3.0" ``` ## Example: the simplest MCP client @@ -13,9 +13,6 @@ libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.2.0" Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example that connects to an MCP server over HTTP and invokes a tool: ```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 -//> using dep com.softwaremill.sttp.client4::core:4.0.23 - import chimp.client.* import chimp.client.transport.ClientHttpTransport import chimp.protocol.* @@ -24,20 +21,21 @@ import sttp.client4.DefaultSyncBackend import sttp.model.Uri.UriContext import sttp.shared.Identity -@main def mcpClient(): Unit = -val backend = DefaultSyncBackend() -val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") -val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object QuickstartClient: + def main(args: Array[String]): Unit = + val backend = DefaultSyncBackend() + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) -val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) -result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) -client.close() -backend.close() + client.close() + backend.close() ``` For streaming transports (e.g. ZIO), also add: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-zio" % "0.2.0" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-zio" % "0.3.0" ``` diff --git a/generated-docs/out/client/transport.md b/generated-docs/out/client/transport.md index 64aac4a..c1f8b0c 100644 --- a/generated-docs/out/client/transport.md +++ b/generated-docs/out/client/transport.md @@ -2,38 +2,46 @@ A transport carries JSON-RPC messages between the client and the server. There are two families: -- **Unidirectional** (`Transport[F]`) — the client sends a message and optionally gets a response back. Enough for calling tools, listing resources, etc. -- **Bidirectional** (`BidirectionalTransport[F]`) — additionally lets the server push messages to the client (server-initiated requests and notifications). Required for [client capabilities](capabilities.md). +- **Unidirectional** (`ClientTransport[F]`) — the client sends a message and optionally gets a response back. Enough for calling tools, listing resources, etc. +- **Bidirectional** (`ClientBidirectionalTransport[F]`) — additionally lets the server push messages to the client (server-initiated requests and notifications). Required for [client capabilities](capabilities.md). The streaming transports are abstract; their concrete, effect-specific implementations live in separate modules (e.g. ZIO). ```{mermaid} classDiagram - class Transport~F~ { + class ClientTransport~F~ { <> +send(msg) Option~Message~ +close() } - class BidirectionalTransport~F~ { + class ClientBidirectionalTransport~F~ { <> +onIncoming(handler) } - class HttpTransport~F~ - class StdioTransport - class StreamingHttpTransport~F, S~ { + class ClientHttpTransport~F~ + class ClientStdioTransport + class ClientStreamingHttpTransport~F, S~ { <> } - class StreamingStdioTransport~F~ { + class ClientStreamingStdioTransport~F~ { <> } - Transport <|-- BidirectionalTransport - Transport <|-- HttpTransport - BidirectionalTransport <|-- StdioTransport - BidirectionalTransport <|-- StreamingHttpTransport - BidirectionalTransport <|-- StreamingStdioTransport + ClientTransport <|-- ClientBidirectionalTransport + ClientTransport <|-- ClientHttpTransport + ClientBidirectionalTransport <|-- ClientStdioTransport + ClientBidirectionalTransport <|-- ClientStreamingHttpTransport + ClientBidirectionalTransport <|-- ClientStreamingStdioTransport ``` +## Streaming integrations + +The streaming transports have concrete implementations per effect system, in separate modules: + +| Integration | Streaming HTTP | STDIO | +|---|---|---| +| ZIO | `ZioClientHttpTransport` | `ZioClientStdioTransport` | + ## Backends - **HTTP** transports run on any [sttp](https://sttp.softwaremill.com/en/latest/) backend. The streaming HTTP transports additionally require a backend with streaming capability. diff --git a/generated-docs/out/index.md b/generated-docs/out/index.md index da56c91..1504059 100644 --- a/generated-docs/out/index.md +++ b/generated-docs/out/index.md @@ -10,9 +10,12 @@ and [sttp](https://github.com/softwaremill/sttp), supporting the variety of the :caption: Server server/quickstart - server/protocol + server/transport server/tools - server/zio + server/prompts + server/resources + server/capabilities + server/examples .. toctree:: :maxdepth: 2 diff --git a/generated-docs/out/server/capabilities.md b/generated-docs/out/server/capabilities.md new file mode 100644 index 0000000..335aac7 --- /dev/null +++ b/generated-docs/out/server/capabilities.md @@ -0,0 +1,36 @@ +# Server capabilities + +Tool logic runs with a **server context**. There are two: + +- `ServerContext[F]` — available on every server; exposes cancellation observation (`isCancelled`, `onCancel`). +- `StreamingServerContext[F]` — extends it with server→client interactions emitted while a tool runs: + - `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. + - `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. + - `sample` / `elicit` — server-initiated [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) and [elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) requests (planned). + +```{note} +Pushing to the client requires an open stream, so `StreamingServerContext` is only available over a **streaming transport**. A tool that uses it is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. +``` + +Use `serverLogic` for a tool that only needs the base context, and `streamingServerLogic` for one that pushes to the client: + +```scala +import chimp.server.* +import chimp.protocol.LoggingLevel +import io.circe.{Codec, Json} +import sttp.shared.Identity +import sttp.tapir.* + +case class WorkInput(steps: Int) derives Codec, Schema + +val work = tool("work") + .input[WorkInput] + .streamingServerLogic[Identity]: (_, ctx, _) => + ctx.reportProgress(0.5, total = Some(1.0)) + ctx.log(LoggingLevel.Info, Json.fromString("halfway")) + ToolResult.text("done") + +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`. diff --git a/generated-docs/out/server/examples.md b/generated-docs/out/server/examples.md new file mode 100644 index 0000000..c429549 --- /dev/null +++ b/generated-docs/out/server/examples.md @@ -0,0 +1,91 @@ +# Examples + +Each example builds an `McpServer` (or `StreamingMcpServer`) and serves it over a transport. The sync HTTP example uses `chimp-server`; the ZIO examples additionally use `chimp-server-zio`. + +## HTTP server + +A synchronous server exposed with the Tapir Netty interpreter: + +```scala +import chimp.server.* +import io.circe.Codec +import sttp.tapir.* +import sttp.tapir.server.netty.sync.NettySyncServer + +case class SyncAddInput(a: Int, b: Int) derives Codec, Schema + +object HttpSyncServer: + def main(args: Array[String]): Unit = + val adder = tool("adder").description("Adds two numbers").input[SyncAddInput].handle(in => ToolResult.text(s"The result is ${in.a + in.b}")) + val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) + NettySyncServer().port(8080).addEndpoint(endpoint).startAndWait() +``` + +## HTTP server (ZIO) + +The Tapir-ZIO integration requires a `RIO[R, A]` effect (error channel fixed to `Throwable`), so the effect type is stated explicitly: + +```scala +import chimp.server.{McpServer, ToolResult, tool} +import io.circe.Codec +import sttp.tapir.* +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.{RIO, ZIO, ZIOAppDefault} +import zio.http.Server + +case class ZioAddInput(a: Int, b: Int) derives Codec, Schema + +object HttpZioServer extends ZIOAppDefault: + val adder = tool("adder").description("Adds two numbers").input[ZioAddInput].serverLogic[[X] =>> RIO[Any, X]]: (in, _, _) => + ZIO.succeed(ToolResult.text(s"The result is ${in.a + in.b}")) + val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) + override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) +``` + +## Streaming HTTP server (ZIO) + +A streaming tool that pushes progress and log notifications over SSE while it runs, served with `ZioServerHttpTransport`: + +```scala +import chimp.server.{StreamingMcpServer, ToolResult, tool} +import chimp.server.zio.ZioServerHttpTransport +import chimp.protocol.LoggingLevel +import io.circe.{Codec, Json} +import sttp.tapir.* +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.{Task, ZIO, ZIOAppDefault} +import zio.http.Server + +case class ProgressInput(steps: Int) derives Codec, Schema + +object StreamingZioServer extends ZIOAppDefault: + val work = tool("work").input[ProgressInput].streamingServerLogic[Task]: (_, ctx, _) => + for + _ <- ctx.reportProgress(0.5, total = Some(1.0)) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("halfway")) + yield ToolResult.text("done") + val server = StreamingMcpServer[Task]().withLoggingLevel(_ => ZIO.unit).addStreamingTool(work) + val endpoint = ZioServerHttpTransport(List("mcp")).serve(server) + override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) +``` + +## STDIO server (ZIO) + +A server that exchanges line-delimited JSON-RPC over stdin/stdout, served with `ZioServerStdioTransport`: + +```scala +import chimp.server.{StreamingMcpServer, ToolResult, tool} +import chimp.server.zio.ZioServerStdioTransport +import io.circe.Codec +import sttp.tapir.* +import zio.{Task, ZIO, ZIOAppDefault} + +case class EchoInput(message: String) derives Codec, Schema + +object StdioZioServer extends ZIOAppDefault: + val echo = tool("echo").input[EchoInput].serverLogic[Task]((in, _, _) => ZIO.succeed(ToolResult.text(in.message))) + val server = StreamingMcpServer[Task]().addTool(echo) + override def run = ZioServerStdioTransport().serve(server) +``` + +More runnable examples live in [`examples/`](https://github.com/softwaremill/chimp/tree/master/examples/src/main/scala/examples). diff --git a/generated-docs/out/server/prompts.md b/generated-docs/out/server/prompts.md new file mode 100644 index 0000000..2d28363 --- /dev/null +++ b/generated-docs/out/server/prompts.md @@ -0,0 +1,23 @@ +# Prompts + +- Use `prompt(name)` to start defining a [prompt](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts). +- Add a description and declare the arguments it accepts. +- Provide the logic that turns the supplied argument values into a `GetPromptResult`: + - `handle` — synchronous; `handleWithHeaders` — synchronous, with headers; `serverLogic` — effectful. +- Register prompts with `.addPrompt` / `.addPrompts`. + +```scala +import chimp.server.* +import chimp.protocol.{GetPromptResult, PromptMessage, Role, ToolContent} + +val greeting = prompt("greeting") + .description("Greets a person") + .argument("name", required = true) + .handle: args => + val name = args.getOrElse("name", "world") + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello, $name!")))) + +val endpoint = McpServer(prompts = List(greeting)).endpoint(List("mcp")) +``` + +Prompt messages reuse the `ToolContent` content types, so they can embed images (`ToolContent.Image`) and resources (`ToolContent.ResourceContent`) just like tool results. diff --git a/generated-docs/out/server/protocol.md b/generated-docs/out/server/protocol.md deleted file mode 100644 index bc9cd7f..0000000 --- a/generated-docs/out/server/protocol.md +++ /dev/null @@ -1,9 +0,0 @@ -# MCP Protocol - -Chimp implements the HTTP transport of the [MCP protocol](https://modelcontextprotocol.io/specification/2025-03-26) (version **2025-03-26**). Only tools are supported, via the following JSON-RPC commands: - -- Initialization and capabilities negotiation (`initialize`) -- Listing available tools (`tools/list`) -- Invoking a tool (`tools/call`) - -All requests and responses use JSON-RPC 2.0. Tool input schemas are described using JSON Schema, auto-generated from Scala types. diff --git a/generated-docs/out/server/quickstart.md b/generated-docs/out/server/quickstart.md index 2356494..c1532da 100644 --- a/generated-docs/out/server/quickstart.md +++ b/generated-docs/out/server/quickstart.md @@ -5,7 +5,7 @@ Chimp lets you expose MCP tools over a JSON-RPC HTTP API. Tool inputs are descri Add the dependency to your `build.sbt`: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.2.0" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.3.0" ``` ## Example: the simplest MCP server @@ -13,7 +13,7 @@ libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.2.0" Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example: ```scala -//> using dep com.softwaremill.chimp::chimp-server:0.2.0 +//> using dep com.softwaremill.chimp::chimp-server:0.3.0 import chimp.* import sttp.tapir.* @@ -27,10 +27,10 @@ case class AdderInput(a: Int, b: Int) derives io.circe.Codec, Schema val adderTool = tool("adder").description("Adds two numbers").input[AdderInput] // combine the tool description with the server-side logic - val adderServerTool = adderTool.handle(i => Right(s"The result is ${i.a + i.b}")) + val adderServerTool = adderTool.handle(i => ToolResult.text(s"The result is ${i.a + i.b}")) // create the MCP server endpoint; it will be available at http://localhost:8080/mcp - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) // start the server NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() diff --git a/generated-docs/out/server/resources.md b/generated-docs/out/server/resources.md new file mode 100644 index 0000000..3b10dda --- /dev/null +++ b/generated-docs/out/server/resources.md @@ -0,0 +1,25 @@ +# Resources + +- Use `resource(uri)` for a fixed [resource](https://modelcontextprotocol.io/specification/2025-11-25/server/resources), or `resourceTemplate(uriTemplate)` for a `{variable}` URI template. +- Add metadata: `name`, `title`, `description`, `mimeType`, `size`. +- Provide the read logic, returning `Either[ResourceError, List[ResourceContents]]`: + - `handle` — synchronous; `handleWithHeaders` — synchronous, with headers; `serverLogic` — effectful. +- Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`. + +```scala +import chimp.server.* +import chimp.protocol.ResourceContents + +val readme = resource("file:///readme.txt") + .name("readme") + .mimeType("text/plain") + .handle(() => Right(List(ResourceContents.Text(uri = "file:///readme.txt", text = "Hello!", mimeType = Some("text/plain"))))) + +val item = resourceTemplate("item://{id}") + .name("item") + .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) + +val endpoint = McpServer(resources = List(readme), resourceTemplates = List(item)).endpoint(List("mcp")) +``` + +Returning `Left(ResourceError(...))` (or reading an unknown URI) responds with a JSON-RPC error carrying the offending `uri`. diff --git a/generated-docs/out/server/tools.md b/generated-docs/out/server/tools.md index db323ea..829b239 100644 --- a/generated-docs/out/server/tools.md +++ b/generated-docs/out/server/tools.md @@ -1,10 +1,28 @@ -# Defining tools and server logic +# Tools -- Use `tool(name)` to start defining a tool. +- Use `tool(name)` to start defining a [tool](https://modelcontextprotocol.io/specification/2025-11-25/server/tools). - Add a description and annotations for metadata and hints. -- Specify the input type (must have a Circe `Codec` and Tapir `Schema`). -- Provide the server logic as a function from input to `Either[String, String]` (or a generic effect type). - - Use `handle` to connect the tool definition with the server logic when the use of headers is not required. - - Use `handleWithHeaders` to connect the tool definition with the server logic when headers are required. -- Create a Tapir endpoint by providing your tools to `mcpEndpoint`. -- Start an HTTP server using your preferred Tapir server interpreter. +- Specify the input type (must have a Circe `Codec` and Tapir `Schema`), or use `.inputJson(schema)` for a raw JSON Schema. +- Provide the server logic: + - `handle` — synchronous logic from input to `ToolResult`. + - `handleWithHeaders` — synchronous logic that also receives the request headers. + - `serverLogic` — effectful logic, with access to the [server context](capabilities.md) and headers. +- Assemble tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. + +```scala +import chimp.server.* +import io.circe.Codec +import sttp.tapir.* + +case class AddInput(a: Int, b: Int) derives Codec, Schema + +val adder = tool("adder") + .description("Adds two numbers") + .withAnnotations(ToolAnnotations(idempotentHint = Some(true))) + .input[AddInput] + .handle(in => ToolResult.text(s"The result is ${in.a + in.b}")) + +val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) +``` + +A `ToolResult` can carry text, images, audio, embedded resources and structured output — see its constructors (`text`, `image`, `audio`, `embedded`, `structured`). diff --git a/generated-docs/out/server/transport.md b/generated-docs/out/server/transport.md new file mode 100644 index 0000000..e4948ab --- /dev/null +++ b/generated-docs/out/server/transport.md @@ -0,0 +1,49 @@ +# Transport + +A transport exposes an `McpServer` over a particular medium. `serve(server)` produces the transport-specific artifact `A` — a Tapir `ServerEndpoint` for HTTP, or a runnable loop for stdio. There are two families: + +- **Unidirectional** (`ServerTransport[F, A]`) — request/response only. Enough for tools, resources, prompts, completion. +- **Bidirectional** (`StreamingServerTransport[F, A]`) — additionally lets the server push messages to the client (progress and logging notifications). Required for [streaming server capabilities](capabilities.md). + +The streaming transports are abstract; their concrete, effect-specific implementations live in separate modules (e.g. ZIO). + +```{mermaid} +classDiagram + class ServerTransport~F, A~ { + <> + +serve(server) A + } + class StreamingServerTransport~F, A~ { + <> + +serve(server) A + } + class ServerHttpTransport~F~ + class ServerStdioTransport + class ServerStreamingHttpTransport~F, S~ { + <> + } + class ServerStreamingStdioTransport~F~ { + <> + } + + ServerTransport <|-- ServerHttpTransport + ServerTransport <|-- ServerStdioTransport + StreamingServerTransport <|-- ServerStdioTransport + StreamingServerTransport <|-- ServerStreamingHttpTransport + StreamingServerTransport <|-- ServerStreamingStdioTransport +``` + +`McpServer(...).endpoint(path)` is a shortcut for `ServerHttpTransport(path).serve(...)`. + +## Streaming integrations + +The streaming transports have concrete implementations per effect system, in separate modules: + +| Integration | Streaming HTTP | STDIO | +|---|---|---| +| ZIO | `ZioServerHttpTransport` | `ZioServerStdioTransport` | + +## Medium + +- **HTTP** transports produce a Tapir `ServerEndpoint` that you run on any Tapir server interpreter. The streaming HTTP transport additionally requires an interpreter with streaming capability. +- **STDIO** transports run the read/dispatch/write loop using plain JDK components (synchronous), or an effect's own semantics (e.g. ZIO). diff --git a/generated-docs/out/server/zio.md b/generated-docs/out/server/zio.md deleted file mode 100644 index ccd5d9b..0000000 --- a/generated-docs/out/server/zio.md +++ /dev/null @@ -1,8 +0,0 @@ -# Using with ZIO - -When using ZIO, you might have to explicitly state the effect type that you are using, as the Tapir-ZIO integration requires a `RIO[R, A]` effect (which is an alias for `ZIO[R, Throwable, A]`), for example: - -```scala -val myServerTool = myTool.serverLogic[[X] =>> RIO[Any, X]]: (input, headers) => - ZIO.succeed(???) -``` From a8ff22b3792911f67e5ba60f605cb9cbd5035abe Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 16:39:55 +0200 Subject: [PATCH 18/20] remove comments --- conformance-baseline.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/conformance-baseline.yml b/conformance-baseline.yml index a0b0211..113306b 100644 --- a/conformance-baseline.yml +++ b/conformance-baseline.yml @@ -1,15 +1,12 @@ server: - # Streaming / bidirectional — Phase 2/3 followup - tools-call-with-progress - tools-call-with-logging - tools-call-sampling - tools-call-elicitation - elicitation-sep1034-defaults - elicitation-sep1330-enums - # SSE polish — Phase 4 followup - server-sse-polling - server-sse-multiple-streams - # Gated to an older/draft protocol version (out of scope — latest protocol only) - json-schema-2020-12 - sep-2164-resource-not-found client: From cb3e0e1c703ec56056728dbbe0f79fda3f612586 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 18 Jun 2026 16:53:26 +0200 Subject: [PATCH 19/20] simplify server context --- docs/server/capabilities.md | 1 - generated-docs/out/README.md | 2 +- generated-docs/out/server/capabilities.md | 1 - server/src/main/scala/chimp/server/ServerContext.scala | 8 -------- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/docs/server/capabilities.md b/docs/server/capabilities.md index 209882b..82fbc16 100644 --- a/docs/server/capabilities.md +++ b/docs/server/capabilities.md @@ -6,7 +6,6 @@ Tool logic runs with a **server context**. There are two: - `StreamingServerContext[F]` — extends it with server→client interactions emitted while a tool runs: - `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. - `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. - - `sample` / `elicit` — server-initiated [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) and [elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) requests (planned). ```{note} Pushing to the client requires an open stream, so `StreamingServerContext` is only available over a **streaming transport**. A tool that uses it is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. diff --git a/generated-docs/out/README.md b/generated-docs/out/README.md index 57016ba..4086452 100644 --- a/generated-docs/out/README.md +++ b/generated-docs/out/README.md @@ -34,5 +34,5 @@ Commit both `docs/` (the source) and `generated-docs/` (the mdoc output) — if ## Notes -- `0.3.0+19-0bfa048a+20260618-1638-SNAPSHOT` and other mdoc variables are **not** substituted in the local watch mode. For a fully-rendered preview, run `sbt docs/mdoc` from the repo root and serve `generated-docs/out/` instead. +- `0.3.0+21-a8ff22b3+20260618-1651-SNAPSHOT` and other mdoc variables are **not** substituted in the local watch mode. For a fully-rendered preview, run `sbt docs/mdoc` from the repo root and serve `generated-docs/out/` instead. - Scala code snippets are verified by `sbt compileDocs` (also runs in CI). diff --git a/generated-docs/out/server/capabilities.md b/generated-docs/out/server/capabilities.md index 335aac7..0687536 100644 --- a/generated-docs/out/server/capabilities.md +++ b/generated-docs/out/server/capabilities.md @@ -6,7 +6,6 @@ Tool logic runs with a **server context**. There are two: - `StreamingServerContext[F]` — extends it with server→client interactions emitted while a tool runs: - `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. - `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. - - `sample` / `elicit` — server-initiated [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) and [elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) requests (planned). ```{note} Pushing to the client requires an open stream, so `StreamingServerContext` is only available over a **streaming transport**. A tool that uses it is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. diff --git a/server/src/main/scala/chimp/server/ServerContext.scala b/server/src/main/scala/chimp/server/ServerContext.scala index 6d325a6..c0a050e 100644 --- a/server/src/main/scala/chimp/server/ServerContext.scala +++ b/server/src/main/scala/chimp/server/ServerContext.scala @@ -17,8 +17,6 @@ object ServerContext: trait StreamingServerContext[F[_]] extends ServerContext[F]: def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] - def sample(params: CreateMessageParams): F[CreateMessageResult] - def elicit(params: ElicitParams): F[ElicitResult] private[server] final class SinkStreamingServerContext[F[_]](sink: OutboundSink[F], progressToken: Option[ProgressToken])(using m: MonadError[F] @@ -41,9 +39,3 @@ private[server] final class SinkStreamingServerContext[F[_]](sink: OutboundSink[ sink.send( JSONRPCMessage.Notification(method = "notifications/message", params = Some(LoggingMessageParams(level, data, logger).asJson)) ) - - def sample(params: CreateMessageParams): F[CreateMessageResult] = - m.error(UnsupportedOperationException("server client sampling is not yet supported")) - - def elicit(params: ElicitParams): F[ElicitResult] = - m.error(UnsupportedOperationException("server client elicitation is not yet supported")) From e08f71ede1a97e179d4b8b2089c5996f3e58679b Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Thu, 25 Jun 2026 10:02:36 +0200 Subject: [PATCH 20/20] feat: simplify server context --- client/src/main/scala/chimp/client/McpClient.scala | 9 --------- .../src/main/scala/chimp/client/McpClientImpl.scala | 4 ---- docs/conf.py | 4 ++-- docs/server/capabilities.md | 12 ++++-------- docs/server/examples.md | 4 ++-- docs/server/prompts.md | 4 +++- docs/server/resources.md | 4 +++- docs/server/tools.md | 4 +++- docs/server/transport.md | 2 +- .../src/main/scala/examples/server/AdderMcpZio.scala | 2 +- generated-docs/out/README.md | 2 +- generated-docs/out/server/capabilities.md | 12 ++++-------- generated-docs/out/server/examples.md | 4 ++-- generated-docs/out/server/prompts.md | 4 +++- generated-docs/out/server/resources.md | 4 +++- generated-docs/out/server/tools.md | 4 +++- generated-docs/out/server/transport.md | 2 +- .../src/main/scala/chimp/server/ServerContext.scala | 11 ++--------- server/src/main/scala/chimp/server/Tool.scala | 6 +++--- .../src/test/scala/chimp/server/McpServerTests.scala | 2 +- .../chimp/server/ServerStdioTransportTests.scala | 2 +- 21 files changed, 43 insertions(+), 59 deletions(-) diff --git a/client/src/main/scala/chimp/client/McpClient.scala b/client/src/main/scala/chimp/client/McpClient.scala index a680742..8c33c40 100644 --- a/client/src/main/scala/chimp/client/McpClient.scala +++ b/client/src/main/scala/chimp/client/McpClient.scala @@ -94,15 +94,6 @@ trait McpClient[F[_]]: */ def sendProgress(token: ProgressToken, progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] - /** Sends a `cancelled` notification, asking the server to stop processing a previously issued request. - * - * @param requestId - * Identifier of the request to cancel. - * @param reason - * Optional human-readable explanation. - */ - def sendCancelled(requestId: RequestId, reason: Option[String] = None): F[Unit] - /** An [[McpClient]] used over a [[chimp.client.transport.ClientBidirectionalTransport]], which additionally supports server-initiated * interactions: subscribing to resource updates, notifying the server about changes to the client's roots, and handling notifications * pushed by the server. diff --git a/client/src/main/scala/chimp/client/McpClientImpl.scala b/client/src/main/scala/chimp/client/McpClientImpl.scala index cfcc4ec..0b96663 100644 --- a/client/src/main/scala/chimp/client/McpClientImpl.scala +++ b/client/src/main/scala/chimp/client/McpClientImpl.scala @@ -220,10 +220,6 @@ object McpClientImpl: val params = ProgressParams(progressToken = token, progress = progress, total = total, message = message).asJson sendNotification("notifications/progress", Some(params)) - override def sendCancelled(requestId: RequestId, reason: Option[String]): F[Unit] = - val params = CancelledParams(requestId = requestId, reason = reason).asJson - sendNotification("notifications/cancelled", Some(params)) - protected def requireServerCapability[A](method: String, present: ServerCapabilities => Boolean)(action: => F[A]): F[A] = if present(serverCapabilities) then action else monad.error(McpProtocolException(s"Server did not negotiate the capability required for $method")) diff --git a/docs/conf.py b/docs/conf.py index bcdedda..f7ec520 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,9 +51,9 @@ # built documents. # # The short X.Y version. -version = u'0.1' +version = u'0.3' # The full version, including alpha/beta/rc tags. -release = u'0.1' +release = u'0.3.0' # The language for content autogenerated by Sphinx. language = 'en' diff --git a/docs/server/capabilities.md b/docs/server/capabilities.md index 82fbc16..f4f34cf 100644 --- a/docs/server/capabilities.md +++ b/docs/server/capabilities.md @@ -1,18 +1,14 @@ # Server capabilities -Tool logic runs with a **server context**. There are two: +Most tools just answer a request, so `serverLogic`/`handle` expose no context. A tool that needs to **push to the client while it runs** uses `streamingServerLogic`, which receives a `StreamingServerContext[F]`: -- `ServerContext[F]` — available on every server; exposes cancellation observation (`isCancelled`, `onCancel`). -- `StreamingServerContext[F]` — extends it with server→client interactions emitted while a tool runs: - - `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. - - `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. +- `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. +- `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. ```{note} -Pushing to the client requires an open stream, so `StreamingServerContext` is only available over a **streaming transport**. A tool that uses it is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. +Pushing to the client requires an open stream, so a `streamingServerLogic` tool is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. ``` -Use `serverLogic` for a tool that only needs the base context, and `streamingServerLogic` for one that pushes to the client: - ```scala mdoc:compile-only import chimp.server.* import chimp.protocol.LoggingLevel diff --git a/docs/server/examples.md b/docs/server/examples.md index a044236..10c4451 100644 --- a/docs/server/examples.md +++ b/docs/server/examples.md @@ -36,7 +36,7 @@ import zio.http.Server case class ZioAddInput(a: Int, b: Int) derives Codec, Schema object HttpZioServer extends ZIOAppDefault: - val adder = tool("adder").description("Adds two numbers").input[ZioAddInput].serverLogic[[X] =>> RIO[Any, X]]: (in, _, _) => + val adder = tool("adder").description("Adds two numbers").input[ZioAddInput].serverLogic[[X] =>> RIO[Any, X]]: (in, _) => ZIO.succeed(ToolResult.text(s"The result is ${in.a + in.b}")) val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) @@ -83,7 +83,7 @@ import zio.{Task, ZIO, ZIOAppDefault} case class EchoInput(message: String) derives Codec, Schema object StdioZioServer extends ZIOAppDefault: - val echo = tool("echo").input[EchoInput].serverLogic[Task]((in, _, _) => ZIO.succeed(ToolResult.text(in.message))) + val echo = tool("echo").input[EchoInput].serverLogic[Task]((in, _) => ZIO.succeed(ToolResult.text(in.message))) val server = StreamingMcpServer[Task]().addTool(echo) override def run = ZioServerStdioTransport().serve(server) ``` diff --git a/docs/server/prompts.md b/docs/server/prompts.md index 254db41..34128d3 100644 --- a/docs/server/prompts.md +++ b/docs/server/prompts.md @@ -3,7 +3,9 @@ - Use `prompt(name)` to start defining a [prompt](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts). - Add a description and declare the arguments it accepts. - Provide the logic that turns the supplied argument values into a `GetPromptResult`: - - `handle` — synchronous; `handleWithHeaders` — synchronous, with headers; `serverLogic` — effectful. + - `handle` — synchronous logic from the argument values to `GetPromptResult`. + - `handleWithHeaders` — synchronous logic that also receives the request headers. + - `serverLogic` — effectful logic, with the request headers. - Register prompts with `.addPrompt` / `.addPrompts`. ```scala mdoc:compile-only diff --git a/docs/server/resources.md b/docs/server/resources.md index 0746cea..7bbe3e8 100644 --- a/docs/server/resources.md +++ b/docs/server/resources.md @@ -3,7 +3,9 @@ - Use `resource(uri)` for a fixed [resource](https://modelcontextprotocol.io/specification/2025-11-25/server/resources), or `resourceTemplate(uriTemplate)` for a `{variable}` URI template. - Add metadata: `name`, `title`, `description`, `mimeType`, `size`. - Provide the read logic, returning `Either[ResourceError, List[ResourceContents]]`: - - `handle` — synchronous; `handleWithHeaders` — synchronous, with headers; `serverLogic` — effectful. + - `handle` — synchronous read logic. + - `handleWithHeaders` — synchronous read logic that also receives the request headers. + - `serverLogic` — effectful read logic, with the request headers. - Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`. ```scala mdoc:compile-only diff --git a/docs/server/tools.md b/docs/server/tools.md index f2e160e..40b01f6 100644 --- a/docs/server/tools.md +++ b/docs/server/tools.md @@ -6,7 +6,9 @@ - Provide the server logic: - `handle` — synchronous logic from input to `ToolResult`. - `handleWithHeaders` — synchronous logic that also receives the request headers. - - `serverLogic` — effectful logic, with access to the [server context](capabilities.md) and headers. + - `serverLogic` — effectful logic, with the request headers. + + A tool that pushes to the client while running (progress, logging) instead uses `streamingServerLogic` — see [server capabilities](capabilities.md). - Assemble tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. ```scala mdoc:compile-only diff --git a/docs/server/transport.md b/docs/server/transport.md index e4948ab..0931154 100644 --- a/docs/server/transport.md +++ b/docs/server/transport.md @@ -46,4 +46,4 @@ The streaming transports have concrete implementations per effect system, in sep ## Medium - **HTTP** transports produce a Tapir `ServerEndpoint` that you run on any Tapir server interpreter. The streaming HTTP transport additionally requires an interpreter with streaming capability. -- **STDIO** transports run the read/dispatch/write loop using plain JDK components (synchronous), or an effect's own semantics (e.g. ZIO). +- **STDIO** transports run the read/dispatch/write loop using plain JDK components (synchronous), or an effect's own semantics. diff --git a/examples/src/main/scala/examples/server/AdderMcpZio.scala b/examples/src/main/scala/examples/server/AdderMcpZio.scala index c47839b..01a5caf 100644 --- a/examples/src/main/scala/examples/server/AdderMcpZio.scala +++ b/examples/src/main/scala/examples/server/AdderMcpZio.scala @@ -23,7 +23,7 @@ object Main extends ZIOAppDefault: // note that here we need to explicitly state the effect type, as the Tapir-ZIO integration requires a `RIO[R, A]` // effect (with the error channel fixed to `Throwable`) - val adderServerTool = adderTool.serverLogic[[X] =>> RIO[Any, X]]: (input, _, _) => + val adderServerTool = adderTool.serverLogic[[X] =>> RIO[Any, X]]: (input, _) => ZIO.succeed(ToolResult.text(s"The result is ${input.a + input.b}")) val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) diff --git a/generated-docs/out/README.md b/generated-docs/out/README.md index 4086452..c5d6c44 100644 --- a/generated-docs/out/README.md +++ b/generated-docs/out/README.md @@ -34,5 +34,5 @@ Commit both `docs/` (the source) and `generated-docs/` (the mdoc output) — if ## Notes -- `0.3.0+21-a8ff22b3+20260618-1651-SNAPSHOT` and other mdoc variables are **not** substituted in the local watch mode. For a fully-rendered preview, run `sbt docs/mdoc` from the repo root and serve `generated-docs/out/` instead. +- `0.3.0+22-cb3e0e1c+20260625-0956-SNAPSHOT` and other mdoc variables are **not** substituted in the local watch mode. For a fully-rendered preview, run `sbt docs/mdoc` from the repo root and serve `generated-docs/out/` instead. - Scala code snippets are verified by `sbt compileDocs` (also runs in CI). diff --git a/generated-docs/out/server/capabilities.md b/generated-docs/out/server/capabilities.md index 0687536..ebe60eb 100644 --- a/generated-docs/out/server/capabilities.md +++ b/generated-docs/out/server/capabilities.md @@ -1,18 +1,14 @@ # Server capabilities -Tool logic runs with a **server context**. There are two: +Most tools just answer a request, so `serverLogic`/`handle` expose no context. A tool that needs to **push to the client while it runs** uses `streamingServerLogic`, which receives a `StreamingServerContext[F]`: -- `ServerContext[F]` — available on every server; exposes cancellation observation (`isCancelled`, `onCancel`). -- `StreamingServerContext[F]` — extends it with server→client interactions emitted while a tool runs: - - `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. - - `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. +- `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. +- `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. ```{note} -Pushing to the client requires an open stream, so `StreamingServerContext` is only available over a **streaming transport**. A tool that uses it is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. +Pushing to the client requires an open stream, so a `streamingServerLogic` tool is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. ``` -Use `serverLogic` for a tool that only needs the base context, and `streamingServerLogic` for one that pushes to the client: - ```scala import chimp.server.* import chimp.protocol.LoggingLevel diff --git a/generated-docs/out/server/examples.md b/generated-docs/out/server/examples.md index c429549..d662bef 100644 --- a/generated-docs/out/server/examples.md +++ b/generated-docs/out/server/examples.md @@ -36,7 +36,7 @@ import zio.http.Server case class ZioAddInput(a: Int, b: Int) derives Codec, Schema object HttpZioServer extends ZIOAppDefault: - val adder = tool("adder").description("Adds two numbers").input[ZioAddInput].serverLogic[[X] =>> RIO[Any, X]]: (in, _, _) => + val adder = tool("adder").description("Adds two numbers").input[ZioAddInput].serverLogic[[X] =>> RIO[Any, X]]: (in, _) => ZIO.succeed(ToolResult.text(s"The result is ${in.a + in.b}")) val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) @@ -83,7 +83,7 @@ import zio.{Task, ZIO, ZIOAppDefault} case class EchoInput(message: String) derives Codec, Schema object StdioZioServer extends ZIOAppDefault: - val echo = tool("echo").input[EchoInput].serverLogic[Task]((in, _, _) => ZIO.succeed(ToolResult.text(in.message))) + val echo = tool("echo").input[EchoInput].serverLogic[Task]((in, _) => ZIO.succeed(ToolResult.text(in.message))) val server = StreamingMcpServer[Task]().addTool(echo) override def run = ZioServerStdioTransport().serve(server) ``` diff --git a/generated-docs/out/server/prompts.md b/generated-docs/out/server/prompts.md index 2d28363..edf9c27 100644 --- a/generated-docs/out/server/prompts.md +++ b/generated-docs/out/server/prompts.md @@ -3,7 +3,9 @@ - Use `prompt(name)` to start defining a [prompt](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts). - Add a description and declare the arguments it accepts. - Provide the logic that turns the supplied argument values into a `GetPromptResult`: - - `handle` — synchronous; `handleWithHeaders` — synchronous, with headers; `serverLogic` — effectful. + - `handle` — synchronous logic from the argument values to `GetPromptResult`. + - `handleWithHeaders` — synchronous logic that also receives the request headers. + - `serverLogic` — effectful logic, with the request headers. - Register prompts with `.addPrompt` / `.addPrompts`. ```scala diff --git a/generated-docs/out/server/resources.md b/generated-docs/out/server/resources.md index 3b10dda..bb95bb3 100644 --- a/generated-docs/out/server/resources.md +++ b/generated-docs/out/server/resources.md @@ -3,7 +3,9 @@ - Use `resource(uri)` for a fixed [resource](https://modelcontextprotocol.io/specification/2025-11-25/server/resources), or `resourceTemplate(uriTemplate)` for a `{variable}` URI template. - Add metadata: `name`, `title`, `description`, `mimeType`, `size`. - Provide the read logic, returning `Either[ResourceError, List[ResourceContents]]`: - - `handle` — synchronous; `handleWithHeaders` — synchronous, with headers; `serverLogic` — effectful. + - `handle` — synchronous read logic. + - `handleWithHeaders` — synchronous read logic that also receives the request headers. + - `serverLogic` — effectful read logic, with the request headers. - Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`. ```scala diff --git a/generated-docs/out/server/tools.md b/generated-docs/out/server/tools.md index 829b239..83b774f 100644 --- a/generated-docs/out/server/tools.md +++ b/generated-docs/out/server/tools.md @@ -6,7 +6,9 @@ - Provide the server logic: - `handle` — synchronous logic from input to `ToolResult`. - `handleWithHeaders` — synchronous logic that also receives the request headers. - - `serverLogic` — effectful logic, with access to the [server context](capabilities.md) and headers. + - `serverLogic` — effectful logic, with the request headers. + + A tool that pushes to the client while running (progress, logging) instead uses `streamingServerLogic` — see [server capabilities](capabilities.md). - Assemble tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. ```scala diff --git a/generated-docs/out/server/transport.md b/generated-docs/out/server/transport.md index e4948ab..0931154 100644 --- a/generated-docs/out/server/transport.md +++ b/generated-docs/out/server/transport.md @@ -46,4 +46,4 @@ The streaming transports have concrete implementations per effect system, in sep ## Medium - **HTTP** transports produce a Tapir `ServerEndpoint` that you run on any Tapir server interpreter. The streaming HTTP transport additionally requires an interpreter with streaming capability. -- **STDIO** transports run the read/dispatch/write loop using plain JDK components (synchronous), or an effect's own semantics (e.g. ZIO). +- **STDIO** transports run the read/dispatch/write loop using plain JDK components (synchronous), or an effect's own semantics. diff --git a/server/src/main/scala/chimp/server/ServerContext.scala b/server/src/main/scala/chimp/server/ServerContext.scala index c0a050e..c764818 100644 --- a/server/src/main/scala/chimp/server/ServerContext.scala +++ b/server/src/main/scala/chimp/server/ServerContext.scala @@ -5,14 +5,10 @@ import io.circe.Json import io.circe.syntax.* import sttp.monad.MonadError -trait ServerContext[F[_]]: - def isCancelled: F[Boolean] - def onCancel(action: F[Unit]): F[Unit] +trait ServerContext[F[_]] object ServerContext: - def noop[F[_]](using m: MonadError[F]): ServerContext[F] = new ServerContext[F]: - def isCancelled: F[Boolean] = m.unit(false) - def onCancel(action: F[Unit]): F[Unit] = m.unit(()) + def noop[F[_]]: ServerContext[F] = new ServerContext[F] {} trait StreamingServerContext[F[_]] extends ServerContext[F]: def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] @@ -21,9 +17,6 @@ trait StreamingServerContext[F[_]] extends ServerContext[F]: private[server] final class SinkStreamingServerContext[F[_]](sink: OutboundSink[F], progressToken: Option[ProgressToken])(using m: MonadError[F] ) extends StreamingServerContext[F]: - def isCancelled: F[Boolean] = m.unit(false) - def onCancel(action: F[Unit]): F[Unit] = m.unit(()) - def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] = progressToken match case Some(token) => diff --git a/server/src/main/scala/chimp/server/Tool.scala b/server/src/main/scala/chimp/server/Tool.scala index e6bec1a..617c599 100644 --- a/server/src/main/scala/chimp/server/Tool.scala +++ b/server/src/main/scala/chimp/server/Tool.scala @@ -77,9 +77,9 @@ case class Tool[I]( inputDecoder: Decoder[I], annotations: Option[ToolAnnotations] ): - /** Attaches effectful logic with access to the base [[ServerContext]]. */ - def serverLogic[F[_]](logic: (I, ServerContext[F], Seq[Header]) => F[ToolResult]): ServerTool[I, F, ServerContext[F]] = - ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) + /** Attaches effectful logic, with access to the request headers. */ + def serverLogic[F[_]](logic: (I, Seq[Header]) => F[ToolResult]): ServerTool[I, F, ServerContext[F]] = + ServerTool(name, description, inputSchema, inputDecoder, annotations, (input, _, headers) => logic(input, headers)) /** Attaches effectful logic with access to the [[StreamingServerContext]]; usable only on a streaming server. */ def streamingServerLogic[F[_]]( diff --git a/server/src/test/scala/chimp/server/McpServerTests.scala b/server/src/test/scala/chimp/server/McpServerTests.scala index bacf15a..58ef073 100644 --- a/server/src/test/scala/chimp/server/McpServerTests.scala +++ b/server/src/test/scala/chimp/server/McpServerTests.scala @@ -24,7 +24,7 @@ trait McpServerTests[F[_]] extends AsyncFlatSpec with Matchers: tool("echo") .description("Echoes a message") .input[EchoInput] - .serverLogic[F]((in, _, _) => monad.unit(ToolResult.text(in.message))) + .serverLogic[F]((in, _) => monad.unit(ToolResult.text(in.message))) ) .addResource( resource("test://greeting") diff --git a/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala b/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala index 99317c7..b7894a5 100644 --- a/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala +++ b/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala @@ -30,7 +30,7 @@ trait ServerStdioTransportTests[F[_]] extends AnyFlatSpec with Matchers: private def server: StreamingMcpServer[F] = StreamingMcpServer[F]() .withLoggingLevel(_ => monad.unit(())) - .addTool(tool("echo").input[EchoInput].serverLogic[F]((in, _, _) => monad.unit(ToolResult.text(in.message)))) + .addTool(tool("echo").input[EchoInput].serverLogic[F]((in, _) => monad.unit(ToolResult.text(in.message)))) .addStreamingTool( tool("noisy") .input[NoInput]