diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 11f8c90c9..285c97a56 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 || @@ -1231,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/Session.cs b/dotnet/src/Session.cs index fc7d82675..0916a7b21 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 + ? SerializeHookOutput(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)!, @@ -1283,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. /// @@ -1607,6 +1620,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..bb72820e5 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1,4 +1,4 @@ -/*--------------------------------------------------------------------------------------------- +/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ @@ -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 @@ -1215,7 +1216,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 +1272,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 +1371,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 +1438,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 +1493,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 +1553,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 +1627,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 +1699,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. /// @@ -2518,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/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/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/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/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..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, } @@ -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 || @@ -943,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: // @@ -1499,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..39358a72a 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,8 +169,8 @@ func TestClient_SessionFsConfig(t *testing.T) { NewClient(&ClientOptions{ SessionFs: &SessionFsConfig{ - InitialCwd: "/", - Conventions: rpc.SessionFsSetProviderConventionsPosix, + 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 d8d6399ee..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() }) @@ -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) } @@ -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"` - Cwd 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/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/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/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/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..2253f3825 --- /dev/null +++ b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go @@ -0,0 +1,208 @@ +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 { + tools := []string{"*"} + return map[string]copilot.MCPServerConfig{ + "meta-echo": copilot.MCPStdioServerConfig{ + Command: "node", + Args: []string{metaEchoServer}, + WorkingDirectory: testHarnessDir, + Tools: &tools, + }, + } + } + + 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: 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.IsZero() { + t.Error("Expected non-zero 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: 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: nil, + }, 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/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..ef392ebbc 100644 --- a/go/internal/e2e/session_fs_e2e_test.go +++ b/go/internal/e2e/session_fs_e2e_test.go @@ -20,9 +20,9 @@ func TestSessionFsE2E(t *testing.T) { providerRoot := t.TempDir() sessionStatePath := createSessionStatePath(t) sessionFsConfig := &copilot.SessionFsConfig{ - InitialCwd: "/", - SessionStatePath: sessionStatePath, - Conventions: rpc.SessionFsSetProviderConventionsPosix, + InitialWorkingDirectory: "/", + 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 3d453f0a8..f7e849f56 100644 --- a/go/internal/e2e/session_fs_sqlite_e2e_test.go +++ b/go/internal/e2e/session_fs_sqlite_e2e_test.go @@ -243,10 +243,10 @@ func TestSessionFsSqliteE2E(t *testing.T) { ctx := testharness.NewTestContext(t) sessionStatePath := createSessionStatePath(t) sessionFsConfig := &copilot.SessionFsConfig{ - InitialCwd: "/", - SessionStatePath: sessionStatePath, - Conventions: rpc.SessionFsSetProviderConventionsPosix, - Capabilities: &copilot.SessionFsCapabilities{Sqlite: true}, + InitialWorkingDirectory: "/", + 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 9055442a9..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}, - Cwd: c.WorkDir, - Env: c.Env(), + Connection: copilot.StdioConnection{Path: c.CLIPath}, + WorkingDirectory: c.WorkDir, + Env: c.Env(), } for _, opt := range opts { 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..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. @@ -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. @@ -658,12 +702,12 @@ 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"` - Cwd string `json:"cwd,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"` } func (MCPStdioServerConfig) mcpServerConfig() {} @@ -759,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 @@ -1268,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 @@ -1280,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) diff --git a/java/.lastmerge b/java/.lastmerge index 88ed2a952..62c7fb670 100644 --- a/java/.lastmerge +++ b/java/.lastmerge @@ -1 +1 @@ -f6c1adf8329ad4206e5ed2e8d12fb8082bc841a2 +553e662b83b2ba502d8a346cf898fb63bb3b12dc 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/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/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/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/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/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..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", 1234L, 2L); + var result = new PingResult("pong", 1735689600000L, 2L); assertEquals("pong", result.message()); - assertEquals(1234L, 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", 9999L, 1L); + var result = new PingResult("pong", 1750000200000L, 1L); assertEquals("pong", result.message()); - assertEquals(9999L, result.timestamp()); + assertEquals(1750000200000L, result.timestamp()); assertEquals(1L, result.protocolVersion()); } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 991f23fa1..f4b558024 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, }); @@ -1691,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/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..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.). @@ -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 */ @@ -1238,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; } /** @@ -1810,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 */ @@ -1858,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) */ @@ -1877,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/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/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 new file mode 100644 index 000000000..571113239 --- /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], + workingDirectory: 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], + workingDirectory: 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], + workingDirectory: 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/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, 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 82736cddd..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] @@ -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 @@ -788,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): @@ -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_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_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/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 new file mode 100644 index 000000000..9a140dd38 --- /dev/null +++ b/python/e2e/test_pre_mcp_tool_call_hook_e2e.py @@ -0,0 +1,119 @@ +""" +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], + "working_directory": 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/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..d5a0c970e 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,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.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", }, 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/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/e2e.rs b/rust/tests/e2e.rs index 7a4bd4b04..09ece6cf5 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -45,6 +45,8 @@ mod pending_work_resume; 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/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"); }) 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..32f97cda1 --- /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::{Value, json}; +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], + working_directory: 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; +} 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/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, }); 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/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 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"}'