From b8836bf3da28e3c556724d254464e618f239b043 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Sat, 7 Mar 2026 15:10:18 +0000 Subject: [PATCH 1/3] Add v2 protocol backward compatibility adapters Register v2-style tool.call and permission.request JSON-RPC request handlers on all 4 SDKs so that SDK clients written against the v3 API still work at runtime when connected to a v2 CLI server. - Change protocol version negotiation from strict equality to range-based [MIN_PROTOCOL_VERSION(2), SDK_PROTOCOL_VERSION(3)] - Always register v2 handlers unconditionally (v3 servers never send them) - Node/Python/Go/.NET all implement handleToolCallRequestV2 and handlePermissionRequestV2 adapters that invoke the same user-facing tool and permission handlers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 132 +++++++++++++++++++++++++++-- go/client.go | 174 ++++++++++++++++++++++++++++++++------- nodejs/src/client.ts | 167 ++++++++++++++++++++++++++++++++++--- nodejs/src/session.ts | 24 ++++++ python/copilot/client.py | 141 +++++++++++++++++++++++++++---- 5 files changed, 576 insertions(+), 62 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 76af9b3a..ee043d6c 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -54,6 +54,11 @@ namespace GitHub.Copilot.SDK; /// public sealed partial class CopilotClient : IDisposable, IAsyncDisposable { + /// + /// Minimum protocol version this SDK can communicate with. + /// + private const int MinProtocolVersion = 2; + private readonly ConcurrentDictionary _sessions = new(); private readonly CopilotClientOptions _options; private readonly ILogger _logger; @@ -62,6 +67,7 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable private readonly int? _optionsPort; private readonly string? _optionsHost; private int? _actualPort; + private int? _negotiatedProtocolVersion; private List? _modelsCache; private readonly SemaphoreSlim _modelsCacheLock = new(1, 1); private readonly List> _lifecycleHandlers = []; @@ -923,27 +929,30 @@ private Task EnsureConnectedAsync(CancellationToken cancellationToke return (Task)StartAsync(cancellationToken); } - private static async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken) + private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken) { - var expectedVersion = SdkProtocolVersion.GetVersion(); + var maxVersion = SdkProtocolVersion.GetVersion(); var pingResponse = await InvokeRpcAsync( connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken); if (!pingResponse.ProtocolVersion.HasValue) { throw new InvalidOperationException( - $"SDK protocol version mismatch: SDK expects version {expectedVersion}, " + + $"SDK protocol version mismatch: SDK expects version {MinProtocolVersion}-{maxVersion}, " + $"but server does not report a protocol version. " + $"Please update your server to ensure compatibility."); } - if (pingResponse.ProtocolVersion.Value != expectedVersion) + var serverVersion = pingResponse.ProtocolVersion.Value; + if (serverVersion < MinProtocolVersion || serverVersion > maxVersion) { throw new InvalidOperationException( - $"SDK protocol version mismatch: SDK expects version {expectedVersion}, " + - $"but server reports version {pingResponse.ProtocolVersion.Value}. " + + $"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " + + $"but server reports version {serverVersion}. " + $"Please update your SDK or server to ensure compatibility."); } + + _negotiatedProtocolVersion = serverVersion; } private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken) @@ -1137,6 +1146,12 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? var handler = new RpcHandler(this); rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent); rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle); + // Protocol v3 servers send tool calls / permission requests as broadcast events. + // Protocol v2 servers use the older tool.call / permission.request RPC model. + // We always register v2 adapters because handlers are set up before version + // negotiation; a v3 server will simply never send these requests. + rpc.AddLocalRpcMethod("tool.call", handler.OnToolCallV2); + rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2); rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.StartListening(); @@ -1257,6 +1272,102 @@ public async Task OnHooksInvoke(string sessionId, string ho var output = await session.HandleHooksInvokeAsync(hookType, input); return new HooksInvokeResponse(output); } + + // Protocol v2 backward-compatibility adapters + + public async Task OnToolCallV2(string sessionId, + string toolCallId, + string toolName, + object? arguments) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + if (session.GetTool(toolName) is not { } tool) + { + return new ToolCallResponseV2(new ToolResultObject + { + TextResultForLlm = $"Tool '{toolName}' is not supported.", + ResultType = "failure", + Error = $"tool '{toolName}' not supported" + }); + } + + try + { + var invocation = new ToolInvocation + { + SessionId = sessionId, + ToolCallId = toolCallId, + ToolName = toolName, + Arguments = arguments + }; + + var aiFunctionArgs = new AIFunctionArguments + { + Context = new Dictionary + { + [typeof(ToolInvocation)] = invocation + } + }; + + if (arguments is not null) + { + if (arguments is not JsonElement incomingJsonArgs) + { + throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}"); + } + + foreach (var prop in incomingJsonArgs.EnumerateObject()) + { + aiFunctionArgs[prop.Name] = prop.Value; + } + } + + var result = await tool.InvokeAsync(aiFunctionArgs); + + var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject + { + ResultType = "success", + TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je + ? je.GetString()! + : JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))), + }; + return new ToolCallResponseV2(toolResultObject); + } + catch (Exception ex) + { + return new ToolCallResponseV2(new ToolResultObject + { + TextResultForLlm = "Invoking this tool produced an error. Detailed information is not available.", + ResultType = "failure", + Error = ex.Message + }); + } + } + + public async Task OnPermissionRequestV2(string sessionId, JsonElement permissionRequest) + { + var session = client.GetSession(sessionId); + if (session == null) + { + return new PermissionRequestResponseV2(new PermissionRequestResult + { + Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser + }); + } + + try + { + var result = await session.HandlePermissionRequestAsync(permissionRequest); + return new PermissionRequestResponseV2(result); + } + catch + { + return new PermissionRequestResponseV2(new PermissionRequestResult + { + Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser + }); + } + } } private class Connection( @@ -1376,6 +1487,13 @@ internal record UserInputRequestResponse( internal record HooksInvokeResponse( object? Output); + // Protocol v2 backward-compatibility response types + internal record ToolCallResponseV2( + ToolResultObject? Result); + + internal record PermissionRequestResponseV2( + PermissionRequestResult Result); + /// Trace source that forwards all logs to the ILogger. internal sealed class LoggerTraceSource : TraceSource { @@ -1469,11 +1587,13 @@ private static LogLevel MapLevel(TraceEventType eventType) [JsonSerializable(typeof(ListSessionsRequest))] [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(PermissionRequestResult))] + [JsonSerializable(typeof(PermissionRequestResponseV2))] [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SystemMessageConfig))] + [JsonSerializable(typeof(ToolCallResponseV2))] [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(ToolResultAIContent))] [JsonSerializable(typeof(ToolResultObject))] diff --git a/go/client.go b/go/client.go index 9cb263f9..f4cc981c 100644 --- a/go/client.go +++ b/go/client.go @@ -69,28 +69,29 @@ import ( // } // defer client.Stop() type Client struct { - options ClientOptions - process *exec.Cmd - client *jsonrpc2.Client - actualPort int - actualHost string - state ConnectionState - sessions map[string]*Session - sessionsMux sync.Mutex - isExternalServer bool - conn net.Conn // stores net.Conn for external TCP connections - useStdio bool // resolved value from options - autoStart bool // resolved value from options - autoRestart bool // resolved value from options - modelsCache []ModelInfo - modelsCacheMux sync.Mutex - lifecycleHandlers []SessionLifecycleHandler - typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler - lifecycleHandlersMux sync.Mutex - startStopMux sync.RWMutex // protects process and state during start/[force]stop - processDone chan struct{} - processErrorPtr *error - osProcess atomic.Pointer[os.Process] + options ClientOptions + process *exec.Cmd + client *jsonrpc2.Client + actualPort int + actualHost string + state ConnectionState + sessions map[string]*Session + sessionsMux sync.Mutex + isExternalServer bool + conn net.Conn // stores net.Conn for external TCP connections + useStdio bool // resolved value from options + autoStart bool // resolved value from options + autoRestart bool // resolved value from options + modelsCache []ModelInfo + modelsCacheMux sync.Mutex + lifecycleHandlers []SessionLifecycleHandler + typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler + lifecycleHandlersMux sync.Mutex + startStopMux sync.RWMutex // protects process and state during start/[force]stop + processDone chan struct{} + processErrorPtr *error + osProcess atomic.Pointer[os.Process] + negotiatedProtocolVersion int // RPC provides typed server-scoped RPC methods. // This field is nil until the client is connected via Start(). @@ -1068,22 +1069,28 @@ func (c *Client) ListModels(ctx context.Context) ([]ModelInfo, error) { return models, nil } -// verifyProtocolVersion verifies that the server's protocol version matches the SDK's expected version +// minProtocolVersion is the minimum protocol version this SDK can communicate with. +const minProtocolVersion = 2 + +// verifyProtocolVersion verifies that the server's protocol version is within the supported range +// and stores the negotiated version. func (c *Client) verifyProtocolVersion(ctx context.Context) error { - expectedVersion := GetSdkProtocolVersion() + maxVersion := GetSdkProtocolVersion() pingResult, err := c.Ping(ctx, "") if err != nil { return err } if pingResult.ProtocolVersion == nil { - return fmt.Errorf("SDK protocol version mismatch: SDK expects version %d, but server does not report a protocol version. Please update your server to ensure compatibility", expectedVersion) + return fmt.Errorf("SDK protocol version mismatch: SDK expects version %d-%d, but server does not report a protocol version. Please update your server to ensure compatibility", minProtocolVersion, maxVersion) } - if *pingResult.ProtocolVersion != expectedVersion { - return fmt.Errorf("SDK protocol version mismatch: SDK expects version %d, but server reports version %d. Please update your SDK or server to ensure compatibility", expectedVersion, *pingResult.ProtocolVersion) + serverVersion := *pingResult.ProtocolVersion + if serverVersion < minProtocolVersion || serverVersion > maxVersion { + return fmt.Errorf("SDK protocol version mismatch: SDK supports versions %d-%d, but server reports version %d. Please update your SDK or server to ensure compatibility", minProtocolVersion, maxVersion, serverVersion) } + c.negotiatedProtocolVersion = serverVersion return nil } @@ -1296,12 +1303,15 @@ func (c *Client) connectViaTcp(ctx context.Context) error { } // setupNotificationHandler configures handlers for session events and RPC requests. -// Tool calls and permission requests are handled via the broadcast event model (protocol v3): -// the server broadcasts external_tool.requested / permission.requested as session events, -// and clients respond via session.tools.handlePendingToolCall / session.permissions.handlePendingPermissionRequest RPCs. +// Protocol v3 servers send tool calls and permission requests as broadcast session events. +// Protocol v2 servers use the older tool.call / permission.request RPC model. +// We always register v2 adapters because handlers are set up before version negotiation; +// a v3 server will simply never send these requests. func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("session.event", jsonrpc2.NotificationHandlerFor(c.handleSessionEvent)) c.client.SetRequestHandler("session.lifecycle", jsonrpc2.NotificationHandlerFor(c.handleLifecycleEvent)) + c.client.SetRequestHandler("tool.call", jsonrpc2.RequestHandlerFor(c.handleToolCallRequestV2)) + c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2)) c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) } @@ -1369,3 +1379,107 @@ func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jso } return result, nil } + +// ======================================================================== +// Protocol v2 backward-compatibility adapters +// ======================================================================== + +// toolCallRequestV2 is the v2 RPC request payload for tool.call. +type toolCallRequestV2 struct { + SessionID string `json:"sessionId"` + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Arguments any `json:"arguments"` +} + +// toolCallResponseV2 is the v2 RPC response payload for tool.call. +type toolCallResponseV2 struct { + Result ToolResult `json:"result"` +} + +// permissionRequestV2 is the v2 RPC request payload for permission.request. +type permissionRequestV2 struct { + SessionID string `json:"sessionId"` + Request PermissionRequest `json:"permissionRequest"` +} + +// permissionResponseV2 is the v2 RPC response payload for permission.request. +type permissionResponseV2 struct { + Result PermissionRequestResult `json:"result"` +} + +// handleToolCallRequestV2 handles a v2-style tool.call RPC request from the server. +func (c *Client) handleToolCallRequestV2(req toolCallRequestV2) (*toolCallResponseV2, *jsonrpc2.Error) { + if req.SessionID == "" || req.ToolCallID == "" || req.ToolName == "" { + return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid tool call payload"} + } + + c.sessionsMux.Lock() + session, ok := c.sessions[req.SessionID] + c.sessionsMux.Unlock() + if !ok { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + } + + handler, ok := session.getToolHandler(req.ToolName) + if !ok { + return &toolCallResponseV2{Result: ToolResult{ + TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", req.ToolName), + ResultType: "failure", + Error: fmt.Sprintf("tool '%s' not supported", req.ToolName), + ToolTelemetry: map[string]any{}, + }}, nil + } + + invocation := ToolInvocation(req) + + result, err := handler(invocation) + if err != nil { + return &toolCallResponseV2{Result: ToolResult{ + TextResultForLLM: "Invoking this tool produced an error. Detailed information is not available.", + ResultType: "failure", + Error: err.Error(), + ToolTelemetry: map[string]any{}, + }}, nil + } + + return &toolCallResponseV2{Result: result}, nil +} + +// handlePermissionRequestV2 handles a v2-style permission.request RPC request from the server. +func (c *Client) handlePermissionRequestV2(req permissionRequestV2) (*permissionResponseV2, *jsonrpc2.Error) { + if req.SessionID == "" { + return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid permission request payload"} + } + + c.sessionsMux.Lock() + session, ok := c.sessions[req.SessionID] + c.sessionsMux.Unlock() + if !ok { + return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + } + + handler := session.getPermissionHandler() + if handler == nil { + return &permissionResponseV2{ + Result: PermissionRequestResult{ + Kind: PermissionRequestResultKindDeniedCouldNotRequestFromUser, + }, + }, nil + } + + invocation := PermissionInvocation{ + SessionID: session.SessionID, + } + + result, err := handler(req.Request, invocation) + if err != nil { + return &permissionResponseV2{ + Result: PermissionRequestResult{ + Kind: PermissionRequestResultKindDeniedCouldNotRequestFromUser, + }, + }, nil + } + + return &permissionResponseV2{Result: result}, nil +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 0ce47a2a..44ed946b 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -42,9 +42,18 @@ import type { SessionListFilter, SessionMetadata, Tool, + ToolCallRequestPayload, + ToolCallResponsePayload, + ToolResultObject, TypedSessionLifecycleHandler, } from "./types.js"; +/** + * Minimum protocol version this SDK can communicate with. + * Servers reporting a version below this are rejected. + */ +const MIN_PROTOCOL_VERSION = 2; + /** * Check if value is a Zod schema (has toJSONSchema method) */ @@ -149,6 +158,7 @@ export class CopilotClient { > = new Map(); private _rpc: ReturnType | null = null; private processExitPromise: Promise | null = null; // Rejects when CLI process exits + private negotiatedProtocolVersion: number | null = null; /** * Typed server-scoped RPC methods. @@ -778,10 +788,11 @@ export class CopilotClient { } /** - * Verify that the server's protocol version matches the SDK's expected version + * Verify that the server's protocol version is within the supported range + * and store the negotiated version. */ private async verifyProtocolVersion(): Promise { - const expectedVersion = getSdkProtocolVersion(); + const maxVersion = getSdkProtocolVersion(); // Race ping against process exit to detect early CLI failures let pingResult: Awaited>; @@ -795,17 +806,19 @@ export class CopilotClient { if (serverVersion === undefined) { throw new Error( - `SDK protocol version mismatch: SDK expects version ${expectedVersion}, but server does not report a protocol version. ` + + `SDK protocol version mismatch: SDK expects version ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server does not report a protocol version. ` + `Please update your server to ensure compatibility.` ); } - if (serverVersion !== expectedVersion) { + if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > maxVersion) { throw new Error( - `SDK protocol version mismatch: SDK expects version ${expectedVersion}, but server reports version ${serverVersion}. ` + + `SDK protocol version mismatch: SDK supports versions ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server reports version ${serverVersion}. ` + `Please update your SDK or server to ensure compatibility.` ); } + + this.negotiatedProtocolVersion = serverVersion; } /** @@ -1310,11 +1323,24 @@ export class CopilotClient { this.handleSessionLifecycleNotification(notification); }); - // External tool calls and permission requests are now handled via broadcast events: - // the server sends external_tool.requested / permission.requested as session event - // notifications, and CopilotSession._dispatchEvent handles them internally by - // executing the handler and responding via session.tools.handlePendingToolCall / - // session.permissions.handlePendingPermissionRequest RPC. + // Protocol v3 servers send tool calls and permission requests as broadcast events + // (external_tool.requested / permission.requested) handled in CopilotSession._dispatchEvent. + // Protocol v2 servers use the older tool.call / permission.request RPC model instead. + // We always register v2 adapters because handlers are set up before version negotiation; + // a v3 server will simply never send these requests. + this.connection.onRequest( + "tool.call", + async (params: ToolCallRequestPayload): Promise => + await this.handleToolCallRequestV2(params) + ); + + this.connection.onRequest( + "permission.request", + async (params: { + sessionId: string; + permissionRequest: unknown; + }): Promise<{ result: unknown }> => await this.handlePermissionRequestV2(params) + ); this.connection.onRequest( "userInput.request", @@ -1449,6 +1475,127 @@ export class CopilotClient { return { output }; } + // ======================================================================== + // Protocol v2 backward-compatibility adapters + // ======================================================================== + + /** + * Handles a v2-style tool.call RPC request from the server. + * Looks up the session and tool handler, executes it, and returns the result + * in the v2 response format. + */ + private async handleToolCallRequestV2( + params: ToolCallRequestPayload + ): Promise { + if ( + !params || + typeof params.sessionId !== "string" || + typeof params.toolCallId !== "string" || + typeof params.toolName !== "string" + ) { + throw new Error("Invalid tool call payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Unknown session ${params.sessionId}`); + } + + const handler = session.getToolHandler(params.toolName); + if (!handler) { + return { + result: { + textResultForLlm: `Tool '${params.toolName}' is not supported by this client instance.`, + resultType: "failure", + error: `tool '${params.toolName}' not supported`, + toolTelemetry: {}, + }, + }; + } + + try { + const invocation = { + sessionId: params.sessionId, + toolCallId: params.toolCallId, + toolName: params.toolName, + arguments: params.arguments, + }; + const result = await handler(params.arguments, invocation); + return { result: this.normalizeToolResultV2(result) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + result: { + textResultForLlm: + "Invoking this tool produced an error. Detailed information is not available.", + resultType: "failure", + error: message, + toolTelemetry: {}, + }, + }; + } + } + + /** + * Handles a v2-style permission.request RPC request from the server. + */ + private async handlePermissionRequestV2(params: { + sessionId: string; + permissionRequest: unknown; + }): Promise<{ result: unknown }> { + if (!params || typeof params.sessionId !== "string" || !params.permissionRequest) { + throw new Error("Invalid permission request payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + try { + const result = await session._handlePermissionRequestV2(params.permissionRequest); + return { result }; + } catch (_error) { + return { + result: { + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }, + }; + } + } + + private normalizeToolResultV2(result: unknown): ToolResultObject { + if (result === undefined || result === null) { + return { + textResultForLlm: "Tool returned no result", + resultType: "failure", + error: "tool returned no result", + toolTelemetry: {}, + }; + } + + if (this.isToolResultObject(result)) { + return result; + } + + const textResult = typeof result === "string" ? result : JSON.stringify(result); + return { + textResultForLlm: textResult, + resultType: "success", + toolTelemetry: {}, + }; + } + + private isToolResultObject(value: unknown): value is ToolResultObject { + return ( + typeof value === "object" && + value !== null && + "textResultForLlm" in value && + typeof (value as ToolResultObject).textResultForLlm === "string" && + "resultType" in value + ); + } + /** * Attempt to reconnect to the server */ diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 8332d948..181d1a96 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -14,6 +14,7 @@ import type { MessageOptions, PermissionHandler, PermissionRequest, + PermissionRequestResult, SessionEvent, SessionEventHandler, SessionEventPayload, @@ -487,6 +488,29 @@ export class CopilotSession { this.hooks = hooks; } + /** + * Handles a permission request in the v2 protocol format (synchronous RPC). + * Used as a back-compat adapter when connected to a v2 server. + * + * @param request - The permission request data from the CLI + * @returns A promise that resolves with the permission decision + * @internal This method is for internal use by the SDK. + */ + async _handlePermissionRequestV2(request: unknown): Promise { + if (!this.permissionHandler) { + return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; + } + + try { + const result = await this.permissionHandler(request as PermissionRequest, { + sessionId: this.sessionId, + }); + return result; + } catch (_error) { + return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; + } + } + /** * Handles a user input request from the Copilot CLI. * diff --git a/python/copilot/client.py b/python/copilot/client.py index dae15bf5..e0edd05c 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -23,7 +23,7 @@ from typing import Any, cast from .generated.rpc import ServerRpc -from .generated.session_events import session_event_from_dict +from .generated.session_events import PermissionRequest, session_event_from_dict from .jsonrpc import JsonRpcClient, ProcessExitedError from .sdk_protocol_version import get_sdk_protocol_version from .session import CopilotSession @@ -44,8 +44,14 @@ SessionListFilter, SessionMetadata, StopError, + ToolInvocation, + ToolResult, ) +# Minimum protocol version this SDK can communicate with. +# Servers reporting a version below this are rejected. +MIN_PROTOCOL_VERSION = 2 + def _get_bundled_cli_path() -> str | None: """Get the path to the bundled CLI binary, if available.""" @@ -206,6 +212,7 @@ def __init__(self, options: CopilotClientOptions | None = None): ] = {} self._lifecycle_handlers_lock = threading.Lock() self._rpc: ServerRpc | None = None + self._negotiated_protocol_version: int | None = None @property def rpc(self) -> ServerRpc: @@ -1139,25 +1146,30 @@ def _dispatch_lifecycle_event(self, event: SessionLifecycleEvent) -> None: pass # Ignore handler errors async def _verify_protocol_version(self) -> None: - """Verify that the server's protocol version matches the SDK's expected version.""" - expected_version = get_sdk_protocol_version() + """Verify that the server's protocol version is within the supported range + and store the negotiated version.""" + max_version = get_sdk_protocol_version() ping_result = await self.ping() server_version = ping_result.protocolVersion if server_version is None: raise RuntimeError( - f"SDK protocol version mismatch: SDK expects version {expected_version}, " - f"but server does not report a protocol version. " - f"Please update your server to ensure compatibility." + "SDK protocol version mismatch: " + f"SDK expects version {MIN_PROTOCOL_VERSION}-{max_version}" + ", but server does not report a protocol version. " + "Please update your server to ensure compatibility." ) - if server_version != expected_version: + if server_version < MIN_PROTOCOL_VERSION or server_version > max_version: raise RuntimeError( - f"SDK protocol version mismatch: SDK expects version {expected_version}, " - f"but server reports version {server_version}. " - f"Please update your SDK or server to ensure compatibility." + "SDK protocol version mismatch: " + f"SDK supports versions {MIN_PROTOCOL_VERSION}-{max_version}" + f", but server reports version {server_version}. " + "Please update your SDK or server to ensure compatibility." ) + self._negotiated_protocol_version = server_version + def _convert_provider_to_wire_format( self, provider: ProviderConfig | dict[str, Any] ) -> dict[str, Any]: @@ -1367,10 +1379,12 @@ def handle_notification(method: str, params: dict): self._dispatch_lifecycle_event(lifecycle_event) self._client.set_notification_handler(handle_notification) - # Protocol v3: tool.call and permission.request RPC handlers removed. - # Tool calls and permission requests are now broadcast as session events - # (external_tool.requested, permission.requested) and handled in - # Session._handle_broadcast_event. + # Protocol v3 servers send tool calls / permission requests as broadcast events. + # Protocol v2 servers use the older tool.call / permission.request RPC model. + # We always register v2 adapters because handlers are set up before version + # negotiation; a v3 server will simply never send these requests. + self._client.set_request_handler("tool.call", self._handle_tool_call_request_v2) + self._client.set_request_handler("permission.request", self._handle_permission_request_v2) self._client.set_request_handler("userInput.request", self._handle_user_input_request) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) @@ -1450,8 +1464,11 @@ def handle_notification(method: str, params: dict): self._dispatch_lifecycle_event(lifecycle_event) self._client.set_notification_handler(handle_notification) - # Protocol v3: tool.call and permission.request RPC handlers removed. - # See _connect_via_stdio for details. + # Protocol v3 servers send tool calls / permission requests as broadcast events. + # Protocol v2 servers use the older tool.call / permission.request RPC model. + # We always register v2 adapters; a v3 server will simply never send these requests. + self._client.set_request_handler("tool.call", self._handle_tool_call_request_v2) + self._client.set_request_handler("permission.request", self._handle_permission_request_v2) self._client.set_request_handler("userInput.request", self._handle_user_input_request) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) @@ -1513,3 +1530,95 @@ async def _handle_hooks_invoke(self, params: dict) -> dict: output = await session._handle_hooks_invoke(hook_type, input_data) return {"output": output} + + # ======================================================================== + # Protocol v2 backward-compatibility adapters + # ======================================================================== + + async def _handle_tool_call_request_v2(self, params: dict) -> dict: + """Handle a v2-style tool.call RPC request from the server.""" + session_id = params.get("sessionId") + tool_call_id = params.get("toolCallId") + tool_name = params.get("toolName") + + if not session_id or not tool_call_id or not tool_name: + raise ValueError("invalid tool call payload") + + with self._sessions_lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"unknown session {session_id}") + + handler = session._get_tool_handler(tool_name) + if not handler: + return { + "result": { + "textResultForLlm": ( + f"Tool '{tool_name}' is not supported by this client instance." + ), + "resultType": "failure", + "error": f"tool '{tool_name}' not supported", + "toolTelemetry": {}, + } + } + + arguments = params.get("arguments") + invocation = ToolInvocation( + session_id=session_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + arguments=arguments, + ) + + try: + import inspect + + result = handler(invocation) + if inspect.isawaitable(result): + result = await result + + tool_result: ToolResult = result # type: ignore[assignment] + return { + "result": { + "textResultForLlm": tool_result.text_result_for_llm, + "resultType": tool_result.result_type, + "error": tool_result.error, + "toolTelemetry": tool_result.tool_telemetry or {}, + } + } + except Exception as exc: + return { + "result": { + "textResultForLlm": ( + "Invoking this tool produced an error." + " Detailed information is not available." + ), + "resultType": "failure", + "error": str(exc), + "toolTelemetry": {}, + } + } + + async def _handle_permission_request_v2(self, params: dict) -> dict: + """Handle a v2-style permission.request RPC request from the server.""" + session_id = params.get("sessionId") + permission_request = params.get("permissionRequest") + + if not session_id or not permission_request: + raise ValueError("invalid permission request payload") + + with self._sessions_lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"unknown session {session_id}") + + try: + perm_request = PermissionRequest.from_dict(permission_request) + result = await session._handle_permission_request(perm_request) + return {"result": {"kind": result.kind}} + except Exception: # pylint: disable=broad-except + return { + "result": { + "kind": "denied-no-approval-rule-and-could-not-request-from-user", + } + } From 13fea36a41b62d3ede61483348fe293aa48245c7 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Sat, 7 Mar 2026 15:17:13 +0000 Subject: [PATCH 2/3] Address PR review feedback - Use 'supports versions' consistently in all error messages (all SDKs) - Move 'import inspect' to top of module (Python) - Return full PermissionRequestResult fields, not just kind (Python) - Change bare catch to catch (Exception) (C#) - Make ToolCallResponseV2.Result non-nullable (C#) - Throw for unknown session in OnPermissionRequestV2 (C#) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 17 +++++------------ go/client.go | 2 +- nodejs/src/client.ts | 2 +- python/copilot/client.py | 16 ++++++++++++---- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index ee043d6c..b8bbe9ff 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -938,7 +938,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio if (!pingResponse.ProtocolVersion.HasValue) { throw new InvalidOperationException( - $"SDK protocol version mismatch: SDK expects version {MinProtocolVersion}-{maxVersion}, " + + $"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " + $"but server does not report a protocol version. " + $"Please update your server to ensure compatibility."); } @@ -1346,22 +1346,15 @@ public async Task OnToolCallV2(string sessionId, public async Task OnPermissionRequestV2(string sessionId, JsonElement permissionRequest) { - var session = client.GetSession(sessionId); - if (session == null) - { - return new PermissionRequestResponseV2(new PermissionRequestResult - { - Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser - }); - } + var session = client.GetSession(sessionId) + ?? throw new ArgumentException($"Unknown session {sessionId}"); try { var result = await session.HandlePermissionRequestAsync(permissionRequest); return new PermissionRequestResponseV2(result); } - catch - { + catch (Exception) { return new PermissionRequestResponseV2(new PermissionRequestResult { Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser @@ -1489,7 +1482,7 @@ internal record HooksInvokeResponse( // Protocol v2 backward-compatibility response types internal record ToolCallResponseV2( - ToolResultObject? Result); + ToolResultObject Result); internal record PermissionRequestResponseV2( PermissionRequestResult Result); diff --git a/go/client.go b/go/client.go index f4cc981c..a43530ad 100644 --- a/go/client.go +++ b/go/client.go @@ -1082,7 +1082,7 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error { } if pingResult.ProtocolVersion == nil { - return fmt.Errorf("SDK protocol version mismatch: SDK expects version %d-%d, but server does not report a protocol version. Please update your server to ensure compatibility", minProtocolVersion, maxVersion) + return fmt.Errorf("SDK protocol version mismatch: SDK supports versions %d-%d, but server does not report a protocol version. Please update your server to ensure compatibility", minProtocolVersion, maxVersion) } serverVersion := *pingResult.ProtocolVersion diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 44ed946b..de5f1856 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -806,7 +806,7 @@ export class CopilotClient { if (serverVersion === undefined) { throw new Error( - `SDK protocol version mismatch: SDK expects version ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server does not report a protocol version. ` + + `SDK protocol version mismatch: SDK supports versions ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server does not report a protocol version. ` + `Please update your server to ensure compatibility.` ); } diff --git a/python/copilot/client.py b/python/copilot/client.py index e0edd05c..7ea4e97a 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -13,6 +13,7 @@ """ import asyncio +import inspect import os import re import subprocess @@ -1155,7 +1156,7 @@ async def _verify_protocol_version(self) -> None: if server_version is None: raise RuntimeError( "SDK protocol version mismatch: " - f"SDK expects version {MIN_PROTOCOL_VERSION}-{max_version}" + f"SDK supports versions {MIN_PROTOCOL_VERSION}-{max_version}" ", but server does not report a protocol version. " "Please update your server to ensure compatibility." ) @@ -1571,8 +1572,6 @@ async def _handle_tool_call_request_v2(self, params: dict) -> dict: ) try: - import inspect - result = handler(invocation) if inspect.isawaitable(result): result = await result @@ -1615,7 +1614,16 @@ async def _handle_permission_request_v2(self, params: dict) -> dict: try: perm_request = PermissionRequest.from_dict(permission_request) result = await session._handle_permission_request(perm_request) - return {"result": {"kind": result.kind}} + result_payload: dict = {"kind": result.kind} + if result.rules is not None: + result_payload["rules"] = result.rules + if result.feedback is not None: + result_payload["feedback"] = result.feedback + if result.message is not None: + result_payload["message"] = result.message + if result.path is not None: + result_payload["path"] = result.path + return {"result": result_payload} except Exception: # pylint: disable=broad-except return { "result": { From bd192392af2713a16fd62ff3dd4d1c164c01dde6 Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Sat, 7 Mar 2026 15:21:14 +0000 Subject: [PATCH 3/3] Fix .NET whitespace formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index b8bbe9ff..8cad6b04 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1354,7 +1354,8 @@ public async Task OnPermissionRequestV2(string sess var result = await session.HandlePermissionRequestAsync(permissionRequest); return new PermissionRequestResponseV2(result); } - catch (Exception) { + catch (Exception) + { return new PermissionRequestResponseV2(new PermissionRequestResult { Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser