Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ void deployScriptInstrumentsTheTarget() throws Exception {
assertSuccess(result);
assertTrue(text(result).contains("Script deployed successfully to PID " + pid));
awaitCounter(RUNNING_PROBE, "processCalls", calls -> calls > 0);

// The agent lists only detached probes; list_probes must still show the one this server holds.
String listed = text(tool("list_probes"));
assertTrue(listed.contains("Connected through this MCP server: a probe on port"), listed);
assertFalse(listed.contains(RUNNING_PROBE), "a connected probe is listed as detached:\n" + listed);
}

@Test
Expand Down Expand Up @@ -242,6 +247,9 @@ void listProbesReportsTheRunningProbe() throws Exception {
Map<String, Object> result = tool("list_probes");
assertSuccess(result);
assertTrue(text(result).contains(RUNNING_PROBE));
assertTrue(
text(result).contains("Connected through this MCP server: none\n"),
"the detached probe is not this server's session:\n" + text(result));
assertTrue(server.isAlive(), "list_probes terminated the MCP server");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,23 @@

import io.btrace.mcp.BTraceClient;
import io.btrace.mcp.ClientManager;
import java.io.StringWriter;
import io.btrace.mcp.ProbeOutput;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Handles the list_probes MCP tool - lists active probes on a JVM. */
/**
* Handles the list_probes MCP tool. The agent lists only probes no client is connected to, so the
* probe this server's own session holds is reported from {@link ClientManager}. A probe another
* client (a btrace CLI, another MCP server) is connected to appears in neither list.
*/
public final class ListProbesHandler {
private static final Logger log = LoggerFactory.getLogger(ListProbesHandler.class);
private static final int DEFAULT_PORT = 2020;
Expand All @@ -40,7 +46,10 @@ private ListProbesHandler() {}
public static Map<String, Object> schema() {
Map<String, Object> tool = new LinkedHashMap<>();
tool.put("name", "list_probes");
tool.put("description", "List detached probes available to reconnect.");
tool.put(
"description",
"List the probe this server is connected to on a JVM and the detached probes still running"
+ " there. Probes connected through another client are not listed.");

Map<String, Object> properties = new LinkedHashMap<>();

Expand All @@ -67,40 +76,125 @@ public static Map<String, Object> schema() {

/** Executes the list_probes tool. */
public static Map<String, Object> execute(Map<String, Object> arguments) {
return execute(arguments, TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
}

static Map<String, Object> execute(Map<String, Object> arguments, long timeoutMillis) {
String pid = getStringArg(arguments, "pid");
int port = getIntArg(arguments, "port", DEFAULT_PORT);

if (pid == null || pid.isEmpty()) {
return toolResult("Error: 'pid' parameter is required", true);
}

// Read before listing. Only a session this listing reports as lost is forgotten afterwards; one
// that drops while the list is in flight is left for the next call to report.
Session session = connectedSession(pid);
String connectedLine = "Connected through this MCP server: " + session.description;
BTraceClient client = null;
try {
BTraceClient client = ClientManager.getClient(port);
client = ClientManager.getClient(port);
client.attach(pid, null, ".");

StringBuilder output = new StringBuilder();
CountDownLatch latch = new CountDownLatch(1);

client.listProbes(
"localhost",
cmd -> {
output.append(client.printableText(cmd));
if (client.commandType(cmd) == client.commandConstant("LIST_PROBES")) {
latch.countDown();
}
});

boolean done = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!done) {
return toolResult("Timed out waiting for probe list from PID " + pid, true);
BTraceClient lister = client;
// The client reads the reply with no timeout; never let an agent that does not answer block
// the request.
FutureTask<Void> list =
new FutureTask<>(
() -> {
lister.listProbes(
"localhost", cmd -> output.append(lister.printableText(cmd)));
return null;
});
Thread listerThread = new Thread(list, "btrace-list-probes-" + pid);
listerThread.setDaemon(true);
listerThread.start();
try {
list.get(timeoutMillis, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
return toolResult(
"Timed out waiting for probe list from PID " + pid + "\n" + connectedLine, true);
} catch (ExecutionException e) {
throw e.getCause() instanceof Exception ? (Exception) e.getCause() : e;
}

ClientManager.forgetLostSession(pid);
String resultText = "Active probes on PID " + pid + ":\n" + output.toString();
if (session.lost) {
ClientManager.forgetLostSession(pid);
}
String detached = output.toString().trim();
String resultText =
"Probes on PID "
+ pid
+ ":\n"
+ connectedLine
+ "\n"
+ "Detached (stop with stop_detached_probe): "
+ (detached.isEmpty() ? "none" : "\n" + detached)
+ "\n"
+ "Probes connected through another client (a btrace CLI or another MCP server) are"
+ " not listed.";
return toolResult(resultText, false);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return toolResult(
"Interrupted while listing probes on PID " + pid + "\n" + connectedLine, true);
} catch (Exception e) {
log.error("Failed to list probes", e);
return toolResult("Error listing probes on PID " + pid + ": " + e.getMessage(), true);
return toolResult(
"Error listing probes on PID " + pid + ": " + rootMessage(e) + "\n" + connectedLine,
true);
} finally {
closeQuietly(client);
}
}

/** What this server's session on a PID looked like when the listing started. */
private static final class Session {
final String description;
final boolean lost;

Session(String description, boolean lost) {
this.description = description;
this.lost = lost;
}
}

/** Describes this server's session on the PID, which the agent's list does not include. */
private static Session connectedSession(String pid) {
int port = ClientManager.sessionPort(pid);
if (port < 0) {
return new Session("none", false);
}
ProbeOutput output = ClientManager.getOutput(pid, port);
if (output == null || !output.isFinished()) {
return new Session("a probe on port " + port + " (stop with exit_probe)", false);
}
return output.hasExited()
? new Session("none (the probe on port " + port + " exited)", false)
: new Session(
"none (the connection to the probe on port "
+ port
+ " was lost; if it is still running, it is listed as detached)",
true);
}

private static String rootMessage(Throwable e) {
Throwable cause = e;
while (cause.getCause() != null && cause.getMessage() == null) {
cause = cause.getCause();
}
return cause.getMessage();
}

private static void closeQuietly(BTraceClient client) {
if (client == null) {
return;
}
try {
client.close();
} catch (Exception e) {
log.debug("Failed to close the list_probes client", e);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ public class Client {
public static String exitOutput = "";
public static boolean failAttach = false;
public static boolean ignoreExit = false;
/** An agent that never answers a probe list request: only closing the client ends the wait. */
public static boolean hangListProbes = false;
/** Runs while a probe list request is in flight, to model events that race with it. */
public static Runnable duringListProbes = null;
public final int port;
public String attachedPid;
public int exitCode;
Expand Down Expand Up @@ -78,6 +82,13 @@ public void submit(
}

public void connectAndListProbes(String host, CommandListener listener) throws Exception {
if (hangListProbes) {
closeLatch.await();
return;
}
if (duringListProbes != null) {
duringListProbes.run();
}
listener.onCommand(new FakePrintableCommand(Command.LIST_PROBES, host));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ void runsTheMaskedClientToolWorkflowWithFixtures() {

Map<String, Object> probes = ListProbesHandler.execute(Map.of("pid", "53", "port", 2053));
assertFalse((Boolean) probes.get("isError"));
assertTrue(text(probes).contains("Active probes on PID 53"));
assertTrue(text(probes).contains("Probes on PID 53"));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package io.btrace.mcp.tools;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.btrace.client.Client;
import io.btrace.mcp.ClientManager;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;

class ListProbesHandlerTest {
@Test
void reportsTheProbeThisServerIsConnectedTo() {
deploy("70", 2070);
Client session = Client.last;
try {
Map<String, Object> result = ListProbesHandler.execute(Map.of("pid", "70", "port", 2070));

assertEquals(false, result.get("isError"), text(result));
assertTrue(
text(result).contains("Connected through this MCP server: a probe on port 2070"),
text(result));
assertTrue(text(result).contains("exit_probe"), text(result));
assertTrue(text(result).contains("another client"), text(result));
assertTrue(Client.last != session && Client.last.closed, "the list client must be closed");
assertTrue(!session.closed, "listing must not touch the probe's own session");
} finally {
ExitProbeHandler.execute(Map.of("pid", "70", "port", 2070));
}
}

@Test
void reportsNoConnectedProbeWithoutASession() {
Map<String, Object> result = ListProbesHandler.execute(Map.of("pid", "71", "port", 2071));

assertEquals(false, result.get("isError"), text(result));
assertTrue(text(result).contains("Connected through this MCP server: none"), text(result));
assertTrue(text(result).contains("Detached (stop with stop_detached_probe):"), text(result));
assertTrue(Client.last.closed, "the list client must be closed");
}

@Test
void saysALostSessionsProbeIsListedAsDetached() throws Exception {
deploy("72", 2072);
ClientManager.getExistingClient("72", 2072).close();
assertTrue(ClientManager.getOutput("72", 2072).awaitFinished(5000));

Map<String, Object> result = ListProbesHandler.execute(Map.of("pid", "72", "port", 2072));

assertEquals(false, result.get("isError"), text(result));
assertTrue(text(result).contains("was lost"), text(result));
assertTrue(text(result).contains("listed as detached"), text(result));
assertEquals(null, ClientManager.getExistingClient("72", 2072), "the lost session is forgotten");
}

@Test
void keepsASessionLostWhileTheListIsInFlightForTheNextCall() throws Exception {
deploy("75", 2075);
Client.duringListProbes =
() -> {
try {
ClientManager.getExistingClient("75", 2075).close();
assertTrue(ClientManager.getOutput("75", 2075).awaitFinished(5000));
} catch (Exception e) {
throw new AssertionError(e);
}
};
try {
Map<String, Object> first = ListProbesHandler.execute(Map.of("pid", "75", "port", 2075));
assertTrue(text(first).contains("a probe on port 2075"), text(first));
assertTrue(
ClientManager.getExistingClient("75", 2075) != null,
"a session this listing reported as live must not be forgotten");
} finally {
Client.duringListProbes = null;
}

Map<String, Object> second = ListProbesHandler.execute(Map.of("pid", "75", "port", 2075));
assertTrue(text(second).contains("was lost"), text(second));
assertEquals(null, ClientManager.getExistingClient("75", 2075), "reported, then forgotten");
}

@Test
void givesUpAndClosesTheClientWhenTheAgentNeverAnswers() {
deploy("73", 2073);
Client session = Client.last;
Client.hangListProbes = true;
try {
Map<String, Object> result =
ListProbesHandler.execute(Map.of("pid", "73", "port", 2073), 200);

assertEquals(true, result.get("isError"));
assertTrue(text(result).contains("Timed out"), text(result));
assertTrue(
text(result).contains("Connected through this MCP server: a probe on port 2073"),
text(result));
assertTrue(Client.last != session, "the list must use its own client");
assertTrue(Client.last.closed, "client must be closed to release the connection");
} finally {
Client.hangListProbes = false;
ExitProbeHandler.execute(Map.of("pid", "73", "port", 2073));
}
}

@Test
void closesTheClientWhenAttachFails() {
Client.failAttach = true;
try {
Map<String, Object> result = ListProbesHandler.execute(Map.of("pid", "74", "port", 2074));

assertEquals(true, result.get("isError"));
assertTrue(text(result).contains("attach failed"), text(result));
assertTrue(text(result).contains("Connected through this MCP server: none"), text(result));
assertTrue(Client.last.closed, "client must be closed after a failed attach");
} finally {
Client.failAttach = false;
}
}

private static void deploy(String pid, int port) {
Map<String, Object> deployed =
DeployOnelinerHandler.execute(
Map.of("pid", pid, "oneliner", "orders.Api::create @return { print duration }", "port", port));
assertEquals(false, deployed.get("isError"), text(deployed));
}

@SuppressWarnings("unchecked")
private static String text(Map<String, Object> result) {
return (String) ((List<Map<String, Object>>) result.get("content")).get(0).get("text");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ The BTrace MCP server offers structured local-JVM operations such as `list_jvms`
and `stop_detached_probe`. It is suitable when the AI client and target JVM are on the same host and the
operator wants an auditable conversational workflow.

`list_probes` reports probes that were detached with `detach_probe`, or whose connection was lost,
and are still running on the target; probes deployed in the current session that are still attached do not appear. Call it
`list_probes` reports the probe this MCP server is connected to on the target, if any, and the
probes that were detached with `detach_probe`, or whose connection was lost, and are still running
there. A probe connected through another client (a `btrace` CLI or another MCP server) appears in
neither list, so an empty result does not prove that nothing else is attached. Call it
before deploying, so probes left behind by an earlier session are found rather than stacked on, and
after any `detach_probe` to confirm what is still running. Stop a listed probe with
`stop_detached_probe`, passing its id or the whole `list_probes` line, only after the operator
Expand Down
Loading