mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Stack - Base: #27191 - This PR is the third vertical and should be reviewed against `jif/external-plugins-2`, not `main`. ## Why #27191 moves the host-owned Apps MCP registration behind an extension contributor, but deliberately preserves the existing endpoint-selection feature while that contribution contract lands. App-server can therefore resolve the server through extensions, yet the hosted plugin endpoint is still selected through temporary `apps_mcp_path_override` plumbing. That is not the long-term plugin model. A plugin can bundle skills, connectors, MCP servers, and hooks, and those components do not all need the same source or execution environment. In particular, an authenticated HTTP MCP server can expose plugin capabilities directly from a backend without an executor or an orchestrator filesystem. This PR completes that hosted vertical. App-server's MCP extension now owns the aggregate hosted plugin runtime at `/ps/mcp`. Connector actions continue to arrive as MCP tools, while backend-provided skills arrive as MCP resources and use Codex's existing resource list/read paths. No second backend client, skill filesystem, or generic plugin activation framework is introduced. The backend route remains the hosted implementation. This change replaces Codex's temporary endpoint-selection mechanism, not the service behind the endpoint. ## What changed ### Hosted plugin runtime The MCP extension now contributes `codex_apps` as the hosted plugin runtime rather than as a configurable Apps endpoint: - `https://chatgpt.com` resolves to `https://chatgpt.com/backend-api/ps/mcp`; - a bare custom ChatGPT base resolves to `/api/codex/ps/mcp`; - the existing product-SKU header and ChatGPT authentication behavior are preserved; - executor availability is never consulted for this streamable HTTP transport. The same MCP connection carries both component shapes supported by the hosted endpoint: - connector actions are discovered and invoked as MCP tools; - hosted skills are enumerated and read as MCP resources through the existing `list_mcp_resources` and `read_mcp_resource` paths. This keeps component access in the subsystem that already owns the protocol instead of downloading backend skills into an orchestrator filesystem or inventing a parallel hosted-skill client. ### Explicit runtime ordering `McpManager` now resolves the reserved `codex_apps` entry in three ordered phases: 1. install the legacy Apps fallback for compatibility; 2. apply ordered extension `Set` or `Remove` overlays; 3. apply the final ChatGPT-auth gate without synthesizing the server again. This ordering is important: - an ordinary configured or plugin MCP server cannot claim the auth-bearing `codex_apps` name; - an extension-contributed hosted runtime wins over the fallback; - an extension `Remove` remains authoritative; - a host without the MCP extension retains the legacy Apps endpoint and current local-only behavior. The temporary `legacy_apps_mcp_loader_enabled` coordination flag is no longer needed. ### Remove the path override The `apps_mcp_path_override` feature and its runtime plumbing are removed, including: - the feature registry entry and structured feature config; - `Config` and `McpConfig` fields; - config schema output; - config-lock materialization; - URL override handling in `codex-mcp`. Existing boolean and structured forms still deserialize as ignored compatibility input. They are omitted from new serialized config, and config-lock comparison normalizes the removed input so older locks remain replayable. ### App-server coverage App-server MCP fixtures now serve the hosted route at `/api/codex/ps/mcp`. Existing resource-read and tool/elicitation flows therefore exercise the extension-owned endpoint rather than succeeding through the legacy fallback. The stack also adds the missing `codex_chatgpt::connectors` re-export for the manager-backed connector helper introduced in #27191. ## Compatibility - App-server installs the extension and uses `/ps/mcp` for the hosted runtime. - CLI and other hosts that do not install the extension retain the legacy Apps endpoint. - Apps disabled or non-ChatGPT authentication removes `codex_apps` from the effective runtime view. - Existing local plugins, local skills, executor-selected skills, configured MCP servers, and MCP OAuth behavior are otherwise unchanged. - Backend plugin enablement remains account/workspace state owned by the hosted endpoint; this PR does not add thread-local backend plugin selection. ## Architectural fit The stack now proves two independent runtime shapes: 1. #27184 resolves filesystem-backed skills through the executor that owns a selected root. 2. #27191 and this PR resolve a backend-hosted HTTP MCP through an extension with no executor. Together they preserve the intended separation: - selection identifies a plugin/root when explicit selection is needed; - each component's owning extension resolves its concrete access mechanism; - execution stays with the runtime required by that component; - existing skills, MCP, connector, and hook subsystems remain the downstream consumers. ## Planned follow-ups 1. **Executor stdio MCP:** selecting an executor plugin registers a manifest-declared stdio MCP server and executes it in the environment that owns the plugin. 2. **Optional backend selection:** only if CCA needs thread-local selection distinct from backend account/workspace enablement, add a concrete backend-owned capability location and surface those selected skills through the skills catalog. 3. **Connector metadata and hooks:** activate those plugin components through their existing owning subsystems, with executor hooks remaining environment-bound. 4. **Propagation and persistence:** define explicit resume, fork, subagent, refresh, and environment-removal semantics once selected roots have multiple real consumers. 5. **Local convergence:** migrate legacy local skill, MCP, connector, and hook paths behind their owning extensions one vertical at a time, then remove duplicate core managers and compatibility plumbing after parity. ## Verification Coverage in this change exercises: - extension-owned `/backend-api/ps/mcp` registration without an executor; - preservation of the legacy endpoint in hosts without the extension; - extension `Set` and `Remove` precedence over the legacy fallback; - ChatGPT-auth gating for the reserved server; - hosted MCP resource reads with and without an active thread; - connector tool invocation and MCP elicitation through the hosted route; - ignored boolean and structured forms of the removed path override; - config-lock replay compatibility for the removed feature. `cargo check -p codex-features -p codex-mcp-extension -p codex-app-server` passes. Tests and Clippy were not run locally under the current development instruction; CI provides the full validation pass.
1623 lines
50 KiB
Rust
1623 lines
50 KiB
Rust
use std::borrow::Cow;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex as StdMutex;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use anyhow::bail;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::to_response;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use axum::Json;
|
|
use axum::Router;
|
|
use axum::extract::State;
|
|
use axum::http::HeaderMap;
|
|
use axum::http::StatusCode;
|
|
use axum::http::Uri;
|
|
use axum::http::header::AUTHORIZATION;
|
|
use axum::routing::get;
|
|
use codex_app_server_protocol::AppBranding;
|
|
use codex_app_server_protocol::AppInfo;
|
|
use codex_app_server_protocol::AppListUpdatedNotification;
|
|
use codex_app_server_protocol::AppMetadata;
|
|
use codex_app_server_protocol::AppReview;
|
|
use codex_app_server_protocol::AppScreenshot;
|
|
use codex_app_server_protocol::AppsListParams;
|
|
use codex_app_server_protocol::AppsListResponse;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_login::AuthDotJson;
|
|
use codex_login::save_auth;
|
|
use pretty_assertions::assert_eq;
|
|
use rmcp::handler::server::ServerHandler;
|
|
use rmcp::model::JsonObject;
|
|
use rmcp::model::ListToolsResult;
|
|
use rmcp::model::Meta;
|
|
use rmcp::model::ServerCapabilities;
|
|
use rmcp::model::ServerInfo;
|
|
use rmcp::model::Tool;
|
|
use rmcp::model::ToolAnnotations;
|
|
use rmcp::transport::StreamableHttpServerConfig;
|
|
use rmcp::transport::StreamableHttpService;
|
|
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::net::TcpListener;
|
|
use tokio::task::JoinHandle;
|
|
use tokio::time::timeout;
|
|
|
|
// Bazel CI can spend tens of seconds starting app-server subprocesses or
|
|
// processing app-list RPCs under load.
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: Some(50),
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
let AppsListResponse { data, next_cursor } = to_response(response)?;
|
|
|
|
assert!(data.is_empty());
|
|
assert!(next_cursor.is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
|
|
let connectors = vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta".to_string(),
|
|
description: Some("Beta connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) =
|
|
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
save_auth(
|
|
codex_home.path(),
|
|
&AuthDotJson {
|
|
auth_mode: Some(AuthMode::ApiKey),
|
|
openai_api_key: Some("test-api-key".to_string()),
|
|
tokens: None,
|
|
last_refresh: None,
|
|
agent_identity: None,
|
|
personal_access_token: None,
|
|
},
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: Some(50),
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
let AppsListResponse { data, next_cursor } = to_response(response)?;
|
|
assert!(data.is_empty());
|
|
assert!(next_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> {
|
|
let connectors = vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta".to_string(),
|
|
description: Some("Beta connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) = start_apps_server_with_workspace_plugins_enabled(
|
|
connectors, tools, /*workspace_plugins_enabled*/ false,
|
|
)
|
|
.await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123")
|
|
.plan_type("team"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: Some(50),
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
let AppsListResponse { data, next_cursor } = to_response(response)?;
|
|
assert!(data.is_empty());
|
|
assert!(next_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Result<()> {
|
|
let connectors = vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta".to_string(),
|
|
description: Some("Beta connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) =
|
|
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let start_request = mcp
|
|
.send_thread_start_request(ThreadStartParams::default())
|
|
.await?;
|
|
let start_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(start_request)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response(start_response)?;
|
|
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{server_url}"
|
|
mcp_oauth_credentials_store = "file"
|
|
|
|
[features]
|
|
connectors = false
|
|
"#
|
|
),
|
|
)?;
|
|
|
|
let global_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let global_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(global_request)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: global_data,
|
|
next_cursor: global_next_cursor,
|
|
} = to_response(global_response)?;
|
|
assert!(global_data.is_empty());
|
|
assert!(global_next_cursor.is_none());
|
|
|
|
let thread_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: Some(thread.id),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let thread_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(thread_request)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: thread_data,
|
|
next_cursor: thread_next_cursor,
|
|
} = to_response(thread_response)?;
|
|
assert!(thread_data.iter().any(|app| app.id == "beta"));
|
|
assert!(thread_next_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> {
|
|
let connectors = vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta".to_string(),
|
|
description: Some("Beta connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
let mut app_only_tool = connector_tool("beta", "Beta App")?;
|
|
app_only_tool
|
|
.meta
|
|
.as_mut()
|
|
.expect("connector tool should include metadata")
|
|
.0
|
|
.insert("ui".to_string(), json!({ "visibility": ["app"] }));
|
|
let tools = vec![app_only_tool];
|
|
let (server_url, server_handle) =
|
|
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-app-only")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: true,
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse { data, next_cursor } = to_response(response)?;
|
|
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].id, "beta");
|
|
assert!(data[0].is_accessible);
|
|
assert!(next_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
|
|
let connectors = vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta".to_string(),
|
|
description: Some("Beta connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) =
|
|
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{server_url}"
|
|
|
|
[features]
|
|
connectors = true
|
|
|
|
[apps.beta]
|
|
enabled = false
|
|
"#
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: response_data,
|
|
next_cursor,
|
|
} = to_response(response)?;
|
|
assert!(next_cursor.is_none());
|
|
assert_eq!(response_data.len(), 1);
|
|
assert_eq!(response_data[0].id, "beta");
|
|
assert!(!response_data[0].is_enabled);
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> {
|
|
let alpha_branding = Some(AppBranding {
|
|
category: Some("PRODUCTIVITY".to_string()),
|
|
developer: Some("Acme".to_string()),
|
|
website: Some("https://acme.example".to_string()),
|
|
privacy_policy: Some("https://acme.example/privacy".to_string()),
|
|
terms_of_service: Some("https://acme.example/terms".to_string()),
|
|
is_discoverable_app: true,
|
|
});
|
|
let alpha_app_metadata = Some(AppMetadata {
|
|
review: Some(AppReview {
|
|
status: "APPROVED".to_string(),
|
|
}),
|
|
categories: Some(vec!["PRODUCTIVITY".to_string()]),
|
|
sub_categories: Some(vec!["WRITING".to_string()]),
|
|
seo_description: Some("Alpha connector".to_string()),
|
|
screenshots: Some(vec![AppScreenshot {
|
|
url: Some("https://example.com/alpha-screenshot.png".to_string()),
|
|
file_id: Some("file_123".to_string()),
|
|
user_prompt: "Summarize this draft".to_string(),
|
|
}]),
|
|
developer: Some("Acme".to_string()),
|
|
version: Some("1.2.3".to_string()),
|
|
version_id: Some("version_123".to_string()),
|
|
version_notes: Some("Fixes and improvements".to_string()),
|
|
first_party_type: Some("internal".to_string()),
|
|
first_party_requires_install: Some(true),
|
|
show_in_composer_when_unlinked: Some(true),
|
|
});
|
|
let alpha_labels = Some(HashMap::from([
|
|
("feature".to_string(), "beta".to_string()),
|
|
("source".to_string(), "directory".to_string()),
|
|
]));
|
|
|
|
let connectors = vec![
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: Some("https://example.com/alpha.png".to_string()),
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: alpha_branding.clone(),
|
|
app_metadata: alpha_app_metadata.clone(),
|
|
labels: alpha_labels.clone(),
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "beta".to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
];
|
|
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) = start_apps_server_with_delays(
|
|
connectors.clone(),
|
|
tools,
|
|
Duration::from_millis(300),
|
|
Duration::ZERO,
|
|
)
|
|
.await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let expected_accessible = vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
|
is_accessible: true,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
|
|
let first_update = read_app_list_updated_notification(&mut mcp).await?;
|
|
assert_eq!(first_update.data, expected_accessible);
|
|
|
|
let expected_merged = vec![
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
|
is_accessible: true,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: Some("https://example.com/alpha.png".to_string()),
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: alpha_branding,
|
|
app_metadata: alpha_app_metadata,
|
|
labels: alpha_labels,
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
];
|
|
|
|
let second_update = read_app_list_updated_notification(&mut mcp).await?;
|
|
assert_eq!(second_update.data, expected_merged);
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
let AppsListResponse {
|
|
data: response_data,
|
|
next_cursor,
|
|
} = to_response(response)?;
|
|
assert_eq!(response_data, expected_merged);
|
|
assert!(next_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() -> Result<()> {
|
|
let connectors = vec![
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: Some("https://example.com/alpha.png".to_string()),
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "beta".to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
];
|
|
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) = start_apps_server_with_delays(
|
|
connectors.clone(),
|
|
tools,
|
|
Duration::ZERO,
|
|
Duration::from_millis(300),
|
|
)
|
|
.await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-directory-first")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let expected = vec![
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
|
is_accessible: true,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: Some("https://example.com/alpha.png".to_string()),
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
];
|
|
|
|
loop {
|
|
let update = read_app_list_updated_notification(&mut mcp).await?;
|
|
if update.data == expected {
|
|
break;
|
|
}
|
|
|
|
assert!(
|
|
!update.data.is_empty() && update.data.iter().all(|connector| connector.is_accessible),
|
|
"unexpected directory-only app/list update before accessible apps loaded"
|
|
);
|
|
}
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse { data, next_cursor } = to_response(response)?;
|
|
assert_eq!(data, expected);
|
|
assert!(next_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> {
|
|
let connectors = vec![AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
let (server_url, server_handle) = start_apps_server_with_delays(
|
|
connectors.clone(),
|
|
Vec::new(),
|
|
Duration::from_millis(300),
|
|
Duration::ZERO,
|
|
)
|
|
.await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-empty-interim")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let maybe_update = timeout(
|
|
Duration::from_millis(150),
|
|
read_app_list_updated_notification(&mut mcp),
|
|
)
|
|
.await;
|
|
assert!(
|
|
maybe_update.is_err(),
|
|
"unexpected empty interim app/list update"
|
|
);
|
|
|
|
let expected = vec![AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
|
|
let update = read_app_list_updated_notification(&mut mcp).await?;
|
|
assert_eq!(update.data, expected);
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse { data, next_cursor } = to_response(response)?;
|
|
assert_eq!(data, expected);
|
|
assert!(next_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_paginates_results() -> Result<()> {
|
|
let connectors = vec![
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "beta".to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
];
|
|
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) = start_apps_server_with_delays(
|
|
connectors.clone(),
|
|
tools,
|
|
Duration::ZERO,
|
|
Duration::from_millis(300),
|
|
)
|
|
.await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let first_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: Some(1),
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let first_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(first_request)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: first_page,
|
|
next_cursor: first_cursor,
|
|
} = to_response(first_response)?;
|
|
|
|
let expected_first = vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
|
is_accessible: true,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
|
|
assert_eq!(first_page, expected_first);
|
|
let next_cursor = first_cursor.ok_or_else(|| anyhow::anyhow!("missing cursor"))?;
|
|
|
|
loop {
|
|
let update = read_app_list_updated_notification(&mut mcp).await?;
|
|
if update.data.len() == 2 && update.data.iter().any(|connector| connector.is_accessible) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let second_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: Some(1),
|
|
cursor: Some(next_cursor),
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let second_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(second_request)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: second_page,
|
|
next_cursor: second_cursor,
|
|
} = to_response(second_response)?;
|
|
|
|
let expected_second = vec![AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
|
|
assert_eq!(second_page, expected_second);
|
|
assert!(second_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result<()> {
|
|
let connectors = vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: Some("Beta connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) =
|
|
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let initial_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let initial_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(initial_request)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: initial_data,
|
|
next_cursor: initial_next_cursor,
|
|
} = to_response(initial_response)?;
|
|
assert!(initial_next_cursor.is_none());
|
|
assert_eq!(initial_data.len(), 1);
|
|
assert!(initial_data.iter().all(|app| app.is_accessible));
|
|
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token-invalid")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let refetch_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: true,
|
|
})
|
|
.await?;
|
|
let refetch_error: JSONRPCError = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(refetch_request)),
|
|
)
|
|
.await??;
|
|
assert!(refetch_error.error.message.contains("failed to"));
|
|
|
|
let cached_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let cached_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(cached_request)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: cached_data,
|
|
next_cursor: cached_next_cursor,
|
|
} = to_response(cached_response)?;
|
|
|
|
assert_eq!(cached_data, initial_data);
|
|
assert!(cached_next_cursor.is_none());
|
|
server_handle.abort();
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Result<()> {
|
|
let initial_connectors = vec![
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha v1".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: Some("Beta v1".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
];
|
|
let initial_tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control(
|
|
initial_connectors,
|
|
initial_tools,
|
|
Duration::from_millis(300),
|
|
Duration::ZERO,
|
|
)
|
|
.await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let warm_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let warm_first_update = read_app_list_updated_notification(&mut mcp).await?;
|
|
assert_eq!(
|
|
warm_first_update.data,
|
|
vec![AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
|
is_accessible: true,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}]
|
|
);
|
|
|
|
let warm_second_update = read_app_list_updated_notification(&mut mcp).await?;
|
|
assert_eq!(
|
|
warm_second_update.data,
|
|
vec![
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: Some("Beta v1".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
|
is_accessible: true,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha v1".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
]
|
|
);
|
|
|
|
let warm_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(warm_request)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: warm_data,
|
|
next_cursor: warm_next_cursor,
|
|
} = to_response(warm_response)?;
|
|
assert_eq!(warm_data, warm_second_update.data);
|
|
assert!(warm_next_cursor.is_none());
|
|
|
|
server_control.set_connectors(vec![AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha v2".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}]);
|
|
server_control.set_tools(Vec::new());
|
|
|
|
let refetch_request = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
limit: None,
|
|
cursor: None,
|
|
thread_id: None,
|
|
force_refetch: true,
|
|
})
|
|
.await?;
|
|
|
|
let first_update = read_app_list_updated_notification(&mut mcp).await?;
|
|
assert_eq!(
|
|
first_update.data,
|
|
vec![
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta App".to_string(),
|
|
description: Some("Beta v1".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
|
is_accessible: true,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha v1".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
]
|
|
);
|
|
|
|
let maybe_second_update = timeout(
|
|
Duration::from_millis(150),
|
|
read_app_list_updated_notification(&mut mcp),
|
|
)
|
|
.await;
|
|
assert!(
|
|
maybe_second_update.is_err(),
|
|
"unexpected inaccessible-only app/list update during force refetch"
|
|
);
|
|
|
|
let expected_final = vec![AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha v2".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}];
|
|
let second_update = read_app_list_updated_notification(&mut mcp).await?;
|
|
assert_eq!(second_update.data, expected_final);
|
|
|
|
let refetch_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(refetch_request)),
|
|
)
|
|
.await??;
|
|
let AppsListResponse {
|
|
data: refetch_data,
|
|
next_cursor: refetch_next_cursor,
|
|
} = to_response(refetch_response)?;
|
|
assert_eq!(refetch_data, expected_final);
|
|
assert!(refetch_next_cursor.is_none());
|
|
|
|
server_handle.abort();
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_app_list_updated_notification(
|
|
mcp: &mut TestAppServer,
|
|
) -> Result<AppListUpdatedNotification> {
|
|
let notification = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("app/list/updated"),
|
|
)
|
|
.await??;
|
|
let parsed: ServerNotification = notification.try_into()?;
|
|
let ServerNotification::AppListUpdated(payload) = parsed else {
|
|
bail!("unexpected notification variant");
|
|
};
|
|
Ok(payload)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AppsServerState {
|
|
expected_bearer: String,
|
|
expected_account_id: String,
|
|
response: Arc<StdMutex<serde_json::Value>>,
|
|
directory_delay: Duration,
|
|
workspace_plugins_enabled: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AppListMcpServer {
|
|
tools: Arc<StdMutex<Vec<Tool>>>,
|
|
tools_delay: Duration,
|
|
}
|
|
|
|
impl AppListMcpServer {
|
|
fn new(tools: Arc<StdMutex<Vec<Tool>>>, tools_delay: Duration) -> Self {
|
|
Self { tools, tools_delay }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AppsServerControl {
|
|
response: Arc<StdMutex<serde_json::Value>>,
|
|
tools: Arc<StdMutex<Vec<Tool>>>,
|
|
}
|
|
|
|
impl AppsServerControl {
|
|
fn set_connectors(&self, connectors: Vec<AppInfo>) {
|
|
let mut response_guard = self
|
|
.response
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
*response_guard = json!({ "apps": connectors, "next_token": null });
|
|
}
|
|
|
|
fn set_tools(&self, tools: Vec<Tool>) {
|
|
let mut tools_guard = self
|
|
.tools
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
*tools_guard = tools;
|
|
}
|
|
}
|
|
|
|
impl ServerHandler for AppListMcpServer {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
|
}
|
|
|
|
fn list_tools(
|
|
&self,
|
|
_request: Option<rmcp::model::PaginatedRequestParams>,
|
|
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_
|
|
{
|
|
let tools = self.tools.clone();
|
|
let tools_delay = self.tools_delay;
|
|
async move {
|
|
if tools_delay > Duration::ZERO {
|
|
tokio::time::sleep(tools_delay).await;
|
|
}
|
|
let tools = tools
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone();
|
|
Ok(ListToolsResult {
|
|
tools,
|
|
next_cursor: None,
|
|
meta: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn start_apps_server_with_delays(
|
|
connectors: Vec<AppInfo>,
|
|
tools: Vec<Tool>,
|
|
directory_delay: Duration,
|
|
tools_delay: Duration,
|
|
) -> Result<(String, JoinHandle<()>)> {
|
|
let (server_url, server_handle, _server_control) =
|
|
start_apps_server_with_delays_and_control(connectors, tools, directory_delay, tools_delay)
|
|
.await?;
|
|
Ok((server_url, server_handle))
|
|
}
|
|
|
|
async fn start_apps_server_with_workspace_plugins_enabled(
|
|
connectors: Vec<AppInfo>,
|
|
tools: Vec<Tool>,
|
|
workspace_plugins_enabled: bool,
|
|
) -> Result<(String, JoinHandle<()>)> {
|
|
let (server_url, server_handle, _server_control) =
|
|
start_apps_server_with_delays_and_control_inner(
|
|
connectors,
|
|
tools,
|
|
Duration::ZERO,
|
|
Duration::ZERO,
|
|
workspace_plugins_enabled,
|
|
)
|
|
.await?;
|
|
Ok((server_url, server_handle))
|
|
}
|
|
|
|
async fn start_apps_server_with_delays_and_control(
|
|
connectors: Vec<AppInfo>,
|
|
tools: Vec<Tool>,
|
|
directory_delay: Duration,
|
|
tools_delay: Duration,
|
|
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
|
|
start_apps_server_with_delays_and_control_inner(
|
|
connectors,
|
|
tools,
|
|
directory_delay,
|
|
tools_delay,
|
|
/*workspace_plugins_enabled*/ true,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn start_apps_server_with_delays_and_control_inner(
|
|
connectors: Vec<AppInfo>,
|
|
tools: Vec<Tool>,
|
|
directory_delay: Duration,
|
|
tools_delay: Duration,
|
|
workspace_plugins_enabled: bool,
|
|
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
|
|
let response = Arc::new(StdMutex::new(
|
|
json!({ "apps": connectors, "next_token": null }),
|
|
));
|
|
let tools = Arc::new(StdMutex::new(tools));
|
|
let state = AppsServerState {
|
|
expected_bearer: "Bearer chatgpt-token".to_string(),
|
|
expected_account_id: "account-123".to_string(),
|
|
response: response.clone(),
|
|
directory_delay,
|
|
workspace_plugins_enabled,
|
|
};
|
|
let state = Arc::new(state);
|
|
let server_control = AppsServerControl {
|
|
response,
|
|
tools: tools.clone(),
|
|
};
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let addr = listener.local_addr()?;
|
|
|
|
let mcp_service = StreamableHttpService::new(
|
|
{
|
|
let tools = tools.clone();
|
|
move || Ok(AppListMcpServer::new(tools.clone(), tools_delay))
|
|
},
|
|
Arc::new(LocalSessionManager::default()),
|
|
StreamableHttpServerConfig::default(),
|
|
);
|
|
|
|
let router = Router::new()
|
|
.route("/connectors/directory/list", get(list_directory_connectors))
|
|
.route(
|
|
"/connectors/directory/list_workspace",
|
|
get(list_directory_connectors),
|
|
)
|
|
.route(
|
|
"/accounts/account-123/settings",
|
|
get(workspace_settings_response),
|
|
)
|
|
.with_state(state)
|
|
.nest_service("/api/codex/ps/mcp", mcp_service);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let _ = axum::serve(listener, router).await;
|
|
});
|
|
|
|
Ok((format!("http://{addr}"), handle, server_control))
|
|
}
|
|
|
|
async fn workspace_settings_response(
|
|
State(state): State<Arc<AppsServerState>>,
|
|
headers: HeaderMap,
|
|
) -> Result<impl axum::response::IntoResponse, StatusCode> {
|
|
let bearer_ok = headers
|
|
.get(AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value == state.expected_bearer);
|
|
let account_ok = headers
|
|
.get("chatgpt-account-id")
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value == state.expected_account_id);
|
|
|
|
if !bearer_ok || !account_ok {
|
|
Err(StatusCode::UNAUTHORIZED)
|
|
} else {
|
|
Ok(Json(json!({
|
|
"beta_settings": {
|
|
"enable_plugins": state.workspace_plugins_enabled
|
|
}
|
|
})))
|
|
}
|
|
}
|
|
|
|
async fn list_directory_connectors(
|
|
State(state): State<Arc<AppsServerState>>,
|
|
headers: HeaderMap,
|
|
uri: Uri,
|
|
) -> Result<impl axum::response::IntoResponse, StatusCode> {
|
|
if state.directory_delay > Duration::ZERO {
|
|
tokio::time::sleep(state.directory_delay).await;
|
|
}
|
|
|
|
let bearer_ok = headers
|
|
.get(AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value == state.expected_bearer);
|
|
let account_ok = headers
|
|
.get("chatgpt-account-id")
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value == state.expected_account_id);
|
|
let external_logos_ok = uri
|
|
.query()
|
|
.is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));
|
|
|
|
if !bearer_ok || !account_ok {
|
|
Err(StatusCode::UNAUTHORIZED)
|
|
} else if !external_logos_ok {
|
|
Err(StatusCode::BAD_REQUEST)
|
|
} else {
|
|
let response = state
|
|
.response
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone();
|
|
Ok(Json(response))
|
|
}
|
|
}
|
|
|
|
fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
|
|
let schema: JsonObject = serde_json::from_value(json!({
|
|
"type": "object",
|
|
"additionalProperties": false
|
|
}))?;
|
|
let mut tool = Tool::new(
|
|
Cow::Owned(format!("connector_{connector_id}")),
|
|
Cow::Borrowed("Connector test tool"),
|
|
Arc::new(schema),
|
|
);
|
|
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
|
|
|
let mut meta = Meta::new();
|
|
meta.0
|
|
.insert("connector_id".to_string(), json!(connector_id));
|
|
meta.0
|
|
.insert("connector_name".to_string(), json!(connector_name));
|
|
tool.meta = Some(meta);
|
|
Ok(tool)
|
|
}
|
|
|
|
fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
|
let config_toml = codex_home.join("config.toml");
|
|
std::fs::write(
|
|
config_toml,
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{base_url}"
|
|
mcp_oauth_credentials_store = "file"
|
|
|
|
[features]
|
|
connectors = true
|
|
"#
|
|
),
|
|
)
|
|
}
|