Skip to content

Commit f4d22d7

Browse files
stephentoubCopilotgithub-actions[bot]
authored
Add preMcpToolCall hook support to all SDKs (#1366)
* Add preMcpToolCall hook support to all SDKs Add the preMcpToolCall hook which fires before an MCP tool call is dispatched to an MCP server. This aligns with copilot-agent-runtime 1.0.51 which added support for this hook type. The hook receives serverName, toolName, arguments, optional toolCallId, and optional _meta as input. The output supports a tri-state metaToUse field: absent (preserve existing _meta), null (remove _meta), or object (replace _meta). Changes per SDK: - Node.js: PreMcpToolCallHookInput/Output types, handler, SessionHooks - Python: PreMcpToolCallHookInput/Output TypedDicts, handler, SessionHooks - Go: PreMcpToolCallHookInput/Output structs, handler, helper functions - .NET: PreMcpToolCallHookInput/Output classes, SessionHooks, JsonElement? - Rust: PreMcpToolCallInput/Output structs, HookEvent/Output variants, trait Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add preMcpToolCall hook E2E tests for Node.js, Python, Go, and Rust Port the three preMcpToolCall hook test scenarios (set meta, replace meta, remove meta) from the .NET reference implementation to all four remaining SDK test suites. Each test: - Configures an MCP stdio server (meta-echo) that echoes _meta back - Registers a preMcpToolCall hook that sets/replaces/removes metadata - Verifies the tool result reflects the hook's effect - Asserts hook input fields (serverName, toolName, workingDirectory, timestamp) Snapshot files are reused from test/snapshots/pre_mcp_tool_call_hook/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix code review feedback and add .NET E2E test infrastructure - Add OnPreMcpToolCall to hasHooks checks in .NET Client.cs and Go client.go - Add SerializeHookOutput helper for source-gen serialization - Add .NET PreMcpToolCallHookE2ETests (3 tests: set, replace, remove meta) - Add MCP meta-echo test server and snapshot YAML files - Fix Go mcp_and_agents_e2e_test.go (Cwd -> WorkingDirectory) - Remove stale dead_code lint expectation in Rust support.rs - Add serialization unit tests for hook output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Java PingResponse timestamp deserialization for ISO-8601 format The Copilot CLI now returns ISO-8601 timestamp strings instead of numeric epoch milliseconds. Update PingResponse.timestamp from long to String and PingResult.timestamp from Long to String. Update corresponding test assertions accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Regenerate Java codegen output Auto-committed by java-codegen-check workflow. * Fix Java test type mismatch: PingResult.timestamp is Long not String The generated PingResult record has timestamp as Long (milliseconds), but tests were passing String values (ISO date format). Update tests to use Long millisecond values instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename cwd to working_directory in Rust public API Rename the public `cwd` field to `working_directory` on: - ClientOptions (local-only, not serialized) - McpStdioServerConfig (serialized; add #[serde(rename = "cwd")]) - SessionListFilter (serialized; add #[serde(rename = "cwd")]) The wire format remains unchanged (JSON key stays "cwd"). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename cwd to working_directory in Python public API Rename all public API fields named 'cwd' to 'working_directory' and 'initial_cwd' to 'initial_working_directory' in the Python SDK while preserving the wire format (JSON sent to/from the Copilot CLI runtime still uses 'cwd' and 'initialCwd'). Fields renamed: - SubprocessConfig.cwd -> working_directory - MCPStdioServerConfig['cwd'] -> working_directory - SessionContext.cwd -> working_directory - SessionListFilter.cwd -> working_directory - SessionFsConfig['initial_cwd'] -> initial_working_directory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename cwd to workingDirectory in Node.js public API Rename the public-facing 'cwd' field to 'workingDirectory' in: - CopilotClientOptions.cwd - MCPStdioServerConfig.cwd - SessionContext.cwd - SessionListFilter.cwd The wire format (JSON sent to/from the Copilot CLI runtime) is preserved as 'cwd' via transformation layers in client.ts: - Outgoing: workingDirectory -> cwd (mcpServers, customAgents, listSessions filter) - Incoming: cwd -> workingDirectory (session context in toSessionMetadata) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix formatting in Node.js and Python after cwd rename Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Rust E2E test: use working_directory in McpStdioServerConfig Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix flaky tests: increase CLI start timeout and add missing snapshot - Node.js: Increase CLI server start timeout from 10s to 30s to accommodate slow Windows CI runners - Java: Add missing conversation to mcp_and_agents/should_accept_both_mcp_servers_and_custom_agents snapshot (was empty, causing proxy 500 errors) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Java .lastmerge to include snapshot fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename Cwd/InitialCwd to WorkingDirectory/InitialWorkingDirectory in Go and .NET Complete the cwd → workingDirectory rename across all SDKs for consistency. Wire format (JSON) is preserved via struct tags and JsonPropertyName attributes. Go: - ClientOptions.Cwd → WorkingDirectory - SessionFsConfig.InitialCwd → InitialWorkingDirectory - SessionContext.Cwd → WorkingDirectory (json:"cwd") - SessionListFilter.Cwd → WorkingDirectory (json:"cwd,omitempty") .NET: - SessionFsConfig.InitialCwd → InitialWorkingDirectory ([JsonPropertyName("initialCwd")]) - SessionContext.Cwd → WorkingDirectory ([JsonPropertyName("cwd")]) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix missed Cwd reference and run go fmt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert Java SDK changes Remove Java preMcpToolCall hook implementation from this PR to be handled separately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Node.js Windows CI test failure Split dependent SQL tool calls into separate CAPI turns in the session_fs_sqlite snapshot. The CREATE TABLE and INSERT were previously returned as separate choices in a single response, causing the CLI on Windows to execute them concurrently. Since INSERT depends on CREATE TABLE completing first, this produced a 'no such table: items' error. The fix restructures the snapshot into 3 CAPI turns: first executing report_intent + CREATE TABLE, then INSERT, then the final response. This ensures CREATE TABLE always completes before INSERT is attempted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 311695e commit f4d22d7

74 files changed

Lines changed: 1716 additions & 248 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

dotnet/src/Client.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
528528

529529
var hasHooks = config.Hooks != null && (
530530
config.Hooks.OnPreToolUse != null ||
531+
config.Hooks.OnPreMcpToolCall != null ||
531532
config.Hooks.OnPostToolUse != null ||
532533
config.Hooks.OnUserPromptSubmitted != null ||
533534
config.Hooks.OnSessionStart != null ||
@@ -688,6 +689,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
688689

689690
var hasHooks = config.Hooks != null && (
690691
config.Hooks.OnPreToolUse != null ||
692+
config.Hooks.OnPreMcpToolCall != null ||
691693
config.Hooks.OnPostToolUse != null ||
692694
config.Hooks.OnUserPromptSubmitted != null ||
693695
config.Hooks.OnSessionStart != null ||
@@ -1231,7 +1233,7 @@ private async Task ConfigureSessionFsAsync(CancellationToken cancellationToken)
12311233
}
12321234

12331235
await Rpc.SessionFs.SetProviderAsync(
1234-
_options.SessionFs.InitialCwd,
1236+
_options.SessionFs.InitialWorkingDirectory,
12351237
_options.SessionFs.SessionStatePath,
12361238
_options.SessionFs.Conventions,
12371239
_options.SessionFs.Capabilities,

dotnet/src/Session.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,6 +1245,11 @@ internal void RegisterHooks(SessionHooks hooks)
12451245
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreToolUseHookInput)!,
12461246
invocation)
12471247
: null,
1248+
"preMcpToolCall" => hooks.OnPreMcpToolCall != null
1249+
? SerializeHookOutput(await hooks.OnPreMcpToolCall(
1250+
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PreMcpToolCallHookInput)!,
1251+
invocation))
1252+
: null,
12481253
"postToolUse" => hooks.OnPostToolUse != null
12491254
? await hooks.OnPostToolUse(
12501255
JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.PostToolUseHookInput)!,
@@ -1283,6 +1288,14 @@ internal void RegisterHooks(SessionHooks hooks)
12831288
}
12841289
}
12851290

1291+
/// <summary>
1292+
/// Pre-serializes a hook output to JsonElement so that the <c>object?</c> typed
1293+
/// <see cref="CopilotClient.HooksInvokeResponse.Output"/> property writes the
1294+
/// correct JSON without relying on polymorphic type resolution.
1295+
/// </summary>
1296+
private static JsonElement? SerializeHookOutput(PreMcpToolCallHookOutput? output) =>
1297+
output is null ? null : JsonSerializer.SerializeToElement(output, SessionJsonContext.Default.PreMcpToolCallHookOutput);
1298+
12861299
/// <summary>
12871300
/// Registers transform callbacks for system message sections.
12881301
/// </summary>
@@ -1607,6 +1620,8 @@ internal void ThrowIfDisposed()
16071620
[JsonSerializable(typeof(GetMessagesResponse))]
16081621
[JsonSerializable(typeof(PostToolUseHookInput))]
16091622
[JsonSerializable(typeof(PostToolUseHookOutput))]
1623+
[JsonSerializable(typeof(PreMcpToolCallHookInput))]
1624+
[JsonSerializable(typeof(PreMcpToolCallHookOutput))]
16101625
[JsonSerializable(typeof(PreToolUseHookInput))]
16111626
[JsonSerializable(typeof(PreToolUseHookOutput))]
16121627
[JsonSerializable(typeof(SendMessageRequest))]

dotnet/src/Types.cs

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*---------------------------------------------------------------------------------------------
1+
/*---------------------------------------------------------------------------------------------
22
* Copyright (c) Microsoft Corporation. All rights reserved.
33
*--------------------------------------------------------------------------------------------*/
44

@@ -396,7 +396,8 @@ public sealed class SessionFsConfig
396396
/// <summary>
397397
/// Initial working directory for sessions (user's project directory).
398398
/// </summary>
399-
public required string InitialCwd { get; init; }
399+
[JsonPropertyName("initialCwd")]
400+
public required string InitialWorkingDirectory { get; init; }
400401

401402
/// <summary>
402403
/// Path within each session's SessionFs where the runtime stores
@@ -1215,7 +1216,7 @@ public sealed class PreToolUseHookInput
12151216
/// Current working directory of the session.
12161217
/// </summary>
12171218
[JsonPropertyName("cwd")]
1218-
public string Cwd { get; set; } = string.Empty;
1219+
public string WorkingDirectory { get; set; } = string.Empty;
12191220

12201221
/// <summary>
12211222
/// Name of the tool about to be executed.
@@ -1271,6 +1272,83 @@ public sealed class PreToolUseHookOutput
12711272
public bool? SuppressOutput { get; set; }
12721273
}
12731274

1275+
/// <summary>
1276+
/// Input for a pre-MCP-tool-call hook.
1277+
/// </summary>
1278+
public sealed class PreMcpToolCallHookInput
1279+
{
1280+
/// <summary>
1281+
/// The runtime session ID of the session that triggered the hook.
1282+
/// </summary>
1283+
[JsonPropertyName("sessionId")]
1284+
public string SessionId { get; set; } = string.Empty;
1285+
1286+
/// <summary>
1287+
/// Unix timestamp in milliseconds when the hook was triggered.
1288+
/// </summary>
1289+
[JsonPropertyName("timestamp")]
1290+
[JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))]
1291+
public DateTimeOffset Timestamp { get; set; }
1292+
1293+
/// <summary>
1294+
/// Current working directory of the session.
1295+
/// </summary>
1296+
[JsonPropertyName("cwd")]
1297+
public string WorkingDirectory { get; set; } = string.Empty;
1298+
1299+
/// <summary>
1300+
/// Name of the MCP server being called.
1301+
/// </summary>
1302+
[JsonPropertyName("serverName")]
1303+
public string ServerName { get; set; } = string.Empty;
1304+
1305+
/// <summary>
1306+
/// Name of the MCP tool being called.
1307+
/// </summary>
1308+
[JsonPropertyName("toolName")]
1309+
public string ToolName { get; set; } = string.Empty;
1310+
1311+
/// <summary>
1312+
/// Arguments for the MCP tool call.
1313+
/// </summary>
1314+
[JsonPropertyName("arguments")]
1315+
public JsonElement? Arguments { get; set; }
1316+
1317+
/// <summary>
1318+
/// Tool call ID, if available.
1319+
/// </summary>
1320+
[JsonPropertyName("toolCallId")]
1321+
public string? ToolCallId { get; set; }
1322+
1323+
/// <summary>
1324+
/// MCP request metadata, if present.
1325+
/// </summary>
1326+
[JsonPropertyName("_meta")]
1327+
public IDictionary<string, JsonElement>? Meta { get; set; }
1328+
}
1329+
1330+
/// <summary>
1331+
/// Output for a pre-MCP-tool-call hook.
1332+
/// </summary>
1333+
/// <remarks>
1334+
/// <para>The <see cref="MetaToUse"/> property controls outgoing MCP request metadata:</para>
1335+
/// <list type="bullet">
1336+
/// <item><description>Return <c>null</c> from the hook handler: preserve existing <c>_meta</c> (no-op).</description></item>
1337+
/// <item><description>Return a <see cref="PreMcpToolCallHookOutput"/> with <see cref="MetaToUse"/> left as <c>null</c>: omit <c>_meta</c> from the request.</description></item>
1338+
/// <item><description>Return a <see cref="PreMcpToolCallHookOutput"/> with <see cref="MetaToUse"/> set to a <see cref="JsonElement"/> object: replace <c>_meta</c> with that object.</description></item>
1339+
/// </list>
1340+
/// </remarks>
1341+
public sealed class PreMcpToolCallHookOutput
1342+
{
1343+
/// <summary>
1344+
/// Hook-controlled metadata to use for the outgoing MCP request.
1345+
/// See class remarks for semantics.
1346+
/// </summary>
1347+
[JsonPropertyName("metaToUse")]
1348+
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
1349+
public JsonElement? MetaToUse { get; set; }
1350+
}
1351+
12741352
/// <summary>
12751353
/// Input for a post-tool-use hook.
12761354
/// </summary>
@@ -1293,7 +1371,7 @@ public sealed class PostToolUseHookInput
12931371
/// Current working directory of the session.
12941372
/// </summary>
12951373
[JsonPropertyName("cwd")]
1296-
public string Cwd { get; set; } = string.Empty;
1374+
public string WorkingDirectory { get; set; } = string.Empty;
12971375

12981376
/// <summary>
12991377
/// Name of the tool that was executed.
@@ -1360,7 +1438,7 @@ public sealed class UserPromptSubmittedHookInput
13601438
/// Current working directory of the session.
13611439
/// </summary>
13621440
[JsonPropertyName("cwd")]
1363-
public string Cwd { get; set; } = string.Empty;
1441+
public string WorkingDirectory { get; set; } = string.Empty;
13641442

13651443
/// <summary>
13661444
/// The user's prompt text.
@@ -1415,7 +1493,7 @@ public sealed class SessionStartHookInput
14151493
/// Current working directory of the session.
14161494
/// </summary>
14171495
[JsonPropertyName("cwd")]
1418-
public string Cwd { get; set; } = string.Empty;
1496+
public string WorkingDirectory { get; set; } = string.Empty;
14191497

14201498
/// <summary>
14211499
/// Source of the session start.
@@ -1475,7 +1553,7 @@ public sealed class SessionEndHookInput
14751553
/// Current working directory of the session.
14761554
/// </summary>
14771555
[JsonPropertyName("cwd")]
1478-
public string Cwd { get; set; } = string.Empty;
1556+
public string WorkingDirectory { get; set; } = string.Empty;
14791557

14801558
/// <summary>
14811559
/// Reason for session end.
@@ -1549,7 +1627,7 @@ public sealed class ErrorOccurredHookInput
15491627
/// Current working directory of the session.
15501628
/// </summary>
15511629
[JsonPropertyName("cwd")]
1552-
public string Cwd { get; set; } = string.Empty;
1630+
public string WorkingDirectory { get; set; } = string.Empty;
15531631

15541632
/// <summary>
15551633
/// Error message describing what went wrong.
@@ -1621,6 +1699,11 @@ public sealed class SessionHooks
16211699
/// </summary>
16221700
public Func<PreToolUseHookInput, HookInvocation, Task<PreToolUseHookOutput?>>? OnPreToolUse { get; set; }
16231701

1702+
/// <summary>
1703+
/// Handler called before an MCP tool is called.
1704+
/// </summary>
1705+
public Func<PreMcpToolCallHookInput, HookInvocation, Task<PreMcpToolCallHookOutput?>>? OnPreMcpToolCall { get; set; }
1706+
16241707
/// <summary>
16251708
/// Handler called after a tool has been executed.
16261709
/// </summary>
@@ -2518,7 +2601,8 @@ public MessageOptions Clone()
25182601
public sealed class SessionContext
25192602
{
25202603
/// <summary>Working directory where the session was created.</summary>
2521-
public string Cwd { get; set; } = string.Empty;
2604+
[JsonPropertyName("cwd")]
2605+
public string WorkingDirectory { get; set; } = string.Empty;
25222606
/// <summary>Git repository root (if in a git repo).</summary>
25232607
public string? GitRoot { get; set; }
25242608
/// <summary>GitHub repository in "owner/repo" format.</summary>

dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public async Task Should_Invoke_OnSessionStart_Hook_On_New_Session()
4545
Assert.NotEmpty(sessionStartInputs);
4646
Assert.Equal("new", sessionStartInputs[0].Source);
4747
Assert.True(sessionStartInputs[0].Timestamp > DateTimeOffset.UnixEpoch);
48-
Assert.False(string.IsNullOrEmpty(sessionStartInputs[0].Cwd));
48+
Assert.False(string.IsNullOrEmpty(sessionStartInputs[0].WorkingDirectory));
4949

5050
await session.DisposeAsync();
5151
}
@@ -73,7 +73,7 @@ public async Task Should_Invoke_OnUserPromptSubmitted_Hook_When_Sending_A_Messag
7373
Assert.NotEmpty(userPromptInputs);
7474
Assert.Contains("Say hello", userPromptInputs[0].Prompt);
7575
Assert.True(userPromptInputs[0].Timestamp > DateTimeOffset.UnixEpoch);
76-
Assert.False(string.IsNullOrEmpty(userPromptInputs[0].Cwd));
76+
Assert.False(string.IsNullOrEmpty(userPromptInputs[0].WorkingDirectory));
7777

7878
await session.DisposeAsync();
7979
}
@@ -118,7 +118,7 @@ public async Task Should_Invoke_OnErrorOccurred_Hook_When_Error_Occurs()
118118
{
119119
Assert.Equal(session!.SessionId, invocation.SessionId);
120120
Assert.True(input.Timestamp > DateTimeOffset.UnixEpoch);
121-
Assert.False(string.IsNullOrEmpty(input.Cwd));
121+
Assert.False(string.IsNullOrEmpty(input.WorkingDirectory));
122122
Assert.False(string.IsNullOrEmpty(input.Error));
123123
Assert.Contains(input.ErrorContext, ValidErrorContexts);
124124
return Task.FromResult<ErrorOccurredHookOutput?>(null);
@@ -188,7 +188,7 @@ public async Task Should_Invoke_SessionStart_Hook()
188188

189189
Assert.NotEmpty(inputs);
190190
Assert.Equal("new", inputs[0].Source);
191-
Assert.False(string.IsNullOrEmpty(inputs[0].Cwd));
191+
Assert.False(string.IsNullOrEmpty(inputs[0].WorkingDirectory));
192192
}
193193

194194
[Fact]

0 commit comments

Comments
 (0)