Skip to main content

plugin_mcp/
lib.rs

1//! An MCP client Plugin: it connects a prompt to one remote MCP server
2//! over Streamable HTTP and offers that server's tools as ordinary tools.
3//!
4//! A Host installs [`PACKAGE`] once per server, under the server's name
5//! lowercased in ASCII, so the tools of a server named `wg21-papers` are
6//! `wg21-papers/<tool>`. The Plugin has no prelude, so declaring it, as in
7//! `plugins: [wg21-papers]`, only makes it required.
8//!
9//! The Host passes the server's entry from an `mcp.json` file as the
10//! configuration. A remote entry has `url` and optional `headers`, and a
11//! `type` of `"sse"`, the legacy transport, is refused. A local entry has
12//! `command`, with optional `args`, `env`, and `cwd`. This Plugin
13//! recognizes that shape and refuses it at install with `local MCP servers
14//! (command) are not supported yet`, so the Host installs it as
15//! unavailable. Other `type` values and unknown fields are ignored, because
16//! the format is defined outside this repository.
17//!
18//! String values in an entry may use `${env:NAME}`, which an unset
19//! variable refuses naming `NAME`, and `${userHome}`. Any other `${...}`
20//! form passes through unchanged. `headers` may not set `accept`,
21//! `Mcp-Session-Id`, or `Last-Event-Id`, which the client owns.
22//!
23//! Install does no I/O. It reads the Host's runtime from the Plugin
24//! contract's [`runvil_plugin::TOKIO_RUNTIME`] service and starts one
25//! background task on it, which connects, runs the handshake, and lists
26//! every page of tools within two minutes. The Plugin's `ready` waits for
27//! that task, so a run sees the tools of a server that is still starting.
28//! A server that fails to start is unavailable to each run that starts
29//! while it is down, with the reason, and the Plugin keeps trying in the
30//! background, waiting 1 second after the first failure and doubling up to
31//! a minute, so a later run offers its tools once it answers. A server
32//! that is ready stays ready, and a later failure shows as a transport
33//! error on the call.
34//!
35//! A tool whose name, lowercased in ASCII, is not a valid tool id, or that
36//! repeats an earlier tool's id after lowercasing, is dropped with a
37//! warning. A tool's description is its `description`, else its `title`,
38//! else its name, and its parameters are its `inputSchema`.
39//!
40//! A call sends `tools/call` with the tool's original name and waits up to
41//! five minutes. Text blocks and embedded text resources come back joined
42//! with blank lines. An image, audio, or binary resource becomes a
43//! placeholder such as `[image omitted: image/png]`, a resource link
44//! becomes `[resource: <uri>]`, and a result with no blocks but with
45//! `structuredContent` gives that JSON. A result marked `isError` is a
46//! backend error with the server's text, and a transport failure or the
47//! deadline is a transport error. Dropping a call sends the server
48//! `notifications/cancelled` for its request.
49//!
50//! The client declares no `capabilities`, because an optional extension
51//! would make GitHub's remote server answer with interactive forms this
52//! Plugin cannot show. Its HTTP client refuses redirects, which keeps the
53//! configured headers off any other server.
54//!
55//! ## Invariants
56//!
57//! - May depend on: `runvil-plugin`, `shared-*` crates, and outside
58//!   libraries. `cargo test -p build-xtask` enforces the Plugin family's
59//!   allow-list. Only [`PACKAGE`] is public.
60//! - Every output is untrusted.
61//! - No reason, log line, `Debug` output, or error names an `env` or
62//!   `headers` value. They may name keys.
63//! - It runs on the Host's runtime, read through the contract's
64//!   `TOKIO_RUNTIME` service, and `construct` does no I/O.
65//! - It starts no process: a local entry is refused at `construct`.
66//! - Dropping a call tells the server to drop the request.
67
68mod connect;
69mod entry;
70mod result;
71mod server;
72
73use std::sync::Arc;
74
75use runvil_plugin::{
76    HostServices, Package, Plugin, PluginId, ServiceKey, TOKIO_RUNTIME, ToolError,
77};
78use serde_json::Value;
79use tokio::runtime::Handle;
80use tokio::sync::watch;
81
82use crate::entry::Vars;
83use crate::server::{Server, State};
84
85/// The Plugin's label, which a Host adds to its package registry.
86///
87/// Its name is `runvil/mcp`. The Host installs it once per server,
88/// passing the server's `mcp.json` entry as the configuration, and
89/// provides the Plugin contract's [`runvil_plugin::TOKIO_RUNTIME`]
90/// service. It has no prelude and needs no per-run service.
91pub const PACKAGE: Package =
92    Package::new("runvil/mcp", construct).description("Tools from one MCP server");
93
94/// The key `construct` reads the Host's runtime through.
95const RUNTIME: ServiceKey<Handle> = ServiceKey::new(TOKIO_RUNTIME);
96
97/// Validates the entry, reads the runtime, and starts the connection task.
98/// It does no I/O itself.
99#[expect(
100    clippy::needless_pass_by_value,
101    reason = "the Package construct signature fixes the argument types"
102)]
103fn construct(
104    name: &PluginId,
105    config: Value,
106    services: &HostServices,
107) -> Result<Arc<dyn Plugin>, ToolError> {
108    let env = |key: &str| std::env::var(key).ok();
109    let home = std::env::home_dir().map(|dir| dir.to_string_lossy().into_owned());
110    let entry = entry::parse(&config, &Vars { env: &env, home })?;
111    let runtime = services.get(&RUNTIME).ok_or_else(|| {
112        ToolError::message("MCP needs runvil/tokio-runtime, and this host provides none")
113    })?;
114    let (state, watching) = watch::channel(State::Starting);
115    let connection = runtime.spawn(connect::run(
116        name.clone(),
117        entry,
118        connect::STARTUP_DEADLINE,
119        state,
120    ));
121    Ok(Arc::new(Server::new(
122        watching,
123        connection,
124        Handle::clone(&runtime),
125    )))
126}