@volcanic-dev/tephra 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.
Files changed (3) hide show
  1. package/README.md +40 -0
  2. package/package.json +33 -0
  3. package/server.js +210 -0
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # Tephra
2
+
3
+ **A clipboard for AI agents.** Tephra is an MCP server that lets a model copy an exact character range from a file — start `(line, char)` to end `(line, char)` — and paste it at an exact position somewhere else. Byte-for-byte, no retyping, no transcription drift, no tokens spent regenerating code that already exists.
4
+
5
+ The clipboard is a **private in-process buffer, deliberately not the OS clipboard**. An agent can never read what you last copied (passwords, tokens, anything) and can never overwrite what you're about to paste. Nothing touches disk; the buffer dies with the session.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ # Claude Code
11
+ claude mcp add tephra -- npx -y @volcanic-dev/tephra
12
+
13
+ # or any MCP client (stdio transport)
14
+ npx -y @volcanic-dev/tephra
15
+ ```
16
+
17
+ ## Tools
18
+
19
+ | Tool | What it does |
20
+ |------|--------------|
21
+ | `copy` | Copy `(start_line, start_char)` → `(end_line, end_char)` from a file to the clipboard. Both ends inclusive, 1-indexed. Newlines inside the range are included. |
22
+ | `cut` | Same as `copy`, but removes the range from the source file. |
23
+ | `paste` | Insert the clipboard into a file before the character at `(line, char)`. `char` may be line length + 1 to append to a line. Never overwrites — text shifts to make room. |
24
+ | `peek` | Show the clipboard's current size and a preview. |
25
+
26
+ ## Addressing
27
+
28
+ - Lines and characters are **1-indexed**: the first character of a file is `(1, 1)`.
29
+ - Copy/cut ranges are **inclusive on both ends**: copying `(2, 5)` → `(2, 7)` yields exactly 3 characters.
30
+ - To copy whole lines 10–14: start `(10, 1)`, end `(14, <length of line 14>)`.
31
+ - A file ending in a newline has an addressable empty final line, so appending at end-of-file is `paste` at `(lastLine, 1)`.
32
+ - Out-of-range positions return the line's real length and a preview of it, so an agent can self-correct in one step.
33
+
34
+ ## Requirements
35
+
36
+ Node ≥ 18. No native dependencies, no OS clipboard backends — the buffer lives in the server process.
37
+
38
+ ---
39
+
40
+ Part of [Volcanic](https://volcanic.dev/tephra).
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@volcanic-dev/tephra",
3
+ "version": "0.1.0",
4
+ "description": "MCP server that gives AI agents a clipboard of their own: copy an exact line:char range from a file, paste it anywhere — byte-for-byte, without touching the OS clipboard.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "bin": {
11
+ "tephra": "server.js"
12
+ },
13
+ "files": [
14
+ "server.js",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "modelcontextprotocol",
23
+ "clipboard",
24
+ "copy-paste",
25
+ "ai-agents"
26
+ ],
27
+ "author": "Volcanic <noreply@volcanic.dev>",
28
+ "homepage": "https://volcanic.dev/tephra",
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.12.0",
31
+ "zod": "^3.24.0"
32
+ }
33
+ }
package/server.js ADDED
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ // @tephra-server
3
+ // Tephra — an MCP server that gives AI agents a clipboard of their own.
4
+ //
5
+ // Models are bad at retyping: moving 80 lines of code by regenerating them
6
+ // invites transcription drift and burns tokens. Tephra lets an agent name an
7
+ // exact range — start (line, char) to end (line, char), both 1-indexed and
8
+ // inclusive — copy it byte-for-byte, and paste it at an exact position in
9
+ // another file.
10
+ //
11
+ // The clipboard is a private in-process buffer, deliberately NOT the OS
12
+ // clipboard: an agent must not be able to read whatever the human last
13
+ // copied (passwords, tokens) or overwrite what they're about to paste.
14
+ // Nothing is written to disk; the buffer dies with the session.
15
+ //
16
+ // Tools: copy, cut, paste, peek.
17
+
18
+ import { readFile, writeFile } from 'node:fs/promises'
19
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
20
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
21
+ import { z } from 'zod'
22
+
23
+ // @tephra-buffer — the private clipboard; in-memory only, per-session
24
+ let clipboard = ''
25
+
26
+ // @tephra-positions — 1-indexed (line, char) ↔ absolute offset math
27
+ // indexLines keeps a trailing empty line when the file ends with \n, so
28
+ // "append at end of file" is addressable as (lastLine, 1).
29
+ function indexLines(text) {
30
+ const lines = []
31
+ let start = 0
32
+ for (let i = 0; i <= text.length; i++) {
33
+ if (i === text.length || text[i] === '\n') {
34
+ let contentEnd = i
35
+ if (contentEnd > start && text[contentEnd - 1] === '\r') contentEnd--
36
+ lines.push({ start, contentEnd })
37
+ start = i + 1
38
+ }
39
+ }
40
+ return lines
41
+ }
42
+
43
+ class PositionError extends Error {}
44
+
45
+ function lineAt(lines, line, label) {
46
+ if (!Number.isInteger(line) || line < 1 || line > lines.length) {
47
+ throw new PositionError(`${label}: line ${line} is out of range — the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.`)
48
+ }
49
+ return lines[line - 1]
50
+ }
51
+
52
+ // charOffset: char 1 = first character of the line. maxExtra=0 for copy/cut
53
+ // anchors (must land on a real character); maxExtra=1 for paste (char may be
54
+ // lineLength+1, meaning "after the last character, before the newline").
55
+ function charOffset(text, lines, line, char, label, maxExtra) {
56
+ const L = lineAt(lines, line, label)
57
+ const len = L.contentEnd - L.start
58
+ const max = len + maxExtra
59
+ if (!Number.isInteger(char) || char < 1 || char > max) {
60
+ const preview = text.slice(L.start, L.contentEnd)
61
+ const shown = preview.length > 80 ? `${preview.slice(0, 80)}…` : preview
62
+ throw new PositionError(
63
+ `${label}: char ${char} is out of range on line ${line}, which has ${len} character${len === 1 ? '' : 's'}` +
64
+ (maxExtra ? ` (valid: 1–${max}, where ${max} means end of line)` : ` (valid: 1–${Math.max(max, 1)})`) +
65
+ `. Line ${line} is: "${shown}"`,
66
+ )
67
+ }
68
+ return L.start + (char - 1)
69
+ }
70
+
71
+ // @tephra-preview — head/tail preview so results stay small even for big copies
72
+ function preview(text) {
73
+ if (text.length <= 300) return text
74
+ return `${text.slice(0, 200)}\n… [${text.length - 300} chars omitted] …\n${text.slice(-100)}`
75
+ }
76
+
77
+ function describe(text) {
78
+ const lines = text.length === 0 ? 0 : text.split('\n').length
79
+ return `${text.length} chars, ${lines} line${lines === 1 ? '' : 's'}`
80
+ }
81
+
82
+ // endPosition: (line, char) of the character right after `text` inserted at (line, char)
83
+ function endPosition(startLine, startChar, text) {
84
+ const parts = text.split('\n')
85
+ if (parts.length === 1) return { line: startLine, char: startChar + text.length }
86
+ return { line: startLine + parts.length - 1, char: parts[parts.length - 1].length + 1 }
87
+ }
88
+
89
+ // @tephra-extract — shared range extraction for copy/cut
90
+ async function extractRange(file, startLine, startChar, endLine, endChar) {
91
+ const text = await readFile(file, 'utf8')
92
+ const lines = indexLines(text)
93
+ const startOff = charOffset(text, lines, startLine, startChar, 'start', 0)
94
+ const endOff = charOffset(text, lines, endLine, endChar, 'end', 0)
95
+ if (endOff < startOff) {
96
+ throw new PositionError(`The end position (line ${endLine}, char ${endChar}) comes before the start position (line ${startLine}, char ${startChar}).`)
97
+ }
98
+ return { text, startOff, endOff: endOff + 1 } // end is inclusive
99
+ }
100
+
101
+ function ok(message) {
102
+ return { content: [{ type: 'text', text: message }] }
103
+ }
104
+
105
+ function fail(message) {
106
+ return { content: [{ type: 'text', text: message }], isError: true }
107
+ }
108
+
109
+ async function guarded(fn) {
110
+ try {
111
+ return await fn()
112
+ } catch (e) {
113
+ return fail(e instanceof PositionError ? e.message : `Error: ${e.message}`)
114
+ }
115
+ }
116
+
117
+ // @tephra-tools
118
+ const server = new McpServer({ name: 'tephra', version: '0.1.0' })
119
+
120
+ const rangeSchema = {
121
+ file: z.string().describe('Absolute path of the file to copy from'),
122
+ start_line: z.number().int().describe('Line of the first character to copy (1-indexed)'),
123
+ start_char: z.number().int().describe('Character position on start_line of the first character to copy (1-indexed)'),
124
+ end_line: z.number().int().describe('Line of the last character to copy (1-indexed, inclusive)'),
125
+ end_char: z.number().int().describe('Character position on end_line of the last character to copy (1-indexed, inclusive)'),
126
+ }
127
+
128
+ server.registerTool(
129
+ 'copy',
130
+ {
131
+ title: 'Copy range to clipboard',
132
+ description:
133
+ "Copy an exact character range from a file to Tephra's clipboard, byte-for-byte. " +
134
+ 'The range runs from (start_line, start_char) through (end_line, end_char), inclusive on both ends; ' +
135
+ 'newlines inside the range are included. Use this instead of retyping text you intend to move or duplicate. ' +
136
+ 'The clipboard is private to this session, not the operating system clipboard.',
137
+ inputSchema: rangeSchema,
138
+ },
139
+ async ({ file, start_line, start_char, end_line, end_char }) =>
140
+ guarded(async () => {
141
+ const { text, startOff, endOff } = await extractRange(file, start_line, start_char, end_line, end_char)
142
+ const snippet = text.slice(startOff, endOff)
143
+ clipboard = snippet
144
+ return ok(`Copied ${describe(snippet)} from ${file} (${start_line}:${start_char} → ${end_line}:${end_char}) to the clipboard.\n\nPreview:\n${preview(snippet)}`)
145
+ }),
146
+ )
147
+
148
+ server.registerTool(
149
+ 'cut',
150
+ {
151
+ title: 'Cut range to clipboard',
152
+ description:
153
+ 'Same as copy, but also removes the range from the source file. ' +
154
+ 'The range is inclusive on both ends. The file is written back immediately.',
155
+ inputSchema: rangeSchema,
156
+ },
157
+ async ({ file, start_line, start_char, end_line, end_char }) =>
158
+ guarded(async () => {
159
+ const { text, startOff, endOff } = await extractRange(file, start_line, start_char, end_line, end_char)
160
+ const snippet = text.slice(startOff, endOff)
161
+ clipboard = snippet
162
+ await writeFile(file, text.slice(0, startOff) + text.slice(endOff), 'utf8')
163
+ return ok(`Cut ${describe(snippet)} from ${file} (${start_line}:${start_char} → ${end_line}:${end_char}) to the clipboard. The file has been updated.\n\nPreview:\n${preview(snippet)}`)
164
+ }),
165
+ )
166
+
167
+ server.registerTool(
168
+ 'paste',
169
+ {
170
+ title: 'Paste clipboard into file',
171
+ description:
172
+ "Insert Tephra's clipboard contents into a file at an exact position. " +
173
+ 'The clipboard text is inserted before the character at (line, char); char may be one past the end of the line to append to it. ' +
174
+ 'Existing text is never overwritten — it shifts to make room.',
175
+ inputSchema: {
176
+ file: z.string().describe('Absolute path of the file to paste into'),
177
+ line: z.number().int().describe('Line to paste at (1-indexed)'),
178
+ char: z.number().int().describe('Character position to paste at (1-indexed; the clipboard is inserted before this character; line length + 1 means end of line)'),
179
+ },
180
+ },
181
+ async ({ file, line, char }) =>
182
+ guarded(async () => {
183
+ const clip = clipboard
184
+ if (clip.length === 0) return fail('The clipboard is empty — nothing to paste. Use copy or cut first.')
185
+ const text = await readFile(file, 'utf8')
186
+ const lines = indexLines(text)
187
+ const off = charOffset(text, lines, line, char, 'paste position', 1)
188
+ await writeFile(file, text.slice(0, off) + clip + text.slice(off), 'utf8')
189
+ const end = endPosition(line, char, clip)
190
+ return ok(`Pasted ${describe(clip)} into ${file} at ${line}:${char}. The inserted text now spans ${line}:${char} → ${end.line}:${end.char} (exclusive end).\n\nPreview:\n${preview(clip)}`)
191
+ }),
192
+ )
193
+
194
+ server.registerTool(
195
+ 'peek',
196
+ {
197
+ title: 'Peek at clipboard',
198
+ description: "Show what is currently on Tephra's clipboard (size and a preview) without modifying anything.",
199
+ inputSchema: {},
200
+ },
201
+ async () =>
202
+ guarded(async () => {
203
+ if (clipboard.length === 0) return ok('The clipboard is empty.')
204
+ return ok(`Clipboard holds ${describe(clipboard)}.\n\nPreview:\n${preview(clipboard)}`)
205
+ }),
206
+ )
207
+
208
+ // @tephra-main
209
+ const transport = new StdioServerTransport()
210
+ await server.connect(transport)