Add per-app tool exposure configuration (#46035)

## Why

Server-level tool exposure settings apply to every connector on the apps server. Individual connectors need a way to opt out of deferred discovery without changing exposure for other apps.

## What changed

Add `apps.<connector_id>.omit_tools_from` with support for `code_mode`, `deferred`, and `direct`. Combine connector omissions with server omissions so app settings preserve server restrictions.

For example, `omit_tools_from = ["deferred"]` makes a connector's tools available without tool search, through direct calls or Code Mode as permitted by the active tool mode and remaining restrictions.

Expose the setting in the app-server protocol, JSON schemas, and generated TypeScript and Python types.

## Testing

Add integration coverage for connector-specific exposure and MCP dispatch across tool modes, server restrictions, and direct-only namespaces. Extend config tests to cover populated, absent, and empty omission lists, and add a Code Mode request-history snapshot.

GitOrigin-RevId: cc99ab290c8a878d06d8d7ccf2d1dbd7c46bfa8a
This commit is contained in:
ptiet-oai
2026-09-16 21:50:07 +00:00
committed by copyberry
parent 29e6bc814e
commit a6d4741d39
20 changed files with 465 additions and 2 deletions
@@ -7094,6 +7094,16 @@
],
"description": "Per-account approval settings keyed by link ID."
},
"omit_tools_from": {
"description": "Additional model-facing surfaces omitted for this connector's tools.",
"items": {
"$ref": "#/definitions/v2/ToolExposureSurface"
},
"type": [
"array",
"null"
]
},
"open_world_enabled": {
"type": [
"boolean",
@@ -24873,6 +24883,32 @@
],
"type": "object"
},
"ToolExposureSurface": {
"description": "A model-facing surface on which a tool can be exposed.",
"oneOf": [
{
"description": "Nested tools available to Code Mode scripts.",
"enum": [
"code_mode"
],
"type": "string"
},
{
"description": "Tools discovered later through tool search.",
"enum": [
"deferred"
],
"type": "string"
},
{
"description": "Tools present in the model's initial tool list.",
"enum": [
"direct"
],
"type": "string"
}
]
},
"ToolsV2": {
"properties": {
"web_search": {
@@ -510,6 +510,16 @@
],
"description": "Per-account approval settings keyed by link ID."
},
"omit_tools_from": {
"description": "Additional model-facing surfaces omitted for this connector's tools.",
"items": {
"$ref": "#/definitions/ToolExposureSurface"
},
"type": [
"array",
"null"
]
},
"open_world_enabled": {
"type": [
"boolean",
@@ -22587,6 +22597,32 @@
],
"type": "object"
},
"ToolExposureSurface": {
"description": "A model-facing surface on which a tool can be exposed.",
"oneOf": [
{
"description": "Nested tools available to Code Mode scripts.",
"enum": [
"code_mode"
],
"type": "string"
},
{
"description": "Tools discovered later through tool search.",
"enum": [
"deferred"
],
"type": "string"
},
{
"description": "Tools present in the model's initial tool list.",
"enum": [
"direct"
],
"type": "string"
}
]
},
"ToolsV2": {
"properties": {
"web_search": {
@@ -73,6 +73,16 @@
],
"description": "Per-account approval settings keyed by link ID."
},
"omit_tools_from": {
"description": "Additional model-facing surfaces omitted for this connector's tools.",
"items": {
"$ref": "#/definitions/ToolExposureSurface"
},
"type": [
"array",
"null"
]
},
"open_world_enabled": {
"type": [
"boolean",
@@ -1009,6 +1019,32 @@
},
"type": "object"
},
"ToolExposureSurface": {
"description": "A model-facing surface on which a tool can be exposed.",
"oneOf": [
{
"description": "Nested tools available to Code Mode scripts.",
"enum": [
"code_mode"
],
"type": "string"
},
{
"description": "Tools discovered later through tool search.",
"enum": [
"deferred"
],
"type": "string"
},
{
"description": "Tools present in the model's initial tool list.",
"enum": [
"direct"
],
"type": "string"
}
]
},
"ToolsV2": {
"properties": {
"web_search": {
@@ -0,0 +1,8 @@
// 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.
/**
* A model-facing surface on which a tool can be exposed.
*/
export type ToolExposureSurface = "code_mode" | "deferred" | "direct";
+1
View File
@@ -85,6 +85,7 @@ export type { SubAgentSource } from "./SubAgentSource";
export type { ThreadId } from "./ThreadId";
export type { ThreadMemoryMode } from "./ThreadMemoryMode";
export type { Tool } from "./Tool";
export type { ToolExposureSurface } from "./ToolExposureSurface";
export type { Verbosity } from "./Verbosity";
export type { WebSearchAction } from "./WebSearchAction";
export type { WebSearchContextSize } from "./WebSearchContextSize";
@@ -1,13 +1,18 @@
// 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 { ToolExposureSurface } from "../ToolExposureSurface";
import type { AppLinksConfig } from "./AppLinksConfig";
import type { AppToolApproval } from "./AppToolApproval";
import type { AppToolsConfig } from "./AppToolsConfig";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AppsDefaultConfig } from "./AppsDefaultConfig";
export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null,
export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean,
/**
* Additional model-facing surfaces omitted for this connector's tools.
*/
omit_tools_from: Array<ToolExposureSurface> | null, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null,
/**
* Per-account approval settings keyed by link ID.
*/
@@ -12,6 +12,7 @@ use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ToolExposureSurface;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
@@ -234,6 +235,8 @@ pub struct AppLinksConfig {
pub struct AppConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Additional model-facing surfaces omitted for this connector's tools.
pub omit_tools_from: Option<Vec<ToolExposureSurface>>,
pub approvals_reviewer: Option<ApprovalsReviewer>,
pub destructive_enabled: Option<bool>,
pub open_world_enabled: Option<bool>,
@@ -915,6 +915,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> {
value: serde_json::json!({
"app1": {
"enabled": false,
"omit_tools_from": ["deferred"],
},
}),
merge_strategy: MergeStrategy::Replace,
@@ -950,6 +951,9 @@ async fn write_value_supports_nested_app_paths() -> Result<()> {
"app1".to_string(),
AppConfig {
enabled: false,
omit_tools_from: Some(vec![
codex_protocol::config_types::ToolExposureSurface::Deferred
]),
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
@@ -42,6 +42,7 @@ use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ToolsV2;
use codex_app_server_protocol::WriteStatus;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::ToolExposureSurface;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchLocation;
@@ -1021,6 +1022,7 @@ default_tools_approval_mode = "writes"
[apps.app1]
enabled = false
omit_tools_from = ["deferred"]
approvals_reviewer = "user"
destructive_enabled = false
default_tools_approval_mode = "prompt"
@@ -1035,6 +1037,9 @@ default_tools_approval_mode = "writes"
[apps.app_without_links]
enabled = true
[apps.app_with_empty_links]
omit_tools_from = []
[apps.app_with_empty_links.links]
"#,
)?;
@@ -1084,6 +1089,7 @@ enabled = true
"app1".to_string(),
AppConfig {
enabled: false,
omit_tools_from: Some(vec![ToolExposureSurface::Deferred]),
approvals_reviewer: Some(ApprovalsReviewer::User),
destructive_enabled: Some(false),
open_world_enabled: None,
@@ -1114,6 +1120,7 @@ enabled = true
"app_without_links".to_string(),
AppConfig {
enabled: true,
omit_tools_from: None,
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
@@ -1127,6 +1134,7 @@ enabled = true
"app_with_empty_links".to_string(),
AppConfig {
enabled: true,
omit_tools_from: Some(vec![]),
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
+7
View File
@@ -20,6 +20,7 @@ pub use codex_protocol::config_types::ApprovalsReviewer;
pub use codex_protocol::config_types::ModeKind;
pub use codex_protocol::config_types::Personality;
pub use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::ToolExposureSurface;
pub use codex_protocol::config_types::WebSearchMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::BTreeMap;
@@ -499,6 +500,12 @@ pub struct AppConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Model-facing surfaces from which this connector's tools must be omitted,
/// in addition to any server-level omissions. `None` leaves lower-priority
/// configuration unchanged; an empty list clears connector-level omissions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub omit_tools_from: Option<Vec<ToolExposureSurface>>,
/// Reviewer for approval prompts from this app, overriding the thread default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approvals_reviewer: Option<ApprovalsReviewer>,
+8 -1
View File
@@ -155,6 +155,13 @@
],
"description": "Per-account approval settings keyed by link ID."
},
"omit_tools_from": {
"description": "Model-facing surfaces from which this connector's tools must be omitted, in addition to any server-level omissions. `None` leaves lower-priority configuration unchanged; an empty list clears connector-level omissions.",
"items": {
"$ref": "#/definitions/ToolExposureSurface"
},
"type": "array"
},
"open_world_enabled": {
"description": "Whether tools with `open_world_hint = true` are allowed for this app.",
"type": "boolean"
@@ -7230,4 +7237,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
+1
View File
@@ -2341,6 +2341,7 @@ async fn persist_codex_app_tool_approval_writes_tool_override() {
"calendar".to_string(),
AppConfig {
enabled: true,
omit_tools_from: None,
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
+10
View File
@@ -61,10 +61,12 @@ use crate::tools::registry::ToolExposure;
use crate::tools::registry::ToolRegistry;
use crate::tools::router::ToolRouter;
use crate::tools::tool_namespaces_info::collect_tool_namespaces_info;
use codex_connectors::apps_config_from_layer_stack;
use codex_extension_api::ExtensionData;
use codex_features::Feature;
use codex_features::SleepToolMode;
use codex_login::AuthManager;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_prompts::ResolvedModelMessages;
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
use codex_protocol::account::PlanType;
@@ -195,6 +197,7 @@ fn apply_mcp_tool_exposure_policy(
registry: &mut ToolRegistry,
) {
let mut omitted_exposures_by_tool = HashMap::new();
let apps_config = apps_config_from_layer_stack(&turn_context.config.config_layer_stack);
for tool in mcp.tools() {
let tool_name = tool.canonical_tool_name();
if !registered_mcp_tools.contains(&tool_name) {
@@ -206,12 +209,19 @@ fn apply_mcp_tool_exposure_policy(
omitted_exposures_by_tool
.entry(tool_name)
.or_insert_with(|| {
let connector_omissions = (tool.server_name == CODEX_APPS_MCP_SERVER_NAME)
.then_some(tool.connector_id.as_deref())
.flatten()
.and_then(|id| apps_config.as_ref()?.apps.get(id))
.and_then(|app| app.omit_tools_from.as_deref())
.unwrap_or_default();
server
.config()
.omit_tools_from
.as_deref()
.unwrap_or_default()
.iter()
.chain(connector_omissions)
.copied()
.collect::<ToolExposures>()
});
@@ -0,0 +1,232 @@
//! Verifies connector-scoped exposure, server restrictions, and MCP dispatch.
use codex_core::config::Config;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributionContext;
use codex_extension_api::McpServerContributor;
use codex_features::Feature;
use codex_protocol::config_types::ToolExposureSurface;
use codex_protocol::openai_models::ToolMode;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::search_capable_apps_builder;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_partial_json;
use wiremock::matchers::method;
struct AppsServer(Vec<ToolExposureSurface>);
impl McpServerContributor<Config> for AppsServer {
fn id(&self) -> &'static str {
"app_exposure_fixture"
}
fn contribute<'a>(
&'a self,
context: McpServerContributionContext<'a, Config>,
) -> ExtensionFuture<'a, Vec<McpServerContribution>> {
Box::pin(async move {
let mut config = codex_mcp::hosted_plugin_runtime_mcp_server_config(
&context.config().chatgpt_base_url,
/*apps_mcp_product_sku*/ None,
context.originator(),
);
config.omit_tools_from = Some(self.0.clone());
vec![McpServerContribution::HostedApps {
config: Box::new(config),
protocol_mode: None,
}]
})
}
}
pub(super) struct ExposureCase {
mode: ToolMode,
app_config: &'static str,
server_omissions: &'static [&'static str],
direct_only: bool,
expect_direct: bool,
expect_exec: bool,
}
impl ExposureCase {
pub(super) fn non_deferred(mode: ToolMode) -> Self {
Self {
mode,
app_config: "omit_tools_from = [\"deferred\"]",
server_omissions: &[],
direct_only: false,
expect_direct: mode != ToolMode::CodeModeOnly,
expect_exec: mode != ToolMode::Direct,
}
}
}
#[test_case::test_case(ToolMode::Direct; "direct")]
#[test_case::test_case(ToolMode::CodeMode; "code_mode")]
#[test_case::test_case(ToolMode::CodeModeOnly; "code_mode_only")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn non_deferred_connector_exposure_and_dispatch(mode: ToolMode) -> anyhow::Result<()> {
connector_exposure_requests(ExposureCase::non_deferred(mode)).await?;
Ok(())
}
#[test_case::test_case("", &[], false, false; "unconfigured")]
#[test_case::test_case("omit_tools_from = [\"deferred\"]", &["code_mode"], true, false; "server_excludes_exec")]
#[test_case::test_case("omit_tools_from = [\"deferred\"]", &["direct"], false, true; "server_excludes_direct")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn connector_omissions_respect_server_restrictions(
app_config: &'static str,
server_omissions: &'static [&'static str],
expect_direct: bool,
expect_exec: bool,
) -> anyhow::Result<()> {
connector_exposure_requests(ExposureCase {
app_config,
server_omissions,
expect_direct,
expect_exec,
..ExposureCase::non_deferred(ToolMode::CodeMode)
})
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn connector_omissions_preserve_direct_only_namespace() -> anyhow::Result<()> {
connector_exposure_requests(ExposureCase {
direct_only: true,
expect_direct: true,
expect_exec: false,
..ExposureCase::non_deferred(ToolMode::CodeModeOnly)
})
.await?;
Ok(())
}
pub(super) async fn connector_exposure_requests(
case: ExposureCase,
) -> anyhow::Result<Vec<responses::ResponsesRequest>> {
let server = responses::start_mock_server().await;
AppsTestServer::mount_searchable(&server).await?;
for rpc_method in ["tools/list", "tools/call"] {
Mock::given(method("POST"))
.and(body_partial_json(json!({"method": rpc_method})))
.respond_with(move |request: &wiremock::Request| {
let body: serde_json::Value =
request.body_json().expect("valid MCP fixture request");
let result = if rpc_method == "tools/list" {
json!({"tools": [
{
"name": "lookup",
"description": "Look up calendar events",
"inputSchema": {"type": "object", "properties": {}},
"annotations": {"readOnlyHint": true},
"_meta": {"connector_id": "calendar", "connector_name": "calendar"}
},
{
"name": "read",
"description": "Read notes",
"inputSchema": {"type": "object", "properties": {}},
"annotations": {"readOnlyHint": true},
"_meta": {"connector_id": "notes", "connector_name": "notes"}
}
]})
} else {
assert_eq!(body["params"]["name"], "lookup");
json!({"content": [{"type": "text", "text": "calendar-lookup-ok"}]})
};
ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": body["id"], "result": result
}))
})
.with_priority(1)
.mount(&server)
.await;
}
let namespace = "mcp__codex_apps__calendar";
let tool_call = if case.expect_exec {
responses::ev_custom_tool_call(
"lookup",
"exec",
&format!("text((await tools.{namespace}__lookup({{}})).content[0].text);"),
)
} else if case.expect_direct {
responses::ev_function_call_with_namespace("lookup", namespace, "lookup", "{}")
} else {
responses::ev_assistant_message("message", "done")
};
let calls_tool = case.expect_direct || case.expect_exec;
let mut events = vec![responses::sse(vec![
tool_call,
responses::ev_completed("first"),
])];
if calls_tool {
events.push(responses::sse(vec![responses::ev_completed("second")]));
}
let response_mock = responses::mount_sse_sequence(&server, events).await;
let auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing();
let mut extensions = ExtensionRegistryBuilder::new();
extensions.mcp_server_contributor(Arc::new(AppsServer(serde_json::from_value(json!(
case.server_omissions
))?)));
let mut builder = search_capable_apps_builder(server.uri())
.with_code_mode_host_program(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?)
.with_pre_build_hook(move |home| {
std::fs::write(
home.join("config.toml"),
format!("[apps.calendar]\n{}\n", case.app_config),
)
.expect("valid fixture configuration");
})
.with_auth(auth)
.with_extensions(Arc::new(extensions.build()))
.with_model_info_override("gpt-5.5", move |model| model.tool_mode = Some(case.mode))
.with_config(move |config| {
if case.direct_only {
config.code_mode.direct_only_tool_namespaces = vec![namespace.to_string()];
}
config.analytics_enabled = Some(false);
config
.features
.enable(Feature::CodeModeHost)
.expect("code mode host");
});
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("Look up my calendar.").await?;
let requests = response_mock.requests();
assert_eq!(requests.len(), if calls_tool { 2 } else { 1 });
assert_eq!(
requests[0].tool_by_name(namespace, "lookup").is_some(),
case.expect_direct
);
assert!(
requests[0]
.tool_by_name("mcp__codex_apps__notes", "read")
.is_none()
);
let body = requests[0].body_json();
let exec_description = body["tools"]
.as_array()
.expect("request tool list")
.iter()
.find(|tool| tool["name"] == "exec")
.and_then(|tool| tool["description"].as_str())
.unwrap_or_default();
assert_eq!(
exec_description.contains(&format!("{namespace}__lookup(")),
case.expect_exec && case.mode == ToolMode::CodeModeOnly
);
assert!(!exec_description.contains("mcp__codex_apps__notes__read("));
if calls_tool {
assert!(requests[1].body_contains_text("calendar-lookup-ok"));
}
test.codex.shutdown_and_wait().await?;
Ok(requests)
}
+1
View File
@@ -39,6 +39,7 @@ mod additional_context;
mod agent_execution;
mod agent_websocket;
mod agents_md;
mod app_tool_exposure;
mod apply_patch_cli;
mod apply_patch_serialization;
#[cfg(not(target_os = "windows"))]
+21
View File
@@ -783,3 +783,24 @@ async fn guardian_checkpoint_migration_request_history() -> Result<()> {
insta::assert_snapshot!("guardian_checkpoint_migration", snapshot);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_tool_exposure_request_history() -> Result<()> {
let requests = super::app_tool_exposure::connector_exposure_requests(
super::app_tool_exposure::ExposureCase::non_deferred(
codex_protocol::openai_models::ToolMode::CodeModeOnly,
),
)
.await?;
insta::assert_snapshot!(
"app_tool_exposure_CodeModeOnly",
context_snapshot::format_request_history_snapshot(
"A non-deferred connector is called through code mode while another connector stays deferred.",
&requests,
&ContextSnapshotOptions::default()
.rewrite_known_segments()
.include_request_settings(),
)
);
Ok(())
}
@@ -0,0 +1,37 @@
---
source: core/tests/suite/scenarios.rs
expression: "context_snapshot::format_request_history_snapshot(\"A non-deferred connector is called through code mode while another connector stays deferred.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: A non-deferred connector is called through code mode while another connector stays deferred.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
instructions: <MODEL_INSTRUCTIONS>
model: "gpt-5.5"
parallel_tool_calls: true
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
reasoning: {"effort":"medium"}
store: false
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
tools (4; hash=8f44dd235bfe4473):
- custom/exec: Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in...; hash=0fc6c2a32798a2ec
- function/wait: Waits on a yielded `exec` cell and returns new output or completion. - Use `wait` only after `exe...; args=[cell_id, max_tokens, terminate, yield_time_ms]; hash=6692d8f6c55be489
- function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6
- web_search; hash=e214d4db1bc49e89
-- request 1 (turn) --
00:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Look up my calendar.
-- request 2 (turn) --
03:custom_tool_call/exec:text((await tools.mcp__codex_apps__calendar__lookup({})).content[0].text);
04:custom_tool_call_output:Script completed
Wall time <DURATION> seconds
Output:
| calendar-lookup-ok
@@ -6179,6 +6179,12 @@ class Tool(BaseModel):
title: str | None = None
class ToolExposureSurface(Enum):
code_mode = "code_mode"
deferred = "deferred"
direct = "direct"
class TurnDiffUpdatedNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6587,6 +6593,10 @@ class AppConfig(BaseModel):
links: Annotated[
AppLinksConfig | None, Field(description="Per-account approval settings keyed by link ID.")
] = None
omit_tools_from: Annotated[
list[ToolExposureSurface] | None,
Field(description="Additional model-facing surfaces omitted for this connector's tools."),
] = None
open_world_enabled: bool | None = None
tools: AppToolsConfig | None = None