plugin_mcp/lib.rs
1//! An MCP client Pack: one Pack over every MCP server its configuration
2//! names, each reached over Streamable HTTP, whose tools a prompt lists
3//! and calls through two static tools.
4//!
5//! The crate's Plugin, [`install`], inserts the Pack `runvil/mcp`, whose
6//! Pack id is `mcp`, with the sub-object of its config under `mcp`. A Host
7//! passes the `mcpServers` object of an `mcp.json` file there: each
8//! server's entry under the server's name. A `null` configuration means
9//! no servers, and any other value that is not an object leaves the Pack
10//! unavailable. The Pack has no prelude, so declaring it, as in
11//! `uses: [mcp]`, only makes it required.
12//!
13//! The Pack offers exactly two tools under the Pack id `construct`
14//! receives, such as `mcp/get_dynamic_tools` under `mcp`, modeled on
15//! Cursor's `GetDynamicTools` and `CallDynamicTool`:
16//!
17//! - `get_dynamic_tools` takes the optional strings `namespace` and
18//! `toolName` and answers JSON text, which a script reads with
19//! `json.decode`. With no arguments it lists each server's namespace,
20//! status, and tool names, in name order; with `namespace`, that
21//! server's tools with their input schemas; with both, one tool.
22//! - `call_dynamic_tool` takes the strings `namespace` and `toolName` and
23//! an optional object `arguments`, which defaults to `{}`, and calls that
24//! server's tool.
25//!
26//! A namespace is the server's name exactly as the configuration writes
27//! it, and a tool's name is the one its server lists. Both must match
28//! exactly. A server's status is `ready`, `loading` while it is still
29//! starting, or `error` with the reason. A tool's description is its
30//! `description`, else its `title`, else its name. An unknown namespace
31//! or tool, a `toolName` without a `namespace`, and a call to a server
32//! that is not ready each give an error a model can read.
33//!
34//! Each entry is parsed on its own. A remote entry has `url` and optional
35//! `headers`, and a `type` of `"sse"`, the legacy transport, is refused. A
36//! local entry has `command`, with optional `args`, `env`, and `cwd`. This
37//! Pack recognizes that shape and refuses it with `local MCP servers
38//! (command) are not supported yet`. Other `type` values and unknown
39//! fields are ignored, because the format is defined outside this
40//! repository. A refused entry is a namespace with status `error` and the
41//! reason, and the other servers still connect, so one bad entry never
42//! leaves the Pack unavailable.
43//!
44//! String values in an entry may use `${env:NAME}`, which an unset
45//! variable refuses naming `NAME`, and `${userHome}`. Any other `${...}`
46//! form passes through unchanged. `headers` may not set `accept`,
47//! `Mcp-Session-Id`, or `Last-Event-Id`, which the client owns.
48//!
49//! Building the Pack does no I/O. It reads the Host's runtime from the
50//! Plugin contract's [`runvil_plugin::TOKIO_RUNTIME`] service, even with
51//! no servers, and starts one background task per parsed entry on it, which
52//! connects, runs the handshake, and lists every page of tools within two
53//! minutes. The Pack's `ready` resolves at once, so a run can see the two
54//! tools while servers are still connecting, and the listing shows each
55//! such server as `loading`. A server that fails to start shows as
56//! `error` with the reason, and its task keeps trying in the background,
57//! waiting 1 second after the first failure and doubling up to a minute,
58//! so the server lists its tools once it answers. A server that is ready
59//! stays ready, and a later failure shows as a transport error on the
60//! call.
61//!
62//! A call sends `tools/call` with the tool's name and waits up to five
63//! minutes. Text blocks and embedded text resources come back joined
64//! with blank lines. An image, audio, or binary resource becomes a
65//! placeholder such as `[image omitted: image/png]`, a resource link
66//! becomes `[resource: <uri>]`, and a result with no blocks but with
67//! `structuredContent` gives that JSON. A result marked `isError` is a
68//! backend error with the server's text, and a transport failure or the
69//! deadline is a transport error. Dropping a call sends the server
70//! `notifications/cancelled` for its request.
71//!
72//! The client declares no `capabilities`, because an optional extension
73//! would make GitHub's remote server answer with interactive forms this
74//! Pack cannot show. Its HTTP client refuses redirects, which keeps the
75//! configured headers off any other server.
76//!
77//! ## Invariants
78//!
79//! - May depend on: `runvil-plugin`, `shared-*` crates, and outside
80//! libraries. `cargo test -p build-xtask` enforces the Plugin family's
81//! allow-list. Only [`install`] is public.
82//! - Every output is untrusted, because descriptions, schemas, and results
83//! all come from the servers.
84//! - No reason, log line, `Debug` output, or error names an `env` or
85//! `headers` value. They may name keys.
86//! - It runs on the Host's runtime, read through the contract's
87//! `TOKIO_RUNTIME` service, and `construct` does no I/O.
88//! - It starts no process: each local entry is refused at `construct`, on
89//! its own.
90//! - Dropping a call tells the server to drop the request.
91
92mod connect;
93mod dynamic;
94mod entry;
95mod result;
96mod server;
97
98use std::collections::BTreeMap;
99use std::sync::Arc;
100
101use runvil_plugin::{
102 HostServices, InstallError, Pack, PackId, PackInfo, PackRegistry, ServiceKey, TOKIO_RUNTIME,
103 ToolError,
104};
105use serde_json::{Map, Value};
106use tokio::runtime::Handle;
107use tokio::sync::watch;
108
109use crate::dynamic::{Mcp, Namespace};
110use crate::entry::Vars;
111use crate::server::{Server, State};
112
113/// The Pack's `PackInfo`.
114///
115/// Its FQN is `runvil/mcp`, so its Pack id is `mcp`. Its config is the
116/// `mcpServers` object of an `mcp.json` file, and its `construct` reads
117/// the Plugin contract's [`runvil_plugin::TOKIO_RUNTIME`] service. It has
118/// no prelude and needs no per-run service.
119const MCP: PackInfo =
120 PackInfo::new("runvil/mcp", construct).description("Tools from every configured MCP server");
121
122/// The Plugin: inserts the `mcp` Pack into `packs` with `config["mcp"]`, which
123/// is `null`, meaning no servers, when `config` has no `mcp` key.
124///
125/// # Errors
126/// Returns the registry's [`InstallError`], such as
127/// [`InstallError::DuplicatePack`] when `packs` holds `runvil/mcp`
128/// already.
129#[expect(
130 clippy::needless_pass_by_value,
131 reason = "the Plugin signature fixes the argument types"
132)]
133pub fn install(packs: &mut PackRegistry, config: Value) -> Result<(), InstallError> {
134 packs.insert(MCP, config["mcp"].clone())
135}
136
137/// The key `construct` reads the Host's runtime through.
138const RUNTIME: ServiceKey<Handle> = ServiceKey::new(TOKIO_RUNTIME);
139
140/// Reads the runtime, parses each server's entry on its own, and starts
141/// one connection task per parsed entry. It does no I/O itself.
142fn construct(
143 name: &PackId,
144 config: Value,
145 services: &HostServices,
146) -> Result<Arc<dyn Pack>, ToolError> {
147 let entries = match config {
148 Value::Null => Map::new(),
149 Value::Object(entries) => entries,
150 _ => {
151 return Err(ToolError::message(
152 "the MCP configuration must be a JSON object of servers by name, as the \
153 `mcpServers` object of an mcp.json file is",
154 ));
155 }
156 };
157 let runtime = services.get(&RUNTIME).ok_or_else(|| {
158 ToolError::message("MCP needs runvil/tokio-runtime, and this host provides none")
159 })?;
160 let env = |key: &str| std::env::var(key).ok();
161 let home = std::env::home_dir().map(|dir| dir.to_string_lossy().into_owned());
162 let vars = Vars { env: &env, home };
163 let mut namespaces = BTreeMap::new();
164 for (server, config) in entries {
165 let namespace = match entry::parse(&config, &vars) {
166 Ok(entry) => {
167 let (state, watching) = watch::channel(State::Starting);
168 let connection = runtime.spawn(connect::run(
169 server.clone(),
170 entry,
171 connect::STARTUP_DEADLINE,
172 state,
173 ));
174 Namespace::Server(Server::new(watching, connection))
175 }
176 Err(refused) => {
177 tracing::warn!(%server, reason = %refused, "the MCP server's entry was refused");
178 Namespace::Refused(refused.to_string())
179 }
180 };
181 namespaces.insert(server, namespace);
182 }
183 Ok(Arc::new(Mcp::new(
184 name,
185 namespaces,
186 Handle::clone(&runtime),
187 )?))
188}