Declare the experimental plugin search API (#36402)

## What changed

- Add the experimental `plugin/search` request with search term, scope, working-directory, cursor, and limit parameters.
- Define paginated results that include each plugin's marketplace name and optional local marketplace path.
- Route the request through app server while returning method-not-found until the search implementation is available.

## Testing

- Add an app-server test that verifies `plugin/search` returns JSON-RPC error `-32601` with the expected not-implemented message.

GitOrigin-RevId: 755400468331f0887f7da7d933195c347aadf6d0
This commit is contained in:
Greg Brisebois
2026-07-31 23:08:55 +00:00
committed by copyberry
parent 003ec63bba
commit 64b2a3008e
18 changed files with 213 additions and 0 deletions
@@ -2287,6 +2287,14 @@
],
"type": "object"
},
"PluginSearchScope": {
"enum": [
"global",
"workspace",
"personal"
],
"type": "string"
},
"PluginShareCheckoutParams": {
"properties": {
"remotePluginId": {
@@ -14778,6 +14778,39 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginSearchResult": {
"properties": {
"marketplaceName": {
"type": "string"
},
"marketplacePath": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"plugin": {
"$ref": "#/definitions/v2/PluginSummary"
}
},
"required": [
"marketplaceName",
"plugin"
],
"type": "object"
},
"PluginSearchScope": {
"enum": [
"global",
"workspace",
"personal"
],
"type": "string"
},
"PluginShareCheckoutParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -11120,6 +11120,39 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginSearchResult": {
"properties": {
"marketplaceName": {
"type": "string"
},
"marketplacePath": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"plugin": {
"$ref": "#/definitions/PluginSummary"
}
},
"required": [
"marketplaceName",
"plugin"
],
"type": "object"
},
"PluginSearchScope": {
"enum": [
"global",
"workspace",
"personal"
],
"type": "string"
},
"PluginShareCheckoutParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { PluginSummary } from "./PluginSummary";
export type PluginSearchResult = { plugin: PluginSummary, marketplaceName: string, marketplacePath: AbsolutePathBuf | null, };
@@ -0,0 +1,5 @@
// 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.
export type PluginSearchScope = "global" | "workspace" | "personal";
@@ -325,6 +325,8 @@ export type { PluginListResponse } from "./PluginListResponse";
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginSearchResult } from "./PluginSearchResult";
export type { PluginSearchScope } from "./PluginSearchScope";
export type { PluginShareCheckoutParams } from "./PluginShareCheckoutParams";
export type { PluginShareCheckoutResponse } from "./PluginShareCheckoutResponse";
export type { PluginShareContext } from "./PluginShareContext";
@@ -729,6 +729,12 @@ client_request_definitions! {
serialization: None,
response: v2::PluginListResponse,
},
#[experimental("plugin/search")]
PluginSearch => "plugin/search" {
params: v2::PluginSearchParams,
serialization: None,
response: v2::PluginSearchResponse,
},
PluginInstalled => "plugin/installed" {
params: v2::PluginInstalledParams,
serialization: None,
@@ -18,6 +18,7 @@ mod model;
mod notification;
mod permissions;
mod plugin;
mod plugin_search;
mod process;
mod realtime;
mod remote_control;
@@ -45,6 +46,7 @@ pub use model::*;
pub use notification::*;
pub use permissions::*;
pub use plugin::*;
pub use plugin_search::*;
pub use process::*;
pub use realtime::*;
pub use remote_control::*;
@@ -0,0 +1,47 @@
use super::PluginSummary;
use crate::JsonSchema;
use crate::TS;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSearchParams {
pub search_term: String,
#[ts(optional = nullable)]
pub scope: Option<PluginSearchScope>,
#[ts(optional = nullable)]
pub cwds: Option<Vec<AbsolutePathBuf>>,
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum PluginSearchScope {
Global,
Workspace,
Personal,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSearchResult {
pub plugin: PluginSummary,
pub marketplace_name: String,
pub marketplace_path: Option<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSearchResponse {
pub data: Vec<PluginSearchResult>,
pub next_cursor: Option<String>,
}
+1
View File
@@ -224,6 +224,7 @@ Example with notification opt-out:
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Every `PluginSummary` returned by plugin list, installed, read, and share-list methods includes nullable `disabledReason` and `eligiblePlanTypes`, preserving plugin-service availability metadata and raw plan identifiers for remote plugins while returning `null` for local plugins or older remote responses. The same summaries include `mustShowInstallationInterstitial`: remote service values preserve `true` or `false`, while local plugins and remote responses that omit the policy return `null`. Clients should fail closed when the value is `null`. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. Set `forceRefetch: true` to bypass TTL-backed remote catalog caches for the requested marketplaces and wait for fresh data; cache entries are replaced only after a successful fetch. When local marketplaces are included, the request also waits for configured plugin caches to reconcile before marketplace summaries are returned. At app-server startup, existing cached catalogs remain available to `plugin/list` while they refresh in the background. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/search` — declares the paginated plugin-search request and response shapes. The RPC currently returns JSON-RPC method-not-found (`-32601`) with `plugin/search is not implemented` (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Remote rows include nullable `installPolicySource` and `installedAt`, the backend installation timestamp in Unix seconds. `installedAt` is also returned by `plugin/list`, `plugin/read`, and `plugin/share/list`; it is `null` for local plugins, uninstalled plugins, plugins installed by default, and older backend responses that do not include an installation timestamp. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details can include scheduled task summaries from the catalog; `scheduledTasks: null` means the metadata is unavailable, while an empty array means the catalog found no scheduled tasks. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. For owned workspace plugins, `summary.shareContext.canPublishToWorkspace` reports whether the current user may add the plugin to the workspace directory; `plugin/share/save` returns the same capability after creating or updating a share, and clients should fail closed when either value is `null`. Remote skill interfaces expose `iconSmallUrl` and `iconLargeUrl` when the catalog supplies icon URLs. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
@@ -1225,6 +1225,9 @@ impl MessageProcessor {
ClientRequest::PluginList { params, .. } => {
self.plugin_processor.plugin_list(params).await
}
ClientRequest::PluginSearch { params, .. } => {
self.plugin_processor.plugin_search(params).await
}
ClientRequest::PluginInstalled { params, .. } => {
self.plugin_processor.plugin_installed(params).await
}
@@ -34,6 +34,8 @@ use codex_protocol::auth::AuthMode as DomainAuthMode;
use codex_rmcp_client::OAuthDiscoveryTimeout;
use codex_rmcp_client::perform_oauth_login_silent;
mod search;
#[derive(Clone)]
pub(crate) struct PluginRequestProcessor {
auth_manager: Arc<AuthManager>,
@@ -0,0 +1,12 @@
use super::*;
use crate::error_code::method_not_found;
use codex_app_server_protocol::PluginSearchParams;
impl PluginRequestProcessor {
pub(crate) async fn plugin_search(
&self,
_params: PluginSearchParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
Err(method_not_found("plugin/search is not implemented"))
}
}
@@ -68,6 +68,7 @@ use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstalledParams;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginSearchParams;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::ProcessKillParams;
@@ -908,6 +909,15 @@ impl TestAppServer {
self.send_request("plugin/list", params).await
}
/// Send a `plugin/search` JSON-RPC request.
pub async fn send_plugin_search_request(
&mut self,
params: PluginSearchParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("plugin/search", params).await
}
/// Send a `plugin/installed` JSON-RPC request.
pub async fn send_plugin_installed_request(
&mut self,
@@ -53,6 +53,7 @@ mod plan_item;
mod plugin_install;
mod plugin_list;
mod plugin_read;
mod plugin_search;
mod plugin_share;
mod plugin_uninstall;
mod process_exec;
@@ -0,0 +1,41 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::TestAppServer;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::PluginSearchParams;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn plugin_search_returns_not_implemented() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_plugin_search_request(PluginSearchParams {
search_term: "linear".to_string(),
scope: None,
cwds: None,
cursor: None,
limit: None,
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32601);
assert_eq!(error.error.message, "plugin/search is not implemented");
Ok(())
}