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
85 changes: 85 additions & 0 deletions integration/vt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -730,3 +730,88 @@ func TestVT_020a_ArmedTimeoutAutoClose(t *testing.T) {
t.Errorf("VT-020a: expected CLOSED after armed timeout, got %s", state)
}
}

// TestSessionOpenRecoveryTool verifies the AI-facing recovery path for closed sessions.
func TestSessionOpenRecoveryTool(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}

repoRoot := findRepoRoot(t)
ensureJavaJar(t, repoRoot)
binPath := buildBinary(t, repoRoot)
sockPath, _, cleanup := startDaemon(t, binPath, repoRoot)
defer cleanup()

t.Run("closed_tools_list_shows_session_open_only", func(t *testing.T) {
result, rpcErr := call(t, sockPath, "tools/list", nil)
if rpcErr != nil {
t.Fatalf("tools/list RPC error: %d %s", rpcErr.Code, rpcErr.Message)
}
var listResult struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if err := json.Unmarshal(result, &listResult); err != nil {
t.Fatalf("unmarshal tools/list: %v", err)
}
if len(listResult.Tools) != 1 {
t.Fatalf("expected 1 tool in CLOSED state, got %d", len(listResult.Tools))
}
if listResult.Tools[0].Name != "mcphub.session.open" {
t.Fatalf("expected mcphub.session.open, got %s", listResult.Tools[0].Name)
}
})

t.Run("closed_tool_call_returns_actionable_recovery", func(t *testing.T) {
params := map[string]interface{}{"name": "webfetch", "arguments": map[string]interface{}{"url": "https://example.com"}}
result, rpcErr := call(t, sockPath, "tools/call", params)
if rpcErr != nil {
t.Fatalf("tools/call RPC error: %d %s", rpcErr.Code, rpcErr.Message)
}
var out map[string]interface{}
if err := json.Unmarshal(result, &out); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if out["isError"] != true {
t.Fatalf("expected isError=true, got %v", out["isError"])
}
contentArr, ok := out["content"].([]interface{})
if !ok || len(contentArr) == 0 {
t.Fatalf("expected content array, got %v", out["content"])
}
textContent, ok := contentArr[0].(map[string]interface{})["text"].(string)
if !ok {
t.Fatalf("content[0].text not a string: %T", contentArr[0])
}
if !strings.Contains(textContent, "mcphub.session.open") {
t.Errorf("error must mention mcphub.session.open, got: %s", textContent)
}
if !strings.Contains(textContent, "call_mcphub_session_open") {
t.Errorf("error must include call_mcphub_session_open next_action, got: %s", textContent)
}
})

t.Run("session_open_recovers", func(t *testing.T) {
params := map[string]interface{}{"name": "mcphub.session.open", "arguments": map[string]interface{}{}}
result, rpcErr := call(t, sockPath, "tools/call", params)
if rpcErr != nil {
t.Fatalf("mcphub.session.open RPC error: %d %s", rpcErr.Code, rpcErr.Message)
}
var out map[string]interface{}
if err := json.Unmarshal(result, &out); err != nil {
t.Fatalf("unmarshal session.open result: %v", err)
}
if isError, _ := out["isError"].(bool); isError {
t.Fatalf("mcphub.session.open should not return an error: %v", out)
}
})

time.Sleep(2 * time.Second)
if got := statusState(t, sockPath); got != "OPEN" {
t.Fatalf("expected OPEN after recovery, got %s", got)
}

closeSession(t, sockPath)
}
73 changes: 70 additions & 3 deletions java/src/main/java/dev/sorted/mcphub/McpHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class McpHandler implements JsonRpcServer.MethodHandler {
private static final Logger log = LoggerFactory.getLogger(McpHandler.class);
private static final ObjectMapper mapper = new ObjectMapper();
private static final String DISAMBIGUATION_TOOL = "mcphub_disambiguate";
private static final String SESSION_OPEN_TOOL = "mcphub.session.open";
private static final String SERVER_VERSION = "0.1.0-alpha";

private final StateMachine stateMachine;
Expand Down Expand Up @@ -128,7 +129,12 @@ private JsonNode handleToolsList() {

boolean isOpen = stateMachine.getState() == StateMachine.State.OPEN;
if (!isOpen) {
// REQ-7.4.2: not Open → empty tool list
// Expose the recovery tool in the same AI-facing surface that reports session_not_open.
StateMachine.State current = stateMachine.getState();
if (!stateMachine.isLockedUntilUnlock()
&& (current == StateMachine.State.CLOSED || current == StateMachine.State.ARMED)) {
tools.add(buildSessionOpenTool());
}
r.set("tools", tools);
return r;
}
Expand Down Expand Up @@ -202,6 +208,62 @@ private ObjectNode toMcpToolEntry(CapabilityEntry entry) {
return tool;
}

/** Build the MCP-visible session recovery tool. */
private ObjectNode buildSessionOpenTool() {
ObjectNode tool = mapper.createObjectNode();
tool.put("name", SESSION_OPEN_TOOL);
tool.put("description", "Open the MCPHUB session so tools become available. Call this when session is CLOSED or ARMED.");
ObjectNode schema = mapper.createObjectNode();
schema.put("type", "object");
schema.set("properties", mapper.createObjectNode());
tool.set("inputSchema", schema);
return tool;
}

/** Handle mcphub.session.open from tools/call so recovery is executable by AI clients. */
private JsonNode handleSessionOpen(long startMs, int requestSizeBytes, String intentAnnotation) {
try {
StateMachine.State current = stateMachine.getState();
String sessionId = sessionManager != null ? sessionManager.getCurrentSessionId() : null;

if (current == StateMachine.State.OPEN) {
logRoute(sessionId, SESSION_OPEN_TOOL, "mcphub-internal", "builtin_hosted",
"allowed", null, System.currentTimeMillis() - startMs,
requestSizeBytes, 0, intentAnnotation, null);
ObjectNode resp = mapper.createObjectNode();
resp.set("content", wrapTextContent("{\"state\":\"OPEN\",\"message\":\"Session already open.\"}"));
return resp;
}

if (current == StateMachine.State.CLOSED) {
sessionId = sessionManager != null ? sessionManager.startSession() : "mcp-session";
stateMachine.transition(StateMachine.Trigger.ARM, sessionId);
} else if (current == StateMachine.State.ARMED) {
sessionId = sessionManager != null ? sessionManager.getCurrentSessionId() : "mcp-session";
} else {
return failureResponse("session_not_open",
"Cannot open session in state: " + current.name() + ". Wait and retry.",
"wait_session", null, null);
}

stateMachine.transition(StateMachine.Trigger.OPEN, sessionId);
if (sessionManager != null) sessionManager.onOpen();

logRoute(sessionId, SESSION_OPEN_TOOL, "mcphub-internal", "builtin_hosted",
"allowed", null, System.currentTimeMillis() - startMs,
requestSizeBytes, 0, intentAnnotation, null);

ObjectNode resp = mapper.createObjectNode();
resp.put("mcphub_providers", "start");
resp.set("content", wrapTextContent("{\"state\":\"OPEN\",\"session_id\":\"" + sessionId + "\",\"message\":\"Session opened. All tools are now available.\"}"));
return resp;
} catch (StateMachine.TransitionException e) {
return failureResponse("session_not_open",
"Failed to open session: " + e.getMessage(),
"retry", null, null);
}
}

/** Build the disambiguation MCP tool entry. REQ-5.3.1 */
private ObjectNode buildDisambiguationTool() {
ObjectNode tool = mapper.createObjectNode();
Expand Down Expand Up @@ -269,15 +331,20 @@ private JsonNode handleToolsCall(JsonNode params) {
return resp;
}

// --- MCP-visible session recovery tool bypasses the state guard ---
if (SESSION_OPEN_TOOL.equals(toolName)) {
return handleSessionOpen(startMs, requestSizeBytes, intentAnnotation);
}

// --- State guard (REQ-2.4.5, REQ-5.6.2 session_not_open) ---
if (stateMachine.getState() != StateMachine.State.OPEN) {
long latency = System.currentTimeMillis() - startMs;
logRoute(null, toolName, null, null,
"error", null, latency, requestSizeBytes, null, intentAnnotation,
"session_not_open");
return failureResponse("session_not_open",
"Session is not Open. Call mcphub.control.open first.",
"wait_session", null, null);
"Session is not Open. Call mcphub.session.open to reopen the session.",
"call_mcphub_session_open", null, null);
}

// REQ-3.7.3: reset idle timer on every tools/call for the active session.
Expand Down
34 changes: 30 additions & 4 deletions java/src/test/java/dev/sorted/mcphub/McpHandlerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@ void initialize_defaultServerName() throws Exception {
// --- tools/list ---

@Test
void toolsList_whenClosed_returnsEmpty() throws Exception {
// Session is CLOSED — tools/list must return empty list (REQ-7.4.2)
void toolsList_whenClosed_returnsSessionOpenTool() throws Exception {
// Session is CLOSED — recovery must be visible in the same AI-facing surface.
JsonNode r = handler.handle("tools/list", null);
assertEquals(0, r.path("tools").size());
assertEquals(1, r.path("tools").size());
assertEquals("mcphub.session.open", r.path("tools").get(0).path("name").asText());
}

@Test
Expand Down Expand Up @@ -140,7 +141,32 @@ void toolsCall_sessionNotOpen_returnsSessionNotOpen() throws Exception {
assertTrue(r.path("isError").asBoolean(), "isError must be true");
JsonNode err = parseErrorJson(r);
assertEquals("session_not_open", err.path("error_code").asText());
assertEquals("wait_session", err.path("next_action").asText());
assertEquals("call_mcphub_session_open", err.path("next_action").asText());
assertTrue(err.path("reason").asText().contains("mcphub.session.open"));
}

@Test
void toolsCall_sessionOpenTool_opensSessionFromClosed() throws Exception {
ObjectNode params = mapper.createObjectNode();
params.put("name", "mcphub.session.open");
params.set("arguments", mapper.createObjectNode());

JsonNode r = handler.handle("tools/call", params);

assertFalse(r.path("isError").asBoolean(false), "session recovery should not be an error");
assertEquals(StateMachine.State.OPEN, sm.getState());
assertEquals("start", r.path("mcphub_providers").asText());

JsonNode listed = handler.handle("tools/list", null);
JsonNode tools = listed.path("tools");
boolean hasSessionOpen = false;
for (JsonNode t : tools) {
if ("mcphub.session.open".equals(t.path("name").asText())) {
hasSessionOpen = true;
break;
}
}
assertFalse(hasSessionOpen, "session recovery tool should not remain in the OPEN tool surface");
}

@Test
Expand Down
Loading