copapers-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.
Files changed (3) hide show
  1. package/README.md +72 -0
  2. package/dist/cli.js +665 -0
  3. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # copapers-mcp
2
+
3
+ MCP server for [Copapers](https://copapers.com) — let your agents edit your Copapers
4
+ documents. An AI agent (Claude Code, Claude Desktop, Codex, …) can list, pull, edit, and push
5
+ **tracked suggestions**, attributed to your signed-in Copapers account.
6
+
7
+ It runs locally over stdio and talks to a Copapers server over HTTPS. You authenticate once
8
+ with the built-in `connect` tool — no API keys to manage.
9
+
10
+ ## Install
11
+
12
+ ### Claude Code
13
+
14
+ ```sh
15
+ claude mcp add copapers -s user -- npx -y copapers-mcp --server https://copapers.com
16
+ ```
17
+
18
+ ### Codex
19
+
20
+ Add to `~/.codex/config.toml`:
21
+
22
+ ```toml
23
+ [mcp_servers.copapers]
24
+ command = "npx"
25
+ args = ["-y", "copapers-mcp", "--server", "https://copapers.com"]
26
+ ```
27
+
28
+ ### Claude Desktop
29
+
30
+ Add to your `claude_desktop_config.json` (Settings → Developer → Edit Config):
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "copapers": {
36
+ "command": "npx",
37
+ "args": ["-y", "copapers-mcp", "--server", "https://copapers.com"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ ## Sign in
44
+
45
+ After the server is added, in your agent call the **`connect`** tool:
46
+
47
+ 1. It returns a URL (`https://copapers.com/?connect=<code>`) and a short code.
48
+ 2. Open the URL in the browser where you're **already signed in** to Copapers and approve.
49
+ 3. Call `connect` again to finish. The credential is saved to `~/.copapers/credentials.json`
50
+ (mode 0600) and refreshed automatically. One `connect` authenticates every agent on the
51
+ machine. `disconnect` signs out.
52
+
53
+ If any tool returns `not_authenticated`, just call `connect` and retry.
54
+
55
+ ## Tools
56
+
57
+ `list_documents`, `list_checkouts`, `pull_document`, `push_document`, `resolve_suggestion`,
58
+ `resolve_comment`, `connect`, `disconnect`.
59
+
60
+ The workflow is: `list_documents` → `pull_document` (writes the doc to a local Markdown file)
61
+ → edit that file with normal tools → `push_document` (the server diffs your edits and records
62
+ them as tracked suggestions). Never hand-write CriticMarkup; existing `{++ ++}` / `{-- --}`
63
+ spans in the pulled file are read-only.
64
+
65
+ ## Options
66
+
67
+ | Flag / env | Default | Purpose |
68
+ | --- | --- | --- |
69
+ | `--server <url>` / `COPAPERS_SERVER_URL` | `http://127.0.0.1:3077` | Copapers server to talk to |
70
+ | `--state-dir <dir>` / `COPAPERS_MCP_STATE_DIR` | `.copapers-mcp` | local checkout state |
71
+ | `COPAPERS_CONFIG_DIR` | `~/.copapers` | credential dir (set per-agent for a distinct identity) |
72
+ | `COPAPERS_ACCESS_TOKEN` | — | bearer-token override (CI); otherwise the stored credential is used |
package/dist/cli.js ADDED
@@ -0,0 +1,665 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../../src/mcp/copapers-mcp.js
4
+ import { createHash } from "node:crypto";
5
+ import { mkdir as mkdir2, readFile as readFile2, readdir, writeFile as writeFile2 } from "node:fs/promises";
6
+ import { join as join2, resolve } from "node:path";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+
11
+ // ../../src/cli/credentials.js
12
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ var EXPIRY_SKEW_MS = 3e4;
16
+ var NotAuthenticatedError = class extends Error {
17
+ constructor(message = "Not authenticated. Run the `connect` tool (or set COPAPERS_ACCESS_TOKEN) to sign in.") {
18
+ super(message);
19
+ this.name = "NotAuthenticatedError";
20
+ this.code = "not_authenticated";
21
+ }
22
+ };
23
+ function credentialsDir(env = process.env) {
24
+ return env.COPAPERS_CONFIG_DIR || join(homedir(), ".copapers");
25
+ }
26
+ function credentialsPath(env = process.env) {
27
+ return join(credentialsDir(env), "credentials.json");
28
+ }
29
+ async function loadCredentials(env = process.env) {
30
+ try {
31
+ return JSON.parse(await readFile(credentialsPath(env), "utf8"));
32
+ } catch (error) {
33
+ if (error.code === "ENOENT") return null;
34
+ throw error;
35
+ }
36
+ }
37
+ async function saveCredentials(credentials, env = process.env) {
38
+ const path = credentialsPath(env);
39
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
40
+ await writeFile(path, `${JSON.stringify(credentials, null, 2)}
41
+ `, { mode: 384 });
42
+ await chmod(path, 384).catch(() => {
43
+ });
44
+ }
45
+ async function clearCredentials(env = process.env) {
46
+ await rm(credentialsPath(env), { force: true });
47
+ }
48
+ async function resolveBearerToken({ env = process.env, fetchImpl = fetch, now = Date.now } = {}) {
49
+ if (env.COPAPERS_ACCESS_TOKEN) return env.COPAPERS_ACCESS_TOKEN;
50
+ const credentials = await loadCredentials(env);
51
+ if (!credentials) return null;
52
+ const valid = credentials.accessToken && credentials.expiresAt && credentials.expiresAt - EXPIRY_SKEW_MS > now();
53
+ if (valid) return credentials.accessToken;
54
+ if (credentials.refreshToken && credentials.supabaseUrl) {
55
+ const session = await refreshSession({ ...credentials, fetchImpl });
56
+ const next = {
57
+ ...credentials,
58
+ accessToken: session.access_token,
59
+ refreshToken: session.refresh_token ?? credentials.refreshToken,
60
+ expiresAt: expiresAtMs(session, now)
61
+ };
62
+ await saveCredentials(next, env);
63
+ return next.accessToken;
64
+ }
65
+ return credentials.accessToken ?? null;
66
+ }
67
+ function notAuthenticatedFrom(status, body) {
68
+ if (status !== 401) return null;
69
+ const detail = body?.error?.message ? `${body.error.message} ` : "";
70
+ return new NotAuthenticatedError(`${detail}Run the \`connect\` tool (or set COPAPERS_ACCESS_TOKEN) to authenticate.`);
71
+ }
72
+ async function refreshSession({ supabaseUrl, anonKey, refreshToken, fetchImpl = fetch }) {
73
+ const response = await fetchImpl(`${stripTrailingSlash(supabaseUrl)}/auth/v1/token?grant_type=refresh_token`, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/json", ...anonKey ? { apikey: anonKey, authorization: `Bearer ${anonKey}` } : {} },
76
+ body: JSON.stringify({ refresh_token: refreshToken })
77
+ });
78
+ if (!response.ok) {
79
+ throw new NotAuthenticatedError("Your session has expired. Run the `connect` tool to sign in again.");
80
+ }
81
+ return response.json();
82
+ }
83
+ function expiresAtMs(session, now) {
84
+ if (session.expires_at) return session.expires_at * 1e3;
85
+ if (session.expires_in) return now() + session.expires_in * 1e3;
86
+ return now() + 36e5;
87
+ }
88
+ function stripTrailingSlash(value) {
89
+ return String(value ?? "").replace(/\/+$/, "");
90
+ }
91
+
92
+ // ../../src/mcp/copapers-mcp.js
93
+ var DEFAULT_COPAPERS_SERVER_URL = "http://127.0.0.1:3077";
94
+ var DEFAULT_COPAPERS_MCP_STATE_DIR = ".copapers-mcp";
95
+ var COPAPERS_MCP_CHECKOUT_STATE_KIND = "copapers.mcp-checkout-state.v1";
96
+ function createCopapersMcpTools(options2 = {}) {
97
+ const config = normalizeConfig(options2);
98
+ const connectState = { grant: null };
99
+ return {
100
+ listDocuments: (args) => listDocuments({ ...config, ...args ?? {} }),
101
+ listCheckouts: (args) => listCheckouts({ ...config, ...args ?? {} }),
102
+ pullDocument: (args) => pullDocument({ ...config, ...args ?? {} }),
103
+ pushDocument: (args) => pushDocument({ ...config, ...args ?? {} }),
104
+ resolveSuggestion: (args) => resolveSuggestion({ ...config, ...args ?? {} }),
105
+ resolveComment: (args) => resolveComment({ ...config, ...args ?? {} }),
106
+ connect: () => connectAgent({ ...config, connectState }),
107
+ disconnect: () => disconnectAgent(config)
108
+ };
109
+ }
110
+ function createCopapersMcpServer(options2 = {}) {
111
+ const server = new McpServer(
112
+ { name: "copapers", version: "0.1.0" },
113
+ {
114
+ instructions: [
115
+ "Use this Copapers server for Copapers documents and app doc ids such as report_123 or fixture-doc. Do not use Google Docs/Drive for these ids unless the user explicitly says Google.",
116
+ "A Copapers document link looks like https://<host>/d/<docId> \u2014 the id is the last path segment. If the user pastes such a link, pass it straight to pull_document (it extracts the id); you do NOT need list_documents first.",
117
+ "Authentication: these tools call a Copapers server with your stored credential. If a tool returns a not_authenticated error, call the `connect` tool \u2014 it returns a URL and a short code for the user to open and approve in their browser (where they are already signed in); relay those to the user, then call `connect` again to finish and retry the original tool. One connect authenticates every Copapers session on this machine (no per-project sign-in); `disconnect` signs out. Your changes are attributed to your signed-in account and this tool \u2014 you never pass an actor or author name.",
118
+ "Workflow: list_documents -> pull_document -> edit the returned documentPath file with your normal file tools -> push_document. These tools only manage checkout state; the document text lives in documentPath. Edit only that file; never edit the checkout state (.json) files.",
119
+ 'Edit the file as an ordinary Markdown document. To change the DOCUMENT, do NOT write suggestion CriticMarkup yourself \u2014 never type {++ ++}, {-- --}, {~~ ~> ~~}, or {^^ ^^}. Just insert, delete, and rewrite text normally; on push the server diffs your edits against the document. Depending on your permission on the doc those edits either apply DIRECTLY (a clean edit) or are recorded as tracked SUGGESTIONS for you \u2014 pull_document reports which under agentAuthority.mode ("direct" or "suggest"), and the push result confirms it as appliedMode. Either way you never hand-write suggestion syntax; that corrupts the diff. To deliberately record a suggestion even when you are allowed to edit directly, pass suggesting:true to push_document.',
120
+ "Resolving existing suggestions is an EXPLICIT action: use resolve_suggestion to accept or reject a pending change \u2014 target one by its changeId (from pull_document's pendingSuggestions list), or every change by an actor, or all. Whether you may accept depends on your role (pull_document's agentAuthority): editors/owners accept or reject anyone's; a suggest-only agent may only reject its OWN. Editing the text near a suggestion never silently accepts or rejects it \u2014 only resolve_suggestion does.",
121
+ "COMMENTS use the ONE kind of CriticMarkup you may type. To leave a comment, add a NEW span {>> your comment <<} where it applies. To reply to an existing thread, add a NEW span {>> reply c_ID: your reply <<} where c_ID is the bare thread id shown in that comment block (e.g. {>> reply c_7gk2: agreed, trimmed <<}) \u2014 write the id exactly as shown, with NO surrounding < > brackets. To RESOLVE a thread, use the resolve_comment tool with its thread id \u2014 whether you may resolve depends on your role (pull_document's agentAuthority): editors/owners resolve anyone's thread, a suggest-only agent only threads it authored. You CANNOT reopen or delete a comment (a person does that in the editor). Never edit, move, or retype an existing {>> <<} block \u2014 like every other span it is read-only; only ADD new ones.",
122
+ 'Existing spans are already rendered inline in the file as CriticMarkup: suggestions {++inserted++}, {--deleted--}, {~~old~>new~~}, {^^formatted^^}, and comment threads {>>\\ncomment c_\u2026 (open) \u2014 re: "\u2026"\\n@Name: \u2026\\n<<}. They are obvious on a normal read \u2014 do not grep for them or compute character offsets. Every existing span is READ-ONLY: never edit, delete, reorder, or retype one \u2014 changing even one character of any span makes the WHOLE push fail (it is rejected, nothing is applied). Make all your edits in the plain text around the spans (and add new {>> <<} comments anywhere); if the text you need to change sits inside a span, re-pull and edit elsewhere.',
123
+ "Supported content: headings, paragraphs, bold/italic/inline-code/links, bullet/numbered/task lists, blockquotes, fenced code blocks, GFM tables, horizontal rules, and images. NOT supported: raw HTML and HTML comments, merged table cells (colspan/rowspan), and nested tables \u2014 express those with the supported elements instead. A push containing unsupported Markdown is rejected with an unsupported_markdown error.",
124
+ "Review granularity for a pending change to a code block, table, or horizontal rule is WHOLE-BLOCK: it appears as ONE multi-line, line-anchored CriticMarkup span \u2014 {++\\n\u2026\\n++}, {--\\n\u2026\\n--}, or {~~\\n old \\n~>\\n new \\n~~} with each delimiter alone on its own line. Treat the whole span as one read-only unit (the same rule as inline spans): never edit inside it; edit the text around it or re-pull. The block content between the delimiters may contain ```, |, ~>, etc. \u2014 that is the literal code/table, not structure."
125
+ ].join("\n")
126
+ }
127
+ );
128
+ const tools = createCopapersMcpTools({
129
+ ...options2,
130
+ getClientName: () => server.server?.getClientVersion?.()?.name
131
+ });
132
+ server.registerTool(
133
+ "list_documents",
134
+ {
135
+ title: "Copapers: List Documents",
136
+ description: "List Copapers app documents. Use first when the user asks for a Copapers doc, a report doc, or a doc id like report_123/fixture-doc; this is not Google Docs.",
137
+ inputSchema: z.object({
138
+ query: z.string().optional().describe("Optional case-insensitive Copapers document id or title filter, e.g. report_123.")
139
+ }),
140
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
141
+ },
142
+ (args) => toMcpResult(() => tools.listDocuments(args))
143
+ );
144
+ server.registerTool(
145
+ "list_checkouts",
146
+ {
147
+ title: "Copapers: List Checkouts",
148
+ description: "List local Copapers MCP checkouts created by pull_document.",
149
+ inputSchema: z.object({
150
+ doc_id: z.string().min(1).optional().describe("Optional filter for a Copapers document id.")
151
+ }),
152
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
153
+ },
154
+ (args) => toMcpResult(() => tools.listCheckouts(args))
155
+ );
156
+ server.registerTool(
157
+ "pull_document",
158
+ {
159
+ title: "Copapers: Pull Document",
160
+ description: "Pull a Copapers document by doc_id into a local Markdown checkout and return documentPath for file editing. The file is the full document with any existing pending suggestions shown inline as CriticMarkup context; edit it as plain Markdown (do not write CriticMarkup yourself). The result reports agentAuthority (whether your pushes apply directly or as suggestions, and whether you may resolve others' suggestions) and a pendingSuggestions list (changeId + author + preview) you can target with resolve_suggestion.",
161
+ inputSchema: z.object({
162
+ doc_id: z.string().min(1).describe("Copapers document id (e.g. report_123 or fixture-doc), or a pasted document URL like https://host/d/<docId> \u2014 the id is extracted from it."),
163
+ actor: z.string().min(1).optional().describe("Deprecated/ignored: identity comes from your signed-in credential, not this hint.")
164
+ }),
165
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
166
+ },
167
+ (args) => toMcpResult(() => tools.pullDocument(args))
168
+ );
169
+ server.registerTool(
170
+ "push_document",
171
+ {
172
+ title: "Copapers: Push Document",
173
+ description: "Push the edited checkout file. Your changes are diffed against the document and applied as a DIRECT edit or recorded as tracked SUGGESTIONS depending on your permission (pull_document's agentAuthority.mode tells you in advance; the result's appliedMode confirms what happened, and downgraded:true means a direct edit fell back to a suggestion for lack of edit access). Pass suggesting:true to force a tracked suggestion even when you could edit directly. You never write suggestion CriticMarkup yourself, but you MAY add new {>> comment <<} spans (and {>> reply c_ID: \u2026 <<} replies, using the bare thread id with no < > brackets) \u2014 those are recorded as comments. The whole push is rejected if you changed any EXISTING span (leave every {.. ..} and {>> <<} span exactly as-is) or used unsupported Markdown (raw HTML, HTML comments, merged table cells, or nested tables).",
174
+ inputSchema: z.object({
175
+ checkout_id: z.string().min(1).describe("Checkout id returned by pull_document."),
176
+ summary: z.string().optional().describe("Short summary of the change you are pushing."),
177
+ suggesting: z.boolean().optional().describe("Force this push to land as a tracked suggestion instead of a direct edit. Default: a direct edit when you have edit access, otherwise a suggestion."),
178
+ doc_id: z.string().min(1).optional().describe("Optional safety check; must match the checkout document id.")
179
+ }),
180
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
181
+ },
182
+ (args) => toMcpResult(() => tools.pushDocument(args))
183
+ );
184
+ server.registerTool(
185
+ "resolve_suggestion",
186
+ {
187
+ title: "Copapers: Resolve Suggestion",
188
+ description: "Accept or reject EXISTING pending suggestions on a Copapers document. Target one change by its changeId (from pull_document's pendingSuggestions), every change by an actor, or all of them. Accepting requires editor/owner authority; a suggest-only agent may only reject its own suggestions. This is the only way to resolve a suggestion \u2014 editing the text never does.",
189
+ inputSchema: z.object({
190
+ doc_id: z.string().min(1).describe("Copapers document id whose suggestion(s) to resolve."),
191
+ action: z.enum(["accept", "reject"]).describe("accept = apply the change into the clean document; reject = discard it."),
192
+ change_id: z.string().min(1).optional().describe("Resolve a single change by its changeId (from pull_document pendingSuggestions)."),
193
+ actor: z.string().min(1).optional().describe("Resolve every pending change authored by this actorId."),
194
+ all: z.boolean().optional().describe("Resolve ALL pending suggestions on the document. Requires editor/owner authority.")
195
+ }),
196
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
197
+ },
198
+ (args) => toMcpResult(() => tools.resolveSuggestion(args))
199
+ );
200
+ server.registerTool(
201
+ "resolve_comment",
202
+ {
203
+ title: "Copapers: Resolve Comment",
204
+ description: "Mark a comment THREAD resolved (done) by its c_\u2026 id, shown in the {>> <<} comment block on pull_document. Resolve-only: you cannot reopen or delete a comment (a person does that in the editor). Resolving anyone's thread requires editor/owner authority; a suggest-only agent may resolve only threads it authored (pull_document's agentAuthority.canReview / reviewOwnOnly tell you).",
205
+ inputSchema: z.object({
206
+ doc_id: z.string().min(1).describe("Copapers document id (e.g. report_123), or a pasted document URL \u2014 the id is extracted from it."),
207
+ thread_id: z.string().min(1).describe("The comment thread id (c_\u2026 ) shown in the comment block on pull.")
208
+ }),
209
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
210
+ },
211
+ (args) => toMcpResult(() => tools.resolveComment(args))
212
+ );
213
+ server.registerTool(
214
+ "connect",
215
+ {
216
+ title: "Copapers: Connect (sign in)",
217
+ description: "Authenticate this machine with Copapers \u2014 run this when a tool returns not_authenticated, or when the user asks to sign in/connect. It returns a URL and a short code; the user opens the URL in the browser where they are signed in to Copapers and approves. Relay the URL + code to the user, then call connect again to finish. One connect authenticates every Copapers MCP/CLI session on this machine \u2014 no per-project sign-in.",
218
+ inputSchema: z.object({}),
219
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
220
+ },
221
+ () => toMcpResult(() => tools.connect())
222
+ );
223
+ server.registerTool(
224
+ "disconnect",
225
+ {
226
+ title: "Copapers: Disconnect (sign out)",
227
+ description: "Remove this machine's stored Copapers credential. This signs out every Copapers MCP/CLI session on this machine; run connect to sign back in.",
228
+ inputSchema: z.object({}),
229
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }
230
+ },
231
+ () => toMcpResult(() => tools.disconnect())
232
+ );
233
+ return server;
234
+ }
235
+ async function runCopapersMcpServer(options2 = {}) {
236
+ const server = createCopapersMcpServer(options2);
237
+ const transport = new StdioServerTransport();
238
+ await server.connect(transport);
239
+ return server;
240
+ }
241
+ async function listDocuments({ serverUrl, query, resolveToken }) {
242
+ const response = await fetchJson(new URL("/documents", serverUrl), { token: await resolveToken?.() });
243
+ const normalizedQuery = query?.trim().toLowerCase();
244
+ const documents = normalizedQuery ? response.documents.filter((document) => document.docId.toLowerCase().includes(normalizedQuery) || (document.title ?? "").toLowerCase().includes(normalizedQuery)) : response.documents;
245
+ return {
246
+ kind: "copapers.mcp.document-list",
247
+ serverUrl,
248
+ query: query ?? null,
249
+ count: documents.length,
250
+ documents
251
+ };
252
+ }
253
+ async function listCheckouts({ stateDir, doc_id: docId }) {
254
+ const checkoutsDir = join2(stateDir, "checkouts");
255
+ let names;
256
+ try {
257
+ names = await readdir(checkoutsDir);
258
+ } catch (error) {
259
+ if (error.code !== "ENOENT") throw error;
260
+ names = [];
261
+ }
262
+ const checkouts = [];
263
+ for (const checkoutId of names.sort()) {
264
+ if (!isSafeCheckoutId(checkoutId)) continue;
265
+ const state = await readCheckoutState({ stateDir, checkoutId });
266
+ if (docId && state.docId !== docId) continue;
267
+ checkouts.push({
268
+ ...summarizeCheckoutState(state),
269
+ local: await inspectCheckoutLocalState(state)
270
+ });
271
+ }
272
+ return {
273
+ kind: "copapers.mcp.checkout-list",
274
+ count: checkouts.length,
275
+ checkouts
276
+ };
277
+ }
278
+ async function pullDocument({ serverUrl, stateDir, defaultActor, resolveToken, doc_id: rawDocId, actor }) {
279
+ const docId = extractDocId(rawDocId);
280
+ const checkout = await fetchJson(checkoutUrl(serverUrl, docId, actor ?? defaultActor), { token: await resolveToken?.() });
281
+ const paths = checkoutPaths(stateDir, checkout.manifest.checkoutId);
282
+ const state = {
283
+ kind: COPAPERS_MCP_CHECKOUT_STATE_KIND,
284
+ serverUrl,
285
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
286
+ docId: checkout.docId,
287
+ title: checkout.title,
288
+ version: checkout.version,
289
+ checkoutId: checkout.manifest.checkoutId,
290
+ checkoutDir: paths.checkoutDir,
291
+ documentPath: paths.documentPath,
292
+ originalPath: paths.originalPath,
293
+ originalMarkdown: checkout.markdown,
294
+ manifest: checkout.manifest
295
+ };
296
+ await writeCheckoutFiles({ stateDir, state, markdown: checkout.markdown });
297
+ return {
298
+ kind: "copapers.mcp.checkout",
299
+ ...summarizeCheckoutState(state),
300
+ // What this agent may do on the doc, and the addressable pending changes it can resolve.
301
+ agentAuthority: checkout.agentAuthority ?? null,
302
+ pendingSuggestions: checkout.pendingSuggestions ?? []
303
+ };
304
+ }
305
+ async function pushDocument({ serverUrl, stateDir, defaultActor, getClientName, resolveToken, checkout_id: checkoutId, summary = "", doc_id: rawDocId, suggesting }) {
306
+ const state = await readCheckoutState({ stateDir, checkoutId });
307
+ const editedMarkdown = await readFile2(state.documentPath, "utf8");
308
+ const docId = rawDocId ? extractDocId(rawDocId) : rawDocId;
309
+ if (docId && docId !== state.docId) {
310
+ throw new Error(`Checkout ${checkoutId} belongs to ${state.docId}, not ${docId}.`);
311
+ }
312
+ const result = await fetchJson(pushUrl(state.serverUrl ?? serverUrl, state.docId), {
313
+ method: "POST",
314
+ token: await resolveToken?.(),
315
+ body: JSON.stringify({
316
+ manifest: state.manifest,
317
+ originalMarkdown: state.originalMarkdown,
318
+ editedMarkdown,
319
+ summary,
320
+ // Opt this push into a tracked suggestion even when the agent could edit directly. Only
321
+ // `true` is meaningful (the server defaults to a direct edit when allowed); omit otherwise.
322
+ ...suggesting === true ? { suggesting: true } : {},
323
+ // Display-only hint: the server groups this push under ${userId}::mcp::<tool>. The tool
324
+ // name is auto-read from the MCP clientInfo handshake (getClientName), falling back to the
325
+ // configured name only when there is no client to read it from. Identity (the userId) is
326
+ // the bearer token's principal, never this hint.
327
+ client: { kind: "mcp", name: getClientName?.() ?? defaultActor }
328
+ })
329
+ });
330
+ return summarizePushResult(result);
331
+ }
332
+ async function resolveSuggestion({ serverUrl, resolveToken, doc_id: docId, action, change_id: changeId, actor, all }) {
333
+ if (action !== "accept" && action !== "reject") {
334
+ throw new Error("resolve_suggestion action must be 'accept' or 'reject'.");
335
+ }
336
+ const filter = changeId ? { changeId } : actor ? { actor } : all === true ? { all: true } : null;
337
+ if (!filter) {
338
+ throw new Error("resolve_suggestion requires one target: change_id, actor, or all:true.");
339
+ }
340
+ const result = await fetchJson(resolveUrl(serverUrl, docId), {
341
+ method: "POST",
342
+ token: await resolveToken?.(),
343
+ body: JSON.stringify({ filter, action })
344
+ });
345
+ return {
346
+ kind: "copapers.mcp.resolve-result",
347
+ docId,
348
+ action: result.action ?? action,
349
+ // How many pending changes this action resolved (the matched changes in scope).
350
+ resolvedChanges: result.resolution?.unresolvedChanges ?? null
351
+ };
352
+ }
353
+ async function resolveComment({ serverUrl, defaultActor, getClientName, resolveToken, doc_id: rawDocId, thread_id: threadId }) {
354
+ const docId = rawDocId ? extractDocId(rawDocId) : rawDocId;
355
+ if (!docId) throw new Error("resolve_comment requires doc_id.");
356
+ if (!threadId) throw new Error("resolve_comment requires thread_id (the c_\u2026 id shown in the comment block).");
357
+ const result = await fetchJson(resolveCommentUrl(serverUrl, docId, threadId), {
358
+ method: "POST",
359
+ token: await resolveToken?.(),
360
+ // resolved:true (resolve, not reopen) is implicit server-side; client identity attributes the
361
+ // resolver to this agent (the name is auto-read from the MCP clientInfo handshake).
362
+ body: JSON.stringify({ resolved: true, client: { kind: "mcp", name: getClientName?.() ?? defaultActor } })
363
+ });
364
+ return {
365
+ kind: "copapers.mcp.resolve-comment-result",
366
+ docId,
367
+ threadId,
368
+ resolved: result.thread?.status === "resolved"
369
+ };
370
+ }
371
+ async function connectAgent({ serverUrl, saveCredentials: saveCredentials2, sleep, now, connectWaitMs, connectState, getClientName }) {
372
+ const existing = connectState.grant;
373
+ if (existing && existing.deadline > now()) {
374
+ const outcome = await pollForApproval({ serverUrl, grant: existing, sleep, now, waitMs: connectWaitMs });
375
+ if (outcome.status === "approved") {
376
+ await saveCredentials2(outcome.credential);
377
+ connectState.grant = null;
378
+ return connectedResult(outcome.credential);
379
+ }
380
+ if (outcome.status === "pending") return pendingConnectResult(existing);
381
+ connectState.grant = null;
382
+ }
383
+ const clientLabel = typeof getClientName === "function" ? getClientName() : null;
384
+ const start = await fetchJson(new URL("/auth/device/start", serverUrl), {
385
+ method: "POST",
386
+ body: JSON.stringify({ client_label: clientLabel ?? null })
387
+ });
388
+ connectState.grant = {
389
+ deviceCode: start.device_code,
390
+ userCode: start.user_code,
391
+ verificationUrl: start.verification_url,
392
+ verificationUrlComplete: start.verification_url_complete,
393
+ interval: start.interval,
394
+ deadline: now() + (start.expires_in ?? 600) * 1e3
395
+ };
396
+ return pendingConnectResult(connectState.grant);
397
+ }
398
+ async function pollForApproval({ serverUrl, grant, sleep, now, waitMs }) {
399
+ const deadline = Math.min(now() + Math.max(waitMs, 0), grant.deadline);
400
+ const intervalMs = Math.max((grant.interval ?? 3) * 1e3, 500);
401
+ for (; ; ) {
402
+ const poll = await fetchJson(new URL("/auth/device/poll", serverUrl), {
403
+ method: "POST",
404
+ body: JSON.stringify({ device_code: grant.deviceCode })
405
+ });
406
+ if (poll.status === "approved") return { status: "approved", credential: poll.credential };
407
+ if (poll.status === "expired") return { status: "expired" };
408
+ if (now() >= deadline) return { status: "pending" };
409
+ await sleep(Math.min(intervalMs, Math.max(deadline - now(), 0)));
410
+ }
411
+ }
412
+ function pendingConnectResult(grant) {
413
+ const url = grant.verificationUrlComplete ?? grant.verificationUrl;
414
+ return {
415
+ kind: "copapers.mcp.connect",
416
+ status: "pending",
417
+ verificationUrl: url,
418
+ userCode: grant.userCode,
419
+ message: `To connect, open ${url} in the browser where you're signed in to copapers and approve the request (code ${grant.userCode}). Then call connect again to finish \u2014 I'll wait for your approval.`
420
+ };
421
+ }
422
+ function connectedResult(credential) {
423
+ return {
424
+ kind: "copapers.mcp.connect",
425
+ status: "connected",
426
+ email: credential.email ?? null,
427
+ message: `Connected to copapers${credential.email ? ` as ${credential.email}` : ""}. Pulls and pushes are now authenticated on this machine \u2014 you won't need to connect again.`
428
+ };
429
+ }
430
+ async function disconnectAgent({ clearCredentials: clearCredentials2 }) {
431
+ await clearCredentials2();
432
+ return {
433
+ kind: "copapers.mcp.connect",
434
+ status: "disconnected",
435
+ message: "Disconnected from copapers on this machine. Call connect to sign in again."
436
+ };
437
+ }
438
+ function summarizePushResult(result) {
439
+ const conflicts = result.conflicts ?? [];
440
+ return {
441
+ kind: "copapers.mcp.push-result",
442
+ status: result.status ?? null,
443
+ // 'direct' (a clean edit landed) or 'suggest' (a tracked suggestion); downgraded means a
444
+ // would-be direct edit fell back to a suggestion for lack of edit access on the doc.
445
+ appliedMode: result.appliedMode ?? null,
446
+ ...result.downgraded ? { downgraded: true } : {},
447
+ proposalId: result.proposalId,
448
+ ...conflicts.length > 0 ? { conflicts } : {}
449
+ };
450
+ }
451
+ function summarizeCheckoutState(state) {
452
+ return {
453
+ checkoutId: state.checkoutId,
454
+ docId: state.docId,
455
+ title: state.title,
456
+ documentPath: state.documentPath,
457
+ protectedSpanCount: state.manifest.review?.protectedSpanCount ?? 0
458
+ };
459
+ }
460
+ async function inspectCheckoutLocalState(state) {
461
+ try {
462
+ const editedMarkdown = await readFile2(state.documentPath, "utf8");
463
+ const editedMarkdownHash = sha256(editedMarkdown);
464
+ return {
465
+ status: editedMarkdownHash === state.manifest.originalMarkdownHash ? "clean" : "dirty",
466
+ editedMarkdownHash
467
+ };
468
+ } catch (error) {
469
+ return {
470
+ status: "unknown",
471
+ message: error.message
472
+ };
473
+ }
474
+ }
475
+ async function toMcpResult(callback) {
476
+ try {
477
+ const value = await callback();
478
+ return jsonToolResult(value);
479
+ } catch (error) {
480
+ return {
481
+ content: [{ type: "text", text: formatError(error) }],
482
+ isError: true
483
+ };
484
+ }
485
+ }
486
+ function jsonToolResult(value) {
487
+ return {
488
+ content: [{ type: "text", text: `${JSON.stringify(value)}
489
+ ` }],
490
+ structuredContent: value
491
+ };
492
+ }
493
+ async function fetchJson(url, options2 = {}) {
494
+ const { token, headers: extraHeaders, ...rest } = options2;
495
+ let response;
496
+ try {
497
+ response = await fetch(url, {
498
+ ...rest,
499
+ headers: {
500
+ accept: "application/json",
501
+ ...rest.body ? { "content-type": "application/json" } : {},
502
+ ...token ? { authorization: `Bearer ${token}` } : {},
503
+ ...extraHeaders ?? {}
504
+ }
505
+ });
506
+ } catch (error) {
507
+ throw new Error(`Could not reach copapers server at ${url.origin}. Run "copapers serve" or set COPAPERS_SERVER_URL. ${error.message}`);
508
+ }
509
+ const text = await response.text();
510
+ const body = text ? JSON.parse(text) : null;
511
+ if (!response.ok) {
512
+ const authError = notAuthenticatedFrom(response.status, body);
513
+ if (authError) throw authError;
514
+ const detail = body?.error;
515
+ const message = detail ? `${detail.message} (${detail.code})` : `Server returned HTTP ${response.status}.`;
516
+ const error = new Error(message);
517
+ error.httpStatus = response.status;
518
+ error.body = body;
519
+ throw error;
520
+ }
521
+ return body;
522
+ }
523
+ async function writeCheckoutFiles({ stateDir, state, markdown }) {
524
+ const paths = checkoutPaths(stateDir, state.checkoutId);
525
+ await mkdir2(paths.checkoutDir, { recursive: true });
526
+ await writeFile2(paths.documentPath, markdown);
527
+ await writeFile2(paths.originalPath, markdown);
528
+ await writeFile2(paths.statePath, `${JSON.stringify(state, null, 2)}
529
+ `);
530
+ }
531
+ async function readCheckoutState({ stateDir, checkoutId }) {
532
+ const path = checkoutPaths(stateDir, checkoutId).statePath;
533
+ const state = JSON.parse(await readFile2(path, "utf8"));
534
+ if (state.kind !== COPAPERS_MCP_CHECKOUT_STATE_KIND) {
535
+ throw new Error(`Invalid checkout state file for ${checkoutId}.`);
536
+ }
537
+ return state;
538
+ }
539
+ function checkoutPaths(stateDir, checkoutId) {
540
+ assertSafeCheckoutId(checkoutId);
541
+ const checkoutDir = join2(stateDir, "checkouts", checkoutId);
542
+ return {
543
+ checkoutDir,
544
+ documentPath: join2(checkoutDir, "document.md"),
545
+ originalPath: join2(checkoutDir, "original.md"),
546
+ statePath: join2(checkoutDir, "checkout.json")
547
+ };
548
+ }
549
+ function assertSafeCheckoutId(checkoutId) {
550
+ if (!isSafeCheckoutId(checkoutId)) {
551
+ throw new Error(`Invalid checkout id "${checkoutId}".`);
552
+ }
553
+ }
554
+ function isSafeCheckoutId(checkoutId) {
555
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(checkoutId);
556
+ }
557
+ function checkoutUrl(serverUrl, docId, actor) {
558
+ const url = documentUrl(serverUrl, docId, "/checkout");
559
+ url.searchParams.set("actor", actor);
560
+ return url;
561
+ }
562
+ function pushUrl(serverUrl, docId) {
563
+ return documentUrl(serverUrl, docId, "/push");
564
+ }
565
+ function resolveUrl(serverUrl, docId) {
566
+ return documentUrl(serverUrl, docId, "/suggestions/resolve");
567
+ }
568
+ function resolveCommentUrl(serverUrl, docId, threadId) {
569
+ return documentUrl(serverUrl, docId, `/comments/${encodeURIComponent(threadId)}/resolve`);
570
+ }
571
+ function documentUrl(serverUrl, docId, suffix = "") {
572
+ return new URL(`/documents/${encodeURIComponent(docId)}${suffix}`, serverUrl);
573
+ }
574
+ function normalizeConfig(options2) {
575
+ return {
576
+ serverUrl: stripTrailingSlash2(options2.serverUrl ?? process.env.COPAPERS_SERVER_URL ?? DEFAULT_COPAPERS_SERVER_URL),
577
+ stateDir: resolve(process.cwd(), options2.stateDir ?? process.env.COPAPERS_MCP_STATE_DIR ?? DEFAULT_COPAPERS_MCP_STATE_DIR),
578
+ // The grouping-label fallback used only when there is no MCP client handshake to read the
579
+ // tool name from (e.g. the tools invoked directly). With a real client, getClientName wins.
580
+ defaultActor: options2.actor ?? process.env.COPAPERS_ACTOR ?? "mcp-agent",
581
+ // Reads clientInfo.name from the live MCP handshake (set by createCopapersMcpServer).
582
+ getClientName: options2.getClientName ?? null,
583
+ // Resolves the bearer token (env hatch → stored credential, refreshing as needed). Override
584
+ // in tests. The bearer authenticates every server call; identity is the token's principal.
585
+ resolveToken: options2.resolveToken ?? (() => resolveBearerToken()),
586
+ // `connect`/`disconnect` write the shared machine-wide credential. Injectable for tests.
587
+ saveCredentials: options2.saveCredentials ?? ((credential) => saveCredentials(credential)),
588
+ clearCredentials: options2.clearCredentials ?? (() => clearCredentials()),
589
+ sleep: options2.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms))),
590
+ now: options2.now ?? Date.now,
591
+ // How long a connect RESUME call waits inline for approval before returning "still pending"
592
+ // (capped by the grant's expiry). The first connect call always returns the URL immediately.
593
+ // 0 in tests → a single poll, no sleeping.
594
+ connectWaitMs: options2.connectWaitMs ?? 9e4
595
+ };
596
+ }
597
+ function stripTrailingSlash2(value) {
598
+ return value.replace(/\/+$/, "");
599
+ }
600
+ function extractDocId(input) {
601
+ if (typeof input !== "string") return input;
602
+ const value = input.trim();
603
+ const match = value.match(/\/d\/([^/?#]+)/);
604
+ return match ? decodeURIComponent(match[1]) : value;
605
+ }
606
+ function sha256(text) {
607
+ return createHash("sha256").update(text).digest("hex");
608
+ }
609
+ function formatError(error) {
610
+ if (error?.code === "not_authenticated") {
611
+ return "not_authenticated. Call the `connect` tool now to sign in \u2014 it returns a URL and a short code for the user to open and approve in their browser, then call `connect` again to finish and retry this tool. Do NOT tell the user to authenticate elsewhere or give up; `connect` is the way to authenticate and you can call it directly.";
612
+ }
613
+ if (error?.body?.error) {
614
+ return JSON.stringify(error.body.error);
615
+ }
616
+ return error?.message ?? String(error);
617
+ }
618
+
619
+ // ../../bin/copapers-mcp.js
620
+ var options = parseOptions(process.argv.slice(2));
621
+ if (options.help) {
622
+ printHelp();
623
+ process.exit(0);
624
+ }
625
+ runCopapersMcpServer(options).catch((error) => {
626
+ console.error(`copapers-mcp: ${error.message}`);
627
+ process.exit(1);
628
+ });
629
+ function parseOptions(args) {
630
+ return {
631
+ help: hasFlag(args, "--help") || hasFlag(args, "-h"),
632
+ serverUrl: optionValue(args, "--server"),
633
+ stateDir: optionValue(args, "--state-dir"),
634
+ actor: optionValue(args, "--actor")
635
+ };
636
+ }
637
+ function optionValue(args, name) {
638
+ const index = args.indexOf(name);
639
+ if (index === -1) return void 0;
640
+ return args[index + 1];
641
+ }
642
+ function hasFlag(args, name) {
643
+ return args.includes(name);
644
+ }
645
+ function printHelp() {
646
+ console.log(`Usage:
647
+ copapers-mcp [--server <url>] [--state-dir <dir>]
648
+
649
+ Auth:
650
+ Call the "connect" tool to sign in: it returns a URL + short code to approve in your browser
651
+ (where you're signed in), and stores a credential shared by every copapers session on this
652
+ machine. "disconnect" signs out. Suggestions are attributed to your signed-in account and this
653
+ MCP client's name \u2014 the agent passes no actor.
654
+
655
+ Environment:
656
+ COPAPERS_SERVER_URL Default copapers server URL
657
+ COPAPERS_MCP_STATE_DIR Directory for MCP checkout state
658
+ COPAPERS_CONFIG_DIR Credential dir (default ~/.copapers; set per-MCP for a distinct identity)
659
+ COPAPERS_ACCESS_TOKEN Bearer token override (otherwise the stored credential is used)
660
+
661
+ Example:
662
+ node bin/copapers.js serve
663
+ copapers-mcp --server http://127.0.0.1:3077
664
+ `);
665
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "copapers-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Copapers — let your agents edit your Copapers documents.",
5
+ "homepage": "https://copapers.com",
6
+ "author": "Andy Bromberg <andy@andybromberg.com>",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "bin": {
10
+ "copapers-mcp": "dist/cli.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "build": "node build.js",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "modelcontextprotocol",
26
+ "copapers",
27
+ "claude",
28
+ "codex"
29
+ ],
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.29.0",
32
+ "zod": "^3.25.76"
33
+ },
34
+ "devDependencies": {
35
+ "esbuild": "^0.24.0"
36
+ }
37
+ }