mirror of
https://github.com/openai/codex.git
synced 2026-11-05 08:28:29 +00:00
Expose MCP provenance to tool lifecycle extensions (#40976)
## What changed - Add optional `McpToolContext` metadata to `ToolStartInput`, exposing the model-visible MCP tool details and its source classification without exposing the executable client. - Classify MCP calls as connectors, configured servers, plugin servers, executor-selected plugins, or other registrations based on the prepared call. - Prepare each MCP call before notifying tool lifecycle contributors and reuse that same call for execution so the callback describes the call that runs. ## Testing - Cover host-owned connector calls and extension-owned Apps server calls, including their distinct provenance and executed tool names. GitOrigin-RevId: efd23f511b1045ffdd96349d621ad62365569b2b
This commit is contained in:
1 parent
daa3eaf10f
commit
21ff2e802c
10 files changed
+264
-3
No files matched your search
@@ -6,24 +6,39 @@ use std::sync::Mutex;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
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_extension_api::McpToolSource;
|
||||
use codex_extension_api::ResponseItem;
|
||||
use codex_extension_api::ToolLifecycleContributor;
|
||||
use codex_extension_api::ToolLifecycleFuture;
|
||||
use codex_extension_api::ToolStartInput;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL;
|
||||
use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE;
|
||||
use core_test_support::apps_test_server::apps_enabled_builder;
|
||||
use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id;
|
||||
use core_test_support::hooks::trust_discovered_hooks;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_wine_exec;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use test_case::test_case;
|
||||
|
||||
struct RecordedHistory {
|
||||
call_id: String,
|
||||
arguments: String,
|
||||
items: Vec<ResponseItem>,
|
||||
mcp_tool: Option<(String, Option<String>, McpToolSource)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -31,6 +46,36 @@ struct ConversationHistoryRecorder {
|
||||
histories: Mutex<Vec<RecordedHistory>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AppsServerOwner {
|
||||
Host,
|
||||
Extension,
|
||||
}
|
||||
|
||||
struct ExtensionOwnedAppsServer {
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl McpServerContributor<Config> for ExtensionOwnedAppsServer {
|
||||
fn id(&self) -> &'static str {
|
||||
"extension_owned_apps_lifecycle_test"
|
||||
}
|
||||
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_context: McpServerContributionContext<'a, Config>,
|
||||
) -> ExtensionFuture<'a, Vec<McpServerContribution>> {
|
||||
Box::pin(async move {
|
||||
let config = serde_json::from_value(json!({ "url": self.url }))
|
||||
.expect("test Apps MCP server config should be valid");
|
||||
vec![McpServerContribution::Set {
|
||||
name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
config: Box::new(config),
|
||||
}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolLifecycleContributor for ConversationHistoryRecorder {
|
||||
fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
|
||||
Box::pin(async move {
|
||||
@@ -41,6 +86,13 @@ impl ToolLifecycleContributor for ConversationHistoryRecorder {
|
||||
call_id: input.call_id.to_owned(),
|
||||
arguments: input.payload.log_payload().into_owned(),
|
||||
items: input.conversation_history.items().cloned().collect(),
|
||||
mcp_tool: input.mcp_tool.map(|tool| {
|
||||
(
|
||||
tool.tool_info().server_name.clone(),
|
||||
tool.tool_info().connector_id.clone(),
|
||||
tool.source().clone(),
|
||||
)
|
||||
}),
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -155,6 +207,84 @@ async fn tool_start_receives_conversation_history() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(AppsServerOwner::Host, McpToolSource::Connector; "host_owned_apps")]
|
||||
#[test_case(AppsServerOwner::Extension, McpToolSource::Other; "extension_owned_apps")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tool_start_receives_executed_mcp_call_for_connector(
|
||||
owner: AppsServerOwner,
|
||||
expected_source: McpToolSource,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount(&server).await?;
|
||||
let call_id = "calendar-lifecycle-call";
|
||||
responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
SEARCH_CALENDAR_NAMESPACE,
|
||||
SEARCH_CALENDAR_LIST_TOOL,
|
||||
"{}",
|
||||
),
|
||||
responses::ev_completed("first-response"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("assistant-1", "done"),
|
||||
responses::ev_completed("second-response"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let recorder = Arc::new(ConversationHistoryRecorder::default());
|
||||
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
||||
extensions.tool_lifecycle_contributor(recorder.clone());
|
||||
if matches!(owner, AppsServerOwner::Extension) {
|
||||
extensions.mcp_server_contributor(Arc::new(ExtensionOwnedAppsServer {
|
||||
url: format!("{}/api/codex/ps/mcp", apps_server.chatgpt_base_url),
|
||||
}));
|
||||
}
|
||||
let test = apps_enabled_builder(apps_server.chatgpt_base_url)
|
||||
.with_extensions(Arc::new(extensions.build()))
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&test.codex, CODEX_APPS_MCP_SERVER_NAME).await?;
|
||||
|
||||
test.submit_text_turn("List my calendar events.").await?;
|
||||
|
||||
{
|
||||
let histories = recorder
|
||||
.histories
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let [history] = histories.as_slice() else {
|
||||
panic!("expected one tool start, got {}", histories.len());
|
||||
};
|
||||
assert_eq!(history.call_id, call_id);
|
||||
assert_eq!(
|
||||
history.mcp_tool,
|
||||
Some((
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
Some("calendar".to_string()),
|
||||
expected_source,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
let executed_call = recorded_apps_tool_call_by_call_id(&server, call_id).await;
|
||||
assert_eq!(
|
||||
executed_call
|
||||
.pointer("/params/name")
|
||||
.and_then(Value::as_str),
|
||||
Some("calendar_list_events")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tool_start_receives_rewritten_payload_and_post_hook_history() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
Reference in new issue
Block a user