Skip to main content

patchworkmcp/
lib.rs

1//! PatchworkMCP — Drop-in feedback tool for Rust MCP servers.
2//!
3//! Copy this file into your project and call `send_feedback()` with your
4//! MCP server router, or use the constants and helpers directly.
5//!
6//! Dependencies (add to Cargo.toml):
7//!   reqwest = { version = "0.12", features = ["json"] }
8//!   serde = { version = "1", features = ["derive"] }
9//!   serde_json = "1"
10//!   tokio = { version = "1", features = ["full"] }
11//!
12//! Configuration via environment:
13//!   PATCHWORKMCP_URL          - default: https://patchworkmcp.com
14//!   PATCHWORKMCP_API_KEY      - required API key
15//!   PATCHWORKMCP_SERVER_SLUG  - required server identifier
16//!
17//! Note: The Rust MCP ecosystem is still maturing. This file provides the
18//! feedback payload, HTTP submission, and schema constants. Wire the tool
19//! into your MCP framework's registration system as needed.
20
21use serde::{Deserialize, Serialize};
22use std::env;
23use std::sync::LazyLock;
24use std::time::Duration;
25
26// ── Constants ───────────────────────────────────────────────────────────────
27
28pub const TOOL_NAME: &str = "feedback";
29
30pub const TOOL_DESCRIPTION: &str = concat!(
31    "Report when you cannot find what you need or when available tools don't ",
32    "fully address the task. This feedback directly improves this server. ",
33    "Call this tool whenever: ",
34    "(1) you looked for a tool or resource that doesn't exist, ",
35    "(2) a tool returned incomplete or unhelpful results, ",
36    "(3) you had to work around a limitation or approximate an answer, ",
37    "(4) a new tool or parameter would have made the task easier. ",
38    "If you could not fully satisfy the user's request with the available ",
39    "tools, call this BEFORE giving your final response.",
40);
41
42// ── Types ───────────────────────────────────────────────────────────────────
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FeedbackPayload {
46    pub server_slug: String,
47    pub what_i_needed: String,
48    pub what_i_tried: String,
49    pub gap_type: String,
50    #[serde(default)]
51    pub suggestion: String,
52    #[serde(default)]
53    pub user_goal: String,
54    #[serde(default)]
55    pub resolution: String,
56    #[serde(default)]
57    pub agent_model: String,
58    #[serde(default)]
59    pub session_id: String,
60    #[serde(default)]
61    pub client_type: String,
62    #[serde(default)]
63    pub tools_available: Vec<String>,
64}
65
66#[derive(Debug, Deserialize)]
67struct ApiResponse {
68    #[allow(dead_code)]
69    id: String,
70    #[allow(dead_code)]
71    status: String,
72}
73
74// ── HTTP Client Config ──────────────────────────────────────────────────────
75
76const MAX_RETRIES: u32 = 2;
77const INITIAL_BACKOFF_MS: u64 = 500; // doubles each retry
78const USER_AGENT: &str = "PatchworkMCP-Rust/1.0";
79
80/// Module-level HTTP client for connection pooling and TLS session reuse.
81/// LazyLock is stable since Rust 1.80.
82static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
83    reqwest::Client::builder()
84        .connect_timeout(Duration::from_secs(2))
85        .timeout(Duration::from_secs(5))
86        .user_agent(USER_AGENT)
87        .pool_max_idle_per_host(5)
88        .build()
89        .expect("Failed to build reqwest HTTP client")
90});
91
92fn is_retryable_status(code: u16) -> bool {
93    matches!(code, 429 | 500 | 502 | 503 | 504)
94}
95
96/// Prefix makes these log lines greppable in any log aggregator.
97const LOG_PREFIX: &str = "PATCHWORKMCP_UNSENT_FEEDBACK";
98
99/// Log the full payload to stderr so the hosting environment captures it.
100/// The structured JSON is greppable via LOG_PREFIX and can be replayed from
101/// whatever log aggregation the containing server uses (Heroku logs,
102/// CloudWatch, Docker stdout, etc.).
103fn log_unsent_payload(payload: &FeedbackPayload, reason: &str) {
104    let json = serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string());
105    eprintln!("{LOG_PREFIX} reason={reason} payload={json}");
106}
107
108// ── Config ──────────────────────────────────────────────────────────────────
109
110/// Override environment variable defaults for PatchworkMCP connection.
111pub struct Options {
112    /// Override PATCHWORKMCP_URL.
113    pub patchwork_url: Option<String>,
114    /// Override PATCHWORKMCP_API_KEY.
115    pub api_key: Option<String>,
116    /// Override PATCHWORKMCP_SERVER_SLUG.
117    pub server_slug: Option<String>,
118}
119
120fn resolve_url(opts: Option<&Options>) -> String {
121    if let Some(o) = opts {
122        if let Some(ref url) = o.patchwork_url {
123            return url.clone();
124        }
125    }
126    env::var("PATCHWORKMCP_URL").unwrap_or_else(|_| "https://patchworkmcp.com".to_string())
127}
128
129fn resolve_key(opts: Option<&Options>) -> Option<String> {
130    if let Some(o) = opts {
131        if let Some(ref key) = o.api_key {
132            return if key.is_empty() { None } else { Some(key.clone()) };
133        }
134    }
135    env::var("PATCHWORKMCP_API_KEY").ok().filter(|k| !k.is_empty())
136}
137
138fn resolve_slug(opts: Option<&Options>) -> String {
139    if let Some(o) = opts {
140        if let Some(ref slug) = o.server_slug {
141            return slug.clone();
142        }
143    }
144    env::var("PATCHWORKMCP_SERVER_SLUG").unwrap_or_else(|_| "unknown".to_string())
145}
146
147// ── Submission ──────────────────────────────────────────────────────────────
148
149/// Send feedback to PatchworkMCP with retry logic.
150///
151/// Retries up to `MAX_RETRIES` times on transient failures (connection errors,
152/// 5xx, 429) with exponential backoff. Uses a module-level `reqwest::Client`
153/// for connection pooling and TLS session reuse.
154///
155/// Best-effort — returns a user-facing message regardless of success or failure.
156/// Pass `None` for opts to use environment variable defaults.
157pub async fn send_feedback(payload: &FeedbackPayload, opts: Option<&Options>) -> String {
158    let endpoint = format!("{}/api/v1/feedback/", resolve_url(opts));
159    let auth_key = resolve_key(opts);
160
161    for attempt in 0..=MAX_RETRIES {
162        let mut req = CLIENT.post(&endpoint).json(payload);
163        if let Some(ref key) = auth_key {
164            req = req.header("Authorization", format!("Bearer {key}"));
165        }
166
167        match req.send().await {
168            Ok(resp) => {
169                let status = resp.status().as_u16();
170                if status == 201 {
171                    return "Thank you. Your feedback has been recorded and will be \
172                            used to improve this server's capabilities."
173                        .to_string();
174                }
175                if is_retryable_status(status) && attempt < MAX_RETRIES {
176                    eprintln!(
177                        "PatchworkMCP returned {status}, retrying ({}/{})",
178                        attempt + 1,
179                        MAX_RETRIES
180                    );
181                    tokio::time::sleep(Duration::from_millis(
182                        INITIAL_BACKOFF_MS * 2u64.pow(attempt),
183                    ))
184                    .await;
185                    continue;
186                }
187                log_unsent_payload(payload, &format!("status_{status}"));
188                return format!(
189                    "Feedback could not be delivered and was logged. (Server returned {status})"
190                );
191            }
192            Err(e) => {
193                if attempt < MAX_RETRIES {
194                    eprintln!(
195                        "PatchworkMCP: delivery failed ({e}), retrying ({}/{})",
196                        attempt + 1,
197                        MAX_RETRIES
198                    );
199                    tokio::time::sleep(Duration::from_millis(
200                        INITIAL_BACKOFF_MS * 2u64.pow(attempt),
201                    ))
202                    .await;
203                    continue;
204                }
205                eprintln!(
206                    "PatchworkMCP: could not reach API after {} attempts: {e}",
207                    MAX_RETRIES + 1
208                );
209                log_unsent_payload(payload, &format!("unreachable:{e}"));
210                return "Feedback could not be delivered and was logged. (Server unreachable)"
211                    .to_string();
212            }
213        }
214    }
215
216    log_unsent_payload(payload, "unreachable:retries_exhausted");
217    "Feedback could not be delivered and was logged. (Server unreachable)".to_string()
218}
219
220/// Build a FeedbackPayload from a JSON value (as received from MCP call_tool).
221/// Missing fields get sensible defaults. Server slug is resolved from opts/env.
222pub fn payload_from_args(args: &serde_json::Value, opts: Option<&Options>) -> FeedbackPayload {
223    let s = |key: &str| -> String {
224        args.get(key)
225            .and_then(|v| v.as_str())
226            .unwrap_or("")
227            .to_string()
228    };
229
230    let tools: Vec<String> = args
231        .get("tools_available")
232        .and_then(|v| v.as_array())
233        .map(|arr| {
234            arr.iter()
235                .filter_map(|v| v.as_str().map(String::from))
236                .collect()
237        })
238        .unwrap_or_default();
239
240    FeedbackPayload {
241        server_slug: resolve_slug(opts),
242        what_i_needed: s("what_i_needed"),
243        what_i_tried: s("what_i_tried"),
244        gap_type: {
245            let g = s("gap_type");
246            if g.is_empty() {
247                "other".to_string()
248            } else {
249                g
250            }
251        },
252        suggestion: s("suggestion"),
253        user_goal: s("user_goal"),
254        resolution: s("resolution"),
255        agent_model: s("agent_model"),
256        session_id: s("session_id"),
257        client_type: s("client_type"),
258        tools_available: tools,
259    }
260}
261
262// ── JSON Schema (for manual tool registration) ──────────────────────────────
263
264/// Returns the tool input schema as a serde_json::Value. Use this when
265/// registering the tool manually with your MCP framework.
266pub fn tool_input_schema() -> serde_json::Value {
267    serde_json::json!({
268        "type": "object",
269        "properties": {
270            "what_i_needed": {
271                "type": "string",
272                "description": "What capability, data, or tool were you looking for?"
273            },
274            "what_i_tried": {
275                "type": "string",
276                "description": "What tools or approaches did you try? Include tool names and brief results."
277            },
278            "gap_type": {
279                "type": "string",
280                "enum": ["missing_tool", "incomplete_results", "missing_parameter", "wrong_format", "other"],
281                "description": "The category of gap encountered."
282            },
283            "suggestion": {
284                "type": "string",
285                "description": "Your idea for what would have helped."
286            },
287            "user_goal": {
288                "type": "string",
289                "description": "The user's original request or goal."
290            },
291            "resolution": {
292                "type": "string",
293                "enum": ["blocked", "worked_around", "partial"],
294                "description": "What happened after hitting the gap."
295            },
296            "tools_available": {
297                "type": "array",
298                "items": { "type": "string" },
299                "description": "Tool names you considered or tried."
300            },
301            "agent_model": {
302                "type": "string",
303                "description": "Your model identifier, if known."
304            },
305            "session_id": {
306                "type": "string",
307                "description": "Conversation or session identifier."
308            },
309            "client_type": {
310                "type": "string",
311                "description": "The MCP client in use, if known (e.g. 'claude-desktop', 'cursor', 'claude-code')."
312            }
313        },
314        "required": ["what_i_needed", "what_i_tried", "gap_type"]
315    })
316}