1use serde::{Deserialize, Serialize};
22use std::env;
23use std::sync::LazyLock;
24use std::time::Duration;
25
26pub 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#[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
74const MAX_RETRIES: u32 = 2;
77const INITIAL_BACKOFF_MS: u64 = 500; const USER_AGENT: &str = "PatchworkMCP-Rust/1.0";
79
80static 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
96const LOG_PREFIX: &str = "PATCHWORKMCP_UNSENT_FEEDBACK";
98
99fn 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
108pub struct Options {
112 pub patchwork_url: Option<String>,
114 pub api_key: Option<String>,
116 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
147pub 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
220pub 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
262pub 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}