Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ tasks.register("validateProjectList") {
project(":usvm-python"),
project(":usvm-ts"),
project(":usvm-ts-dataflow"),
project(":usvm-mcp"),
)

// Gather the actual subprojects from the current root project.
Expand Down
8 changes: 8 additions & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ object Versions {
const val kotlinx_serialization = "1.7.3"
const val ksmt = "0.5.26"
const val logback = "1.4.8"
const val mcp_kotlin_sdk = "0.8.3"
const val mockk = "1.13.4"
const val rd = "2023.2.0"
const val sarif4k = "0.5.0"
Expand Down Expand Up @@ -248,6 +249,13 @@ object Libs {
name = "clikt",
version = Versions.clikt
)

// https://github.com/modelcontextprotocol/kotlin-sdk
val mcp_kotlin_sdk = dep(
group = "io.modelcontextprotocol",
name = "kotlin-sdk",
version = Versions.mcp_kotlin_sdk
)
}

object Plugins {
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ include("usvm-sample-language")
include("usvm-dataflow")
include("usvm-jvm-dataflow")
include("usvm-ts-dataflow")
include("usvm-mcp")

include("usvm-python")
include("usvm-python:cpythonadapter")
Expand Down
163 changes: 163 additions & 0 deletions usvm-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# usvm-mcp

An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that exposes the USVM
symbolic execution engine for TypeScript (`usvm-ts`) as a set of tools for LLM agents
(Claude Code, Claude Desktop, MCP Inspector, or any other MCP client).

The goal is **hybrid analysis of dynamic languages**: the LLM reads and writes code,
while the symbolic machine provides ground-truth facts about it — concrete inputs per
execution path, reachability witnesses, crashing inputs, and counterexamples to
hypotheses formulated by the LLM.

## Prerequisites

1. **JDK 11+** — the MCP Kotlin SDK requires JVM 11 (the rest of USVM targets 1.8;
this leaf module overrides the target).
2. **Node.js** — used by ArkAnalyzer to convert TypeScript into ETS IR.
3. **ArkAnalyzer** — a built checkout, pointed to by the `ARKANALYZER_DIR` environment variable:

```bash
git clone https://gitee.com/openharmony-sig/arkanalyzer
cd arkanalyzer && npm install && npm run build
export ARKANALYZER_DIR=$(pwd)
```

## Build and run

```bash
./gradlew :usvm-mcp:installDist
```

This produces a launcher at `usvm-mcp/build/install/usvm-mcp/bin/usvm-mcp`.
The server speaks MCP over **stdio**: stdout carries JSON-RPC, all logging goes to stderr.

### Connect from Claude Code

```bash
claude mcp add usvm \
-e ARKANALYZER_DIR=/path/to/arkanalyzer \
-- /path/to/usvm/usvm-mcp/build/install/usvm-mcp/bin/usvm-mcp
```

### Connect from MCP Inspector (debugging)

```bash
ARKANALYZER_DIR=/path/to/arkanalyzer \
npx @modelcontextprotocol/inspector usvm-mcp/build/install/usvm-mcp/bin/usvm-mcp
```

### Run from Gradle (debugging)

```bash
./gradlew :usvm-mcp:run
```

## Tools

All tools take a `file` argument — a path to a single `.ts` file. Converted scenes are
cached in memory (keyed by path and mtime), so repeated calls on the same file are fast.
Analysis tools also accept `timeoutMs` (default 30000, max 300000).

| Tool | What it does |
|------|--------------|
| `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. |
| `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`. |
| `generate_tests` | Symbolically executes a method and returns one test case per explored path: concrete `parameters`/`thisInstance` plus the expected return value or exception. |
| `check_exceptions` | Same exploration, but reports only the paths that throw, together with the inputs that trigger them. |
| `check_reachability` | Directed (targeted) search towards a given statement. Returns `REACHABLE` with a witness (concrete inputs) or `NOT_REACHED_WITHIN_BUDGET`. |
| `find_unreachable_code` | Reports `if` branches never taken during exploration — dead-code candidates (statement indices match `get_method_ir`). |
| `find_counterexample` | Tries to **falsify a boolean property function** written by the LLM: searches for inputs where it returns `false` (counterexamples) or throws (crashes). |

### Result format

Analysis results are JSON. Concrete values map to JSON naturally; JS-specific values are
tagged objects so nothing is ambiguous:

```json
{
"kind": "SUCCESS",
"thisInstance": { "$kind": "object", "class": "BasicConditions", "properties": {} },
"parameters": [ 12.0, { "$kind": "number", "value": "NaN" }, { "$kind": "undefined" } ],
"returnValue": 1.0
}
```

Exceptional paths use `"kind": "EXCEPTION"` with an `exception` object instead of `returnValue`.

## Hybrid workflows

**Test generation.** `generate_tests` yields per-path inputs and outcomes; the LLM turns
them into a unit-test file with real assertions and can immediately execute it to validate.

**Reachability querying.** `get_method_ir` → pick the index of an interesting statement
(a `return`, a branch) → `check_reachability`. A witness is a ready-made regression input.

**Hypothesis falsification** (`find_counterexample`). The LLM writes a property function
into a `.ts` file and asks the machine to break it:

```typescript
function abs(x: number): number {
if (x < 0) return -x;
return x;
}

// Hypothesis: "abs is always non-negative"
function propAbsNonNegative(x: number): boolean {
return abs(x) >= 0;
}
```

`find_counterexample(file, method="propAbsNonNegative")` → `COUNTEREXAMPLE_FOUND` with
concrete falsifying inputs, or `NO_COUNTEREXAMPLE_WITHIN_BUDGET`.

**Equivalence checking of a refactoring.** A special case of the above: the LLM puts the
original `f`, its refactored `g`, and `function equiv(x: number): boolean { return f(x) === g(x); }`
into one file and falsifies `equiv`. A counterexample is an input where the refactoring
changed behavior.

## Interpreting verdicts honestly

- Exploration is **bounded by a time budget**. `NOT_REACHED_WITHIN_BUDGET`,
an empty `check_exceptions` result, or `NO_COUNTEREXAMPLE_WITHIN_BUDGET` are
*evidence*, not proofs. Retry with a larger `timeoutMs` when it matters.
- The symbolic model **over-approximates JavaScript semantics** in places
(e.g., number comparisons involving `NaN` and untyped values), so a `REACHABLE`
witness may occasionally be spurious. The recommended hybrid loop is to
**validate every witness by actually running the code** with the reported inputs
(node/ts-node) — the tool responses remind about this. The same over-approximation
can make `find_unreachable_code` miss dead branches.
- The property/code for `find_counterexample` must live in a **single `.ts` file**
(project-level scenes are not wired up yet), and must stay within the TS subset
supported by `usvm-ts` (numbers, booleans, objects, arrays; strings partially).

## Module layout

```
src/main/kotlin/org/usvm/mcp/
├── Main.kt # stdio transport, stdout guard
├── UsvmMcpServer.kt # server construction, tool registration
├── McpErrors.kt # expected-failure handling (isError results)
├── scene/ # EtsScene cache (ArkAnalyzer), method lookup
├── exec/ # UMachineOptions presets, serialized machine runs
├── json/ # DTOs and TsTestValue -> JSON rendering
└── tools/ # one file per MCP tool
```

Design notes:

- **stdout discipline**: stdout is the JSON-RPC channel. `Main.kt` re-points `System.out`
to stderr before anything else, and `logback.xml` sends all logging to stderr
(`org.usvm`/`org.jacodb`/`io.ksmt` are capped at `WARN`).
- **One analysis at a time**: machine runs are serialized with a mutex; MCP clients may
issue concurrent calls, but the solver and the machine are heavyweight.
- Concrete values are resolved from symbolic states by `TsTestResolver`
(`usvm-ts`, `org.usvm.util`), shared with the usvm-ts test infrastructure.

## Tests

```bash
./gradlew :usvm-mcp:test # unit tests (no ArkAnalyzer required)
```

For an end-to-end check, use the MCP Inspector recipe above on
`usvm-ts/src/test/resources/reachability/BasicConditions.ts`.
48 changes: 48 additions & 0 deletions usvm-mcp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
id("usvm.kotlin-conventions")
kotlin("plugin.serialization") version Versions.kotlin
application
id(Plugins.Shadow)
}

dependencies {
implementation(project(":usvm-ts"))
implementation(project(":usvm-core"))
implementation(project(":usvm-util"))

implementation(Libs.jacodb_ets)
implementation(Libs.mcp_kotlin_sdk)
implementation(Libs.kotlinx_serialization_json)

runtimeOnly(Libs.logback)
}

// The MCP Kotlin SDK (and its Ktor/kotlinx-io dependencies) requires JVM 11,
// while the shared conventions target JVM 1.8. This is a leaf application
// module, so it is safe to raise the target here.
tasks.withType<JavaCompile> {
sourceCompatibility = JavaVersion.VERSION_11.toString()
targetCompatibility = JavaVersion.VERSION_11.toString()
}
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
}

application {
mainClass = "org.usvm.mcp.MainKt"
applicationDefaultJvmArgs = listOf("-Dfile.encoding=UTF-8", "-Dsun.stdout.encoding=UTF-8")
}

tasks.startScripts {
applicationName = "usvm-mcp"
}

// Forward stdin to the process so that `./gradlew :usvm-mcp:run` can be used
// as an MCP stdio server directly (e.g., for local debugging).
tasks.named<JavaExec>("run") {
standardInput = System.`in`
}
36 changes: 36 additions & 0 deletions usvm-mcp/src/main/kotlin/org/usvm/mcp/Main.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.usvm.mcp

import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import java.io.FileDescriptor
import java.io.FileOutputStream
import java.io.PrintStream

/**
* Entry point of the USVM MCP server (stdio transport).
*
* Stdout is the JSON-RPC channel, so before anything else we redirect
* [System.out] to stderr: any stray `println` from USVM/ksmt/ArkAnalyzer
* integration must not corrupt the protocol stream.
*/
fun main() {
val realStdout = System.out
System.setOut(PrintStream(FileOutputStream(FileDescriptor.err), true))

val server = buildUsvmMcpServer()
val transport = StdioServerTransport(
System.`in`.asSource().buffered(),
realStdout.asSink().buffered(),
)

runBlocking {
val session = server.createSession(transport)
val done = CompletableDeferred<Unit>()
session.onClose { done.complete(Unit) }
done.await()
}
}
32 changes: 32 additions & 0 deletions usvm-mcp/src/main/kotlin/org/usvm/mcp/McpErrors.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.usvm.mcp

import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.TextContent

/**
* An expected, user-facing tool failure. Its message is written as an
* actionable instruction for the calling LLM/user (what went wrong and
* how to fix the call or the environment).
*/
class McpToolException(message: String) : RuntimeException(message)

Check warning

Code scanning / detekt

If a source file contains only a single non-private top-level class or object, the file name should reflect the case-sensitive name plus the .kt extension. Warning

The file name 'McpErrors' does not match the name of the single top-level declaration 'McpToolException'.

fun textResult(text: String): CallToolResult =
CallToolResult(content = listOf(TextContent(text)))

fun errorResult(text: String): CallToolResult =
CallToolResult(content = listOf(TextContent(text)), isError = true)

/**
* Wraps a tool handler body: expected errors become `isError` results with
* actionable messages, unexpected ones become `isError` results with a short
* diagnostic. The server never crashes because of a single tool call.
*/
suspend fun runTool(block: suspend () -> CallToolResult): CallToolResult =
try {
block()
} catch (e: McpToolException) {
errorResult(e.message ?: "Tool failed")
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
val frames = e.stackTrace.take(5).joinToString("\n") { " at $it" }

Check warning

Code scanning / detekt

Report magic numbers. Magic number is a numeric literal that is not defined as a constant and hence it's unclear what the purpose of this number is. It's better to declare such numbers as constants and give them a proper name. By default, -1, 0, 1, and 2 are not considered to be magic numbers. Warning

This expression contains a magic number. Consider defining it to a well named constant.
errorResult("Internal error: ${e::class.simpleName}: ${e.message}\n$frames")
}
58 changes: 58 additions & 0 deletions usvm-mcp/src/main/kotlin/org/usvm/mcp/UsvmMcpServer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.usvm.mcp

import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import org.usvm.mcp.exec.TsAnalysisRunner
import org.usvm.mcp.scene.SceneCache
import org.usvm.mcp.tools.UsvmToolContext
import org.usvm.mcp.tools.registerCheckExceptionsTool
import org.usvm.mcp.tools.registerCheckReachabilityTool
import org.usvm.mcp.tools.registerFindCounterexampleTool
import org.usvm.mcp.tools.registerFindUnreachableCodeTool
import org.usvm.mcp.tools.registerGenerateTestsTool
import org.usvm.mcp.tools.registerGetMethodIrTool
import org.usvm.mcp.tools.registerListMethodsTool

const val SERVER_NAME = "usvm-mcp"
const val SERVER_VERSION = "0.1.0"

private const val SERVER_INSTRUCTIONS = """
USVM symbolic execution tools for TypeScript.

Typical hybrid workflow:
1. `list_methods` to see what is analyzable in a .ts file;
2. `get_method_ir` to inspect the CFG of a method (statement indices are used as targets);
3. `generate_tests` / `check_exceptions` to obtain concrete inputs per execution path;
4. `check_reachability` with a statement index from `get_method_ir`;
5. `find_unreachable_code` for dead branches;
6. `find_counterexample` to falsify a boolean property function you wrote yourself.

Analysis budgets are limited: a negative answer means "not found within budget",
not a proof, unless stated otherwise.
"""

/**
* Builds the MCP server and registers all USVM tools on it.
*/
fun buildUsvmMcpServer(): Server {
val server = Server(
serverInfo = Implementation(name = SERVER_NAME, version = SERVER_VERSION),
options = ServerOptions(
capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false)),
),
instructions = SERVER_INSTRUCTIONS.trimIndent(),
)

val ctx = UsvmToolContext(scenes = SceneCache(), runner = TsAnalysisRunner())
server.registerListMethodsTool(ctx)
server.registerGetMethodIrTool(ctx)
server.registerGenerateTestsTool(ctx)
server.registerCheckExceptionsTool(ctx)
server.registerCheckReachabilityTool(ctx)
server.registerFindUnreachableCodeTool(ctx)
server.registerFindCounterexampleTool(ctx)

return server
}
Loading
Loading