test: add integration test for MCP server (#1633)
This PR introduces a single integration test for `cargo mcp`, though it
also introduces a number of reusable components so that it should be
easier to introduce more integration tests going forward.
The new test is introduced in `codex-rs/mcp-server/tests/elicitation.rs`
and the reusable pieces are in `codex-rs/mcp-server/tests/common`.
The test itself verifies new functionality around elicitations
introduced in https://github.com/openai/codex/pull/1623 (and the fix
introduced in https://github.com/openai/codex/pull/1629) by doing the
following:
- starts a mock model provider with canned responses for
`/v1/chat/completions`
- starts the MCP server with a `config.toml` to use that model provider
(and `approval_policy = "untrusted"`)
- sends the `codex` tool call which causes the mock model provider to
request a shell call for `git init`
- the MCP server sends an elicitation to the client to approve the
request
- the client replies to the elicitation with `"approved"`
- the MCP server runs the command and re-samples the model, getting a
`"finish_reason": "stop"`
- in turn, the MCP server sends the final response to the original
`codex` tool call
- verifies that `git init` ran as expected
To test:
```
cargo test shell_command_approval_triggers_elicitation
```
In writing this test, I discovered that `ExecApprovalResponse` does not
conform to `ElicitResult`, so I added a TODO to fix that, since I think
that should be updated in a separate PR. As it stands, this PR does not
update any business logic, though it does make a number of members of
the `mcp-server` crate `pub` so they can be used in the test.
One additional learning from this PR is that
`std::process::Command::cargo_bin()` from the `assert_cmd` trait is only
available for `std::process::Command`, but we really want to use
`tokio::process::Command` so that everything is async and we can
leverage utilities like `tokio::time::timeout()`. The trick I came up
with was to use `cargo_bin()` to locate the program, and then to use
`std::process::Command::get_program()` when constructing the
`tokio::process::Command`.
2025-07-21 10:27:07 -07:00
|
|
|
use serde_json::json;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
pub fn create_shell_sse_response(
|
|
|
|
|
command: Vec<String>,
|
|
|
|
|
workdir: Option<&Path>,
|
|
|
|
|
timeout_ms: Option<u64>,
|
|
|
|
|
call_id: &str,
|
|
|
|
|
) -> anyhow::Result<String> {
|
|
|
|
|
// The `arguments`` for the `shell` tool is a serialized JSON object.
|
|
|
|
|
let tool_call_arguments = serde_json::to_string(&json!({
|
|
|
|
|
"command": command,
|
|
|
|
|
"workdir": workdir.map(|w| w.to_string_lossy()),
|
|
|
|
|
"timeout": timeout_ms
|
|
|
|
|
}))?;
|
|
|
|
|
let tool_call = json!({
|
|
|
|
|
"choices": [
|
|
|
|
|
{
|
|
|
|
|
"delta": {
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{
|
|
|
|
|
"id": call_id,
|
|
|
|
|
"function": {
|
|
|
|
|
"name": "shell",
|
|
|
|
|
"arguments": tool_call_arguments
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"finish_reason": "tool_calls"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let sse = format!(
|
|
|
|
|
"data: {}\n\ndata: DONE\n\n",
|
|
|
|
|
serde_json::to_string(&tool_call)?
|
|
|
|
|
);
|
|
|
|
|
Ok(sse)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result<String> {
|
|
|
|
|
let assistant_message = json!({
|
|
|
|
|
"choices": [
|
|
|
|
|
{
|
|
|
|
|
"delta": {
|
|
|
|
|
"content": message
|
|
|
|
|
},
|
|
|
|
|
"finish_reason": "stop"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let sse = format!(
|
|
|
|
|
"data: {}\n\ndata: DONE\n\n",
|
|
|
|
|
serde_json::to_string(&assistant_message)?
|
|
|
|
|
);
|
|
|
|
|
Ok(sse)
|
|
|
|
|
}
|
2025-07-21 23:58:41 -07:00
|
|
|
|
|
|
|
|
pub fn create_apply_patch_sse_response(
|
|
|
|
|
patch_content: &str,
|
|
|
|
|
call_id: &str,
|
|
|
|
|
) -> anyhow::Result<String> {
|
|
|
|
|
// Use shell command to call apply_patch with heredoc format
|
|
|
|
|
let shell_command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF");
|
|
|
|
|
let tool_call_arguments = serde_json::to_string(&json!({
|
|
|
|
|
"command": ["bash", "-lc", shell_command]
|
|
|
|
|
}))?;
|
|
|
|
|
|
|
|
|
|
let tool_call = json!({
|
|
|
|
|
"choices": [
|
|
|
|
|
{
|
|
|
|
|
"delta": {
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{
|
|
|
|
|
"id": call_id,
|
|
|
|
|
"function": {
|
|
|
|
|
"name": "shell",
|
|
|
|
|
"arguments": tool_call_arguments
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
"finish_reason": "tool_calls"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let sse = format!(
|
|
|
|
|
"data: {}\n\ndata: DONE\n\n",
|
|
|
|
|
serde_json::to_string(&tool_call)?
|
|
|
|
|
);
|
|
|
|
|
Ok(sse)
|
|
|
|
|
}
|