1use rmcp::{
15 handler::server::{router::tool::ToolRouter, wrapper::Parameters},
16 model::{Implementation, ServerCapabilities, ServerConfig},
17 tool, tool_handler, tool_router,
18 transport::stdio,
19 ErrorData, ServerHandler, ServiceExt,
20};
21use rtk_mcp_cc::{
22 check_args, discover_args, gain_args, proxy_args, Config, DiscoverOptions, GainOptions,
23};
24use schemars::JsonSchema;
25use serde::Deserialize;
26use std::path::PathBuf;
27use std::process::Command;
28use tracing::info;
29
30#[derive(Debug, Deserialize, JsonSchema)]
38pub struct GainRequest {
39 pub history: Option<bool>,
41 pub project_only: Option<bool>,
43 pub format: Option<String>,
45}
46
47#[derive(Debug, Deserialize, JsonSchema)]
49pub struct DiscoverRequest {
50 pub project: Option<String>,
52 pub limit: Option<u32>,
54 pub since: Option<u32>,
56 pub all_projects: Option<bool>,
58 pub format: Option<String>,
60}
61
62#[derive(Debug, Deserialize, JsonSchema)]
64pub struct CheckRequest {
65 pub command: String,
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
72pub struct ProxyRequest {
73 pub argv: Vec<String>,
78}
79
80#[derive(Debug, Clone)]
84pub struct RtkServer {
85 #[allow(dead_code)]
88 tool_router: ToolRouter<Self>,
89 config: Config,
90}
91
92impl RtkServer {
93 pub fn new(project_dir: &std::path::Path) -> Self {
95 Self {
96 tool_router: Self::tool_router(),
97 config: Config::load(project_dir),
98 }
99 }
100
101 fn run(&self, args: &[String]) -> Result<String, ErrorData> {
107 let output = Command::new(&self.config.rtk_bin)
108 .args(args)
109 .output()
110 .map_err(|e| {
111 ErrorData::internal_error(
112 format!(
113 "could not run {} ({e}). Install rtk, or set RTK_BIN to its path.",
114 self.config.rtk_bin
115 ),
116 None,
117 )
118 })?;
119
120 let stdout = String::from_utf8_lossy(&output.stdout)
121 .trim_end()
122 .to_string();
123 let stderr = String::from_utf8_lossy(&output.stderr)
124 .trim_end()
125 .to_string();
126
127 if !output.status.success() {
128 return Err(ErrorData::internal_error(
129 format!(
130 "rtk exited with {}: {}",
131 output.status,
132 if stderr.is_empty() { &stdout } else { &stderr }
133 ),
134 None,
135 ));
136 }
137
138 Ok(match (stdout.is_empty(), stderr.is_empty()) {
139 (true, true) => "(rtk produced no output)".to_string(),
140 (false, true) => stdout,
141 (true, false) => stderr,
142 (false, false) => format!("{stdout}\n\n[rtk stderr]\n{stderr}"),
143 })
144 }
145}
146
147fn bad_params(message: String) -> ErrorData {
149 ErrorData::invalid_params(message, None)
150}
151
152#[tool_router]
153impl RtkServer {
154 #[tool(
156 name = "rtk_gain",
157 description = "Report rtk's token-savings statistics: how many tokens rtk has saved, and \
158 optionally the recent per-command history. Read-only."
159 )]
160 async fn rtk_gain(
161 &self,
162 Parameters(GainRequest {
163 history,
164 project_only,
165 format,
166 }): Parameters<GainRequest>,
167 ) -> Result<String, ErrorData> {
168 let opts = GainOptions {
169 history: history.unwrap_or(false),
170 project_only: project_only.unwrap_or(false),
171 format,
172 };
173 let args = gain_args(&opts, &self.config).map_err(bad_params)?;
174 info!(?args, "rtk_gain");
175 self.run(&args)
176 }
177
178 #[tool(
180 name = "rtk_discover",
181 description = "Analyze past Claude Code session history for commands that rtk could have \
182 optimized but did not, i.e. missed token savings. Read-only."
183 )]
184 async fn rtk_discover(
185 &self,
186 Parameters(DiscoverRequest {
187 project,
188 limit,
189 since,
190 all_projects,
191 format,
192 }): Parameters<DiscoverRequest>,
193 ) -> Result<String, ErrorData> {
194 let opts = DiscoverOptions {
195 project,
196 limit,
197 since,
198 all_projects: all_projects.unwrap_or(false),
199 format,
200 };
201 let args = discover_args(&opts, &self.config).map_err(bad_params)?;
202 info!(?args, "rtk_discover");
203 self.run(&args)
204 }
205
206 #[tool(
208 name = "rtk_check",
209 description = "Show how rtk would rewrite a given shell command, without running it. Use \
210 this to explain why a command was or was not optimized. Read-only: the \
211 command is analyzed, never executed."
212 )]
213 async fn rtk_check(
214 &self,
215 Parameters(CheckRequest { command }): Parameters<CheckRequest>,
216 ) -> Result<String, ErrorData> {
217 let args = check_args(&command, &self.config).map_err(bad_params)?;
218 info!(?args, "rtk_check");
219 self.run(&args)
220 }
221
222 #[tool(
230 name = "rtk_proxy",
231 description = "Execute a command through `rtk proxy`: it runs UNFILTERED (no rewriting) \
232 but is still counted in rtk's statistics. Takes an argument vector, not a \
233 shell line — no pipes, redirects or globbing. This tool EXECUTES the \
234 command; prefer the ordinary Bash tool unless you specifically need to \
235 bypass rtk's rewriting while keeping it tracked."
236 )]
237 async fn rtk_proxy(
238 &self,
239 Parameters(ProxyRequest { argv }): Parameters<ProxyRequest>,
240 ) -> Result<String, ErrorData> {
241 let args = proxy_args(&argv, &self.config).map_err(bad_params)?;
242 info!(?args, "rtk_proxy");
243 self.run(&args)
244 }
245}
246
247#[tool_handler]
248impl ServerHandler for RtkServer {
249 fn get_info(&self) -> ServerConfig {
250 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
251 .with_server_info(Implementation::new(
259 env!("CARGO_BIN_NAME"),
260 env!("CARGO_PKG_VERSION"),
261 ))
262 .with_instructions(
263 "Analytics for rtk (Rust Token Killer), which rewrites shell commands into \
264 token-cheaper equivalents. Use rtk_gain for how much has been saved, \
265 rtk_discover for savings being missed, and rtk_check to explain how one \
266 particular command would be rewritten. rtk_proxy executes a command \
267 unfiltered and is rarely what you want.",
268 )
269 }
270}
271
272#[tokio::main]
275async fn main() -> anyhow::Result<()> {
276 let args: Vec<String> = std::env::args().collect();
277 if args.iter().any(|a| a == "--version" || a == "-V") {
278 println!("rtk-cc-mcp {}", env!("CARGO_PKG_VERSION"));
279 return Ok(());
280 }
281 if args.iter().any(|a| a == "--help" || a == "-h") {
282 println!(
283 "rtk-cc-mcp {} — MCP server exposing rtk analytics",
284 env!("CARGO_PKG_VERSION")
285 );
286 println!();
287 println!("Usage: rtk-cc-mcp");
288 println!(" Speaks MCP over stdio. Launched by Claude Code via .mcp.json;");
289 println!(" running it by hand is only useful for debugging.");
290 println!();
291 println!("Tools: rtk_gain, rtk_discover, rtk_check, rtk_proxy");
292 println!();
293 println!("Environment:");
294 println!(" RTK_BIN Path to the rtk executable (default: `rtk` on PATH)");
295 return Ok(());
296 }
297
298 tracing_subscriber::fmt()
300 .with_writer(std::io::stderr)
301 .with_env_filter(
302 tracing_subscriber::EnvFilter::try_from_default_env()
303 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
304 )
305 .init();
306
307 let project_dir = std::env::var("CLAUDE_PROJECT_DIR")
308 .map(PathBuf::from)
309 .or_else(|_| std::env::current_dir())
310 .unwrap_or_else(|_| PathBuf::from("."));
311
312 info!(project_dir = %project_dir.display(), "starting rtk-cc-mcp");
313
314 let service = RtkServer::new(&project_dir).serve(stdio()).await?;
315 service.waiting().await?;
316 Ok(())
317}