We started working with MCP in Codex before
https://crates.io/crates/rmcp was mature, so we had our own crate for
MCP types that was generated from the MCP schema:
8b95d3e082/codex-rs/mcp-types/README.md
Now that `rmcp` is more mature, it makes more sense to use their MCP
types in Rust, as they handle details (like the `_meta` field) that our
custom version ignored. Though one advantage that our custom types had
is that our generated types implemented `JsonSchema` and `ts_rs::TS`,
whereas the types in `rmcp` do not. As such, part of the work of this PR
is leveraging the adapters between `rmcp` types and the serializable
types that are API for us (app server and MCP) introduced in #10356.
Note this PR results in a number of changes to
`codex-rs/app-server-protocol/schema`, which merit special attention
during review. We must ensure that these changes are still
backwards-compatible, which is possible because we have:
```diff
- export type CallToolResult = { content: Array<ContentBlock>, isError?: boolean, structuredContent?: JsonValue, };
+ export type CallToolResult = { content: Array<JsonValue>, structuredContent?: JsonValue, isError?: boolean, _meta?: JsonValue, };
```
so `ContentBlock` has been replaced with the more general `JsonValue`.
Note that `ContentBlock` was defined as:
```typescript
export type ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource;
```
so the deletion of those individual variants should not be a cause of
great concern.
Similarly, we have the following change in
`codex-rs/app-server-protocol/schema/typescript/Tool.ts`:
```
- export type Tool = { annotations?: ToolAnnotations, description?: string, inputSchema: ToolInputSchema, name: string, outputSchema?: ToolOutputSchema, title?: string, };
+ export type Tool = { name: string, title?: string, description?: string, inputSchema: JsonValue, outputSchema?: JsonValue, annotations?: JsonValue, icons?: Array<JsonValue>, _meta?: JsonValue, };
```
so:
- `annotations?: ToolAnnotations` ➡️ `JsonValue`
- `inputSchema: ToolInputSchema` ➡️ `JsonValue`
- `outputSchema?: ToolOutputSchema` ➡️ `JsonValue`
and two new fields: `icons?: Array<JsonValue>, _meta?: JsonValue`
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/10349).
* #10357
* __->__ #10349
* #10356
218 lines
5.8 KiB
Rust
218 lines
5.8 KiB
Rust
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use anyhow::anyhow;
|
|
use reqwest::ClientBuilder;
|
|
use reqwest::header::HeaderMap;
|
|
use reqwest::header::HeaderName;
|
|
use reqwest::header::HeaderValue;
|
|
use rmcp::service::ServiceError;
|
|
use tokio::time;
|
|
|
|
pub(crate) async fn run_with_timeout<F, T>(
|
|
fut: F,
|
|
timeout: Option<Duration>,
|
|
label: &str,
|
|
) -> Result<T>
|
|
where
|
|
F: std::future::Future<Output = Result<T, ServiceError>>,
|
|
{
|
|
if let Some(duration) = timeout {
|
|
let result = time::timeout(duration, fut)
|
|
.await
|
|
.with_context(|| anyhow!("timed out awaiting {label} after {duration:?}"))?;
|
|
result.map_err(|err| anyhow!("{label} failed: {err}"))
|
|
} else {
|
|
fut.await.map_err(|err| anyhow!("{label} failed: {err}"))
|
|
}
|
|
}
|
|
|
|
pub(crate) fn create_env_for_mcp_server(
|
|
extra_env: Option<HashMap<String, String>>,
|
|
env_vars: &[String],
|
|
) -> HashMap<String, String> {
|
|
DEFAULT_ENV_VARS
|
|
.iter()
|
|
.copied()
|
|
.chain(env_vars.iter().map(String::as_str))
|
|
.filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value)))
|
|
.chain(extra_env.unwrap_or_default())
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn build_default_headers(
|
|
http_headers: Option<HashMap<String, String>>,
|
|
env_http_headers: Option<HashMap<String, String>>,
|
|
) -> Result<HeaderMap> {
|
|
let mut headers = HeaderMap::new();
|
|
|
|
if let Some(static_headers) = http_headers {
|
|
for (name, value) in static_headers {
|
|
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
|
|
Ok(name) => name,
|
|
Err(err) => {
|
|
tracing::warn!("invalid HTTP header name `{name}`: {err}");
|
|
continue;
|
|
}
|
|
};
|
|
let header_value = match HeaderValue::from_str(value.as_str()) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
tracing::warn!("invalid HTTP header value for `{name}`: {err}");
|
|
continue;
|
|
}
|
|
};
|
|
headers.insert(header_name, header_value);
|
|
}
|
|
}
|
|
|
|
if let Some(env_headers) = env_http_headers {
|
|
for (name, env_var) in env_headers {
|
|
if let Ok(value) = env::var(&env_var) {
|
|
if value.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let header_name = match HeaderName::from_bytes(name.as_bytes()) {
|
|
Ok(name) => name,
|
|
Err(err) => {
|
|
tracing::warn!("invalid HTTP header name `{name}`: {err}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let header_value = match HeaderValue::from_str(value.as_str()) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
"invalid HTTP header value read from {env_var} for `{name}`: {err}"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
headers.insert(header_name, header_value);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(headers)
|
|
}
|
|
|
|
pub(crate) fn apply_default_headers(
|
|
builder: ClientBuilder,
|
|
default_headers: &HeaderMap,
|
|
) -> ClientBuilder {
|
|
if default_headers.is_empty() {
|
|
builder
|
|
} else {
|
|
builder.default_headers(default_headers.clone())
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[
|
|
"HOME",
|
|
"LOGNAME",
|
|
"PATH",
|
|
"SHELL",
|
|
"USER",
|
|
"__CF_USER_TEXT_ENCODING",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"TERM",
|
|
"TMPDIR",
|
|
"TZ",
|
|
];
|
|
|
|
#[cfg(windows)]
|
|
pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[
|
|
// Core path resolution
|
|
"PATH",
|
|
"PATHEXT",
|
|
// Shell and system roots
|
|
"COMSPEC",
|
|
"SYSTEMROOT",
|
|
"SYSTEMDRIVE",
|
|
// User context and profiles
|
|
"USERNAME",
|
|
"USERDOMAIN",
|
|
"USERPROFILE",
|
|
"HOMEDRIVE",
|
|
"HOMEPATH",
|
|
// Program locations
|
|
"PROGRAMFILES",
|
|
"PROGRAMFILES(X86)",
|
|
"PROGRAMW6432",
|
|
"PROGRAMDATA",
|
|
// App data and caches
|
|
"LOCALAPPDATA",
|
|
"APPDATA",
|
|
// Temp locations
|
|
"TEMP",
|
|
"TMP",
|
|
// Common shells/pwsh hints
|
|
"POWERSHELL",
|
|
"PWSH",
|
|
];
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use serial_test::serial;
|
|
use std::ffi::OsString;
|
|
|
|
struct EnvVarGuard {
|
|
key: String,
|
|
original: Option<OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set(key: &str, value: &str) -> Self {
|
|
let original = std::env::var_os(key);
|
|
unsafe {
|
|
std::env::set_var(key, value);
|
|
}
|
|
Self {
|
|
key: key.to_string(),
|
|
original,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(value) = &self.original {
|
|
unsafe {
|
|
std::env::set_var(&self.key, value);
|
|
}
|
|
} else {
|
|
unsafe {
|
|
std::env::remove_var(&self.key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_env_honors_overrides() {
|
|
let value = "custom".to_string();
|
|
let env =
|
|
create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())])), &[]);
|
|
assert_eq!(env.get("TZ"), Some(&value));
|
|
}
|
|
|
|
#[test]
|
|
#[serial(extra_rmcp_env)]
|
|
fn create_env_includes_additional_whitelisted_variables() {
|
|
let custom_var = "EXTRA_RMCP_ENV";
|
|
let value = "from-env";
|
|
let _guard = EnvVarGuard::set(custom_var, value);
|
|
let env = create_env_for_mcp_server(None, &[custom_var.to_string()]);
|
|
assert_eq!(env.get(custom_var), Some(&value.to_string()));
|
|
}
|
|
}
|