@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.
- package/README.md +40 -0
- package/package.json +33 -0
- 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)
|