@pastepile/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @pastepile/mcp
2
+
3
+ The **persistent memory layer for AI coding assistants.** A
4
+ [Model Context Protocol](https://modelcontextprotocol.io) server that lets
5
+ Claude, Cursor, Windsurf, and any MCP client save knowledge to
6
+ [Pastepile](https://pastepile.com) and retrieve it later by a stable URL.
7
+
8
+ Give your assistant durable, URL-addressable storage: when it produces a design
9
+ doc, a working snippet, a log, or a decision record, it saves it here and
10
+ remembers the slug. Next session, it loads it straight back. Zero dependencies,
11
+ so it adds no supply-chain surface of its own.
12
+
13
+ ## Tools
14
+
15
+ | Tool | What it does |
16
+ | ------------------ | ------------------------------------------------------------------- |
17
+ | `pastepile_save` | Save text/code and get back a stable `url`, `slug`, and `edit_key`. |
18
+ | `pastepile_get` | Retrieve a paste by slug or full Pastepile URL. |
19
+ | `pastepile_search` | Search the public paste corpus by keyword. |
20
+ | `pastepile_list` | List the most recent public pastes. |
21
+
22
+ Saved pastes default to **unlisted** (retrievable by URL, never shown in public
23
+ listings or search) and **never expire**, so stored knowledge persists. Both are
24
+ adjustable per call. End-to-end encrypted, password-protected, and
25
+ burn-after-read pastes cannot be read back through the API and are out of scope
26
+ for `pastepile_get`.
27
+
28
+ ## Requirements
29
+
30
+ Node.js 18 or newer (the server uses the built-in global `fetch`). No install
31
+ step, no build.
32
+
33
+ ## Configuration
34
+
35
+ The server is configured entirely by environment variables:
36
+
37
+ | Variable | Default | Purpose |
38
+ | ------------------- | ----------------------- | -------------------------------------------------------------------------------------- |
39
+ | `PASTEPILE_API` | `https://pastepile.com` | Base URL. Point at a self-hosted or staging deploy. |
40
+ | `PASTEPILE_API_KEY` | (none) | Optional Pastepile API key, sent as `X-API-Key` for a higher rate limit. Never logged. |
41
+
42
+ ## Setup
43
+
44
+ ### Claude Code
45
+
46
+ ```bash
47
+ claude mcp add pastepile -- npx -y @pastepile/mcp
48
+ ```
49
+
50
+ Or add it to `.mcp.json` in your project (shared with your team):
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "pastepile": {
56
+ "command": "npx",
57
+ "args": ["-y", "@pastepile/mcp"],
58
+ "env": { "PASTEPILE_API_KEY": "your-key-optional" }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### Cursor
65
+
66
+ Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "pastepile": {
72
+ "command": "npx",
73
+ "args": ["-y", "@pastepile/mcp"],
74
+ "env": { "PASTEPILE_API_KEY": "your-key-optional" }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ### Windsurf
81
+
82
+ Add to `~/.codeium/windsurf/mcp_config.json`:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "pastepile": {
88
+ "command": "npx",
89
+ "args": ["-y", "@pastepile/mcp"],
90
+ "env": { "PASTEPILE_API_KEY": "your-key-optional" }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ### Any other MCP client
97
+
98
+ The server speaks JSON-RPC 2.0 over stdio. Run it with:
99
+
100
+ ```bash
101
+ npx -y @pastepile/mcp
102
+ ```
103
+
104
+ Point your client at that command. The `env` block is optional; without it the
105
+ server talks to `https://pastepile.com` anonymously.
106
+
107
+ ## Example
108
+
109
+ Once configured, ask your assistant to use it in plain language:
110
+
111
+ > "Save this migration plan to Pastepile so we can pick it up tomorrow."
112
+
113
+ > "Pull up the paste at pastepile.com/p/abc123 and continue where we left off."
114
+
115
+ The assistant calls `pastepile_save` / `pastepile_get` for you and keeps the
116
+ returned slug in context.
117
+
118
+ ## How it works
119
+
120
+ This server is a thin wrapper over the public Pastepile REST API
121
+ (`/api/public/pastes`). It holds no state of its own: every tool call is one
122
+ HTTPS request, tagged with a `pastepile-mcp/<version>` User-Agent. Your content
123
+ goes straight to Pastepile under the same privacy model as the web app and CLI.
124
+ For end-to-end encryption, create the paste in the web app or CLI (the
125
+ encryption key never leaves the client) and share the resulting link.
126
+
127
+ ## License
128
+
129
+ MIT. Part of the [Pastepile](https://pastepile.com) project.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // Pastepile MCP server: stdio transport.
3
+ //
4
+ // Reads newline-delimited JSON-RPC messages from stdin, hands each to the pure
5
+ // dispatcher in ../src/server.mjs, and writes responses (one JSON object per
6
+ // line) to stdout. Nothing but protocol messages is ever written to stdout;
7
+ // diagnostics go to stderr so they cannot corrupt the JSON-RPC stream.
8
+ //
9
+ // Configuration is by environment:
10
+ // PASTEPILE_API base URL (default https://pastepile.com)
11
+ // PASTEPILE_API_KEY optional API key (sent as X-API-Key for higher limits)
12
+ import { createInterface } from "node:readline";
13
+ import { dispatch, VERSION, DEFAULT_BASE } from "../src/server.mjs";
14
+
15
+ const deps = {
16
+ fetch: globalThis.fetch,
17
+ baseUrl: (process.env.PASTEPILE_API || DEFAULT_BASE).replace(/\/+$/, ""),
18
+ apiKey: process.env.PASTEPILE_API_KEY || null,
19
+ version: VERSION,
20
+ };
21
+
22
+ if (typeof deps.fetch !== "function") {
23
+ process.stderr.write(
24
+ "pastepile-mcp: global fetch is unavailable. Node 18 or newer is required.\n",
25
+ );
26
+ process.exit(1);
27
+ }
28
+
29
+ function write(obj) {
30
+ process.stdout.write(JSON.stringify(obj) + "\n");
31
+ }
32
+
33
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
34
+
35
+ rl.on("line", async (line) => {
36
+ const trimmed = line.trim();
37
+ if (!trimmed) return;
38
+
39
+ let message;
40
+ try {
41
+ message = JSON.parse(trimmed);
42
+ } catch {
43
+ write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
44
+ return;
45
+ }
46
+
47
+ try {
48
+ const response = await dispatch(message, deps);
49
+ if (response) write(response);
50
+ } catch (err) {
51
+ const id = message && message.id != null ? message.id : null;
52
+ write({
53
+ jsonrpc: "2.0",
54
+ id,
55
+ error: { code: -32603, message: err && err.message ? err.message : "Internal error" },
56
+ });
57
+ }
58
+ });
59
+
60
+ // Exit cleanly when the host closes the pipe.
61
+ rl.on("close", () => process.exit(0));
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@pastepile/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for Pastepile - durable, URL-addressable memory for AI coding assistants. Save and retrieve knowledge from Claude, Cursor, Windsurf, and any MCP client.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "pastepile-mcp": "bin/pastepile-mcp.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test",
20
+ "prepublishOnly": "node --test"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "keywords": [
26
+ "pastepile",
27
+ "mcp",
28
+ "model-context-protocol",
29
+ "ai",
30
+ "memory",
31
+ "claude",
32
+ "cursor",
33
+ "windsurf",
34
+ "pastebin"
35
+ ],
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/asterismos-v1/pastepile.git",
39
+ "directory": "mcp-server"
40
+ },
41
+ "homepage": "https://pastepile.com"
42
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,376 @@
1
+ // Pastepile MCP server: the persistent memory layer for AI coding assistants.
2
+ //
3
+ // Implements the Model Context Protocol (JSON-RPC 2.0 over stdio) as a thin,
4
+ // hardened wrapper over the Pastepile public REST API. Zero dependencies,
5
+ // exactly like the CLI, so it adds no supply-chain surface. Node 18+ (global
6
+ // fetch).
7
+ //
8
+ // The transport (bin/pastepile-mcp.mjs) reads newline-delimited JSON from
9
+ // stdin and writes responses to stdout. Everything protocol-related lives here
10
+ // and is pure and testable: `dispatch(message, deps)` takes a parsed JSON-RPC
11
+ // message plus injectable dependencies (fetch, base URL, api key, version) and
12
+ // returns the response object, or null for a notification that must not be
13
+ // answered.
14
+
15
+ export const VERSION = "0.1.0";
16
+ export const PROTOCOL_VERSION = "2025-06-18";
17
+ export const SERVER_NAME = "pastepile";
18
+ export const DEFAULT_BASE = "https://pastepile.com";
19
+
20
+ // One line the host shows the model so it treats Pastepile as durable memory.
21
+ export const INSTRUCTIONS =
22
+ "Pastepile is durable, URL-addressable storage for AI assistants. When you " +
23
+ "produce something worth keeping across sessions (a design, a snippet, a " +
24
+ "log, a decision record), call pastepile_save and remember the returned " +
25
+ "slug or URL. Reload it later with pastepile_get. Search the public corpus " +
26
+ "with pastepile_search. Saved pastes default to unlisted (retrievable by " +
27
+ "URL, not shown in public listings or search).";
28
+
29
+ const EXPIRY_VALUES = ["10m", "1h", "1d", "1w", "1mo", "never", "burn"];
30
+ const VISIBILITY_VALUES = ["public", "unlisted"];
31
+
32
+ export const TOOLS = [
33
+ {
34
+ name: "pastepile_save",
35
+ description:
36
+ "Save text, code, notes, or any knowledge to Pastepile and get back a " +
37
+ "stable, shareable URL you can retrieve later. Use this as durable " +
38
+ "memory: whenever you produce output worth keeping across sessions, " +
39
+ "save it and remember the returned slug or URL. Returns url, slug, " +
40
+ "raw_url, and edit_key. The edit_key is shown only once and is required " +
41
+ "to later update or delete the paste, so retain it if you may change it.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ content: {
46
+ type: "string",
47
+ description: "The text or code to store. Required.",
48
+ },
49
+ title: {
50
+ type: "string",
51
+ description: "Optional short title for the paste.",
52
+ },
53
+ language: {
54
+ type: "string",
55
+ description:
56
+ 'Optional syntax-highlighting language, e.g. "typescript", ' +
57
+ '"python", "markdown". Defaults to "plaintext".',
58
+ },
59
+ expiry: {
60
+ type: "string",
61
+ enum: EXPIRY_VALUES,
62
+ description:
63
+ 'When the paste expires. Defaults to "never" so stored knowledge ' +
64
+ 'persists. "burn" destroys it after the first read.',
65
+ },
66
+ visibility: {
67
+ type: "string",
68
+ enum: VISIBILITY_VALUES,
69
+ description:
70
+ '"unlisted" (default) keeps the paste out of public listings and ' +
71
+ 'search but retrievable by URL. "public" also lists it and makes ' +
72
+ "it findable via pastepile_search.",
73
+ },
74
+ },
75
+ required: ["content"],
76
+ additionalProperties: false,
77
+ },
78
+ },
79
+ {
80
+ name: "pastepile_get",
81
+ description:
82
+ "Retrieve a previously saved paste by its slug or full Pastepile URL. " +
83
+ "Returns the title, language, and file contents. Use this to reload " +
84
+ "knowledge you or a teammate saved earlier. End-to-end encrypted, " +
85
+ "password-protected, and burn-after-read pastes are not retrievable " +
86
+ "through this tool.",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ slug: {
91
+ type: "string",
92
+ description:
93
+ 'The paste slug (e.g. "abc123") or a full share URL (e.g. ' +
94
+ '"https://pastepile.com/p/abc123"). Required.',
95
+ },
96
+ },
97
+ required: ["slug"],
98
+ additionalProperties: false,
99
+ },
100
+ },
101
+ {
102
+ name: "pastepile_search",
103
+ description:
104
+ "Search public Pastepile pastes by keyword and get back matching " +
105
+ "titles, previews, and URLs. This searches the global public corpus of " +
106
+ "listed pastes, not a private per-user store. Use it to find existing " +
107
+ "public snippets or reference material before writing from scratch.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ query: {
112
+ type: "string",
113
+ description: "Keywords to match against public paste titles and content. Required.",
114
+ },
115
+ limit: {
116
+ type: "integer",
117
+ minimum: 1,
118
+ maximum: 100,
119
+ description: "Maximum results to return (1-100, default 20).",
120
+ },
121
+ },
122
+ required: ["query"],
123
+ additionalProperties: false,
124
+ },
125
+ },
126
+ {
127
+ name: "pastepile_list",
128
+ description:
129
+ "List the most recent public Pastepile pastes. Returns slugs, titles, " +
130
+ "and URLs. Use this to browse what has been shared publicly; for " +
131
+ "targeted lookups use pastepile_search instead.",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: {
135
+ limit: {
136
+ type: "integer",
137
+ minimum: 1,
138
+ maximum: 100,
139
+ description: "Maximum results to return (1-100, default 20).",
140
+ },
141
+ },
142
+ additionalProperties: false,
143
+ },
144
+ },
145
+ ];
146
+
147
+ // A tool-level failure the model should see (returned as an isError result,
148
+ // not a JSON-RPC protocol error).
149
+ export class ToolError extends Error {}
150
+
151
+ function normalizeBase(base) {
152
+ return String(base || DEFAULT_BASE).replace(/\/+$/, "");
153
+ }
154
+
155
+ // Accept a bare slug or a full share/raw URL, exactly like the CLI's `get`.
156
+ // Returns null for a URL we could not extract a slug from.
157
+ export function slugFromInput(input) {
158
+ const s = String(input == null ? "" : input).trim();
159
+ if (!s) return null;
160
+ const hashIdx = s.indexOf("#");
161
+ const raw = hashIdx >= 0 ? s.slice(0, hashIdx) : s;
162
+ const m = /\/(?:p|raw)\/([^/?#]+)/.exec(raw);
163
+ if (m) return decodeURIComponent(m[1]);
164
+ if (/^https?:\/\//.test(raw)) return null;
165
+ return raw;
166
+ }
167
+
168
+ function clampLimit(value, fallback = 20) {
169
+ const n = Number.parseInt(value, 10);
170
+ if (!Number.isFinite(n)) return fallback;
171
+ return Math.min(Math.max(n, 1), 100);
172
+ }
173
+
174
+ async function readJson(res) {
175
+ const txt = await res.text();
176
+ if (!txt) return null;
177
+ try {
178
+ return JSON.parse(txt);
179
+ } catch {
180
+ return { raw: txt };
181
+ }
182
+ }
183
+
184
+ function apiError(res, data) {
185
+ const message =
186
+ data && data.error && typeof data.error.message === "string"
187
+ ? data.error.message
188
+ : data && typeof data.raw === "string"
189
+ ? data.raw.slice(0, 300)
190
+ : `${res.status} ${res.statusText}`;
191
+ return new ToolError(`Pastepile API error (HTTP ${res.status}): ${message}`);
192
+ }
193
+
194
+ async function apiFetch(deps, method, path, body) {
195
+ const url = `${normalizeBase(deps.baseUrl)}${path}`;
196
+ const headers = {
197
+ // Analytics attribution: every request from this server is tagged so
198
+ // MCP-driven traffic can be measured distinctly from the web app and CLI.
199
+ "User-Agent": `pastepile-mcp/${deps.version || VERSION}`,
200
+ Accept: "application/json",
201
+ };
202
+ if (deps.apiKey) headers["X-API-Key"] = deps.apiKey;
203
+ if (body !== undefined) headers["Content-Type"] = "application/json";
204
+ return deps.fetch(url, {
205
+ method,
206
+ headers,
207
+ body: body !== undefined ? JSON.stringify(body) : undefined,
208
+ });
209
+ }
210
+
211
+ async function toolSave(deps, args) {
212
+ if (typeof args.content !== "string" || args.content.length === 0) {
213
+ throw new ToolError("`content` is required and must be a non-empty string.");
214
+ }
215
+ const expiry = args.expiry === undefined ? "never" : args.expiry;
216
+ if (!EXPIRY_VALUES.includes(expiry)) {
217
+ throw new ToolError(`Invalid expiry "${expiry}". Use one of: ${EXPIRY_VALUES.join(", ")}.`);
218
+ }
219
+ const visibility = args.visibility === undefined ? "unlisted" : args.visibility;
220
+ if (!VISIBILITY_VALUES.includes(visibility)) {
221
+ throw new ToolError(`Invalid visibility "${visibility}". Use "public" or "unlisted".`);
222
+ }
223
+
224
+ const body = { content: args.content, expiry, visibility };
225
+ if (typeof args.title === "string" && args.title.length > 0) body.title = args.title;
226
+ if (typeof args.language === "string" && args.language.length > 0) body.language = args.language;
227
+
228
+ const res = await apiFetch(deps, "POST", "/api/public/pastes", body);
229
+ const data = await readJson(res);
230
+ if (!res.ok || !data) throw apiError(res, data);
231
+ return {
232
+ saved: true,
233
+ url: data.url,
234
+ slug: data.slug,
235
+ raw_url: data.raw_url,
236
+ edit_key: data.edit_key,
237
+ visibility,
238
+ expiry,
239
+ note: "Retain edit_key to update or delete this paste later; it is shown only once.",
240
+ };
241
+ }
242
+
243
+ async function toolGet(deps, args) {
244
+ const slug = slugFromInput(args.slug);
245
+ if (!slug) throw new ToolError("Provide a valid paste slug or Pastepile URL.");
246
+ const res = await apiFetch(deps, "GET", `/api/public/pastes/${encodeURIComponent(slug)}`);
247
+ const data = await readJson(res);
248
+ if (!res.ok || !data) throw apiError(res, data);
249
+ return {
250
+ slug: data.slug,
251
+ title: data.title,
252
+ language: data.language,
253
+ files: data.files,
254
+ created_at: data.created_at,
255
+ expires_at: data.expires_at,
256
+ url: `${normalizeBase(deps.baseUrl)}/p/${data.slug}`,
257
+ };
258
+ }
259
+
260
+ function pickListItem(item) {
261
+ return {
262
+ slug: item.slug,
263
+ title: item.title,
264
+ language: item.language,
265
+ preview: item.preview,
266
+ file_count: item.file_count,
267
+ created_at: item.created_at,
268
+ url: item.url,
269
+ raw_url: item.raw_url,
270
+ };
271
+ }
272
+
273
+ async function toolSearch(deps, args) {
274
+ if (typeof args.query !== "string" || args.query.trim().length === 0) {
275
+ throw new ToolError("`query` is required and must be a non-empty string.");
276
+ }
277
+ const limit = clampLimit(args.limit);
278
+ const qs = new URLSearchParams({ search: args.query.slice(0, 80), limit: String(limit) });
279
+ const res = await apiFetch(deps, "GET", `/api/public/pastes?${qs.toString()}`);
280
+ const data = await readJson(res);
281
+ if (!res.ok || !data) throw apiError(res, data);
282
+ const items = Array.isArray(data.items) ? data.items : [];
283
+ return {
284
+ query: args.query,
285
+ total: typeof data.total === "number" ? data.total : items.length,
286
+ results: items.map(pickListItem),
287
+ };
288
+ }
289
+
290
+ async function toolList(deps, args) {
291
+ const limit = clampLimit(args.limit);
292
+ const qs = new URLSearchParams({ limit: String(limit) });
293
+ const res = await apiFetch(deps, "GET", `/api/public/pastes?${qs.toString()}`);
294
+ const data = await readJson(res);
295
+ if (!res.ok || !data) throw apiError(res, data);
296
+ const items = Array.isArray(data.items) ? data.items : [];
297
+ return {
298
+ total: typeof data.total === "number" ? data.total : items.length,
299
+ results: items.map(pickListItem),
300
+ };
301
+ }
302
+
303
+ const TOOL_HANDLERS = {
304
+ pastepile_save: toolSave,
305
+ pastepile_get: toolGet,
306
+ pastepile_search: toolSearch,
307
+ pastepile_list: toolList,
308
+ };
309
+
310
+ /**
311
+ * Handle one parsed JSON-RPC message. Returns a response object to write back,
312
+ * or null when the message is a notification (no `id`) that must not be
313
+ * answered. `deps` is { fetch, baseUrl, apiKey, version }.
314
+ */
315
+ export async function dispatch(message, deps) {
316
+ const isNotification = message == null || message.id === undefined || message.id === null;
317
+ const respond = (result) => (isNotification ? null : { jsonrpc: "2.0", id: message.id, result });
318
+ const fail = (code, msg) =>
319
+ isNotification ? null : { jsonrpc: "2.0", id: message.id, error: { code, message: msg } };
320
+
321
+ if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
322
+ return fail(-32600, "Invalid Request");
323
+ }
324
+
325
+ switch (message.method) {
326
+ case "initialize":
327
+ return respond({
328
+ protocolVersion: PROTOCOL_VERSION,
329
+ capabilities: { tools: {} },
330
+ serverInfo: { name: SERVER_NAME, version: deps.version || VERSION },
331
+ instructions: INSTRUCTIONS,
332
+ });
333
+
334
+ // Client -> server notifications: acknowledged by silence.
335
+ case "notifications/initialized":
336
+ case "initialized":
337
+ case "notifications/cancelled":
338
+ return null;
339
+
340
+ case "ping":
341
+ return respond({});
342
+
343
+ case "tools/list":
344
+ return respond({ tools: TOOLS });
345
+
346
+ case "tools/call": {
347
+ const params = message.params || {};
348
+ const name = params.name;
349
+ const args = params.arguments || {};
350
+ const handler = TOOL_HANDLERS[name];
351
+ if (!handler) {
352
+ return respond({
353
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
354
+ isError: true,
355
+ });
356
+ }
357
+ try {
358
+ const result = await handler(deps, args);
359
+ return respond({
360
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
361
+ });
362
+ } catch (err) {
363
+ // Tool failures are reported as isError results (model-visible), not as
364
+ // JSON-RPC protocol errors, so the assistant can read and react.
365
+ const text =
366
+ err instanceof ToolError
367
+ ? err.message
368
+ : `Unexpected error: ${err && err.message ? err.message : String(err)}`;
369
+ return respond({ content: [{ type: "text", text }], isError: true });
370
+ }
371
+ }
372
+
373
+ default:
374
+ return fail(-32601, `Method not found: ${message.method}`);
375
+ }
376
+ }