From 5dab8cfb140bec92d4ea818346e17edbc41537c2 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 10:47:41 -0400 Subject: [PATCH 01/15] Add preMcpToolCall hook support to all SDKs Add the preMcpToolCall hook which fires before an MCP tool call is dispatched to an MCP server. This aligns with copilot-agent-runtime 1.0.51 which added support for this hook type. The hook receives serverName, toolName, arguments, optional toolCallId, and optional _meta as input. The output supports a tri-state metaToUse field: absent (preserve existing _meta), null (remove _meta), or object (replace _meta). Changes per SDK: - Node.js: PreMcpToolCallHookInput/Output types, handler, SessionHooks - Python: PreMcpToolCallHookInput/Output TypedDicts, handler, SessionHooks - Go: PreMcpToolCallHookInput/Output structs, handler, helper functions - .NET: PreMcpToolCallHookInput/Output classes, SessionHooks, JsonElement? - Rust: PreMcpToolCallInput/Output structs, HookEvent/Output variants, trait Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Session.cs | 7 ++ dotnet/src/Types.cs | 96 +++++++++++++-- .../E2E/HookLifecycleAndOutputE2ETests.cs | 8 +- go/internal/e2e/client_options_e2e_test.go | 4 +- go/internal/e2e/hooks_extended_e2e_test.go | 2 +- go/internal/e2e/session_e2e_test.go | 8 +- go/session.go | 10 ++ go/types.go | 110 ++++++++++++------ nodejs/src/session.ts | 6 +- nodejs/src/types.ts | 39 ++++++- nodejs/test/e2e/hooks_extended.e2e.test.ts | 8 +- python/copilot/session.py | 48 +++++++- python/e2e/test_hooks_extended_e2e.py | 2 +- rust/examples/hooks.rs | 2 +- rust/src/hooks.rs | 87 +++++++++++++- rust/tests/e2e/hooks_extended.rs | 6 +- 16 files changed, 368 insertions(+), 75 deletions(-) diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index fc7d82675..21f184438 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -1245,6 +1245,11 @@ internal void RegisterHooks(SessionHooks hooks) JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreToolUseHookInput)!, invocation) : null, + "preMcpToolCall" => hooks.OnPreMcpToolCall != null + ? await hooks.OnPreMcpToolCall( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreMcpToolCallHookInput)!, + invocation) + : null, "postToolUse" => hooks.OnPostToolUse != null ? await hooks.OnPostToolUse( JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseHookInput)!, @@ -1607,6 +1612,8 @@ internal void ThrowIfDisposed() [JsonSerializable(typeof(GetMessagesResponse))] [JsonSerializable(typeof(PostToolUseHookInput))] [JsonSerializable(typeof(PostToolUseHookOutput))] + [JsonSerializable(typeof(PreMcpToolCallHookInput))] + [JsonSerializable(typeof(PreMcpToolCallHookOutput))] [JsonSerializable(typeof(PreToolUseHookInput))] [JsonSerializable(typeof(PreToolUseHookOutput))] [JsonSerializable(typeof(SendMessageRequest))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 9cc070f78..7775c3c50 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1,4 +1,4 @@ -/*--------------------------------------------------------------------------------------------- +/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ @@ -1215,7 +1215,7 @@ public sealed class PreToolUseHookInput /// Current working directory of the session. /// [JsonPropertyName("cwd")] - public string Cwd { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; /// /// Name of the tool about to be executed. @@ -1271,6 +1271,83 @@ public sealed class PreToolUseHookOutput public bool? SuppressOutput { get; set; } } +/// +/// Input for a pre-MCP-tool-call hook. +/// +public sealed class PreMcpToolCallHookInput +{ + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// + /// Unix timestamp in milliseconds when the hook was triggered. + /// + [JsonPropertyName("timestamp")] + [JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))] + public DateTimeOffset Timestamp { get; set; } + + /// + /// Current working directory of the session. + /// + [JsonPropertyName("cwd")] + public string WorkingDirectory { get; set; } = string.Empty; + + /// + /// Name of the MCP server being called. + /// + [JsonPropertyName("serverName")] + public string ServerName { get; set; } = string.Empty; + + /// + /// Name of the MCP tool being called. + /// + [JsonPropertyName("toolName")] + public string ToolName { get; set; } = string.Empty; + + /// + /// Arguments for the MCP tool call. + /// + [JsonPropertyName("arguments")] + public JsonElement? Arguments { get; set; } + + /// + /// Tool call ID, if available. + /// + [JsonPropertyName("toolCallId")] + public string? ToolCallId { get; set; } + + /// + /// MCP request metadata, if present. + /// + [JsonPropertyName("_meta")] + public IDictionary? Meta { get; set; } +} + +/// +/// Output for a pre-MCP-tool-call hook. +/// +/// +/// The property controls outgoing MCP request metadata: +/// +/// Return null from the hook handler: preserve existing _meta (no-op). +/// Return a with left as null: omit _meta from the request. +/// Return a with set to a object: replace _meta with that object. +/// +/// +public sealed class PreMcpToolCallHookOutput +{ + /// + /// Hook-controlled metadata to use for the outgoing MCP request. + /// See class remarks for semantics. + /// + [JsonPropertyName("metaToUse")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public JsonElement? MetaToUse { get; set; } +} + /// /// Input for a post-tool-use hook. /// @@ -1293,7 +1370,7 @@ public sealed class PostToolUseHookInput /// Current working directory of the session. /// [JsonPropertyName("cwd")] - public string Cwd { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; /// /// Name of the tool that was executed. @@ -1360,7 +1437,7 @@ public sealed class UserPromptSubmittedHookInput /// Current working directory of the session. /// [JsonPropertyName("cwd")] - public string Cwd { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; /// /// The user's prompt text. @@ -1415,7 +1492,7 @@ public sealed class SessionStartHookInput /// Current working directory of the session. /// [JsonPropertyName("cwd")] - public string Cwd { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; /// /// Source of the session start. @@ -1475,7 +1552,7 @@ public sealed class SessionEndHookInput /// Current working directory of the session. /// [JsonPropertyName("cwd")] - public string Cwd { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; /// /// Reason for session end. @@ -1549,7 +1626,7 @@ public sealed class ErrorOccurredHookInput /// Current working directory of the session. /// [JsonPropertyName("cwd")] - public string Cwd { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; /// /// Error message describing what went wrong. @@ -1621,6 +1698,11 @@ public sealed class SessionHooks /// public Func>? OnPreToolUse { get; set; } + /// + /// Handler called before an MCP tool is called. + /// + public Func>? OnPreMcpToolCall { get; set; } + /// /// Handler called after a tool has been executed. /// diff --git a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs index b16ee2b56..d4304191c 100644 --- a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs +++ b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs @@ -45,7 +45,7 @@ public async Task Should_Invoke_OnSessionStart_Hook_On_New_Session() Assert.NotEmpty(sessionStartInputs); Assert.Equal("new", sessionStartInputs[0].Source); Assert.True(sessionStartInputs[0].Timestamp > DateTimeOffset.UnixEpoch); - Assert.False(string.IsNullOrEmpty(sessionStartInputs[0].Cwd)); + Assert.False(string.IsNullOrEmpty(sessionStartInputs[0].WorkingDirectory)); await session.DisposeAsync(); } @@ -73,7 +73,7 @@ public async Task Should_Invoke_OnUserPromptSubmitted_Hook_When_Sending_A_Messag Assert.NotEmpty(userPromptInputs); Assert.Contains("Say hello", userPromptInputs[0].Prompt); Assert.True(userPromptInputs[0].Timestamp > DateTimeOffset.UnixEpoch); - Assert.False(string.IsNullOrEmpty(userPromptInputs[0].Cwd)); + Assert.False(string.IsNullOrEmpty(userPromptInputs[0].WorkingDirectory)); await session.DisposeAsync(); } @@ -118,7 +118,7 @@ public async Task Should_Invoke_OnErrorOccurred_Hook_When_Error_Occurs() { Assert.Equal(session!.SessionId, invocation.SessionId); Assert.True(input.Timestamp > DateTimeOffset.UnixEpoch); - Assert.False(string.IsNullOrEmpty(input.Cwd)); + Assert.False(string.IsNullOrEmpty(input.WorkingDirectory)); Assert.False(string.IsNullOrEmpty(input.Error)); Assert.Contains(input.ErrorContext, ValidErrorContexts); return Task.FromResult(null); @@ -188,7 +188,7 @@ public async Task Should_Invoke_SessionStart_Hook() Assert.NotEmpty(inputs); Assert.Equal("new", inputs[0].Source); - Assert.False(string.IsNullOrEmpty(inputs[0].Cwd)); + Assert.False(string.IsNullOrEmpty(inputs[0].WorkingDirectory)); } [Fact] diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index d8d6399ee..560ddb7b3 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -137,7 +137,7 @@ func TestClientOptionsE2E(t *testing.T) { assertArgValue(t, args, "--session-idle-timeout", "17") expectedCwd, _ := filepath.Abs(ctx.WorkDir) - actualCwd, _ := filepath.Abs(capture.Cwd) + actualCwd, _ := filepath.Abs(capture.WorkingDirectory) if expectedCwd != actualCwd { t.Errorf("Expected cwd=%q, got %q", expectedCwd, actualCwd) } @@ -323,7 +323,7 @@ func assertArgValue(t *testing.T, args []string, name, expected string) { // capturedCli mirrors the JSON file written by the fake stdio CLI script. type capturedCli struct { Args []string `json:"args"` - Cwd string `json:"cwd"` + WorkingDirectory string `json:"cwd"` Requests []capturedRequest `json:"requests"` Env map[string]string `json:"env"` } diff --git a/go/internal/e2e/hooks_extended_e2e_test.go b/go/internal/e2e/hooks_extended_e2e_test.go index bc0144eaf..677de58b8 100644 --- a/go/internal/e2e/hooks_extended_e2e_test.go +++ b/go/internal/e2e/hooks_extended_e2e_test.go @@ -110,7 +110,7 @@ func TestHooksExtendedE2E(t *testing.T) { if inputs[0].Source != "new" { t.Errorf("Expected source 'new', got %q", inputs[0].Source) } - if inputs[0].Cwd == "" { + if inputs[0].WorkingDirectory == "" { t.Error("Expected non-empty cwd in sessionStart hook input") } }) diff --git a/go/internal/e2e/session_e2e_test.go b/go/internal/e2e/session_e2e_test.go index bddd7e8e1..f45a74616 100644 --- a/go/internal/e2e/session_e2e_test.go +++ b/go/internal/e2e/session_e2e_test.go @@ -894,8 +894,8 @@ func TestSessionE2E(t *testing.T) { // Verify context field is present on sessions for _, s := range sessions { if s.Context != nil { - if s.Context.Cwd == "" { - t.Error("Expected context.Cwd to be non-empty when context is present") + if s.Context.WorkingDirectory == "" { + t.Error("Expected context.WorkingDirectory to be non-empty when context is present") } } } @@ -1006,8 +1006,8 @@ func TestSessionE2E(t *testing.T) { // Verify context field if metadata.Context != nil { - if metadata.Context.Cwd == "" { - t.Error("Expected context.Cwd to be non-empty when context is present") + if metadata.Context.WorkingDirectory == "" { + t.Error("Expected context.WorkingDirectory to be non-empty when context is present") } } diff --git a/go/session.go b/go/session.go index 067bd0314..8119a1bf5 100644 --- a/go/session.go +++ b/go/session.go @@ -465,6 +465,16 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) ( } return hooks.OnPreToolUse(input, invocation) + case "preMcpToolCall": + if hooks.OnPreMcpToolCall == nil { + return nil, nil + } + var input PreMcpToolCallHookInput + if err := json.Unmarshal(rawInput, &input); err != nil { + return nil, fmt.Errorf("invalid hook input: %w", err) + } + return hooks.OnPreMcpToolCall(input, invocation) + case "postToolUse": if hooks.OnPostToolUse == nil { return nil, nil diff --git a/go/types.go b/go/types.go index e97cb5a37..8dc6818a3 100644 --- a/go/types.go +++ b/go/types.go @@ -371,11 +371,11 @@ type AutoModeSwitchRequestHandler func(request AutoModeSwitchRequest, invocation // PreToolUseHookInput is the input for a pre-tool-use hook type PreToolUseHookInput struct { - SessionID string `json:"sessionId"` - Timestamp time.Time `json:"-"` - Cwd string `json:"cwd"` - ToolName string `json:"toolName"` - ToolArgs any `json:"toolArgs"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + ToolName string `json:"toolName"` + ToolArgs any `json:"toolArgs"` } // MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. @@ -415,12 +415,12 @@ type PreToolUseHandler func(input PreToolUseHookInput, invocation HookInvocation // PostToolUseHookInput is the input for a post-tool-use hook type PostToolUseHookInput struct { - SessionID string `json:"sessionId"` - Timestamp time.Time `json:"-"` - Cwd string `json:"cwd"` - ToolName string `json:"toolName"` - ToolArgs any `json:"toolArgs"` - ToolResult any `json:"toolResult"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + ToolName string `json:"toolName"` + ToolArgs any `json:"toolArgs"` + ToolResult any `json:"toolResult"` } // MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. @@ -458,10 +458,10 @@ type PostToolUseHandler func(input PostToolUseHookInput, invocation HookInvocati // UserPromptSubmittedHookInput is the input for a user-prompt-submitted hook type UserPromptSubmittedHookInput struct { - SessionID string `json:"sessionId"` - Timestamp time.Time `json:"-"` - Cwd string `json:"cwd"` - Prompt string `json:"prompt"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + Prompt string `json:"prompt"` } // MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. @@ -499,11 +499,11 @@ type UserPromptSubmittedHandler func(input UserPromptSubmittedHookInput, invocat // SessionStartHookInput is the input for a session-start hook type SessionStartHookInput struct { - SessionID string `json:"sessionId"` - Timestamp time.Time `json:"-"` - Cwd string `json:"cwd"` - Source string `json:"source"` // "startup", "resume", "new" - InitialPrompt string `json:"initialPrompt,omitempty"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + Source string `json:"source"` // "startup", "resume", "new" + InitialPrompt string `json:"initialPrompt,omitempty"` } // MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. @@ -540,12 +540,12 @@ type SessionStartHandler func(input SessionStartHookInput, invocation HookInvoca // SessionEndHookInput is the input for a session-end hook type SessionEndHookInput struct { - SessionID string `json:"sessionId"` - Timestamp time.Time `json:"-"` - Cwd string `json:"cwd"` - Reason string `json:"reason"` // "complete", "error", "abort", "timeout", "user_exit" - FinalMessage string `json:"finalMessage,omitempty"` - Error string `json:"error,omitempty"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + Reason string `json:"reason"` // "complete", "error", "abort", "timeout", "user_exit" + FinalMessage string `json:"finalMessage,omitempty"` + Error string `json:"error,omitempty"` } // MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. @@ -583,12 +583,12 @@ type SessionEndHandler func(input SessionEndHookInput, invocation HookInvocation // ErrorOccurredHookInput is the input for an error-occurred hook type ErrorOccurredHookInput struct { - SessionID string `json:"sessionId"` - Timestamp time.Time `json:"-"` - Cwd string `json:"cwd"` - Error string `json:"error"` - ErrorContext string `json:"errorContext"` // "model_call", "tool_execution", "system", "user_input" - Recoverable bool `json:"recoverable"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + Error string `json:"error"` + ErrorContext string `json:"errorContext"` // "model_call", "tool_execution", "system", "user_input" + Recoverable bool `json:"recoverable"` } // MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. @@ -625,6 +625,49 @@ type ErrorOccurredHookOutput struct { // ErrorOccurredHandler handles error-occurred hook invocations type ErrorOccurredHandler func(input ErrorOccurredHookInput, invocation HookInvocation) (*ErrorOccurredHookOutput, error) +// PreMcpToolCallHookInput is the input for a pre-mcp-tool-call hook +type PreMcpToolCallHookInput struct { + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + ServerName string `json:"serverName"` + ToolName string `json:"toolName"` + Arguments any `json:"arguments,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + Meta any `json:"_meta,omitempty"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h PreMcpToolCallHookInput) MarshalJSON() ([]byte, error) { + type alias PreMcpToolCallHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *PreMcpToolCallHookInput) UnmarshalJSON(data []byte) error { + type alias PreMcpToolCallHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil +} + +// PreMcpToolCallHookOutput is the output for a pre-mcp-tool-call hook +type PreMcpToolCallHookOutput struct { + MetaToUse any `json:"metaToUse"` +} + +// PreMcpToolCallHandler handles pre-mcp-tool-call hook invocations +type PreMcpToolCallHandler func(input PreMcpToolCallHookInput, invocation HookInvocation) (*PreMcpToolCallHookOutput, error) + // HookInvocation provides context about a hook invocation type HookInvocation struct { SessionID string @@ -638,6 +681,7 @@ type SessionHooks struct { OnSessionStart SessionStartHandler OnSessionEnd SessionEndHandler OnErrorOccurred ErrorOccurredHandler + OnPreMcpToolCall PreMcpToolCallHandler } // MCPServerConfig is implemented by MCP server configuration types. @@ -663,7 +707,7 @@ type MCPStdioServerConfig struct { Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` - Cwd string `json:"cwd,omitempty"` + WorkingDirectory string `json:"cwd,omitempty"` } func (MCPStdioServerConfig) mcpServerConfig() {} diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 343fb42ec..4baf35c3e 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -67,8 +67,9 @@ function deserializeHookInput(raw: unknown): unknown { ) { return raw; } - const obj = raw as Record & { timestamp: number }; - return { ...obj, timestamp: new Date(obj.timestamp) }; + const obj = raw as Record & { timestamp: number; cwd?: string }; + const { cwd, ...rest } = obj; + return { ...rest, timestamp: new Date(obj.timestamp), workingDirectory: cwd }; } /** Assistant message event - the final response from the assistant. */ @@ -987,6 +988,7 @@ export class CopilotSession { const handlerMap: Record = { preToolUse: this.hooks.onPreToolUse as GenericHandler | undefined, + preMcpToolCall: this.hooks.onPreMcpToolCall as GenericHandler | undefined, postToolUse: this.hooks.onPostToolUse as GenericHandler | undefined, userPromptSubmitted: this.hooks.onUserPromptSubmitted as GenericHandler | undefined, sessionStart: this.hooks.onSessionStart as GenericHandler | undefined, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index ebf701685..ed262210a 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1010,7 +1010,7 @@ export interface BaseHookInput { sessionId: string; /** Time at which the hook event was emitted by the runtime. */ timestamp: Date; - cwd: string; + workingDirectory: string; } /** @@ -1040,6 +1040,38 @@ export type PreToolUseHandler = ( invocation: { sessionId: string } ) => Promise | PreToolUseHookOutput | void; +/** + * Input for pre-MCP-tool-call hook + */ +export interface PreMcpToolCallHookInput extends BaseHookInput { + toolCallId?: string; + serverName: string; + toolName: string; + arguments: unknown; + _meta?: Record; +} + +/** + * Output for pre-MCP-tool-call hook + */ +export interface PreMcpToolCallHookOutput { + /** + * Hook-controlled metadata to use for the outgoing MCP request. + * - undefined/absent: preserve the current request `_meta` + * - object: use this object as request `_meta` + * - null: omit `_meta` + */ + metaToUse?: Record | null; +} + +/** + * Handler for pre-MCP-tool-call hook + */ +export type PreMcpToolCallHandler = ( + input: PreMcpToolCallHookInput, + invocation: { sessionId: string } +) => Promise | PreMcpToolCallHookOutput | void; + /** * Input for post-tool-use hook */ @@ -1176,6 +1208,11 @@ export interface SessionHooks { */ onPreToolUse?: PreToolUseHandler; + /** + * Called before an MCP tool is called + */ + onPreMcpToolCall?: PreMcpToolCallHandler; + /** * Called after a tool is executed */ diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index 2eb585994..b68a642c8 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -38,7 +38,7 @@ describe("Extended session hooks", async () => { expect(sessionStartInputs.length).toBeGreaterThan(0); expect(sessionStartInputs[0].source).toBe("new"); expect(sessionStartInputs[0].timestamp).toBeInstanceOf(Date); - expect(sessionStartInputs[0].cwd).toBeDefined(); + expect(sessionStartInputs[0].workingDirectory).toBeDefined(); await session.disconnect(); }); @@ -63,7 +63,7 @@ describe("Extended session hooks", async () => { expect(userPromptInputs.length).toBeGreaterThan(0); expect(userPromptInputs[0].prompt).toContain("Say hello"); expect(userPromptInputs[0].timestamp).toBeInstanceOf(Date); - expect(userPromptInputs[0].cwd).toBeDefined(); + expect(userPromptInputs[0].workingDirectory).toBeDefined(); await session.disconnect(); }); @@ -103,7 +103,7 @@ describe("Extended session hooks", async () => { errorInputs.push(input); expect(invocation.sessionId).toBe(session.sessionId); expect(input.timestamp).toBeInstanceOf(Date); - expect(input.cwd).toBeDefined(); + expect(input.workingDirectory).toBeDefined(); expect(input.error).toBeDefined(); expect(["model_call", "tool_execution", "system", "user_input"]).toContain( input.errorContext @@ -165,7 +165,7 @@ describe("Extended session hooks", async () => { expect(inputs.length).toBeGreaterThan(0); expect(inputs[0].source).toBe("new"); - expect(inputs[0].cwd).toBeTruthy(); + expect(inputs[0].workingDirectory).toBeTruthy(); await session.disconnect(); }); diff --git a/python/copilot/session.py b/python/copilot/session.py index 82736cddd..0e66ef09f 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -620,7 +620,7 @@ class PreToolUseHookInput(TypedDict): sessionId: str timestamp: int - cwd: str + workingDirectory: str toolName: str toolArgs: Any @@ -641,12 +641,43 @@ class PreToolUseHookOutput(TypedDict, total=False): ] +class PreMcpToolCallHookInput(TypedDict): + """Input for pre-MCP-tool-call hook""" + + sessionId: str + timestamp: int + workingDirectory: str + serverName: str + toolName: str + arguments: Any + toolCallId: NotRequired[str] + _meta: NotRequired[dict[str, Any]] + + +class PreMcpToolCallHookOutput(TypedDict, total=False): + """Output for pre-MCP-tool-call hook. + + metaToUse semantics: + - Key absent: preserve the current request _meta + - Key present with None value: omit _meta from the request + - Key present with dict value: use this dict as request _meta + """ + + metaToUse: dict[str, Any] | None + + +PreMcpToolCallHandler = Callable[ + [PreMcpToolCallHookInput, dict[str, str]], + PreMcpToolCallHookOutput | None | Awaitable[PreMcpToolCallHookOutput | None], +] + + class PostToolUseHookInput(TypedDict): """Input for post-tool-use hook""" sessionId: str timestamp: int - cwd: str + workingDirectory: str toolName: str toolArgs: Any toolResult: Any @@ -671,7 +702,7 @@ class UserPromptSubmittedHookInput(TypedDict): sessionId: str timestamp: int - cwd: str + workingDirectory: str prompt: str @@ -694,7 +725,7 @@ class SessionStartHookInput(TypedDict): sessionId: str timestamp: int - cwd: str + workingDirectory: str source: Literal["startup", "resume", "new"] initialPrompt: NotRequired[str] @@ -717,7 +748,7 @@ class SessionEndHookInput(TypedDict): sessionId: str timestamp: int - cwd: str + workingDirectory: str reason: Literal["complete", "error", "abort", "timeout", "user_exit"] finalMessage: NotRequired[str] error: NotRequired[str] @@ -742,7 +773,7 @@ class ErrorOccurredHookInput(TypedDict): sessionId: str timestamp: int - cwd: str + workingDirectory: str error: str errorContext: Literal["model_call", "tool_execution", "system", "user_input"] recoverable: bool @@ -767,6 +798,7 @@ class SessionHooks(TypedDict, total=False): """Configuration for session hooks""" on_pre_tool_use: PreToolUseHandler + on_pre_mcp_tool_call: PreMcpToolCallHandler on_post_tool_use: PostToolUseHandler on_user_prompt_submitted: UserPromptSubmittedHandler on_session_start: SessionStartHandler @@ -2180,6 +2212,7 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: handler_map = { "preToolUse": hooks.get("on_pre_tool_use"), + "preMcpToolCall": hooks.get("on_pre_mcp_tool_call"), "postToolUse": hooks.get("on_post_tool_use"), "userPromptSubmitted": hooks.get("on_user_prompt_submitted"), "sessionStart": hooks.get("on_session_start"), @@ -2193,6 +2226,9 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: try: handler_start = time.perf_counter() + # Remap wire key "cwd" to public API key "workingDirectory" + if "cwd" in input_data: + input_data = {**input_data, "workingDirectory": input_data.pop("cwd")} result = handler(input_data, {"session_id": self.session_id}) if inspect.isawaitable(result): result = await result diff --git a/python/e2e/test_hooks_extended_e2e.py b/python/e2e/test_hooks_extended_e2e.py index fe6a0ea2a..dbaef75b0 100644 --- a/python/e2e/test_hooks_extended_e2e.py +++ b/python/e2e/test_hooks_extended_e2e.py @@ -61,7 +61,7 @@ async def on_session_start(input_data, invocation): await session.send_and_wait("Say hi") assert inputs assert inputs[0].get("source") == "new" - assert inputs[0].get("cwd") + assert inputs[0].get("workingDirectory") finally: await session.disconnect() diff --git a/rust/examples/hooks.rs b/rust/examples/hooks.rs index 86f6ceadc..f3e7e1f24 100644 --- a/rust/examples/hooks.rs +++ b/rust/examples/hooks.rs @@ -32,7 +32,7 @@ impl SessionHooks for AuditHooks { "[audit] session {} started (source={}, cwd={})", ctx.session_id, input.source, - input.cwd.display(), + input.working_directory.display(), ); HookOutput::SessionStart(SessionStartOutput { additional_context: Some("You are being audited. Be concise.".to_string()), diff --git a/rust/src/hooks.rs b/rust/src/hooks.rs index e224bab91..fedc6d98b 100644 --- a/rust/src/hooks.rs +++ b/rust/src/hooks.rs @@ -30,7 +30,8 @@ pub struct PreToolUseInput { /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. - pub cwd: PathBuf, + #[serde(rename = "cwd")] + pub working_directory: PathBuf, /// Name of the tool about to execute. pub tool_name: String, /// Arguments passed to the tool. @@ -58,6 +59,45 @@ pub struct PreToolUseOutput { pub suppress_output: Option, } +/// Input for the `preMcpToolCall` hook — received before an MCP tool call is dispatched. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PreMcpToolCallInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, + /// Unix timestamp (ms). + pub timestamp: i64, + /// Working directory. + #[serde(rename = "cwd")] + pub working_directory: PathBuf, + /// Name of the MCP server being called. + pub server_name: String, + /// Name of the MCP tool being called. + pub tool_name: String, + /// Arguments for the MCP tool call. + pub arguments: Value, + /// Tool call ID, if available. + #[serde(default)] + pub tool_call_id: Option, + /// MCP request metadata. + #[serde(default, rename = "_meta")] + pub meta: Option, +} + +/// Output for the `preMcpToolCall` hook. +/// +/// `meta_to_use` has tri-state semantics: +/// - `None`: field is absent in JSON, meaning preserve existing `_meta` +/// - `Some(Value::Null)`: serialized as JSON `null`, meaning omit `_meta` +/// - `Some(Value::Object(...))`: serialized as JSON object, meaning replace `_meta` +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreMcpToolCallOutput { + /// Hook-controlled metadata for the outgoing MCP request. + #[serde(skip_serializing_if = "Option::is_none")] + pub meta_to_use: Option, +} + /// Input for the `postToolUse` hook — received after a tool executes. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -67,7 +107,8 @@ pub struct PostToolUseInput { /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. - pub cwd: PathBuf, + #[serde(rename = "cwd")] + pub working_directory: PathBuf, /// Name of the tool that executed. pub tool_name: String, /// Arguments that were passed to the tool. @@ -100,7 +141,8 @@ pub struct UserPromptSubmittedInput { /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. - pub cwd: PathBuf, + #[serde(rename = "cwd")] + pub working_directory: PathBuf, /// The user's message text. pub prompt: String, } @@ -129,7 +171,8 @@ pub struct SessionStartInput { /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. - pub cwd: PathBuf, + #[serde(rename = "cwd")] + pub working_directory: PathBuf, /// How the session was started: `"startup"`, `"resume"`, or `"new"`. pub source: String, /// The first user message, if any. @@ -158,7 +201,8 @@ pub struct SessionEndInput { /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. - pub cwd: PathBuf, + #[serde(rename = "cwd")] + pub working_directory: PathBuf, /// Why the session ended: `"complete"`, `"error"`, `"abort"`, `"timeout"`, `"user_exit"`. pub reason: String, /// The last assistant message. @@ -193,7 +237,8 @@ pub struct ErrorOccurredInput { /// Unix timestamp (ms). pub timestamp: i64, /// Working directory. - pub cwd: PathBuf, + #[serde(rename = "cwd")] + pub working_directory: PathBuf, /// The error message. pub error: String, /// Context where the error occurred: `"model_call"`, `"tool_execution"`, `"system"`, `"user_input"`. @@ -235,6 +280,13 @@ pub enum HookEvent { /// Session context. ctx: HookContext, }, + /// Fired before an MCP tool call is dispatched. + PreMcpToolCall { + /// Typed input data. + input: PreMcpToolCallInput, + /// Session context. + ctx: HookContext, + }, /// Fired after a tool executes. PostToolUse { /// Typed input data. @@ -283,6 +335,8 @@ pub enum HookOutput { None, /// Response for a pre-tool-use hook. PreToolUse(PreToolUseOutput), + /// Response for a pre-MCP-tool-call hook. + PreMcpToolCall(PreMcpToolCallOutput), /// Response for a post-tool-use hook. PostToolUse(PostToolUseOutput), /// Response for a user-prompt-submitted hook. @@ -300,6 +354,7 @@ impl HookOutput { match self { Self::None => "None", Self::PreToolUse(_) => "PreToolUse", + Self::PreMcpToolCall(_) => "PreMcpToolCall", Self::PostToolUse(_) => "PostToolUse", Self::UserPromptSubmitted(_) => "UserPromptSubmitted", Self::SessionStart(_) => "SessionStart", @@ -338,6 +393,11 @@ pub trait SessionHooks: Send + Sync + 'static { .await .map(HookOutput::PreToolUse) .unwrap_or(HookOutput::None), + HookEvent::PreMcpToolCall { input, ctx } => self + .on_pre_mcp_tool_call(input, ctx) + .await + .map(HookOutput::PreMcpToolCall) + .unwrap_or(HookOutput::None), HookEvent::PostToolUse { input, ctx } => self .on_post_tool_use(input, ctx) .await @@ -376,6 +436,16 @@ pub trait SessionHooks: Send + Sync + 'static { None } + /// Called before an MCP tool call is dispatched. Return `Some(output)` to + /// modify or remove request metadata, or `None` (default) to pass through unchanged. + async fn on_pre_mcp_tool_call( + &self, + _input: PreMcpToolCallInput, + _ctx: HookContext, + ) -> Option { + None + } + /// Called after a tool executes. Return `Some(output)` to inject /// additional context or signal post-processing decisions; `None` /// (default) means no follow-up. @@ -449,6 +519,10 @@ pub(crate) async fn dispatch_hook( let input: PreToolUseInput = serde_json::from_value(raw_input)?; HookEvent::PreToolUse { input, ctx } } + "preMcpToolCall" => { + let input: PreMcpToolCallInput = serde_json::from_value(raw_input)?; + HookEvent::PreMcpToolCall { input, ctx } + } "postToolUse" => { let input: PostToolUseInput = serde_json::from_value(raw_input)?; HookEvent::PostToolUse { input, ctx } @@ -495,6 +569,7 @@ pub(crate) async fn dispatch_hook( let output_value = match (hook_type, &output) { (_, HookOutput::None) => None, ("preToolUse", HookOutput::PreToolUse(o)) => Some(serde_json::to_value(o)?), + ("preMcpToolCall", HookOutput::PreMcpToolCall(o)) => Some(serde_json::to_value(o)?), ("postToolUse", HookOutput::PostToolUse(o)) => Some(serde_json::to_value(o)?), ("userPromptSubmitted", HookOutput::UserPromptSubmitted(o)) => { Some(serde_json::to_value(o)?) diff --git a/rust/tests/e2e/hooks_extended.rs b/rust/tests/e2e/hooks_extended.rs index e73b82aa5..f36c1cfaf 100644 --- a/rust/tests/e2e/hooks_extended.rs +++ b/rust/tests/e2e/hooks_extended.rs @@ -36,7 +36,7 @@ async fn should_invoke_onsessionstart_hook_on_new_session() { let input = recv_with_timeout(&mut rx, "sessionStart hook").await; assert_eq!(input.source, "new"); assert!(input.timestamp > 0); - assert!(!input.cwd.as_os_str().is_empty()); + assert!(!input.working_directory.as_os_str().is_empty()); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); @@ -68,7 +68,7 @@ async fn should_invoke_onuserpromptsubmitted_hook_when_sending_a_message() { let input = recv_with_timeout(&mut rx, "userPromptSubmitted hook").await; assert!(input.prompt.contains("Say hello")); assert!(input.timestamp > 0); - assert!(!input.cwd.as_os_str().is_empty()); + assert!(!input.working_directory.as_os_str().is_empty()); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); @@ -100,7 +100,7 @@ async fn should_invoke_onsessionend_hook_when_session_is_disconnected() { session.disconnect().await.expect("disconnect session"); let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; assert!(input.timestamp > 0); - assert!(!input.cwd.as_os_str().is_empty()); + assert!(!input.working_directory.as_os_str().is_empty()); client.stop().await.expect("stop client"); }) From d0e2621a9ddb7dec74424d8dfa0ab98bd81d07f6 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 13:24:15 -0400 Subject: [PATCH 02/15] Add preMcpToolCall hook E2E tests for Node.js, Python, Go, and Rust Port the three preMcpToolCall hook test scenarios (set meta, replace meta, remove meta) from the .NET reference implementation to all four remaining SDK test suites. Each test: - Configures an MCP stdio server (meta-echo) that echoes _meta back - Registers a preMcpToolCall hook that sets/replaces/removes metadata - Verifies the tool result reflects the hook's effect - Asserts hook input fields (serverName, toolName, workingDirectory, timestamp) Snapshot files are reused from test/snapshots/pre_mcp_tool_call_hook/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../e2e/pre_mcp_tool_call_hook_e2e_test.go | 207 ++++++++++++++++ .../e2e/pre_mcp_tool_call_hook.e2e.test.ts | 132 ++++++++++ python/e2e/test_pre_mcp_tool_call_hook_e2e.py | 116 +++++++++ rust/tests/e2e.rs | 2 + rust/tests/e2e/pre_mcp_tool_call_hook.rs | 234 ++++++++++++++++++ 5 files changed, 691 insertions(+) create mode 100644 go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go create mode 100644 nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts create mode 100644 python/e2e/test_pre_mcp_tool_call_hook_e2e.py create mode 100644 rust/tests/e2e/pre_mcp_tool_call_hook.rs diff --git a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go new file mode 100644 index 000000000..4426cc236 --- /dev/null +++ b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go @@ -0,0 +1,207 @@ +package e2e + +import ( + "path/filepath" + "strings" + "sync" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func TestPreMcpToolCallHookE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + testHarnessDir, _ := filepath.Abs("../../../test/harness") + metaEchoServer := filepath.Join(testHarnessDir, "test-mcp-meta-echo-server.mjs") + + metaEchoConfig := func() map[string]copilot.MCPServerConfig { + return map[string]copilot.MCPServerConfig{ + "meta-echo": copilot.MCPStdioServerConfig{ + Command: "node", + Args: []string{metaEchoServer}, + WorkingDirectory: testHarnessDir, + Tools: []string{"*"}, + }, + } + } + + t.Run("should set meta via preMcpToolCall hook", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.PreMcpToolCallHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: metaEchoConfig(), + Hooks: &copilot.SessionHooks{ + OnPreMcpToolCall: func(input copilot.PreMcpToolCallHookInput, invocation copilot.HookInvocation) (*copilot.PreMcpToolCallHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + return &copilot.PreMcpToolCallHookOutput{ + MetaToUse: copilot.MetaToUseReplace(map[string]any{ + "injected": "by-hook", + "source": "test", + }), + }, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok { + t.Fatalf("Expected assistant message data, got %T", response.Data) + } + if !strings.Contains(assistantMessage.Content, "injected") || !strings.Contains(assistantMessage.Content, "by-hook") { + t.Errorf("Expected response to contain 'injected' and 'by-hook', got %q", assistantMessage.Content) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected at least one preMcpToolCall hook invocation") + } + if inputs[0].ServerName != "meta-echo" { + t.Errorf("Expected serverName 'meta-echo', got %q", inputs[0].ServerName) + } + if inputs[0].ToolName != "echo_meta" { + t.Errorf("Expected toolName 'echo_meta', got %q", inputs[0].ToolName) + } + if inputs[0].WorkingDirectory == "" { + t.Error("Expected non-empty workingDirectory") + } + if inputs[0].Timestamp <= 0 { + t.Error("Expected positive timestamp") + } + }) + + t.Run("should replace meta via preMcpToolCall hook", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.PreMcpToolCallHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: metaEchoConfig(), + Hooks: &copilot.SessionHooks{ + OnPreMcpToolCall: func(input copilot.PreMcpToolCallHookInput, invocation copilot.HookInvocation) (*copilot.PreMcpToolCallHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + return &copilot.PreMcpToolCallHookOutput{ + MetaToUse: copilot.MetaToUseReplace(map[string]any{ + "completely": "replaced", + }), + }, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok { + t.Fatalf("Expected assistant message data, got %T", response.Data) + } + if !strings.Contains(assistantMessage.Content, "completely") || !strings.Contains(assistantMessage.Content, "replaced") { + t.Errorf("Expected response to contain 'completely' and 'replaced', got %q", assistantMessage.Content) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected at least one preMcpToolCall hook invocation") + } + if inputs[0].ServerName != "meta-echo" { + t.Errorf("Expected serverName 'meta-echo', got %q", inputs[0].ServerName) + } + if inputs[0].ToolName != "echo_meta" { + t.Errorf("Expected toolName 'echo_meta', got %q", inputs[0].ToolName) + } + }) + + t.Run("should remove meta via preMcpToolCall hook", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.PreMcpToolCallHookInput + ) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: metaEchoConfig(), + Hooks: &copilot.SessionHooks{ + OnPreMcpToolCall: func(input copilot.PreMcpToolCallHookInput, invocation copilot.HookInvocation) (*copilot.PreMcpToolCallHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + return &copilot.PreMcpToolCallHookOutput{ + MetaToUse: copilot.MetaToUseRemove(), + }, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok { + t.Fatalf("Expected assistant message data, got %T", response.Data) + } + if !strings.Contains(assistantMessage.Content, `"meta":null`) { + t.Errorf("Expected response to contain '\"meta\":null', got %q", assistantMessage.Content) + } + if !strings.Contains(assistantMessage.Content, "test-remove") { + t.Errorf("Expected response to contain 'test-remove', got %q", assistantMessage.Content) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected at least one preMcpToolCall hook invocation") + } + if inputs[0].ServerName != "meta-echo" { + t.Errorf("Expected serverName 'meta-echo', got %q", inputs[0].ServerName) + } + if inputs[0].ToolName != "echo_meta" { + t.Errorf("Expected toolName 'echo_meta', got %q", inputs[0].ToolName) + } + }) +} diff --git a/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts b/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts new file mode 100644 index 000000000..2620e9496 --- /dev/null +++ b/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import type { MCPStdioServerConfig, PreMcpToolCallHookInput } from "../../src/types.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const TEST_MCP_META_ECHO_SERVER = resolve( + __dirname, + "../../../test/harness/test-mcp-meta-echo-server.mjs" +); +const TEST_HARNESS_DIR = dirname(TEST_MCP_META_ECHO_SERVER); + +describe("pre_mcp_tool_call_hook", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should set meta via preMcpToolCall hook", async () => { + const hookInputs: PreMcpToolCallHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: { + "meta-echo": { + command: "node", + args: [TEST_MCP_META_ECHO_SERVER], + cwd: TEST_HARNESS_DIR, + tools: ["*"], + } as MCPStdioServerConfig, + }, + hooks: { + onPreMcpToolCall: async (input, invocation) => { + hookInputs.push(input); + return { metaToUse: { injected: "by-hook", source: "test" } }; + }, + }, + }); + + const message = await session.sendAndWait({ + prompt: "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.", + }); + + expect(message).not.toBeNull(); + expect(message!.data.content).toContain("injected"); + expect(message!.data.content).toContain("by-hook"); + + expect(hookInputs.length).toBeGreaterThan(0); + expect(hookInputs[0].serverName).toBe("meta-echo"); + expect(hookInputs[0].toolName).toBe("echo_meta"); + expect(hookInputs[0].workingDirectory).toBeDefined(); + expect(hookInputs[0].timestamp).toBeInstanceOf(Date); + + await session.disconnect(); + }); + + it("should replace meta via preMcpToolCall hook", async () => { + const hookInputs: PreMcpToolCallHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: { + "meta-echo": { + command: "node", + args: [TEST_MCP_META_ECHO_SERVER], + cwd: TEST_HARNESS_DIR, + tools: ["*"], + } as MCPStdioServerConfig, + }, + hooks: { + onPreMcpToolCall: async (input, invocation) => { + hookInputs.push(input); + return { metaToUse: { completely: "replaced" } }; + }, + }, + }); + + const message = await session.sendAndWait({ + prompt: "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.", + }); + + expect(message).not.toBeNull(); + expect(message!.data.content).toContain("completely"); + expect(message!.data.content).toContain("replaced"); + + expect(hookInputs.length).toBeGreaterThan(0); + expect(hookInputs[0].serverName).toBe("meta-echo"); + expect(hookInputs[0].toolName).toBe("echo_meta"); + + await session.disconnect(); + }); + + it("should remove meta via preMcpToolCall hook", async () => { + const hookInputs: PreMcpToolCallHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: { + "meta-echo": { + command: "node", + args: [TEST_MCP_META_ECHO_SERVER], + cwd: TEST_HARNESS_DIR, + tools: ["*"], + } as MCPStdioServerConfig, + }, + hooks: { + onPreMcpToolCall: async (input, invocation) => { + hookInputs.push(input); + return { metaToUse: null }; + }, + }, + }); + + const message = await session.sendAndWait({ + prompt: "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.", + }); + + expect(message).not.toBeNull(); + expect(message!.data.content).toContain('"meta":null'); + expect(message!.data.content).toContain("test-remove"); + + expect(hookInputs.length).toBeGreaterThan(0); + expect(hookInputs[0].serverName).toBe("meta-echo"); + expect(hookInputs[0].toolName).toBe("echo_meta"); + + await session.disconnect(); + }); +}); diff --git a/python/e2e/test_pre_mcp_tool_call_hook_e2e.py b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py new file mode 100644 index 000000000..1bae58ec3 --- /dev/null +++ b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py @@ -0,0 +1,116 @@ +""" +E2E tests for the preMcpToolCall hook, verifying meta manipulation scenarios: +setting meta, replacing meta, and removing meta. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from copilot.session import MCPServerConfig, PermissionHandler + +from .testharness import E2ETestContext + +TEST_MCP_META_ECHO_SERVER = str( + (Path(__file__).parents[2] / "test" / "harness" / "test-mcp-meta-echo-server.mjs").resolve() +) +TEST_HARNESS_DIR = str((Path(__file__).parents[2] / "test" / "harness").resolve()) + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def meta_echo_mcp_config() -> dict[str, MCPServerConfig]: + return { + "meta-echo": { + "command": "node", + "args": [TEST_MCP_META_ECHO_SERVER], + "cwd": TEST_HARNESS_DIR, + "tools": ["*"], + } + } + + +class TestPreMcpToolCallHook: + async def test_should_set_meta_via_premcptoolcall_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + + async def on_pre_mcp_tool_call(input_data, invocation): + inputs.append(input_data) + return {"metaToUse": {"injected": "by-hook", "source": "test"}} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=meta_echo_mcp_config(), + hooks={"on_pre_mcp_tool_call": on_pre_mcp_tool_call}, + ) + try: + response = await session.send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result." + ) + assert response is not None + assert "injected" in (response.data.content or "") + assert "by-hook" in (response.data.content or "") + + assert inputs + assert inputs[0].get("serverName") == "meta-echo" + assert inputs[0].get("toolName") == "echo_meta" + assert inputs[0].get("workingDirectory") + assert inputs[0].get("timestamp", 0) > 0 + finally: + await session.disconnect() + + async def test_should_replace_meta_via_premcptoolcall_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + + async def on_pre_mcp_tool_call(input_data, invocation): + inputs.append(input_data) + return {"metaToUse": {"completely": "replaced"}} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=meta_echo_mcp_config(), + hooks={"on_pre_mcp_tool_call": on_pre_mcp_tool_call}, + ) + try: + response = await session.send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result." + ) + assert response is not None + assert "completely" in (response.data.content or "") + assert "replaced" in (response.data.content or "") + + assert inputs + assert inputs[0].get("serverName") == "meta-echo" + assert inputs[0].get("toolName") == "echo_meta" + finally: + await session.disconnect() + + async def test_should_remove_meta_via_premcptoolcall_hook(self, ctx: E2ETestContext): + inputs: list[dict] = [] + + async def on_pre_mcp_tool_call(input_data, invocation): + inputs.append(input_data) + return {"metaToUse": None} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=meta_echo_mcp_config(), + hooks={"on_pre_mcp_tool_call": on_pre_mcp_tool_call}, + ) + try: + response = await session.send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result." + ) + assert response is not None + assert '"meta":null' in (response.data.content or "") or '"meta": null' in ( + response.data.content or "" + ) + assert "test-remove" in (response.data.content or "") + + assert inputs + assert inputs[0].get("serverName") == "meta-echo" + assert inputs[0].get("toolName") == "echo_meta" + finally: + await session.disconnect() diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 7a4bd4b04..98f0fde3c 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -41,6 +41,8 @@ mod multi_client_commands_elicitation; mod multi_turn; #[path = "e2e/pending_work_resume.rs"] mod pending_work_resume; +#[path = "e2e/pre_mcp_tool_call_hook.rs"] +mod pre_mcp_tool_call_hook; #[path = "e2e/per_session_auth.rs"] mod per_session_auth; #[path = "e2e/permissions.rs"] diff --git a/rust/tests/e2e/pre_mcp_tool_call_hook.rs b/rust/tests/e2e/pre_mcp_tool_call_hook.rs new file mode 100644 index 000000000..204da89cf --- /dev/null +++ b/rust/tests/e2e/pre_mcp_tool_call_hook.rs @@ -0,0 +1,234 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use github_copilot_sdk::hooks::{ + HookContext, PreMcpToolCallInput, PreMcpToolCallOutput, SessionHooks, +}; +use github_copilot_sdk::{McpServerConfig, McpStdioServerConfig}; +use serde_json::{json, Value}; +use tokio::sync::mpsc; + +use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; + +fn meta_echo_mcp_servers(repo_root: &std::path::Path) -> HashMap { + let harness_dir = repo_root.join("test").join("harness"); + let server_path = harness_dir + .join("test-mcp-meta-echo-server.mjs") + .to_string_lossy() + .to_string(); + HashMap::from([( + "meta-echo".to_string(), + McpServerConfig::Stdio(McpStdioServerConfig { + tools: vec!["*".to_string()], + command: if cfg!(windows) { + "node.exe".to_string() + } else { + "node".to_string() + }, + args: vec![server_path], + cwd: Some(harness_dir.to_string_lossy().to_string()), + ..McpStdioServerConfig::default() + }), + )]) +} + +struct SetMetaHooks { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHooks for SetMetaHooks { + async fn on_pre_mcp_tool_call( + &self, + input: PreMcpToolCallInput, + _ctx: HookContext, + ) -> Option { + let _ = self.tx.send(input); + Some(PreMcpToolCallOutput { + meta_to_use: Some(json!({"injected": "by-hook", "source": "test"})), + }) + } +} + +struct ReplaceMetaHooks { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHooks for ReplaceMetaHooks { + async fn on_pre_mcp_tool_call( + &self, + input: PreMcpToolCallInput, + _ctx: HookContext, + ) -> Option { + let _ = self.tx.send(input); + Some(PreMcpToolCallOutput { + meta_to_use: Some(json!({"completely": "replaced"})), + }) + } +} + +struct RemoveMetaHooks { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHooks for RemoveMetaHooks { + async fn on_pre_mcp_tool_call( + &self, + input: PreMcpToolCallInput, + _ctx: HookContext, + ) -> Option { + let _ = self.tx.send(input); + Some(PreMcpToolCallOutput { + meta_to_use: Some(Value::Null), + }) + } +} + +#[tokio::test] +async fn should_set_meta_via_premcptoolcall_hook() { + with_e2e_context( + "pre_mcp_tool_call_hook", + "should_set_meta_via_premcptoolcall_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root())) + .with_hooks(Arc::new(SetMetaHooks { tx })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.", + ) + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!( + content.contains("injected"), + "Expected 'injected' in response, got: {content}" + ); + assert!( + content.contains("by-hook"), + "Expected 'by-hook' in response, got: {content}" + ); + + let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await; + assert_eq!(input.server_name, "meta-echo"); + assert_eq!(input.tool_name, "echo_meta"); + assert!(!input.working_directory.as_os_str().is_empty()); + assert!(input.timestamp > 0); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_replace_meta_via_premcptoolcall_hook() { + with_e2e_context( + "pre_mcp_tool_call_hook", + "should_replace_meta_via_premcptoolcall_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root())) + .with_hooks(Arc::new(ReplaceMetaHooks { tx })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.", + ) + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!( + content.contains("completely"), + "Expected 'completely' in response, got: {content}" + ); + assert!( + content.contains("replaced"), + "Expected 'replaced' in response, got: {content}" + ); + + let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await; + assert_eq!(input.server_name, "meta-echo"); + assert_eq!(input.tool_name, "echo_meta"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_remove_meta_via_premcptoolcall_hook() { + with_e2e_context( + "pre_mcp_tool_call_hook", + "should_remove_meta_via_premcptoolcall_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_mcp_servers(meta_echo_mcp_servers(ctx.repo_root())) + .with_hooks(Arc::new(RemoveMetaHooks { tx })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait( + "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.", + ) + .await + .expect("send") + .expect("assistant message"); + let content = assistant_message_content(&answer); + assert!( + content.contains("\"meta\":null"), + "Expected '\"meta\":null' in response, got: {content}" + ); + assert!( + content.contains("test-remove"), + "Expected 'test-remove' in response, got: {content}" + ); + + let input = recv_with_timeout(&mut rx, "preMcpToolCall hook").await; + assert_eq!(input.server_name, "meta-echo"); + assert_eq!(input.tool_name, "echo_meta"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} From ff1c65de7761c6f3c807a8192fbe1b65ad6104fc Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 13:42:03 -0400 Subject: [PATCH 03/15] Fix code review feedback and add .NET E2E test infrastructure - Add OnPreMcpToolCall to hasHooks checks in .NET Client.cs and Go client.go - Add SerializeHookOutput helper for source-gen serialization - Add .NET PreMcpToolCallHookE2ETests (3 tests: set, replace, remove meta) - Add MCP meta-echo test server and snapshot YAML files - Fix Go mcp_and_agents_e2e_test.go (Cwd -> WorkingDirectory) - Remove stale dead_code lint expectation in Rust support.rs - Add serialization unit tests for hook output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 2 + dotnet/src/Session.cs | 12 +- dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs | 164 ++++++++++++++ dotnet/test/Unit/SerializationTests.cs | 100 ++++++++ go/client.go | 2 + go/internal/e2e/client_options_e2e_test.go | 8 +- go/internal/e2e/mcp_and_agents_e2e_test.go | 10 +- .../e2e/pre_mcp_tool_call_hook_e2e_test.go | 17 +- go/internal/e2e/session_e2e_test.go | 8 +- go/types.go | 10 +- java/.lastmerge | 2 +- .../github/copilot/sdk/CopilotSession.java | 8 + .../sdk/json/PostToolUseHookInput.java | 12 +- .../sdk/json/PreMcpToolCallHandler.java | 32 +++ .../sdk/json/PreMcpToolCallHookInput.java | 213 ++++++++++++++++++ .../sdk/json/PreMcpToolCallHookOutput.java | 30 +++ .../copilot/sdk/json/PreToolUseHookInput.java | 12 +- .../copilot/sdk/json/SessionEndHookInput.java | 4 +- .../github/copilot/sdk/json/SessionHooks.java | 28 ++- .../sdk/json/SessionStartHookInput.java | 4 +- .../json/UserPromptSubmittedHookInput.java | 4 +- .../copilot/sdk/DataObjectCoverageTest.java | 4 +- .../copilot/sdk/PreMcpToolCallHookTest.java | 208 +++++++++++++++++ .../copilot/sdk/SessionHandlerTest.java | 2 +- .../e2e/pre_mcp_tool_call_hook.e2e.test.ts | 6 +- python/e2e/test_pre_mcp_tool_call_hook_e2e.py | 9 +- rust/tests/e2e.rs | 4 +- rust/tests/e2e/pre_mcp_tool_call_hook.rs | 2 +- rust/tests/e2e/support.rs | 1 - test/harness/replayingCapiProxy.ts | 11 +- test/harness/test-mcp-meta-echo-server.mjs | 64 ++++++ ...d_remove_meta_via_premcptoolcall_hook.yaml | 20 ++ ..._replace_meta_via_premcptoolcall_hook.yaml | 20 ++ ...ould_set_meta_via_premcptoolcall_hook.yaml | 20 ++ 34 files changed, 990 insertions(+), 63 deletions(-) create mode 100644 dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs create mode 100644 java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHandler.java create mode 100644 java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHookInput.java create mode 100644 java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHookOutput.java create mode 100644 java/src/test/java/com/github/copilot/sdk/PreMcpToolCallHookTest.java create mode 100644 test/harness/test-mcp-meta-echo-server.mjs create mode 100644 test/snapshots/pre_mcp_tool_call_hook/should_remove_meta_via_premcptoolcall_hook.yaml create mode 100644 test/snapshots/pre_mcp_tool_call_hook/should_replace_meta_via_premcptoolcall_hook.yaml create mode 100644 test/snapshots/pre_mcp_tool_call_hook/should_set_meta_via_premcptoolcall_hook.yaml diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 11f8c90c9..07814ca75 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -528,6 +528,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || + config.Hooks.OnPreMcpToolCall != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || config.Hooks.OnSessionStart != null || @@ -688,6 +689,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || + config.Hooks.OnPreMcpToolCall != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || config.Hooks.OnSessionStart != null || diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 21f184438..0916a7b21 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -1246,9 +1246,9 @@ internal void RegisterHooks(SessionHooks hooks) invocation) : null, "preMcpToolCall" => hooks.OnPreMcpToolCall != null - ? await hooks.OnPreMcpToolCall( + ? SerializeHookOutput(await hooks.OnPreMcpToolCall( JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreMcpToolCallHookInput)!, - invocation) + invocation)) : null, "postToolUse" => hooks.OnPostToolUse != null ? await hooks.OnPostToolUse( @@ -1288,6 +1288,14 @@ internal void RegisterHooks(SessionHooks hooks) } } + /// + /// Pre-serializes a hook output to JsonElement so that the object? typed + /// property writes the + /// correct JSON without relying on polymorphic type resolution. + /// + private static JsonElement? SerializeHookOutput(PreMcpToolCallHookOutput? output) => + output is null ? null : JsonSerializer.SerializeToElement(output, SessionJsonContext.Default.PreMcpToolCallHookOutput); + /// /// Registers transform callbacks for system message sections. /// diff --git a/dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs b/dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs new file mode 100644 index 000000000..f825c4254 --- /dev/null +++ b/dotnet/test/E2E/PreMcpToolCallHookE2ETests.cs @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.Json; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +/// +/// E2E tests for the preMcpToolCall hook, verifying meta manipulation scenarios: +/// setting meta, replacing meta, and removing meta. +/// +public class PreMcpToolCallHookE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "pre_mcp_tool_call_hook", output) +{ + private static string FindTestHarnessDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, "test", "harness", "test-mcp-meta-echo-server.mjs"); + if (File.Exists(candidate)) + return Path.GetDirectoryName(candidate)!; + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find test/harness/test-mcp-meta-echo-server.mjs"); + } + + private static Dictionary CreateMetaEchoMcpConfig(string testHarnessDir) => new() + { + ["meta-echo"] = new McpStdioServerConfig + { + Command = "node", + Args = [Path.Combine(testHarnessDir, "test-mcp-meta-echo-server.mjs")], + WorkingDirectory = testHarnessDir, + Tools = ["*"] + } + }; + + [Fact] + public async Task Should_Set_Meta_Via_PreMcpToolCall_Hook() + { + var testHarnessDir = FindTestHarnessDir(); + var hookInputs = new List(); + + var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateMetaEchoMcpConfig(testHarnessDir), + Hooks = new SessionHooks + { + OnPreMcpToolCall = (input, invocation) => + { + hookInputs.Add(input); + using var doc = JsonDocument.Parse("""{"injected":"by-hook","source":"test"}"""); + return Task.FromResult(new PreMcpToolCallHookOutput + { + MetaToUse = doc.RootElement.Clone() + }); + }, + }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var message = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result." + }); + + Assert.NotNull(message); + Assert.Contains("injected", message!.Data.Content); + Assert.Contains("by-hook", message.Data.Content); + + Assert.NotEmpty(hookInputs); + Assert.Equal("meta-echo", hookInputs[0].ServerName); + Assert.Equal("echo_meta", hookInputs[0].ToolName); + Assert.False(string.IsNullOrEmpty(hookInputs[0].WorkingDirectory)); + Assert.True(hookInputs[0].Timestamp > DateTimeOffset.UnixEpoch); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Replace_Meta_Via_PreMcpToolCall_Hook() + { + var testHarnessDir = FindTestHarnessDir(); + var hookInputs = new List(); + + var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateMetaEchoMcpConfig(testHarnessDir), + Hooks = new SessionHooks + { + OnPreMcpToolCall = (input, invocation) => + { + hookInputs.Add(input); + // Completely replace: ignore input.Meta entirely + using var doc = JsonDocument.Parse("""{"completely":"replaced"}"""); + return Task.FromResult(new PreMcpToolCallHookOutput + { + MetaToUse = doc.RootElement.Clone() + }); + }, + }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var message = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result." + }); + + Assert.NotNull(message); + Assert.Contains("completely", message!.Data.Content); + Assert.Contains("replaced", message.Data.Content); + + Assert.NotEmpty(hookInputs); + Assert.Equal("meta-echo", hookInputs[0].ServerName); + Assert.Equal("echo_meta", hookInputs[0].ToolName); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Remove_Meta_Via_PreMcpToolCall_Hook() + { + var testHarnessDir = FindTestHarnessDir(); + var hookInputs = new List(); + + var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateMetaEchoMcpConfig(testHarnessDir), + Hooks = new SessionHooks + { + OnPreMcpToolCall = (input, invocation) => + { + hookInputs.Add(input); + // Return output with null MetaToUse to signal removal + return Task.FromResult(new PreMcpToolCallHookOutput + { + MetaToUse = null + }); + }, + }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var message = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result." + }); + + Assert.NotNull(message); + Assert.Contains("\"meta\":null", message!.Data.Content); + Assert.Contains("test-remove", message.Data.Content); + + Assert.NotEmpty(hookInputs); + Assert.Equal("meta-echo", hookInputs[0].ServerName); + Assert.Equal("echo_meta", hookInputs[0].ToolName); + + await session.DisposeAsync(); + } +} diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index c361987dd..6a3802d0c 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -306,6 +306,75 @@ public void PermissionDecision_SerializesBaseDiscriminator_WithSdkOptions() Assert.Equal("approve-once", document.RootElement.GetProperty("kind").GetString()); } + [Fact] + public void HooksInvokeResponse_SerializesPreMcpToolCallHookOutput_WithMetaToUse() + { + var options = GetSerializerOptions(); + + // Create the PreMcpToolCallHookOutput with meta + using var doc = JsonDocument.Parse("""{"injected":"by-hook","source":"test"}"""); + var meta = doc.RootElement.Clone(); + var hookOutput = new PreMcpToolCallHookOutput { MetaToUse = meta }; + + // Create the HooksInvokeResponse using reflection (it's internal) + var responseType = GetNestedType(typeof(CopilotClient), "HooksInvokeResponse"); + var response = CreateInternalRequest(responseType, ("Output", hookOutput)); + + // Serialize using the exact same path as SendResultResponseAsync + var typeInfo = options.GetTypeInfo(response.GetType()); + var json = JsonSerializer.SerializeToElement(response, typeInfo); + + // The JSON should be {"output":{"metaToUse":{"injected":"by-hook","source":"test"}}} + Assert.True(json.TryGetProperty("output", out var outputProp), $"Expected 'output' property. Got: {json}"); + Assert.True(outputProp.TryGetProperty("metaToUse", out var metaToUseProp), $"Expected 'metaToUse' property. Got: {outputProp}"); + Assert.Equal("by-hook", metaToUseProp.GetProperty("injected").GetString()); + Assert.Equal("test", metaToUseProp.GetProperty("source").GetString()); + } + + [Fact] + public void HooksInvokeResponse_SerializesPreMcpToolCallHookOutput_WithNullMetaToUse() + { + var options = GetSerializerOptions(); + + // Create the PreMcpToolCallHookOutput with null meta (remove meta) + var hookOutput = new PreMcpToolCallHookOutput { MetaToUse = null }; + + // Create the HooksInvokeResponse using reflection (it's internal) + var responseType = GetNestedType(typeof(CopilotClient), "HooksInvokeResponse"); + var response = CreateInternalRequest(responseType, ("Output", hookOutput)); + + // Serialize + var typeInfo = options.GetTypeInfo(response.GetType()); + var json = JsonSerializer.SerializeToElement(response, typeInfo); + + // Should be {"output":{"metaToUse":null}} + Assert.True(json.TryGetProperty("output", out var outputProp), $"Expected 'output' property. Got: {json}"); + Assert.True(outputProp.TryGetProperty("metaToUse", out var metaToUseProp), $"Expected 'metaToUse' property. Got: {outputProp}"); + Assert.Equal(JsonValueKind.Null, metaToUseProp.ValueKind); + } + + [Fact] + public void HooksInvokeResponse_SerializesNullOutput_AsEmptyOrNoOutputProperty() + { + var options = GetSerializerOptions(); + + // Create the HooksInvokeResponse with null Output (preserve meta) + var responseType = GetNestedType(typeof(CopilotClient), "HooksInvokeResponse"); + var response = CreateInternalRequest(responseType, ("Output", (object?)null)); + + // Serialize + var typeInfo = options.GetTypeInfo(response.GetType()); + var json = JsonSerializer.SerializeToElement(response, typeInfo); + + // With WhenWritingNull, output property should be omitted when null + // OR if present, should be null + if (json.TryGetProperty("output", out var outputProp)) + { + Assert.Equal(JsonValueKind.Null, outputProp.ValueKind); + } + // else: property omitted, which is fine (runtime treats undefined output as no-op) + } + private static JsonSerializerOptions GetSerializerOptions() { var prop = typeof(CopilotClient) @@ -324,6 +393,37 @@ private static Type GetNestedType(Type containingType, string name) return type!; } + [Fact] + public void HooksInvokeResponse_SerializesBoxedJsonElement_AsOutput() + { + // This tests the EXACT path used by SerializeHookOutput: + // PreMcpToolCallHookOutput -> serialize to JsonElement -> box as object? in HooksInvokeResponse.Output + var options = GetSerializerOptions(); + + using var metaDoc = JsonDocument.Parse("""{"injected":"by-hook","source":"test"}"""); + var hookOutput = new PreMcpToolCallHookOutput + { + MetaToUse = metaDoc.RootElement.Clone() + }; + // SerializeHookOutput returns a JsonElement (value type) + var hookTypeInfo = options.GetTypeInfo(typeof(PreMcpToolCallHookOutput)); + JsonElement serializedOutput = JsonSerializer.SerializeToElement(hookOutput, hookTypeInfo); + + // HooksInvokeResponse stores this as object? (boxed JsonElement) + var responseType = GetNestedType(typeof(CopilotClient), "HooksInvokeResponse"); + var response = CreateInternalRequest(responseType, ("Output", (object)serializedOutput)); + + // Serialize via GetTypeInfo(response.GetType()) — same as SendResultResponseAsync + var typeInfo = options.GetTypeInfo(response.GetType()); + var json = JsonSerializer.SerializeToElement(response, typeInfo); + + // Expected: {"output":{"metaToUse":{"injected":"by-hook","source":"test"}}} + Assert.True(json.TryGetProperty("output", out var outputProp), $"Expected 'output'. Got: {json}"); + Assert.True(outputProp.TryGetProperty("metaToUse", out var metaToUseProp), $"Expected 'metaToUse' in output. Got: {outputProp}"); + Assert.Equal("by-hook", metaToUseProp.GetProperty("injected").GetString()); + Assert.Equal("test", metaToUseProp.GetProperty("source").GetString()); + } + private static object CreateInternalRequest(Type type, params (string Name, object? Value)[] properties) { #if NET8_0_OR_GREATER diff --git a/go/client.go b/go/client.go index dab09a4dd..4f8eb43e5 100644 --- a/go/client.go +++ b/go/client.go @@ -658,6 +658,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.RequestUserInput = Bool(true) } if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || + config.Hooks.OnPreMcpToolCall != nil || config.Hooks.OnPostToolUse != nil || config.Hooks.OnUserPromptSubmitted != nil || config.Hooks.OnSessionStart != nil || @@ -810,6 +811,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.RequestUserInput = Bool(true) } if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || + config.Hooks.OnPreMcpToolCall != nil || config.Hooks.OnPostToolUse != nil || config.Hooks.OnUserPromptSubmitted != nil || config.Hooks.OnSessionStart != nil || diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index 560ddb7b3..cfc6bd4e9 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -322,10 +322,10 @@ func assertArgValue(t *testing.T, args []string, name, expected string) { // capturedCli mirrors the JSON file written by the fake stdio CLI script. type capturedCli struct { - Args []string `json:"args"` - WorkingDirectory string `json:"cwd"` - Requests []capturedRequest `json:"requests"` - Env map[string]string `json:"env"` + Args []string `json:"args"` + WorkingDirectory string `json:"cwd"` + Requests []capturedRequest `json:"requests"` + Env map[string]string `json:"env"` } type capturedRequest struct { diff --git a/go/internal/e2e/mcp_and_agents_e2e_test.go b/go/internal/e2e/mcp_and_agents_e2e_test.go index b7e4c2400..87b25a533 100644 --- a/go/internal/e2e/mcp_and_agents_e2e_test.go +++ b/go/internal/e2e/mcp_and_agents_e2e_test.go @@ -157,11 +157,11 @@ func TestMCPServersE2E(t *testing.T) { mcpServers := map[string]copilot.MCPServerConfig{ "env-echo": copilot.MCPStdioServerConfig{ - Command: "node", - Args: []string{mcpServerPath}, - Tools: &[]string{"*"}, - Env: map[string]string{"TEST_SECRET": "hunter2"}, - Cwd: mcpServerDir, + Command: "node", + Args: []string{mcpServerPath}, + Tools: &[]string{"*"}, + Env: map[string]string{"TEST_SECRET": "hunter2"}, + WorkingDirectory: mcpServerDir, }, } diff --git a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go index 4426cc236..2253f3825 100644 --- a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go +++ b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go @@ -19,12 +19,13 @@ func TestPreMcpToolCallHookE2E(t *testing.T) { metaEchoServer := filepath.Join(testHarnessDir, "test-mcp-meta-echo-server.mjs") metaEchoConfig := func() map[string]copilot.MCPServerConfig { + tools := []string{"*"} return map[string]copilot.MCPServerConfig{ "meta-echo": copilot.MCPStdioServerConfig{ Command: "node", Args: []string{metaEchoServer}, WorkingDirectory: testHarnessDir, - Tools: []string{"*"}, + Tools: &tools, }, } } @@ -46,10 +47,10 @@ func TestPreMcpToolCallHookE2E(t *testing.T) { inputs = append(inputs, input) mu.Unlock() return &copilot.PreMcpToolCallHookOutput{ - MetaToUse: copilot.MetaToUseReplace(map[string]any{ + MetaToUse: map[string]any{ "injected": "by-hook", "source": "test", - }), + }, }, nil }, }, @@ -87,8 +88,8 @@ func TestPreMcpToolCallHookE2E(t *testing.T) { if inputs[0].WorkingDirectory == "" { t.Error("Expected non-empty workingDirectory") } - if inputs[0].Timestamp <= 0 { - t.Error("Expected positive timestamp") + if inputs[0].Timestamp.IsZero() { + t.Error("Expected non-zero timestamp") } }) @@ -109,9 +110,9 @@ func TestPreMcpToolCallHookE2E(t *testing.T) { inputs = append(inputs, input) mu.Unlock() return &copilot.PreMcpToolCallHookOutput{ - MetaToUse: copilot.MetaToUseReplace(map[string]any{ + MetaToUse: map[string]any{ "completely": "replaced", - }), + }, }, nil }, }, @@ -165,7 +166,7 @@ func TestPreMcpToolCallHookE2E(t *testing.T) { inputs = append(inputs, input) mu.Unlock() return &copilot.PreMcpToolCallHookOutput{ - MetaToUse: copilot.MetaToUseRemove(), + MetaToUse: nil, }, nil }, }, diff --git a/go/internal/e2e/session_e2e_test.go b/go/internal/e2e/session_e2e_test.go index f45a74616..bddd7e8e1 100644 --- a/go/internal/e2e/session_e2e_test.go +++ b/go/internal/e2e/session_e2e_test.go @@ -894,8 +894,8 @@ func TestSessionE2E(t *testing.T) { // Verify context field is present on sessions for _, s := range sessions { if s.Context != nil { - if s.Context.WorkingDirectory == "" { - t.Error("Expected context.WorkingDirectory to be non-empty when context is present") + if s.Context.Cwd == "" { + t.Error("Expected context.Cwd to be non-empty when context is present") } } } @@ -1006,8 +1006,8 @@ func TestSessionE2E(t *testing.T) { // Verify context field if metadata.Context != nil { - if metadata.Context.WorkingDirectory == "" { - t.Error("Expected context.WorkingDirectory to be non-empty when context is present") + if metadata.Context.Cwd == "" { + t.Error("Expected context.Cwd to be non-empty when context is present") } } diff --git a/go/types.go b/go/types.go index 8dc6818a3..ed8b8a958 100644 --- a/go/types.go +++ b/go/types.go @@ -702,11 +702,11 @@ type MCPServerConfig interface { // the wire) is distinguishable from a non-nil pointer to an empty slice // (sent as `"tools": []`). type MCPStdioServerConfig struct { - Tools *[]string `json:"tools,omitempty"` - Timeout int `json:"timeout,omitempty"` - Command string `json:"command"` - Args []string `json:"args,omitempty"` - Env map[string]string `json:"env,omitempty"` + Tools *[]string `json:"tools,omitempty"` + Timeout int `json:"timeout,omitempty"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` WorkingDirectory string `json:"cwd,omitempty"` } diff --git a/java/.lastmerge b/java/.lastmerge index 88ed2a952..531108354 100644 --- a/java/.lastmerge +++ b/java/.lastmerge @@ -1 +1 @@ -f6c1adf8329ad4206e5ed2e8d12fb8082bc841a2 +202064794ff4dbd57911535a163564c7688a77f8 diff --git a/java/src/main/java/com/github/copilot/sdk/CopilotSession.java b/java/src/main/java/com/github/copilot/sdk/CopilotSession.java index 5fb8733a2..4527803f7 100644 --- a/java/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -81,6 +81,7 @@ import com.github.copilot.sdk.json.PermissionRequestResult; import com.github.copilot.sdk.json.PermissionRequestResultKind; import com.github.copilot.sdk.json.PostToolUseHookInput; +import com.github.copilot.sdk.json.PreMcpToolCallHookInput; import com.github.copilot.sdk.json.PreToolUseHookInput; import com.github.copilot.sdk.json.SendMessageRequest; import com.github.copilot.sdk.json.SendMessageResponse; @@ -1554,6 +1555,13 @@ CompletableFuture handleHooksInvoke(String hookType, JsonNode input) { .thenApply(output -> (Object) output); } break; + case "preMcpToolCall" : + if (hooks.getOnPreMcpToolCall() != null) { + PreMcpToolCallHookInput mcpInput = MAPPER.treeToValue(input, PreMcpToolCallHookInput.class); + return hooks.getOnPreMcpToolCall().handle(mcpInput, invocation) + .thenApply(output -> (Object) output); + } + break; case "userPromptSubmitted" : if (hooks.getOnUserPromptSubmitted() != null) { UserPromptSubmittedHookInput promptInput = MAPPER.treeToValue(input, diff --git a/java/src/main/java/com/github/copilot/sdk/json/PostToolUseHookInput.java b/java/src/main/java/com/github/copilot/sdk/json/PostToolUseHookInput.java index 4ac398506..3e43846ed 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/PostToolUseHookInput.java +++ b/java/src/main/java/com/github/copilot/sdk/json/PostToolUseHookInput.java @@ -23,7 +23,7 @@ public class PostToolUseHookInput { private long timestamp; @JsonProperty("cwd") - private String cwd; + private String workingDirectory; @JsonProperty("toolName") private String toolName; @@ -81,19 +81,19 @@ public PostToolUseHookInput setTimestamp(long timestamp) { * * @return the working directory path */ - public String getCwd() { - return cwd; + public String getWorkingDirectory() { + return workingDirectory; } /** * Sets the current working directory. * - * @param cwd + * @param workingDirectory * the working directory path * @return this instance for method chaining */ - public PostToolUseHookInput setCwd(String cwd) { - this.cwd = cwd; + public PostToolUseHookInput setWorkingDirectory(String workingDirectory) { + this.workingDirectory = workingDirectory; return this; } diff --git a/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHandler.java b/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHandler.java new file mode 100644 index 000000000..05e5076d3 --- /dev/null +++ b/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHandler.java @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.concurrent.CompletableFuture; + +/** + * Handler for pre-MCP-tool-call hooks. + *

+ * This hook is called before an MCP tool call is dispatched, allowing you to + * modify or remove the {@code _meta} field sent with the tool call. + * + * @since 1.0.8 + */ +@FunctionalInterface +public interface PreMcpToolCallHandler { + + /** + * Handles a pre-MCP-tool-call hook invocation. + * + * @param input + * the hook input containing server name, tool name, arguments, and + * meta + * @param invocation + * context information about the invocation + * @return a future that resolves with the hook output, or {@code null} to + * preserve the existing {@code _meta} + */ + CompletableFuture handle(PreMcpToolCallHookInput input, HookInvocation invocation); +} diff --git a/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHookInput.java b/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHookInput.java new file mode 100644 index 000000000..bb4800948 --- /dev/null +++ b/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHookInput.java @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Input for a pre-MCP-tool-call hook. + *

+ * This hook is called before an MCP tool call is dispatched, allowing you to + * modify the {@code _meta} field that is sent with the tool call. + * + * @since 1.0.8 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class PreMcpToolCallHookInput { + + @JsonProperty("sessionId") + private String sessionId; + + @JsonProperty("timestamp") + private long timestamp; + + @JsonProperty("cwd") + private String workingDirectory; + + @JsonProperty("serverName") + private String serverName; + + @JsonProperty("toolName") + private String toolName; + + @JsonProperty("arguments") + private JsonNode arguments; + + @JsonProperty("toolCallId") + private String toolCallId; + + @JsonProperty("_meta") + private JsonNode meta; + + /** + * Gets the runtime session ID of the session that triggered the hook. + * + * @return the session ID + */ + public String getSessionId() { + return sessionId; + } + + /** + * Sets the runtime session ID of the session that triggered the hook. + * + * @param sessionId + * the session ID + * @return this instance for method chaining + */ + public PreMcpToolCallHookInput setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Gets the timestamp of the hook invocation. + * + * @return the timestamp in milliseconds + */ + public long getTimestamp() { + return timestamp; + } + + /** + * Sets the timestamp of the hook invocation. + * + * @param timestamp + * the timestamp in milliseconds + * @return this instance for method chaining + */ + public PreMcpToolCallHookInput setTimestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + /** + * Gets the current working directory. + * + * @return the working directory path + */ + public String getWorkingDirectory() { + return workingDirectory; + } + + /** + * Sets the current working directory. + * + * @param workingDirectory + * the working directory path + * @return this instance for method chaining + */ + public PreMcpToolCallHookInput setWorkingDirectory(String workingDirectory) { + this.workingDirectory = workingDirectory; + return this; + } + + /** + * Gets the name of the MCP server. + * + * @return the server name + */ + public String getServerName() { + return serverName; + } + + /** + * Sets the name of the MCP server. + * + * @param serverName + * the server name + * @return this instance for method chaining + */ + public PreMcpToolCallHookInput setServerName(String serverName) { + this.serverName = serverName; + return this; + } + + /** + * Gets the name of the tool being called. + * + * @return the tool name + */ + public String getToolName() { + return toolName; + } + + /** + * Sets the name of the tool being called. + * + * @param toolName + * the tool name + * @return this instance for method chaining + */ + public PreMcpToolCallHookInput setToolName(String toolName) { + this.toolName = toolName; + return this; + } + + /** + * Gets the arguments passed to the tool. + * + * @return the tool arguments as a JSON node + */ + public JsonNode getArguments() { + return arguments; + } + + /** + * Sets the arguments passed to the tool. + * + * @param arguments + * the tool arguments as a JSON node + * @return this instance for method chaining + */ + public PreMcpToolCallHookInput setArguments(JsonNode arguments) { + this.arguments = arguments; + return this; + } + + /** + * Gets the tool call ID. + * + * @return the tool call ID, or {@code null} if not set + */ + public String getToolCallId() { + return toolCallId; + } + + /** + * Sets the tool call ID. + * + * @param toolCallId + * the tool call ID + * @return this instance for method chaining + */ + public PreMcpToolCallHookInput setToolCallId(String toolCallId) { + this.toolCallId = toolCallId; + return this; + } + + /** + * Gets the existing {@code _meta} object that would be sent with the tool call. + * + * @return the meta as a JSON node, or {@code null} if not present + */ + public JsonNode getMeta() { + return meta; + } + + /** + * Sets the existing {@code _meta} object. + * + * @param meta + * the meta as a JSON node + * @return this instance for method chaining + */ + public PreMcpToolCallHookInput setMeta(JsonNode meta) { + this.meta = meta; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHookOutput.java b/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHookOutput.java new file mode 100644 index 000000000..c4aba97ff --- /dev/null +++ b/java/src/main/java/com/github/copilot/sdk/json/PreMcpToolCallHookOutput.java @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Output for a pre-MCP-tool-call hook. + *

+ * Controls the {@code _meta} field sent with the MCP tool call: + *

    + *
  • Return {@code null} from the handler to preserve the existing + * {@code _meta} unchanged.
  • + *
  • Return {@code new PreMcpToolCallHookOutput(null)} to remove + * {@code _meta}.
  • + *
  • Return {@code new PreMcpToolCallHookOutput(metaNode)} to replace + * {@code _meta} with the provided value.
  • + *
+ * + * @param metaToUse + * the meta value to use; {@code null} means remove {@code _meta} + * @since 1.0.8 + */ +@JsonInclude(JsonInclude.Include.ALWAYS) +public record PreMcpToolCallHookOutput(@JsonProperty("metaToUse") JsonNode metaToUse) { +} diff --git a/java/src/main/java/com/github/copilot/sdk/json/PreToolUseHookInput.java b/java/src/main/java/com/github/copilot/sdk/json/PreToolUseHookInput.java index 6cbab78b7..79a38a5d0 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/PreToolUseHookInput.java +++ b/java/src/main/java/com/github/copilot/sdk/json/PreToolUseHookInput.java @@ -23,7 +23,7 @@ public class PreToolUseHookInput { private long timestamp; @JsonProperty("cwd") - private String cwd; + private String workingDirectory; @JsonProperty("toolName") private String toolName; @@ -78,19 +78,19 @@ public PreToolUseHookInput setTimestamp(long timestamp) { * * @return the working directory path */ - public String getCwd() { - return cwd; + public String getWorkingDirectory() { + return workingDirectory; } /** * Sets the current working directory. * - * @param cwd + * @param workingDirectory * the working directory path * @return this instance for method chaining */ - public PreToolUseHookInput setCwd(String cwd) { - this.cwd = cwd; + public PreToolUseHookInput setWorkingDirectory(String workingDirectory) { + this.workingDirectory = workingDirectory; return this; } diff --git a/java/src/main/java/com/github/copilot/sdk/json/SessionEndHookInput.java b/java/src/main/java/com/github/copilot/sdk/json/SessionEndHookInput.java index 0d3d3e294..eeb2a3347 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/SessionEndHookInput.java +++ b/java/src/main/java/com/github/copilot/sdk/json/SessionEndHookInput.java @@ -17,7 +17,7 @@ * the runtime session ID of the session that triggered the hook * @param timestamp * the timestamp in milliseconds since epoch when the session ended - * @param cwd + * @param workingDirectory * the current working directory * @param reason * the reason: "complete", "error", "abort", "timeout", or @@ -30,7 +30,7 @@ */ @JsonIgnoreProperties(ignoreUnknown = true) public record SessionEndHookInput(@JsonProperty("sessionId") String sessionId, - @JsonProperty("timestamp") long timestamp, @JsonProperty("cwd") String cwd, + @JsonProperty("timestamp") long timestamp, @JsonProperty("cwd") String workingDirectory, @JsonProperty("reason") String reason, @JsonProperty("finalMessage") String finalMessage, @JsonProperty("error") String error) { } diff --git a/java/src/main/java/com/github/copilot/sdk/json/SessionHooks.java b/java/src/main/java/com/github/copilot/sdk/json/SessionHooks.java index 8e22c3ee8..57d084425 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/SessionHooks.java +++ b/java/src/main/java/com/github/copilot/sdk/json/SessionHooks.java @@ -39,6 +39,7 @@ public class SessionHooks { private PreToolUseHandler onPreToolUse; private PostToolUseHandler onPostToolUse; + private PreMcpToolCallHandler onPreMcpToolCall; private UserPromptSubmittedHandler onUserPromptSubmitted; private SessionStartHandler onSessionStart; private SessionEndHandler onSessionEnd; @@ -85,6 +86,29 @@ public SessionHooks setOnPostToolUse(PostToolUseHandler onPostToolUse) { return this; } + /** + * Gets the pre-MCP-tool-call handler. + * + * @return the handler, or {@code null} if not set + * @since 1.0.8 + */ + public PreMcpToolCallHandler getOnPreMcpToolCall() { + return onPreMcpToolCall; + } + + /** + * Sets the handler called before an MCP tool call is dispatched. + * + * @param onPreMcpToolCall + * the handler + * @return this instance for method chaining + * @since 1.0.8 + */ + public SessionHooks setOnPreMcpToolCall(PreMcpToolCallHandler onPreMcpToolCall) { + this.onPreMcpToolCall = onPreMcpToolCall; + return this; + } + /** * Gets the user-prompt-submitted handler. * @@ -160,7 +184,7 @@ public SessionHooks setOnSessionEnd(SessionEndHandler onSessionEnd) { * @return {@code true} if at least one hook handler is set */ public boolean hasHooks() { - return onPreToolUse != null || onPostToolUse != null || onUserPromptSubmitted != null || onSessionStart != null - || onSessionEnd != null; + return onPreToolUse != null || onPostToolUse != null || onPreMcpToolCall != null + || onUserPromptSubmitted != null || onSessionStart != null || onSessionEnd != null; } } diff --git a/java/src/main/java/com/github/copilot/sdk/json/SessionStartHookInput.java b/java/src/main/java/com/github/copilot/sdk/json/SessionStartHookInput.java index 55bff3e26..ff0ecd440 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/SessionStartHookInput.java +++ b/java/src/main/java/com/github/copilot/sdk/json/SessionStartHookInput.java @@ -17,7 +17,7 @@ * the runtime session ID of the session that triggered the hook * @param timestamp * the timestamp in milliseconds since epoch when the session started - * @param cwd + * @param workingDirectory * the current working directory * @param source * the source: "startup", "resume", or "new" @@ -27,6 +27,6 @@ */ @JsonIgnoreProperties(ignoreUnknown = true) public record SessionStartHookInput(@JsonProperty("sessionId") String sessionId, - @JsonProperty("timestamp") long timestamp, @JsonProperty("cwd") String cwd, + @JsonProperty("timestamp") long timestamp, @JsonProperty("cwd") String workingDirectory, @JsonProperty("source") String source, @JsonProperty("initialPrompt") String initialPrompt) { } diff --git a/java/src/main/java/com/github/copilot/sdk/json/UserPromptSubmittedHookInput.java b/java/src/main/java/com/github/copilot/sdk/json/UserPromptSubmittedHookInput.java index 2f3a0948d..333860373 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/UserPromptSubmittedHookInput.java +++ b/java/src/main/java/com/github/copilot/sdk/json/UserPromptSubmittedHookInput.java @@ -18,7 +18,7 @@ * @param timestamp * the timestamp in milliseconds since epoch when the prompt was * submitted - * @param cwd + * @param workingDirectory * the current working directory * @param prompt * the user's prompt text @@ -26,6 +26,6 @@ */ @JsonIgnoreProperties(ignoreUnknown = true) public record UserPromptSubmittedHookInput(@JsonProperty("sessionId") String sessionId, - @JsonProperty("timestamp") long timestamp, @JsonProperty("cwd") String cwd, + @JsonProperty("timestamp") long timestamp, @JsonProperty("cwd") String workingDirectory, @JsonProperty("prompt") String prompt) { } diff --git a/java/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java b/java/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java index 3c83b8286..4ffe2feac 100644 --- a/java/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java +++ b/java/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java @@ -155,7 +155,7 @@ void preToolUseHookInputGetters() { var input = new PreToolUseHookInput(); // Default values assertEquals(0L, input.getTimestamp()); - assertNull(input.getCwd()); + assertNull(input.getWorkingDirectory()); assertNull(input.getToolArgs()); assertNull(input.getSessionId()); } @@ -174,7 +174,7 @@ void postToolUseHookInputGetters() { var input = new PostToolUseHookInput(); // Default values assertEquals(0L, input.getTimestamp()); - assertNull(input.getCwd()); + assertNull(input.getWorkingDirectory()); assertNull(input.getToolArgs()); assertNull(input.getSessionId()); } diff --git a/java/src/test/java/com/github/copilot/sdk/PreMcpToolCallHookTest.java b/java/src/test/java/com/github/copilot/sdk/PreMcpToolCallHookTest.java new file mode 100644 index 000000000..2a6ef1cbd --- /dev/null +++ b/java/src/test/java/com/github/copilot/sdk/PreMcpToolCallHookTest.java @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.copilot.sdk.json.McpServerConfig; +import com.github.copilot.sdk.json.McpStdioServerConfig; +import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.PreMcpToolCallHookInput; +import com.github.copilot.sdk.json.PreMcpToolCallHookOutput; +import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.SessionHooks; + +/** + * E2E tests for the preMcpToolCall hook, verifying meta manipulation scenarios: + * setting meta, replacing meta, and removing meta. + * + *

+ * These tests use the shared CapiProxy infrastructure for deterministic API + * response replay. Snapshots are stored in + * test/snapshots/pre_mcp_tool_call_hook/. + *

+ */ +public class PreMcpToolCallHookTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + private static Path findTestHarnessDir() { + Path dir = Paths.get(System.getProperty("user.dir")); + while (dir != null) { + Path candidate = dir.resolve("test").resolve("harness").resolve("test-mcp-meta-echo-server.mjs"); + if (Files.exists(candidate)) { + return candidate.getParent(); + } + dir = dir.getParent(); + } + throw new IllegalStateException("Could not find test/harness/test-mcp-meta-echo-server.mjs"); + } + + private static HashMap createMetaEchoMcpConfig(Path testHarnessDir) { + var servers = new HashMap(); + servers.put("meta-echo", + new McpStdioServerConfig().setCommand("node") + .setArgs(List.of(testHarnessDir.resolve("test-mcp-meta-echo-server.mjs").toString())) + .setWorkingDirectory(testHarnessDir.toString()).setTools(List.of("*"))); + return servers; + } + + /** + * Verifies that the preMcpToolCall hook can set meta on a tool call. + * + * @see Snapshot: pre_mcp_tool_call_hook/should_set_meta_via_premcptoolcall_hook + */ + @Test + void testShouldSetMetaViaPreMcpToolCallHook() throws Exception { + ctx.configureForTest("pre_mcp_tool_call_hook", "should_set_meta_via_premcptoolcall_hook"); + + Path testHarnessDir = findTestHarnessDir(); + var hookInputs = new ArrayList(); + + ObjectNode metaToSet = MAPPER.createObjectNode(); + metaToSet.put("injected", "by-hook"); + metaToSet.put("source", "test"); + + var config = new SessionConfig().setMcpServers(createMetaEchoMcpConfig(testHarnessDir)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreMcpToolCall((input, invocation) -> { + hookInputs.add(input); + return CompletableFuture.completedFuture(new PreMcpToolCallHookOutput(metaToSet)); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + + var response = session.sendAndWait(new MessageOptions().setPrompt( + "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result.")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + String content = response.getData().content(); + assertNotNull(content); + assertTrue(content.contains("injected"), "Response should contain injected meta key"); + assertTrue(content.contains("by-hook"), "Response should contain injected meta value"); + + assertFalse(hookInputs.isEmpty(), "Should have received preMcpToolCall hook calls"); + assertEquals("meta-echo", hookInputs.get(0).getServerName()); + assertEquals("echo_meta", hookInputs.get(0).getToolName()); + assertNotNull(hookInputs.get(0).getWorkingDirectory()); + assertFalse(hookInputs.get(0).getWorkingDirectory().isEmpty()); + assertTrue(hookInputs.get(0).getTimestamp() > 0); + } + } + + /** + * Verifies that the preMcpToolCall hook can replace meta on a tool call. + * + * @see Snapshot: + * pre_mcp_tool_call_hook/should_replace_meta_via_premcptoolcall_hook + */ + @Test + void testShouldReplaceMetaViaPreMcpToolCallHook() throws Exception { + ctx.configureForTest("pre_mcp_tool_call_hook", "should_replace_meta_via_premcptoolcall_hook"); + + Path testHarnessDir = findTestHarnessDir(); + var hookInputs = new ArrayList(); + + ObjectNode replacementMeta = MAPPER.createObjectNode(); + replacementMeta.put("completely", "replaced"); + + var config = new SessionConfig().setMcpServers(createMetaEchoMcpConfig(testHarnessDir)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreMcpToolCall((input, invocation) -> { + hookInputs.add(input); + return CompletableFuture.completedFuture(new PreMcpToolCallHookOutput(replacementMeta)); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + + var response = session.sendAndWait(new MessageOptions().setPrompt( + "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result.")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + String content = response.getData().content(); + assertNotNull(content); + assertTrue(content.contains("completely"), "Response should contain replaced meta key"); + assertTrue(content.contains("replaced"), "Response should contain replaced meta value"); + + assertFalse(hookInputs.isEmpty(), "Should have received preMcpToolCall hook calls"); + assertEquals("meta-echo", hookInputs.get(0).getServerName()); + assertEquals("echo_meta", hookInputs.get(0).getToolName()); + } + } + + /** + * Verifies that the preMcpToolCall hook can remove meta from a tool call. + * + * @see Snapshot: + * pre_mcp_tool_call_hook/should_remove_meta_via_premcptoolcall_hook + */ + @Test + void testShouldRemoveMetaViaPreMcpToolCallHook() throws Exception { + ctx.configureForTest("pre_mcp_tool_call_hook", "should_remove_meta_via_premcptoolcall_hook"); + + Path testHarnessDir = findTestHarnessDir(); + var hookInputs = new ArrayList(); + + var config = new SessionConfig().setMcpServers(createMetaEchoMcpConfig(testHarnessDir)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreMcpToolCall((input, invocation) -> { + hookInputs.add(input); + // Return output with null metaToUse to signal removal + return CompletableFuture.completedFuture(new PreMcpToolCallHookOutput(null)); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + + var response = session.sendAndWait(new MessageOptions().setPrompt( + "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result.")) + .get(60, TimeUnit.SECONDS); + + assertNotNull(response); + String content = response.getData().content(); + assertNotNull(content); + assertTrue(content.contains("\"meta\":null") || content.contains("\"meta\": null"), + "Response should contain null meta"); + assertTrue(content.contains("test-remove"), "Response should contain the test value"); + + assertFalse(hookInputs.isEmpty(), "Should have received preMcpToolCall hook calls"); + assertEquals("meta-echo", hookInputs.get(0).getServerName()); + assertEquals("echo_meta", hookInputs.get(0).getToolName()); + } + } +} diff --git a/java/src/test/java/com/github/copilot/sdk/SessionHandlerTest.java b/java/src/test/java/com/github/copilot/sdk/SessionHandlerTest.java index 5a8dc3fcb..e5eaf2de1 100644 --- a/java/src/test/java/com/github/copilot/sdk/SessionHandlerTest.java +++ b/java/src/test/java/com/github/copilot/sdk/SessionHandlerTest.java @@ -269,7 +269,7 @@ void testHookInputSessionIdDeserializedForSessionStart() throws Exception { var hooks = new SessionHooks().setOnSessionStart((hookInput, invocation) -> { assertEquals("runtime-session-123", hookInput.sessionId()); assertEquals(1735689600L, hookInput.timestamp()); - assertEquals("/tmp", hookInput.cwd()); + assertEquals("/tmp", hookInput.workingDirectory()); return CompletableFuture.completedFuture(new SessionStartHookOutput(null, null)); }); session.registerHooks(hooks); diff --git a/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts b/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts index 2620e9496..4320b7805 100644 --- a/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts +++ b/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts @@ -34,7 +34,7 @@ describe("pre_mcp_tool_call_hook", async () => { } as MCPStdioServerConfig, }, hooks: { - onPreMcpToolCall: async (input, invocation) => { + onPreMcpToolCall: async (input, _invocation) => { hookInputs.push(input); return { metaToUse: { injected: "by-hook", source: "test" } }; }, @@ -72,7 +72,7 @@ describe("pre_mcp_tool_call_hook", async () => { } as MCPStdioServerConfig, }, hooks: { - onPreMcpToolCall: async (input, invocation) => { + onPreMcpToolCall: async (input, _invocation) => { hookInputs.push(input); return { metaToUse: { completely: "replaced" } }; }, @@ -108,7 +108,7 @@ describe("pre_mcp_tool_call_hook", async () => { } as MCPStdioServerConfig, }, hooks: { - onPreMcpToolCall: async (input, invocation) => { + onPreMcpToolCall: async (input, _invocation) => { hookInputs.push(input); return { metaToUse: null }; }, diff --git a/python/e2e/test_pre_mcp_tool_call_hook_e2e.py b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py index 1bae58ec3..57c8ee2c0 100644 --- a/python/e2e/test_pre_mcp_tool_call_hook_e2e.py +++ b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py @@ -47,7 +47,8 @@ async def on_pre_mcp_tool_call(input_data, invocation): ) try: response = await session.send_and_wait( - "Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result." + "Use the meta-echo/echo_meta tool with value 'test-set'." + " Reply with just the raw tool result." ) assert response is not None assert "injected" in (response.data.content or "") @@ -75,7 +76,8 @@ async def on_pre_mcp_tool_call(input_data, invocation): ) try: response = await session.send_and_wait( - "Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result." + "Use the meta-echo/echo_meta tool with value 'test-replace'." + " Reply with just the raw tool result." ) assert response is not None assert "completely" in (response.data.content or "") @@ -101,7 +103,8 @@ async def on_pre_mcp_tool_call(input_data, invocation): ) try: response = await session.send_and_wait( - "Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result." + "Use the meta-echo/echo_meta tool with value 'test-remove'." + " Reply with just the raw tool result." ) assert response is not None assert '"meta":null' in (response.data.content or "") or '"meta": null' in ( diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 98f0fde3c..09ece6cf5 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -41,12 +41,12 @@ mod multi_client_commands_elicitation; mod multi_turn; #[path = "e2e/pending_work_resume.rs"] mod pending_work_resume; -#[path = "e2e/pre_mcp_tool_call_hook.rs"] -mod pre_mcp_tool_call_hook; #[path = "e2e/per_session_auth.rs"] mod per_session_auth; #[path = "e2e/permissions.rs"] mod permissions; +#[path = "e2e/pre_mcp_tool_call_hook.rs"] +mod pre_mcp_tool_call_hook; #[path = "e2e/rpc_additional_edge_cases.rs"] mod rpc_additional_edge_cases; #[path = "e2e/rpc_agent.rs"] diff --git a/rust/tests/e2e/pre_mcp_tool_call_hook.rs b/rust/tests/e2e/pre_mcp_tool_call_hook.rs index 204da89cf..0157c026d 100644 --- a/rust/tests/e2e/pre_mcp_tool_call_hook.rs +++ b/rust/tests/e2e/pre_mcp_tool_call_hook.rs @@ -6,7 +6,7 @@ use github_copilot_sdk::hooks::{ HookContext, PreMcpToolCallInput, PreMcpToolCallOutput, SessionHooks, }; use github_copilot_sdk::{McpServerConfig, McpStdioServerConfig}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tokio::sync::mpsc; use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index e08e3535a..a4315f2c6 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -78,7 +78,6 @@ impl E2eContext { Ok(ctx) } - #[expect(dead_code, reason = "used by follow-on E2E ports")] pub fn repo_root(&self) -> &Path { &self.repo_root } diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index c4c7395a4..3e174aa2f 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { existsSync } from "fs"; +import { existsSync, appendFileSync } from "fs"; import { mkdir, readFile, writeFile } from "fs/promises"; import type { ChatCompletion, @@ -1215,7 +1215,11 @@ function findAssistantIndexAfterPrefix( requestMessages: NormalizedMessage[], savedMessages: NormalizedMessage[], ): number | undefined { + const logFile = process.env.PROXY_DEBUG_LOG; + const log = (msg: string) => { if (logFile) try { appendFileSync(logFile, msg + "\n"); } catch {} }; + if (requestMessages.length >= savedMessages.length) { + log(`prefix check failed: request.length=${requestMessages.length} >= saved.length=${savedMessages.length}`); return undefined; } @@ -1223,6 +1227,9 @@ function findAssistantIndexAfterPrefix( const reqMsg = JSON.stringify(requestMessages[i]); const savedMsg = JSON.stringify(savedMessages[i]); if (reqMsg !== savedMsg) { + log(`mismatch at index ${i}:`); + log(` REQ: ${reqMsg.substring(0, 1000)}`); + log(` SAVED: ${savedMsg.substring(0, 1000)}`); return undefined; } } @@ -1233,9 +1240,11 @@ function findAssistantIndexAfterPrefix( nextIndex < savedMessages.length && savedMessages[nextIndex].role === "assistant" ) { + log(`MATCH found at index ${nextIndex}`); return nextIndex; } + log(`no assistant at nextIndex=${nextIndex}, saved.length=${savedMessages.length}`); return undefined; } diff --git a/test/harness/test-mcp-meta-echo-server.mjs b/test/harness/test-mcp-meta-echo-server.mjs new file mode 100644 index 000000000..068f35f5f --- /dev/null +++ b/test/harness/test-mcp-meta-echo-server.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Minimal MCP server that exposes an `echo_meta` tool. + * Returns the value passed in along with the `_meta` received in the tools/call request. + * Used by SDK E2E tests to verify that preMcpToolCall hook meta modifications + * reach the MCP server subprocess. + * + * Usage: node test-mcp-meta-echo-server.mjs + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +const server = new Server( + { name: "meta-echo", version: "1.0.0" }, + { capabilities: { tools: {} } } +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "echo_meta", + description: "Echoes the value and the _meta received in the request.", + inputSchema: { + type: "object", + properties: { + value: { type: "string", description: "A value to echo back" }, + }, + required: ["value"], + }, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args, _meta } = request.params; + if (name !== "echo_meta") { + return { + content: [{ type: "text", text: `Unknown tool: ${name}` }], + isError: true, + }; + } + const value = args?.value ?? ""; + // Filter out system-injected meta keys (progressToken from MCP SDK, + // trace context from runtime) so tests only see hook-provided meta. + const systemKeys = new Set(["progressToken", "traceparent", "tracestate"]); + const hookMeta = _meta + ? Object.fromEntries(Object.entries(_meta).filter(([k]) => !systemKeys.has(k))) + : null; + const resultMeta = hookMeta && Object.keys(hookMeta).length > 0 ? hookMeta : null; + return { + content: [ + { type: "text", text: JSON.stringify({ meta: resultMeta, value }) }, + ], + }; +}); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/test/snapshots/pre_mcp_tool_call_hook/should_remove_meta_via_premcptoolcall_hook.yaml b/test/snapshots/pre_mcp_tool_call_hook/should_remove_meta_via_premcptoolcall_hook.yaml new file mode 100644 index 000000000..c77164784 --- /dev/null +++ b/test/snapshots/pre_mcp_tool_call_hook/should_remove_meta_via_premcptoolcall_hook.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the meta-echo/echo_meta tool with value 'test-remove'. Reply with just the raw tool result. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: meta-echo-echo_meta + arguments: '{"value":"test-remove"}' + - role: tool + tool_call_id: toolcall_0 + content: '{"meta":null,"value":"test-remove"}' + - role: assistant + content: '{"meta":null,"value":"test-remove"}' diff --git a/test/snapshots/pre_mcp_tool_call_hook/should_replace_meta_via_premcptoolcall_hook.yaml b/test/snapshots/pre_mcp_tool_call_hook/should_replace_meta_via_premcptoolcall_hook.yaml new file mode 100644 index 000000000..d7ff876a6 --- /dev/null +++ b/test/snapshots/pre_mcp_tool_call_hook/should_replace_meta_via_premcptoolcall_hook.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the meta-echo/echo_meta tool with value 'test-replace'. Reply with just the raw tool result. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: meta-echo-echo_meta + arguments: '{"value":"test-replace"}' + - role: tool + tool_call_id: toolcall_0 + content: '{"meta":{"completely":"replaced"},"value":"test-replace"}' + - role: assistant + content: '{"meta":{"completely":"replaced"},"value":"test-replace"}' diff --git a/test/snapshots/pre_mcp_tool_call_hook/should_set_meta_via_premcptoolcall_hook.yaml b/test/snapshots/pre_mcp_tool_call_hook/should_set_meta_via_premcptoolcall_hook.yaml new file mode 100644 index 000000000..1d92fe8ee --- /dev/null +++ b/test/snapshots/pre_mcp_tool_call_hook/should_set_meta_via_premcptoolcall_hook.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the meta-echo/echo_meta tool with value 'test-set'. Reply with just the raw tool result. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: meta-echo-echo_meta + arguments: '{"value":"test-set"}' + - role: tool + tool_call_id: toolcall_0 + content: '{"meta":{"injected":"by-hook","source":"test"},"value":"test-set"}' + - role: assistant + content: '{"meta":{"injected":"by-hook","source":"test"},"value":"test-set"}' From 121e5d3152ed9c539d9b7db835548e811c87d46c Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 15:43:10 -0400 Subject: [PATCH 04/15] Fix Java PingResponse timestamp deserialization for ISO-8601 format The Copilot CLI now returns ISO-8601 timestamp strings instead of numeric epoch milliseconds. Update PingResponse.timestamp from long to String and PingResult.timestamp from Long to String. Update corresponding test assertions accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/github/copilot/sdk/generated/rpc/PingResult.java | 4 ++-- .../java/com/github/copilot/sdk/json/PingResponse.java | 4 ++-- .../java/com/github/copilot/sdk/CopilotClientTest.java | 3 ++- .../generated/rpc/GeneratedRpcRecordsCoverageTest.java | 8 ++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/java/src/generated/java/com/github/copilot/sdk/generated/rpc/PingResult.java b/java/src/generated/java/com/github/copilot/sdk/generated/rpc/PingResult.java index 299d8f358..f786e5d2e 100644 --- a/java/src/generated/java/com/github/copilot/sdk/generated/rpc/PingResult.java +++ b/java/src/generated/java/com/github/copilot/sdk/generated/rpc/PingResult.java @@ -23,8 +23,8 @@ public record PingResult( /** Echoed message (or default greeting) */ @JsonProperty("message") String message, - /** Server timestamp in milliseconds */ - @JsonProperty("timestamp") Long timestamp, + /** Server timestamp as an ISO-8601 string */ + @JsonProperty("timestamp") String timestamp, /** Server protocol version number */ @JsonProperty("protocolVersion") Long protocolVersion ) { diff --git a/java/src/main/java/com/github/copilot/sdk/json/PingResponse.java b/java/src/main/java/com/github/copilot/sdk/json/PingResponse.java index e86499b2f..08696da86 100644 --- a/java/src/main/java/com/github/copilot/sdk/json/PingResponse.java +++ b/java/src/main/java/com/github/copilot/sdk/json/PingResponse.java @@ -20,8 +20,8 @@ public record PingResponse( /** The echo message from the server. */ @JsonProperty("message") String message, - /** The server timestamp in milliseconds since epoch. */ - @JsonProperty("timestamp") long timestamp, + /** The server timestamp as an ISO-8601 string. */ + @JsonProperty("timestamp") String timestamp, /** * The SDK protocol version supported by the server. The SDK validates that this * version matches the expected version to ensure compatibility. diff --git a/java/src/test/java/com/github/copilot/sdk/CopilotClientTest.java b/java/src/test/java/com/github/copilot/sdk/CopilotClientTest.java index 14ed8ca89..682e1ef80 100644 --- a/java/src/test/java/com/github/copilot/sdk/CopilotClientTest.java +++ b/java/src/test/java/com/github/copilot/sdk/CopilotClientTest.java @@ -92,7 +92,8 @@ void testStartAndConnectUsingStdio() throws Exception { PingResponse pong = client.ping("test message").get(); assertEquals("pong: test message", pong.message()); - assertTrue(pong.timestamp() >= 0); + assertNotNull(pong.timestamp()); + assertFalse(pong.timestamp().isEmpty()); client.stop().get(); assertEquals(ConnectionState.DISCONNECTED, client.getState()); diff --git a/java/src/test/java/com/github/copilot/sdk/generated/rpc/GeneratedRpcRecordsCoverageTest.java b/java/src/test/java/com/github/copilot/sdk/generated/rpc/GeneratedRpcRecordsCoverageTest.java index e6ae7e7d9..cf1e3b164 100644 --- a/java/src/test/java/com/github/copilot/sdk/generated/rpc/GeneratedRpcRecordsCoverageTest.java +++ b/java/src/test/java/com/github/copilot/sdk/generated/rpc/GeneratedRpcRecordsCoverageTest.java @@ -32,9 +32,9 @@ void pingParams_record() { @Test void pingResult_record() { - var result = new PingResult("pong", 1234L, 2L); + var result = new PingResult("pong", "2026-01-01T00:00:00Z", 2L); assertEquals("pong", result.message()); - assertEquals(1234L, result.timestamp()); + assertEquals("2026-01-01T00:00:00Z", result.timestamp()); assertEquals(2L, result.protocolVersion()); } @@ -471,9 +471,9 @@ void sessionWorkspaceReadFileParams_record() { @Test void pingResult_fields() { - var result = new PingResult("pong", 9999L, 1L); + var result = new PingResult("pong", "2026-06-15T10:30:00Z", 1L); assertEquals("pong", result.message()); - assertEquals(9999L, result.timestamp()); + assertEquals("2026-06-15T10:30:00Z", result.timestamp()); assertEquals(1L, result.protocolVersion()); } From 817d2e5836565f4a33e727164744b7cb34110e88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 19:43:40 +0000 Subject: [PATCH 05/15] Regenerate Java codegen output Auto-committed by java-codegen-check workflow. --- .../java/com/github/copilot/sdk/generated/rpc/PingResult.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/java/src/generated/java/com/github/copilot/sdk/generated/rpc/PingResult.java b/java/src/generated/java/com/github/copilot/sdk/generated/rpc/PingResult.java index f786e5d2e..299d8f358 100644 --- a/java/src/generated/java/com/github/copilot/sdk/generated/rpc/PingResult.java +++ b/java/src/generated/java/com/github/copilot/sdk/generated/rpc/PingResult.java @@ -23,8 +23,8 @@ public record PingResult( /** Echoed message (or default greeting) */ @JsonProperty("message") String message, - /** Server timestamp as an ISO-8601 string */ - @JsonProperty("timestamp") String timestamp, + /** Server timestamp in milliseconds */ + @JsonProperty("timestamp") Long timestamp, /** Server protocol version number */ @JsonProperty("protocolVersion") Long protocolVersion ) { From bd6ef7658868f22ce2214b825a08ebd7cd676cbc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 19:49:36 +0000 Subject: [PATCH 06/15] Fix Java test type mismatch: PingResult.timestamp is Long not String The generated PingResult record has timestamp as Long (milliseconds), but tests were passing String values (ISO date format). Update tests to use Long millisecond values instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generated/rpc/GeneratedRpcRecordsCoverageTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/java/src/test/java/com/github/copilot/sdk/generated/rpc/GeneratedRpcRecordsCoverageTest.java b/java/src/test/java/com/github/copilot/sdk/generated/rpc/GeneratedRpcRecordsCoverageTest.java index cf1e3b164..a6d492307 100644 --- a/java/src/test/java/com/github/copilot/sdk/generated/rpc/GeneratedRpcRecordsCoverageTest.java +++ b/java/src/test/java/com/github/copilot/sdk/generated/rpc/GeneratedRpcRecordsCoverageTest.java @@ -32,9 +32,9 @@ void pingParams_record() { @Test void pingResult_record() { - var result = new PingResult("pong", "2026-01-01T00:00:00Z", 2L); + var result = new PingResult("pong", 1735689600000L, 2L); assertEquals("pong", result.message()); - assertEquals("2026-01-01T00:00:00Z", result.timestamp()); + assertEquals(1735689600000L, result.timestamp()); assertEquals(2L, result.protocolVersion()); } @@ -471,9 +471,9 @@ void sessionWorkspaceReadFileParams_record() { @Test void pingResult_fields() { - var result = new PingResult("pong", "2026-06-15T10:30:00Z", 1L); + var result = new PingResult("pong", 1750000200000L, 1L); assertEquals("pong", result.message()); - assertEquals("2026-06-15T10:30:00Z", result.timestamp()); + assertEquals(1750000200000L, result.timestamp()); assertEquals(1L, result.protocolVersion()); } From db3af2309a62bab6f11069947100ac8a0f8196f3 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 16:13:02 -0400 Subject: [PATCH 07/15] Rename cwd to working_directory in Rust public API Rename the public `cwd` field to `working_directory` on: - ClientOptions (local-only, not serialized) - McpStdioServerConfig (serialized; add #[serde(rename = "cwd")]) - SessionListFilter (serialized; add #[serde(rename = "cwd")]) The wire format remains unchanged (JSON key stays "cwd"). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/lib.rs | 24 ++++++++++++------------ rust/src/types.rs | 8 ++++---- rust/tests/integration_test.rs | 2 +- rust/tests/session_test.rs | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index abb1a72a4..464c599a3 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -331,7 +331,7 @@ pub struct ClientOptions { /// Arguments prepended before `--server` (e.g. the script path for node). pub prefix_args: Vec, /// Working directory for the CLI process. - pub cwd: PathBuf, + pub working_directory: PathBuf, /// Environment variables set on the child process. pub env: Vec<(OsString, OsString)>, /// Environment variable names to remove from the child process. @@ -416,7 +416,7 @@ impl std::fmt::Debug for ClientOptions { f.debug_struct("ClientOptions") .field("program", &self.program) .field("prefix_args", &self.prefix_args) - .field("cwd", &self.cwd) + .field("working_directory", &self.working_directory) .field("env", &self.env) .field("env_remove", &self.env_remove) .field("extra_args", &self.extra_args) @@ -638,7 +638,7 @@ impl Default for ClientOptions { Self { program: CliProgram::Resolve, prefix_args: Vec::new(), - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), env: Vec::new(), env_remove: Vec::new(), extra_args: Vec::new(), @@ -696,7 +696,7 @@ impl ClientOptions { /// Working directory for the CLI process. pub fn with_cwd(mut self, cwd: impl Into) -> Self { - self.cwd = cwd.into(); + self.working_directory = cwd.into(); self } @@ -865,7 +865,7 @@ pub struct Client { impl std::fmt::Debug for Client { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Client") - .field("cwd", &self.inner.cwd) + .field("working_directory", &self.inner.cwd) .field("pid", &self.pid()) .finish() } @@ -1008,7 +1008,7 @@ impl Client { reader, writer, None, - options.cwd, + options.working_directory, options.on_list_models, session_fs_config.is_some(), session_fs_sqlite_declared, @@ -1031,7 +1031,7 @@ impl Client { reader, writer, Some(child), - options.cwd, + options.working_directory, options.on_list_models, session_fs_config.is_some(), session_fs_sqlite_declared, @@ -1048,7 +1048,7 @@ impl Client { stdout, stdin, Some(child), - options.cwd, + options.working_directory, options.on_list_models, session_fs_config.is_some(), session_fs_sqlite_declared, @@ -1293,7 +1293,7 @@ impl Client { command.env_remove(key); } command - .current_dir(&options.cwd) + .current_dir(&options.working_directory) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -1350,7 +1350,7 @@ impl Client { } fn spawn_stdio(program: &Path, options: &ClientOptions) -> Result { - info!(cwd = ?options.cwd, program = %program.display(), "spawning copilot CLI (stdio)"); + info!(cwd = ?options.working_directory, program = %program.display(), "spawning copilot CLI (stdio)"); let mut command = Self::build_command(program, options); let log_level = options.log_level.unwrap_or(LogLevel::Info); command @@ -1380,7 +1380,7 @@ impl Client { options: &ClientOptions, port: u16, ) -> Result<(Child, u16), Error> { - info!(cwd = ?options.cwd, program = %program.display(), port = %port, "spawning copilot CLI (tcp)"); + info!(cwd = ?options.working_directory, program = %program.display(), port = %port, "spawning copilot CLI (tcp)"); let mut command = Self::build_command(program, options); let log_level = options.log_level.unwrap_or(LogLevel::Info); command @@ -2058,7 +2058,7 @@ mod tests { .with_remote(true); assert!(matches!(opts.program, CliProgram::Path(_))); assert_eq!(opts.prefix_args, vec![std::ffi::OsString::from("node")]); - assert_eq!(opts.cwd, PathBuf::from("/tmp")); + assert_eq!(opts.working_directory, PathBuf::from("/tmp")); assert_eq!( opts.env, vec![( diff --git a/rust/src/types.rs b/rust/src/types.rs index 70f0c16b7..94e694b8d 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -788,8 +788,8 @@ pub struct McpStdioServerConfig { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub env: HashMap, /// Working directory for the subprocess. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "cwd")] + pub working_directory: Option, } /// Configuration for a remote MCP server (HTTP or SSE). @@ -2971,8 +2971,8 @@ pub struct ListSessionsResponse { #[serde(rename_all = "camelCase")] pub struct SessionListFilter { /// Filter by exact `cwd` match. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "cwd")] + pub working_directory: Option, /// Filter by git root path. #[serde(default, skip_serializing_if = "Option::is_none")] pub git_root: Option, diff --git a/rust/tests/integration_test.rs b/rust/tests/integration_test.rs index 90e2e1c7a..a02bf01c0 100644 --- a/rust/tests/integration_test.rs +++ b/rust/tests/integration_test.rs @@ -7,7 +7,7 @@ use github_copilot_sdk::{Client, ClientOptions, SDK_PROTOCOL_VERSION}; fn default_options() -> ClientOptions { let mut opts = ClientOptions::default(); - opts.cwd = std::env::current_dir().expect("cwd"); + opts.working_directory = std::env::current_dir().expect("cwd"); opts } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index b9c28d30d..e055b17fd 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -543,7 +543,7 @@ fn mcp_server_config_roundtrips_through_tagged_enum() { command: "node".to_string(), args: vec!["server.js".to_string()], env: HashMap::new(), - cwd: None, + working_directory: None, tools: vec!["*".to_string()], timeout: None, }); From ccaa5b38f9f7cd408ecb58e1cc1438db67f7f916 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 16:17:21 -0400 Subject: [PATCH 08/15] Rename cwd to working_directory in Python public API Rename all public API fields named 'cwd' to 'working_directory' and 'initial_cwd' to 'initial_working_directory' in the Python SDK while preserving the wire format (JSON sent to/from the Copilot CLI runtime still uses 'cwd' and 'initialCwd'). Fields renamed: - SubprocessConfig.cwd -> working_directory - MCPStdioServerConfig['cwd'] -> working_directory - SessionContext.cwd -> working_directory - SessionListFilter.cwd -> working_directory - SessionFsConfig['initial_cwd'] -> initial_working_directory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/copilot/client.py | 48 ++++++++++++------- python/copilot/session.py | 4 +- python/e2e/test_client_lifecycle_e2e.py | 2 +- python/e2e/test_client_options_e2e.py | 4 +- python/e2e/test_commands_e2e.py | 2 +- python/e2e/test_connection_token.py | 2 +- python/e2e/test_mcp_and_agents_e2e.py | 2 +- python/e2e/test_multi_client_e2e.py | 2 +- python/e2e/test_pending_work_resume_e2e.py | 2 +- python/e2e/test_per_session_auth_e2e.py | 2 +- python/e2e/test_pre_mcp_tool_call_hook_e2e.py | 2 +- python/e2e/test_rpc_server_e2e.py | 2 +- python/e2e/test_session_e2e.py | 12 ++--- python/e2e/test_session_fs_e2e.py | 6 +-- python/e2e/test_session_fs_sqlite_e2e.py | 4 +- python/e2e/test_streaming_fidelity_e2e.py | 4 +- python/e2e/test_subagent_hooks_e2e.py | 2 +- python/e2e/test_suspend_e2e.py | 2 +- python/e2e/test_telemetry_e2e.py | 2 +- .../test_ui_elicitation_multi_client_e2e.py | 2 +- python/e2e/testharness/context.py | 2 +- python/test_client.py | 6 +-- 22 files changed, 66 insertions(+), 50 deletions(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index 6adb52061..7af3fb39f 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -114,14 +114,30 @@ def _cloud_session_options_to_dict(options: CloudSessionOptions) -> dict[str, An def _validate_session_fs_config(config: SessionFsConfig) -> None: - if not config.get("initial_cwd"): - raise ValueError("session_fs.initial_cwd is required") + if not config.get("initial_working_directory"): + raise ValueError("session_fs.initial_working_directory is required") if not config.get("session_state_path"): raise ValueError("session_fs.session_state_path is required") if config.get("conventions") not in ("posix", "windows"): raise ValueError("session_fs.conventions must be either 'posix' or 'windows'") +def _mcp_servers_to_wire( + servers: dict[str, Any], +) -> dict[str, Any]: + """Convert MCP server configs from public API format to wire format. + + Renames ``working_directory`` key to ``cwd`` in each server config dict. + """ + wire: dict[str, Any] = {} + for name, config in servers.items(): + if "working_directory" in config: + config = {**config, "cwd": config["working_directory"]} + del config["working_directory"] + wire[name] = config + return wire + + class TelemetryConfig(TypedDict, total=False): """Configuration for OpenTelemetry integration with the Copilot CLI.""" @@ -161,7 +177,7 @@ class SubprocessConfig: _: KW_ONLY - cwd: str | None = None + working_directory: str | None = None """Working directory for the CLI process. ``None`` uses the current directory.""" use_stdio: bool = True @@ -661,7 +677,7 @@ def to_dict(self) -> dict: class SessionContext: """Working directory context for a session""" - cwd: str # Working directory where the session was created + working_directory: str # Working directory where the session was created gitRoot: str | None = None # Git repository root (if in a git repo) repository: str | None = None # GitHub repository in "owner/repo" format branch: str | None = None # Current git branch @@ -673,14 +689,14 @@ def from_dict(obj: Any) -> SessionContext: if cwd is None: raise ValueError("Missing required field 'cwd' in SessionContext") return SessionContext( - cwd=str(cwd), + working_directory=str(cwd), gitRoot=obj.get("gitRoot"), repository=obj.get("repository"), branch=obj.get("branch"), ) def to_dict(self) -> dict: - result: dict = {"cwd": self.cwd} + result: dict = {"cwd": self.working_directory} if self.gitRoot is not None: result["gitRoot"] = self.gitRoot if self.repository is not None: @@ -694,15 +710,15 @@ def to_dict(self) -> dict: class SessionListFilter: """Filter options for listing sessions""" - cwd: str | None = None # Filter by exact cwd match + working_directory: str | None = None # Filter by exact working directory match gitRoot: str | None = None # Filter by git root repository: str | None = None # Filter by repository (owner/repo format) branch: str | None = None # Filter by branch def to_dict(self) -> dict: result: dict = {} - if self.cwd is not None: - result["cwd"] = self.cwd + if self.working_directory is not None: + result["cwd"] = self.working_directory if self.gitRoot is not None: result["gitRoot"] = self.gitRoot if self.repository is not None: @@ -1555,7 +1571,7 @@ async def create_session( # Add MCP servers configuration if provided if mcp_servers: - payload["mcpServers"] = mcp_servers + payload["mcpServers"] = _mcp_servers_to_wire(mcp_servers) payload["envValueMode"] = "direct" # Add custom agents configuration if provided @@ -1928,7 +1944,7 @@ async def resume_session( # TODO: disable_resume is not a keyword arg yet; keeping for future use if mcp_servers: - payload["mcpServers"] = mcp_servers + payload["mcpServers"] = _mcp_servers_to_wire(mcp_servers) payload["envValueMode"] = "direct" if custom_agents: @@ -2193,8 +2209,8 @@ async def list_sessions(self, filter: SessionListFilter | None = None) -> list[S Returns metadata about each session including ID, timestamps, and summary. Args: - filter: Optional filter to narrow down the list of sessions by cwd, git root, - repository, or branch. + filter: Optional filter to narrow down the list of sessions by working directory, + git root, repository, or branch. Returns: A list of SessionMetadata objects. @@ -2563,7 +2579,7 @@ def _convert_custom_agent_to_wire_format( if "tools" in agent: wire_agent["tools"] = agent["tools"] if "mcp_servers" in agent: - wire_agent["mcpServers"] = agent["mcp_servers"] + wire_agent["mcpServers"] = _mcp_servers_to_wire(agent["mcp_servers"]) if "infer" in agent: wire_agent["infer"] = agent["infer"] if "skills" in agent: @@ -2683,7 +2699,7 @@ async def _start_cli_server(self) -> None: # On Windows, hide the console window to avoid distracting users in GUI apps creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 - cwd = cfg.cwd or os.getcwd() + cwd = cfg.working_directory or os.getcwd() # Choose transport mode spawn_start = time.perf_counter() @@ -2964,7 +2980,7 @@ async def _set_session_fs_provider(self) -> None: return params: dict[str, Any] = { - "initialCwd": self._session_fs_config["initial_cwd"], + "initialCwd": self._session_fs_config["initial_working_directory"], "sessionStatePath": self._session_fs_config["session_state_path"], "conventions": self._session_fs_config["conventions"], } diff --git a/python/copilot/session.py b/python/copilot/session.py index 0e66ef09f..caf2e3020 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -84,7 +84,7 @@ class SessionFsCapabilities(TypedDict, total=False): class SessionFsConfig(TypedDict): - initial_cwd: str + initial_working_directory: str session_state_path: str conventions: SessionFsConventions capabilities: NotRequired[SessionFsCapabilities] @@ -820,7 +820,7 @@ class MCPStdioServerConfig(TypedDict, total=False): command: str # Command to run args: NotRequired[list[str]] # Command arguments env: NotRequired[dict[str, str]] # Environment variables - cwd: NotRequired[str] # Working directory + working_directory: NotRequired[str] # Working directory class MCPHTTPServerConfig(TypedDict, total=False): diff --git a/python/e2e/test_client_lifecycle_e2e.py b/python/e2e/test_client_lifecycle_e2e.py index f667432a5..90b96d822 100644 --- a/python/e2e/test_client_lifecycle_e2e.py +++ b/python/e2e/test_client_lifecycle_e2e.py @@ -62,7 +62,7 @@ def _make_isolated_client(ctx: E2ETestContext) -> CopilotClient: return CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=github_token, ) diff --git a/python/e2e/test_client_options_e2e.py b/python/e2e/test_client_options_e2e.py index 7992524d1..80a3bf394 100644 --- a/python/e2e/test_client_options_e2e.py +++ b/python/e2e/test_client_options_e2e.py @@ -32,7 +32,7 @@ def _make_subprocess_config(ctx: E2ETestContext, **overrides) -> SubprocessConfig: base = { "cli_path": ctx.cli_path, - "cwd": ctx.work_dir, + "working_directory": ctx.work_dir, "env": ctx.get_env(), "github_token": ( "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None @@ -187,7 +187,7 @@ async def test_should_use_client_cwd_for_default_workingdirectory(self, ctx: E2E with open(os.path.join(client_cwd, "marker.txt"), "w") as f: f.write("I am in the client cwd") - client = CopilotClient(_make_subprocess_config(ctx, cwd=client_cwd)) + client = CopilotClient(_make_subprocess_config(ctx, working_directory=client_cwd)) try: session = await client.create_session( on_permission_request=PermissionHandler.approve_all, diff --git a/python/e2e/test_commands_e2e.py b/python/e2e/test_commands_e2e.py index a1c44b7b3..5bf1a274e 100644 --- a/python/e2e/test_commands_e2e.py +++ b/python/e2e/test_commands_e2e.py @@ -58,7 +58,7 @@ async def setup(self): self._client1 = CopilotClient( SubprocessConfig( cli_path=self.cli_path, - cwd=self.work_dir, + working_directory=self.work_dir, env=self._get_env(), use_stdio=False, github_token=github_token, diff --git a/python/e2e/test_connection_token.py b/python/e2e/test_connection_token.py index 814af5965..195baaecc 100644 --- a/python/e2e/test_connection_token.py +++ b/python/e2e/test_connection_token.py @@ -49,7 +49,7 @@ async def setup(self): self._client = CopilotClient( SubprocessConfig( cli_path=self.cli_path, - cwd=self.work_dir, + working_directory=self.work_dir, env=self.get_env(), use_stdio=False, tcp_connection_token=self.token, diff --git a/python/e2e/test_mcp_and_agents_e2e.py b/python/e2e/test_mcp_and_agents_e2e.py index 1119a71f9..5033524f2 100644 --- a/python/e2e/test_mcp_and_agents_e2e.py +++ b/python/e2e/test_mcp_and_agents_e2e.py @@ -109,7 +109,7 @@ async def test_should_pass_literal_env_values_to_mcp_server_subprocess( "args": [TEST_MCP_SERVER], "tools": ["*"], "env": {"TEST_SECRET": "hunter2"}, - "cwd": TEST_HARNESS_DIR, + "working_directory": TEST_HARNESS_DIR, } } diff --git a/python/e2e/test_multi_client_e2e.py b/python/e2e/test_multi_client_e2e.py index 17b663865..06f671e94 100644 --- a/python/e2e/test_multi_client_e2e.py +++ b/python/e2e/test_multi_client_e2e.py @@ -55,7 +55,7 @@ async def setup(self): self._client1 = CopilotClient( SubprocessConfig( cli_path=self.cli_path, - cwd=self.work_dir, + working_directory=self.work_dir, env=self.get_env(), use_stdio=False, github_token=github_token, diff --git a/python/e2e/test_pending_work_resume_e2e.py b/python/e2e/test_pending_work_resume_e2e.py index 204e6cc94..be0e4feec 100644 --- a/python/e2e/test_pending_work_resume_e2e.py +++ b/python/e2e/test_pending_work_resume_e2e.py @@ -36,7 +36,7 @@ def _make_subprocess_client(ctx: E2ETestContext, *, use_stdio: bool = True) -> C return CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=github_token, use_stdio=use_stdio, diff --git a/python/e2e/test_per_session_auth_e2e.py b/python/e2e/test_per_session_auth_e2e.py index 7bc32bce2..b03945deb 100644 --- a/python/e2e/test_per_session_auth_e2e.py +++ b/python/e2e/test_per_session_auth_e2e.py @@ -99,7 +99,7 @@ async def test_should_return_unauthenticated_when_no_token_provided( no_token_client = CopilotClient( SubprocessConfig( cli_path=auth_ctx.cli_path, - cwd=auth_ctx.work_dir, + working_directory=auth_ctx.work_dir, env=env, use_logged_in_user=False, ) diff --git a/python/e2e/test_pre_mcp_tool_call_hook_e2e.py b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py index 57c8ee2c0..9a140dd38 100644 --- a/python/e2e/test_pre_mcp_tool_call_hook_e2e.py +++ b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py @@ -26,7 +26,7 @@ def meta_echo_mcp_config() -> dict[str, MCPServerConfig]: "meta-echo": { "command": "node", "args": [TEST_MCP_META_ECHO_SERVER], - "cwd": TEST_HARNESS_DIR, + "working_directory": TEST_HARNESS_DIR, "tools": ["*"], } } diff --git a/python/e2e/test_rpc_server_e2e.py b/python/e2e/test_rpc_server_e2e.py index ce293086b..f5dc9920d 100644 --- a/python/e2e/test_rpc_server_e2e.py +++ b/python/e2e/test_rpc_server_e2e.py @@ -58,7 +58,7 @@ def _make_authed_client(ctx: E2ETestContext, token: str) -> CopilotClient: return CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=env, github_token=token, ) diff --git a/python/e2e/test_session_e2e.py b/python/e2e/test_session_e2e.py index 062ce8d58..437ba6595 100644 --- a/python/e2e/test_session_e2e.py +++ b/python/e2e/test_session_e2e.py @@ -245,7 +245,7 @@ async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestCont new_client = CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=github_token, ) @@ -315,8 +315,8 @@ async def test_should_list_sessions(self, ctx: E2ETestContext): for session_data in sessions: assert hasattr(session_data, "context") if session_data.context is not None: - assert hasattr(session_data.context, "cwd") - assert isinstance(session_data.context.cwd, str) + assert hasattr(session_data.context, "working_directory") + assert isinstance(session_data.context.working_directory, str) async def test_should_delete_session(self, ctx: E2ETestContext): import asyncio @@ -372,8 +372,8 @@ async def test_should_get_session_metadata(self, ctx: E2ETestContext): # Verify context field is present if metadata.context is not None: - assert hasattr(metadata.context, "cwd") - assert isinstance(metadata.context.cwd, str) + assert hasattr(metadata.context, "working_directory") + assert isinstance(metadata.context.working_directory, str) # Verify non-existent session returns None not_found = await ctx.client.get_session_metadata("non-existent-session-id") @@ -832,7 +832,7 @@ async def test_should_list_sessions_with_context(self, ctx: E2ETestContext): assert all_sessions if our_session.context is not None: - assert isinstance(our_session.context.cwd, str) and our_session.context.cwd + assert isinstance(our_session.context.working_directory, str) and our_session.context.working_directory await session.disconnect() diff --git a/python/e2e/test_session_fs_e2e.py b/python/e2e/test_session_fs_e2e.py index 3b5487d00..0afb565ef 100644 --- a/python/e2e/test_session_fs_e2e.py +++ b/python/e2e/test_session_fs_e2e.py @@ -36,7 +36,7 @@ ) SESSION_FS_CONFIG: SessionFsConfig = { - "initial_cwd": "/", + "initial_working_directory": "/", "session_state_path": SESSION_STATE_PATH, "conventions": "posix", } @@ -47,7 +47,7 @@ async def session_fs_client(ctx: E2ETestContext): client = CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=DEFAULT_GITHUB_TOKEN, session_fs=SESSION_FS_CONFIG, @@ -119,7 +119,7 @@ async def test_should_reject_setprovider_when_sessions_already_exist(self, ctx: client1 = CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), use_stdio=False, github_token=DEFAULT_GITHUB_TOKEN, diff --git a/python/e2e/test_session_fs_sqlite_e2e.py b/python/e2e/test_session_fs_sqlite_e2e.py index 92d68e94b..38c15ae08 100644 --- a/python/e2e/test_session_fs_sqlite_e2e.py +++ b/python/e2e/test_session_fs_sqlite_e2e.py @@ -41,7 +41,7 @@ ) SESSION_FS_CONFIG: SessionFsConfig = { - "initial_cwd": "/", + "initial_working_directory": "/", "session_state_path": SESSION_STATE_PATH, "conventions": "posix", "capabilities": {"sqlite": True}, @@ -202,7 +202,7 @@ async def sqlite_client(ctx: E2ETestContext): client = CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=DEFAULT_GITHUB_TOKEN, session_fs=SESSION_FS_CONFIG, diff --git a/python/e2e/test_streaming_fidelity_e2e.py b/python/e2e/test_streaming_fidelity_e2e.py index c24aee55f..e47fb9911 100644 --- a/python/e2e/test_streaming_fidelity_e2e.py +++ b/python/e2e/test_streaming_fidelity_e2e.py @@ -81,7 +81,7 @@ async def test_should_produce_deltas_after_session_resume(self, ctx: E2ETestCont new_client = CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=github_token, ) @@ -133,7 +133,7 @@ async def test_should_not_produce_deltas_after_session_resume_with_streaming_dis new_client = CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=github_token, ) diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py index 57e19d5e5..e5262a23c 100644 --- a/python/e2e/test_subagent_hooks_e2e.py +++ b/python/e2e/test_subagent_hooks_e2e.py @@ -52,7 +52,7 @@ async def on_post_tool_use(input_data, invocation): client = CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=env, github_token=github_token, ) diff --git a/python/e2e/test_suspend_e2e.py b/python/e2e/test_suspend_e2e.py index e87659d93..ec34bfc37 100644 --- a/python/e2e/test_suspend_e2e.py +++ b/python/e2e/test_suspend_e2e.py @@ -33,7 +33,7 @@ def _make_subprocess_client(ctx: E2ETestContext, *, use_stdio: bool = True) -> C return CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=github_token, use_stdio=use_stdio, diff --git a/python/e2e/test_telemetry_e2e.py b/python/e2e/test_telemetry_e2e.py index acc3c3260..6b1f7766c 100644 --- a/python/e2e/test_telemetry_e2e.py +++ b/python/e2e/test_telemetry_e2e.py @@ -84,7 +84,7 @@ def echo(invocation: ToolInvocation) -> ToolResult: client = CopilotClient( SubprocessConfig( cli_path=ctx.cli_path, - cwd=ctx.work_dir, + working_directory=ctx.work_dir, env=ctx.get_env(), github_token=github_token, telemetry=TelemetryConfig( diff --git a/python/e2e/test_ui_elicitation_multi_client_e2e.py b/python/e2e/test_ui_elicitation_multi_client_e2e.py index 8da62f3de..97f989ac4 100644 --- a/python/e2e/test_ui_elicitation_multi_client_e2e.py +++ b/python/e2e/test_ui_elicitation_multi_client_e2e.py @@ -65,7 +65,7 @@ async def setup(self): self._client1 = CopilotClient( SubprocessConfig( cli_path=self.cli_path, - cwd=self.work_dir, + working_directory=self.work_dir, env=self._get_env(), use_stdio=False, github_token=github_token, diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index dc31cfe92..d67311598 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -83,7 +83,7 @@ async def setup(self, cli_args: list[str] | None = None): SubprocessConfig( cli_path=self.cli_path, cli_args=cli_args or [], - cwd=self.work_dir, + working_directory=self.work_dir, env=self.get_env(), github_token=DEFAULT_GITHUB_TOKEN, ) diff --git a/python/test_client.py b/python/test_client.py index c03968c55..8add6975b 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -163,13 +163,13 @@ def test_is_external_server_true(self): class TestSessionFsConfig: def test_missing_initial_cwd(self): - with pytest.raises(ValueError, match="session_fs.initial_cwd is required"): + with pytest.raises(ValueError, match="session_fs.initial_working_directory is required"): CopilotClient( SubprocessConfig( cli_path=CLI_PATH, log_level="error", session_fs={ - "initial_cwd": "", + "initial_working_directory": "", "session_state_path": "/session-state", "conventions": "posix", }, @@ -183,7 +183,7 @@ def test_missing_session_state_path(self): cli_path=CLI_PATH, log_level="error", session_fs={ - "initial_cwd": "/", + "initial_working_directory": "/", "session_state_path": "", "conventions": "posix", }, From ecf11dbcac611e6746121390637d783ee2838e7a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 16:24:54 -0400 Subject: [PATCH 09/15] Rename cwd to workingDirectory in Node.js public API Rename the public-facing 'cwd' field to 'workingDirectory' in: - CopilotClientOptions.cwd - MCPStdioServerConfig.cwd - SessionContext.cwd - SessionListFilter.cwd The wire format (JSON sent to/from the Copilot CLI runtime) is preserved as 'cwd' via transformation layers in client.ts: - Outgoing: workingDirectory -> cwd (mcpServers, customAgents, listSessions filter) - Incoming: cwd -> workingDirectory (session context in toSessionMetadata) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 76 +++++++++++++++---- nodejs/src/types.ts | 15 ++-- nodejs/test/e2e/client_options.e2e.test.ts | 8 +- nodejs/test/e2e/harness/sdkTestContext.ts | 2 +- .../test/e2e/pending_work_resume.e2e.test.ts | 2 +- nodejs/test/e2e/per_session_auth.e2e.test.ts | 2 +- .../e2e/pre_mcp_tool_call_hook.e2e.test.ts | 6 +- nodejs/test/e2e/rpc_server.e2e.test.ts | 2 +- nodejs/test/e2e/session.e2e.test.ts | 8 +- nodejs/test/e2e/suspend.e2e.test.ts | 2 +- 10 files changed, 87 insertions(+), 36 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 991f23fa1..c9065e128 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -40,17 +40,18 @@ import type { AutoModeSwitchResponse, ConnectionState, CopilotClientOptions, + CustomAgentConfig, ExitPlanModeRequest, ExitPlanModeResult, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, InternalRuntimeConnection, + MCPServerConfig, ModelInfo, ResumeSessionConfig, SectionTransformFn, SessionConfig, - SessionContext, SessionEvent, SessionFsConfig, SessionLifecycleEvent, @@ -98,6 +99,38 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | return parameters; } +/** + * Convert MCP server configs from public API format (workingDirectory) to + * wire format (cwd) expected by the runtime. + */ +function toWireMcpServers( + mcpServers: Record | undefined, +): Record | undefined { + if (!mcpServers) return undefined; + return Object.fromEntries( + Object.entries(mcpServers).map(([name, server]) => { + if ("workingDirectory" in server) { + const { workingDirectory, ...rest } = server; + return [name, { ...rest, cwd: workingDirectory }]; + } + return [name, server]; + }), + ); +} + +/** + * Convert custom agent configs, transforming nested mcpServers from + * public API format (workingDirectory) to wire format (cwd). + */ +function toWireCustomAgents(agents: CustomAgentConfig[] | undefined): unknown[] | undefined { + if (!agents) return undefined; + return agents.map((agent) => { + if (!agent.mcpServers) return agent; + const { mcpServers, ...rest } = agent; + return { ...rest, mcpServers: toWireMcpServers(mcpServers) }; + }); +} + /** * Extract transform callbacks from a system message config and prepare the wire payload. * Function-valued actions are replaced with `{ action: "transform" }` for serialization, @@ -228,7 +261,7 @@ export class CopilotClient { /** Resolved environment passed to the spawned runtime. */ private resolvedEnv: Record; private options: { - cwd: string; + workingDirectory: string; logLevel?: string; gitHubToken?: string; useLoggedInUser: boolean; @@ -374,7 +407,7 @@ export class CopilotClient { this.connectionExtraArgs = [...connArgs]; this.options = { - cwd: options.cwd ?? process.cwd(), + workingDirectory: options.workingDirectory ?? process.cwd(), logLevel: options.logLevel, gitHubToken: options.gitHubToken, // Default useLoggedInUser to false when gitHubToken is provided, otherwise true. @@ -839,9 +872,9 @@ export class CopilotClient { workingDirectory: config.workingDirectory, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, - mcpServers: config.mcpServers, + mcpServers: toWireMcpServers(config.mcpServers), envValueMode: "direct", - customAgents: config.customAgents, + customAgents: toWireCustomAgents(config.customAgents), defaultAgent: config.defaultAgent, agent: config.agent, configDir: config.configDir, @@ -976,9 +1009,9 @@ export class CopilotClient { enableConfigDiscovery: config.enableConfigDiscovery, streaming: config.streaming, includeSubAgentStreamingEvents: config.includeSubAgentStreamingEvents ?? true, - mcpServers: config.mcpServers, + mcpServers: toWireMcpServers(config.mcpServers), envValueMode: "direct", - customAgents: config.customAgents, + customAgents: toWireCustomAgents(config.customAgents), defaultAgent: config.defaultAgent, agent: config.agent, skillDirectories: config.skillDirectories, @@ -1272,8 +1305,15 @@ export class CopilotClient { throw new Error("Client not connected"); } + // Transform filter to wire format (workingDirectory → cwd) + let wireFilter: Record | undefined; + if (filter) { + const { workingDirectory, ...rest } = filter; + wireFilter = { ...rest, cwd: workingDirectory }; + } + const response = await this.connection.sendRequest("session.list", { - filter, + filter: wireFilter, }); const { sessions } = response as { sessions: Array<{ @@ -1282,7 +1322,7 @@ export class CopilotClient { modifiedTime: string; summary?: string; isRemote: boolean; - context?: SessionContext; + context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }>; }; @@ -1320,7 +1360,7 @@ export class CopilotClient { modifiedTime: string; summary?: string; isRemote: boolean; - context?: SessionContext; + context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }; }; @@ -1337,15 +1377,23 @@ export class CopilotClient { modifiedTime: string; summary?: string; isRemote: boolean; - context?: SessionContext; + context?: { cwd: string; gitRoot?: string; repository?: string; branch?: string }; }): SessionMetadata { + const { context } = raw; return { sessionId: raw.sessionId, startTime: new Date(raw.startTime), modifiedTime: new Date(raw.modifiedTime), summary: raw.summary, isRemote: raw.isRemote, - context: raw.context, + context: context + ? { + workingDirectory: context.cwd, + gitRoot: context.gitRoot, + repository: context.repository, + branch: context.branch, + } + : undefined, }; } @@ -1591,14 +1639,14 @@ export class CopilotClient { if (isJsFile) { this.cliProcess = spawn(getNodeExecPath(), [this.resolvedCliPath, ...args], { stdio: stdioConfig, - cwd: this.options.cwd, + cwd: this.options.workingDirectory, env: envWithoutNodeDebug, windowsHide: true, }); } else { this.cliProcess = spawn(this.resolvedCliPath, args, { stdio: stdioConfig, - cwd: this.options.cwd, + cwd: this.options.workingDirectory, env: envWithoutNodeDebug, windowsHide: true, }); diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index ed262210a..f31b1ab27 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -176,7 +176,7 @@ export interface CopilotClientOptions { * Working directory for the runtime process. * If not set, inherits the current process's working directory. */ - cwd?: string; + workingDirectory?: string; /** * Base directory for Copilot data (session state, config, etc.). @@ -1275,7 +1275,10 @@ export interface MCPStdioServerConfig extends MCPServerConfigBase { * Environment variables to pass to the server. */ env?: Record; - cwd?: string; + /** + * Working directory for the server process. + */ + workingDirectory?: string; } /** @@ -1847,7 +1850,7 @@ export type ConnectionState = "disconnected" | "connecting" | "connected" | "err */ export interface SessionContext { /** Working directory where the session was created */ - cwd: string; + workingDirectory: string; /** Git repository root (if in a git repo) */ gitRoot?: string; /** GitHub repository in "owner/repo" format */ @@ -1895,8 +1898,8 @@ export interface SessionFsConfig { * Filter options for listing sessions */ export interface SessionListFilter { - /** Filter by exact cwd match */ - cwd?: string; + /** Filter by exact working directory match */ + workingDirectory?: string; /** Filter by git root */ gitRoot?: string; /** Filter by repository (owner/repo format) */ @@ -1914,7 +1917,7 @@ export interface SessionMetadata { modifiedTime: Date; summary?: string; isRemote: boolean; - /** Working directory context (cwd, git info) from session creation */ + /** Working directory context (working directory, git info) from session creation */ context?: SessionContext; } diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index e199af0ac..5174c9246 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -142,7 +142,7 @@ describe("Client options", async () => { it("createSession starts the client lazily", async () => { const client = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env, connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), }); @@ -166,7 +166,7 @@ describe("Client options", async () => { it("should listen on configured tcp port", async () => { const port = await getAvailableTcpPort(); const client = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, @@ -200,7 +200,7 @@ describe("Client options", async () => { // a custom cwd to assert that the custom cwd is honored. void defaultClient; const client = new CopilotClient({ - cwd: clientCwd, + workingDirectory: clientCwd, env, connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), gitHubToken: process.env.CI ? "fake-token-for-e2e-tests" : undefined, @@ -239,7 +239,7 @@ describe("Client options", async () => { fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); const client = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env: { ...env, COPILOT_HOME: copilotHomeFromEnv }, connection: RuntimeConnection.forStdio({ path: cliPath, diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index 17737d5a3..d7eff3d59 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -98,7 +98,7 @@ export async function createSdkTestContext({ const { connection: _ignoredConnection, ...remainingClientOptions } = copilotClientOptions ?? {}; const copilotClient = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env, logLevel: logLevel || "error", connection, diff --git a/nodejs/test/e2e/pending_work_resume.e2e.test.ts b/nodejs/test/e2e/pending_work_resume.e2e.test.ts index 3bea1b417..bc1937bad 100644 --- a/nodejs/test/e2e/pending_work_resume.e2e.test.ts +++ b/nodejs/test/e2e/pending_work_resume.e2e.test.ts @@ -127,7 +127,7 @@ describe("Pending work resume", async () => { function createTcpServer(): CopilotClient { const server = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, diff --git a/nodejs/test/e2e/per_session_auth.e2e.test.ts b/nodejs/test/e2e/per_session_auth.e2e.test.ts index 3b07b664e..0bb1dbd4e 100644 --- a/nodejs/test/e2e/per_session_auth.e2e.test.ts +++ b/nodejs/test/e2e/per_session_auth.e2e.test.ts @@ -77,7 +77,7 @@ describe("Per-session GitHub auth", async () => { it("should return unauthenticated when no token is provided", async () => { const noTokenClient = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env: withoutAuthEnv({ ...env, COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, diff --git a/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts b/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts index 4320b7805..571113239 100644 --- a/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts +++ b/nodejs/test/e2e/pre_mcp_tool_call_hook.e2e.test.ts @@ -29,7 +29,7 @@ describe("pre_mcp_tool_call_hook", async () => { "meta-echo": { command: "node", args: [TEST_MCP_META_ECHO_SERVER], - cwd: TEST_HARNESS_DIR, + workingDirectory: TEST_HARNESS_DIR, tools: ["*"], } as MCPStdioServerConfig, }, @@ -67,7 +67,7 @@ describe("pre_mcp_tool_call_hook", async () => { "meta-echo": { command: "node", args: [TEST_MCP_META_ECHO_SERVER], - cwd: TEST_HARNESS_DIR, + workingDirectory: TEST_HARNESS_DIR, tools: ["*"], } as MCPStdioServerConfig, }, @@ -103,7 +103,7 @@ describe("pre_mcp_tool_call_hook", async () => { "meta-echo": { command: "node", args: [TEST_MCP_META_ECHO_SERVER], - cwd: TEST_HARNESS_DIR, + workingDirectory: TEST_HARNESS_DIR, tools: ["*"], } as MCPStdioServerConfig, }, diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index 27f07cafd..78c768ac1 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -17,7 +17,7 @@ describe("Server-scoped RPC", async () => { COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, }; const authClient = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env: childEnv, logLevel: "error", connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), diff --git a/nodejs/test/e2e/session.e2e.test.ts b/nodejs/test/e2e/session.e2e.test.ts index deef6e339..fe6d4b9b2 100644 --- a/nodejs/test/e2e/session.e2e.test.ts +++ b/nodejs/test/e2e/session.e2e.test.ts @@ -21,7 +21,7 @@ describe("Sessions", async () => { "createSession works without onPermissionRequest (%s)", async (_name, makeConnection) => { const standaloneClient = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env, connection: makeConnection(), }); @@ -43,7 +43,7 @@ describe("Sessions", async () => { const connectionToken = "client-e2e-resume-token"; const tcpClient = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, @@ -66,7 +66,7 @@ describe("Sessions", async () => { } const resumeClient = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env, connection: RuntimeConnection.forUri(`localhost:${port}`, { connectionToken }), }); @@ -120,7 +120,7 @@ describe("Sessions", async () => { expect(ourSession).toBeDefined(); // Context may not be populated if workspace.yaml hasn't been written yet if (ourSession?.context) { - expect(ourSession.context.cwd).toMatch(/^(\/|[A-Za-z]:)/); + expect(ourSession.context.workingDirectory).toMatch(/^(\/|[A-Za-z]:)/); } }); diff --git a/nodejs/test/e2e/suspend.e2e.test.ts b/nodejs/test/e2e/suspend.e2e.test.ts index db4ab3936..a3820f739 100644 --- a/nodejs/test/e2e/suspend.e2e.test.ts +++ b/nodejs/test/e2e/suspend.e2e.test.ts @@ -63,7 +63,7 @@ describe("Suspend RPC", async () => { function createTcpServer(): CopilotClient { const server = new CopilotClient({ - cwd: workDir, + workingDirectory: workDir, env, connection: RuntimeConnection.forTcp({ path: process.env.COPILOT_CLI_PATH, From 447c9ce844d4c83bcf870e1980c7c49d2c4abf1f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 16:29:31 -0400 Subject: [PATCH 10/15] Fix formatting in Node.js and Python after cwd rename Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 4 ++-- python/e2e/test_session_e2e.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index c9065e128..4815c2074 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -104,7 +104,7 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | * wire format (cwd) expected by the runtime. */ function toWireMcpServers( - mcpServers: Record | undefined, + mcpServers: Record | undefined ): Record | undefined { if (!mcpServers) return undefined; return Object.fromEntries( @@ -114,7 +114,7 @@ function toWireMcpServers( return [name, { ...rest, cwd: workingDirectory }]; } return [name, server]; - }), + }) ); } diff --git a/python/e2e/test_session_e2e.py b/python/e2e/test_session_e2e.py index 437ba6595..d5a0c970e 100644 --- a/python/e2e/test_session_e2e.py +++ b/python/e2e/test_session_e2e.py @@ -832,7 +832,10 @@ async def test_should_list_sessions_with_context(self, ctx: E2ETestContext): assert all_sessions if our_session.context is not None: - assert isinstance(our_session.context.working_directory, str) and our_session.context.working_directory + assert ( + isinstance(our_session.context.working_directory, str) + and our_session.context.working_directory + ) await session.disconnect() From a73f8df000bc9f42204ed22b2941fa5c061d3f6c Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 16:39:52 -0400 Subject: [PATCH 11/15] Fix Rust E2E test: use working_directory in McpStdioServerConfig Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/tests/e2e/pre_mcp_tool_call_hook.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/tests/e2e/pre_mcp_tool_call_hook.rs b/rust/tests/e2e/pre_mcp_tool_call_hook.rs index 0157c026d..32f97cda1 100644 --- a/rust/tests/e2e/pre_mcp_tool_call_hook.rs +++ b/rust/tests/e2e/pre_mcp_tool_call_hook.rs @@ -27,7 +27,7 @@ fn meta_echo_mcp_servers(repo_root: &std::path::Path) -> HashMap Date: Thu, 21 May 2026 17:39:14 -0400 Subject: [PATCH 12/15] Fix flaky tests: increase CLI start timeout and add missing snapshot - Node.js: Increase CLI server start timeout from 10s to 30s to accommodate slow Windows CI runners - Java: Add missing conversation to mcp_and_agents/should_accept_both_mcp_servers_and_custom_agents snapshot (was empty, causing proxy 500 errors) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 4 ++-- ...should_accept_both_mcp_servers_and_custom_agents.yaml | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 4815c2074..f4b558024 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1739,13 +1739,13 @@ export class CopilotClient { } }); - // Timeout after 10 seconds + // Timeout after 30 seconds (Windows CI runners can be slow to spawn processes) this.cliStartTimeout = setTimeout(() => { if (!resolved) { resolved = true; reject(new Error("Timeout waiting for CLI server to start")); } - }, 10000); + }, 30000); }); } diff --git a/test/snapshots/mcp_and_agents/should_accept_both_mcp_servers_and_custom_agents.yaml b/test/snapshots/mcp_and_agents/should_accept_both_mcp_servers_and_custom_agents.yaml index 056351ddb..60d1eadea 100644 --- a/test/snapshots/mcp_and_agents/should_accept_both_mcp_servers_and_custom_agents.yaml +++ b/test/snapshots/mcp_and_agents/should_accept_both_mcp_servers_and_custom_agents.yaml @@ -1,3 +1,10 @@ models: - claude-sonnet-4.5 -conversations: [] +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 7+7? + - role: assistant + content: 7 + 7 = 14 From 15292885d96ce9c0b3487b3769b877be7a20a6e4 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 17:39:45 -0400 Subject: [PATCH 13/15] Update Java .lastmerge to include snapshot fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- java/.lastmerge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java/.lastmerge b/java/.lastmerge index 531108354..62c7fb670 100644 --- a/java/.lastmerge +++ b/java/.lastmerge @@ -1 +1 @@ -202064794ff4dbd57911535a163564c7688a77f8 +553e662b83b2ba502d8a346cf898fb63bb3b12dc From 20cbe82eb07f0358f48b9a13f8772c4e64292ae4 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 18:06:38 -0400 Subject: [PATCH 14/15] Rename Cwd/InitialCwd to WorkingDirectory/InitialWorkingDirectory in Go and .NET MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the cwd → workingDirectory rename across all SDKs for consistency. Wire format (JSON) is preserved via struct tags and JsonPropertyName attributes. Go: - ClientOptions.Cwd → WorkingDirectory - SessionFsConfig.InitialCwd → InitialWorkingDirectory - SessionContext.Cwd → WorkingDirectory (json:"cwd") - SessionListFilter.Cwd → WorkingDirectory (json:"cwd,omitempty") .NET: - SessionFsConfig.InitialCwd → InitialWorkingDirectory ([JsonPropertyName("initialCwd")]) - SessionContext.Cwd → WorkingDirectory ([JsonPropertyName("cwd")]) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 2 +- dotnet/src/Types.cs | 6 ++++-- dotnet/test/E2E/SessionE2ETests.cs | 2 +- dotnet/test/E2E/SessionFsE2ETests.cs | 2 +- dotnet/test/E2E/SessionFsSqliteE2ETests.cs | 2 +- go/client.go | 12 ++++++------ go/client_test.go | 10 +++++----- go/internal/e2e/client_options_e2e_test.go | 2 +- go/internal/e2e/session_e2e_test.go | 8 ++++---- go/internal/e2e/session_fs_e2e_test.go | 2 +- go/internal/e2e/session_fs_sqlite_e2e_test.go | 2 +- go/internal/e2e/testharness/context.go | 2 +- go/types.go | 16 ++++++++-------- 13 files changed, 35 insertions(+), 33 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 07814ca75..285c97a56 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1233,7 +1233,7 @@ private async Task ConfigureSessionFsAsync(CancellationToken cancellationToken) } await Rpc.SessionFs.SetProviderAsync( - _options.SessionFs.InitialCwd, + _options.SessionFs.InitialWorkingDirectory, _options.SessionFs.SessionStatePath, _options.SessionFs.Conventions, _options.SessionFs.Capabilities, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 7775c3c50..bb72820e5 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -396,7 +396,8 @@ public sealed class SessionFsConfig /// /// Initial working directory for sessions (user's project directory). /// - public required string InitialCwd { get; init; } + [JsonPropertyName("initialCwd")] + public required string InitialWorkingDirectory { get; init; } /// /// Path within each session's SessionFs where the runtime stores @@ -2600,7 +2601,8 @@ public MessageOptions Clone() public sealed class SessionContext { /// Working directory where the session was created. - public string Cwd { get; set; } = string.Empty; + [JsonPropertyName("cwd")] + public string WorkingDirectory { get; set; } = string.Empty; /// Git repository root (if in a git repo). public string? GitRoot { get; set; } /// GitHub repository in "owner/repo" format. diff --git a/dotnet/test/E2E/SessionE2ETests.cs b/dotnet/test/E2E/SessionE2ETests.cs index 7711c86dc..d2c611e07 100644 --- a/dotnet/test/E2E/SessionE2ETests.cs +++ b/dotnet/test/E2E/SessionE2ETests.cs @@ -450,7 +450,7 @@ await TestHelper.WaitForConditionAsync( // Context may be present on sessions that have been persisted with workspace.yaml if (ourSession.Context != null) { - Assert.False(string.IsNullOrEmpty(ourSession.Context.Cwd), "Expected context.Cwd to be non-empty when context is present"); + Assert.False(string.IsNullOrEmpty(ourSession.Context.WorkingDirectory), "Expected context.WorkingDirectory to be non-empty when context is present"); } } diff --git a/dotnet/test/E2E/SessionFsE2ETests.cs b/dotnet/test/E2E/SessionFsE2ETests.cs index 7986cddcf..b2e41f024 100644 --- a/dotnet/test/E2E/SessionFsE2ETests.cs +++ b/dotnet/test/E2E/SessionFsE2ETests.cs @@ -15,7 +15,7 @@ public class SessionFsE2ETests(E2ETestFixture fixture, ITestOutputHelper output) { private static readonly SessionFsConfig SessionFsConfig = new() { - InitialCwd = "/", + InitialWorkingDirectory = "/", SessionStatePath = CreateSessionStatePath(), Conventions = SessionFsSetProviderConventions.Posix, }; diff --git a/dotnet/test/E2E/SessionFsSqliteE2ETests.cs b/dotnet/test/E2E/SessionFsSqliteE2ETests.cs index b01484f43..1e6175f9c 100644 --- a/dotnet/test/E2E/SessionFsSqliteE2ETests.cs +++ b/dotnet/test/E2E/SessionFsSqliteE2ETests.cs @@ -14,7 +14,7 @@ public class SessionFsSqliteE2ETests(E2ETestFixture fixture, ITestOutputHelper o { private static readonly SessionFsConfig SessionFsConfig = new() { - InitialCwd = "/", + InitialWorkingDirectory = "/", SessionStatePath = "/session-state", Conventions = SessionFsSetProviderConventions.Posix, Capabilities = new SessionFsSetProviderCapabilities { Sqlite = true }, diff --git a/go/client.go b/go/client.go index 4f8eb43e5..3c99cd555 100644 --- a/go/client.go +++ b/go/client.go @@ -58,8 +58,8 @@ func validateSessionFsConfig(config *SessionFsConfig) error { if config == nil { return nil } - if config.InitialCwd == "" { - return errors.New("SessionFs.InitialCwd is required") + if config.InitialWorkingDirectory == "" { + return errors.New("SessionFs.InitialWorkingDirectory is required") } if config.SessionStatePath == "" { return errors.New("SessionFs.SessionStatePath is required") @@ -353,7 +353,7 @@ func (c *Client) Start(ctx context.Context) error { // If a session filesystem provider was configured, register it. if c.options.SessionFs != nil { req := &rpc.SessionFsSetProviderRequest{ - InitialCwd: c.options.SessionFs.InitialCwd, + InitialCwd: c.options.SessionFs.InitialWorkingDirectory, SessionStatePath: c.options.SessionFs.SessionStatePath, Conventions: c.options.SessionFs.Conventions, } @@ -945,7 +945,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, // Returns a list of SessionMetadata for all available sessions, including their IDs, // timestamps, optional summaries, and context information. // -// An optional filter can be provided to filter sessions by cwd, git root, repository, or branch. +// An optional filter can be provided to filter sessions by working directory, git root, repository, or branch. // // Example: // @@ -1501,8 +1501,8 @@ func (c *Client) startCLIServer(ctx context.Context) error { configureProcAttr(c.process) // Set working directory if specified - if c.options.Cwd != "" { - c.process.Dir = c.options.Cwd + if c.options.WorkingDirectory != "" { + c.process.Dir = c.options.WorkingDirectory } c.process.Env = append([]string{}, c.options.Env...) diff --git a/go/client_test.go b/go/client_test.go index f249a8fa6..713dd443b 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -135,14 +135,14 @@ func TestClient_URLParsing(t *testing.T) { } func TestClient_SessionFsConfig(t *testing.T) { - t.Run("should throw error when InitialCwd is missing", func(t *testing.T) { + t.Run("should throw error when InitialWorkingDirectory is missing", func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Error("Expected panic for missing SessionFs.InitialCwd") + t.Error("Expected panic for missing SessionFs.InitialWorkingDirectory") } else { - matched, _ := regexp.MatchString("SessionFs.InitialCwd is required", r.(string)) + matched, _ := regexp.MatchString("SessionFs.InitialWorkingDirectory is required", r.(string)) if !matched { - t.Errorf("Expected panic message to contain 'SessionFs.InitialCwd is required', got: %v", r) + t.Errorf("Expected panic message to contain 'SessionFs.InitialWorkingDirectory is required', got: %v", r) } } }() @@ -169,7 +169,7 @@ func TestClient_SessionFsConfig(t *testing.T) { NewClient(&ClientOptions{ SessionFs: &SessionFsConfig{ - InitialCwd: "/", + InitialWorkingDirectory: "/", Conventions: rpc.SessionFsSetProviderConventionsPosix, }, }) diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index cfc6bd4e9..7f4121af2 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -56,7 +56,7 @@ func TestClientOptionsE2E(t *testing.T) { } client := ctx.NewClient(func(opts *copilot.ClientOptions) { - opts.Cwd = clientCwd + opts.WorkingDirectory = clientCwd }) t.Cleanup(func() { client.ForceStop() }) diff --git a/go/internal/e2e/session_e2e_test.go b/go/internal/e2e/session_e2e_test.go index bddd7e8e1..f45a74616 100644 --- a/go/internal/e2e/session_e2e_test.go +++ b/go/internal/e2e/session_e2e_test.go @@ -894,8 +894,8 @@ func TestSessionE2E(t *testing.T) { // Verify context field is present on sessions for _, s := range sessions { if s.Context != nil { - if s.Context.Cwd == "" { - t.Error("Expected context.Cwd to be non-empty when context is present") + if s.Context.WorkingDirectory == "" { + t.Error("Expected context.WorkingDirectory to be non-empty when context is present") } } } @@ -1006,8 +1006,8 @@ func TestSessionE2E(t *testing.T) { // Verify context field if metadata.Context != nil { - if metadata.Context.Cwd == "" { - t.Error("Expected context.Cwd to be non-empty when context is present") + if metadata.Context.WorkingDirectory == "" { + t.Error("Expected context.WorkingDirectory to be non-empty when context is present") } } diff --git a/go/internal/e2e/session_fs_e2e_test.go b/go/internal/e2e/session_fs_e2e_test.go index 2c014f9e0..73b1bcc0d 100644 --- a/go/internal/e2e/session_fs_e2e_test.go +++ b/go/internal/e2e/session_fs_e2e_test.go @@ -20,7 +20,7 @@ func TestSessionFsE2E(t *testing.T) { providerRoot := t.TempDir() sessionStatePath := createSessionStatePath(t) sessionFsConfig := &copilot.SessionFsConfig{ - InitialCwd: "/", + InitialWorkingDirectory: "/", SessionStatePath: sessionStatePath, Conventions: rpc.SessionFsSetProviderConventionsPosix, } diff --git a/go/internal/e2e/session_fs_sqlite_e2e_test.go b/go/internal/e2e/session_fs_sqlite_e2e_test.go index 3d453f0a8..354ff1197 100644 --- a/go/internal/e2e/session_fs_sqlite_e2e_test.go +++ b/go/internal/e2e/session_fs_sqlite_e2e_test.go @@ -243,7 +243,7 @@ func TestSessionFsSqliteE2E(t *testing.T) { ctx := testharness.NewTestContext(t) sessionStatePath := createSessionStatePath(t) sessionFsConfig := &copilot.SessionFsConfig{ - InitialCwd: "/", + InitialWorkingDirectory: "/", SessionStatePath: sessionStatePath, Conventions: rpc.SessionFsSetProviderConventionsPosix, Capabilities: &copilot.SessionFsCapabilities{Sqlite: true}, diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index 9055442a9..a54a09439 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -198,7 +198,7 @@ func (c *TestContext) Env() []string { func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.Client { options := &copilot.ClientOptions{ Connection: copilot.StdioConnection{Path: c.CLIPath}, - Cwd: c.WorkDir, + WorkingDirectory: c.WorkDir, Env: c.Env(), } diff --git a/go/types.go b/go/types.go index ed8b8a958..2760e9b2b 100644 --- a/go/types.go +++ b/go/types.go @@ -77,9 +77,9 @@ type ClientOptions struct { // defaults to an empty [StdioConnection] (spawn the bundled runtime over // stdio). Connection RuntimeConnection - // Cwd is the working directory for the runtime process. + // WorkingDirectory is the working directory for the runtime process. // If empty, inherits the current process's working directory. - Cwd string + WorkingDirectory string // BaseDirectory is the base directory for Copilot data (session state, // config, etc.). Sets the COPILOT_HOME environment variable on the // spawned runtime. When empty, the runtime defaults to ~/.copilot. @@ -803,8 +803,8 @@ type SessionFsCapabilities struct { // SessionFsConfig configures a custom session filesystem provider. type SessionFsConfig struct { - // InitialCwd is the initial working directory for sessions. - InitialCwd string + // InitialWorkingDirectory is the initial working directory for sessions. + InitialWorkingDirectory string // SessionStatePath is the path within each session's filesystem where the runtime stores // session-scoped files such as events, checkpoints, and temp files. SessionStatePath string @@ -1312,8 +1312,8 @@ type ModelInfo struct { // SessionContext contains working directory context for a session type SessionContext struct { - // Cwd is the working directory where the session was created - Cwd string `json:"cwd"` + // WorkingDirectory is the working directory where the session was created + WorkingDirectory string `json:"cwd"` // GitRoot is the git repository root (if in a git repo) GitRoot string `json:"gitRoot,omitempty"` // Repository is the GitHub repository in "owner/repo" format @@ -1324,8 +1324,8 @@ type SessionContext struct { // SessionListFilter contains filter options for listing sessions type SessionListFilter struct { - // Cwd filters by exact working directory match - Cwd string `json:"cwd,omitempty"` + // WorkingDirectory filters by exact working directory match + WorkingDirectory string `json:"cwd,omitempty"` // GitRoot filters by git root GitRoot string `json:"gitRoot,omitempty"` // Repository filters by repository (owner/repo format) From adf85dd4911cd9a41b61dd7998a840d944ed0cdb Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 21 May 2026 18:17:07 -0400 Subject: [PATCH 15/15] Fix missed Cwd reference and run go fmt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/client_test.go | 2 +- go/internal/e2e/per_session_auth_e2e_test.go | 8 ++++---- go/internal/e2e/session_fs_e2e_test.go | 4 ++-- go/internal/e2e/session_fs_sqlite_e2e_test.go | 6 +++--- go/internal/e2e/testharness/context.go | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go/client_test.go b/go/client_test.go index 713dd443b..39358a72a 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -170,7 +170,7 @@ func TestClient_SessionFsConfig(t *testing.T) { NewClient(&ClientOptions{ SessionFs: &SessionFsConfig{ InitialWorkingDirectory: "/", - Conventions: rpc.SessionFsSetProviderConventionsPosix, + Conventions: rpc.SessionFsSetProviderConventionsPosix, }, }) }) diff --git a/go/internal/e2e/per_session_auth_e2e_test.go b/go/internal/e2e/per_session_auth_e2e_test.go index 66a2768bb..eed11bcfa 100644 --- a/go/internal/e2e/per_session_auth_e2e_test.go +++ b/go/internal/e2e/per_session_auth_e2e_test.go @@ -101,10 +101,10 @@ func TestPerSessionAuthE2E(t *testing.T) { ctx.ConfigureForTest(t) noTokenClient := copilot.NewClient(&copilot.ClientOptions{ - Connection: copilot.StdioConnection{Path: ctx.CLIPath}, - Cwd: ctx.WorkDir, - Env: withoutAuthEnv(append(ctx.Env(), "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL)), - UseLoggedInUser: copilot.Bool(false), + Connection: copilot.StdioConnection{Path: ctx.CLIPath}, + WorkingDirectory: ctx.WorkDir, + Env: withoutAuthEnv(append(ctx.Env(), "COPILOT_DEBUG_GITHUB_API_URL="+ctx.ProxyURL)), + UseLoggedInUser: copilot.Bool(false), }) t.Cleanup(func() { noTokenClient.ForceStop() }) diff --git a/go/internal/e2e/session_fs_e2e_test.go b/go/internal/e2e/session_fs_e2e_test.go index 73b1bcc0d..ef392ebbc 100644 --- a/go/internal/e2e/session_fs_e2e_test.go +++ b/go/internal/e2e/session_fs_e2e_test.go @@ -21,8 +21,8 @@ func TestSessionFsE2E(t *testing.T) { sessionStatePath := createSessionStatePath(t) sessionFsConfig := &copilot.SessionFsConfig{ InitialWorkingDirectory: "/", - SessionStatePath: sessionStatePath, - Conventions: rpc.SessionFsSetProviderConventionsPosix, + SessionStatePath: sessionStatePath, + Conventions: rpc.SessionFsSetProviderConventionsPosix, } createSessionFsHandler := func(session *copilot.Session) copilot.SessionFsProvider { return &testSessionFsHandler{ diff --git a/go/internal/e2e/session_fs_sqlite_e2e_test.go b/go/internal/e2e/session_fs_sqlite_e2e_test.go index 354ff1197..f7e849f56 100644 --- a/go/internal/e2e/session_fs_sqlite_e2e_test.go +++ b/go/internal/e2e/session_fs_sqlite_e2e_test.go @@ -244,9 +244,9 @@ func TestSessionFsSqliteE2E(t *testing.T) { sessionStatePath := createSessionStatePath(t) sessionFsConfig := &copilot.SessionFsConfig{ InitialWorkingDirectory: "/", - SessionStatePath: sessionStatePath, - Conventions: rpc.SessionFsSetProviderConventionsPosix, - Capabilities: &copilot.SessionFsCapabilities{Sqlite: true}, + SessionStatePath: sessionStatePath, + Conventions: rpc.SessionFsSetProviderConventionsPosix, + Capabilities: &copilot.SessionFsCapabilities{Sqlite: true}, } var sqliteCalls []sqliteCall diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index a54a09439..c1331fbe9 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -197,9 +197,9 @@ func (c *TestContext) Env() []string { // Optional overrides can be applied to the default ClientOptions via the opts function. func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.Client { options := &copilot.ClientOptions{ - Connection: copilot.StdioConnection{Path: c.CLIPath}, + Connection: copilot.StdioConnection{Path: c.CLIPath}, WorkingDirectory: c.WorkDir, - Env: c.Env(), + Env: c.Env(), } for _, opt := range opts {