Skip to content

Commit 378a916

Browse files
NagyViktclaude
andcommitted
v1.18: flashpaste-mcp server + flashpaste agent skill
flashpaste-mcp: hand-rolled JSON-RPC 2.0 over stdio MCP server (~300 LOC, no external SDK churn). Exposes four tools to MCP-aware agents: take_screenshot(interactive?) → returns real PNG bytes via XDG portal (wraps flashpaste-shoot --print-path) read_clipboard() → text via wl-paste shim chain copy_text(text) → places text on clipboard via wl-copy paste_to_pane(pane_id) → cross-pane / cross-agent paste via flashpasted daemon (sub-15ms) Register with Claude Code: ~/.config/claude-code/mcp.json { "mcpServers": { "flashpaste": { "command": "flashpaste-mcp" } } } Skill: skills/flashpaste/SKILL.md — paired prompt + usage notes for Claude Code's skills system. Install with: cp -r skills/flashpaste ~/.claude/skills/ build-deb now bundles flashpaste-mcp alongside the other Rust binaries. README has a new MCP section above 'For agents & contributors' with the registration snippet and tool table. Verified locally: - echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | flashpaste-mcp → valid protocolVersion / capabilities / serverInfo response. - tools/list returns 4 tools with valid input schemas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7caa978 commit 378a916

6 files changed

Lines changed: 793 additions & 198 deletions

File tree

README.md

Lines changed: 306 additions & 196 deletions
Large diffs are not rendered by default.

packaging/build-deb.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ done
5656
# has been run. Falls back gracefully to bash-only if they don't exist.
5757
RS_RELEASE="$REPO_DIR/rs/target/release"
5858
if [ -d "$RS_RELEASE" ]; then
59-
for bin in flashpasted flashpaste-dispatch flashpaste-shoot flashpaste-trigger; do
59+
for bin in flashpasted flashpaste-dispatch flashpaste-shoot flashpaste-trigger flashpaste-mcp; do
6060
if [ -x "$RS_RELEASE/$bin" ]; then
6161
install -m 0755 "$RS_RELEASE/$bin" "$STAGE/usr/bin/$bin"
6262
say " + Rust binary: $bin ($(stat -c%s "$RS_RELEASE/$bin") bytes)"

rs/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ members = [
66
"flashpasted",
77
"flashpaste-trigger",
88
"flashpaste-shoot",
9+
"flashpaste-mcp",
910
]
1011

1112
[workspace.package]
@@ -27,7 +28,7 @@ notify = "6"
2728
inotify = "0.10"
2829

2930
# async runtime (daemon)
30-
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "signal", "time", "sync", "fs"] }
31+
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "io-util", "io-std", "signal", "time", "sync", "fs"] }
3132

3233
# logging
3334
tracing = "0.1"

rs/flashpaste-mcp/Cargo.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "flashpaste-mcp"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
authors.workspace = true
7+
repository.workspace = true
8+
rust-version.workspace = true
9+
description = "MCP server exposing flashpaste tools (screenshot, clipboard read/write, paste) to LLM agents over stdio"
10+
11+
[[bin]]
12+
name = "flashpaste-mcp"
13+
path = "src/main.rs"
14+
15+
[dependencies]
16+
tokio = { workspace = true }
17+
tracing = { workspace = true }
18+
tracing-subscriber = { workspace = true }
19+
anyhow = { workspace = true }
20+
serde = { workspace = true }
21+
serde_json = { workspace = true }

rs/flashpaste-mcp/src/main.rs

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
//! flashpaste-mcp — hand-rolled Model Context Protocol server over stdio.
2+
//!
3+
//! Implements just enough of the MCP spec (initialize, tools/list, tools/call)
4+
//! to give LLM agents like Claude Code real eyes (take_screenshot) and hands
5+
//! (read_clipboard, copy_text, paste_to_pane) on a Linux desktop, without
6+
//! pulling in the full rmcp macro stack (whose API churns between versions).
7+
//!
8+
//! ## Register with Claude Code
9+
//!
10+
//! Add to `~/.config/claude-code/mcp.json`:
11+
//!
12+
//! ```json
13+
//! {
14+
//! "mcpServers": {
15+
//! "flashpaste": {
16+
//! "command": "flashpaste-mcp",
17+
//! "args": []
18+
//! }
19+
//! }
20+
//! }
21+
//! ```
22+
//!
23+
//! ## Protocol
24+
//!
25+
//! Plain JSON-RPC 2.0 messages, one per line on stdin/stdout. All logs go
26+
//! to stderr so they don't corrupt the JSON-RPC stream.
27+
28+
use std::process::Command;
29+
30+
use anyhow::Result;
31+
use serde::{Deserialize, Serialize};
32+
use serde_json::{json, Value};
33+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
34+
use tracing::{info, warn};
35+
use tracing_subscriber::EnvFilter;
36+
37+
const PROTOCOL_VERSION: &str = "2024-11-05";
38+
const SERVER_NAME: &str = "flashpaste-mcp";
39+
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
40+
41+
#[derive(Debug, Deserialize)]
42+
struct JsonRpcRequest {
43+
jsonrpc: String,
44+
id: Option<Value>,
45+
method: String,
46+
#[serde(default)]
47+
params: Value,
48+
}
49+
50+
#[derive(Debug, Serialize)]
51+
struct JsonRpcResponse<'a> {
52+
jsonrpc: &'a str,
53+
id: Value,
54+
#[serde(skip_serializing_if = "Option::is_none")]
55+
result: Option<Value>,
56+
#[serde(skip_serializing_if = "Option::is_none")]
57+
error: Option<JsonRpcError>,
58+
}
59+
60+
#[derive(Debug, Serialize)]
61+
struct JsonRpcError {
62+
code: i64,
63+
message: String,
64+
}
65+
66+
#[tokio::main(flavor = "current_thread")]
67+
async fn main() -> Result<()> {
68+
tracing_subscriber::fmt()
69+
.with_env_filter(
70+
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
71+
)
72+
.with_writer(std::io::stderr)
73+
.init();
74+
75+
info!(
76+
version = SERVER_VERSION,
77+
"flashpaste-mcp starting (stdio transport)"
78+
);
79+
80+
let stdin = tokio::io::stdin();
81+
let mut reader = BufReader::new(stdin);
82+
let mut stdout = tokio::io::stdout();
83+
let mut line = String::new();
84+
85+
loop {
86+
line.clear();
87+
let n = reader.read_line(&mut line).await?;
88+
if n == 0 {
89+
info!("stdin closed, exiting");
90+
break;
91+
}
92+
let trimmed = line.trim();
93+
if trimmed.is_empty() {
94+
continue;
95+
}
96+
let req: JsonRpcRequest = match serde_json::from_str(trimmed) {
97+
Ok(r) => r,
98+
Err(e) => {
99+
warn!(error=%e, "ignoring malformed JSON-RPC line");
100+
continue;
101+
}
102+
};
103+
if req.jsonrpc != "2.0" {
104+
warn!(method=%req.method, "ignoring non-2.0 JSON-RPC request");
105+
continue;
106+
}
107+
let id = req.id.clone().unwrap_or(Value::Null);
108+
let response = dispatch(&req.method, req.params).await;
109+
// MCP "notifications" (no id) per spec MUST not get a response.
110+
if req.id.is_none() {
111+
continue;
112+
}
113+
let out_msg = match response {
114+
Ok(result) => JsonRpcResponse {
115+
jsonrpc: "2.0",
116+
id,
117+
result: Some(result),
118+
error: None,
119+
},
120+
Err(e) => JsonRpcResponse {
121+
jsonrpc: "2.0",
122+
id,
123+
result: None,
124+
error: Some(JsonRpcError {
125+
code: -32000,
126+
message: e.to_string(),
127+
}),
128+
},
129+
};
130+
let mut buf = serde_json::to_vec(&out_msg)?;
131+
buf.push(b'\n');
132+
stdout.write_all(&buf).await?;
133+
stdout.flush().await?;
134+
}
135+
Ok(())
136+
}
137+
138+
/// Top-level method router.
139+
async fn dispatch(method: &str, params: Value) -> Result<Value> {
140+
match method {
141+
"initialize" => Ok(json!({
142+
"protocolVersion": PROTOCOL_VERSION,
143+
"capabilities": {
144+
"tools": {}
145+
},
146+
"serverInfo": {
147+
"name": SERVER_NAME,
148+
"version": SERVER_VERSION
149+
},
150+
"instructions": "flashpaste MCP — eyes (take_screenshot) and hands \
151+
(read_clipboard, copy_text, paste_to_pane) for a Linux desktop. \
152+
Screenshot returns real PNG bytes the model can see; paste tool \
153+
routes through the flashpasted daemon so cross-pane and \
154+
cross-agent paste is sub-15ms."
155+
})),
156+
"tools/list" => Ok(json!({
157+
"tools": tool_list()
158+
})),
159+
"tools/call" => tool_call(params).await,
160+
"notifications/initialized" | "notifications/cancelled" | "ping" => {
161+
Ok(json!({}))
162+
}
163+
other => anyhow::bail!("unknown method: {other}"),
164+
}
165+
}
166+
167+
fn tool_list() -> Value {
168+
json!([
169+
{
170+
"name": "take_screenshot",
171+
"description": "Capture a screenshot of the user's screen via the XDG desktop \
172+
portal and return it as image content the agent can see directly. \
173+
Use this whenever the user is debugging visual UI or asks 'what \
174+
do you see on my screen?'.",
175+
"inputSchema": {
176+
"type": "object",
177+
"properties": {
178+
"interactive": {
179+
"type": "boolean",
180+
"description": "Open the portal's interactive area-selection UI \
181+
before capturing. Default: false (full screen).",
182+
"default": false
183+
}
184+
}
185+
}
186+
},
187+
{
188+
"name": "read_clipboard",
189+
"description": "Read the current system clipboard as text. Falls back through \
190+
wl-paste shim → xclip → xsel. Returns empty string if the \
191+
clipboard holds non-text data (use take_screenshot for image).",
192+
"inputSchema": {"type": "object", "properties": {}}
193+
},
194+
{
195+
"name": "copy_text",
196+
"description": "Place plain text onto the user's system clipboard so they can \
197+
paste it elsewhere.",
198+
"inputSchema": {
199+
"type": "object",
200+
"required": ["text"],
201+
"properties": {
202+
"text": {
203+
"type": "string",
204+
"description": "Text content to place on the clipboard."
205+
}
206+
}
207+
}
208+
},
209+
{
210+
"name": "paste_to_pane",
211+
"description": "Trigger flashpaste to paste the current clipboard contents into \
212+
a specific tmux pane (e.g. into another agent's pane for \
213+
cross-agent handoff). Uses the flashpasted daemon for sub-15ms \
214+
round-trip with bash fallback.",
215+
"inputSchema": {
216+
"type": "object",
217+
"required": ["pane_id"],
218+
"properties": {
219+
"pane_id": {
220+
"type": "string",
221+
"description": "Target tmux pane id (e.g. '%4'). Get pane ids via \
222+
`tmux list-panes -F '#{pane_id} #{pane_current_command}'`."
223+
}
224+
}
225+
}
226+
}
227+
])
228+
}
229+
230+
async fn tool_call(params: Value) -> Result<Value> {
231+
let name = params
232+
.get("name")
233+
.and_then(|v| v.as_str())
234+
.ok_or_else(|| anyhow::anyhow!("missing tool name"))?;
235+
let args = params.get("arguments").cloned().unwrap_or(json!({}));
236+
match name {
237+
"take_screenshot" => take_screenshot(args).await,
238+
"read_clipboard" => read_clipboard().await,
239+
"copy_text" => copy_text(args).await,
240+
"paste_to_pane" => paste_to_pane(args).await,
241+
other => anyhow::bail!("unknown tool: {other}"),
242+
}
243+
}
244+
245+
// ── tool implementations ────────────────────────────────────────────
246+
247+
async fn take_screenshot(args: Value) -> Result<Value> {
248+
let interactive = args
249+
.get("interactive")
250+
.and_then(|v| v.as_bool())
251+
.unwrap_or(false);
252+
let mut cmd = Command::new("flashpaste-shoot");
253+
cmd.arg("--print-path");
254+
if interactive {
255+
cmd.arg("--interactive");
256+
}
257+
let out = cmd.output()?;
258+
if !out.status.success() {
259+
anyhow::bail!(
260+
"flashpaste-shoot failed: {}",
261+
String::from_utf8_lossy(&out.stderr).trim()
262+
);
263+
}
264+
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
265+
if path.is_empty() {
266+
anyhow::bail!("flashpaste-shoot produced no path");
267+
}
268+
let bytes = std::fs::read(&path)?;
269+
let b64 = base64_encode(&bytes);
270+
Ok(json!({
271+
"content": [
272+
{"type": "image", "data": b64, "mimeType": "image/png"},
273+
{"type": "text", "text": format!("screenshot saved to {path} ({} bytes)", bytes.len())}
274+
]
275+
}))
276+
}
277+
278+
async fn read_clipboard() -> Result<Value> {
279+
let out = Command::new("wl-paste").arg("--no-newline").output()?;
280+
let text = String::from_utf8_lossy(&out.stdout).to_string();
281+
Ok(json!({
282+
"content": [{"type": "text", "text": text}]
283+
}))
284+
}
285+
286+
async fn copy_text(args: Value) -> Result<Value> {
287+
use std::io::Write;
288+
let text = args
289+
.get("text")
290+
.and_then(|v| v.as_str())
291+
.ok_or_else(|| anyhow::anyhow!("missing 'text' argument"))?;
292+
let mut child = Command::new("wl-copy")
293+
.stdin(std::process::Stdio::piped())
294+
.spawn()?;
295+
if let Some(mut stdin) = child.stdin.take() {
296+
stdin.write_all(text.as_bytes())?;
297+
}
298+
let _ = child.wait();
299+
Ok(json!({
300+
"content": [
301+
{"type": "text", "text": format!("copied {} bytes to clipboard", text.len())}
302+
]
303+
}))
304+
}
305+
306+
async fn paste_to_pane(args: Value) -> Result<Value> {
307+
let pane = args
308+
.get("pane_id")
309+
.and_then(|v| v.as_str())
310+
.ok_or_else(|| anyhow::anyhow!("missing 'pane_id' argument"))?;
311+
let out = Command::new("flashpaste-trigger").arg(pane).output()?;
312+
let msg = if out.status.success() {
313+
format!("paste dispatched to pane {pane}")
314+
} else {
315+
format!(
316+
"paste failed: {}",
317+
String::from_utf8_lossy(&out.stderr).trim()
318+
)
319+
};
320+
Ok(json!({
321+
"content": [{"type": "text", "text": msg}]
322+
}))
323+
}
324+
325+
// ── base64 (stdlib-only, no extra dep) ─────────────────────────────
326+
327+
fn base64_encode(bytes: &[u8]) -> String {
328+
const ALPHA: &[u8; 64] =
329+
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
330+
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
331+
for chunk in bytes.chunks(3) {
332+
let b0 = chunk[0];
333+
let b1 = chunk.get(1).copied().unwrap_or(0);
334+
let b2 = chunk.get(2).copied().unwrap_or(0);
335+
out.push(ALPHA[(b0 >> 2) as usize] as char);
336+
out.push(ALPHA[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
337+
out.push(if chunk.len() > 1 {
338+
ALPHA[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char
339+
} else {
340+
'='
341+
});
342+
out.push(if chunk.len() > 2 {
343+
ALPHA[(b2 & 0x3F) as usize] as char
344+
} else {
345+
'='
346+
});
347+
}
348+
out
349+
}

0 commit comments

Comments
 (0)