Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7068ce4
feat(mcp-client): split core/server modules
kubinio123 May 11, 2026
73d7cc6
feat: define protocol in shared core project using latest version MCP…
kubinio123 May 11, 2026
972ff76
feat: drop batch support in server, it was part of the protocol only …
kubinio123 May 11, 2026
d767843
feat: client scaffold, transport abstraction and default http and std…
kubinio123 May 11, 2026
cc3e515
feat: client capabilities, server notifications, full mcp client defi…
kubinio123 May 11, 2026
7ca3ded
feat: integrate conformance tests into client and server, baseline wi…
kubinio123 May 12, 2026
323b1e7
feat: MCP version 2025-11-25 (latest) json schema file for model unit…
kubinio123 May 12, 2026
d72b95f
feat: validate core model against actual json schema, fix found issues
kubinio123 May 12, 2026
20c35ea
feat: test encode decode round trip in single spec
kubinio123 May 12, 2026
c6125cb
feat: fill in missing model tests
kubinio123 May 12, 2026
5587886
fix: stricter compilation settings, resolve warnings
kubinio123 May 12, 2026
e053362
fix: simplify client capabilities
kubinio123 May 12, 2026
fa42cfb
feat: separated client and server examples, first client example
kubinio123 May 13, 2026
98918cf
misc: rename root project
kubinio123 May 13, 2026
158da52
fix: formatting
kubinio123 May 13, 2026
8c12b2f
refactor: remove comments
kubinio123 May 13, 2026
3af8f47
docs: document client and server conformance
kubinio123 May 13, 2026
078b95b
refactor: small build.sbt cleanup
kubinio123 May 13, 2026
587dd4c
refactor: small updates
kubinio123 May 13, 2026
ae83b7b
fix: post rebase update
kubinio123 May 13, 2026
e1cd2f2
fix: fail client initialization when server protocol is unsuported
kubinio123 May 13, 2026
4d9ed9e
fix: respect server capabilities in client
kubinio123 May 13, 2026
96ec74d
refactor: small refactor in client impl
kubinio123 May 13, 2026
0e5cde6
refactor: small refactor in client impl
kubinio123 May 13, 2026
9961666
refactor: typed server notification listener, refactor
kubinio123 May 13, 2026
5bf2fe4
refactor: refactor http transport spec
kubinio123 May 13, 2026
731de8e
refactor: use defined media type
kubinio123 May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 143 additions & 10 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings
import com.softwaremill.Publish.{ossPublishSettings, updateDocs}
import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings
import com.softwaremill.UpdateVersionInDocs

// Version constants
val scalaTestV = "3.2.20"
val circeV = "0.14.15"
val slf4jV = "2.0.18"
val logbackV = "1.5.32"
val tapirV = "1.13.18"
val sttpClientV = "4.0.23"

lazy val verifyExamplesCompileUsingScalaCli = taskKey[Unit]("Verify that each example compiles using Scala CLI")

Expand All @@ -19,32 +21,57 @@ lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq(
}
}.value,
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s",
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s"
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s",
scalacOptions ++= Seq("-Wunused:all", "-Werror")
)

val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test

lazy val rootProject = (project in file("."))
lazy val root = (project in file("."))
.settings(commonSettings: _*)
.settings(publishArtifact := false, name := "chimp")
.aggregate(core, examples)
.aggregate(core, server, client, examples, serverConformance, clientConformance)

val conformance = inputKey[Unit]("Run the MCP conformance harness via npx, extra args are passed through")

lazy val core: Project = (project in file("core"))
.settings(commonSettings: _*)
.settings(
name := "core",
name := "chimp-core",
libraryDependencies ++= Seq(
scalaTest,
"io.circe" %% "circe-core" % circeV,
"io.circe" %% "circe-generic" % circeV,
"io.circe" %% "circe-parser" % circeV,
"org.slf4j" % "slf4j-api" % slf4jV,
"com.networknt" % "json-schema-validator" % "3.0.2" % Test
)
)

lazy val server: Project = (project in file("server"))
.settings(commonSettings: _*)
.settings(
name := "chimp-server",
libraryDependencies ++= Seq(
scalaTest,
"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",
"org.slf4j" % "slf4j-api" % "2.0.18"
"com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10"
)
)
.dependsOn(core)

lazy val client: Project = (project in file("client"))
.settings(commonSettings: _*)
.settings(
name := "chimp-client",
libraryDependencies ++= Seq(
scalaTest,
"com.softwaremill.sttp.client4" %% "core" % sttpClientV
)
)
.dependsOn(core)

lazy val examples = (project in file("examples"))
.settings(commonSettings: _*)
Expand All @@ -55,8 +82,114 @@ lazy val examples = (project in file("examples"))
"com.softwaremill.sttp.client4" %% "core" % "4.0.23",
"com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV,
"com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirV,
"ch.qos.logback" % "logback-classic" % "1.5.32"
"ch.qos.logback" % "logback-classic" % logbackV
),
verifyExamplesCompileUsingScalaCli := VerifyExamplesCompileUsingScalaCli(sLog.value, sourceDirectory.value)
)
.dependsOn(core)
.dependsOn(server, client)

import sbtassembly.AssemblyPlugin.autoImport.*

lazy val assemblySettings = Seq(
assembly / assemblyMergeStrategy := {
case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard
case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard
case PathList("META-INF", "DEPENDENCIES") => MergeStrategy.discard
case PathList("META-INF", "services", _ @_*) => MergeStrategy.concat
case PathList("META-INF", xs @ _*) if xs.lastOption.exists(s => s.endsWith(".SF") || s.endsWith(".DSA") || s.endsWith(".RSA")) =>
MergeStrategy.discard
case PathList("META-INF", _ @_*) => MergeStrategy.first
case PathList("module-info.class") => MergeStrategy.discard
case _ => MergeStrategy.first
}
)

lazy val serverConformance = (project in file("server-conformance"))
.enablePlugins(AssemblyPlugin)
.settings(commonSettings: _*)
.settings(assemblySettings: _*)
.settings(
publishArtifact := false,
name := "server-conformance",
Compile / mainClass := Some("chimp.conformance.server.Main"),
assembly / assemblyJarName := "chimp-server-conformance.jar",
libraryDependencies ++= Seq(
"com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV,
"ch.qos.logback" % "logback-classic" % logbackV
),
conformance := {
import complete.DefaultParsers.*

import scala.sys.process.*
val args = spaceDelimited("<args>").parsed.toList
val jar = assembly.value
val rootDir = (LocalRootProject / baseDirectory).value
val baseline = (rootDir / "conformance-baseline.yml").getAbsolutePath
val log = streams.value.log

val urlPromise = scala.concurrent.Promise[String]()
val pb = new java.lang.ProcessBuilder("java", "-jar", jar.getAbsolutePath).redirectErrorStream(false)
val proc = pb.start()
val readerThread = new Thread(new Runnable {
def run(): Unit = {
val reader = new java.io.BufferedReader(new java.io.InputStreamReader(proc.getInputStream, "UTF-8"))
try {
val line = reader.readLine()
if (line != null && line.startsWith("http")) urlPromise.trySuccess(line.trim)
else urlPromise.tryFailure(new RuntimeException(s"Server did not print a URL; first line was: $line"))
var more: String = reader.readLine()
while (more != null) {
log.info(s"[server] $more")
more = reader.readLine()
}
} catch {
case t: Throwable => urlPromise.tryFailure(t)
}
}
})
readerThread.setDaemon(true)
readerThread.start()

try {
val url = scala.concurrent.Await.result(urlPromise.future, scala.concurrent.duration.Duration("15s"))
log.info(s"Server started at $url")
val cmd = List("npx", "@modelcontextprotocol/conformance") ++ args ++
List("--url", url, "--expected-failures", baseline)
val rc = Process(cmd, rootDir).!
if (rc != 0) sys.error(s"conformance harness exited with code $rc")
} finally {
proc.destroy()
if (!proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) proc.destroyForcibly()
}
}
)
.dependsOn(server)

lazy val clientConformance = (project in file("client-conformance"))
.enablePlugins(AssemblyPlugin)
.settings(commonSettings: _*)
.settings(assemblySettings: _*)
.settings(
publishArtifact := false,
name := "client-conformance",
Compile / mainClass := Some("chimp.conformance.client.Main"),
assembly / assemblyJarName := "chimp-client-conformance.jar",
libraryDependencies ++= Seq(
"ch.qos.logback" % "logback-classic" % "1.5.32"
),
conformance := {
import complete.DefaultParsers.*

import scala.sys.process.*
val args = spaceDelimited("<args>").parsed.toList
val _ = assembly.value
val baseDir = baseDirectory.value
val rootDir = (LocalRootProject / baseDirectory).value
val wrapper = (baseDir / "bin" / "chimp-conformance-client").getAbsolutePath
val cmd = List("npx", "@modelcontextprotocol/conformance") ++ args ++
List("--command", wrapper, "--expected-failures", (rootDir / "conformance-baseline.yml").getAbsolutePath)
val rc = Process(cmd, rootDir).!
if (rc != 0) sys.error(s"conformance harness exited with code $rc")
}
)
.dependsOn(client)
45 changes: 45 additions & 0 deletions client-conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# client-conformance

Runs chimp's MCP client against the
official [MCP conformance test suite](https://github.com/modelcontextprotocol/conformance).

## What it does

The conformance harness is the inverse of a server: it **starts a test MCP server**, spawns the binary configured
via `--command` (this fat-jar wrapped in [`bin/chimp-conformance-client`](bin/chimp-conformance-client)), and passes the
test-server URL as the last argument. The client process reads the `MCP_CONFORMANCE_SCENARIO` env var to decide what
protocol exchange to drive, talks to the harness's server, and exits 0 on success.

This subproject is the binary the harness invokes. `Main.scala` dispatches on the scenario name and uses `chimp-client`
to drive the protocol.

## How to run

Using sbt task (assembles a client fat jar and runs test suite in one step):

```bash
sbt 'clientConformance/conformance client --suite core'
sbt 'clientConformance/conformance client --scenario initialize'
sbt 'clientConformance/conformance client --scenario tools_call'
```

The `@modelcontextprotocol/conformance` will be installed using npm, it must be available on the PATH.

## Adding a scenario

Add a case in `Main.scala` matching on the scenario name (the value of `MCP_CONFORMANCE_SCENARIO`), drive the protocol
with `McpClient`, return exit code 0 on success. Once it passes, remove the entry from the baseline file (see below).

## The baseline file

[`conformance-baseline.yml`](../conformance-baseline.yml) lists scenarios that are known to fail today. The harness uses
it like this:

| Scenario result | In baseline? | Exit code | Meaning |
|-----------------|--------------|-----------|---------------------------------------|
| Fails | Yes | 0 | Expected failure — keep working on it |
| Fails | No | 1 | Regression — CI fails |
| Passes | Yes | 1 | Stale baseline — remove the entry |
| Passes | No | 0 | Normal pass |

So the file shrinks as the SDK matures.
9 changes: 9 additions & 0 deletions client-conformance/bin/chimp-conformance-client
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JAR="${DIR}/../target/scala-3.3.7/chimp-client-conformance.jar"
if [[ ! -f "${JAR}" ]]; then
echo "Fat jar not found at ${JAR}. Run 'sbt clientConformance/assembly' first." >&2
exit 127
fi
exec java -jar "${JAR}" "$@"
11 changes: 11 additions & 0 deletions client-conformance/src/main/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<configuration>
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="warn">
<appender-ref ref="STDERR" />
</root>
</configuration>
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package chimp.conformance.client

import chimp.client.McpClient
import chimp.client.transport.HttpTransport
import chimp.protocol.*
import io.circe.Json
import sttp.client4.DefaultSyncBackend
import sttp.model.Uri
import sttp.shared.Identity

object Main:

private val clientInfo = Implementation(name = "chimp-conformance-client", version = "0.1.0")

def main(args: Array[String]): Unit =
if args.isEmpty then
System.err.println("Usage: chimp-conformance-client <serverUrl>")
sys.exit(2)

val serverUrl = Uri.parse(args.last) match
case Right(url) => url
case Left(e) => System.err.println(s"Invalid server URL: $e"); sys.exit(2)

val scenario = sys.env.getOrElse("MCP_CONFORMANCE_SCENARIO", "")
val protocolVersion: ProtocolVersion = sys.env
.get("MCP_CONFORMANCE_PROTOCOL_VERSION")
.flatMap(ProtocolVersion.from)
.getOrElse(ProtocolVersion.Latest)

val backend = DefaultSyncBackend()
val transport = HttpTransport[Identity](backend, serverUrl, protocolVersion)

val rc: Int =
try
scenario match
case "initialize" =>
val client = McpClient[Identity](transport, clientInfo, protocolVersion = protocolVersion)
val _ = client.initialize()
client.close()
0

case "tools_call" =>
val client = McpClient[Identity](transport, clientInfo, protocolVersion = protocolVersion)
val _ = client.initialize()
val _ = client.callTool(
"add_numbers",
Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))
)
client.close()
0

case s if s == "elicitation-sep1034-client-defaults" || s == "sse-retry" || s.startsWith("auth/") =>
2

case other =>
System.err.println(s"Scenario not implemented: $other")
3
catch
case t: Throwable =>
t.printStackTrace()
1
finally
try backend.close()
catch case _: Throwable => ()

sys.exit(rc)
54 changes: 54 additions & 0 deletions client/src/main/scala/chimp/client/McpClient.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package chimp.client

import chimp.client.notifications.ServerNotificationListener
import chimp.client.transport.Transport
import chimp.protocol.*
import io.circe.Json

trait McpClient[F[_]]:
def initialize(): F[InitializeResult]
def ping(): F[Unit]
def close(): F[Unit]

/** The server's negotiated capabilities. `None` before `initialize()` has completed successfully. */
def serverCapabilities: Option[ServerCapabilities]

def listTools(cursor: Option[Cursor] = None): F[ListToolsResponse]
def callTool(name: String, arguments: Json): F[CallToolResult]

def listPrompts(cursor: Option[Cursor] = None): F[ListPromptsResult]
def getPrompt(name: String, arguments: Map[String, String] = Map.empty): F[GetPromptResult]

def listResources(cursor: Option[Cursor] = None): F[ListResourcesResult]
def listResourceTemplates(cursor: Option[Cursor] = None): F[ListResourceTemplatesResult]
def readResource(uri: String): F[ReadResourceResult]
def subscribeResource(uri: String): F[Unit]
def unsubscribeResource(uri: String): F[Unit]

def complete(ref: CompleteRef, argument: CompleteArgument): F[CompleteResult]

def setLoggingLevel(level: LoggingLevel): F[Unit]

def sendProgress(token: ProgressToken, progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit]
def sendCancelled(requestId: RequestId, reason: Option[String] = None): F[Unit]
def sendRootsListChanged(): F[Unit]

def onServerNotification(listener: ServerNotificationListener[F]): F[Unit]

object McpClient:
def apply[F[_]](
transport: Transport[F],
clientInfo: Implementation,
rootsHandler: Option[() => F[ListRootsResult]] = None,
samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None,
elicitationHandler: Option[ElicitRequest => F[ElicitResult]] = None,
protocolVersion: ProtocolVersion = ProtocolVersion.Latest
): McpClient[F] =
McpClientImpl.create(
transport,
clientInfo,
protocolVersion,
rootsHandler,
samplingHandler,
elicitationHandler
)
10 changes: 10 additions & 0 deletions client/src/main/scala/chimp/client/McpClientException.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package chimp.client

class McpTransportException(message: String, cause: Throwable = null) extends RuntimeException(message, cause)

final class McpAuthorizationException(message: String, val statusCode: Int) extends McpTransportException(message)

final class McpSessionNotFoundException(sessionId: String)
extends McpTransportException(s"Server reported session-id $sessionId as not found")

final class McpProtocolException(message: String) extends McpTransportException(message)
Loading
Loading