Skip to content

Commit ea5e952

Browse files
committed
[TS] Add usvm-mcp: MCP server exposing symbolic execution tools
New module wrapping usvm-ts in a Model Context Protocol (stdio) server for hybrid LLM + symbolic analysis of TypeScript. Tools: generate_tests, check_exceptions, check_reachability, find_unreachable_code, find_counterexample (property falsification), list_methods, get_method_ir. TsTestResolver and ObjectClass are moved from the usvm-ts test source set to main so that the server can resolve symbolic states to concrete inputs.
1 parent dab6d43 commit ea5e952

26 files changed

Lines changed: 1479 additions & 0 deletions

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ tasks.register("validateProjectList") {
2222
project(":usvm-python"),
2323
project(":usvm-ts"),
2424
project(":usvm-ts-dataflow"),
25+
project(":usvm-mcp"),
2526
)
2627

2728
// Gather the actual subprojects from the current root project.

buildSrc/src/main/kotlin/Dependencies.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ object Versions {
1616
const val kotlinx_serialization = "1.7.3"
1717
const val ksmt = "0.5.26"
1818
const val logback = "1.4.8"
19+
const val mcp_kotlin_sdk = "0.8.3"
1920
const val mockk = "1.13.4"
2021
const val rd = "2023.2.0"
2122
const val sarif4k = "0.5.0"
@@ -248,6 +249,13 @@ object Libs {
248249
name = "clikt",
249250
version = Versions.clikt
250251
)
252+
253+
// https://github.com/modelcontextprotocol/kotlin-sdk
254+
val mcp_kotlin_sdk = dep(
255+
group = "io.modelcontextprotocol",
256+
name = "kotlin-sdk",
257+
version = Versions.mcp_kotlin_sdk
258+
)
251259
}
252260

253261
object Plugins {

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ include("usvm-sample-language")
3939
include("usvm-dataflow")
4040
include("usvm-jvm-dataflow")
4141
include("usvm-ts-dataflow")
42+
include("usvm-mcp")
4243

4344
include("usvm-python")
4445
include("usvm-python:cpythonadapter")

usvm-mcp/README.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# usvm-mcp
2+
3+
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that exposes the USVM
4+
symbolic execution engine for TypeScript (`usvm-ts`) as a set of tools for LLM agents
5+
(Claude Code, Claude Desktop, MCP Inspector, or any other MCP client).
6+
7+
The goal is **hybrid analysis of dynamic languages**: the LLM reads and writes code,
8+
while the symbolic machine provides ground-truth facts about it — concrete inputs per
9+
execution path, reachability witnesses, crashing inputs, and counterexamples to
10+
hypotheses formulated by the LLM.
11+
12+
## Prerequisites
13+
14+
1. **JDK 11+** — the MCP Kotlin SDK requires JVM 11 (the rest of USVM targets 1.8;
15+
this leaf module overrides the target).
16+
2. **Node.js** — used by ArkAnalyzer to convert TypeScript into ETS IR.
17+
3. **ArkAnalyzer** — a built checkout, pointed to by the `ARKANALYZER_DIR` environment variable:
18+
19+
```bash
20+
git clone https://gitee.com/openharmony-sig/arkanalyzer
21+
cd arkanalyzer && npm install && npm run build
22+
export ARKANALYZER_DIR=$(pwd)
23+
```
24+
25+
## Build and run
26+
27+
```bash
28+
./gradlew :usvm-mcp:installDist
29+
```
30+
31+
This produces a launcher at `usvm-mcp/build/install/usvm-mcp/bin/usvm-mcp`.
32+
The server speaks MCP over **stdio**: stdout carries JSON-RPC, all logging goes to stderr.
33+
34+
### Connect from Claude Code
35+
36+
```bash
37+
claude mcp add usvm \
38+
-e ARKANALYZER_DIR=/path/to/arkanalyzer \
39+
-- /path/to/usvm/usvm-mcp/build/install/usvm-mcp/bin/usvm-mcp
40+
```
41+
42+
### Connect from MCP Inspector (debugging)
43+
44+
```bash
45+
ARKANALYZER_DIR=/path/to/arkanalyzer \
46+
npx @modelcontextprotocol/inspector usvm-mcp/build/install/usvm-mcp/bin/usvm-mcp
47+
```
48+
49+
### Run from Gradle (debugging)
50+
51+
```bash
52+
./gradlew :usvm-mcp:run
53+
```
54+
55+
## Tools
56+
57+
All tools take a `file` argument — a path to a single `.ts` file. Converted scenes are
58+
cached in memory (keyed by path and mtime), so repeated calls on the same file are fast.
59+
Analysis tools also accept `timeoutMs` (default 30000, max 300000).
60+
61+
| Tool | What it does |
62+
|------|--------------|
63+
| `list_methods` | Lists classes and methods visible to the machine. Call it first to discover exact `class`/`method` argument values. Top-level functions live in a synthetic `%dflt` class. |
64+
| `get_method_ir` | Dumps the CFG of a method: IR statements with indices and successor indices. **Statement indices are the only way to address a statement** (source line numbers are not preserved in the IR); pass them as `stmtIndex` to `check_reachability`. |
65+
| `generate_tests` | Symbolically executes a method and returns one test case per explored path: concrete `parameters`/`thisInstance` plus the expected return value or exception. |
66+
| `check_exceptions` | Same exploration, but reports only the paths that throw, together with the inputs that trigger them. |
67+
| `check_reachability` | Directed (targeted) search towards a given statement. Returns `REACHABLE` with a witness (concrete inputs) or `NOT_REACHED_WITHIN_BUDGET`. |
68+
| `find_unreachable_code` | Reports `if` branches never taken during exploration — dead-code candidates (statement indices match `get_method_ir`). |
69+
| `find_counterexample` | Tries to **falsify a boolean property function** written by the LLM: searches for inputs where it returns `false` (counterexamples) or throws (crashes). |
70+
71+
### Result format
72+
73+
Analysis results are JSON. Concrete values map to JSON naturally; JS-specific values are
74+
tagged objects so nothing is ambiguous:
75+
76+
```json
77+
{
78+
"kind": "SUCCESS",
79+
"thisInstance": { "$kind": "object", "class": "BasicConditions", "properties": {} },
80+
"parameters": [ 12.0, { "$kind": "number", "value": "NaN" }, { "$kind": "undefined" } ],
81+
"returnValue": 1.0
82+
}
83+
```
84+
85+
Exceptional paths use `"kind": "EXCEPTION"` with an `exception` object instead of `returnValue`.
86+
87+
## Hybrid workflows
88+
89+
**Test generation.** `generate_tests` yields per-path inputs and outcomes; the LLM turns
90+
them into a unit-test file with real assertions and can immediately execute it to validate.
91+
92+
**Reachability querying.** `get_method_ir` → pick the index of an interesting statement
93+
(a `return`, a branch) → `check_reachability`. A witness is a ready-made regression input.
94+
95+
**Hypothesis falsification** (`find_counterexample`). The LLM writes a property function
96+
into a `.ts` file and asks the machine to break it:
97+
98+
```typescript
99+
function abs(x: number): number {
100+
if (x < 0) return -x;
101+
return x;
102+
}
103+
104+
// Hypothesis: "abs is always non-negative"
105+
function propAbsNonNegative(x: number): boolean {
106+
return abs(x) >= 0;
107+
}
108+
```
109+
110+
`find_counterexample(file, method="propAbsNonNegative")``COUNTEREXAMPLE_FOUND` with
111+
concrete falsifying inputs, or `NO_COUNTEREXAMPLE_WITHIN_BUDGET`.
112+
113+
**Equivalence checking of a refactoring.** A special case of the above: the LLM puts the
114+
original `f`, its refactored `g`, and `function equiv(x: number): boolean { return f(x) === g(x); }`
115+
into one file and falsifies `equiv`. A counterexample is an input where the refactoring
116+
changed behavior.
117+
118+
## Interpreting verdicts honestly
119+
120+
- Exploration is **bounded by a time budget**. `NOT_REACHED_WITHIN_BUDGET`,
121+
an empty `check_exceptions` result, or `NO_COUNTEREXAMPLE_WITHIN_BUDGET` are
122+
*evidence*, not proofs. Retry with a larger `timeoutMs` when it matters.
123+
- The symbolic model **over-approximates JavaScript semantics** in places
124+
(e.g., number comparisons involving `NaN` and untyped values), so a `REACHABLE`
125+
witness may occasionally be spurious. The recommended hybrid loop is to
126+
**validate every witness by actually running the code** with the reported inputs
127+
(node/ts-node) — the tool responses remind about this. The same over-approximation
128+
can make `find_unreachable_code` miss dead branches.
129+
- The property/code for `find_counterexample` must live in a **single `.ts` file**
130+
(project-level scenes are not wired up yet), and must stay within the TS subset
131+
supported by `usvm-ts` (numbers, booleans, objects, arrays; strings partially).
132+
133+
## Module layout
134+
135+
```
136+
src/main/kotlin/org/usvm/mcp/
137+
├── Main.kt # stdio transport, stdout guard
138+
├── UsvmMcpServer.kt # server construction, tool registration
139+
├── McpErrors.kt # expected-failure handling (isError results)
140+
├── scene/ # EtsScene cache (ArkAnalyzer), method lookup
141+
├── exec/ # UMachineOptions presets, serialized machine runs
142+
├── json/ # DTOs and TsTestValue -> JSON rendering
143+
└── tools/ # one file per MCP tool
144+
```
145+
146+
Design notes:
147+
148+
- **stdout discipline**: stdout is the JSON-RPC channel. `Main.kt` re-points `System.out`
149+
to stderr before anything else, and `logback.xml` sends all logging to stderr
150+
(`org.usvm`/`org.jacodb`/`io.ksmt` are capped at `WARN`).
151+
- **One analysis at a time**: machine runs are serialized with a mutex; MCP clients may
152+
issue concurrent calls, but the solver and the machine are heavyweight.
153+
- Concrete values are resolved from symbolic states by `TsTestResolver`
154+
(`usvm-ts`, `org.usvm.util`), shared with the usvm-ts test infrastructure.
155+
156+
## Tests
157+
158+
```bash
159+
./gradlew :usvm-mcp:test # unit tests (no ArkAnalyzer required)
160+
```
161+
162+
For an end-to-end check, use the MCP Inspector recipe above on
163+
`usvm-ts/src/test/resources/reachability/BasicConditions.ts`.

usvm-mcp/build.gradle.kts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2+
3+
plugins {
4+
id("usvm.kotlin-conventions")
5+
kotlin("plugin.serialization") version Versions.kotlin
6+
application
7+
id(Plugins.Shadow)
8+
}
9+
10+
dependencies {
11+
implementation(project(":usvm-ts"))
12+
implementation(project(":usvm-core"))
13+
implementation(project(":usvm-util"))
14+
15+
implementation(Libs.jacodb_ets)
16+
implementation(Libs.mcp_kotlin_sdk)
17+
implementation(Libs.kotlinx_serialization_json)
18+
19+
runtimeOnly(Libs.logback)
20+
}
21+
22+
// The MCP Kotlin SDK (and its Ktor/kotlinx-io dependencies) requires JVM 11,
23+
// while the shared conventions target JVM 1.8. This is a leaf application
24+
// module, so it is safe to raise the target here.
25+
tasks.withType<JavaCompile> {
26+
sourceCompatibility = JavaVersion.VERSION_11.toString()
27+
targetCompatibility = JavaVersion.VERSION_11.toString()
28+
}
29+
tasks.withType<KotlinCompile> {
30+
kotlinOptions {
31+
jvmTarget = JavaVersion.VERSION_11.toString()
32+
}
33+
}
34+
35+
application {
36+
mainClass = "org.usvm.mcp.MainKt"
37+
applicationDefaultJvmArgs = listOf("-Dfile.encoding=UTF-8", "-Dsun.stdout.encoding=UTF-8")
38+
}
39+
40+
tasks.startScripts {
41+
applicationName = "usvm-mcp"
42+
}
43+
44+
// Forward stdin to the process so that `./gradlew :usvm-mcp:run` can be used
45+
// as an MCP stdio server directly (e.g., for local debugging).
46+
tasks.named<JavaExec>("run") {
47+
standardInput = System.`in`
48+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package org.usvm.mcp
2+
3+
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
4+
import kotlinx.coroutines.CompletableDeferred
5+
import kotlinx.coroutines.runBlocking
6+
import kotlinx.io.asSink
7+
import kotlinx.io.asSource
8+
import kotlinx.io.buffered
9+
import java.io.FileDescriptor
10+
import java.io.FileOutputStream
11+
import java.io.PrintStream
12+
13+
/**
14+
* Entry point of the USVM MCP server (stdio transport).
15+
*
16+
* Stdout is the JSON-RPC channel, so before anything else we redirect
17+
* [System.out] to stderr: any stray `println` from USVM/ksmt/ArkAnalyzer
18+
* integration must not corrupt the protocol stream.
19+
*/
20+
fun main() {
21+
val realStdout = System.out
22+
System.setOut(PrintStream(FileOutputStream(FileDescriptor.err), true))
23+
24+
val server = buildUsvmMcpServer()
25+
val transport = StdioServerTransport(
26+
System.`in`.asSource().buffered(),
27+
realStdout.asSink().buffered(),
28+
)
29+
30+
runBlocking {
31+
val session = server.createSession(transport)
32+
val done = CompletableDeferred<Unit>()
33+
session.onClose { done.complete(Unit) }
34+
done.await()
35+
}
36+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package org.usvm.mcp
2+
3+
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
4+
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
5+
6+
/**
7+
* An expected, user-facing tool failure. Its message is written as an
8+
* actionable instruction for the calling LLM/user (what went wrong and
9+
* how to fix the call or the environment).
10+
*/
11+
class McpToolException(message: String) : RuntimeException(message)
12+
13+
fun textResult(text: String): CallToolResult =
14+
CallToolResult(content = listOf(TextContent(text)))
15+
16+
fun errorResult(text: String): CallToolResult =
17+
CallToolResult(content = listOf(TextContent(text)), isError = true)
18+
19+
/**
20+
* Wraps a tool handler body: expected errors become `isError` results with
21+
* actionable messages, unexpected ones become `isError` results with a short
22+
* diagnostic. The server never crashes because of a single tool call.
23+
*/
24+
suspend fun runTool(block: suspend () -> CallToolResult): CallToolResult =
25+
try {
26+
block()
27+
} catch (e: McpToolException) {
28+
errorResult(e.message ?: "Tool failed")
29+
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
30+
val frames = e.stackTrace.take(5).joinToString("\n") { " at $it" }
31+
errorResult("Internal error: ${e::class.simpleName}: ${e.message}\n$frames")
32+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.usvm.mcp
2+
3+
import io.modelcontextprotocol.kotlin.sdk.server.Server
4+
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
5+
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
6+
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
7+
import org.usvm.mcp.exec.TsAnalysisRunner
8+
import org.usvm.mcp.scene.SceneCache
9+
import org.usvm.mcp.tools.UsvmToolContext
10+
import org.usvm.mcp.tools.registerCheckExceptionsTool
11+
import org.usvm.mcp.tools.registerCheckReachabilityTool
12+
import org.usvm.mcp.tools.registerFindCounterexampleTool
13+
import org.usvm.mcp.tools.registerFindUnreachableCodeTool
14+
import org.usvm.mcp.tools.registerGenerateTestsTool
15+
import org.usvm.mcp.tools.registerGetMethodIrTool
16+
import org.usvm.mcp.tools.registerListMethodsTool
17+
18+
const val SERVER_NAME = "usvm-mcp"
19+
const val SERVER_VERSION = "0.1.0"
20+
21+
private const val SERVER_INSTRUCTIONS = """
22+
USVM symbolic execution tools for TypeScript.
23+
24+
Typical hybrid workflow:
25+
1. `list_methods` to see what is analyzable in a .ts file;
26+
2. `get_method_ir` to inspect the CFG of a method (statement indices are used as targets);
27+
3. `generate_tests` / `check_exceptions` to obtain concrete inputs per execution path;
28+
4. `check_reachability` with a statement index from `get_method_ir`;
29+
5. `find_unreachable_code` for dead branches;
30+
6. `find_counterexample` to falsify a boolean property function you wrote yourself.
31+
32+
Analysis budgets are limited: a negative answer means "not found within budget",
33+
not a proof, unless stated otherwise.
34+
"""
35+
36+
/**
37+
* Builds the MCP server and registers all USVM tools on it.
38+
*/
39+
fun buildUsvmMcpServer(): Server {
40+
val server = Server(
41+
serverInfo = Implementation(name = SERVER_NAME, version = SERVER_VERSION),
42+
options = ServerOptions(
43+
capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false)),
44+
),
45+
instructions = SERVER_INSTRUCTIONS.trimIndent(),
46+
)
47+
48+
val ctx = UsvmToolContext(scenes = SceneCache(), runner = TsAnalysisRunner())
49+
server.registerListMethodsTool(ctx)
50+
server.registerGetMethodIrTool(ctx)
51+
server.registerGenerateTestsTool(ctx)
52+
server.registerCheckExceptionsTool(ctx)
53+
server.registerCheckReachabilityTool(ctx)
54+
server.registerFindUnreachableCodeTool(ctx)
55+
server.registerFindCounterexampleTool(ctx)
56+
57+
return server
58+
}

0 commit comments

Comments
 (0)