Skip to content

Commit 5cb4331

Browse files
authored
MCP client as a new subproject (#138)
* feat(mcp-client): split core/server modules * feat: define protocol in shared core project using latest version MCP 2025-11-25 * feat: drop batch support in server, it was part of the protocol only for a short period of time and is gone in 2 last versions, not adopted by official clients * feat: client scaffold, transport abstraction and default http and stdio implementation, basic client interface * feat: client capabilities, server notifications, full mcp client definition * feat: integrate conformance tests into client and server, baseline with expected failures * feat: MCP version 2025-11-25 (latest) json schema file for model unit tests * feat: validate core model against actual json schema, fix found issues * feat: test encode decode round trip in single spec * feat: fill in missing model tests * fix: stricter compilation settings, resolve warnings * fix: simplify client capabilities * feat: separated client and server examples, first client example * misc: rename root project * fix: formatting * refactor: remove comments * docs: document client and server conformance * refactor: small build.sbt cleanup * refactor: small updates * fix: post rebase update * fix: fail client initialization when server protocol is unsuported * fix: respect server capabilities in client * refactor: small refactor in client impl * refactor: small refactor in client impl * refactor: typed server notification listener, refactor * refactor: refactor http transport spec * refactor: use defined media type * refactor: remove the isAckLike workaround in http transport, report a bug in conformance repository instead modelcontextprotocol/conformance#274 * misc: temporarily disable examples compilation check * feat: add conformance tests to ci * fix: update JDK on ci from 11 to 17 * fix: update Node on ci to 24 * fix: capture stderr for server conformance to resolve ci issues * fix: update ci JDK to 21 * refactor: inline runnable * refactor: update examples * fix: fix accept header * fix: log notification lister failures * fix: improve default stdio impl shutdown
1 parent 81cdc00 commit 5cb4331

56 files changed

Lines changed: 6486 additions & 843 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@ jobs:
2626
uses: actions/setup-java@v5
2727
with:
2828
distribution: 'temurin'
29-
java-version: 11
29+
java-version: 21
3030
cache: 'sbt'
3131
- uses: sbt/setup-sbt@3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd # v1, specifically v1.1.14
32+
- name: Set up Node
33+
uses: actions/setup-node@v4
34+
with:
35+
node-version: '24'
3236
- name: Install scala-cli
3337
uses: VirtusLab/scala-cli-setup@68bd9c30954d20e6cb6ddaf01b3b38336f25df4b # main, specifically v1.10.1
3438
with:
@@ -37,10 +41,15 @@ jobs:
3741
run: sbt -v scalafmtCheckAll
3842
- name: Compile
3943
run: sbt -v compile
40-
- name: Verify that examples compile using Scala CLI
41-
run: sbt -v "project examples" verifyExamplesCompileUsingScalaCli
44+
# TODO bring this step back after first release with new project structure (client + server)
45+
# - name: Verify that examples compile using Scala CLI
46+
# run: sbt -v "project examples" verifyExamplesCompileUsingScalaCli
4247
- name: Test
4348
run: sbt -v test
49+
- name: Client conformance
50+
run: sbt -v "clientConformance/conformance client --suite core"
51+
- name: Server conformance
52+
run: sbt -v "serverConformance/conformance server"
4453
- uses: actions/upload-artifact@v5 # upload test results
4554
if: success() || failure() # run this step even if previous step failed
4655
with:

build.sbt

Lines changed: 156 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings
21
import com.softwaremill.Publish.{ossPublishSettings, updateDocs}
2+
import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings
33
import com.softwaremill.UpdateVersionInDocs
44

5-
// Version constants
65
val scalaTestV = "3.2.20"
76
val circeV = "0.14.15"
7+
val slf4jV = "2.0.18"
8+
val logbackV = "1.5.32"
89
val tapirV = "1.13.19"
10+
val sttpClientV = "4.0.23"
911

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

@@ -19,32 +21,57 @@ lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq(
1921
}
2022
}.value,
2123
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s",
22-
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s"
24+
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s",
25+
scalacOptions ++= Seq("-Wunused:all", "-Werror")
2326
)
2427

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

27-
lazy val rootProject = (project in file("."))
30+
lazy val root = (project in file("."))
2831
.settings(commonSettings: _*)
2932
.settings(publishArtifact := false, name := "chimp")
30-
.aggregate(core, examples)
33+
.aggregate(core, server, client, examples, serverConformance, clientConformance)
34+
35+
val conformance = inputKey[Unit]("Run the MCP conformance harness via npx, extra args are passed through")
3136

3237
lazy val core: Project = (project in file("core"))
3338
.settings(commonSettings: _*)
3439
.settings(
35-
name := "core",
40+
name := "chimp-core",
3641
libraryDependencies ++= Seq(
3742
scalaTest,
3843
"io.circe" %% "circe-core" % circeV,
3944
"io.circe" %% "circe-generic" % circeV,
4045
"io.circe" %% "circe-parser" % circeV,
46+
"org.slf4j" % "slf4j-api" % slf4jV,
47+
"com.networknt" % "json-schema-validator" % "3.0.2" % Test
48+
)
49+
)
50+
51+
lazy val server: Project = (project in file("server"))
52+
.settings(commonSettings: _*)
53+
.settings(
54+
name := "chimp-server",
55+
libraryDependencies ++= Seq(
56+
scalaTest,
4157
"com.softwaremill.sttp.tapir" %% "tapir-core" % tapirV,
4258
"com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirV,
4359
"com.softwaremill.sttp.tapir" %% "tapir-apispec-docs" % tapirV,
44-
"com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10",
45-
"org.slf4j" % "slf4j-api" % "2.0.18"
60+
"com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10"
61+
)
62+
)
63+
.dependsOn(core)
64+
65+
lazy val client: Project = (project in file("client"))
66+
.settings(commonSettings: _*)
67+
.settings(
68+
name := "chimp-client",
69+
libraryDependencies ++= Seq(
70+
scalaTest,
71+
"com.softwaremill.sttp.client4" %% "core" % sttpClientV
4672
)
4773
)
74+
.dependsOn(core)
4875

4976
lazy val examples = (project in file("examples"))
5077
.settings(commonSettings: _*)
@@ -55,8 +82,127 @@ lazy val examples = (project in file("examples"))
5582
"com.softwaremill.sttp.client4" %% "core" % "4.0.23",
5683
"com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV,
5784
"com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirV,
58-
"ch.qos.logback" % "logback-classic" % "1.5.32"
85+
"ch.qos.logback" % "logback-classic" % logbackV
5986
),
6087
verifyExamplesCompileUsingScalaCli := VerifyExamplesCompileUsingScalaCli(sLog.value, sourceDirectory.value)
6188
)
62-
.dependsOn(core)
89+
.dependsOn(server, client)
90+
91+
import sbtassembly.AssemblyPlugin.autoImport.*
92+
93+
lazy val assemblySettings = Seq(
94+
assembly / assemblyMergeStrategy := {
95+
case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard
96+
case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard
97+
case PathList("META-INF", "DEPENDENCIES") => MergeStrategy.discard
98+
case PathList("META-INF", "services", _ @_*) => MergeStrategy.concat
99+
case PathList("META-INF", xs @ _*) if xs.lastOption.exists(s => s.endsWith(".SF") || s.endsWith(".DSA") || s.endsWith(".RSA")) =>
100+
MergeStrategy.discard
101+
case PathList("META-INF", _ @_*) => MergeStrategy.first
102+
case PathList("module-info.class") => MergeStrategy.discard
103+
case _ => MergeStrategy.first
104+
}
105+
)
106+
107+
lazy val serverConformance = (project in file("server-conformance"))
108+
.enablePlugins(AssemblyPlugin)
109+
.settings(commonSettings: _*)
110+
.settings(assemblySettings: _*)
111+
.settings(
112+
publishArtifact := false,
113+
name := "server-conformance",
114+
Compile / mainClass := Some("chimp.conformance.server.Main"),
115+
assembly / assemblyJarName := "chimp-server-conformance.jar",
116+
libraryDependencies ++= Seq(
117+
"com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV,
118+
"ch.qos.logback" % "logback-classic" % logbackV
119+
),
120+
conformance := {
121+
import complete.DefaultParsers.*
122+
123+
import scala.sys.process.*
124+
val args = spaceDelimited("<args>").parsed.toList
125+
val jar = assembly.value
126+
val rootDir = (LocalRootProject / baseDirectory).value
127+
val baseline = (rootDir / "conformance-baseline.yml").getAbsolutePath
128+
val log = streams.value.log
129+
130+
val urlPromise = scala.concurrent.Promise[String]()
131+
val pb = new java.lang.ProcessBuilder("java", "-jar", jar.getAbsolutePath).redirectErrorStream(false)
132+
val proc = pb.start()
133+
val readerThread = new Thread(() => {
134+
val reader = new java.io.BufferedReader(new java.io.InputStreamReader(proc.getInputStream, "UTF-8"))
135+
try {
136+
val line = reader.readLine()
137+
if (line != null && line.startsWith("http")) urlPromise.trySuccess(line.trim)
138+
else urlPromise.tryFailure(new RuntimeException(s"Server did not print a URL; first line was: $line"))
139+
var more: String = reader.readLine()
140+
while (more != null) {
141+
log.info(s"[server] $more")
142+
more = reader.readLine()
143+
}
144+
} catch {
145+
case t: Throwable => urlPromise.tryFailure(t)
146+
}
147+
})
148+
readerThread.setDaemon(true)
149+
readerThread.start()
150+
151+
val errReaderThread = new Thread(() => {
152+
val reader = new java.io.BufferedReader(new java.io.InputStreamReader(proc.getErrorStream, "UTF-8"))
153+
try {
154+
var line: String = reader.readLine()
155+
while (line != null) {
156+
log.warn(s"[server-stderr] $line")
157+
line = reader.readLine()
158+
}
159+
} catch {
160+
case _: Throwable => ()
161+
}
162+
})
163+
errReaderThread.setDaemon(true)
164+
errReaderThread.start()
165+
166+
try {
167+
val url = scala.concurrent.Await.result(urlPromise.future, scala.concurrent.duration.Duration("15s"))
168+
log.info(s"Server started at $url")
169+
val cmd = List("npx", "@modelcontextprotocol/conformance") ++ args ++
170+
List("--url", url, "--expected-failures", baseline)
171+
val rc = Process(cmd, rootDir).!
172+
if (rc != 0) sys.error(s"conformance harness exited with code $rc")
173+
} finally {
174+
proc.destroy()
175+
if (!proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) proc.destroyForcibly()
176+
}
177+
}
178+
)
179+
.dependsOn(server)
180+
181+
lazy val clientConformance = (project in file("client-conformance"))
182+
.enablePlugins(AssemblyPlugin)
183+
.settings(commonSettings: _*)
184+
.settings(assemblySettings: _*)
185+
.settings(
186+
publishArtifact := false,
187+
name := "client-conformance",
188+
Compile / mainClass := Some("chimp.conformance.client.Main"),
189+
assembly / assemblyJarName := "chimp-client-conformance.jar",
190+
libraryDependencies ++= Seq(
191+
"ch.qos.logback" % "logback-classic" % "1.5.32"
192+
),
193+
conformance := {
194+
import complete.DefaultParsers.*
195+
196+
import scala.sys.process.*
197+
val args = spaceDelimited("<args>").parsed.toList
198+
val _ = assembly.value
199+
val baseDir = baseDirectory.value
200+
val rootDir = (LocalRootProject / baseDirectory).value
201+
val wrapper = (baseDir / "bin" / "chimp-conformance-client").getAbsolutePath
202+
val cmd = List("npx", "@modelcontextprotocol/conformance") ++ args ++
203+
List("--command", wrapper, "--expected-failures", (rootDir / "conformance-baseline.yml").getAbsolutePath)
204+
val rc = Process(cmd, rootDir).!
205+
if (rc != 0) sys.error(s"conformance harness exited with code $rc")
206+
}
207+
)
208+
.dependsOn(client)

client-conformance/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# client-conformance
2+
3+
Runs chimp's MCP client against the
4+
official [MCP conformance test suite](https://github.com/modelcontextprotocol/conformance).
5+
6+
## What it does
7+
8+
The conformance harness is the inverse of a server: it **starts a test MCP server**, spawns the binary configured
9+
via `--command` (this fat-jar wrapped in [`bin/chimp-conformance-client`](bin/chimp-conformance-client)), and passes the
10+
test-server URL as the last argument. The client process reads the `MCP_CONFORMANCE_SCENARIO` env var to decide what
11+
protocol exchange to drive, talks to the harness's server, and exits 0 on success.
12+
13+
This subproject is the binary the harness invokes. `Main.scala` dispatches on the scenario name and uses `chimp-client`
14+
to drive the protocol.
15+
16+
## How to run
17+
18+
Using sbt task (assembles a client fat jar and runs test suite in one step):
19+
20+
```bash
21+
sbt 'clientConformance/conformance client --suite core'
22+
sbt 'clientConformance/conformance client --scenario initialize'
23+
sbt 'clientConformance/conformance client --scenario tools_call'
24+
```
25+
26+
The `@modelcontextprotocol/conformance` will be installed using npm, it must be available on the PATH.
27+
28+
## Adding a scenario
29+
30+
Add a case in `Main.scala` matching on the scenario name (the value of `MCP_CONFORMANCE_SCENARIO`), drive the protocol
31+
with `McpClient`, return exit code 0 on success. Once it passes, remove the entry from the baseline file (see below).
32+
33+
## The baseline file
34+
35+
[`conformance-baseline.yml`](../conformance-baseline.yml) lists scenarios that are known to fail today. The harness uses
36+
it like this:
37+
38+
| Scenario result | In baseline? | Exit code | Meaning |
39+
|-----------------|--------------|-----------|---------------------------------------|
40+
| Fails | Yes | 0 | Expected failure — keep working on it |
41+
| Fails | No | 1 | Regression — CI fails |
42+
| Passes | Yes | 1 | Stale baseline — remove the entry |
43+
| Passes | No | 0 | Normal pass |
44+
45+
So the file shrinks as the SDK matures.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4+
JAR="${DIR}/../target/scala-3.3.7/chimp-client-conformance.jar"
5+
if [[ ! -f "${JAR}" ]]; then
6+
echo "Fat jar not found at ${JAR}. Run 'sbt clientConformance/assembly' first." >&2
7+
exit 127
8+
fi
9+
exec java -jar "${JAR}" "$@"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<configuration>
2+
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
3+
<target>System.err</target>
4+
<encoder>
5+
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
6+
</encoder>
7+
</appender>
8+
<root level="warn">
9+
<appender-ref ref="STDERR" />
10+
</root>
11+
</configuration>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package chimp.conformance.client
2+
3+
import chimp.client.McpClient
4+
import chimp.client.transport.HttpTransport
5+
import chimp.protocol.*
6+
import io.circe.Json
7+
import sttp.client4.DefaultSyncBackend
8+
import sttp.model.Uri
9+
import sttp.shared.Identity
10+
11+
object Main:
12+
13+
private val clientInfo = Implementation(name = "chimp-conformance-client", version = "0.1.0")
14+
15+
def main(args: Array[String]): Unit =
16+
if args.isEmpty then
17+
System.err.println("Usage: chimp-conformance-client <serverUrl>")
18+
sys.exit(2)
19+
20+
val serverUrl = Uri.parse(args.last) match
21+
case Right(url) => url
22+
case Left(e) => System.err.println(s"Invalid server URL: $e"); sys.exit(2)
23+
24+
val scenario = sys.env.getOrElse("MCP_CONFORMANCE_SCENARIO", "")
25+
val protocolVersion: ProtocolVersion = sys.env
26+
.get("MCP_CONFORMANCE_PROTOCOL_VERSION")
27+
.flatMap(ProtocolVersion.from)
28+
.getOrElse(ProtocolVersion.Latest)
29+
30+
val backend = DefaultSyncBackend()
31+
val transport = HttpTransport[Identity](backend, serverUrl, protocolVersion)
32+
33+
val rc: Int =
34+
try
35+
scenario match
36+
case "initialize" =>
37+
val client = McpClient[Identity](transport, clientInfo, protocolVersion = protocolVersion)
38+
val _ = client.initialize()
39+
client.close()
40+
0
41+
42+
case "tools_call" =>
43+
val client = McpClient[Identity](transport, clientInfo, protocolVersion = protocolVersion)
44+
val _ = client.initialize()
45+
val _ = client.callTool(
46+
"add_numbers",
47+
Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))
48+
)
49+
client.close()
50+
0
51+
52+
case s if s == "elicitation-sep1034-client-defaults" || s == "sse-retry" || s.startsWith("auth/") =>
53+
2
54+
55+
case other =>
56+
System.err.println(s"Scenario not implemented: $other")
57+
3
58+
catch
59+
case t: Throwable =>
60+
t.printStackTrace()
61+
1
62+
finally
63+
try backend.close()
64+
catch case _: Throwable => ()
65+
66+
sys.exit(rc)

0 commit comments

Comments
 (0)