@smartdoc/mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -2
- package/index.js +14 -0
- package/lib/bridge.js +71 -0
- package/lib/install.js +109 -0
- package/lib/launch.js +76 -0
- package/lib/platform.js +49 -0
- package/package.json +28 -4
package/README.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SmartDoc
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Review your documents with your own AI. SmartDoc is a desktop app that shows your document (a PDF); your AI reads it, marks up the text and writes findings, and you decide on each one in the app. The AI then writes the next draft from your decisions.
|
|
4
|
+
|
|
5
|
+
This package is SmartDoc's MCP server. AI apps start it, and it opens the SmartDoc app for them (downloading it from the [GitHub release](https://github.com/tw050x/smartdoc/releases) the first time).
|
|
6
|
+
|
|
7
|
+
## Claude Code
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
claude mcp add --scope user smartdoc -- npx -y @smartdoc/mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then start a new session and ask Claude to review your document.
|
|
14
|
+
|
|
15
|
+
## Claude Desktop
|
|
16
|
+
|
|
17
|
+
Download the bundle from the [website](https://tw050x.github.io/smartdoc/) and open it.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- macOS (Apple silicon or Intel). Windows is planned.
|
|
22
|
+
- Node.js 18 or later.
|
|
23
|
+
|
|
24
|
+
SmartDoc keeps your document and its drafts on your computer. Your agent reads the document through SmartDoc's tools, so what it reads goes wherever your agent sends it.
|
package/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SmartDoc's MCP server, as AI apps start it: Claude Code through npx, Claude Desktop from
|
|
3
|
+
// the .mcpb bundle. It opens the SmartDoc app (downloading it the first time) and passes
|
|
4
|
+
// the AI's messages to it. Only MCP messages go to stdout; progress and errors go to stderr.
|
|
5
|
+
import { runBridge } from './lib/bridge.js';
|
|
6
|
+
import { ensureRunning, readToken } from './lib/launch.js';
|
|
7
|
+
|
|
8
|
+
const log = (message) => process.stderr.write(`[smartdoc] ${message}\n`);
|
|
9
|
+
|
|
10
|
+
await runBridge({
|
|
11
|
+
ensure: () => ensureRunning({ log }),
|
|
12
|
+
token: () => readToken(),
|
|
13
|
+
log,
|
|
14
|
+
});
|
package/lib/bridge.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { mcpUrl } from './platform.js';
|
|
3
|
+
|
|
4
|
+
// Carries MCP messages between an AI app, which talks to this launcher over stdio (one JSON-RPC
|
|
5
|
+
// message per line), and the app's MCP server over HTTP. Messages wait until the app is
|
|
6
|
+
// running. If the app goes away (say the user quits it), the next message opens it again.
|
|
7
|
+
//
|
|
8
|
+
// `ensure()` makes sure the app is running; `token()` reads its current token.
|
|
9
|
+
export function runBridge({ input = process.stdin, output = process.stdout, env = process.env, ensure, token, log = () => {} }) {
|
|
10
|
+
let ready = null;
|
|
11
|
+
const whenReady = () => (ready ??= ensure().catch((error) => {
|
|
12
|
+
ready = null; // try again with the next message
|
|
13
|
+
throw error;
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const write = (message) => output.write(`${JSON.stringify(message)}\n`);
|
|
17
|
+
const fail = (id, message) => id !== undefined && write({ jsonrpc: '2.0', id, error: { code: -32000, message } });
|
|
18
|
+
|
|
19
|
+
async function post(line) {
|
|
20
|
+
const res = await fetch(mcpUrl(env), {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: `Bearer ${token() ?? ''}` },
|
|
23
|
+
body: line,
|
|
24
|
+
});
|
|
25
|
+
return { status: res.status, text: (await res.text()).trim() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function forward(line, message) {
|
|
29
|
+
await whenReady();
|
|
30
|
+
let reply;
|
|
31
|
+
try {
|
|
32
|
+
reply = await post(line);
|
|
33
|
+
} catch {
|
|
34
|
+
// Nothing listening: the app was closed since. Open it again and retry once.
|
|
35
|
+
ready = null;
|
|
36
|
+
await whenReady();
|
|
37
|
+
reply = await post(line);
|
|
38
|
+
}
|
|
39
|
+
if (reply.status >= 400) {
|
|
40
|
+
let reason = `The app refused the request (HTTP ${reply.status}).`;
|
|
41
|
+
try {
|
|
42
|
+
reason = JSON.parse(reply.text).error ?? reason;
|
|
43
|
+
} catch {}
|
|
44
|
+
return fail(message.id, reason);
|
|
45
|
+
}
|
|
46
|
+
if (reply.text) output.write(`${reply.text}\n`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const pending = new Set();
|
|
50
|
+
const lines = readline.createInterface({ input });
|
|
51
|
+
lines.on('line', (line) => {
|
|
52
|
+
if (!line.trim()) return;
|
|
53
|
+
let message;
|
|
54
|
+
try {
|
|
55
|
+
message = JSON.parse(line);
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const job = forward(line, message).catch((error) => {
|
|
60
|
+
log(error.message);
|
|
61
|
+
fail(message.id, error.message);
|
|
62
|
+
});
|
|
63
|
+
pending.add(job);
|
|
64
|
+
job.finally(() => pending.delete(job));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Start the app straight away, rather than waiting for the first message.
|
|
68
|
+
whenReady().catch((error) => log(error.message));
|
|
69
|
+
|
|
70
|
+
return new Promise((resolve) => lines.on('close', () => Promise.allSettled([...pending]).then(resolve)));
|
|
71
|
+
}
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createWriteStream, existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, rename, rm, stat } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Readable, Transform } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { APP_NAME, CHECKSUMS, VERSION, assetName, cacheDir, downloadBase } from './platform.js';
|
|
10
|
+
|
|
11
|
+
// Fetches the app build matching this launcher's version from the GitHub release, checks it
|
|
12
|
+
// against the release's checksums, and unpacks it into the cache, once per version. Two
|
|
13
|
+
// launchers starting at the same time (say Claude Desktop and Claude Code) share one download:
|
|
14
|
+
// the first takes a lock and the other waits for it to finish.
|
|
15
|
+
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
18
|
+
const WAIT_STEP_MS = 500;
|
|
19
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
|
|
21
|
+
export class UnsupportedPlatformError extends Error {}
|
|
22
|
+
|
|
23
|
+
export function parseChecksums(text) {
|
|
24
|
+
const sums = new Map();
|
|
25
|
+
for (const line of text.split(/\r?\n/)) {
|
|
26
|
+
const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
|
|
27
|
+
if (match) sums.set(match[2].trim(), match[1].toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
return sums;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The installed app for this version, downloading it first if needed. Resolves to its path.
|
|
33
|
+
export async function ensureInstalled({ env = process.env, log = () => {} } = {}) {
|
|
34
|
+
const asset = assetName();
|
|
35
|
+
if (!asset) throw new UnsupportedPlatformError(`${APP_NAME} isn’t available for ${process.platform} (${process.arch}) yet. It runs on macOS for now.`);
|
|
36
|
+
const root = cacheDir({ env });
|
|
37
|
+
const dir = path.join(root, VERSION);
|
|
38
|
+
const appPath = path.join(dir, `${APP_NAME}.app`);
|
|
39
|
+
if (existsSync(appPath)) return appPath;
|
|
40
|
+
|
|
41
|
+
await mkdir(root, { recursive: true });
|
|
42
|
+
const lock = `${dir}.lock`;
|
|
43
|
+
while (!(await takeLock(lock))) {
|
|
44
|
+
if (existsSync(appPath)) return appPath;
|
|
45
|
+
await sleep(WAIT_STEP_MS);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(appPath)) return appPath;
|
|
49
|
+
const base = downloadBase({ env });
|
|
50
|
+
log(`Downloading ${APP_NAME} ${VERSION}…`);
|
|
51
|
+
const sums = parseChecksums(await (await get(`${base}/${CHECKSUMS}`)).text());
|
|
52
|
+
const expected = sums.get(asset);
|
|
53
|
+
if (!expected) throw new Error(`The release has no checksum for ${asset}.`);
|
|
54
|
+
|
|
55
|
+
const zip = `${dir}.download.zip`;
|
|
56
|
+
const actual = await download(`${base}/${asset}`, zip);
|
|
57
|
+
if (actual !== expected) {
|
|
58
|
+
await rm(zip, { force: true });
|
|
59
|
+
throw new Error(`The download of ${asset} didn’t match the release’s checksum, so it wasn’t used.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ditto keeps the app's code signature, permissions and symlinks intact.
|
|
63
|
+
const unpacked = `${dir}.partial`;
|
|
64
|
+
await rm(unpacked, { recursive: true, force: true });
|
|
65
|
+
await mkdir(unpacked);
|
|
66
|
+
await run('ditto', ['-x', '-k', zip, unpacked]);
|
|
67
|
+
await rm(zip, { force: true });
|
|
68
|
+
if (!existsSync(path.join(unpacked, `${APP_NAME}.app`))) throw new Error(`${asset} doesn’t contain ${APP_NAME}.app.`);
|
|
69
|
+
await rename(unpacked, dir);
|
|
70
|
+
log(`Installed ${APP_NAME} ${VERSION}.`);
|
|
71
|
+
return appPath;
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(lock, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A lock is a folder: creating it is atomic. One left behind by a launcher that died mid-download
|
|
78
|
+
// is taken over once it's old enough.
|
|
79
|
+
async function takeLock(lock) {
|
|
80
|
+
try {
|
|
81
|
+
await mkdir(lock);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code !== 'EEXIST') throw error;
|
|
85
|
+
const { mtimeMs } = await stat(lock).catch(() => ({ mtimeMs: 0 }));
|
|
86
|
+
if (Date.now() - mtimeMs > LOCK_STALE_MS) await rm(lock, { recursive: true, force: true });
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function get(url) {
|
|
92
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
93
|
+
if (!res.ok) throw new Error(`Couldn’t download ${url} (HTTP ${res.status}).`);
|
|
94
|
+
return res;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Streams a download to a file, hashing it on the way. Resolves to its SHA-256.
|
|
98
|
+
async function download(url, file) {
|
|
99
|
+
const res = await get(url);
|
|
100
|
+
const hash = createHash('sha256');
|
|
101
|
+
const tap = new Transform({
|
|
102
|
+
transform(chunk, _encoding, done) {
|
|
103
|
+
hash.update(chunk);
|
|
104
|
+
done(null, chunk);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
await pipeline(Readable.fromWeb(res.body), tap, createWriteStream(file));
|
|
108
|
+
return hash.digest('hex');
|
|
109
|
+
}
|
package/lib/launch.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ensureInstalled } from './install.js';
|
|
5
|
+
import { APP_NAME, mcpUrl, userDataDir } from './platform.js';
|
|
6
|
+
|
|
7
|
+
// Makes sure the app is running with its MCP server up, opening it if not. A running app is
|
|
8
|
+
// shared: every launcher connects to the same one, and it stays open when they disconnect.
|
|
9
|
+
|
|
10
|
+
const START_TIMEOUT_MS = 90 * 1000;
|
|
11
|
+
const POLL_MS = 400;
|
|
12
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
|
|
14
|
+
// The token the app's MCP server expects. It's kept in the app's data folder, so it's only
|
|
15
|
+
// readable by the user (and only exists once the app has run).
|
|
16
|
+
export function readToken({ env = process.env } = {}) {
|
|
17
|
+
try {
|
|
18
|
+
return readFileSync(path.join(userDataDir({ env }), 'mcp-token'), 'utf8').trim();
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 'up' when the server answers with this token; 'down' when nothing is listening; 'locked' when
|
|
25
|
+
// something answers but refuses the token (it may not have been written yet).
|
|
26
|
+
export async function probe({ env = process.env } = {}) {
|
|
27
|
+
const token = readToken({ env });
|
|
28
|
+
try {
|
|
29
|
+
// The server only takes POST, so a GET that gets past the token check answers 405.
|
|
30
|
+
const res = await fetch(mcpUrl(env), { headers: token ? { authorization: `Bearer ${token}` } : {} });
|
|
31
|
+
await res.body?.cancel();
|
|
32
|
+
return res.status === 405 ? 'up' : 'locked';
|
|
33
|
+
} catch {
|
|
34
|
+
return 'down';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function ensureRunning({ env = process.env, log = () => {} } = {}) {
|
|
39
|
+
if ((await probe({ env })) === 'up') return;
|
|
40
|
+
await openApp({ env, log });
|
|
41
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
42
|
+
while (Date.now() < deadline) {
|
|
43
|
+
if ((await probe({ env })) === 'up') return;
|
|
44
|
+
await sleep(POLL_MS);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`${APP_NAME} didn’t start in time. Open it yourself, then try again.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function openApp({ env, log }) {
|
|
50
|
+
const override = env.SMARTDOC_APP;
|
|
51
|
+
if (override) {
|
|
52
|
+
log(`Opening ${override}…`);
|
|
53
|
+
return start(appCommand(path.resolve(override)), env);
|
|
54
|
+
}
|
|
55
|
+
const appPath = await ensureInstalled({ env, log });
|
|
56
|
+
log(`Opening ${APP_NAME}…`);
|
|
57
|
+
return start(['open', [appPath]], env);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// How to start an app given by path: a macOS app bundle, a project folder (run with its own
|
|
61
|
+
// Electron, as in development) or an executable.
|
|
62
|
+
export function appCommand(target) {
|
|
63
|
+
if (target.endsWith('.app')) return ['open', [target]];
|
|
64
|
+
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
65
|
+
const electron = path.join(target, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron');
|
|
66
|
+
return [electron, [target]];
|
|
67
|
+
}
|
|
68
|
+
return [target, []];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Detached, so the app outlives this launcher and the AI app that started it.
|
|
72
|
+
function start([command, args], env) {
|
|
73
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', env });
|
|
74
|
+
child.on('error', () => {}); // reported as a timeout by ensureRunning
|
|
75
|
+
child.unref();
|
|
76
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Where things are on this computer, and which build of the app it needs. Everything here can be
|
|
6
|
+
// overridden with environment variables, which development and tests use to keep away from the
|
|
7
|
+
// user's real app and data:
|
|
8
|
+
// SMARTDOC_APP the app to open: a SmartDoc.app, an executable, or a project
|
|
9
|
+
// folder (run with its own Electron), instead of the downloaded one
|
|
10
|
+
// SMARTDOC_USER_DATA the app's data folder (passed on to a project-folder app)
|
|
11
|
+
// SMARTDOC_PORT the port the app's MCP server listens on (likewise)
|
|
12
|
+
// SMARTDOC_CACHE where downloaded builds are kept
|
|
13
|
+
// SMARTDOC_DOWNLOADS the base URL builds are downloaded from (a release's download URL)
|
|
14
|
+
|
|
15
|
+
export const APP_NAME = 'SmartDoc';
|
|
16
|
+
export const REPOSITORY = 'tw050x/smartdoc';
|
|
17
|
+
export const DEFAULT_PORT = 47815;
|
|
18
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
19
|
+
|
|
20
|
+
export const port = (env = process.env) => Number(env.SMARTDOC_PORT) || DEFAULT_PORT;
|
|
21
|
+
export const mcpUrl = (env = process.env) => `http://127.0.0.1:${port(env)}/mcp`;
|
|
22
|
+
|
|
23
|
+
// The folder Electron gives the app for its data (it holds the MCP server's token).
|
|
24
|
+
export function userDataDir({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
25
|
+
if (env.SMARTDOC_USER_DATA) return path.resolve(env.SMARTDOC_USER_DATA);
|
|
26
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', APP_NAME);
|
|
27
|
+
if (platform === 'win32') return path.join(env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), APP_NAME);
|
|
28
|
+
return path.join(env.XDG_CONFIG_HOME ?? path.join(home, '.config'), APP_NAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Downloaded builds live here, one folder per version.
|
|
32
|
+
export function cacheDir({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
33
|
+
if (env.SMARTDOC_CACHE) return path.resolve(env.SMARTDOC_CACHE);
|
|
34
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Caches', 'smartdoc');
|
|
35
|
+
if (platform === 'win32') return path.join(env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local'), 'smartdoc', 'Cache');
|
|
36
|
+
return path.join(env.XDG_CACHE_HOME ?? path.join(home, '.cache'), 'smartdoc');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The release file holding the app for this computer, as named by the release build
|
|
40
|
+
// (electron-builder's artifactName in the app's package.json). Null where there's no build yet.
|
|
41
|
+
export function assetName({ version = VERSION, platform = process.platform, arch = process.arch } = {}) {
|
|
42
|
+
if (platform === 'darwin' && (arch === 'arm64' || arch === 'x64')) return `smartdoc-${version}-mac-${arch}.zip`;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const downloadBase = ({ env = process.env, version = VERSION } = {}) =>
|
|
47
|
+
(env.SMARTDOC_DOWNLOADS ?? `https://github.com/${REPOSITORY}/releases/download/v${version}`).replace(/\/$/, '');
|
|
48
|
+
|
|
49
|
+
export const CHECKSUMS = 'SHA256SUMS.txt';
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartdoc/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Review documents with your own AI. Opens the SmartDoc app and connects it to Claude Code, Claude Desktop and other MCP clients.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"smartdoc-mcp": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"lib/"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/tw050x/smartdoc.git",
|
|
20
|
+
"directory": "packages/mcp"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://tw050x.github.io/smartdoc/",
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"document",
|
|
26
|
+
"review",
|
|
27
|
+
"pdf",
|
|
28
|
+
"claude"
|
|
29
|
+
]
|
|
30
|
+
}
|