🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

sharedoc-mcp

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

sharedoc-mcp - npm Package Compare versions

Comparing version
1.0.0
to
2.0.0
+30
-7
dist/backend/gist.js

@@ -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));

#!/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); });

@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 @@ });

{
"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
[![npm](https://img.shields.io/npm/v/sharedoc-mcp?color=brightgreen)](https://www.npmjs.com/package/sharedoc-mcp)
[![Release](https://img.shields.io/github/v/release/AugustusW/sharedoc-mcp?color=brightgreen)](https://github.com/AugustusW/sharedoc-mcp/releases)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-blue.svg)](https://nodejs.org/)
[![MCP](https://img.shields.io/badge/MCP-stdio%20server-orange.svg)](https://modelcontextprotocol.io/)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-compatible-orange.svg)](https://claude.com/claude-code)
[![Codex](https://img.shields.io/badge/Codex-compatible-black.svg)](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
# 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
[![npm](https://img.shields.io/npm/v/sharedoc-mcp?color=brightgreen)](https://www.npmjs.com/package/sharedoc-mcp)
[![Release](https://img.shields.io/github/v/release/AugustusW/sharedoc-mcp?color=brightgreen)](https://github.com/AugustusW/sharedoc-mcp/releases)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Node](https://img.shields.io/badge/node-%E2%89%A522.13-blue.svg)](https://nodejs.org/)
[![MCP](https://img.shields.io/badge/MCP-stdio%20server-orange.svg)](https://modelcontextprotocol.io/)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-compatible-orange.svg)](https://claude.com/claude-code)
[![Codex](https://img.shields.io/badge/Codex-compatible-black.svg)](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