Add an app-server API for reading app metadata (#33651)

## What changed

- Add the experimental `app/read` request for fetching metadata for up to 100
  app IDs, with optional display-only tool summaries.
- Preserve first-request order while deduplicating IDs, and report unknown or
  unauthorized apps as partial misses.
- Cache metadata by backend and ChatGPT account/workspace identity, refetch
  metadata-only entries when tools are requested, and preserve cached records
  when a backend request fails.

## Testing

- Cover request serialization, ordering, deduplication, partial misses, caching,
  tool-summary refetches, backend failures, cache isolation, and the ID limit.

GitOrigin-RevId: 55fc3281d99291e76d6c4a581e8d11569d3cb1ac
This commit is contained in:
stevenlee-oai
2026-07-16 17:46:36 +00:00
committed by copyberry
parent f64233d142
commit 726b6378d2
26 changed files with 1716 additions and 1 deletions
@@ -120,6 +120,26 @@
},
"type": "object"
},
"AppsReadParams": {
"description": "EXPERIMENTAL - read metadata for specific apps/connectors.",
"properties": {
"appIds": {
"description": "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.",
"items": {
"type": "string"
},
"type": "array"
},
"includeTools": {
"description": "When true, include display-only public tool summaries in the returned metadata.",
"type": "boolean"
}
},
"required": [
"appIds"
],
"type": "object"
},
"AskForApproval": {
"oneOf": [
{
@@ -5765,6 +5785,30 @@
"title": "Plugin/share/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"app/read"
],
"title": "App/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AppsReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "App/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1069,6 +1069,30 @@
"title": "Plugin/share/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"app/read"
],
"title": "App/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AppsReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "App/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -6801,6 +6825,28 @@
},
"type": "object"
},
"AppToolSummary": {
"description": "EXPERIMENTAL - metadata returned by app/read.",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
}
},
"required": [
"description",
"name"
],
"type": "object"
},
"AppToolsConfig": {
"type": "object"
},
@@ -6925,6 +6971,52 @@
"title": "AppsListResponse",
"type": "object"
},
"AppsReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - read metadata for specific apps/connectors.",
"properties": {
"appIds": {
"description": "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.",
"items": {
"type": "string"
},
"type": "array"
},
"includeTools": {
"description": "When true, include display-only public tool summaries in the returned metadata.",
"type": "boolean"
}
},
"required": [
"appIds"
],
"title": "AppsReadParams",
"type": "object"
},
"AppsReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - app/read response.",
"properties": {
"apps": {
"items": {
"$ref": "#/definitions/v2/ConnectorMetadata"
},
"type": "array"
},
"missingAppIds": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"apps",
"missingAppIds"
],
"title": "AppsReadResponse",
"type": "object"
},
"AskForApproval": {
"oneOf": [
{
@@ -8708,6 +8800,43 @@
],
"type": "object"
},
"ConnectorMetadata": {
"description": "EXPERIMENTAL - metadata returned by app/read.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"iconUrl": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"toolSummaries": {
"items": {
"$ref": "#/definitions/v2/AppToolSummary"
},
"type": [
"array",
"null"
]
}
},
"required": [
"id",
"name"
],
"type": "object"
},
"ConsumeAccountRateLimitResetCreditOutcome": {
"oneOf": [
{
@@ -880,6 +880,28 @@
},
"type": "object"
},
"AppToolSummary": {
"description": "EXPERIMENTAL - metadata returned by app/read.",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
}
},
"required": [
"description",
"name"
],
"type": "object"
},
"AppToolsConfig": {
"type": "object"
},
@@ -1004,6 +1026,52 @@
"title": "AppsListResponse",
"type": "object"
},
"AppsReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - read metadata for specific apps/connectors.",
"properties": {
"appIds": {
"description": "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.",
"items": {
"type": "string"
},
"type": "array"
},
"includeTools": {
"description": "When true, include display-only public tool summaries in the returned metadata.",
"type": "boolean"
}
},
"required": [
"appIds"
],
"title": "AppsReadParams",
"type": "object"
},
"AppsReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - app/read response.",
"properties": {
"apps": {
"items": {
"$ref": "#/definitions/ConnectorMetadata"
},
"type": "array"
},
"missingAppIds": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"apps",
"missingAppIds"
],
"title": "AppsReadResponse",
"type": "object"
},
"AskForApproval": {
"oneOf": [
{
@@ -2107,6 +2175,30 @@
"title": "Plugin/share/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"app/read"
],
"title": "App/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AppsReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "App/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4901,6 +4993,43 @@
],
"type": "object"
},
"ConnectorMetadata": {
"description": "EXPERIMENTAL - metadata returned by app/read.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"iconUrl": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"toolSummaries": {
"items": {
"$ref": "#/definitions/AppToolSummary"
},
"type": [
"array",
"null"
]
}
},
"required": [
"id",
"name"
],
"type": "object"
},
"ConsumeAccountRateLimitResetCreditOutcome": {
"oneOf": [
{
@@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "EXPERIMENTAL - read metadata for specific apps/connectors.",
"properties": {
"appIds": {
"description": "App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while preserving their first-request order.",
"items": {
"type": "string"
},
"type": "array"
},
"includeTools": {
"description": "When true, include display-only public tool summaries in the returned metadata.",
"type": "boolean"
}
},
"required": [
"appIds"
],
"title": "AppsReadParams",
"type": "object"
}
@@ -0,0 +1,85 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AppToolSummary": {
"description": "EXPERIMENTAL - metadata returned by app/read.",
"properties": {
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
}
},
"required": [
"description",
"name"
],
"type": "object"
},
"ConnectorMetadata": {
"description": "EXPERIMENTAL - metadata returned by app/read.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"iconUrl": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"toolSummaries": {
"items": {
"$ref": "#/definitions/AppToolSummary"
},
"type": [
"array",
"null"
]
}
},
"required": [
"id",
"name"
],
"type": "object"
}
},
"description": "EXPERIMENTAL - app/read response.",
"properties": {
"apps": {
"items": {
"$ref": "#/definitions/ConnectorMetadata"
},
"type": "array"
},
"missingAppIds": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"apps",
"missingAppIds"
],
"title": "AppsReadResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -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.
/**
* EXPERIMENTAL - metadata returned by app/read.
*/
export type AppToolSummary = { name: string, title: string | null, description: string, };
@@ -0,0 +1,17 @@
// 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.
/**
* EXPERIMENTAL - read metadata for specific apps/connectors.
*/
export type AppsReadParams = {
/**
* App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while
* preserving their first-request order.
*/
appIds: Array<string>,
/**
* When true, include display-only public tool summaries in the returned metadata.
*/
includeTools?: boolean, };
@@ -0,0 +1,9 @@
// 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 { ConnectorMetadata } from "./ConnectorMetadata";
/**
* EXPERIMENTAL - app/read response.
*/
export type AppsReadResponse = { apps: Array<ConnectorMetadata>, missingAppIds: Array<string>, };
@@ -0,0 +1,9 @@
// 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 { AppToolSummary } from "./AppToolSummary";
/**
* EXPERIMENTAL - metadata returned by app/read.
*/
export type ConnectorMetadata = { id: string, name: string, description: string | null, iconUrl: string | null, toolSummaries: Array<AppToolSummary> | null, };
@@ -26,12 +26,15 @@ export type { AppSummary } from "./AppSummary";
export type { AppTemplateSummary } from "./AppTemplateSummary";
export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
export type { AppToolApproval } from "./AppToolApproval";
export type { AppToolSummary } from "./AppToolSummary";
export type { AppToolsConfig } from "./AppToolsConfig";
export type { ApprovalsReviewer } from "./ApprovalsReviewer";
export type { AppsConfig } from "./AppsConfig";
export type { AppsDefaultConfig } from "./AppsDefaultConfig";
export type { AppsListParams } from "./AppsListParams";
export type { AppsListResponse } from "./AppsListResponse";
export type { AppsReadParams } from "./AppsReadParams";
export type { AppsReadResponse } from "./AppsReadResponse";
export type { AskForApproval } from "./AskForApproval";
export type { AttestationGenerateParams } from "./AttestationGenerateParams";
export type { AttestationGenerateResponse } from "./AttestationGenerateResponse";
@@ -85,6 +88,7 @@ export type { ConfigWarningNotification } from "./ConfigWarningNotification";
export type { ConfigWriteResponse } from "./ConfigWriteResponse";
export type { ConfiguredHookHandler } from "./ConfiguredHookHandler";
export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
export type { ConnectorMetadata } from "./ConnectorMetadata";
export type { ConsumeAccountRateLimitResetCreditOutcome } from "./ConsumeAccountRateLimitResetCreditOutcome";
export type { ConsumeAccountRateLimitResetCreditParams } from "./ConsumeAccountRateLimitResetCreditParams";
export type { ConsumeAccountRateLimitResetCreditResponse } from "./ConsumeAccountRateLimitResetCreditResponse";
@@ -735,6 +735,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginShareDeleteResponse,
},
AppsRead => "app/read" {
params: v2::AppsReadParams,
serialization: None,
response: v2::AppsReadResponse,
},
AppsList => "app/list" {
params: v2::AppsListParams,
serialization: None,
@@ -3103,6 +3108,26 @@ mod tests {
Ok(())
}
#[test]
fn serialize_read_apps() -> Result<()> {
let request = ClientRequest::AppsRead {
request_id: RequestId::Integer(9),
params: v2::AppsReadParams {
app_ids: vec!["app-a".to_string(), "app-b".to_string()],
include_tools: true,
},
};
assert_eq!(
json!({
"method": "app/read",
"id": 9,
"params": { "appIds": ["app-a", "app-b"], "includeTools": true }
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_environment_add() -> Result<()> {
let request = ClientRequest::EnvironmentAdd {
@@ -131,6 +131,50 @@ fn non_empty_category(category: Option<&str>) -> Option<String> {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL - read metadata for specific apps/connectors.
pub struct AppsReadParams {
/// App ids to read. The server accepts at most 100 ids and deduplicates repeated ids while
/// preserving their first-request order.
pub app_ids: Vec<String>,
/// When true, include display-only public tool summaries in the returned metadata.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub include_tools: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL - metadata returned by app/read.
pub struct AppToolSummary {
pub name: String,
pub title: Option<String>,
pub description: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL - metadata returned by app/read.
pub struct ConnectorMetadata {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon_url: Option<String>,
pub tool_summaries: Option<Vec<AppToolSummary>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL - app/read response.
pub struct AppsReadResponse {
pub apps: Vec<ConnectorMetadata>,
pub missing_app_ids: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+38
View File
@@ -1875,6 +1875,44 @@ The server also emits `app/list/updated` notifications when newly loaded accessi
}
```
Use `app/read` when a client already has app ids and only needs metadata. The request accepts at
most 100 `appIds`; repeated ids are deduplicated while preserving first-request order. Both `apps`
and `missingAppIds` follow that order. Unknown or unauthorized ids are returned as partial misses
instead of failing the whole request.
```json
{ "method": "app/read", "id": 51, "params": {
"appIds": ["demo-app", "missing-app"],
"includeTools": true
} }
{ "id": 51, "result": {
"apps": [
{
"id": "demo-app",
"name": "Demo App",
"description": "Example app for documentation.",
"iconUrl": "https://files.openai.com/content?id=demo-app",
"toolSummaries": [
{
"name": "search",
"title": "Search",
"description": "Search the app."
}
]
}
],
"missingAppIds": ["missing-app"]
} }
```
`app/read` reads fresh metadata records from a cache partitioned by backend URL and ChatGPT
account/workspace identity, then makes at most one `POST /ps/apps/batch` for missing or
expired ids. `includeTools` defaults to false and is forwarded as `include_tools`; a fresh
metadata-only cache entry is refetched when tool summaries are requested. Backend or transport
failures return an RPC error without replacing existing cache records. Its metadata shape can
include display-only public tool summaries and intentionally excludes runtime state, MCP tool
state, full actions, and model descriptions.
Connected apps may override the thread's approval reviewer in `config.toml`.
Use `apps._default.approvals_reviewer` to set the reviewer for all apps, and a
per-app value to override that default. When both are omitted, the app inherits
+41
View File
@@ -3,11 +3,15 @@ use codex_app_server_protocol::AppInfo as ApiAppInfo;
use codex_app_server_protocol::AppMetadata as ApiAppMetadata;
use codex_app_server_protocol::AppReview as ApiAppReview;
use codex_app_server_protocol::AppScreenshot as ApiAppScreenshot;
use codex_app_server_protocol::AppToolSummary as ApiAppToolSummary;
use codex_app_server_protocol::ConnectorMetadata as ApiConnectorMetadata;
use codex_connectors::AppBranding;
use codex_connectors::AppInfo;
use codex_connectors::AppMetadata;
use codex_connectors::AppReview;
use codex_connectors::AppScreenshot;
use codex_connectors::ConnectorMetadata;
use codex_connectors::ConnectorToolSummary;
/// Converts connector-domain app metadata owned by `codex-connectors` into the app-server wire
/// type owned by `codex-app-server-protocol`.
@@ -52,6 +56,43 @@ pub(crate) fn app_info_to_api(app: AppInfo) -> ApiAppInfo {
}
}
/// Converts metadata-only connector data into the app-server wire type.
///
/// Keeping this separate from app_info_to_api makes it impossible for app/read to accidentally
/// grow runtime state from the broader app/list shape.
pub(crate) fn connector_metadata_to_api(metadata: ConnectorMetadata) -> ApiConnectorMetadata {
let ConnectorMetadata {
id,
name,
description,
icon_url,
tool_summaries,
} = metadata;
ApiConnectorMetadata {
id,
name,
description,
icon_url,
tool_summaries: tool_summaries.map(|tools| {
tools
.into_iter()
.map(|tool| {
let ConnectorToolSummary {
name,
title,
description,
} = tool;
ApiAppToolSummary {
name,
title,
description,
}
})
.collect()
}),
}
}
fn app_branding_to_api(branding: AppBranding) -> ApiAppBranding {
let AppBranding {
category,
@@ -1219,6 +1219,7 @@ impl MessageProcessor {
ClientRequest::PluginShareDelete { params, .. } => {
self.plugin_processor.plugin_share_delete(params).await
}
ClientRequest::AppsRead { params, .. } => self.apps_processor.apps_read(params).await,
ClientRequest::AppsList { params, .. } => {
self.apps_processor.apps_list(&request_id, params).await
}
@@ -34,6 +34,8 @@ use codex_app_server_protocol::AppTemplateSummary;
use codex_app_server_protocol::AppTemplateUnavailableReason;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::AppsReadParams;
use codex_app_server_protocol::AppsReadResponse;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::CancelLoginAccountParams;
@@ -1,5 +1,6 @@
use super::*;
use crate::app_info::app_info_to_api;
use crate::app_info::connector_metadata_to_api;
pub(crate) struct AppsRequestProcessor {
auth_manager: Arc<AuthManager>,
@@ -32,6 +33,61 @@ impl AppsRequestProcessor {
}
}
pub(crate) async fn apps_read(
&self,
params: AppsReadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let AppsReadParams {
app_ids,
include_tools,
} = params;
if app_ids.len() > APP_READ_MAX_IDS {
return Err(invalid_params(format!(
"app/read accepts at most {APP_READ_MAX_IDS} appIds"
)));
}
let mut seen_app_ids = HashSet::new();
let app_ids = app_ids
.into_iter()
.filter(|app_id| seen_app_ids.insert(app_id.clone()))
.collect::<Vec<_>>();
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let auth = self.auth_manager.auth().await;
if !config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
|| !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
return Ok(Some(
AppsReadResponse {
apps: Vec::new(),
missing_app_ids: app_ids,
}
.into(),
));
}
let auth = auth
.as_ref()
.ok_or_else(|| internal_error("app/read requires ChatGPT auth".to_string()))?;
let connectors::ConnectorMetadataReadResult {
apps,
missing_app_ids,
} = connectors::read_connector_metadata(&config, auth, &app_ids, include_tools)
.await
.map_err(|err| internal_error(format!("failed to read app metadata: {err}")))?;
Ok(Some(
AppsReadResponse {
apps: apps.into_iter().map(connector_metadata_to_api).collect(),
missing_app_ids,
}
.into(),
))
}
pub(crate) async fn apps_list(
&self,
request_id: &ConnectionRequestId,
@@ -400,6 +456,7 @@ fn record_legacy_apps_installed_duration(started_at: Instant, reload: bool) {
);
}
}
const APP_READ_MAX_IDS: usize = 100;
enum AppListLoadResult {
Accessible(Result<AccessibleConnectorsStatus, String>),
@@ -16,6 +16,7 @@ use tokio::process::ChildStdout;
use anyhow::Context;
use anyhow::ensure;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsReadParams;
use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
@@ -775,6 +776,12 @@ impl TestAppServer {
self.send_request("app/list", params).await
}
/// Send an `app/read` JSON-RPC request.
pub async fn send_apps_read_request(&mut self, params: AppsReadParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("app/read", params).await
}
/// Send an `mcpServer/resource/read` JSON-RPC request.
pub async fn send_mcp_resource_read_request(
&mut self,
@@ -0,0 +1,541 @@
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::ChatGptIdTokenClaims;
use app_test_support::TestAppServer;
use app_test_support::encode_id_token;
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::header::AUTHORIZATION;
use axum::routing::post;
use codex_app_server_protocol::AppToolSummary;
use codex_app_server_protocol::AppsReadParams;
use codex_app_server_protocol::AppsReadResponse;
use codex_app_server_protocol::ConnectorMetadata;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[tokio::test]
async fn app_read_deduplicates_orders_partial_misses_and_reuses_cached_metadata() -> Result<()> {
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("external@example.com")
.plan_type("plus")
.chatgpt_account_id("account-123"),
)?;
let state = BatchServerState::new(
json!({
"apps": [
app_response("alpha", "Alpha", Some("https://files.openai.com/content?id=alpha")),
app_response("beta", "Beta", Some("https://files.openai.com/content?id=beta")),
]
}),
&access_token,
"tpp",
);
let (server_url, server_handle) = start_batch_server(state.clone()).await?;
let codex_home = TempDir::new()?;
write_apps_config(codex_home.path(), &server_url, Some("tpp"))?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.without_managed_config()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let login_id = mcp
.send_chatgpt_auth_tokens_login_request(
access_token,
"account-123".to_string(),
Some("plus".to_string()),
)
.await?;
let login_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(login_id)),
)
.await??;
assert_eq!(
to_response::<LoginAccountResponse>(login_response)?,
LoginAccountResponse::ChatgptAuthTokens {}
);
let response = read_apps(
&mut mcp,
vec!["beta", "missing", "alpha", "beta", "forbidden"],
/*include_tools*/ true,
)
.await?;
assert_eq!(
response,
AppsReadResponse {
apps: vec![
metadata(
"beta",
"Beta",
Some("https://files.openai.com/content?id=beta")
),
metadata(
"alpha",
"Alpha",
Some("https://files.openai.com/content?id=alpha")
),
],
missing_app_ids: vec!["missing".to_string(), "forbidden".to_string()],
}
);
assert_eq!(
state.requests(),
vec![json!({
"app_ids": ["beta", "missing", "alpha", "forbidden"],
"include_tools": true,
})]
);
let cached_response =
read_apps(&mut mcp, vec!["alpha", "beta"], /*include_tools*/ true).await?;
assert_eq!(
cached_response,
AppsReadResponse {
apps: vec![
metadata(
"alpha",
"Alpha",
Some("https://files.openai.com/content?id=alpha")
),
metadata(
"beta",
"Beta",
Some("https://files.openai.com/content?id=beta")
),
],
missing_app_ids: Vec::new(),
}
);
assert_eq!(state.requests().len(), 1);
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn app_read_refetches_metadata_only_cache_entries_when_tools_are_requested() -> Result<()> {
let state = BatchServerState::new(
json!({
"apps": [app_response("cached", "Cached", /*icon_url*/ None)]
}),
"chatgpt-token",
"codex",
);
let (server_url, server_handle) = start_batch_server(state.clone()).await?;
let codex_home = TempDir::new()?;
write_apps_config(
codex_home.path(),
&server_url,
/*apps_mcp_product_sku*/ None,
)?;
write_auth(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.without_managed_config()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
assert_eq!(
read_apps(&mut mcp, vec!["cached"], /*include_tools*/ false).await?,
AppsReadResponse {
apps: vec![metadata_without_tools(
"cached", "Cached", /*icon_url*/ None
)],
missing_app_ids: Vec::new(),
}
);
assert_eq!(
state.requests(),
vec![json!({
"app_ids": ["cached"],
"include_tools": false,
})]
);
assert_eq!(
read_apps(&mut mcp, vec!["cached"], /*include_tools*/ true).await?,
AppsReadResponse {
apps: vec![metadata("cached", "Cached", /*icon_url*/ None)],
missing_app_ids: Vec::new(),
}
);
assert_eq!(
state.requests(),
vec![
json!({
"app_ids": ["cached"],
"include_tools": false,
}),
json!({
"app_ids": ["cached"],
"include_tools": true,
}),
]
);
assert_eq!(
read_apps(&mut mcp, vec!["cached"], /*include_tools*/ false).await?,
AppsReadResponse {
apps: vec![metadata_without_tools(
"cached", "Cached", /*icon_url*/ None
)],
missing_app_ids: Vec::new(),
}
);
assert_eq!(
read_apps(&mut mcp, vec!["cached"], /*include_tools*/ true).await?,
AppsReadResponse {
apps: vec![metadata("cached", "Cached", /*icon_url*/ None)],
missing_app_ids: Vec::new(),
}
);
assert_eq!(state.requests().len(), 2);
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn app_read_backend_failure_preserves_fresh_cached_records() -> Result<()> {
let state = BatchServerState::new(
json!({
"apps": [app_response("cached", "Cached", /*icon_url*/ None)]
}),
"chatgpt-token",
"codex",
);
let (server_url, server_handle) = start_batch_server(state.clone()).await?;
let codex_home = TempDir::new()?;
write_apps_config(
codex_home.path(),
&server_url,
/*apps_mcp_product_sku*/ None,
)?;
write_auth(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.without_managed_config()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
assert_eq!(
read_apps(&mut mcp, vec!["cached"], /*include_tools*/ true).await?,
AppsReadResponse {
apps: vec![metadata("cached", "Cached", /*icon_url*/ None)],
missing_app_ids: Vec::new(),
}
);
state.set_status(StatusCode::INTERNAL_SERVER_ERROR);
let request_id = mcp
.send_apps_read_request(AppsReadParams {
app_ids: vec!["cached".to_string(), "uncached".to_string()],
include_tools: true,
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert!(
error.error.message.contains("failed to read app metadata"),
"unexpected error: {error:?}"
);
assert_eq!(
read_apps(&mut mcp, vec!["cached"], /*include_tools*/ true).await?,
AppsReadResponse {
apps: vec![metadata("cached", "Cached", /*icon_url*/ None)],
missing_app_ids: Vec::new(),
}
);
assert_eq!(state.requests().len(), 2);
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn app_read_rejects_more_than_one_hundred_input_ids() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_apps_read_request(AppsReadParams {
app_ids: (0..101).map(|index| format!("app-{index}")).collect(),
include_tools: false,
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.message, "app/read accepts at most 100 appIds");
Ok(())
}
async fn read_apps(
mcp: &mut TestAppServer,
app_ids: Vec<&str>,
include_tools: bool,
) -> Result<AppsReadResponse> {
let request_id = mcp
.send_apps_read_request(AppsReadParams {
app_ids: app_ids.into_iter().map(str::to_string).collect(),
include_tools,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
fn metadata(id: &str, name: &str, icon_url: Option<&str>) -> ConnectorMetadata {
ConnectorMetadata {
id: id.to_string(),
name: name.to_string(),
description: Some(format!("{name} description")),
icon_url: icon_url.map(str::to_string),
tool_summaries: Some(vec![AppToolSummary {
name: format!("{id}_tool"),
title: Some(format!("{name} Tool")),
description: format!("Use {name}"),
}]),
}
}
fn metadata_without_tools(id: &str, name: &str, icon_url: Option<&str>) -> ConnectorMetadata {
ConnectorMetadata {
tool_summaries: None,
..metadata(id, name, icon_url)
}
}
fn app_response(id: &str, name: &str, icon_url: Option<&str>) -> Value {
let mut response = json!({
"id": id,
"name": name,
"description": format!("{name} description"),
"icon_url": null,
"tools": [{
"name": format!("{id}_tool"),
"title": format!("{name} Tool"),
"description": format!("Use {name}"),
}],
"distribution_channel": "ECOSYSTEM_DIRECTORY",
"branding": {
"category": "PRODUCTIVITY",
"developer": "Test Developer",
"website": "https://example.com",
"privacy_policy": "https://example.com/privacy",
"terms_of_service": "https://example.com/terms",
"is_discoverable_app": true,
},
"app_metadata": {
"review": { "status": "RELEASED" },
"categories": ["PRODUCTIVITY"],
"sub_categories": ["CALENDAR"],
"seo_description": "Search description",
"screenshots": [{
"url": "https://example.com/screenshot.png",
"cdn_url": "must-not-escape",
"file_id": "file-1",
"user_prompt": "Use this app",
}],
"developer": "Test Developer",
"version": "1.0.0",
"version_id": "version-1",
"version_notes": "Initial release",
"first_party_type": "test",
"first_party_requires_install": true,
"show_in_composer_when_unlinked": true,
"subtitle": "must-not-escape",
"mcp_server_instructions": "must-not-escape",
},
"labels": null,
"actions": [{ "name": "must_not_escape_metadata_boundary" }],
"model_description": "must not escape metadata boundary",
"icon_assets": { "256_square": "must-not-escape" },
});
if let Some(icon_url) = icon_url {
response["icon_url"] = json!(icon_url);
}
response
}
fn write_apps_config(
codex_home: &Path,
base_url: &str,
apps_mcp_product_sku: Option<&str>,
) -> std::io::Result<()> {
let apps_mcp_product_sku = apps_mcp_product_sku
.map(|product_sku| format!("apps_mcp_product_sku = \"{product_sku}\"\n"))
.unwrap_or_default();
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
chatgpt_base_url = "{base_url}"
{apps_mcp_product_sku}
[features]
connectors = true
"#
),
)
}
fn write_auth(codex_home: &Path) -> Result<()> {
write_chatgpt_auth(
codex_home,
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.plan_type("plus"),
AuthCredentialsStoreMode::File,
)
}
#[derive(Clone)]
struct BatchServerState {
requests: Arc<StdMutex<Vec<Value>>>,
response: Arc<StdMutex<Value>>,
status: Arc<StdMutex<StatusCode>>,
access_token: String,
expected_product_sku: String,
}
impl BatchServerState {
fn new(response: Value, access_token: &str, expected_product_sku: &str) -> Self {
Self {
requests: Arc::new(StdMutex::new(Vec::new())),
response: Arc::new(StdMutex::new(response)),
status: Arc::new(StdMutex::new(StatusCode::OK)),
access_token: access_token.to_string(),
expected_product_sku: expected_product_sku.to_string(),
}
}
fn requests(&self) -> Vec<Value> {
self.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn set_status(&self, status: StatusCode) {
*self
.status
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = status;
}
}
async fn start_batch_server(state: BatchServerState) -> Result<(String, JoinHandle<()>)> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let router = Router::new()
.route("/ps/apps/batch", post(batch_apps))
.with_state(state);
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
Ok((format!("http://{addr}"), handle))
}
async fn batch_apps(
State(state): State<BatchServerState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, StatusCode> {
let bearer_ok = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == format!("Bearer {}", state.access_token));
let account_ok = headers
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "account-123");
// Rejecting a mismatch makes both the configured override and default fallback observable.
let product_sku_ok = headers
.get("oai-product-sku")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == state.expected_product_sku);
if !bearer_ok || !account_ok || !product_sku_ok {
return Err(StatusCode::UNAUTHORIZED);
}
let include_tools = body
.get("include_tools")
.and_then(Value::as_bool)
.unwrap_or_default();
state
.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(body);
let status = *state
.status
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if status != StatusCode::OK {
return Err(status);
}
let mut response = state
.response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if !include_tools && let Some(apps) = response.get_mut("apps").and_then(Value::as_array_mut) {
for app in apps {
app["tools"] = Value::Null;
}
}
Ok(Json(response))
}
@@ -1,6 +1,7 @@
mod account;
mod analytics;
mod app_list;
mod app_read;
mod attestation;
mod auto_env;
mod client_metadata;
+54
View File
@@ -1,8 +1,10 @@
use codex_core::config::Config;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::create_client;
use anyhow::Context;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::time::Duration;
@@ -70,3 +72,55 @@ pub(crate) async fn chatgpt_get_request_with_timeout<T: DeserializeOwned>(
anyhow::bail!("Request failed with status {status}: {body}")
}
}
/// Make a POST request to the ChatGPT backend API with an already-captured auth identity.
///
/// Callers that bind other state to the auth snapshot should pass that same snapshot here rather
/// than reacquiring auth while the request is in flight.
pub(crate) async fn chatgpt_post_request_with_timeout<
TResponse: DeserializeOwned,
TRequest: Serialize + ?Sized,
>(
config: &Config,
auth: &CodexAuth,
path: String,
body: &TRequest,
timeout: Duration,
product_sku: &str,
) -> anyhow::Result<TResponse> {
anyhow::ensure!(
auth.uses_codex_backend(),
"ChatGPT backend requests require Codex backend auth"
);
anyhow::ensure!(
auth.get_account_id().is_some(),
"ChatGPT account ID not available, please re-run codex login"
);
let url = format!(
"{}/{}",
config.chatgpt_base_url.trim_end_matches('/'),
path.trim_start_matches('/')
);
let response = create_client()
.post(&url)
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())
.header(OAI_PRODUCT_SKU_HEADER, product_sku)
.header("Content-Type", "application/json")
.timeout(timeout)
.json(body)
.send()
.await
.context("Failed to send request")?;
if response.status().is_success() {
response
.json()
.await
.context("Failed to parse JSON response")
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Request failed with status {status}: {body}")
}
}
+161
View File
@@ -3,10 +3,14 @@ use std::collections::HashSet;
use std::time::Duration;
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
use crate::chatgpt_client::chatgpt_post_request_with_timeout;
use codex_connectors::AppInfo;
use codex_connectors::ConnectorDirectoryCacheContext;
use codex_connectors::ConnectorDirectoryCacheKey;
use codex_connectors::ConnectorMetadata;
use codex_connectors::ConnectorMetadataStore;
use codex_connectors::ConnectorToolSummary;
use codex_connectors::DirectoryListResponse;
use codex_connectors::merge::merge_connectors;
use codex_connectors::merge::merge_plugin_connectors;
@@ -21,8 +25,12 @@ pub use codex_core::connectors::with_app_enabled_state;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_plugin::AppConnectorId;
use serde::Deserialize;
use serde::Serialize;
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
const CONNECTOR_METADATA_TIMEOUT: Duration = Duration::from_secs(60);
const DEFAULT_APPS_PRODUCT_SKU: &str = "codex";
async fn apps_enabled(config: &Config) -> bool {
let auth_manager =
@@ -116,6 +124,159 @@ pub async fn list_all_connectors_with_options(
))
}
pub struct ConnectorMetadataReadResult {
pub apps: Vec<ConnectorMetadata>,
pub missing_app_ids: Vec<String>,
}
/// Reads display metadata without loading MCP connector tools or runtime state.
///
/// The store is created before awaiting the backend request, so a response that arrives after an
/// account or backend change can only commit to the scope under which it was requested.
pub async fn read_connector_metadata(
config: &Config,
auth: &CodexAuth,
app_ids: &[String],
include_tools: bool,
) -> anyhow::Result<ConnectorMetadataReadResult> {
anyhow::ensure!(
auth.uses_codex_backend(),
"ChatGPT backend requests require Codex backend auth"
);
anyhow::ensure!(
auth.get_account_id().is_some(),
"ChatGPT account ID not available, please re-run codex login"
);
let store = ConnectorMetadataStore::new(
config.chatgpt_base_url.clone(),
auth.get_account_id(),
auth.get_chatgpt_user_id(),
auth.is_workspace_account(),
);
let mut metadata_by_id = store.fresh_records(app_ids, include_tools);
let missing_ids = app_ids
.iter()
.filter(|app_id| !metadata_by_id.contains_key(app_id.as_str()))
.cloned()
.collect::<Vec<_>>();
if !missing_ids.is_empty() {
let product_sku = config
.apps_mcp_product_sku
.as_deref()
.unwrap_or(DEFAULT_APPS_PRODUCT_SKU);
let response: GetAppsResponse = chatgpt_post_request_with_timeout(
config,
auth,
"/ps/apps/batch".to_string(),
&GetAppsRequest {
app_ids: &missing_ids,
include_tools,
},
CONNECTOR_METADATA_TIMEOUT,
product_sku,
)
.await?;
let mut requested_ids = missing_ids.iter().cloned().collect::<HashSet<_>>();
let fetched = response
.apps
.into_iter()
.map(batch_app_to_metadata)
.filter(|metadata| requested_ids.remove(&metadata.id))
.collect::<Vec<_>>();
store.commit(&fetched);
metadata_by_id.extend(
fetched
.into_iter()
.map(|metadata| (metadata.id.clone(), metadata)),
);
}
let mut apps = Vec::new();
let mut missing_app_ids = Vec::new();
for app_id in app_ids {
if let Some(mut metadata) = metadata_by_id.remove(app_id) {
if !include_tools {
metadata.tool_summaries = None;
}
apps.push(metadata);
} else {
missing_app_ids.push(app_id.clone());
}
}
Ok(ConnectorMetadataReadResult {
apps,
missing_app_ids,
})
}
#[derive(Serialize)]
struct GetAppsRequest<'a> {
app_ids: &'a [String],
include_tools: bool,
}
#[derive(Deserialize)]
struct GetAppsResponse {
apps: Vec<BatchApp>,
}
/// The explicit metadata-only projection of Plugin Service's public app response.
///
/// Serde ignores all other backend fields, including full actions, model descriptions, and
/// runtime state.
#[derive(Deserialize)]
struct BatchApp {
id: String,
name: String,
description: Option<String>,
icon_url: Option<String>,
#[serde(default)]
tools: Option<Vec<BatchAppToolSummary>>,
}
#[derive(Deserialize)]
struct BatchAppToolSummary {
name: String,
title: Option<String>,
description: String,
}
fn batch_app_to_metadata(app: BatchApp) -> ConnectorMetadata {
let BatchApp {
id,
name,
description,
icon_url,
tools,
} = app;
ConnectorMetadata {
id,
name,
description,
icon_url,
tool_summaries: tools.map(|tools| {
tools
.into_iter()
.map(|tool| {
let BatchAppToolSummary {
name,
title,
description,
} = tool;
ConnectorToolSummary {
name,
title,
description,
}
})
.collect()
}),
}
}
fn connector_directory_cache_context(
config: &Config,
auth: &CodexAuth,
+6
View File
@@ -16,6 +16,7 @@ mod directory_cache;
pub mod filter;
pub mod merge;
pub mod metadata;
mod metadata_store;
mod plugin_config;
mod snapshot;
@@ -39,12 +40,17 @@ pub use connector_runtime::ConnectorRuntimeSnapshot;
pub use connector_runtime::connector_runtime_cache_path;
pub use connector_runtime::connector_runtime_context_key;
pub use directory_cache::ConnectorDirectoryCacheContext;
pub use metadata_store::ConnectorMetadata;
pub use metadata_store::ConnectorMetadataStore;
pub use metadata_store::ConnectorToolSummary;
pub use plugin_config::parse_plugin_app_config;
pub use plugin_config::parse_plugin_app_config_value;
pub use snapshot::ConnectorSnapshot;
pub use snapshot::PluginConnectorSource;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
/// TTL for app/read metadata; it starts aligned with the connector directory cache.
pub const CONNECTOR_METADATA_CACHE_TTL: Duration = CONNECTORS_CACHE_TTL;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConnectorDirectoryCacheKey {
+136
View File
@@ -0,0 +1,136 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
use std::time::Instant;
use crate::CONNECTOR_METADATA_CACHE_TTL;
/// Display-only summary of one app tool returned by the app batch-read API.
#[derive(Debug, Clone, PartialEq)]
pub struct ConnectorToolSummary {
pub name: String,
pub title: Option<String>,
pub description: String,
}
/// Metadata returned by the app batch-read API.
///
/// This intentionally excludes connector runtime state, full actions, and model descriptions.
/// Tool summaries contain display text only, and the icon URL is already projected as a public
/// URL by the backend.
#[derive(Debug, Clone, PartialEq)]
pub struct ConnectorMetadata {
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon_url: Option<String>,
pub tool_summaries: Option<Vec<ConnectorToolSummary>>,
}
/// A view of the process-wide metadata cache bound to one backend and auth identity.
///
/// The active ChatGPT account id represents the selected personal account or workspace, while the
/// ChatGPT user id identifies the account principal. Keeping both plus workspace classification
/// matches the existing connector-directory cache partition.
pub struct ConnectorMetadataStore {
scope: ConnectorMetadataStoreScope,
}
impl ConnectorMetadataStore {
pub fn new(
backend_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
) -> Self {
Self {
scope: ConnectorMetadataStoreScope {
backend_base_url,
account_id,
chatgpt_user_id,
is_workspace_account,
},
}
}
/// Returns only unexpired records for the requested ids, requiring tool summaries when asked.
///
/// Expired entries are deliberately left in place so a failed refresh cannot mutate prior
/// cache state.
pub fn fresh_records(
&self,
ids: &[String],
include_tools: bool,
) -> HashMap<String, ConnectorMetadata> {
let cache = CONNECTOR_METADATA_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(records) = cache.get(&self.scope) else {
return HashMap::new();
};
let now = Instant::now();
ids.iter()
.filter_map(|id| {
records
.get(id)
.filter(|record| {
now < record.expires_at
&& (!include_tools || record.metadata.tool_summaries.is_some())
})
.map(|record| (id.clone(), record.metadata.clone()))
})
.collect()
}
/// Commits successfully fetched records without letting a late metadata-only response
/// replace fresh tool summaries.
pub fn commit(&self, records: &[ConnectorMetadata]) {
if records.is_empty() {
return;
}
let now = Instant::now();
let expires_at = now + CONNECTOR_METADATA_CACHE_TTL;
let mut cache = CONNECTOR_METADATA_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let scoped_records = cache.entry(self.scope.clone()).or_default();
for metadata in records {
if metadata.tool_summaries.is_none()
&& scoped_records.get(&metadata.id).is_some_and(|record| {
now < record.expires_at && record.metadata.tool_summaries.is_some()
})
{
continue;
}
scoped_records.insert(
metadata.id.clone(),
CachedConnectorMetadata {
metadata: metadata.clone(),
expires_at,
},
);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ConnectorMetadataStoreScope {
backend_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
}
struct CachedConnectorMetadata {
metadata: ConnectorMetadata,
expires_at: Instant,
}
static CONNECTOR_METADATA_CACHE: LazyLock<
StdMutex<HashMap<ConnectorMetadataStoreScope, HashMap<String, CachedConnectorMetadata>>>,
> = LazyLock::new(|| StdMutex::new(HashMap::new()));
#[cfg(test)]
#[path = "metadata_store_tests.rs"]
mod tests;
@@ -0,0 +1,144 @@
use pretty_assertions::assert_eq;
use super::ConnectorMetadata;
use super::ConnectorMetadataStore;
use super::ConnectorToolSummary;
fn metadata(id: &str) -> ConnectorMetadata {
ConnectorMetadata {
id: id.to_string(),
name: format!("{id} name"),
description: None,
icon_url: None,
tool_summaries: None,
}
}
#[test]
fn records_are_isolated_by_backend_account_user_and_workspace_scope() {
let requested_scope = ConnectorMetadataStore::new(
"https://backend-a.example".to_string(),
Some("account-a".to_string()),
Some("user-a".to_string()),
/*is_workspace_account*/ true,
);
let other_backend = ConnectorMetadataStore::new(
"https://backend-b.example".to_string(),
Some("account-a".to_string()),
Some("user-a".to_string()),
/*is_workspace_account*/ true,
);
let other_account = ConnectorMetadataStore::new(
"https://backend-a.example".to_string(),
Some("account-b".to_string()),
Some("user-a".to_string()),
/*is_workspace_account*/ true,
);
let other_user = ConnectorMetadataStore::new(
"https://backend-a.example".to_string(),
Some("account-a".to_string()),
Some("user-b".to_string()),
/*is_workspace_account*/ true,
);
let personal_account = ConnectorMetadataStore::new(
"https://backend-a.example".to_string(),
Some("account-a".to_string()),
Some("user-a".to_string()),
/*is_workspace_account*/ false,
);
let ids = vec!["scoped-app".to_string()];
requested_scope.commit(&[metadata("scoped-app")]);
assert_eq!(
requested_scope.fresh_records(&ids, /*include_tools*/ false),
std::collections::HashMap::from([("scoped-app".to_string(), metadata("scoped-app"))])
);
assert_eq!(
other_backend.fresh_records(&ids, /*include_tools*/ false),
Default::default()
);
assert_eq!(
other_account.fresh_records(&ids, /*include_tools*/ false),
Default::default()
);
assert_eq!(
other_user.fresh_records(&ids, /*include_tools*/ false),
Default::default()
);
assert_eq!(
personal_account.fresh_records(&ids, /*include_tools*/ false),
Default::default()
);
}
#[test]
fn tool_inclusive_reads_require_cached_tool_summaries() {
let store = ConnectorMetadataStore::new(
"https://backend-tools.example".to_string(),
Some("account-tools".to_string()),
Some("user-tools".to_string()),
/*is_workspace_account*/ false,
);
let metadata_only = metadata("metadata-only");
let mut empty_tools = metadata("empty-tools");
empty_tools.tool_summaries = Some(Vec::new());
let mut with_tools = metadata("with-tools");
with_tools.tool_summaries = Some(vec![ConnectorToolSummary {
name: "search".to_string(),
title: Some("Search".to_string()),
description: "Search the app".to_string(),
}]);
let ids = vec![
"metadata-only".to_string(),
"empty-tools".to_string(),
"with-tools".to_string(),
];
store.commit(&[
metadata_only.clone(),
empty_tools.clone(),
with_tools.clone(),
]);
assert_eq!(
store.fresh_records(&ids, /*include_tools*/ false),
std::collections::HashMap::from([
("metadata-only".to_string(), metadata_only),
("empty-tools".to_string(), empty_tools.clone()),
("with-tools".to_string(), with_tools.clone()),
])
);
assert_eq!(
store.fresh_records(&ids, /*include_tools*/ true),
std::collections::HashMap::from([
("empty-tools".to_string(), empty_tools),
("with-tools".to_string(), with_tools),
])
);
}
#[test]
fn metadata_only_commit_does_not_replace_fresh_tool_summaries() {
let store = ConnectorMetadataStore::new(
"https://backend-tools-race.example".to_string(),
Some("account-tools-race".to_string()),
Some("user-tools-race".to_string()),
/*is_workspace_account*/ false,
);
let mut with_tools = metadata("with-tools");
with_tools.tool_summaries = Some(vec![ConnectorToolSummary {
name: "search".to_string(),
title: Some("Search".to_string()),
description: "Search the app".to_string(),
}]);
let ids = vec!["with-tools".to_string()];
store.commit(&[with_tools.clone()]);
store.commit(&[metadata("with-tools")]);
assert_eq!(
store.fresh_records(&ids, /*include_tools*/ true),
std::collections::HashMap::from([("with-tools".to_string(), with_tools)])
);
}