mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
a287c5dffd
## Why #27198 made the extension-owned `codex_apps` MCP connection the hosted plugin runtime, but its `mcp/skill` resources still bypassed the skills extension. App-server could list and read those resources through generic MCP APIs, but a thread with no selected environment did not expose them in the model's skills catalog or load their `SKILL.md` through `$skill`. Hosted skills should stay remote while using the same typed catalog, source authority, deduplication, bounded contextual catalog, and selected-skill prompt injection as host and executor skills. They should not be downloaded or exposed as ambient filesystem paths. ## What changed - Add a session-scoped `McpResourceClient` over the replaceable MCP connection manager so resource list/read calls follow startup and refresh replacements. - Add a `BackendSkillProvider` that pages `codex_apps` resources, accepts bounded and validated `mcp/skill` entries, and reads a selected skill's `SKILL.md` through the same MCP connection. - Register the remote provider in app-server and include it in the skills catalog even when a thread has no selected capability roots or executor. - Contribute hosted skill metadata through the bounded `AvailableSkillsInstructions` developer-context path, exclude remote entries from per-turn catalog injection, and classify `<skills>` messages as contextual developer content so rollback can trim and rebuild them correctly. ## Testing - Extend the app-server MCP resource integration test with `environments: []` to exercise two-page discovery, filter a non-`mcp/skill` resource, verify the escaped developer catalog entry and user-role `<skill>` fragment containing the fetched `SKILL.md`, and preserve generic MCP resource reads. - Add core event-mapping coverage that classifies `<skills>` developer messages as contextual history.
109 lines
3.6 KiB
Rust
109 lines
3.6 KiB
Rust
use std::sync::Arc;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use arc_swap::ArcSwap;
|
|
use codex_protocol::mcp::Resource;
|
|
use codex_protocol::mcp::ResourceContent;
|
|
use rmcp::model::PaginatedRequestParams;
|
|
use rmcp::model::ReadResourceRequestParams;
|
|
|
|
use crate::McpConnectionManager;
|
|
|
|
/// One page of resources returned by an MCP server.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct McpResourcePage {
|
|
/// Resources advertised on this page.
|
|
pub resources: Vec<Resource>,
|
|
/// Opaque cursor to supply when requesting the next page.
|
|
pub next_cursor: Option<String>,
|
|
}
|
|
|
|
/// Contents returned after reading one MCP resource.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct McpResourceReadResult {
|
|
/// Text or blob content returned for the requested resource.
|
|
pub contents: Vec<ResourceContent>,
|
|
}
|
|
|
|
/// Session-scoped access to MCP resources through the currently installed manager.
|
|
///
|
|
/// The client retains the manager's shared publication handle rather than a manager
|
|
/// snapshot, so calls automatically use replacements installed during startup and refresh.
|
|
#[derive(Clone)]
|
|
pub struct McpResourceClient {
|
|
manager: Arc<ArcSwap<McpConnectionManager>>,
|
|
}
|
|
|
|
impl std::fmt::Debug for McpResourceClient {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter
|
|
.debug_struct("McpResourceClient")
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl McpResourceClient {
|
|
/// Creates a resource client backed by the session's replaceable MCP manager.
|
|
pub fn new(manager: Arc<ArcSwap<McpConnectionManager>>) -> Self {
|
|
Self { manager }
|
|
}
|
|
|
|
/// Returns whether the current manager contains the named server.
|
|
///
|
|
/// This does not wait for server startup or imply that startup succeeded.
|
|
pub async fn has_server(&self, server: &str) -> bool {
|
|
self.manager.load_full().contains_server(server)
|
|
}
|
|
|
|
/// Lists one resource page from the named server.
|
|
pub async fn list_resources(
|
|
&self,
|
|
server: &str,
|
|
cursor: Option<String>,
|
|
) -> Result<McpResourcePage> {
|
|
let params =
|
|
cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor)));
|
|
let result = self
|
|
.manager
|
|
.load_full()
|
|
.list_resources(server, params)
|
|
.await?;
|
|
let resources = result
|
|
.resources
|
|
.into_iter()
|
|
.map(resource_from_rmcp)
|
|
.collect::<Result<Vec<_>>>()?;
|
|
Ok(McpResourcePage {
|
|
resources,
|
|
next_cursor: result.next_cursor,
|
|
})
|
|
}
|
|
|
|
/// Reads one resource from the named server.
|
|
pub async fn read_resource(&self, server: &str, uri: &str) -> Result<McpResourceReadResult> {
|
|
let result = self
|
|
.manager
|
|
.load_full()
|
|
.read_resource(server, ReadResourceRequestParams::new(uri.to_string()))
|
|
.await?;
|
|
let contents = result
|
|
.contents
|
|
.into_iter()
|
|
.map(resource_content_from_rmcp)
|
|
.collect::<Result<Vec<_>>>()?;
|
|
Ok(McpResourceReadResult { contents })
|
|
}
|
|
}
|
|
|
|
fn resource_from_rmcp(resource: rmcp::model::Resource) -> Result<Resource> {
|
|
let value = serde_json::to_value(resource).context("failed to serialize MCP resource")?;
|
|
Resource::from_mcp_value(value).context("failed to convert MCP resource")
|
|
}
|
|
|
|
fn resource_content_from_rmcp(content: rmcp::model::ResourceContents) -> Result<ResourceContent> {
|
|
let value =
|
|
serde_json::to_value(content).context("failed to serialize MCP resource content")?;
|
|
serde_json::from_value(value).context("failed to convert MCP resource content")
|
|
}
|