@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 +129 -0
- package/bin/pastepile-mcp.mjs +61 -0
- package/package.json +42 -0
- package/src/server.mjs +376 -0
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
|
+
}
|