diff --git a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift index faa1dce..b0d9bed 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/CursorSDKHarness.swift @@ -297,6 +297,11 @@ public struct LocalCursorSDKHarness: CursorSDKHarness { "sessionKey": prepared.sessionKey ?? agentID, "workingDirectory": prepared.toolContext?.workingDirectory ?? "", "streamEvents": true, + // Separate from `tools`, which is filtered down to the subset worth + // showing the model on this turn. Any declared tool means the caller + // executes locally, so the harness must stay out of the workspace + // even on turns where no inventory is attached. + "clientOwnsToolExecution": !prepared.tools.isEmpty, "tools": Self.bridgeToolObjects(prepared) ] request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.withoutEscapingSlashes]) diff --git a/macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift b/macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift index 45da03a..7c0edef 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/LocalAPIServer.swift @@ -399,7 +399,10 @@ public final class LocalAPIServer: @unchecked Sendable { usage: usage )) } - let output = try await harness.complete(prepared: prepared, settings: settings, authorization: request.header("authorization")) + var output = try await harness.complete(prepared: prepared, settings: settings, authorization: request.header("authorization")) + if let repaired = try? await repairedOutput(for: output, prepared: prepared, settings: settings, authorization: request.header("authorization")) { + output = repaired + } return try .response(withCORS(HTTPResponse.json(OpenAICompatibility.chatCompletionResponse(id: id, created: created, prepared: prepared, output: output)))) } if method == "POST", path == "/v1/responses" { @@ -585,7 +588,17 @@ public final class LocalAPIServer: @unchecked Sendable { let bufferTextUntilToolDecision = shouldBufferTextUntilToolDecision(prepared) var emittedText = "" var emittedToolCalls: [CursorToolCall] = [] + var emittedToolCallChunks = 0 var finalOutput: CursorSDKOutput? + // A tool call that cannot be mapped to a client tool produces + // no chunk. Counting it as emitted would close the stream with + // finish_reason tool_calls and nothing in it. + let yieldToolCall: (CursorToolCall, Int) -> Bool = { toolCall, index in + let chunk = OpenAICompatibility.chatCompletionStreamToolCall(id: id, created: created, prepared: prepared, toolCall: toolCall, index: index) + guard !chunk.isEmpty else { return false } + continuation.yield(chunk) + return true + } for try await event in harness.stream(prepared: prepared, settings: settings, authorization: authorization) { switch event { @@ -595,35 +608,49 @@ public final class LocalAPIServer: @unchecked Sendable { continuation.yield(OpenAICompatibility.chatCompletionStreamText(id: id, created: created, model: prepared.model, delta: delta)) } case .toolCall(let toolCall): - let index = emittedToolCalls.count emittedToolCalls.append(toolCall) - continuation.yield(OpenAICompatibility.chatCompletionStreamToolCall(id: id, created: created, prepared: prepared, toolCall: toolCall, index: index)) + if yieldToolCall(toolCall, emittedToolCallChunks) { + emittedToolCallChunks += 1 + } case .done(let output): finalOutput = output } } - let output = resolvedOutput(finalOutput: finalOutput, emittedText: emittedText, emittedToolCalls: emittedToolCalls) + var output = resolvedOutput(finalOutput: finalOutput, emittedText: emittedText, emittedToolCalls: emittedToolCalls) + if emittedToolCallChunks == 0, + let repaired = try? await repairedOutput(for: output, prepared: prepared, settings: settings, authorization: authorization) { + output = repaired + } usage?.set(Self.usage(fromObject: OpenAICompatibility.chatCompletionResponse(id: id, created: created, prepared: prepared, output: output))) - let shouldEmitText = output.toolCalls.isEmpty + if output.toolCalls.count > emittedToolCalls.count { + for toolCall in output.toolCalls.dropFirst(emittedToolCalls.count) { + if yieldToolCall(toolCall, emittedToolCallChunks) { + emittedToolCallChunks += 1 + } + } + } + let shouldEmitText = emittedToolCallChunks == 0 if shouldEmitText, output.text.count > emittedText.count, output.text.hasPrefix(emittedText) { let suffix = String(output.text.dropFirst(emittedText.count)) continuation.yield(OpenAICompatibility.chatCompletionStreamText(id: id, created: created, model: prepared.model, delta: suffix)) emittedText += suffix } else if shouldEmitText, (bufferTextUntilToolDecision || emittedText.isEmpty), !output.text.isEmpty { continuation.yield(OpenAICompatibility.chatCompletionStreamText(id: id, created: created, model: prepared.model, delta: output.text)) + emittedText = output.text } - if output.toolCalls.count > emittedToolCalls.count { - for (offset, toolCall) in output.toolCalls.dropFirst(emittedToolCalls.count).enumerated() { - let index = emittedToolCalls.count + offset - continuation.yield(OpenAICompatibility.chatCompletionStreamToolCall(id: id, created: created, prepared: prepared, toolCall: toolCall, index: index)) - } + // Last line of defence: never close a stream that carries + // neither content nor a usable tool call. + if emittedToolCallChunks == 0, emittedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let fallback = OpenAICompatibility.nonEmptyAssistantText(prepared: prepared, output: output) + continuation.yield(OpenAICompatibility.chatCompletionStreamText(id: id, created: created, model: prepared.model, delta: fallback)) + emittedText = fallback } continuation.yield(OpenAICompatibility.chatCompletionStreamFinish( id: id, created: created, model: prepared.model, - emittedToolCallCount: max(emittedToolCalls.count, output.toolCalls.count) + emittedToolCallCount: emittedToolCallChunks )) if prepared.streamIncludeUsage { continuation.yield(OpenAICompatibility.chatCompletionStreamUsage(id: id, created: created, prepared: prepared, output: output)) @@ -757,6 +784,29 @@ public final class LocalAPIServer: @unchecked Sendable { } } + /// One extra upstream run when the model called a tool the client cannot + /// execute, telling it which tools actually exist. Returns nil when a repair + /// is not warranted or did not improve on the original output. + private func repairedOutput( + for output: CursorSDKOutput, + prepared: PreparedChatRequest, + settings: CursorAPISettings, + authorization: String? + ) async throws -> CursorSDKOutput? { + guard OpenAICompatibility.needsToolCallRepair(output, prepared: prepared) else { return nil } + let attempted = OpenAICompatibility.unresolvableToolCallNames(output.toolCalls, prepared: prepared) + guard let repairedRequest = OpenAICompatibility.repairedRequest(prepared, attemptedToolNames: attempted) else { return nil } + let retried = try await harness.complete(prepared: repairedRequest, settings: settings, authorization: authorization) + if !retried.toolCalls.isEmpty, + OpenAICompatibility.unresolvableToolCallNames(retried.toolCalls, prepared: prepared).count < retried.toolCalls.count { + return retried + } + if retried.toolCalls.isEmpty, !retried.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return retried + } + return nil + } + private func resolvedOutput( finalOutput: CursorSDKOutput?, emittedText: String, diff --git a/macos/CursorAPI/Sources/CursorAPICore/OpenAICompatibility.swift b/macos/CursorAPI/Sources/CursorAPICore/OpenAICompatibility.swift index 574da44..46e392a 100644 --- a/macos/CursorAPI/Sources/CursorAPICore/OpenAICompatibility.swift +++ b/macos/CursorAPI/Sources/CursorAPICore/OpenAICompatibility.swift @@ -80,6 +80,12 @@ public struct PreparedChatRequest: Equatable, Sendable { public enum OpenAICompatibility { private static let toolResultContinuation = "The above tool calls have been executed. Continue your response based on these results." private static let sdkToolCallMemory = SDKToolCallMemoryStore() + /// The bridge registers the caller's tools as in-process SDK custom tools, + /// which the runtime surfaces under this synthetic MCP server name. + static let clientToolProvider = "custom-user-tools" + /// `shouldAttachBridgeTools` keys off this prefix, so the hint text and that + /// check have to share it. + private static let requestedToolHintPrefix = "Call the client tool now" public static func modelList() -> [String: Any] { [ @@ -157,8 +163,8 @@ public enum OpenAICompatibility { "You are running through a local Cursor SDK-compatible harness.", "The client owns local tool execution. When local inspection, shell commands, or file changes are needed, request a tool call and wait for the tool result.", "When the conversation includes LOCAL TOOL RESULT records, treat them as completed SDK tool_call results for your previous tool requests and continue from those results.", - "If the user explicitly names an allowed client tool, use that tool. Non-builtin client tools and OpenCode MCP/server tools are called through SDK mcp with providerIdentifier, toolName, and args.", - "For local file inspection, edits, commands, and project work, use SDK mcp with providerIdentifier \"client\" so the outer client executes the operation.", + "If the user explicitly names an allowed client tool, use that tool. Non-builtin client tools and OpenCode MCP/server tools are callable by exact name on the \(clientToolProvider) server.", + "For local file inspection, edits, commands, and project work, call the client tools on \(clientToolProvider) so the outer client executes the operation.", "Do not claim that you created, edited, inspected, or ran anything locally unless you emitted a tool call and received a LOCAL TOOL RESULT confirming it.", "When starting a dev server or other long-running watcher, start it in the background with output redirected and return immediately.", "Do not say that agent mode or tools are unavailable." @@ -172,12 +178,14 @@ public enum OpenAICompatibility { var sawToolResult = false var latestUserText = "" var mutationToolCallAfterLatestUser = false + var toolResultAfterLatestUser = false for item in messages { let role = (item["role"] as? String) ?? "user" let text = contentText(item["content"], role: role) if role == "tool" { sawToolResult = true + toolResultAfterLatestUser = true let toolCallID = (item["tool_call_id"] as? String) ?? "" let toolName = (item["name"] as? String) ?? rememberedToolCalls[toolCallID]?.name ?? "" let label = [toolName.isEmpty ? nil : "name=\(toolName)", toolCallID.isEmpty ? nil : "tool_call_id=\(toolCallID)"] @@ -193,6 +201,7 @@ public enum OpenAICompatibility { if role == "user", !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { latestUserText = text mutationToolCallAfterLatestUser = false + toolResultAfterLatestUser = false } } @@ -216,7 +225,7 @@ public enum OpenAICompatibility { transcript.append(toolResultContinuation) } let localToolRequired = shouldRequireLocalTool(for: latestUserText, tools: tools) - if localToolRequired, !mutationToolCallAfterLatestUser { + if localToolRequired, !mutationToolCallAfterLatestUser, !toolResultAfterLatestUser { appendRequiredLocalToolHint(&transcript, tools: tools, latestUserText: latestUserText) } appendOptions(&transcript, raw) @@ -385,7 +394,7 @@ public enum OpenAICompatibility { || prepared.prompt.contains("LOCAL TOOL RESULT:") || prepared.prompt.contains(toolResultContinuation) || prepared.prompt.contains("You must call at least one tool.") - || prepared.prompt.contains("Use SDK mcp now with providerIdentifier") + || prepared.prompt.contains(requestedToolHintPrefix) } private static func shouldForwardAllBridgeTools(for prepared: PreparedChatRequest) -> Bool { @@ -470,8 +479,8 @@ public enum OpenAICompatibility { "You are running through a local Cursor SDK-compatible harness.", "The client owns local tool execution. When local inspection, shell commands, or file changes are needed, request a function_call and wait for the function_call_output.", "When the input includes function_call_output records, treat them as completed local tool results for your previous function_call requests and continue from those results.", - "If the user explicitly names an allowed client tool, use that tool. Non-builtin client tools and OpenCode MCP/server tools are called through SDK mcp with providerIdentifier, toolName, and args.", - "For local file inspection, edits, commands, and project work, use SDK mcp with providerIdentifier \"client\" so the outer client executes the operation.", + "If the user explicitly names an allowed client tool, use that tool. Non-builtin client tools and OpenCode MCP/server tools are callable by exact name on the \(clientToolProvider) server.", + "For local file inspection, edits, commands, and project work, call the client tools on \(clientToolProvider) so the outer client executes the operation.", "Do not claim that you created, edited, inspected, or ran anything locally unless you emitted a function_call and received a function_call_output confirming it.", "When starting a dev server or other long-running watcher, start it in the background with output redirected and return immediately.", "Do not say that agent mode or tools are unavailable." @@ -627,6 +636,61 @@ public enum OpenAICompatibility { ] } + /// Names of SDK tool calls that cannot be expressed as one of the client's + /// declared tools. Forwarding them as `tool_calls` would be rejected and + /// dropping them silently leaves an empty assistant turn, so callers use + /// this to decide between a repair run and a text fallback. + public static func unresolvableToolCallNames(_ toolCalls: [CursorToolCall], prepared: PreparedChatRequest) -> [String] { + toolCalls.compactMap { toolCall in + guard resolveToolCall(toolCall, tools: prepared.tools, context: prepared.toolContext) == nil else { return nil } + return normalizeSDKToolCall(toolCall).name + } + } + + /// True when every tool call the model produced was unmappable, which is the + /// only case worth spending a second upstream run on. + public static func needsToolCallRepair(_ output: CursorSDKOutput, prepared: PreparedChatRequest) -> Bool { + guard !output.toolCalls.isEmpty else { return false } + return unresolvableToolCallNames(output.toolCalls, prepared: prepared).count == output.toolCalls.count + } + + /// A copy of the request with a hint naming the client's actual tools, for + /// one retry after the model reached for a tool that does not exist here. + public static func repairedRequest(_ prepared: PreparedChatRequest, attemptedToolNames: [String]) -> PreparedChatRequest? { + guard !prepared.tools.isEmpty else { return nil } + let attempted = attemptedToolNames.filter { !$0.isEmpty } + let attemptedList = attempted.isEmpty ? "a tool" : attempted.map { "\"\($0)\"" }.joined(separator: ", ") + let hint = [ + "", + "TOOL CALL NOT AVAILABLE:", + "Your last turn called \(attemptedList), which the outer client cannot execute.", + "Call one of these client tools instead, by exact name on \(clientToolProvider): \(prepared.tools.map(\.name).joined(separator: ", ")).", + "Emit exactly one client tool call with arguments matching that tool's schema, and no prose." + ].joined(separator: "\n") + var repaired = prepared + repaired.prompt += hint + // The bridge reuses `incrementalPrompt` whenever it already has a warm + // agent for this session, so the hint has to be present in both. + repaired.incrementalPrompt = (prepared.incrementalPrompt ?? prepared.prompt) + hint + repaired.promptCharacters = repaired.prompt.count + return repaired + } + + /// Assistant text that is never empty. Clients such as Cline treat a turn + /// with no content and no usable tool call as a provider failure, so the + /// model's narration is used when present and an explanation otherwise. + public static func nonEmptyAssistantText(prepared: PreparedChatRequest, output: CursorSDKOutput) -> String { + if !output.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return output.text + } + let attempted = unresolvableToolCallNames(output.toolCalls, prepared: prepared).filter { !$0.isEmpty } + if !attempted.isEmpty { + let names = attempted.map { "`\($0)`" }.joined(separator: ", ") + return "I tried to use \(names), which is not one of the tools available here, so nothing was run. Tell me to retry and I will use an available tool." + } + return "The upstream model returned no content for this turn. Nothing was run. Retrying usually resolves it." + } + public static func chatCompletionResponse( id: String, created: Int, @@ -634,7 +698,7 @@ public enum OpenAICompatibility { output: CursorSDKOutput ) -> [String: Any] { let toolCalls = toOpenAIToolCalls(output.toolCalls, tools: prepared.tools, responseID: id, context: prepared.toolContext) - let content: Any = toolCalls.isEmpty ? output.text : NSNull() + let content: Any = toolCalls.isEmpty ? nonEmptyAssistantText(prepared: prepared, output: output) : NSNull() return [ "id": id, "object": "chat.completion", @@ -1489,13 +1553,13 @@ public enum OpenAICompatibility { transcript.append("") transcript.append("LOCAL TOOL INVENTORY:") transcript.append("Client tool targets: \(tools.map(\.name).joined(separator: ", "))") - transcript.append("These are client execution targets. Prefer the SDK mcp route for the exact client tool name and schema.") - transcript.append("For local work, emit one SDK mcp tool call with providerIdentifier \"client\" and toolName pointing at the exact client tool or a client_* forwarding tool.") - transcript.append("Do not use SDK built-in shell/read/write/edit/glob/grep/ls/delete tools directly; they execute inside the SDK bridge runtime instead of the outer client.") - transcript.append("When the user names a specific allowed client tool, use the SDK mcp route for that exact client tool and do not substitute a different tool.") + transcript.append("These are client execution targets, callable by exact name on the \(clientToolProvider) server.") + transcript.append("For local work, call the exact client tool or a client_* forwarding tool on \(clientToolProvider).") + transcript.append("Do not use SDK built-in shell/read/write/edit/glob/grep/ls/delete tools directly. Your working directory is an empty scratch directory, so those tools cannot see or change the user's project.") + transcript.append("When the user names a specific allowed client tool, call that exact client tool and do not substitute a different tool.") transcript.append("If you need a local tool, emit the tool call before prose. Do not write progress text such as \"creating the file\" instead of calling a tool.") if hasDedicatedCompatibleTool(["write", "edit", "read", "grep", "glob", "ls", "delete"], in: tools) { - transcript.append("For file reads, writes, edits, search, listing, and deletes, prefer exact client file tools through SDK mcp. Do not use shell/bash for those operations when write/read/edit/glob/grep/ls/delete tools are available.") + transcript.append("For file reads, writes, edits, search, listing, and deletes, prefer the exact client file tools. Do not use shell/bash for those operations when write/read/edit/glob/grep/ls/delete tools are available.") } else if hasCompatibleTool("shell", in: tools) { transcript.append("A shell client tool is available as the fallback local execution route. For file creation through shell, use mkdir -p and a quoted heredoc.") } @@ -1546,12 +1610,14 @@ public enum OpenAICompatibility { var sawToolResult = false var latestUserText = "" var mutationToolCallAfterLatestUser = false + var toolResultAfterLatestUser = false for item in currentMessages { let role = (item["role"] as? String) ?? "user" let text = contentText(item["content"], role: role) if role == "tool" { sawToolResult = true + toolResultAfterLatestUser = true let toolCallID = (item["tool_call_id"] as? String) ?? "" let toolName = (item["name"] as? String) ?? rememberedToolCalls[toolCallID]?.name ?? "" let label = [toolName.isEmpty ? nil : "name=\(toolName)", toolCallID.isEmpty ? nil : "tool_call_id=\(toolCallID)"] @@ -1567,6 +1633,7 @@ public enum OpenAICompatibility { if role == "user", !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { latestUserText = text mutationToolCallAfterLatestUser = false + toolResultAfterLatestUser = false } } @@ -1591,7 +1658,7 @@ public enum OpenAICompatibility { transcript.append(toolResultContinuation) } let localToolRequired = shouldRequireLocalTool(for: latestUserText, tools: tools) - if localToolRequired, !mutationToolCallAfterLatestUser { + if localToolRequired, !mutationToolCallAfterLatestUser, !toolResultAfterLatestUser { appendRequiredLocalToolHint(&transcript, tools: tools, latestUserText: latestUserText) } appendOptions(&transcript, raw) @@ -1613,7 +1680,7 @@ public enum OpenAICompatibility { let routes = sdkRoutingRecords(tools: tools, context: context) guard !routes.isEmpty else { return } transcript.append("SDK TOOL ROUTING MAP:") - transcript.append("Prefer routes marked preferred. They forward through SDK mcp to the exact client tool and argument schema.") + transcript.append("Prefer routes marked preferred. They forward to the exact client tool and argument schema.") for route in routes { if let data = try? JSONSerialization.data(withJSONObject: route, options: [.withoutEscapingSlashes]), let json = String(data: data, encoding: .utf8) { @@ -1711,19 +1778,19 @@ public enum OpenAICompatibility { return } if hasDedicatedCompatibleTool(["write", "edit"], in: tools) { - transcript.append("For creating or updating files, use SDK mcp for an exact client write/edit file tool with matching arguments. Do not use shell/bash for file writes when a write/edit tool is available. After the client returns a LOCAL TOOL RESULT, continue.") + transcript.append("For creating or updating files, call an exact client write/edit file tool with matching arguments. Do not use shell/bash for file writes when a write/edit tool is available. After the client returns a LOCAL TOOL RESULT, continue.") } else if hasCompatibleTool("shell", in: tools) { - transcript.append("Use SDK mcp for the client shell/bash tool. For creating or overwriting a file, run mkdir -p for the parent directory and write the file with a single quoted heredoc. After the client returns a LOCAL TOOL RESULT, continue.") + transcript.append("Call the client shell/bash tool. For creating or overwriting a file, run mkdir -p for the parent directory and write the file with a single quoted heredoc. After the client returns a LOCAL TOOL RESULT, continue.") } else { - transcript.append("For creating or overwriting a file, use SDK mcp for an exact client write/edit file tool with matching arguments. After the client returns a LOCAL TOOL RESULT, continue.") + transcript.append("For creating or overwriting a file, call an exact client write/edit file tool with matching arguments. After the client returns a LOCAL TOOL RESULT, continue.") } } private static func requestedToolHint(for toolName: String) -> String { if let mcpTarget = mcpTarget(forClientToolName: toolName, includeMapped: true) { - return "Use SDK mcp now with providerIdentifier \"\(mcpTarget.provider)\", toolName \"\(mcpTarget.toolName)\", and args matching the \(toolName) schema. Do not substitute another tool for this explicitly requested client tool." + return "\(requestedToolHintPrefix): \"\(mcpTarget.toolName)\" on \(mcpTarget.provider), with args matching the \(toolName) schema. Do not substitute another tool for this explicitly requested client tool." } - return "Use SDK mcp now with providerIdentifier \"client\", toolName \"\(toolName)\", and args matching the \(toolName) schema. Do not substitute a different tool." + return "\(requestedToolHintPrefix): \"\(toolName)\" on \(clientToolProvider), with args matching the \(toolName) schema. Do not substitute a different tool." } private static func explicitlyRequestedToolName(in text: String, tools: [OpenAIToolSpec]) -> String? { @@ -1752,9 +1819,9 @@ public enum OpenAICompatibility { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if isKnownMappedToolName(trimmed) { - return includeMapped ? (provider: "client", toolName: trimmed) : nil + return includeMapped ? (provider: clientToolProvider, toolName: trimmed) : nil } - return (provider: "client", toolName: trimmed) + return (provider: clientToolProvider, toolName: trimmed) } private static func isKnownMappedToolName(_ name: String) -> Bool { diff --git a/macos/CursorAPI/Tests/CursorAPITests/LocalAPIServerTests.swift b/macos/CursorAPI/Tests/CursorAPITests/LocalAPIServerTests.swift index 2b944f2..ffcd200 100644 --- a/macos/CursorAPI/Tests/CursorAPITests/LocalAPIServerTests.swift +++ b/macos/CursorAPI/Tests/CursorAPITests/LocalAPIServerTests.swift @@ -1466,7 +1466,7 @@ final class LocalAPIServerTests: XCTestCase { } XCTAssertNotNil(properties["query"]) XCTAssertTrue(prepared.prompt.contains("Client tool targets: repo_search")) - XCTAssertTrue(prepared.prompt.contains("For local work, emit one SDK mcp tool call")) + XCTAssertTrue(prepared.prompt.contains("For local work, call the exact client tool or a client_* forwarding tool")) } func testChatFileRequestAddsRequiredLocalToolHint() throws { @@ -1509,7 +1509,7 @@ final class LocalAPIServerTests: XCTestCase { XCTAssertTrue(prepared.prompt.contains("LOCAL TOOL REQUIRED FOR THE LATEST USER REQUEST")) XCTAssertTrue(prepared.prompt.contains("Emit exactly one SDK tool call next and no prose.")) - XCTAssertTrue(prepared.prompt.contains("For creating or updating files, use SDK mcp for an exact client write/edit file tool")) + XCTAssertTrue(prepared.prompt.contains("For creating or updating files, call an exact client write/edit file tool")) } func testBridgeToolSpecsSkipsClientMCPForConversationalTurns() throws { @@ -1589,7 +1589,7 @@ final class LocalAPIServerTests: XCTestCase { XCTAssertTrue(prepared.prompt.contains("LOCAL TOOL REQUIRED FOR THE LATEST USER REQUEST")) XCTAssertTrue(prepared.prompt.contains("Emit exactly one SDK tool call next and no prose.")) - XCTAssertTrue(prepared.prompt.contains("For creating or updating files, use SDK mcp for an exact client write/edit file tool")) + XCTAssertTrue(prepared.prompt.contains("For creating or updating files, call an exact client write/edit file tool")) } func testChatBuildAppRequestDoesNotRepeatRequiredHintAfterCustomWriterCall() throws { @@ -1679,7 +1679,7 @@ final class LocalAPIServerTests: XCTestCase { """#.utf8)) XCTAssertTrue(prepared.prompt.contains("LOCAL TOOL REQUIRED FOR THE LATEST USER REQUEST")) - XCTAssertTrue(prepared.prompt.contains("Use SDK mcp for the client shell/bash tool")) + XCTAssertTrue(prepared.prompt.contains("Call the client shell/bash tool")) } func testResponsesFileRequestAddsRequiredLocalToolHint() throws { @@ -1718,7 +1718,7 @@ final class LocalAPIServerTests: XCTestCase { XCTAssertTrue(prepared.prompt.contains("LOCAL TOOL REQUIRED FOR THE LATEST USER REQUEST")) XCTAssertTrue(prepared.prompt.contains("Emit exactly one SDK tool call next and no prose.")) - XCTAssertTrue(prepared.prompt.contains("For creating or updating files, use SDK mcp for an exact client write/edit file tool")) + XCTAssertTrue(prepared.prompt.contains("For creating or updating files, call an exact client write/edit file tool")) } func testResponsesFileRequestDoesNotRepeatRequiredHintAfterApplyPatchCall() throws { @@ -1795,7 +1795,7 @@ final class LocalAPIServerTests: XCTestCase { """#.utf8)) XCTAssertTrue(prepared.prompt.contains("LOCAL TOOL REQUIRED FOR THE LATEST USER REQUEST")) - XCTAssertTrue(prepared.prompt.contains("Use SDK mcp now with providerIdentifier \"client\", toolName \"probe_write_file\"")) + XCTAssertTrue(prepared.prompt.contains("Call the client tool now: \"probe_write_file\"")) XCTAssertTrue(prepared.prompt.contains("Do not substitute another tool")) XCTAssertFalse(prepared.prompt.contains("Use SDK shell now. For creating or overwriting a file")) } @@ -1828,7 +1828,7 @@ final class LocalAPIServerTests: XCTestCase { """#.utf8)) XCTAssertTrue(first.prompt.contains("LOCAL TOOL REQUIRED FOR THE LATEST USER REQUEST")) - XCTAssertTrue(first.prompt.contains("Use SDK mcp now with providerIdentifier \"client\", toolName \"glob\"")) + XCTAssertTrue(first.prompt.contains("Call the client tool now: \"glob\"")) let continued = try OpenAICompatibility.prepareChatRequest(Data(#""" { @@ -1945,11 +1945,11 @@ final class LocalAPIServerTests: XCTestCase { """#.utf8)) XCTAssertTrue(prepared.prompt.contains("Client tool targets: webfetch")) - XCTAssertTrue(prepared.prompt.contains("providerIdentifier \"client\"")) - XCTAssertTrue(prepared.prompt.contains("toolName \"webfetch\"")) + XCTAssertTrue(prepared.prompt.contains("on custom-user-tools")) + XCTAssertTrue(prepared.prompt.contains("\"toolName\":\"webfetch\"")) XCTAssertTrue(prepared.prompt.contains("SDK TOOL ROUTING MAP:")) XCTAssertTrue(prepared.prompt.contains("\"parameters\"")) - XCTAssertTrue(prepared.prompt.contains("Use SDK mcp now with providerIdentifier \"client\", toolName \"webfetch\"")) + XCTAssertTrue(prepared.prompt.contains("Call the client tool now: \"webfetch\"")) } func testChatToolInventoryAdvertisesProviderStyleHarnessToolsThroughClientBridge() throws { @@ -1981,10 +1981,10 @@ final class LocalAPIServerTests: XCTestCase { """#.utf8)) XCTAssertTrue(prepared.prompt.contains("Client tool targets: mcp__filesystem__write_file")) - XCTAssertTrue(prepared.prompt.contains("providerIdentifier \"client\"")) - XCTAssertTrue(prepared.prompt.contains("toolName \"mcp__filesystem__write_file\"")) + XCTAssertTrue(prepared.prompt.contains("on custom-user-tools")) + XCTAssertTrue(prepared.prompt.contains("\"toolName\":\"mcp__filesystem__write_file\"")) XCTAssertTrue(prepared.prompt.contains("SDK TOOL ROUTING MAP:")) - XCTAssertTrue(prepared.prompt.contains("Use SDK mcp now with providerIdentifier \"client\", toolName \"mcp__filesystem__write_file\"")) + XCTAssertTrue(prepared.prompt.contains("Call the client tool now: \"mcp__filesystem__write_file\"")) XCTAssertFalse(prepared.prompt.contains("providerIdentifier \"filesystem\", toolName \"write_file\"")) } @@ -2075,7 +2075,7 @@ final class LocalAPIServerTests: XCTestCase { let nested = try XCTUnwrap(arguments["args"] as? [String: Any]) XCTAssertEqual(feedback["toolName"] as? String, "mcp") - XCTAssertEqual(arguments["providerIdentifier"] as? String, "client") + XCTAssertEqual(arguments["providerIdentifier"] as? String, "custom-user-tools") XCTAssertEqual(arguments["toolName"] as? String, "mcp__filesystem__write_file") XCTAssertEqual(nested["file_path"] as? String, "src/App.tsx") XCTAssertEqual(nested["contents"] as? String, "export default function App() { return null }") @@ -2131,7 +2131,7 @@ final class LocalAPIServerTests: XCTestCase { let nested = try XCTUnwrap(arguments["args"] as? [String: Any]) XCTAssertEqual(feedback["toolName"] as? String, "mcp") - XCTAssertEqual(arguments["providerIdentifier"] as? String, "client") + XCTAssertEqual(arguments["providerIdentifier"] as? String, "custom-user-tools") XCTAssertEqual(arguments["toolName"] as? String, "webfetch") XCTAssertEqual(nested["url"] as? String, "https://example.com") XCTAssertEqual(nested["format"] as? String, "markdown") @@ -2203,7 +2203,7 @@ final class LocalAPIServerTests: XCTestCase { XCTAssertEqual(arguments[5]["description"] as? String, "Inspect app") XCTAssertEqual(arguments[5]["prompt"] as? String, "Find the app entry point") XCTAssertEqual(arguments[5]["subagentType"] as? String, "explore") - XCTAssertEqual(arguments[6]["providerIdentifier"] as? String, "client") + XCTAssertEqual(arguments[6]["providerIdentifier"] as? String, "custom-user-tools") XCTAssertEqual(arguments[6]["toolName"] as? String, "skill") let skillArgs = try XCTUnwrap(arguments[6]["args"] as? [String: Any]) XCTAssertEqual(skillArgs["name"] as? String, "customize-opencode") @@ -2668,7 +2668,7 @@ final class LocalAPIServerTests: XCTestCase { XCTAssertTrue(prepared.prompt.contains("The above tool calls have been executed. Continue your response based on these results.")) XCTAssertTrue(prepared.prompt.contains("LOCAL TOOL REQUIRED FOR THE LATEST USER REQUEST")) - XCTAssertTrue(prepared.prompt.contains("Use SDK mcp for the client shell/bash tool")) + XCTAssertTrue(prepared.prompt.contains("Call the client shell/bash tool")) } func testResponsesToolChoiceDirectFunctionShapeAddsPromptHint() throws { @@ -2694,7 +2694,7 @@ final class LocalAPIServerTests: XCTestCase { """#.utf8)) XCTAssertEqual(prepared.tools.map(\.name), ["shell"]) - XCTAssertTrue(prepared.prompt.contains("Use SDK mcp now with providerIdentifier \"client\", toolName \"shell\"")) + XCTAssertTrue(prepared.prompt.contains("Call the client tool now: \"shell\"")) } func testResponsesToolChoiceNestedFunctionShapeAddsPromptHint() throws { @@ -2722,7 +2722,7 @@ final class LocalAPIServerTests: XCTestCase { """#.utf8)) XCTAssertEqual(prepared.tools.map(\.name), ["shell"]) - XCTAssertTrue(prepared.prompt.contains("Use SDK mcp now with providerIdentifier \"client\", toolName \"shell\"")) + XCTAssertTrue(prepared.prompt.contains("Call the client tool now: \"shell\"")) } func testResponsesEndpointReturnsFunctionCallOutputItems() async throws { @@ -4757,7 +4757,7 @@ final class LocalAPIServerTests: XCTestCase { XCTAssertTrue(prepared.prompt.contains("Client tool targets: find")) XCTAssertTrue(prepared.prompt.contains("SDK TOOL ROUTING MAP:")) - XCTAssertTrue(prepared.prompt.contains("Use SDK mcp now with providerIdentifier \"client\", toolName \"find\"")) + XCTAssertTrue(prepared.prompt.contains("Call the client tool now: \"find\"")) let object = OpenAICompatibility.chatCompletionResponse( id: "chatcmpl_test", @@ -10465,6 +10465,183 @@ final class LocalAPIServerTests: XCTestCase { XCTAssertFalse(text.contains("event: response.content_part.added")) XCTAssertFalse(text.contains(#""type":"message""#)) } + + private func preparedRequestWithReadTool() throws -> PreparedChatRequest { + try OpenAICompatibility.prepareChatRequest(Data(#""" + { + "model":"composer-2.5", + "messages":[{"role":"user","content":"read src/App.tsx"}], + "tools":[ + { + "type":"function", + "function":{ + "name":"read_file", + "parameters":{ + "type":"object", + "properties":{"path":{"type":"string"}}, + "required":["path"] + } + } + } + ] + } + """#.utf8)) + } + + func testUnresolvableToolCallNamesReportsOnlyUnmappableCalls() throws { + let prepared = try preparedRequestWithReadTool() + let mappable = CursorToolCall(name: "read", arguments: ["path": .string("src/App.tsx")]) + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + + XCTAssertEqual(OpenAICompatibility.unresolvableToolCallNames([mappable], prepared: prepared), []) + XCTAssertEqual(OpenAICompatibility.unresolvableToolCallNames([unmappable], prepared: prepared), ["recordScreen"]) + } + + func testNeedsToolCallRepairOnlyWhenEveryCallIsUnmappable() throws { + let prepared = try preparedRequestWithReadTool() + let mappable = CursorToolCall(name: "read", arguments: ["path": .string("src/App.tsx")]) + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + + XCTAssertFalse(OpenAICompatibility.needsToolCallRepair( + CursorSDKOutput(text: "", toolCalls: [], agentID: "agent-test", runID: "run-test"), + prepared: prepared + )) + XCTAssertFalse(OpenAICompatibility.needsToolCallRepair( + CursorSDKOutput(text: "", toolCalls: [mappable], agentID: "agent-test", runID: "run-test"), + prepared: prepared + )) + XCTAssertTrue(OpenAICompatibility.needsToolCallRepair( + CursorSDKOutput(text: "", toolCalls: [unmappable], agentID: "agent-test", runID: "run-test"), + prepared: prepared + )) + } + + func testRepairedRequestNamesTheClientToolsInBothPrompts() throws { + let prepared = try preparedRequestWithReadTool() + let repaired = try XCTUnwrap(OpenAICompatibility.repairedRequest(prepared, attemptedToolNames: ["recordScreen"])) + + XCTAssertTrue(repaired.prompt.contains("TOOL CALL NOT AVAILABLE:")) + XCTAssertTrue(repaired.prompt.contains("\"recordScreen\"")) + XCTAssertTrue(repaired.prompt.contains("read_file")) + XCTAssertEqual(repaired.promptCharacters, repaired.prompt.count) + // The bridge prefers incrementalPrompt for warm agents, so the hint has + // to reach both prompts or the retry repeats the same mistake. + let incremental = try XCTUnwrap(repaired.incrementalPrompt) + XCTAssertTrue(incremental.contains("TOOL CALL NOT AVAILABLE:")) + } + + func testRepairedRequestIsSkippedWithoutClientTools() throws { + let prepared = try OpenAICompatibility.prepareChatRequest(Data(#""" + {"model":"composer-2.5","messages":[{"role":"user","content":"hello"}]} + """#.utf8)) + + XCTAssertNil(OpenAICompatibility.repairedRequest(prepared, attemptedToolNames: ["recordScreen"])) + } + + func testNonEmptyAssistantTextPrefersModelNarration() throws { + let prepared = try preparedRequestWithReadTool() + let output = CursorSDKOutput(text: "Reading the file now.", toolCalls: [], agentID: "agent-test", runID: "run-test") + + XCTAssertEqual( + OpenAICompatibility.nonEmptyAssistantText(prepared: prepared, output: output), + "Reading the file now." + ) + } + + func testNonEmptyAssistantTextExplainsAnUnavailableTool() throws { + let prepared = try preparedRequestWithReadTool() + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + let output = CursorSDKOutput(text: "", toolCalls: [unmappable], agentID: "agent-test", runID: "run-test") + + let text = OpenAICompatibility.nonEmptyAssistantText(prepared: prepared, output: output) + XCTAssertTrue(text.contains("recordScreen")) + XCTAssertFalse(text.isEmpty) + } + + func testChatCompletionsStreamingNeverEndsWithoutContentOrToolCall() async throws { + let port = try unusedTCPPort() + // recordScreen has no counterpart among the declared client tools, so it + // cannot be forwarded. It used to be dropped silently, leaving a stream + // that reported finish_reason tool_calls while carrying nothing at all. + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + let server = LocalAPIServer(settingsProvider: { CursorAPISettings(port: port) }, harness: MockHarness(events: [ + .toolCall(unmappable), + .done(CursorSDKOutput(text: "", toolCalls: [unmappable], agentID: "agent-test", runID: "run-test")) + ])) + try server.start(port: port) + defer { server.stop() } + try await Task.sleep(nanoseconds: 150_000_000) + + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/v1/chat/completions")!) + request.httpMethod = "POST" + request.httpBody = Data(#""" + { + "model":"composer-2.5", + "stream":true, + "messages":[{"role":"user","content":"read src/App.tsx"}], + "tools":[{"type":"function","function":{"name":"read_file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}] + } + """#.utf8) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, response) = try await URLSession.shared.data(for: request) + + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + let text = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(text.contains("recordScreen")) + XCTAssertTrue(text.contains(#""finish_reason":"stop""#)) + XCTAssertFalse(text.contains(#""finish_reason":"tool_calls""#)) + } + + func testChatCompletionsStreamingStillForwardsMappableToolCalls() async throws { + let port = try unusedTCPPort() + let toolCall = CursorToolCall(name: "read", arguments: ["path": .string("src/App.tsx")]) + let server = LocalAPIServer(settingsProvider: { CursorAPISettings(port: port) }, harness: MockHarness(events: [ + .toolCall(toolCall), + .done(CursorSDKOutput(text: "", toolCalls: [toolCall], agentID: "agent-test", runID: "run-test")) + ])) + try server.start(port: port) + defer { server.stop() } + try await Task.sleep(nanoseconds: 150_000_000) + + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/v1/chat/completions")!) + request.httpMethod = "POST" + request.httpBody = Data(#""" + { + "model":"composer-2.5", + "stream":true, + "messages":[{"role":"user","content":"read src/App.tsx"}], + "tools":[{"type":"function","function":{"name":"read_file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}] + } + """#.utf8) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, response) = try await URLSession.shared.data(for: request) + + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + let text = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(text.contains(#""name":"read_file""#)) + XCTAssertTrue(text.contains(#""finish_reason":"tool_calls""#)) + } + + func testChatCompletionResponseNeverReturnsEmptyContent() throws { + let prepared = try preparedRequestWithReadTool() + let unmappable = CursorToolCall(name: "recordScreen", arguments: ["mode": .string("START_RECORDING")]) + + let object = OpenAICompatibility.chatCompletionResponse( + id: "chatcmpl_test", + created: 1, + prepared: prepared, + output: CursorSDKOutput(text: "", toolCalls: [unmappable], agentID: "agent-test", runID: "run-test") + ) + + let choices = try XCTUnwrap(object["choices"] as? [[String: Any]]) + let choice = try XCTUnwrap(choices.first) + let message = try XCTUnwrap(choice["message"] as? [String: Any]) + let content = try XCTUnwrap(message["content"] as? String) + + XCTAssertFalse(content.isEmpty) + XCTAssertEqual(choice["finish_reason"] as? String, "stop") + XCTAssertEqual((message["tool_calls"] as? [[String: Any]])?.isEmpty, true) + } } private func sendResponseRequest(port: UInt16, body: String) async throws { diff --git a/scripts/cursor-sdk-local-agent-bridge.mjs b/scripts/cursor-sdk-local-agent-bridge.mjs index bd01f79..7ee7a0e 100644 --- a/scripts/cursor-sdk-local-agent-bridge.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node import { Agent } from "@cursor/sdk"; import crypto from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import http from "node:http"; +import os from "node:os"; import path from "node:path"; -import readline from "node:readline"; import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); @@ -23,30 +23,28 @@ const maxRunRetries = parseInteger(process.env.CURSOR_SDK_BRIDGE_MAX_RUN_RETRIES const retryBaseDelayMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_RETRY_BASE_DELAY_MS, 500); const defaultCwd = process.env.CURSOR_SDK_WORKING_DIRECTORY || process.cwd(); const clientMcpServerName = "client"; -const clientMcpServerMode = "--client-mcp-server"; -const clientToolCallbackPath = "/client-tool-call"; +// The SDK surfaces `local.customTools` under this synthetic MCP server name, so +// tool calls can arrive tagged with either identifier. +const sdkCustomToolsServerName = "custom-user-tools"; +const clientToolHandoffText = "FORWARDED_TO_OUTER_CLIENT"; +const scratchWorkspaceRoot = path.join(os.tmpdir(), "api-for-cursor", "workspaces"); const agentCache = new Map(); const agentRunQueues = new Map(); -const activeClientToolCaptures = new Map(); const forceNextRunAgentKeys = new Set(); let server = null; if (isMainModule()) { installBridgeProcessHandlers(); - if (process.argv.includes(clientMcpServerMode)) { - await runClientForwardingMcpServerFromEnvironment(); - } else { - startServer(); - process.on("SIGINT", () => closeAndExit(0)); - process.on("SIGTERM", () => closeAndExit(0)); - } + startServer(); + process.on("SIGINT", () => closeAndExit(0)); + process.on("SIGTERM", () => closeAndExit(0)); } export { bridgePrompt, + clientCustomTools, clientMcpToolDefinitions, - clientForwardingMcpServerSource, localAgentCreateOptions, localAgentSendOptions, isForwardableSDKToolCall, @@ -56,6 +54,7 @@ export { openAiError, runExclusiveForAgent, sdkRunFailureSummary, + sdkScratchWorkspace, statusFromError, startServer, validateClientMcpToolCall, @@ -64,6 +63,9 @@ export { function startServer() { if (server) return server; + // No agents exist yet, so any scratch directories on disk are leftovers from + // a previous process and would otherwise accumulate one per agent forever. + removeDirectory(scratchWorkspaceRoot); server = http.createServer((request, response) => { handleRequest(request, response).catch((error) => { writeJson(response, openAiError(error), statusFromError(error)); @@ -83,11 +85,6 @@ async function handleRequest(request, response) { return; } - if (request.method === "POST" && url.pathname === clientToolCallbackPath) { - await handleClientToolCallback(request, response); - return; - } - if (request.method !== "POST" || url.pathname !== "/sdk") { writeJson(response, openAiError(new HttpError("Not found", 404, "not_found")), 404); return; @@ -111,6 +108,9 @@ async function handleRequest(request, response) { const requestId = typeof body.requestId === "string" && body.requestId ? body.requestId : crypto.randomUUID(); const clientTools = parseClientTools(body.tools); const streamEvents = body.streamEvents === true; + // The caller filters which tools are worth attaching per turn, so an empty + // list does not mean the caller stopped owning tool execution. + const clientOwnsToolExecution = body.clientOwnsToolExecution === true || clientTools.length > 0; const input = { apiKey, @@ -120,9 +120,12 @@ async function handleRequest(request, response) { sessionKey, workingDirectory, requestId, - clientTools + clientTools, + clientOwnsToolExecution }; + logClientToolInventory(input); + if (streamEvents) { await streamLocalAgent(input, response); return; @@ -132,20 +135,6 @@ async function handleRequest(request, response) { writeJson(response, output); } -async function handleClientToolCallback(request, response) { - if (bridgeToken && bearerToken(request) !== bridgeToken) { - writeJson(response, openAiError(new HttpError("Invalid bridge token", 401, "unauthorized")), 401); - return; - } - - const body = await readJsonBody(request); - const cacheKey = requiredString(body.cacheKey, "cacheKey"); - const toolName = requiredString(body.toolName, "toolName"); - const args = isRecord(body.arguments) ? body.arguments : {}; - const accepted = await captureActiveClientToolCall(cacheKey, { type: toolName, args }); - writeJson(response, { ok: true, accepted }); -} - async function streamLocalAgent(input, response) { let closed = false; const markClosed = () => { @@ -253,12 +242,17 @@ async function runLocalAgentBody(input, onRun, onEvent) { let capturedToolCall = null; let cancelRequested = false; let text = ""; + let narration = ""; const captureToolCall = async (toolCall, options = {}) => { - if (capturedToolCall || !toolCall) return; + if (capturedToolCall || !toolCall) return false; const normalized = normalizeSDKToolCall(toolCall, input.clientTools); - if (!normalized || !isForwardableSDKToolCall(normalized, input.clientTools)) return; + if (!normalized || !isForwardableSDKToolCall(normalized, input.clientTools)) { + logDroppedToolCall(toolCall, normalized, input.clientTools); + return false; + } capturedToolCall = normalized; + logCapturedToolCall(normalized, options.source); if (onEvent) onEvent({ type: "tool_call", toolCall: capturedToolCall }); cancelRequested = true; if (run) { @@ -270,12 +264,9 @@ async function runLocalAgentBody(input, onRun, onEvent) { await cancellation; } } + return true; }; - const unregisterCapture = registerActiveClientToolCapture(cacheKey, async (toolCall) => { - await captureToolCall(toolCall, { waitForCancel: false }); - return capturedToolCall !== null; - }); try { agentEntry = await getAgent(input); const agent = agentEntry.agent; @@ -283,11 +274,24 @@ async function runLocalAgentBody(input, onRun, onEvent) { const force = forceNextRunAgentKeys.delete(cacheKey); run = await agent.send(prompt, { - ...localAgentSendOptions(input, { force }), + ...localAgentSendOptions(input, { + force, + // In-process custom tools are the primary capture path: the SDK invokes + // this callback directly instead of routing through a subprocess. + onClientToolCall: (toolCall) => captureToolCall(toolCall, { source: "custom_tool", waitForCancel: false }) + }), idempotencyKey: input.requestId, onDelta: async ({ update }) => { const toolCall = toolCallFromDelta(update); - if (toolCall) await captureToolCall(toolCall); + if (toolCall) { + await captureToolCall(toolCall, { source: "delta" }); + return; + } + // Keep narration so a tool call that cannot be mapped downstream still + // leaves the client with a non-empty assistant turn. Tracked separately + // from `text` so the normal path cannot double-count it. + const chunk = assistantTextFromDelta(update); + if (chunk) narration += chunk; } }); onRun(run); @@ -318,14 +322,12 @@ async function runLocalAgentBody(input, onRun, onEvent) { if (!capturedToolCall && !(cancelRequested && isBenignCancellationError(error))) { throw error; } - } finally { - unregisterCapture(); } if (capturedToolCall) { if (agentEntry) forceNextRunAgentKeys.add(agentEntry.cacheKey); return { - text: "", + text: stripFinalMarker(narration), toolCalls: [capturedToolCall], agentID: agentEntry?.agent.agentId || "", runID: run?.id || input.requestId, @@ -338,7 +340,7 @@ async function runLocalAgentBody(input, onRun, onEvent) { if (agentEntry) evictAgent(agentEntry.cacheKey, agentEntry.agent); throw sdkRunFailureError(result); } - if (!text && typeof result.result === "string") text = result.result; + if (!text) text = typeof result.result === "string" ? result.result : narration; return { text: stripFinalMarker(text), toolCalls: [], @@ -356,7 +358,9 @@ async function getAgent(input) { return { agent: cached.agent, cacheKey, cached: true }; } - const agent = await Agent.create(localAgentCreateOptions(input)); + const options = localAgentCreateOptions(input); + ensureDirectory(options.local?.cwd); + const agent = await Agent.create(options); agentCache.set(cacheKey, { agent, touchedAt: Date.now() }); evictAgents(); return { agent, cacheKey, cached: false }; @@ -371,6 +375,7 @@ function evictAgent(cacheKey, agent) { try { agent.close(); } catch {} + removeDirectory(path.join(scratchWorkspaceRoot, cacheKey)); } function evictCachedAgent(input) { @@ -379,334 +384,91 @@ function evictCachedAgent(input) { if (cached) evictAgent(cacheKey, cached.agent); } -function registerActiveClientToolCapture(cacheKey, handler) { - if (!activeClientToolCaptures.has(cacheKey)) { - activeClientToolCaptures.set(cacheKey, new Set()); - } - const handlers = activeClientToolCaptures.get(cacheKey); - handlers.add(handler); - return () => { - handlers.delete(handler); - if (handlers.size === 0) activeClientToolCaptures.delete(cacheKey); - }; -} - -async function captureActiveClientToolCall(cacheKey, toolCall) { - const handlers = activeClientToolCaptures.get(cacheKey); - if (!handlers || handlers.size === 0) return false; - for (const handler of [...handlers]) { - if (await handler(toolCall)) return true; - } - return false; -} - function localAgentCreateOptions(input) { return { apiKey: input.apiKey, model: sdkModelSelection(input.model), name: "API for Cursor local bridge", local: { - cwd: input.workingDirectory + cwd: sdkScratchWorkspace(input) } }; } +// When the outer client owns tool execution, the harness must not be able to do +// the work itself: its built-in read/write/shell would succeed against the real +// project and the model would never call back through the client. An empty +// scratch directory makes the client tools the only way to reach the workspace. +// Clients that execute nothing themselves still get the real directory. +function sdkScratchWorkspace(input) { + if (!clientOwnsToolExecution(input)) return input.workingDirectory; + return path.join(scratchWorkspaceRoot, agentCacheKey(input)); +} + +function clientOwnsToolExecution(input) { + return input.clientOwnsToolExecution === true || (input.clientTools ?? []).length > 0; +} + function localAgentSendOptions(input, optionsInput = {}) { const options = { model: sdkModelSelection(input.model) }; + const local = {}; if (optionsInput.force === true) { - options.local = { force: true }; + local.force = true; + } + if ((input.clientTools ?? []).length > 0) { + local.customTools = clientCustomTools(input.clientTools, optionsInput.onClientToolCall); } - if (input.clientTools.length > 0) { - options.mcpServers = clientForwardingMcpServers(input.clientTools, agentCacheKey(input)); + if (Object.keys(local).length > 0) { + options.local = local; } return options; } -function clientToolsNeedingMcp(clientTools = []) { - return clientTools.filter((tool) => tool?.name); +// The client's tools run as in-process SDK callback tools rather than a spawned +// stdio MCP server. Callback tools never fail closed under approval or sandbox +// rules, and capture happens directly in this process instead of over an HTTP +// hop that could time out mid-handoff. +function clientCustomTools(clientTools = [], onClientToolCall) { + const definitions = clientMcpToolDefinitions(clientTools); + const customTools = {}; + for (const definition of definitions) { + customTools[definition.name] = { + description: definition.description, + inputSchema: definition.inputSchema, + execute: async (args) => { + const payload = isRecord(args) ? args : {}; + const validationError = validateClientMcpToolCall(definitions, definition.name, payload); + if (validationError) return clientToolError(validationError); + if (typeof onClientToolCall !== "function") { + return clientToolError("Outer client tool capture is unavailable for this run."); + } + const accepted = await onClientToolCall({ type: definition.name, args: payload }); + if (!accepted) { + return clientToolError("The outer client already accepted a different tool call for this turn."); + } + return { + content: [{ type: "text", text: clientToolHandoffText }], + isError: false + }; + } + }; + } + return customTools; } -function clientForwardingMcpServers(clientTools = [], cacheKey = "") { +function clientToolError(message) { return { - [clientMcpServerName]: { - type: "stdio", - command: process.execPath, - args: [fileURLToPath(import.meta.url), clientMcpServerMode], - env: { - CURSOR_SDK_BRIDGE_CALLBACK_URL: `http://${host}:${port}${clientToolCallbackPath}`, - CURSOR_SDK_BRIDGE_CALLBACK_TOKEN: bridgeToken, - CURSOR_SDK_BRIDGE_AGENT_CACHE_KEY: cacheKey, - CURSOR_SDK_BRIDGE_CLIENT_TOOLS_JSON: JSON.stringify(clientMcpToolDefinitions(clientTools)) - } - } + content: [{ type: "text", text: message }], + isError: true }; } -async function runClientForwardingMcpServerFromEnvironment() { - await runClientForwardingMcpServer({ - tools: parseClientMcpToolsJSON(process.env.CURSOR_SDK_BRIDGE_CLIENT_TOOLS_JSON), - callbackUrl: process.env.CURSOR_SDK_BRIDGE_CALLBACK_URL || "", - callbackToken: process.env.CURSOR_SDK_BRIDGE_CALLBACK_TOKEN || "", - callbackCacheKey: process.env.CURSOR_SDK_BRIDGE_AGENT_CACHE_KEY || "" - }); -} - -function parseClientMcpToolsJSON(value) { - if (typeof value !== "string" || !value.trim()) return clientMcpToolDefinitions([]); - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : clientMcpToolDefinitions([]); - } catch { - return clientMcpToolDefinitions([]); - } -} - -async function runClientForwardingMcpServer({ - tools, - callbackUrl, - callbackToken, - callbackCacheKey, - input = process.stdin, - output = process.stdout -}) { - const rl = readline.createInterface({ input }); - let outputClosed = false; - const writeOutput = (payload) => { - if (outputClosed) return false; - try { - return output.write(payload); - } catch (error) { - if (!isBenignPipeError(error)) throw error; - outputClosed = true; - return false; - } - }; - output.on?.("error", (error) => { - outputClosed = true; - if (!isBenignPipeError(error)) process.exitCode = 1; - }); - const send = (id, result) => { - if (id === undefined || id === null) return; - writeOutput(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); - }; - const sendError = (id, message) => { - if (id === undefined || id === null) return; - writeOutput(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message } })}\n`); - }; - const pending = new Set(); - - const handleLine = async (line) => { - if (!line.trim()) return; - let message; - try { - message = JSON.parse(line); - } catch { - return; - } - if (!message.id && String(message.method || "").startsWith("notifications/")) return; - if (message.method === "initialize") { - send(message.id, { - protocolVersion: "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "api-for-cursor-client-tools", version: "0.1.0" } - }); - return; - } - if (message.method === "tools/list") { - send(message.id, { tools }); - return; - } - if (message.method === "tools/call") { - const params = message.params || {}; - const toolName = params.name || params.toolName; - const toolInput = params.arguments || params.input || {}; - const validationError = validateClientMcpToolCall(tools, toolName, toolInput); - if (validationError) { - sendError(message.id, validationError); - return; - } - const accepted = await notifyParentToolCall({ callbackUrl, callbackToken, callbackCacheKey, toolName, input: toolInput }); - if (!accepted) { - sendError(message.id, "Outer client callback unavailable for forwarded tool call."); - return; - } - send(message.id, { - content: [{ type: "text", text: "FORWARDED_TO_OUTER_CLIENT" }], - isError: false - }); - return; - } - sendError(message.id, `Unsupported MCP method: ${message.method}`); - }; - - await new Promise((resolve) => { - rl.on("line", (line) => { - const task = handleLine(line) - .catch((error) => { - if (!isBenignPipeError(error)) process.exitCode = 1; - }) - .finally(() => { - pending.delete(task); - }); - pending.add(task); - }); - rl.on("close", async () => { - await Promise.allSettled([...pending]); - resolve(); - }); - }); +function clientToolsNeedingMcp(clientTools = []) { + return clientTools.filter((tool) => tool?.name); } -async function notifyParentToolCall({ callbackUrl, callbackToken, callbackCacheKey, toolName, input }) { - if (!callbackUrl || !callbackCacheKey) return true; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 1500); - try { - const headers = { "Content-Type": "application/json" }; - if (callbackToken) headers.Authorization = `Bearer ${callbackToken}`; - const response = await fetch(callbackUrl, { - method: "POST", - headers, - body: JSON.stringify({ - cacheKey: callbackCacheKey, - toolName, - arguments: input && typeof input === "object" && !Array.isArray(input) ? input : {} - }), - signal: controller.signal - }); - if (!response.ok) return false; - const body = await response.json().catch(() => ({})); - return body && body.accepted === true; - } catch { - return false; - } finally { - clearTimeout(timer); - } -} - -function clientForwardingMcpServerSource(clientTools = []) { - const tools = JSON.stringify(clientMcpToolDefinitions(clientTools)); - return ` -const readline = require("node:readline"); -const tools = ${tools}; -const callbackUrl = process.env.CURSOR_SDK_BRIDGE_CALLBACK_URL || ""; -const callbackToken = process.env.CURSOR_SDK_BRIDGE_CALLBACK_TOKEN || ""; -const callbackCacheKey = process.env.CURSOR_SDK_BRIDGE_AGENT_CACHE_KEY || ""; -const validateClientMcpToolCall = ${validateClientMcpToolCall.toString()}; -const validateJsonSchemaValue = ${validateJsonSchemaValue.toString()}; -const canonicalJsonSchema = ${canonicalJsonSchema.toString()}; -const schemaHasStructuralKeyword = ${schemaHasStructuralKeyword.toString()}; -const schemaReferenceTarget = ${schemaReferenceTarget.toString()}; -const jsonPointerTarget = ${jsonPointerTarget.toString()}; -const decodeJsonPointerSegment = ${decodeJsonPointerSegment.toString()}; -const schemaTypes = ${schemaTypes.toString()}; -const schemaAllowsNull = ${schemaAllowsNull.toString()}; -const validateStringConstraints = ${validateStringConstraints.toString()}; -const validateNumberConstraints = ${validateNumberConstraints.toString()}; -const patternPropertySchemasForKey = ${patternPropertySchemasForKey.toString()}; -const schemaEvaluatesObjectProperty = ${schemaEvaluatesObjectProperty.toString()}; -const jsonValueMatchesType = ${jsonValueMatchesType.toString()}; -const jsonValuesEqual = ${jsonValuesEqual.toString()}; -const isRecord = ${isRecord.toString()}; -const stableJson = ${stableJson.toString()}; -const sortJson = ${sortJson.toString()}; -const rl = readline.createInterface({ input: process.stdin }); -let stdoutClosed = false; -function isBenignPipeError(error) { - return error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED"; -} -function writeStdout(payload) { - if (stdoutClosed) return false; - try { - return process.stdout.write(payload); - } catch (error) { - if (!isBenignPipeError(error)) throw error; - stdoutClosed = true; - return false; - } -} -process.stdout.on("error", (error) => { - stdoutClosed = true; - if (isBenignPipeError(error)) process.exit(0); - process.exitCode = 1; -}); -function send(id, result) { - if (id === undefined || id === null) return; - writeStdout(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n"); -} -function sendError(id, message) { - if (id === undefined || id === null) return; - writeStdout(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message } }) + "\\n"); -} -async function notifyParentToolCall(toolName, input) { - if (!callbackUrl || !callbackCacheKey) return true; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 1500); - try { - const headers = { "Content-Type": "application/json" }; - if (callbackToken) headers.Authorization = "Bearer " + callbackToken; - const response = await fetch(callbackUrl, { - method: "POST", - headers, - body: JSON.stringify({ - cacheKey: callbackCacheKey, - toolName, - arguments: input && typeof input === "object" && !Array.isArray(input) ? input : {} - }), - signal: controller.signal - }); - if (!response.ok) return false; - const body = await response.json().catch(() => ({})); - return body && body.accepted === true; - } catch { - return false; - } finally { - clearTimeout(timer); - } -} -rl.on("line", async (line) => { - if (!line.trim()) return; - let message; - try { - message = JSON.parse(line); - } catch { - return; - } - if (!message.id && String(message.method || "").startsWith("notifications/")) return; - if (message.method === "initialize") { - send(message.id, { - protocolVersion: "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "api-for-cursor-client-tools", version: "0.1.0" } - }); - } else if (message.method === "tools/list") { - send(message.id, { tools }); - } else if (message.method === "tools/call") { - const params = message.params || {}; - const toolName = params.name || params.toolName; - const input = params.arguments || params.input || {}; - const validationError = validateClientMcpToolCall(tools, toolName, input); - if (validationError) { - sendError(message.id, validationError); - return; - } - const accepted = await notifyParentToolCall(toolName, input); - if (!accepted) { - sendError(message.id, "Outer client callback unavailable for forwarded tool call."); - return; - } - send(message.id, { - content: [{ type: "text", text: "FORWARDED_TO_OUTER_CLIENT" }], - isError: false - }); - } else { - sendError(message.id, "Unsupported MCP method: " + message.method); - } -}); -`; -} function validateClientMcpToolCall(tools, toolName, input = {}) { if (typeof toolName !== "string" || !toolName.trim()) { @@ -1439,21 +1201,21 @@ function bridgePrompt(prompt, clientTools = []) { ? `The outer client tools are: ${exactTools}.` : "No outer client tools were provided for this request."; const mcpInstruction = exactMcpTools - ? `Client-only tools are exposed through the client MCP server by exact name: ${exactMcpTools}.` - : "No client MCP forwarding tools are attached for this turn; answer without new local tool calls unless the prompt contains LOCAL TOOL RESULT records to continue from."; + ? `Client-only tools are exposed as callable tools by exact name: ${exactMcpTools}.` + : "No client forwarding tools are attached for this turn; answer without new local tool calls unless the prompt contains LOCAL TOOL RESULT records to continue from."; const localServerInstruction = exactMcpTools - ? "A local MCP server named client exposes forwarding tools such as client_shell, client_write, client_read, client_edit, client_delete, client_glob, client_grep, and the exact outer client tool names." - : "No local MCP server tools are available on this turn."; + ? `Those tools live on the ${sdkCustomToolsServerName} server, reachable with GetMcpTools and CallMcpTool. It also exposes client_shell, client_write, client_read, client_edit, client_delete, client_glob, and client_grep.` + : "No client tool server is attached on this turn."; return [ "You are running through the real Cursor SDK local runtime behind an OpenAI-compatible client.", - "The outer client owns local tool execution. The bridge must forward local operations; it must not execute SDK built-in shell/read/write/edit/glob/grep/ls/delete tools inside the bridge runtime.", + "The outer client owns local tool execution, and your working directory is an empty scratch directory that does not contain the user's project. Built-in shell/read/write/edit/glob/grep/ls/delete act on that scratch directory, so they cannot see or change the real workspace.", toolInstruction, mcpInstruction, localServerInstruction, - "Prefer exact client tools and dedicated client MCP tools such as write, read, edit, glob, grep, ls, delete, client_write, client_read, client_edit, client_glob, client_grep, client_ls, and client_delete before bash/client_shell. Use shell only for commands or when no dedicated client tool fits.", - "Use SDK mcp with providerIdentifier \"client\" for every local operation. Do not use SDK built-in shell, write, edit, read, glob, grep, ls, delete, readLints, semSearch, todowrite, task, createPlan, generateImage, or recordScreen.", - "If the prompt says LOCAL TOOL REQUIRED, emit exactly one client MCP forwarding tool call and no prose.", + "Prefer exact client tools and dedicated client tools such as write, read, edit, glob, grep, ls, delete, client_write, client_read, client_edit, client_glob, client_grep, client_ls, and client_delete before bash/client_shell. Use shell only for commands or when no dedicated client tool fits.", + `Route every local operation through the client tools on ${sdkCustomToolsServerName}. Do not use SDK built-in shell, write, edit, read, glob, grep, ls, delete, readLints, semSearch, todowrite, task, createPlan, generateImage, or recordScreen.`, + "If the prompt says LOCAL TOOL REQUIRED, emit exactly one client tool call and no prose.", "If LOCAL TOOL RESULT records are present, treat those tools as already executed by the outer client and continue from the result.", "", prompt @@ -1468,6 +1230,30 @@ function toolCallFromDelta(update) { return toolCall; } +function assistantTextFromDelta(update) { + if (!update || typeof update !== "object") return ""; + if (update.type !== "text-delta") return ""; + return typeof update.text === "string" ? update.text : ""; +} + +function logClientToolInventory(input) { + const names = (input.clientTools ?? []).map((tool) => tool?.name).filter(Boolean); + const scope = names.length ? `tools=${names.length} [${names.join(", ")}]` : "tools=0 (none attached this turn)"; + const owner = clientOwnsToolExecution(input) ? "client" : "harness"; + console.log(`[client-tools] request=${input.requestId} model=${input.model} ${scope} executor=${owner} cwd=${sdkScratchWorkspace(input)}`); +} + +function logCapturedToolCall(toolCall, source = "unknown") { + console.log(`[client-tools] captured via ${source}: ${toolCall.name} args=${Object.keys(toolCall.arguments || {}).join(",") || "none"}`); +} + +function logDroppedToolCall(toolCall, normalized, clientTools = []) { + const rawName = typeof toolCall?.type === "string" ? toolCall.type : typeof toolCall?.name === "string" ? toolCall.name : "unknown"; + const reason = normalized ? "payload incomplete for the client tool schema" : "no client tool matched the SDK tool name"; + const available = clientTools.map((tool) => tool?.name).filter(Boolean).join(", ") || "none"; + console.log(`[client-tools] not forwarded: sdk=${rawName} normalized=${normalized?.name || "none"} reason=${reason} clientTools=${available}`); +} + function normalizeSDKToolCall(toolCall, clientTools = []) { const name = typeof toolCall.type === "string" ? toolCall.type : typeof toolCall.name === "string" ? toolCall.name : ""; if (!name) return null; @@ -1485,7 +1271,7 @@ function normalizeSDKToolCall(toolCall, clientTools = []) { function normalizeClientMcpToolCall(name, args) { if (canonicalToolName(name) !== "mcp") return null; const provider = firstString(args, "providerIdentifier", "provider", "server", "serverName", "server_name"); - if (provider && provider !== clientMcpServerName) return null; + if (provider && !isClientToolProvider(provider)) return null; const toolName = firstString(args, "toolName", "tool_name", "tool", "name"); const sdkName = sdkToolNameFromClientMcpTool(toolName); const payload = clientMcpPayloadArguments(args); @@ -1526,6 +1312,14 @@ function normalizeDirectClientToolCall(name, args, clientTools = []) { }; } +// Client tools reach the model through the SDK's synthetic custom-tools server, +// but older transcripts and prompts still name the forwarding server "client". +function isClientToolProvider(provider) { + const normalized = normalizeToolName(provider); + return normalized === normalizeToolName(clientMcpServerName) + || normalized === normalizeToolName(sdkCustomToolsServerName); +} + function sdkToolNameFromClientMcpTool(toolName) { const normalized = normalizeToolName(toolName).replace(/^client/, ""); switch (normalized) { @@ -1993,30 +1787,78 @@ function evictAgents() { try { oldest[1].agent.close(); } catch {} - } + removeDirectory(path.join(scratchWorkspaceRoot, oldest[0])); + } +} + +// `claude-opus-5:effort=max,context=1m` -> base id plus SDK ModelSelection +// params. OpenAI's schema has nowhere to carry Cursor's per-model options +// (effort/thinking/context/fast), so they ride along in the model string. +function splitModelSpec(model) { + const raw = typeof model === "string" ? model.trim() : ""; + const separator = raw.indexOf(":"); + if (separator === -1) return { base: raw, params: [] }; + const params = raw + .slice(separator + 1) + .split(",") + .map((pair) => { + const eq = pair.indexOf("="); + if (eq === -1) return null; + const id = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + return id && value ? { id, value } : null; + }) + .filter(Boolean); + return { base: raw.slice(0, separator), params }; } function normalizeModel(model) { - const raw = model.trim(); - const normalized = raw.toLowerCase().split("/").filter(Boolean).at(-1) || ""; + const { base, params } = splitModelSpec(model); + const normalized = base.toLowerCase().split("/").filter(Boolean).at(-1) || ""; + const suffix = params.length ? ":" + params.map((p) => `${p.id}=${p.value}`).join(",") : ""; if (!normalized || normalized === "default" || normalized === "auto") return "default"; if (normalized === "composer-latest" || normalized === "composer" || normalized === "composer-2.5" || normalized === "composer-2-5") { - return "composer-2.5"; + return "composer-2.5" + suffix; } - if (normalized === "composer-2.5-sdk" || normalized === "composer-2-5-sdk") return "composer-2.5"; - if (normalized === "composer-2.5-fast" || normalized === "composer-2-5-fast") return "composer-2.5-fast"; - if (normalized === "grok-4.5" || normalized === "grok-4-5") return "grok-4.5"; - if (normalized === "grok-4.5-fast" || normalized === "grok-4-5-fast") return "grok-4.5-fast"; - return raw; + if (normalized === "composer-2.5-sdk" || normalized === "composer-2-5-sdk") return "composer-2.5" + suffix; + if (normalized === "composer-2.5-fast" || normalized === "composer-2-5-fast") return "composer-2.5-fast" + suffix; + if (normalized === "grok-4.5" || normalized === "grok-4-5") return "grok-4.5" + suffix; + if (normalized === "grok-4.5-fast" || normalized === "grok-4-5-fast") return "grok-4.5-fast" + suffix; + return normalized + suffix; } function sdkModelSelection(model) { const normalized = normalizeModel(typeof model === "string" ? model : ""); - if (normalized === "composer-2.5") return { id: "composer-2.5", params: [{ id: "fast", value: "false" }] }; - if (normalized === "composer-2.5-fast") return { id: "composer-2.5", params: [{ id: "fast", value: "true" }] }; - if (normalized === "grok-4.5") return { id: "grok-4.5", params: [{ id: "fast", value: "false" }] }; - if (normalized === "grok-4.5-fast") return { id: "grok-4.5", params: [{ id: "fast", value: "true" }] }; - return { id: normalized }; + const { base, params } = splitModelSpec(normalized); + // The `-fast` aliases predate param passthrough; keep them working unless the + // caller set `fast` explicitly. + const hasFast = params.some((p) => p.id === "fast"); + if (base === "composer-2.5" || base === "composer-2.5-fast") { + const fast = base.endsWith("-fast"); + return { id: "composer-2.5", params: hasFast ? params : [...params, { id: "fast", value: String(fast) }] }; + } + if (base === "grok-4.5" || base === "grok-4.5-fast") { + const fast = base.endsWith("-fast"); + return { id: "grok-4.5", params: hasFast ? params : [...params, { id: "fast", value: String(fast) }] }; + } + return params.length ? { id: base, params } : { id: base }; +} + +function ensureDirectory(directory) { + if (typeof directory !== "string" || !directory.trim()) return; + try { + mkdirSync(directory, { recursive: true }); + } catch (error) { + console.warn(`Could not create SDK working directory ${directory}: ${error?.message || error}`); + } +} + +// Only ever called with paths the bridge itself created under its scratch root. +function removeDirectory(directory) { + if (typeof directory !== "string" || !directory.startsWith(scratchWorkspaceRoot)) return; + try { + rmSync(directory, { recursive: true, force: true }); + } catch {} } function sdkWorkingDirectory(value) { diff --git a/scripts/cursor-sdk-local-agent-bridge.test.mjs b/scripts/cursor-sdk-local-agent-bridge.test.mjs index d08a2d6..fe6cba5 100644 --- a/scripts/cursor-sdk-local-agent-bridge.test.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.test.mjs @@ -1,10 +1,7 @@ import { describe, expect, it } from "vitest"; -import { spawn, spawnSync } from "node:child_process"; -import http from "node:http"; -import { fileURLToPath } from "node:url"; import { bridgePrompt, - clientForwardingMcpServerSource, + clientCustomTools, clientMcpToolDefinitions, localAgentCreateOptions, localAgentSendOptions, @@ -15,13 +12,12 @@ import { openAiError, runExclusiveForAgent, sdkRunFailureSummary, + sdkScratchWorkspace, statusFromError, toolCallFromDelta, validateClientMcpToolCall } from "./cursor-sdk-local-agent-bridge.mjs"; -const bridgeScriptPath = fileURLToPath(new URL("./cursor-sdk-local-agent-bridge.mjs", import.meta.url)); - describe("Cursor SDK local-agent bridge", () => { it("classifies retryable Cursor SDK upstream capacity errors", () => { expect(isRetryableSDKRunError(new Error("Server at capacity"))).toBe(true); @@ -1477,347 +1473,172 @@ describe("Cursor SDK local-agent bridge", () => { })).toBe(null); }); - it("bundles nested schema validation into the generated MCP forwarding server", () => { - const source = clientForwardingMcpServerSource([ - { - name: "call_mcp_tool", - parameters: { - type: "object", - properties: { - serverName: { type: "string" }, - input: { - type: "object", - properties: { - mode: { type: "string", enum: ["create"] } - }, - required: ["mode"] - } - }, - required: ["serverName", "input"] - } - } - ]); - const message = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "call_mcp_tool", - arguments: { - serverName: "filesystem", - input: { mode: "append" } + it("validates nested client tool schemas before handing a call to the outer client", async () => { + const captured = []; + const customTools = clientCustomTools( + [ + { + name: "call_mcp_tool", + parameters: { + type: "object", + properties: { + serverName: { type: "string" }, + input: { + type: "object", + properties: { + mode: { type: "string", enum: ["create"] } + }, + required: ["mode"] + } + }, + required: ["serverName", "input"] + } } + ], + async (toolCall) => { + captured.push(toolCall); + return true; } - }; + ); - const result = spawnSync(process.execPath, ["-e", source], { - input: `${JSON.stringify(message)}\n`, - encoding: "utf8", - timeout: 1000 + const rejected = await customTools.call_mcp_tool.execute({ + serverName: "filesystem", + input: { mode: "append" } }); + expect(rejected.isError).toBe(true); + expect(rejected.content[0].text).toContain("expected one of"); + expect(captured).toEqual([]); - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const response = JSON.parse(result.stdout.trim()); - expect(response.error.message).toContain("expected one of"); + const accepted = await customTools.call_mcp_tool.execute({ + serverName: "filesystem", + input: { mode: "create" } + }); + expect(accepted.isError).toBe(false); + expect(accepted.content[0].text).toBe("FORWARDED_TO_OUTER_CLIENT"); + expect(captured).toEqual([ + { type: "call_mcp_tool", args: { serverName: "filesystem", input: { mode: "create" } } } + ]); }); - it("bundles referenced schema validation into the generated MCP forwarding server", () => { - const source = clientForwardingMcpServerSource([ - { - name: "ref_write_file", - parameters: { - type: "object", - properties: { - target: { $ref: "#/$defs/fileTarget" } - }, - required: ["target"], - $defs: { - fileTarget: { - type: "object", - properties: { - mode: { type: "string", enum: ["create"] } - }, - required: ["mode"] + it("validates referenced client tool schemas before handing a call to the outer client", async () => { + const captured = []; + const customTools = clientCustomTools( + [ + { + name: "ref_write_file", + parameters: { + type: "object", + properties: { + target: { $ref: "#/$defs/fileTarget" } + }, + required: ["target"], + $defs: { + fileTarget: { + type: "object", + properties: { + mode: { type: "string", enum: ["create"] } + }, + required: ["mode"] + } } } } + ], + async (toolCall) => { + captured.push(toolCall); + return true; } - ]); - const message = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "ref_write_file", - arguments: { - target: { mode: "append" } - } - } - }; - - const result = spawnSync(process.execPath, ["-e", source], { - input: `${JSON.stringify(message)}\n`, - encoding: "utf8", - timeout: 1000 - }); + ); - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const response = JSON.parse(result.stdout.trim()); - expect(response.error.message).toContain("expected one of"); + const rejected = await customTools.ref_write_file.execute({ target: { mode: "append" } }); + expect(rejected.isError).toBe(true); + expect(rejected.content[0].text).toContain("expected one of"); + expect(captured).toEqual([]); }); - it("does not fake a forwarded MCP result when the bridge callback is unavailable", () => { - const source = clientForwardingMcpServerSource([]); - const message = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "client_shell", - arguments: { - command: "printf SHOULD_NOT_RUN" - } - } - }; + it("does not fake a handoff when the outer client cannot accept the call", async () => { + const withoutCapture = clientCustomTools([]); + const unavailable = await withoutCapture.client_shell.execute({ command: "printf SHOULD_NOT_RUN" }); + expect(unavailable.isError).toBe(true); + expect(unavailable.content[0].text).toContain("capture is unavailable"); - const result = spawnSync(process.execPath, ["-e", source], { - input: `${JSON.stringify(message)}\n`, - encoding: "utf8", - timeout: 3000, - env: { - ...process.env, - CURSOR_SDK_BRIDGE_CALLBACK_URL: "http://127.0.0.1:1/client-tool-call", - CURSOR_SDK_BRIDGE_AGENT_CACHE_KEY: "cache-key" - } - }); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const response = JSON.parse(result.stdout.trim()); - expect(response.error.message).toContain("Outer client callback unavailable"); + const alreadyCaptured = clientCustomTools([], async () => false); + const refused = await alreadyCaptured.client_shell.execute({ command: "printf SHOULD_NOT_RUN" }); + expect(refused.isError).toBe(true); + expect(refused.content[0].text).toContain("already accepted a different tool call"); }); - it("posts forwarded MCP tool calls to the bridge callback before returning success", async () => { - let observedRequest; - const callbackServer = http.createServer((request, response) => { - let body = ""; - request.setEncoding("utf8"); - request.on("data", (chunk) => { - body += chunk; - }); - request.on("end", () => { - observedRequest = { - url: request.url, - authorization: request.headers.authorization, - body: JSON.parse(body) - }; - response.writeHead(200, { "Content-Type": "application/json" }); - response.end(JSON.stringify({ ok: true, accepted: true })); - }); + it("hands client tool calls to the in-process capture callback", async () => { + const captured = []; + const customTools = clientCustomTools([], async (toolCall) => { + captured.push(toolCall); + return true; }); - await new Promise((resolve) => callbackServer.listen(0, "127.0.0.1", resolve)); - const address = callbackServer.address(); - const port = typeof address === "object" && address ? address.port : 0; - const child = spawn(process.execPath, [bridgeScriptPath, "--client-mcp-server"], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - CURSOR_SDK_BRIDGE_CALLBACK_URL: `http://127.0.0.1:${port}/client-tool-call`, - CURSOR_SDK_BRIDGE_CALLBACK_TOKEN: "bridge-token", - CURSOR_SDK_BRIDGE_AGENT_CACHE_KEY: "cache-key", - CURSOR_SDK_BRIDGE_CLIENT_TOOLS_JSON: JSON.stringify(clientMcpToolDefinitions([])) - } - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); + const result = await customTools.client_shell.execute({ command: "printf CALLBACK_OK" }); - const message = { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { - name: "client_shell", - arguments: { - command: "printf CALLBACK_OK" - } - } - }; - child.stdin.end(`${JSON.stringify(message)}\n`); - - const exitCode = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error("generated MCP server did not exit")); - }, 3000); - child.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - child.on("exit", (code) => { - clearTimeout(timeout); - resolve(code); - }); - }); - callbackServer.close(); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - const response = JSON.parse(stdout.trim()); - expect(response.result.content[0].text).toBe("FORWARDED_TO_OUTER_CLIENT"); - expect(observedRequest).toEqual({ - url: "/client-tool-call", - authorization: "Bearer bridge-token", - body: { - cacheKey: "cache-key", - toolName: "client_shell", - arguments: { - command: "printf CALLBACK_OK" - } - } - }); + expect(result.isError).toBe(false); + expect(result.content[0].text).toBe("FORWARDED_TO_OUTER_CLIENT"); + expect(captured).toEqual([ + { type: "client_shell", args: { command: "printf CALLBACK_OK" } } + ]); }); - it("keeps exact custom harness MCP tools available in the subcommand server", async () => { - let observedRequest; - const callbackServer = http.createServer((request, response) => { - let body = ""; - request.setEncoding("utf8"); - request.on("data", (chunk) => { - body += chunk; - }); - request.on("end", () => { - observedRequest = { - url: request.url, - authorization: request.headers.authorization, - body: JSON.parse(body) - }; - setTimeout(() => { - response.writeHead(200, { "Content-Type": "application/json" }); - response.end(JSON.stringify({ ok: true, accepted: true })); - }, 50); - }); - }); - - await new Promise((resolve) => callbackServer.listen(0, "127.0.0.1", resolve)); - const address = callbackServer.address(); - const port = typeof address === "object" && address ? address.port : 0; - const tools = clientMcpToolDefinitions([ - { - name: "mcp__github__create_issue", - description: "Create a GitHub issue through the outer harness MCP server.", - parameters: { - type: "object", - properties: { - owner: { type: "string" }, - repo: { type: "string" }, - title: { type: "string" }, - body: { type: "string" } - }, - required: ["owner", "repo", "title", "body"], - additionalProperties: false + it("keeps exact custom harness tool names callable as custom tools", async () => { + const captured = []; + const customTools = clientCustomTools( + [ + { + name: "mcp__github__create_issue", + description: "Create a GitHub issue through the outer harness MCP server.", + parameters: { + type: "object", + properties: { + owner: { type: "string" }, + repo: { type: "string" }, + title: { type: "string" }, + body: { type: "string" } + }, + required: ["owner", "repo", "title", "body"], + additionalProperties: false + } } + ], + async (toolCall) => { + captured.push(toolCall); + return true; } + ); + + expect(Object.keys(customTools)).toContain("mcp__github__create_issue"); + expect(customTools.mcp__github__create_issue.description).toContain("GitHub issue"); + expect(Object.keys(customTools.mcp__github__create_issue.inputSchema.properties)).toEqual([ + "owner", + "repo", + "title", + "body" ]); - const child = spawn(process.execPath, [bridgeScriptPath, "--client-mcp-server"], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - CURSOR_SDK_BRIDGE_CALLBACK_URL: `http://127.0.0.1:${port}/client-tool-call`, - CURSOR_SDK_BRIDGE_CALLBACK_TOKEN: "bridge-token", - CURSOR_SDK_BRIDGE_AGENT_CACHE_KEY: "cache-key", - CURSOR_SDK_BRIDGE_CLIENT_TOOLS_JSON: JSON.stringify(tools) - } - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - const listMessage = { - jsonrpc: "2.0", - id: 1, - method: "tools/list" - }; - const callMessage = { - jsonrpc: "2.0", - id: 2, - method: "tools/call", - params: { - name: "mcp__github__create_issue", - arguments: { - owner: "octo", - repo: "hello", - title: "Smoke", - body: "OK" - } - } - }; - child.stdin.end(`${JSON.stringify(listMessage)}\n${JSON.stringify(callMessage)}\n`); - - const exitCode = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error("custom MCP forwarding server did not exit")); - }, 3000); - child.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - child.on("exit", (code) => { - clearTimeout(timeout); - resolve(code); - }); - }); - callbackServer.close(); - - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - const responses = stdout.trim().split(/\n+/).map((line) => JSON.parse(line)); - const listResponse = responses.find((response) => response.id === 1); - const callResponse = responses.find((response) => response.id === 2); - expect(listResponse.result.tools.some((tool) => tool.name === "mcp__github__create_issue")).toBe(true); - expect(callResponse.result.content[0].text).toBe("FORWARDED_TO_OUTER_CLIENT"); - expect(observedRequest).toEqual({ - url: "/client-tool-call", - authorization: "Bearer bridge-token", - body: { - cacheKey: "cache-key", - toolName: "mcp__github__create_issue", - arguments: { - owner: "octo", - repo: "hello", - title: "Smoke", - body: "OK" - } - } - }); + const args = { owner: "octo", repo: "hello", title: "Smoke", body: "OK" }; + const result = await customTools.mcp__github__create_issue.execute(args); + + expect(result.content[0].text).toBe("FORWARDED_TO_OUTER_CLIENT"); + expect(captured).toEqual([{ type: "mcp__github__create_issue", args }]); }); - it("tells the SDK to forward compatible client tools through MCP", () => { + it("points the SDK at the client tools instead of its own built-ins", () => { const prompt = bridgePrompt("USER: create a file", [ { name: "bash" }, { name: "write" } ]); expect(prompt).toContain("outer client tools are: bash, write"); - expect(prompt).toContain("Use SDK mcp with providerIdentifier \"client\" for every local operation"); + expect(prompt).toContain("Route every local operation through the client tools on custom-user-tools"); + expect(prompt).toContain("empty scratch directory"); expect(prompt).toContain("client_shell"); - expect(prompt).toContain("Prefer exact client tools and dedicated client MCP tools"); + expect(prompt).toContain("Prefer exact client tools and dedicated client tools"); expect(prompt).toContain("LOCAL TOOL RESULT records are present"); - expect(prompt).toContain("emit exactly one client MCP forwarding tool call and no prose"); + expect(prompt).toContain("emit exactly one client tool call and no prose"); }); it("uses SDK-compatible local options that do not wedge local runs", () => { @@ -1917,11 +1738,34 @@ describe("Cursor SDK local-agent bridge", () => { const baseSendOptions = localAgentSendOptions(baseInput); const dynamicSendOptions = localAgentSendOptions(dynamicInput); - expect(dynamicSendOptions.mcpServers.client.env.CURSOR_SDK_BRIDGE_AGENT_CACHE_KEY).toEqual( - baseSendOptions.mcpServers.client.env.CURSOR_SDK_BRIDGE_AGENT_CACHE_KEY - ); - expect(baseSendOptions.mcpServers.client.env.CURSOR_SDK_BRIDGE_CLIENT_TOOLS_JSON).toContain("webfetch"); - expect(dynamicSendOptions.mcpServers.client.env.CURSOR_SDK_BRIDGE_CLIENT_TOOLS_JSON).toContain("webfetch"); - expect(dynamicSendOptions.mcpServers.client.env.CURSOR_SDK_BRIDGE_CLIENT_TOOLS_JSON).toContain("probe_write_file"); + // Tool sets change between turns, so they must ride on the send and never + // on the agent identity, or every new tool would strand the warm agent. + expect(localAgentCreateOptions(baseInput).local.cwd).toEqual(localAgentCreateOptions(dynamicInput).local.cwd); + expect(baseSendOptions).not.toHaveProperty("mcpServers"); + expect(Object.keys(baseSendOptions.local.customTools)).toContain("webfetch"); + expect(Object.keys(dynamicSendOptions.local.customTools)).toContain("webfetch"); + expect(Object.keys(dynamicSendOptions.local.customTools)).toContain("probe_write_file"); + }); + + it("keeps the harness out of the real workspace once the client owns tool execution", () => { + const withClientTools = { + apiKey: "test-key", + model: "composer-2.5", + workingDirectory: "/tmp/project", + sessionKey: "shared-session", + clientTools: [{ name: "write" }] + }; + const withoutClientTools = { ...withClientTools, clientTools: [] }; + + const scratch = sdkScratchWorkspace(withClientTools); + expect(scratch).not.toEqual("/tmp/project"); + expect(scratch).toContain("api-for-cursor"); + expect(localAgentCreateOptions(withClientTools).local.cwd).toEqual(scratch); + // Clients that execute nothing themselves still need the real directory. + expect(sdkScratchWorkspace(withoutClientTools)).toEqual("/tmp/project"); + // The caller trims the attached inventory per turn, so ownership is declared + // separately. Without this the harness would regain the real workspace on + // any turn that attaches no tools. + expect(sdkScratchWorkspace({ ...withoutClientTools, clientOwnsToolExecution: true })).toEqual(scratch); }); });