Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
17 changes: 17 additions & 0 deletions Sources/ProxyCore/RequestInspector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package struct RequestTransform {
package let idKey: String?
package let responseIDs: [RPCID]
package let method: String?
package let toolName: String?
package let originalID: RPCID?
package let isCacheableToolsListRequest: Bool
}
Expand All @@ -20,6 +21,7 @@ package enum RequestInspector {
let json = try JSONSerialization.jsonObject(with: data, options: [])
if var object = json as? [String: Any] {
let method = object["method"] as? String
let toolName = toolName(for: method, in: object)
// We intentionally treat tools/list as stable and cache it regardless of params.
// Some clients attach pagination-like params even when they expect the full list.
let isCacheableToolsListRequest = (method == "tools/list")
Expand All @@ -34,6 +36,7 @@ package enum RequestInspector {
idKey: rpcID.key,
responseIDs: [rpcID],
method: method,
toolName: toolName,
originalID: rpcID,
isCacheableToolsListRequest: isCacheableToolsListRequest
)
Expand All @@ -46,6 +49,7 @@ package enum RequestInspector {
idKey: nil,
responseIDs: [],
method: method,
toolName: toolName,
originalID: nil,
isCacheableToolsListRequest: isCacheableToolsListRequest
)
Expand Down Expand Up @@ -75,6 +79,7 @@ package enum RequestInspector {
idKey: nil,
responseIDs: responseIDs,
method: nil,
toolName: nil,
originalID: nil,
isCacheableToolsListRequest: false
)
Expand All @@ -87,8 +92,20 @@ package enum RequestInspector {
idKey: nil,
responseIDs: [],
method: nil,
toolName: nil,
originalID: nil,
isCacheableToolsListRequest: false
)
}

private static func toolName(for method: String?, in object: [String: Any]) -> String? {
guard method == "tools/call",
let params = object["params"] as? [String: Any],
let toolName = params["name"] as? String,
toolName.isEmpty == false
else {
return nil
}
return toolName
}
}
67 changes: 67 additions & 0 deletions Sources/ProxyFeatureXcode/RefreshCodeIssuesToolsListRewriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ package enum RefreshCodeIssuesToolsListRewriter {
return toolValue
}
toolObject["description"] = .string(description(for: mode))
if mode == .proxy {
toolObject["outputSchema"] = proxyOutputSchema
}
return .object(toolObject)
}
resultObject["tools"] = .array(rewrittenTools)
Expand Down Expand Up @@ -66,4 +69,68 @@ package enum RefreshCodeIssuesToolsListRewriter {
"""
}
}

private static let proxyOutputSchema: JSONValue = .object([
"type": .string("object"),
"required": .array([
.string("issues"),
.string("truncated"),
.string("totalFound"),
]),
"properties": .object([
"message": .object([
"type": .string("string"),
"description": .string("Optional message with additional information about the search results"),
]),
"truncated": .object([
"type": .string("boolean"),
"description": .string("Whether results were truncated due to exceeding 100 issues"),
]),
"totalFound": .object([
"type": .string("integer"),
"description": .string("Total number of issues before truncation"),
]),
"issues": .object([
"type": .string("array"),
"description": .string("The list of current issues matching the input filters"),
"items": .object([
"type": .string("object"),
"required": .array([
.string("message"),
.string("severity"),
]),
"properties": .object([
"severity": .object([
"type": .string("string"),
"description": .string("The severity of issue (error, warning, remark)"),
]),
"line": .object([
"type": .string("integer"),
"description": .string("The line number where the issue was detected, if known"),
]),
"vitality": .object([
"type": .string("string"),
"enum": .array([
.string("fresh"),
.string("stale"),
]),
"description": .string("Whether an issue from a previous build is known to still be relevant or whether something might have changed since it was emitted (for example if the source file has been edited and it isn't yet known whether that edit fixes the issue). Possible values: (fresh, stale)"),
]),
"path": .object([
"type": .string("string"),
"description": .string("The file path where the issue was detected, if any"),
]),
"message": .object([
"type": .string("string"),
"description": .string("The message describing the issue"),
]),
"category": .object([
"type": .string("string"),
"description": .string("The category of the issue, if known"),
]),
]),
]),
]),
]),
])
}
177 changes: 174 additions & 3 deletions Sources/ProxyHTTPTransport/MCPForwardingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,21 @@ package struct MCPForwardingService: Sendable {
upstreamData: rewrittenResourcesData,
mode: config.refreshCodeIssuesMode
)
let normalizedToolCallData = Self.rewriteToolCallStructuredContentIfNeeded(
method: started.transform.method,
toolName: started.transform.toolName,
upstreamData: responseData,
cachedToolsListResult: sessionManager.cachedToolsListResult()
)
if started.transform.isCacheableToolsListRequest,
let object = try? JSONSerialization.jsonObject(with: responseData, options: [])
let object = try? JSONSerialization.jsonObject(with: normalizedToolCallData, options: [])
as? [String: Any],
let resultAny = object["result"],
let result = JSONValue(any: resultAny)
{
sessionManager.setCachedToolsListResult(result)
}
if accountSuccess, Self.shouldNotifyUpstreamSuccess(for: responseData) {
if accountSuccess, Self.shouldNotifyUpstreamSuccess(for: normalizedToolCallData) {
for responseID in started.transform.responseIDs {
sessionManager.onRequestSucceeded(
sessionID: sessionID,
Expand All @@ -157,7 +163,7 @@ package struct MCPForwardingService: Sendable {
)
}
}
return .success(responseData)
return .success(normalizedToolCallData)

case .failure:
if let firstResponseID = started.transform.responseIDs.first {
Expand Down Expand Up @@ -447,6 +453,171 @@ package struct MCPForwardingService: Sendable {
)
}

private static func rewriteToolCallStructuredContentIfNeeded(
method: String?,
toolName: String?,
upstreamData: Data,
cachedToolsListResult: JSONValue?
) -> Data {
guard method == "tools/call",
let toolName,
toolHasOutputSchema(named: toolName, in: cachedToolsListResult),
let object = try? JSONSerialization.jsonObject(with: upstreamData, options: []) as? [String: Any],
let result = object["result"] as? [String: Any],
(result["isError"] as? Bool) != true
else {
return upstreamData
}

let rewrittenResult = rewrittenToolCallResult(toolName: toolName, result: result)
guard rewrittenResult.changed else {
return upstreamData
}

var rewrittenObject = object
rewrittenObject["result"] = rewrittenResult.result
guard JSONSerialization.isValidJSONObject(rewrittenObject),
let rewrittenData = try? JSONSerialization.data(withJSONObject: rewrittenObject, options: [])
else {
return upstreamData
}
return rewrittenData
}

private static func toolHasOutputSchema(named toolName: String, in toolsListResult: JSONValue?) -> Bool {
guard let toolsListResult,
case .object(let resultObject) = toolsListResult,
case .array(let tools) = resultObject["tools"]
else {
return false
}

for tool in tools {
guard case .object(let toolObject) = tool,
case .string(let candidateName) = toolObject["name"],
candidateName == toolName
else {
continue
}
return toolObject["outputSchema"] != nil
}
return false
}

private static func structuredToolContent(from result: [String: Any]) -> Any? {
guard let content = result["content"] as? [[String: Any]] else {
return nil
}

for item in content {
guard let text = item["text"] as? String,
Comment thread
romanr marked this conversation as resolved.
text.isEmpty == false,
let textData = text.data(using: .utf8),
let structuredContent = try? JSONSerialization.jsonObject(with: textData, options: [])
else {
continue
}

if structuredContent is [String: Any] || structuredContent is [Any] {
return structuredContent
}
Comment on lines +521 to +523
}

return nil
}

private static func rewrittenToolCallResult(toolName: String, result: [String: Any]) -> (
result: [String: Any], changed: Bool
) {
var rewrittenResult = result
var changed = false

if rewrittenResult["structuredContent"] == nil,
let structuredContent = structuredToolContent(from: rewrittenResult)
{
rewrittenResult["structuredContent"] = structuredContent
changed = true
}

if let structuredContent = rewrittenResult["structuredContent"],
let normalizedStructuredContent = normalizeStructuredContentIfNeeded(
toolName: toolName,
structuredContent: structuredContent
)
{
rewrittenResult["structuredContent"] = normalizedStructuredContent
changed = true
}

return (rewrittenResult, changed)
}

private static func normalizeStructuredContentIfNeeded(
toolName: String,
structuredContent: Any
) -> Any? {
switch toolName {
case "GetBuildLog":
guard let object = structuredContent as? [String: Any] else {
return nil
}
return normalizeGetBuildLogStructuredContentIfNeeded(object)
default:
return nil
}
}

private static func normalizeGetBuildLogStructuredContentIfNeeded(
_ structuredContent: [String: Any]
) -> [String: Any]? {
guard let buildLogEntries = structuredContent["buildLogEntries"] as? [[String: Any]] else {
return nil
}

var normalizedEntries: [[String: Any]] = []
normalizedEntries.reserveCapacity(buildLogEntries.count)
var changed = false

for entry in buildLogEntries {
guard let emittedIssues = entry["emittedIssues"] as? [[String: Any]] else {
normalizedEntries.append(entry)
continue
}

var normalizedIssues: [[String: Any]] = []
normalizedIssues.reserveCapacity(emittedIssues.count)

for issue in emittedIssues {
guard issue["line"] == nil else {
normalizedIssues.append(issue)
continue
}

var normalizedIssue = issue
normalizedIssue["line"] = 0
normalizedIssues.append(normalizedIssue)
Comment on lines +591 to +599
changed = true
}

guard changed else {
normalizedEntries.append(entry)
Comment thread
romanr marked this conversation as resolved.
Outdated
continue
}

var normalizedEntry = entry
normalizedEntry["emittedIssues"] = normalizedIssues
normalizedEntries.append(normalizedEntry)
}

guard changed else {
return nil
}

var normalizedStructuredContent = structuredContent
normalizedStructuredContent["buildLogEntries"] = normalizedEntries
return normalizedStructuredContent
}

private static func shouldNotifyUpstreamSuccess(for responseData: Data) -> Bool {
guard let any = try? JSONSerialization.jsonObject(with: responseData, options: []) else {
return true
Expand Down
Loading
Loading