Recognize MCP tool hook configurations (#37363)

## What changed

- Parse `mcp_tool` hook handlers with a server, tool, JSON input, and optional timeout and status message.
- Return managed MCP tool hooks from `configRequirements/read` and include the handler in the generated protocol schemas.
- Skip MCP tool hooks during discovery with an explicit warning until execution is supported.

## Testing

- Cover MCP tool hook deserialization, including nested and null JSON input values.
- Extend the config requirements RPC test to verify the new handler shape.

GitOrigin-RevId: ff7e2d12b75b486c54d63469af547a29f9b31418
This commit is contained in:
Abhinav
2026-08-07 03:30:29 +00:00
committed by copyberry
parent 5bdc889708
commit 81b9bc2109
14 changed files with 278 additions and 10 deletions
@@ -9069,6 +9069,49 @@
"title": "CommandConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"input": {
"additionalProperties": true,
"type": "object"
},
"server": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"tool": {
"type": "string"
},
"type": {
"enum": [
"mcp_tool"
],
"title": "McpToolConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"input",
"server",
"tool",
"type"
],
"title": "McpToolConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
@@ -5234,6 +5234,49 @@
"title": "CommandConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"input": {
"additionalProperties": true,
"type": "object"
},
"server": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"tool": {
"type": "string"
},
"type": {
"enum": [
"mcp_tool"
],
"title": "McpToolConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"input",
"server",
"tool",
"type"
],
"title": "McpToolConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
@@ -305,6 +305,49 @@
"title": "CommandConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"input": {
"additionalProperties": true,
"type": "object"
},
"server": {
"type": "string"
},
"statusMessage": {
"type": [
"string",
"null"
]
},
"timeoutSec": {
"format": "uint64",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"tool": {
"type": "string"
},
"type": {
"enum": [
"mcp_tool"
],
"title": "McpToolConfiguredHookHandlerType",
"type": "string"
}
},
"required": [
"input",
"server",
"tool",
"type"
],
"title": "McpToolConfiguredHookHandler",
"type": "object"
},
{
"properties": {
"type": {
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null,
/**
@@ -9,4 +10,4 @@ export type ConfiguredHookHandler = { "type": "command", command: string, comman
* evaluated against the original context; a spilled preview also includes recovery
* metadata.
*/
additionalContextLimit: number | null, } | { "type": "prompt", } | { "type": "agent", };
additionalContextLimit: number | null, } | { "type": "mcp_tool", server: string, tool: string, input: { [key in string]?: JsonValue }, timeoutSec: bigint | null, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", };
@@ -519,6 +519,19 @@ pub enum ConfiguredHookHandler {
#[ts(rename = "additionalContextLimit")]
additional_context_limit: Option<usize>,
},
#[serde(rename = "mcp_tool")]
#[ts(rename = "mcp_tool")]
McpTool {
server: String,
tool: String,
input: serde_json::Map<String, serde_json::Value>,
#[serde(rename = "timeoutSec")]
#[ts(rename = "timeoutSec")]
timeout_sec: Option<u64>,
#[serde(rename = "statusMessage")]
#[ts(rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
#[ts(rename = "prompt")]
Prompt {},
+1 -1
View File
@@ -277,7 +277,7 @@ Example with notification opt-out:
- `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Successful session entries include the original imported title when one was available. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. Writes that overlap a managed requirement are rejected with `configRequirementReadonly`.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. Session-static model, reasoning-effort, Plan-mode reasoning-effort, service-tier, and personality defaults do not reload existing threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), Browser Use policy (`browserUse.disableAutoReview`), pinned feature values (`featureRequirements`, including the default-allowed `in_app_updates` policy that administrators can set to `false`), managed lifecycle hooks (`hooks`, including each command handler's optional `additionalContextLimit`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), Browser Use policy (`browserUse.disableAutoReview`), pinned feature values (`featureRequirements`, including the default-allowed `in_app_updates` policy that administrators can set to `false`), managed lifecycle hooks (`hooks`, including command handlers with optional `additionalContextLimit` and `mcp_tool` handlers with `server`, `tool`, `input`, `timeoutSec`, and `statusMessage`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -524,6 +524,19 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand
status_message,
additional_context_limit,
},
CoreHookHandlerConfig::McpTool {
server,
tool,
input,
timeout_sec,
status_message,
} => ConfiguredHookHandler::McpTool {
server,
tool,
input,
timeout_sec,
status_message,
},
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
}
@@ -62,6 +62,14 @@ async fn config_requirements_read_includes_remote_control_and_managed_hooks() ->
type = "command"
command = "echo managed"
additionalContextLimit = 4096
[[hooks.SessionStart.hooks]]
type = "mcp_tool"
server = "security"
tool = "scan"
input = { path = "${tool_input.file_path}", metadata = { enabled = true, retries = 2 } }
timeout = 30
statusMessage = "Scanning file"
"#,
)?;
let mut mcp = TestAppServer::builder()
@@ -83,14 +91,26 @@ additionalContextLimit = 4096
.expect("managed hooks should be returned")
.session_start[0]
.hooks,
vec![ConfiguredHookHandler::Command {
command: "echo managed".to_string(),
command_windows: None,
timeout_sec: None,
r#async: false,
status_message: None,
additional_context_limit: Some(4_096),
}]
vec![
ConfiguredHookHandler::Command {
command: "echo managed".to_string(),
command_windows: None,
timeout_sec: None,
r#async: false,
status_message: None,
additional_context_limit: Some(4_096),
},
ConfiguredHookHandler::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
input: serde_json::from_value(json!({
"path": "${tool_input.file_path}",
"metadata": { "enabled": true, "retries": 2 },
}))?,
timeout_sec: Some(30),
status_message: Some("Scanning file".to_string()),
},
]
);
Ok(())
}
+11
View File
@@ -169,6 +169,17 @@ pub enum HookHandlerConfig {
)]
additional_context_limit: Option<usize>,
},
#[serde(rename = "mcp_tool")]
McpTool {
server: String,
tool: String,
#[serde(default)]
input: serde_json::Map<String, serde_json::Value>,
#[serde(default, rename = "timeout")]
timeout_sec: Option<u64>,
#[serde(default, rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
Prompt {},
#[serde(rename = "agent")]
+40
View File
@@ -56,6 +56,46 @@ fn hooks_file_deserializes_existing_json_shape() {
);
}
#[test]
fn hooks_file_deserializes_mcp_tool_handler_with_json_inputs() {
let parsed: HooksFile = serde_json::from_value(serde_json::json!({
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "mcp_tool",
"server": "security",
"tool": "scan",
"input": {
"file_path": "${tool_input.file_path}",
"optional": null,
},
"timeout": 30,
"statusMessage": "Scanning file",
}],
}],
},
}))
.expect("MCP tool hooks should deserialize");
assert_eq!(
parsed.hooks.post_tool_use[0].hooks,
vec![HookHandlerConfig::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
input: serde_json::Map::from_iter([
(
"file_path".to_string(),
serde_json::Value::String("${tool_input.file_path}".to_string()),
),
("optional".to_string(), serde_json::Value::Null),
]),
timeout_sec: Some(30),
status_message: Some("Scanning file".to_string()),
}]
);
}
#[test]
fn hooks_file_rejects_events_outside_hooks_object() {
let error = serde_json::from_str::<HooksFile>(
+37
View File
@@ -1307,6 +1307,43 @@
],
"type": "object"
},
{
"properties": {
"input": {
"additionalProperties": true,
"default": {},
"type": "object"
},
"server": {
"type": "string"
},
"statusMessage": {
"default": null,
"type": "string"
},
"timeout": {
"default": null,
"format": "uint64",
"minimum": 0.0,
"type": "integer"
},
"tool": {
"type": "string"
},
"type": {
"enum": [
"mcp_tool"
],
"type": "string"
}
},
"required": [
"server",
"tool",
"type"
],
"type": "object"
},
{
"properties": {
"type": {
+4
View File
@@ -583,6 +583,10 @@ fn append_matcher_groups(
}
*display_order += 1;
}
HookHandlerConfig::McpTool { .. } => warnings.push(format!(
"skipping MCP tool hook in {}: MCP tool hooks are not supported yet",
source.path.display()
)),
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source.path.display()