sharedoc-mcp
Advanced tools
+30
-7
@@ -35,3 +35,3 @@ import { createHash } from 'node:crypto'; | ||
| capabilities() { | ||
| return { password: 'none', expiry: 'lazy', files: false, revoke: 'hard-delete' }; | ||
| return { password: 'none', expiry: 'lazy', revoke: 'hard-delete' }; | ||
| } | ||
@@ -82,9 +82,6 @@ async gh(args, input) { | ||
| expiresAt: p.expiresInHours ? new Date(now.getTime() + p.expiresInHours * 3600e3).toISOString() : null, | ||
| contentHash: hash, filename, | ||
| contentHash: hash, filename, excerpt: p.content.slice(0, 200), | ||
| }); | ||
| return { url }; | ||
| } | ||
| async createFile() { | ||
| throw new BackendError('The gist backend cannot share files (gists are text-only). Switch to the selfhost backend for file sharing.'); | ||
| } | ||
| mustGet(docId) { | ||
@@ -101,2 +98,7 @@ const e = this.store.get(docId); | ||
| const e = this.mustGet(docId); | ||
| // Top up the searchable excerpt while it's short — docs created empty and | ||
| // filled by appends would otherwise never be findable via content search. | ||
| if ((e.excerpt ?? '').length < 200) { | ||
| this.store.update(docId, { excerpt: ((e.excerpt ?? '') + content).slice(0, 200) }, this.now()); | ||
| } | ||
| const filename = e.filename ?? `${slugify(e.title)}.md`; | ||
@@ -143,7 +145,28 @@ const raw = await this.gh(['api', `gists/${docId}`]); | ||
| } | ||
| async deleteDoc(docId) { | ||
| const e = this.store.get(docId); | ||
| if (!e) | ||
| throw new BackendError(`doc ${docId} not found in local index.`); | ||
| if (e.status === 'active') { | ||
| try { | ||
| await this.gh(['gist', 'delete', docId, '--yes']); | ||
| } | ||
| catch (err) { | ||
| // Only "already gone" may be swallowed. A real failure (auth, network) | ||
| // must propagate and LEAVE the index entry — otherwise a live public | ||
| // gist would be orphaned while the tool reports success (review C1). | ||
| if (!/not found|404|no such gist/i.test(err.message)) | ||
| throw err; | ||
| } | ||
| } | ||
| this.store.remove(docId); | ||
| } | ||
| async searchDocs(p) { | ||
| await this.lazyCleanup(); | ||
| // Map IndexEntry → DocRecord: internal fields (contentHash, filename) stay internal. | ||
| return this.store.search(p).map(({ docId, title, url, status, author, createdAt, updatedAt, expiresAt }) => ({ docId, title, url, status, author, createdAt, updatedAt, expiresAt })); | ||
| const cq = (p.contentQuery ?? '').toLowerCase(); | ||
| return this.store.search(p) | ||
| .filter(e => cq === '' || (e.excerpt ?? '').toLowerCase().includes(cq)) | ||
| // Map IndexEntry → DocRecord: internal fields (contentHash, filename, excerpt) stay internal. | ||
| .map(({ docId, title, url, status, author, createdAt, updatedAt, expiresAt }) => ({ docId, title, url, status, author, createdAt, updatedAt, expiresAt })); | ||
| } | ||
| } |
+56
-23
| import { DatabaseSync } from 'node:sqlite'; | ||
| import { createHash, randomUUID } from 'node:crypto'; | ||
| import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; | ||
| import { basename, dirname, join } from 'node:path'; | ||
| import { mkdirSync } from 'node:fs'; | ||
| import { dirname } from 'node:path'; | ||
| import bcrypt from 'bcryptjs'; | ||
| import { slugify } from './gist.js'; | ||
| import { BackendError, } from './types.js'; | ||
@@ -12,3 +11,2 @@ const DEDUP_WINDOW_MS = 5 * 60_000; | ||
| dbPath; | ||
| filesDir; | ||
| db; | ||
@@ -19,7 +17,5 @@ publicUrl; | ||
| this.dbPath = opts.dbPath; | ||
| this.filesDir = opts.filesDir; | ||
| this.publicUrl = opts.publicUrl.replace(/\/$/, ''); | ||
| this.now = opts.now ?? (() => new Date()); | ||
| mkdirSync(dirname(this.dbPath), { recursive: true }); | ||
| mkdirSync(this.filesDir, { recursive: true }); | ||
| this.db = new DatabaseSync(this.dbPath); | ||
@@ -47,2 +43,8 @@ // Two MCP clients may share this DB file: WAL + busy_timeout turn lock | ||
| CREATE INDEX IF NOT EXISTS idx_docs_status ON docs(status);`, | ||
| // v2.0.0: file sharing removed (prompt-injection exfiltration vector); | ||
| // rate-limit state moves into SQLite so a restart can't reset counters. | ||
| `DROP TABLE IF EXISTS files; | ||
| CREATE TABLE IF NOT EXISTS rateLimits ( | ||
| key TEXT PRIMARY KEY, windowStart INTEGER NOT NULL, count INTEGER NOT NULL | ||
| );`, | ||
| ]; | ||
@@ -56,4 +58,40 @@ const current = this.db.prepare(`PRAGMA user_version`).get().user_version; | ||
| capabilities() { | ||
| return { password: 'server', expiry: 'enforced', files: true, revoke: 'grace' }; | ||
| return { password: 'server', expiry: 'enforced', revoke: 'grace' }; | ||
| } | ||
| /** SQLite-backed fixed-window rate limiter — counters survive restarts. | ||
| * BEGIN IMMEDIATE serializes the check-then-act across PROCESSES (two viewers | ||
| * can genuinely run concurrently with SHAREDOC_PORT=0) — review I1. */ | ||
| rateAllow(key, capacity = 5, windowMs = 60_000) { | ||
| const t = this.now().getTime(); | ||
| this.db.exec('BEGIN IMMEDIATE'); | ||
| try { | ||
| this.db.prepare(`DELETE FROM rateLimits WHERE windowStart < ?`).run(t - windowMs); | ||
| const row = this.db.prepare(`SELECT windowStart, count FROM rateLimits WHERE key = ?`).get(key); | ||
| let allowed; | ||
| if (!row || t - row.windowStart >= windowMs) { | ||
| this.db.prepare(`INSERT INTO rateLimits (key, windowStart, count) VALUES (?, ?, 1) | ||
| ON CONFLICT(key) DO UPDATE SET windowStart = excluded.windowStart, count = 1`).run(key, t); | ||
| allowed = true; | ||
| } | ||
| else if (row.count >= capacity) { | ||
| allowed = false; | ||
| } | ||
| else { | ||
| this.db.prepare(`UPDATE rateLimits SET count = count + 1 WHERE key = ?`).run(key); | ||
| allowed = true; | ||
| } | ||
| this.db.exec('COMMIT'); | ||
| return allowed; | ||
| } | ||
| catch (e) { | ||
| this.db.exec('ROLLBACK'); | ||
| throw e; | ||
| } | ||
| } | ||
| rateRetryAfterSeconds(key, windowMs = 60_000) { | ||
| const row = this.db.prepare(`SELECT windowStart FROM rateLimits WHERE key = ?`).get(key); | ||
| if (!row) | ||
| return 0; | ||
| return Math.max(1, Math.ceil((row.windowStart + windowMs - this.now().getTime()) / 1000)); | ||
| } | ||
| /** Rebind the public URL after an ephemeral port resolves (SHAREDOC_PORT=0). */ | ||
@@ -81,6 +119,2 @@ setPublicUrl(url) { | ||
| } | ||
| fileRow(key) { | ||
| const r = this.db.prepare(`SELECT path, filename, contentType FROM files WHERE key = ?`).get(key); | ||
| return r ?? undefined; | ||
| } | ||
| mustActive(docId) { | ||
@@ -112,12 +146,2 @@ this.housekeeping(); | ||
| } | ||
| async createFile(p) { | ||
| if (!existsSync(p.filePath)) | ||
| throw new BackendError(`file not found: ${p.filePath}`); | ||
| const filename = p.filename ?? basename(p.filePath); | ||
| const key = `${randomUUID()}-${slugify(filename)}`; | ||
| const dest = join(this.filesDir, key); | ||
| copyFileSync(p.filePath, dest); | ||
| this.db.prepare(`INSERT INTO files (key, path, filename, contentType, createdAt) VALUES (?, ?, ?, ?, ?)`).run(key, dest, filename, p.contentType ?? null, this.now().toISOString()); | ||
| return { url: `${this.publicUrl}/files/${key}` }; | ||
| } | ||
| async appendDoc(docId, content) { | ||
@@ -146,13 +170,22 @@ this.mustActive(docId); | ||
| } | ||
| async deleteDoc(docId) { | ||
| this.housekeeping(); | ||
| const changed = this.db.prepare(`DELETE FROM docs WHERE docId = ?`).run(docId).changes; | ||
| if (changed === 0) | ||
| throw new BackendError(`doc ${docId} not found`); | ||
| } | ||
| async searchDocs(p) { | ||
| this.housekeeping(); | ||
| const limit = Math.min(p.limit ?? 20, 100); | ||
| const q = (p.titleQuery ?? '').replace(/[%_\\]/g, c => `\\${c}`); | ||
| const esc = (s) => s.replace(/[%_\\]/g, c => `\\${c}`); | ||
| const q = esc(p.titleQuery ?? ''); | ||
| const cq = esc(p.contentQuery ?? ''); | ||
| const rows = this.db.prepare(` | ||
| SELECT docId, title, status, author, createdAt, updatedAt, expiresAt FROM docs | ||
| WHERE (? = '' OR lower(title) LIKE '%' || lower(?) || '%' ESCAPE '\\') | ||
| AND (? = '' OR lower(COALESCE(content, '')) LIKE '%' || lower(?) || '%' ESCAPE '\\') | ||
| AND (? IS NULL OR status = ?) | ||
| ORDER BY createdAt DESC LIMIT ?`).all(q, q, p.status ?? null, p.status ?? null, limit); | ||
| ORDER BY createdAt DESC LIMIT ?`).all(q, q, cq, cq, p.status ?? null, p.status ?? null, limit); | ||
| return rows.map(r => ({ ...r, url: `${this.publicUrl}/docs/${r.docId}` })); | ||
| } | ||
| } |
@@ -49,2 +49,5 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; | ||
| } | ||
| remove(docId) { | ||
| this.save(this.load().filter(e => e.docId !== docId)); | ||
| } | ||
| update(docId, patch, now = new Date()) { | ||
@@ -51,0 +54,0 @@ this.save(this.load().map(e => e.docId === docId ? { ...e, ...patch, updatedAt: now.toISOString() } : e)); |
+69
-17
| #!/usr/bin/env node | ||
| import { homedir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { dirname, join } from 'node:path'; | ||
| import { statSync } from 'node:fs'; | ||
| import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; | ||
| import { GistBackend, execRunner } from './backend/gist.js'; | ||
| import { SelfHostBackend } from './backend/selfhost.js'; | ||
| import { startViewer } from './viewer/http-server.js'; | ||
| import { dbFingerprint, startViewer } from './viewer/http-server.js'; | ||
| import { IndexStore } from './index-store.js'; | ||
| import { buildServer } from './server.js'; | ||
| const backendName = process.env.SHAREDOC_BACKEND ?? 'gist'; | ||
| const DB_SIZE_WARN_BYTES = 100 * 1024 * 1024; | ||
| function selfHostConfig() { | ||
| const dataDir = process.env.SHAREDOC_DATA_DIR ?? join(homedir(), '.local', 'share', 'sharedoc-mcp'); | ||
| const port = Number(process.env.SHAREDOC_PORT ?? 8377); | ||
| return { dbPath: join(dataDir, 'docs.db'), port, publicUrl: process.env.SHAREDOC_PUBLIC_URL }; | ||
| } | ||
| function warnIfDbLarge(dbPath) { | ||
| try { | ||
| const size = statSync(dbPath).size; | ||
| if (size > DB_SIZE_WARN_BYTES) { | ||
| console.error(`sharedoc-mcp: docs.db is ${(size / 1024 / 1024).toFixed(0)} MB — consider delete_shared_doc on old docs`); | ||
| } | ||
| } | ||
| catch { /* no db yet */ } | ||
| // v1.0.0 leftovers: the removed file-sharing feature stored uploads next to the DB. | ||
| const legacyFiles = join(dirname(dbPath), 'files'); | ||
| try { | ||
| statSync(legacyFiles); | ||
| console.error(`sharedoc-mcp: ${legacyFiles} is a leftover from the removed v1 file-sharing feature — no longer served, safe to delete manually`); | ||
| } | ||
| catch { /* not present — normal */ } | ||
| } | ||
| /** Verify a busy port is OUR viewer on the SAME database before trusting it (review I2). */ | ||
| async function probeExistingViewer(port, dbPath) { | ||
| try { | ||
| const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1500) }); | ||
| const j = await res.json(); | ||
| return j.server === 'sharedoc-mcp' && j.db === dbFingerprint(dbPath) ? 'ours' : 'other'; | ||
| } | ||
| catch { | ||
| return 'other'; | ||
| } | ||
| } | ||
| /** `sharedoc-mcp serve` — standalone viewer daemon (no MCP): links outlive MCP clients. */ | ||
| async function serveDaemon() { | ||
| const { dbPath, port, publicUrl } = selfHostConfig(); | ||
| const backend = new SelfHostBackend({ dbPath, publicUrl: publicUrl ?? `http://127.0.0.1:${port}` }); | ||
| warnIfDbLarge(dbPath); | ||
| const viewer = await startViewer(backend, { port }); | ||
| if (!publicUrl) | ||
| backend.setPublicUrl(`http://127.0.0.1:${viewer.port}`); | ||
| console.error(`sharedoc-mcp: viewer daemon listening on 127.0.0.1:${viewer.port} (localhost only — use a tunnel to share externally)`); | ||
| let stopping = false; | ||
| const stop = () => { | ||
| if (stopping) | ||
| return; // a second signal during drain must not throw (review M3) | ||
| stopping = true; | ||
| viewer.close().catch(() => { }).finally(() => process.exit(0)); | ||
| }; | ||
| process.on('SIGINT', stop); | ||
| process.on('SIGTERM', stop); | ||
| // No stdin handling: the daemon outlives whatever started it until signaled. | ||
| } | ||
| async function makeBackend() { | ||
| const backendName = process.env.SHAREDOC_BACKEND ?? 'gist'; | ||
| if (backendName === 'gist') { | ||
@@ -17,9 +71,5 @@ const store = new IndexStore(process.env.SHAREDOC_INDEX_PATH ?? join(homedir(), '.config', 'sharedoc-mcp', 'index.json')); | ||
| if (backendName === 'selfhost') { | ||
| const dataDir = process.env.SHAREDOC_DATA_DIR ?? join(homedir(), '.local', 'share', 'sharedoc-mcp'); | ||
| const port = Number(process.env.SHAREDOC_PORT ?? 8377); | ||
| const backend = new SelfHostBackend({ | ||
| dbPath: join(dataDir, 'docs.db'), | ||
| filesDir: join(dataDir, 'files'), | ||
| publicUrl: process.env.SHAREDOC_PUBLIC_URL ?? `http://127.0.0.1:${port}`, | ||
| }); | ||
| const { dbPath, port, publicUrl } = selfHostConfig(); | ||
| const backend = new SelfHostBackend({ dbPath, publicUrl: publicUrl ?? `http://127.0.0.1:${port}` }); | ||
| warnIfDbLarge(dbPath); | ||
| try { | ||
@@ -29,5 +79,4 @@ const viewer = await startViewer(backend, { port }); | ||
| // rebind publicUrl to the actual port unless the user pinned SHAREDOC_PUBLIC_URL. | ||
| if (!process.env.SHAREDOC_PUBLIC_URL) { | ||
| if (!publicUrl) | ||
| backend.setPublicUrl(`http://127.0.0.1:${viewer.port}`); | ||
| } | ||
| console.error(`sharedoc-mcp: viewer listening on 127.0.0.1:${viewer.port} (localhost only — use a tunnel to share externally)`); | ||
@@ -37,6 +86,8 @@ } | ||
| if (e.code === 'EADDRINUSE') { | ||
| // Another MCP client on this machine is already serving this port — | ||
| // with a shared data dir its viewer serves the same docs, so keep the | ||
| // tool layer alive instead of killing the whole process. | ||
| console.error(`sharedoc-mcp: port ${port} already in use — assuming another sharedoc-mcp viewer is serving; tools stay available`); | ||
| if (await probeExistingViewer(port, dbPath) === 'ours') { | ||
| console.error(`sharedoc-mcp: port ${port} already served by another sharedoc-mcp viewer on the same database — tools stay available`); | ||
| } | ||
| else { | ||
| console.error(`sharedoc-mcp: WARNING — port ${port} is occupied by a DIFFERENT service or a sharedoc-mcp viewer on a different database. Share links created by this process will NOT work until the conflict is resolved (change SHAREDOC_PORT or stop the other process).`); | ||
| } | ||
| } | ||
@@ -52,3 +103,3 @@ else { | ||
| } | ||
| async function main() { | ||
| async function mcpMain() { | ||
| const server = buildServer(await makeBackend()); | ||
@@ -62,2 +113,3 @@ await server.connect(new StdioServerTransport()); | ||
| } | ||
| const main = process.argv[2] === 'serve' ? serveDaemon : mcpMain; | ||
| main().catch(e => { console.error(e); process.exit(1); }); |
+16
-12
@@ -34,7 +34,2 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| })), | ||
| create_shared_file: wrap(async (a) => backend.createFile({ | ||
| filePath: String(a.file_path), | ||
| filename: a.filename, | ||
| contentType: a.content_type, | ||
| })), | ||
| append_to_shared_doc: wrap(async (a) => { | ||
@@ -65,2 +60,7 @@ const id = extractDocId(String(a.doc_id_or_url)); | ||
| }), | ||
| delete_shared_doc: wrap(async (a) => { | ||
| const id = extractDocId(String(a.doc_id_or_url)); | ||
| await backend.deleteDoc(id); | ||
| return { ok: true, doc_id: id }; | ||
| }), | ||
| search_shared_docs: wrap(async (a) => { | ||
@@ -73,2 +73,3 @@ const status = a.status; | ||
| titleQuery: a.title_query ?? '', | ||
| contentQuery: a.content_query ?? '', | ||
| status: status, limit: a.limit ?? 20, | ||
@@ -91,6 +92,4 @@ }); | ||
| }, | ||
| create_shared_file: { | ||
| description: 'Share a local file. Only supported on the selfhost backend (gists are text-only). Note: file links have no password or expiry — anyone with the (unguessable) link can download, indefinitely.', | ||
| inputSchema: { file_path: z.string(), filename: optStr, content_type: optStr }, | ||
| }, | ||
| // create_shared_file was removed in v2.0.0: an arbitrary-path file-sharing tool is | ||
| // a prompt-injection exfiltration vector (.env, keys) — no allowlist, no tool. | ||
| append_to_shared_doc: { | ||
@@ -113,9 +112,14 @@ description: 'Append content to an existing shared doc. NOT idempotent: a retry appends twice — check with search_shared_docs before retrying. Accepts a doc id or URL.', | ||
| revoke_shared_doc: { | ||
| description: 'Revoke a shared doc. Gist backend: the gist is deleted immediately and irreversibly. Selfhost backend: revoked with a 7-day grace before content is purged.', | ||
| description: 'Revoke a shared doc: the link stops working but the record stays searchable. Gist backend: the gist is deleted immediately and irreversibly. Selfhost backend: revoked with a 7-day grace before content is purged. To erase the record entirely, use delete_shared_doc.', | ||
| inputSchema: { doc_id_or_url: z.string(), updated_user: optStr }, | ||
| }, | ||
| delete_shared_doc: { | ||
| description: 'Permanently delete a shared doc: the link dies AND the record disappears from search — unlike revoke_shared_doc, no history is kept. Irreversible on both backends.', | ||
| inputSchema: { doc_id_or_url: z.string() }, | ||
| }, | ||
| search_shared_docs: { | ||
| description: 'Search shared docs by title substring (empty = all) with optional status filter, limit max 100.', | ||
| description: 'Find previously shared docs and their links. Call with NO arguments to list the newest docs (each result includes its share URL). title_query filters by title substring; content_query searches body text (selfhost: full content; gist: the opening excerpt only); status filters active/revoked/expired; limit max 100.', | ||
| inputSchema: { | ||
| title_query: optStr, | ||
| content_query: optStr, | ||
| status: z.enum(['active', 'revoked', 'expired']).optional(), | ||
@@ -127,3 +131,3 @@ limit: z.number().optional(), | ||
| export function buildServer(backend) { | ||
| const server = new McpServer({ name: 'sharedoc', version: '1.0.0' }); | ||
| const server = new McpServer({ name: 'sharedoc', version: '2.0.0' }); | ||
| const handlers = buildToolHandlers(backend); | ||
@@ -130,0 +134,0 @@ for (const [name, meta] of Object.entries(TOOL_SCHEMAS)) { |
| import { createServer } from 'node:http'; | ||
| import { createReadStream } from 'node:fs'; | ||
| import { createHash } from 'node:crypto'; | ||
| import { marked } from 'marked'; | ||
| import sanitizeHtml from 'sanitize-html'; | ||
| import bcrypt from 'bcryptjs'; | ||
| import { TokenBucket } from './rate-limit.js'; | ||
| /** Non-reversible identifier for "which DB is this viewer serving" — safe to expose. */ | ||
| export function dbFingerprint(dbPath) { | ||
| return createHash('sha256').update(dbPath).digest('hex').slice(0, 8); | ||
| } | ||
| const PAGE = (title, body) => `<!doctype html> | ||
@@ -15,2 +18,14 @@ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| </head><body>${body}</body></html>`; | ||
| /** Applied to every response — docs may hold sensitive content; lock the page down. */ | ||
| const SEC_HEADERS = { | ||
| 'x-content-type-options': 'nosniff', | ||
| 'x-frame-options': 'DENY', | ||
| 'referrer-policy': 'no-referrer', | ||
| 'cache-control': 'no-store', | ||
| 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", | ||
| }; | ||
| function head(res, status, extra = {}) { | ||
| res.writeHead(status, { ...SEC_HEADERS, ...extra }); | ||
| return res; | ||
| } | ||
| function escapeHtml(s) { | ||
@@ -54,8 +69,12 @@ return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); | ||
| export async function startViewer(backend, opts) { | ||
| const now = opts.now ?? (() => new Date()); | ||
| const bucket = new TokenBucket(5, 60_000); | ||
| async function route(req, res) { | ||
| const url = new URL(req.url ?? '/', 'http://localhost'); | ||
| if (url.pathname === '/healthz' && req.method === 'GET') { | ||
| // Health + identity probe: external monitoring, and MCP-mode processes use it | ||
| // to verify a busy port is really OUR viewer on the SAME database (review I2). | ||
| head(res, 200, { 'content-type': 'application/json' }) | ||
| .end(JSON.stringify({ ok: true, server: 'sharedoc-mcp', db: dbFingerprint(backend.dbPath) })); | ||
| return; | ||
| } | ||
| const docMatch = url.pathname.match(/^\/docs\/([0-9a-f-]{36})$/); | ||
| const fileMatch = url.pathname.match(/^\/files\/([A-Za-z0-9._-]+)$/); | ||
| if (docMatch) { | ||
@@ -65,7 +84,7 @@ const docId = docMatch[1]; | ||
| if (!row) { | ||
| res.writeHead(404, { 'content-type': 'text/plain' }).end('not found'); | ||
| head(res, 404, { 'content-type': 'text/plain' }).end('not found'); | ||
| return; | ||
| } | ||
| if (row.status !== 'active' || row.content === null) { | ||
| res.writeHead(410, { 'content-type': 'text/plain' }).end('gone'); | ||
| head(res, 410, { 'content-type': 'text/plain' }).end('gone'); | ||
| return; | ||
@@ -76,16 +95,15 @@ } | ||
| if (row.passwordHash) { | ||
| res.writeHead(200, html).end(passwordForm(docId)); | ||
| head(res, 200, html).end(passwordForm(docId)); | ||
| return; | ||
| } | ||
| res.writeHead(200, html).end(renderDoc(row.title, row.content)); | ||
| head(res, 200, html).end(renderDoc(row.title, row.content)); | ||
| return; | ||
| } | ||
| if (req.method === 'POST') { | ||
| // NOTE: behind a tunnel (Tailscale funnel / cloudflared) remoteAddress is the | ||
| // tunnel's loopback for ALL external requesters, so this bucket is effectively | ||
| // per-doc, not per-attacker — stricter than intended, never weaker. Documented | ||
| // in the README rather than trusting X-Forwarded-For (spoofable). | ||
| // NOTE: behind a tunnel, remoteAddress is the tunnel's loopback for ALL | ||
| // external requesters — the bucket degrades to per-doc, which is stricter, | ||
| // never weaker. Counters live in SQLite, so a restart doesn't reset them. | ||
| const key = `${req.socket.remoteAddress}:${docId}`; | ||
| if (!bucket.allow(key, now())) { | ||
| res.writeHead(429, { 'content-type': 'text/plain', 'retry-after': String(bucket.retryAfterSeconds(key, now())) }) | ||
| if (!backend.rateAllow(key)) { | ||
| head(res, 429, { 'content-type': 'text/plain', 'retry-after': String(backend.rateRetryAfterSeconds(key)) }) | ||
| .end('too many attempts'); | ||
@@ -99,3 +117,3 @@ return; | ||
| catch { | ||
| res.writeHead(413, { 'content-type': 'text/plain' }).end('payload too large'); | ||
| head(res, 413, { 'content-type': 'text/plain' }).end('payload too large'); | ||
| return; | ||
@@ -105,40 +123,13 @@ } | ||
| if (!row.passwordHash || bcrypt.compareSync(password, row.passwordHash)) { | ||
| res.writeHead(200, html).end(renderDoc(row.title, row.content)); | ||
| head(res, 200, html).end(renderDoc(row.title, row.content)); | ||
| } | ||
| else { | ||
| res.writeHead(401, html).end(passwordForm(docId, true)); | ||
| head(res, 401, html).end(passwordForm(docId, true)); | ||
| } | ||
| return; | ||
| } | ||
| res.writeHead(405).end(); | ||
| head(res, 405).end(); | ||
| return; | ||
| } | ||
| if (fileMatch && req.method === 'GET') { | ||
| const f = backend.fileRow(fileMatch[1]); | ||
| if (!f) { | ||
| res.writeHead(404, { 'content-type': 'text/plain' }).end('not found'); | ||
| return; | ||
| } | ||
| // RFC 5987/6266: non-ASCII filenames go in filename*; the quoted filename is an | ||
| // ASCII fallback with CR/LF and quotes stripped (Node throws on non-ASCII header values). | ||
| const ascii = f.filename.replace(/[^\x20-\x7e]/g, '_').replace(/["\r\n]/g, ''); | ||
| const star = encodeURIComponent(f.filename).replace(/['()]/g, escape); | ||
| const stream = createReadStream(f.path); | ||
| // A stream 'error' fires outside route()'s stack — without this handler a single | ||
| // GET for a row whose file vanished from disk would crash the whole process. | ||
| stream.on('error', () => { | ||
| if (!res.headersSent) | ||
| res.writeHead(404, { 'content-type': 'text/plain' }); | ||
| res.end('file missing'); | ||
| }); | ||
| stream.once('open', () => { | ||
| res.writeHead(200, { | ||
| 'content-type': (f.contentType ?? 'application/octet-stream').replace(/[\r\n]/g, ''), | ||
| 'content-disposition': `attachment; filename="${ascii}"; filename*=UTF-8''${star}`, | ||
| }); | ||
| stream.pipe(res); | ||
| }); | ||
| return; | ||
| } | ||
| res.writeHead(404, { 'content-type': 'text/plain' }).end('not found'); | ||
| head(res, 404, { 'content-type': 'text/plain' }).end('not found'); | ||
| } | ||
@@ -149,3 +140,3 @@ const server = createServer((req, res) => { | ||
| if (!res.headersSent) | ||
| res.writeHead(500, { 'content-type': 'text/plain' }); | ||
| head(res, 500, { 'content-type': 'text/plain' }); | ||
| res.end('internal error'); | ||
@@ -152,0 +143,0 @@ }); |
+1
-1
| { | ||
| "name": "sharedoc-mcp", | ||
| "version": "1.0.0", | ||
| "version": "2.0.0", | ||
| "description": "Share agent-generated Markdown as links — GitHub gists today, your own server tomorrow. An MCP server.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+142
-44
| # sharedoc-mcp | ||
| **Share agent-generated Markdown as links.** An MCP server that turns "here's the report" into a URL you can hand to anyone — backed by GitHub gists (zero setup) or your own machine (passwords, expiry, full control). | ||
| > **Agent-generated Markdown → a link you can hand to anyone. GitHub gists today, your own server tomorrow.** | ||
| [English](./README.md) | [繁體中文](./README.zh-TW.md) | ||
| English | [繁體中文](./README.zh-TW.md) | ||
| **Version 1.0.0** · [CHANGELOG](./CHANGELOG.md) · MIT | ||
| [](https://www.npmjs.com/package/sharedoc-mcp) | ||
| [](https://github.com/AugustusW/sharedoc-mcp/releases) | ||
| [](LICENSE) | ||
| [](https://nodejs.org/) | ||
| [](https://modelcontextprotocol.io/) | ||
| [](https://claude.com/claude-code) | ||
| [](https://developers.openai.com/codex/) | ||
| ## Why | ||
| An [MCP](https://modelcontextprotocol.io/) stdio server — works in [Claude Code](https://claude.com/claude-code), Codex CLI, and any MCP client — that gives your agent **8 tools to publish, update, search, and revoke shareable documents**. Two pluggable backends behind one interface: **gist** (zero setup, rides your logged-in `gh` CLI) and **selfhost** (SQLite on your machine, passwords, enforced expiry). | ||
| AI agents produce Markdown constantly — reports, research digests, meeting notes. Getting that to another human usually means copy-pasting walls of text into a chat. sharedoc-mcp gives your agent 8 tools to publish, update, search, and revoke shareable documents, so "send this to my teammate" becomes a link. | ||
| > When a backend can't honor a parameter (e.g. `password` on gist), it returns a clear error instead of silently ignoring it. | ||
| ## Two backends, one interface | ||
| ## Why? | ||
| | | 🅰 `gist` (default) | 🅱 `selfhost` | | ||
| |---|---|---| | ||
| | Setup | none — uses your logged-in `gh` CLI | none extra — data stays on your machine | | ||
| | Doc lives on | GitHub (secret gist) | your machine (SQLite) | | ||
| | Link reachable | anywhere, immediately | localhost — add a tunnel to share externally | | ||
| | Password | ✗ (the secret URL is the protection) | ✓ server-verified (bcrypt), rate-limited | | ||
| | Expiry | lazy — expired gists are deleted on next use | enforced — expired links return 410 | | ||
| | Revoke | gist deleted immediately, irreversibly | immediate 410, content purged after a 7-day grace | | ||
| | File sharing | ✗ (gists are text-only) | ✓ (no password/expiry on files — link is the only protection) | | ||
| AI agents produce Markdown constantly — reports, research digests, meeting notes. Getting that to another human usually means copy-pasting walls of text into a chat window. | ||
| The 8 MCP tools are identical on both; when a backend can't honor a parameter (e.g. `password` on gist), it returns a clear error instead of silently ignoring it. | ||
| ```text | ||
| Without sharedoc-mcp With sharedoc-mcp | ||
| ──────────────────── ───────────────── | ||
| copy a wall of text into chat "share this as a doc" | ||
| paste again for each person one link for everyone | ||
| content lives in chat scroll revoke / extend / append later | ||
| "can you password it?" …no selfhost backend: bcrypt + expiry | ||
| ``` | ||
| ## Features | ||
| - ✓ 8 MCP tools: create / append / extend / reset password / rename / revoke / delete / search | ||
| - ✓ `sharedoc-mcp serve` daemon mode — selfhost links keep working after your MCP client closes | ||
| - ✓ Content search: find old share links by what's in them, not just the title | ||
| - ✓ `GET /healthz` — identity-aware health probe for external monitoring / restart automation | ||
| - ✓ Two backends, one interface — switch with a single env var, tool schemas stay identical | ||
| - ✓ **Gist backend** (default): secret gists via your logged-in `gh` CLI — no tokens to manage, nothing new to host | ||
| - ✓ **Selfhost backend**: docs stay on your machine (SQLite via built-in `node:sqlite` — zero native modules) | ||
| - ✓ Server-verified passwords (bcrypt) with rate-limited attempts — 5/minute, HTTP 429, counters persisted in SQLite so a restart can't reset them (selfhost) | ||
| - ✓ Enforced expiry (410) and revoke with a 7-day content-purge grace (selfhost); lazy expiry cleanup (gist) | ||
| - ✓ Markdown rendered through `marked` + `sanitize-html` — scripts, event handlers, and `javascript:` URLs in shared content are stripped | ||
| - ✓ Viewer binds **127.0.0.1 only**, answers with a strict security-header set (CSP `default-src 'none'`, nosniff, DENY framing, no-referrer, no-store) — exposure is a tunnel you control (recipes below) | ||
| - ✓ Local index for `search_shared_docs` + create dedup (identical unprotected retries within 5 min return the same URL; a retry that adds a password/expiry always creates a new doc) | ||
| - ✓ Two MCP clients can share one data dir: SQLite WAL + busy timeout, graceful port sharing | ||
| - ✓ 60 offline tests; `npm test` passes on a clean checkout | ||
| ## Install | ||
| Requires Node.js ≥ 22.13.0. For the gist backend: [GitHub CLI](https://cli.github.com) logged in (`gh auth login`). | ||
| Requires Node.js ≥ 22.13.0. Gist backend additionally needs [GitHub CLI](https://cli.github.com) logged in (`gh auth login`). | ||
| **Claude Code:** | ||
| **Option A — Claude Code (one line):** | ||
@@ -37,3 +59,3 @@ ```bash | ||
| **Codex CLI** (`~/.codex/config.toml`): | ||
| **Option B — Codex CLI** (`~/.codex/config.toml`): | ||
@@ -46,12 +68,23 @@ ```toml | ||
| Any other MCP client: run `npx -y sharedoc-mcp` as a stdio server. | ||
| **Option C — any other MCP client:** run `npx -y sharedoc-mcp` as a stdio server. | ||
| ## Quickstart (gist backend) | ||
| ## Pick your backend | ||
| Ask your agent to "share this as a doc" — it calls `create_shared_doc` and returns a secret gist URL. Secret gists are not listed publicly and the URL is unguessable, but **anyone who has the link can read it**. That's the whole security model of this backend — use `selfhost` when you need passwords. | ||
| | | 🅰 `gist` (default) | 🅱 `selfhost` | | ||
| |---|---|---| | ||
| | Setup | none — uses your logged-in `gh` CLI | none extra — data stays on your machine | | ||
| | Doc lives on | GitHub (secret gist) | your machine (SQLite) | | ||
| | Link reachable | anywhere, immediately | localhost — add a tunnel to share externally | | ||
| | Password | ✗ (the secret URL is the protection) | ✓ server-verified (bcrypt), rate-limited | | ||
| | Expiry | lazy — expired gists deleted on next use | enforced — expired links return 410 | | ||
| | Revoke | gist deleted immediately, irreversibly | immediate 410, content purged after 7-day grace | | ||
| A local index (`~/.config/sharedoc-mcp/index.json`) tracks what you've shared, powering `search_shared_docs` and expiry cleanup. Expiry on this backend is *lazy*: expired gists are deleted the next time any tool runs, not at the exact expiry moment. | ||
| ### Gist quickstart | ||
| ## Selfhost backend | ||
| Ask your agent to "share this as a doc" — it calls `create_shared_doc` and returns a secret gist URL. Secret gists are not listed publicly and the URL is unguessable, but **anyone who has the link can read it** — that's the whole security model of this backend. Need passwords? Use `selfhost`. | ||
| A local index (`~/.config/sharedoc-mcp/index.json`) tracks what you've shared, powering search and expiry cleanup. Expiry here is *lazy*: expired gists are deleted the next time any tool runs, not at the exact expiry moment. | ||
| ### Selfhost quickstart | ||
| ```bash | ||
@@ -61,10 +94,58 @@ claude mcp add sharedoc --scope user --env SHAREDOC_BACKEND=selfhost -- npx -y sharedoc-mcp | ||
| Docs live in SQLite at `~/.local/share/sharedoc-mcp/`; a viewer serves them at `http://127.0.0.1:8377`. The server **only ever binds 127.0.0.1** — exposing it to the internet is deliberately left to a tunnel you control: | ||
| Docs live in SQLite at `~/.local/share/sharedoc-mcp/`; a viewer serves them at `http://127.0.0.1:8377`. To share beyond your machine, put a tunnel in front and set `SHAREDOC_PUBLIC_URL`: | ||
| > **Links that outlive your editor:** in MCP mode the viewer dies with the MCP client — close Claude Code and selfhost links stop answering until the next session (data is safe in SQLite). Run the standalone daemon to keep links alive around the clock: | ||
| > | ||
| > ```bash | ||
| > npx -y sharedoc-mcp serve # viewer only, same DB — keep it running via launchd/systemd/tmux | ||
| > ``` | ||
| > | ||
| > MCP clients detect the daemon already owns the port and simply use it. | ||
| > | ||
| > **When to set this up:** the moment you first hand a link to someone else — do it together | ||
| > with your tunnel (both should be long-running, e.g. under launchd/systemd). Until then the | ||
| > MCP-mode viewer is enough, and gist-backend users never need it. | ||
| | Recipe | Fits you if | Setup | | ||
| |---|---|---| | ||
| | **Tailscale Funnel** (recommended) | no domain, want a stable URL | install [Tailscale](https://tailscale.com), then `tailscale funnel 8377` → stable `https://<machine>.<tailnet>.ts.net`; set `SHAREDOC_PUBLIC_URL` to it | | ||
| | **Cloudflare named tunnel** | you own a domain | add the domain to Cloudflare, `cloudflared tunnel create` + route a hostname to `http://127.0.0.1:8377`; set `SHAREDOC_PUBLIC_URL` | | ||
| | **cloudflared quick tunnel** | one-off sharing | `cloudflared tunnel --url http://127.0.0.1:8377` → random `trycloudflare.com` URL that changes every restart; set `SHAREDOC_PUBLIC_URL` per session | | ||
| | **Tailscale private** (recommended) | recipients are your own devices / people you can invite to your tailnet | `tailscale serve --bg 8377` → `https://<machine>.<tailnet>.ts.net`, reachable **only inside your tailnet** — nothing is exposed to the public internet | | ||
| | **Tailscale Funnel** | share with anyone, no domain | `tailscale funnel 8377` → same stable URL, but public | | ||
| | **Cloudflare named tunnel** | you own a domain | domain on Cloudflare, `cloudflared tunnel create` + route a hostname to `http://127.0.0.1:8377` | | ||
| | **cloudflared quick tunnel** | one-off sharing | `cloudflared tunnel --url http://127.0.0.1:8377` → random URL, changes every restart | | ||
| #### Own a domain? Cloudflare named tunnel, step by step | ||
| A branded, stable share URL like `https://docs.example.com/docs/<uuid>` — TLS handled by Cloudflare, works from behind NAT: | ||
| ```bash | ||
| # one-time setup (domain already added to Cloudflare — the free plan is enough) | ||
| cloudflared tunnel login | ||
| cloudflared tunnel create sharedoc | ||
| cloudflared tunnel route dns sharedoc docs.example.com | ||
| ``` | ||
| `~/.cloudflared/config.yml`: | ||
| ```yaml | ||
| tunnel: sharedoc | ||
| credentials-file: ~/.cloudflared/<tunnel-id>.json | ||
| ingress: | ||
| - hostname: docs.example.com | ||
| service: http://127.0.0.1:8377 | ||
| - service: http_status:404 | ||
| ``` | ||
| Run `cloudflared tunnel run sharedoc` (or install it as a service for always-on), and register the MCP server with the public URL: | ||
| ```bash | ||
| claude mcp add sharedoc --scope user \ | ||
| --env SHAREDOC_BACKEND=selfhost \ | ||
| --env SHAREDOC_PUBLIC_URL=https://docs.example.com \ | ||
| -- npx -y sharedoc-mcp | ||
| ``` | ||
| Extras this unlocks: Cloudflare's DDoS protection comes free; you can layer WAF rules, or put [Cloudflare Access](https://www.cloudflare.com/zero-trust/products/access/) (SSO) in front of everything except the share paths — an "SSO inside, password-protected shares outside" split. | ||
| **Alternative — always-on without a home machine:** run sharedoc-mcp on a VPS (where your agent also runs) and point nginx/caddy at `127.0.0.1:8377` with your domain and auto-TLS; no tunnel needed. | ||
| Environment variables: | ||
@@ -76,14 +157,7 @@ | ||
| | `SHAREDOC_PORT` | `8377` | viewer port (selfhost) | | ||
| | `SHAREDOC_PUBLIC_URL` | `http://127.0.0.1:<port>` | the URL prefix returned in share links — set it to your tunnel hostname | | ||
| | `SHAREDOC_DATA_DIR` | `~/.local/share/sharedoc-mcp` | SQLite + files location (selfhost) | | ||
| | `SHAREDOC_PUBLIC_URL` | `http://127.0.0.1:<port>` | URL prefix in share links — set to your tunnel hostname | | ||
| | `SHAREDOC_DATA_DIR` | `~/.local/share/sharedoc-mcp` | SQLite location (selfhost) | | ||
| | `SHAREDOC_INDEX_PATH` | `~/.config/sharedoc-mcp/index.json` | local index (gist) | | ||
| | `MCP_CALLER` | — | default author attribution for created docs | | ||
| ### Security semantics, honestly | ||
| - Passwords are bcrypt-hashed and verified server-side before content is served; wrong-password attempts are rate-limited (5/minute per source+doc, HTTP 429). **Behind a tunnel, all external visitors share one source address**, so the practical limit is 5/minute per doc — stricter than per-visitor, and one person mistyping can briefly lock a doc for others. | ||
| - Document content is rendered through `marked` and sanitized with `sanitize-html` — scripts, event handlers, and `javascript:` URLs in shared content are stripped. | ||
| - Shared **files** have no password or expiry: the unguessable link is the only protection, indefinitely, and downloads are not rate-limited. | ||
| - Two MCP clients can point at the same data dir: SQLite runs in WAL mode with a busy timeout, and if the viewer port is already taken by another sharedoc-mcp instance the second client keeps its tools and relies on the existing viewer. | ||
| ## The 8 tools | ||
@@ -93,11 +167,26 @@ | ||
| |---|---| | ||
| | `create_shared_doc` | title + Markdown (+ optional password / `expires_in_hours` / author) → share URL. Identical unprotected retries within 5 min return the same URL; a retry that adds a password or expiry always creates a new doc. | | ||
| | `create_shared_file` | share a local file (selfhost only) | | ||
| | `append_to_shared_doc` | append Markdown to an existing doc (not idempotent — a retry appends twice) | | ||
| | `create_shared_doc` | title + Markdown (+ optional password / `expires_in_hours` / author) → share URL | | ||
| | `append_to_shared_doc` | append Markdown (not idempotent — a retry appends twice) | | ||
| | `extend_shared_doc` | extend expiry by N hours | | ||
| | `reset_shared_doc_password` | set / change / remove (null) the password (selfhost only) | | ||
| | `update_shared_doc_title` | rename | | ||
| | `revoke_shared_doc` | kill the link (see backend table for semantics) | | ||
| | `search_shared_docs` | title substring + status filter | | ||
| | `revoke_shared_doc` | kill the link, keep the record (see backend table for semantics) | | ||
| | `delete_shared_doc` | kill the link AND erase the record — irreversible | | ||
| | `search_shared_docs` | no args = list newest links; title substring, body-text search (selfhost: full content; gist: opening excerpt), status filter | | ||
| ## Privacy | ||
| Data flow, by backend: | ||
| - **Gist backend**: your document content is uploaded to GitHub as a secret gist under your account — GitHub's terms and retention apply. The local index (titles, URLs, timestamps — not content) stays in `~/.config/sharedoc-mcp/`. Nothing is sent anywhere except GitHub via your own `gh` CLI. | ||
| - **Selfhost backend**: content never leaves your machine unless you attach a tunnel — then it's served to whoever you gave the link (and the tunnel provider relays the traffic). Passwords are stored only as bcrypt hashes. | ||
| - sharedoc-mcp itself has no telemetry and calls no third-party service of its own. | ||
| ## Security semantics, honestly | ||
| - **Gist links are bearer tokens**: anyone with the URL reads the doc. Revoke deletes the gist immediately and irreversibly. | ||
| - Selfhost passwords are verified server-side before content is served; wrong attempts are rate-limited (5/minute per source+doc), with counters persisted in SQLite — restarting the server does not reset them. **Behind a tunnel, all external visitors share one source address**, so the practical limit is 5/minute per doc — stricter than per-visitor; one person mistyping can briefly lock a doc for others. | ||
| - There is deliberately **no file-sharing tool**: an arbitrary-path "share this file" tool is a prompt-injection exfiltration vector (`.env`, keys) — a hijacked agent could publish secrets. Removed rather than allowlisted. | ||
| - The viewer never binds beyond 127.0.0.1. Whether and how it reaches the internet is entirely your tunnel's configuration. | ||
| ## Develop | ||
@@ -109,9 +198,18 @@ | ||
| npm install | ||
| npm test # builds, then runs 52 offline tests — gh CLI is mocked, HTTP tests hit 127.0.0.1 only | ||
| npm test # builds, then runs 60 offline tests — gh CLI is mocked, HTTP tests hit 127.0.0.1 only | ||
| ``` | ||
| Versioning: every release bumps `version` in `package.json`, adds a [CHANGELOG](./CHANGELOG.md) entry, and is published as a git tag + GitHub Release + npm. Your index, docs DB, and files all live outside the package — updating never touches them. | ||
| Versioning: every release bumps `version` in `package.json`, adds a [CHANGELOG](./CHANGELOG.md) entry, and is published as a git tag + [GitHub Release](https://github.com/AugustusW/sharedoc-mcp/releases) + [npm](https://www.npmjs.com/package/sharedoc-mcp). | ||
| **To get update notifications**: Watch this repo (Custom → Releases). `npx -y` fetches the latest published version on each cold run; your index and docs DB live outside the package — updating never touches them. | ||
| ## Status | ||
| v2.0.0 ([CHANGELOG](./CHANGELOG.md)) — core logic is covered by 60 offline unit/integration tests (the `gh` CLI is mocked; HTTP tests run against 127.0.0.1 only; no network needed). The full flows have been manually verified (2026-07-25: real secret-gist create/index/delete via the built server over stdio JSON-RPC, and the selfhost password flow end-to-end — form → wrong password 401 → correct password 200 → rate-limit 429 → revoke 410 — plus `lsof` confirmation of the 127.0.0.1-only bind) on: | ||
| - macOS (Apple Silicon), Node v25 — gist + selfhost backends | ||
| Tunnel recipes are documented from the tools' standard behavior; Windows/Linux and real-tunnel end-to-end runs have **not yet been verified** — reports welcome. | ||
| ## License | ||
| MIT © AugustusW |
+138
-42
| # sharedoc-mcp | ||
| **把 agent 產出的 Markdown 變成分享連結。**一個 MCP server,讓「這份報告給你」從貼一大段文字變成一條 URL——後端可選 GitHub gist(零設定)或自己的機器(密碼、期限、完全掌控)。 | ||
| > **Agent 產出的 Markdown → 一條可以交給任何人的連結。今天用 GitHub gist,明天用你自己的 server。** | ||
| [English](./README.md) | [繁體中文](./README.zh-TW.md) | ||
| [English](./README.md) | 繁體中文 | ||
| **版本 1.0.0** · [CHANGELOG](./CHANGELOG.md) · MIT | ||
| [](https://www.npmjs.com/package/sharedoc-mcp) | ||
| [](https://github.com/AugustusW/sharedoc-mcp/releases) | ||
| [](LICENSE) | ||
| [](https://nodejs.org/) | ||
| [](https://modelcontextprotocol.io/) | ||
| [](https://claude.com/claude-code) | ||
| [](https://developers.openai.com/codex/) | ||
| ## 為什麼 | ||
| 一個 [MCP](https://modelcontextprotocol.io/) stdio server——可用於 [Claude Code](https://claude.com/claude-code)、Codex CLI 與任何 MCP client——給你的 agent **8 個工具:發佈、更新、搜尋、撤銷分享文件**。同一組介面、兩個可切換後端:**gist**(零設定,搭你已登入的 `gh` CLI)與 **selfhost**(SQLite 存你機器上,支援密碼與強制期限)。 | ||
| AI agent 整天在產 Markdown——報告、研究摘要、會議記錄。要交給另一個人,通常得把整面文字牆貼進聊天視窗。sharedoc-mcp 給你的 agent 8 個工具:發佈、更新、搜尋、撤銷分享文件,讓「傳給我同事」變成一條連結。 | ||
| > 後端不支援某參數時(如 gist 收到 `password`)會回明確錯誤,不會靜默忽略。 | ||
| ## 兩個後端,同一組介面 | ||
| ## 為什麼? | ||
| | | 🅰 `gist`(預設) | 🅱 `selfhost` | | ||
| |---|---|---| | ||
| | 設定 | 無——用你已登入的 `gh` CLI | 無額外設定——資料留在你機器上 | | ||
| | 文件放在 | GitHub(secret gist) | 你的機器(SQLite) | | ||
| | 連結可達性 | 任何地方、立即 | localhost——要對外分享請接 tunnel | | ||
| | 密碼 | ✗(secret URL 本身就是保護) | ✓ server 端驗證(bcrypt)+ 錯誤嘗試限流 | | ||
| | 期限 | 惰性——過期 gist 於下次使用時刪除 | 強制——過期連結回 410 | | ||
| | 撤銷 | gist 立即刪除、不可逆 | 立即 410,內容 7 天緩衝期後清除 | | ||
| | 檔案分享 | ✗(gist 僅文字) | ✓(檔案無密碼/期限——連結是唯一保護) | | ||
| AI agent 整天在產 Markdown——報告、研究摘要、會議記錄。要交給另一個人,通常得把一大面文字牆貼進聊天視窗。 | ||
| 8 個 MCP 工具兩邊完全相同;後端不支援某參數時(如 gist 收到 `password`)會回明確錯誤,不會靜默忽略。 | ||
| ```text | ||
| 沒有 sharedoc-mcp 有 sharedoc-mcp | ||
| ───────────────── ──────────────── | ||
| 複製一大段文字貼進聊天 「把這個做成分享文件」 | ||
| 每多一個人就再貼一次 一條連結給所有人 | ||
| 內容埋在聊天記錄裡 事後可撤銷/延長/追加 | ||
| 「可以加密碼嗎?」……不行 selfhost 後端:bcrypt + 期限 | ||
| ``` | ||
| ## 特色 | ||
| - ✓ 8 個 MCP 工具:建立/追加/延長/改密碼/改標題/撤銷/刪除/搜尋 | ||
| - ✓ `sharedoc-mcp serve` daemon 模式——MCP client 關掉後 selfhost 連結照樣活著 | ||
| - ✓ 內容搜尋:用文件裡寫了什麼找回舊連結,不只靠標題 | ||
| - ✓ `GET /healthz`——帶身分識別的健檢端點,外部監控/自動重啟直接掛 | ||
| - ✓ 兩個後端、同一組介面——一個環境變數切換,工具 schema 完全相同 | ||
| - ✓ **Gist 後端**(預設):secret gist 走你已登入的 `gh` CLI——不用管 token、不用架任何東西 | ||
| - ✓ **Selfhost 後端**:文件留在你機器上(內建 `node:sqlite`——零原生模組) | ||
| - ✓ Server 端密碼驗證(bcrypt)+ 錯誤嘗試限流——5 次/分鐘,HTTP 429,計數存 SQLite、重啟不歸零(selfhost) | ||
| - ✓ 強制期限(410)與撤銷 7 天內容清除緩衝(selfhost);惰性過期清理(gist) | ||
| - ✓ Markdown 經 `marked` + `sanitize-html` 渲染——分享內容中的 script、事件屬性、`javascript:` 連結都會被剝除 | ||
| - ✓ Viewer **只 bind 127.0.0.1**,所有回應帶完整安全 headers(CSP `default-src 'none'`、nosniff、禁 iframe、no-referrer、no-store)——對外曝光交給你自己控制的 tunnel(食譜見下) | ||
| - ✓ 本地索引支援 `search_shared_docs` 與建立去重(5 分鐘內相同的無保護重試回同一 URL;補加密碼/期限的重試一律建新文件) | ||
| - ✓ 兩個 MCP client 可共用同一資料目錄:SQLite WAL + busy timeout、埠衝突優雅共存 | ||
| - ✓ 60 個離線測試;乾淨 checkout `npm test` 直接綠 | ||
| ## 安裝 | ||
@@ -31,3 +53,3 @@ | ||
| **Claude Code:** | ||
| **方式 A — Claude Code(一行):** | ||
@@ -38,3 +60,3 @@ ```bash | ||
| **Codex CLI**(`~/.codex/config.toml`): | ||
| **方式 B — Codex CLI**(`~/.codex/config.toml`): | ||
@@ -47,12 +69,23 @@ ```toml | ||
| 其他 MCP client:以 stdio server 方式執行 `npx -y sharedoc-mcp`。 | ||
| **方式 C — 其他 MCP client:**以 stdio server 執行 `npx -y sharedoc-mcp`。 | ||
| ## 快速開始(gist 後端) | ||
| ## 選後端 | ||
| 跟你的 agent 說「把這個做成分享文件」——它會呼叫 `create_shared_doc` 回傳一條 secret gist URL。Secret gist 不會被公開列出、網址無法猜測,但**拿到連結的任何人都能讀**——這就是此後端的完整安全模型;需要密碼請改用 `selfhost`。 | ||
| | | 🅰 `gist`(預設) | 🅱 `selfhost` | | ||
| |---|---|---| | ||
| | 設定 | 無——用你已登入的 `gh` CLI | 無額外設定——資料留在你機器上 | | ||
| | 文件放在 | GitHub(secret gist) | 你的機器(SQLite) | | ||
| | 連結可達性 | 任何地方、立即 | localhost——對外分享請接 tunnel | | ||
| | 密碼 | ✗(secret URL 本身就是保護) | ✓ server 端驗證(bcrypt)+ 限流 | | ||
| | 期限 | 惰性——過期 gist 於下次使用時刪除 | 強制——過期連結回 410 | | ||
| | 撤銷 | gist 立即刪除、不可逆 | 立即 410,內容 7 天緩衝後清除 | | ||
| 本地索引(`~/.config/sharedoc-mcp/index.json`)記錄你分享過的內容,供 `search_shared_docs` 與過期清理使用。此後端的期限是*惰性*的:過期 gist 在下次任一工具執行時才被刪除,不是在到期那一刻。 | ||
| ### Gist 快速開始 | ||
| ## Selfhost 後端 | ||
| 跟 agent 說「把這個做成分享文件」——它呼叫 `create_shared_doc` 回傳 secret gist URL。Secret gist 不會被公開列出、網址無法猜測,但**拿到連結的任何人都能讀**——這就是此後端的完整安全模型。需要密碼請用 `selfhost`。 | ||
| 本地索引(`~/.config/sharedoc-mcp/index.json`)記錄分享過的內容,供搜尋與過期清理。此後端的期限是*惰性*的:過期 gist 於下次任一工具執行時刪除,不是到期那一刻。 | ||
| ### Selfhost 快速開始 | ||
| ```bash | ||
@@ -62,10 +95,56 @@ claude mcp add sharedoc --scope user --env SHAREDOC_BACKEND=selfhost -- npx -y sharedoc-mcp | ||
| 文件存在 `~/.local/share/sharedoc-mcp/` 的 SQLite;viewer 於 `http://127.0.0.1:8377` 服務。Server **永遠只 bind 127.0.0.1**——對外曝光刻意交給你自己控制的 tunnel: | ||
| 文件存在 `~/.local/share/sharedoc-mcp/` 的 SQLite;viewer 於 `http://127.0.0.1:8377` 服務。要分享到機器之外,前面接一個 tunnel 並設定 `SHAREDOC_PUBLIC_URL`: | ||
| > **讓連結活得比編輯器久:**MCP 模式下 viewer 跟著 MCP client 一起關——關掉 Claude Code,selfhost 連結就暫時打不開(資料安全存在 SQLite,下次開就恢復)。要連結全天候在線,跑獨立 daemon: | ||
| > | ||
| > ```bash | ||
| > npx -y sharedoc-mcp serve # 只跑 viewer、共用同一個 DB——用 launchd/systemd/tmux 常駐 | ||
| > ``` | ||
| > | ||
| > MCP client 偵測到 daemon 已佔埠就直接沿用它。 | ||
| > | ||
| > **什麼時候該設:**第一次把連結交給別人的那一刻——跟 tunnel 一起設(兩者都該常駐,如 launchd/systemd)。在那之前 MCP 模式的 viewer 就夠用;gist 後端使用者則永遠不需要。 | ||
| | 食譜 | 適合 | 設定 | | ||
| |---|---|---| | ||
| | **Tailscale Funnel**(推薦) | 沒網域、要固定網址 | 裝 [Tailscale](https://tailscale.com) 後 `tailscale funnel 8377` → 固定 `https://<機器>.<tailnet>.ts.net`;把 `SHAREDOC_PUBLIC_URL` 設成它 | | ||
| | **Cloudflare named tunnel** | 有自己的網域 | 網域掛進 Cloudflare,`cloudflared tunnel create` + 把主機名 route 到 `http://127.0.0.1:8377`;設 `SHAREDOC_PUBLIC_URL` | | ||
| | **cloudflared quick tunnel** | 臨時分享 | `cloudflared tunnel --url http://127.0.0.1:8377` → 隨機 `trycloudflare.com` 網址,每次重啟會變;當次設 `SHAREDOC_PUBLIC_URL` | | ||
| | **Tailscale 私有連線**(推薦) | 收件人是自己的裝置/可邀進 tailnet 的人 | `tailscale serve --bg 8377` → `https://<機器>.<tailnet>.ts.net`,**只有 tailnet 內可達**——完全不暴露到公網 | | ||
| | **Tailscale Funnel** | 要分享給任何人、沒網域 | `tailscale funnel 8377` → 同一條固定網址,但公開 | | ||
| | **Cloudflare named tunnel** | 有自己的網域 | 網域掛 Cloudflare,`cloudflared tunnel create` + 主機名 route 到 `http://127.0.0.1:8377` | | ||
| | **cloudflared quick tunnel** | 臨時分享 | `cloudflared tunnel --url http://127.0.0.1:8377` → 隨機網址,每次重啟會變 | | ||
| #### 有自己的網域?Cloudflare named tunnel 逐步版 | ||
| 品牌化的固定分享網址,例如 `https://docs.example.com/docs/<uuid>`——TLS 由 Cloudflare 處理、機器在 NAT 後面也通: | ||
| ```bash | ||
| # 一次性設定(網域已掛進 Cloudflare——免費方案就夠) | ||
| cloudflared tunnel login | ||
| cloudflared tunnel create sharedoc | ||
| cloudflared tunnel route dns sharedoc docs.example.com | ||
| ``` | ||
| `~/.cloudflared/config.yml`: | ||
| ```yaml | ||
| tunnel: sharedoc | ||
| credentials-file: ~/.cloudflared/<tunnel-id>.json | ||
| ingress: | ||
| - hostname: docs.example.com | ||
| service: http://127.0.0.1:8377 | ||
| - service: http_status:404 | ||
| ``` | ||
| 跑 `cloudflared tunnel run sharedoc`(要常駐就裝成 service),MCP 註冊時帶上公開網址: | ||
| ```bash | ||
| claude mcp add sharedoc --scope user \ | ||
| --env SHAREDOC_BACKEND=selfhost \ | ||
| --env SHAREDOC_PUBLIC_URL=https://docs.example.com \ | ||
| -- npx -y sharedoc-mcp | ||
| ``` | ||
| 順帶解鎖:Cloudflare 的 DDoS 防護免費附送;可疊 WAF 規則,或在分享路徑以外套 [Cloudflare Access](https://www.cloudflare.com/zero-trust/products/access/)(SSO)——變成「內部走 SSO、對外分享靠密碼」的雙層結構。 | ||
| **另一種情境——不想依賴家裡機器常開:**把 sharedoc-mcp 跑在 VPS 上(agent 也在那執行),nginx/caddy 反代 `127.0.0.1:8377` 配網域與自動 TLS 即可,不需要 tunnel。 | ||
| 環境變數: | ||
@@ -78,13 +157,6 @@ | ||
| | `SHAREDOC_PUBLIC_URL` | `http://127.0.0.1:<port>` | 分享連結的網址前綴——設成你的 tunnel 主機名 | | ||
| | `SHAREDOC_DATA_DIR` | `~/.local/share/sharedoc-mcp` | SQLite 與檔案位置(selfhost) | | ||
| | `SHAREDOC_DATA_DIR` | `~/.local/share/sharedoc-mcp` | SQLite 位置(selfhost) | | ||
| | `SHAREDOC_INDEX_PATH` | `~/.config/sharedoc-mcp/index.json` | 本地索引(gist) | | ||
| | `MCP_CALLER` | — | 建立文件的預設作者歸因 | | ||
| ### 安全語意(誠實版) | ||
| - 密碼以 bcrypt 雜湊、server 端驗證通過才吐內容;錯誤嘗試限流(每來源+文件 5 次/分鐘,HTTP 429)。**經 tunnel 時所有外部訪客共用同一個來源位址**,實際效果是每份文件 5 次/分鐘——比逐訪客更嚴格,但一個人打錯幾次密碼會讓該文件對其他人短暫鎖定。 | ||
| - 文件內容經 `marked` 渲染並以 `sanitize-html` 消毒——分享內容中的 script、事件屬性、`javascript:` 連結都會被剝除。 | ||
| - 分享的**檔案**沒有密碼與期限:無法猜測的連結是唯一且永久的保護,下載也不限流。 | ||
| - 兩個 MCP client 可指向同一個資料目錄:SQLite 走 WAL 模式 + busy timeout;若 viewer 埠已被另一個 sharedoc-mcp 佔用,第二個 client 保留工具功能、共用既有 viewer。 | ||
| ## 8 個工具 | ||
@@ -94,11 +166,26 @@ | ||
| |---|---| | ||
| | `create_shared_doc` | 標題 + Markdown(+ 選填密碼 / `expires_in_hours` / 作者)→ 分享 URL。5 分鐘內完全相同的無保護重試回同一 URL;補加密碼或期限的重試一律建新文件 | | ||
| | `create_shared_file` | 分享本機檔案(僅 selfhost) | | ||
| | `append_to_shared_doc` | 在既有文件尾端追加 Markdown(非冪等——重試會加兩次) | | ||
| | `create_shared_doc` | 標題 + Markdown(+ 選填密碼 / `expires_in_hours` / 作者)→ 分享 URL | | ||
| | `append_to_shared_doc` | 尾端追加 Markdown(非冪等——重試會加兩次) | | ||
| | `extend_shared_doc` | 延長期限 N 小時 | | ||
| | `reset_shared_doc_password` | 設定/更換/移除(null)密碼(僅 selfhost) | | ||
| | `update_shared_doc_title` | 改標題 | | ||
| | `revoke_shared_doc` | 撤銷連結(語意見後端對照表) | | ||
| | `search_shared_docs` | 標題子字串 + 狀態篩選 | | ||
| | `revoke_shared_doc` | 撤銷連結、保留紀錄(語意見後端對照表) | | ||
| | `delete_shared_doc` | 連結失效+紀錄整個消失——不可逆 | | ||
| | `search_shared_docs` | 不帶參數=列出最新連結;標題子字串、內文搜尋(selfhost 全文;gist 僅開頭摘要)、狀態篩選 | | ||
| ## 隱私 | ||
| 各後端的資料流: | ||
| - **Gist 後端**:文件內容以 secret gist 上傳到你 GitHub 帳號下——適用 GitHub 的條款與保存政策。本地索引(標題、URL、時間戳——不含內容)留在 `~/.config/sharedoc-mcp/`。除了經你自己的 `gh` CLI 送 GitHub 之外,不送任何地方。 | ||
| - **Selfhost 後端**:內容不離開你的機器,除非你接了 tunnel——那之後就是「拿到連結的人 + 中繼流量的 tunnel 供應商」可及。密碼只以 bcrypt 雜湊儲存。 | ||
| - sharedoc-mcp 本身無遙測、不呼叫任何自己的第三方服務。 | ||
| ## 安全語意(誠實版) | ||
| - **Gist 連結即權限**:拿到 URL 就能讀。撤銷=立即刪除 gist、不可逆。 | ||
| - Selfhost 密碼於 server 端驗證通過才吐內容;錯誤嘗試限流(每來源+文件 5 次/分鐘),計數存 SQLite——重啟 server 不會歸零。**經 tunnel 時所有外部訪客共用同一來源位址**,實際效果是每份文件 5 次/分鐘——比逐訪客更嚴格;一個人打錯幾次會讓該文件對其他人短暫鎖定。 | ||
| - 刻意**沒有檔案分享工具**:可傳任意路徑的「分享這個檔案」工具是 prompt injection 的洩密面(`.env`、金鑰)——被劫持的 agent 可以直接把機敏檔發佈出去。與其做 allowlist 不如整個拿掉。 | ||
| - Viewer 永遠只 bind 127.0.0.1。它是否、如何觸及網際網路,完全由你的 tunnel 設定決定。 | ||
| ## 開發 | ||
@@ -110,9 +197,18 @@ | ||
| npm install | ||
| npm test # 先 build 再跑 52 個離線測試——gh CLI 以 mock 替身,HTTP 測試只打 127.0.0.1 | ||
| npm test # 先 build 再跑 60 個離線測試——gh CLI 以 mock 替身,HTTP 測試只打 127.0.0.1 | ||
| ``` | ||
| 版本規則:每次釋出 bump `package.json` 的 `version`、加一筆 [CHANGELOG](./CHANGELOG.md)、打 git tag 發 GitHub Release + npm。你的索引、文件 DB、檔案都在套件外——更新永遠不會動到它們。 | ||
| 版本規則:每次釋出 bump `package.json` 的 `version`、加一筆 [CHANGELOG](./CHANGELOG.md)、打 git tag 發 [GitHub Release](https://github.com/AugustusW/sharedoc-mcp/releases) + [npm](https://www.npmjs.com/package/sharedoc-mcp)。 | ||
| **想收到更新通知**:Watch 本 repo(Custom → Releases)。`npx -y` 每次冷啟動會抓最新已發佈版本;你的索引與文件 DB 都在套件外——更新永遠不會動到它們。 | ||
| ## 狀態 | ||
| v2.0.0([CHANGELOG](./CHANGELOG.md))——核心邏輯有 60 個離線單元/整合測試(`gh` CLI 以 mock 模擬;HTTP 測試只打 127.0.0.1;不需網路)。完整流程於 2026-07-25 人工驗證(經 built server 走 stdio JSON-RPC 實建 secret gist 的建立/索引/刪除,以及 selfhost 密碼流程端到端——表單 → 錯密碼 401 → 對密碼 200 → 限流 429 → 撤銷 410——並以 `lsof` 確認僅 bind 127.0.0.1),環境: | ||
| - macOS(Apple Silicon)、Node v25——gist + selfhost 兩後端 | ||
| Tunnel 食譜依各工具的標準行為撰寫;Windows/Linux 與真實 tunnel 端到端**尚未驗證**——歡迎回報。 | ||
| ## 授權 | ||
| MIT © AugustusW |
Network access
Supply chain riskThis module accesses the network.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
67738
36.35%855
14.3%210
87.5%12
-7.69%3
50%