sitedrift 0.1.0 → 0.2.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/AGENTS.md +101 -0
- package/README.md +151 -27
- package/assets/viewer.css +766 -0
- package/assets/viewer.html +159 -0
- package/assets/viewer.js +859 -0
- package/docs/images/sitedrift-collaboration.jpg +0 -0
- package/docs/images/sitedrift-diff.jpg +0 -0
- package/docs/images/sitedrift-mobile.jpg +0 -0
- package/docs/images/sitedrift-split.jpg +0 -0
- package/package.json +19 -4
- package/sitedrift-mcp.mjs +4 -0
- package/sitedrift.mjs +71 -1789
- package/src/agent.mjs +73 -0
- package/src/browser.mjs +9 -0
- package/src/cli.mjs +215 -0
- package/src/http.mjs +21 -0
- package/src/mcp.mjs +324 -0
- package/src/notes.mjs +78 -0
- package/src/proxy.mjs +146 -0
- package/src/server.mjs +171 -0
- package/src/session.mjs +53 -0
- package/src/tls.mjs +115 -0
- package/src/viewer.mjs +38 -0
package/src/agent.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { readSession } from './session.mjs';
|
|
4
|
+
|
|
5
|
+
function output(value) {
|
|
6
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function routeFor(command) {
|
|
10
|
+
if (command.name === 'status' || command.name === 'context') return '/api/v1/session';
|
|
11
|
+
return '/api/v1/notes';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function requestSession(session, pathname, init = {}) {
|
|
15
|
+
const url = new URL(pathname, session.url);
|
|
16
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
17
|
+
const text = await new Promise((resolve, reject) => {
|
|
18
|
+
const req = transport.request(url, {
|
|
19
|
+
method: init.method || 'GET',
|
|
20
|
+
rejectUnauthorized: false,
|
|
21
|
+
headers: {
|
|
22
|
+
authorization: `Bearer ${session.token}`,
|
|
23
|
+
'content-type': 'application/json',
|
|
24
|
+
...(init.headers || {}),
|
|
25
|
+
},
|
|
26
|
+
}, (res) => {
|
|
27
|
+
let data = '';
|
|
28
|
+
res.setEncoding('utf8');
|
|
29
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
30
|
+
res.on('end', () => {
|
|
31
|
+
if ((res.statusCode || 500) >= 400) {
|
|
32
|
+
try { reject(new Error(JSON.parse(data).error)); } catch { reject(new Error(data || `HTTP ${res.statusCode}`)); }
|
|
33
|
+
} else {
|
|
34
|
+
resolve(data);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
req.on('error', reject);
|
|
39
|
+
if (init.body) req.write(init.body);
|
|
40
|
+
req.end();
|
|
41
|
+
});
|
|
42
|
+
return JSON.parse(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runAgentCommand(command, config) {
|
|
46
|
+
const session = readSession(config.port);
|
|
47
|
+
if (command.name === 'status' || command.name === 'context' || command.action === 'list') {
|
|
48
|
+
output(await requestSession(session, routeFor(command)));
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const op = command.action === 'add'
|
|
53
|
+
? {
|
|
54
|
+
op: 'add',
|
|
55
|
+
text: command.text,
|
|
56
|
+
author: command.author || config.author,
|
|
57
|
+
route: command.route || '/',
|
|
58
|
+
side: command.side || null,
|
|
59
|
+
}
|
|
60
|
+
: command.action === 'resolve'
|
|
61
|
+
? { op: 'resolve', id: command.id }
|
|
62
|
+
: command.action === 'reopen'
|
|
63
|
+
? { op: 'reopen', id: command.id }
|
|
64
|
+
: command.action === 'remove'
|
|
65
|
+
? { op: 'remove', id: command.id }
|
|
66
|
+
: { op: 'clear' };
|
|
67
|
+
|
|
68
|
+
output(await requestSession(session, '/api/v1/notes', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
body: JSON.stringify(op),
|
|
71
|
+
}));
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function openBrowser(url) {
|
|
4
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
5
|
+
: process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
6
|
+
try {
|
|
7
|
+
spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
|
|
8
|
+
} catch {}
|
|
9
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Short flags and the boolean flags that never consume the next argument.
|
|
6
|
+
const ALIASES = { d: 'dev', l: 'live', p: 'port', o: 'open', h: 'help', v: 'version' };
|
|
7
|
+
const BOOLEANS = new Set(['open', 'http', 'https', 'setup-https', 'help', 'version']);
|
|
8
|
+
const VALUE_FLAGS = new Set([
|
|
9
|
+
'dev', 'live', 'port', 'host', 'cert', 'key', 'notes', 'brand', 'author',
|
|
10
|
+
'vault', 'config', 'route', 'side',
|
|
11
|
+
]);
|
|
12
|
+
const KNOWN_FLAGS = new Set([...BOOLEANS, ...VALUE_FLAGS]);
|
|
13
|
+
const CONFIG_NAMES = ['sitedrift.config.json', '.sitedriftrc.json'];
|
|
14
|
+
|
|
15
|
+
export function parseArgs(argv) {
|
|
16
|
+
const opts = {};
|
|
17
|
+
const positionals = [];
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
let arg = argv[i];
|
|
20
|
+
if (arg === '--') { positionals.push(...argv.slice(i + 1)); break; }
|
|
21
|
+
if (arg[0] !== '-' || arg === '-') { positionals.push(arg); continue; }
|
|
22
|
+
arg = arg.replace(/^--?/, '');
|
|
23
|
+
let value;
|
|
24
|
+
const eq = arg.indexOf('=');
|
|
25
|
+
if (eq !== -1) { value = arg.slice(eq + 1); arg = arg.slice(0, eq); }
|
|
26
|
+
const name = ALIASES[arg] || arg;
|
|
27
|
+
if (!KNOWN_FLAGS.has(name)) throw new Error(`Unknown option: --${arg}`);
|
|
28
|
+
if (BOOLEANS.has(name)) { opts[name] = true; continue; }
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
const next = argv[i + 1];
|
|
31
|
+
if (next !== undefined && next[0] !== '-') { value = next; i++; }
|
|
32
|
+
else throw new Error(`Option --${name} requires a value.`);
|
|
33
|
+
}
|
|
34
|
+
opts[name] = value;
|
|
35
|
+
}
|
|
36
|
+
return { opts, positionals };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findConfigFile(start = process.cwd()) {
|
|
40
|
+
let dir = path.resolve(start);
|
|
41
|
+
while (true) {
|
|
42
|
+
for (const name of CONFIG_NAMES) {
|
|
43
|
+
const file = path.join(dir, name);
|
|
44
|
+
if (fs.existsSync(file)) return file;
|
|
45
|
+
}
|
|
46
|
+
const parent = path.dirname(dir);
|
|
47
|
+
if (parent === dir) return null;
|
|
48
|
+
dir = parent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readConfigFile(explicit) {
|
|
53
|
+
const file = explicit ? path.resolve(explicit) : findConfigFile();
|
|
54
|
+
if (!file) return {};
|
|
55
|
+
let value;
|
|
56
|
+
try {
|
|
57
|
+
value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`Could not read config ${file}: ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') {
|
|
62
|
+
throw new Error(`Config ${file} must contain a JSON object.`);
|
|
63
|
+
}
|
|
64
|
+
const allowed = new Set(['dev', 'live', 'port', 'host', 'cert', 'key', 'notes', 'brand', 'author', 'vault', 'https', 'open']);
|
|
65
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
66
|
+
if (unknown.length) throw new Error(`Unknown config key${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// SITEDRIFT_<NAME> is the public env var; SITE_COMPARE_<NAME> is the legacy name
|
|
71
|
+
// kept so the `site compare` wrapper keeps working after extraction.
|
|
72
|
+
function envVal(name) {
|
|
73
|
+
const v = process.env[`SITEDRIFT_${name}`] ?? process.env[`SITE_COMPARE_${name}`];
|
|
74
|
+
return v === undefined || v === '' ? undefined : v;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Precedence for every setting: CLI flag > env > built-in default.
|
|
78
|
+
function pick(opts, fileConfig, flag, name, fallback) {
|
|
79
|
+
return opts[flag] ?? envVal(name) ?? fileConfig[flag] ?? fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function boolean(value, name) {
|
|
83
|
+
if (typeof value === 'boolean') return value;
|
|
84
|
+
if (value === undefined) return false;
|
|
85
|
+
if (value === '1' || value === 'true') return true;
|
|
86
|
+
if (value === '0' || value === 'false') return false;
|
|
87
|
+
throw new Error(`${name} must be true/false or 1/0.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function cleanBase(value) {
|
|
91
|
+
const url = new URL(value);
|
|
92
|
+
url.pathname = url.pathname.replace(/\/+$/, '');
|
|
93
|
+
url.search = '';
|
|
94
|
+
url.hash = '';
|
|
95
|
+
return url;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readVersion() {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
101
|
+
} catch { return '0.0.0'; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function printHelp() {
|
|
105
|
+
console.log(`sitedrift — frame local dev against production, side-by-side on the same route.
|
|
106
|
+
|
|
107
|
+
Usage:
|
|
108
|
+
sitedrift [path] [options]
|
|
109
|
+
npx sitedrift /pricing --dev http://localhost:4321 --live https://example.com --open
|
|
110
|
+
sitedrift status
|
|
111
|
+
sitedrift context
|
|
112
|
+
sitedrift mcp
|
|
113
|
+
sitedrift notes list
|
|
114
|
+
sitedrift notes add <text> [--route /path] [--side dev|live] [--author name]
|
|
115
|
+
sitedrift notes resolve|reopen|remove <id>
|
|
116
|
+
sitedrift notes clear
|
|
117
|
+
|
|
118
|
+
Options:
|
|
119
|
+
-d, --dev <url> Left-pane (dev) origin [default http://127.0.0.1:4321]
|
|
120
|
+
-l, --live <url> Right-pane (live) origin [default https://example.com]
|
|
121
|
+
-p, --port <n> Listen port [default 4178]
|
|
122
|
+
--host <addr> Bind address [default 127.0.0.1]
|
|
123
|
+
-o, --open Open the viewer in your browser
|
|
124
|
+
--https Serve HTTPS with an auto cert (mkcert if present, else openssl)
|
|
125
|
+
--setup-https One-time: generate + trust a local cert, then exit
|
|
126
|
+
--http Force plain HTTP (the default; overrides --https)
|
|
127
|
+
--cert <file> TLS cert (serve HTTPS; needs --key)
|
|
128
|
+
--key <file> TLS key
|
|
129
|
+
--notes <file> Shared review-notes JSON [default \$TMPDIR/sitedrift-notes.json]
|
|
130
|
+
--brand <text> Strip "| <text>" from pane-header titles
|
|
131
|
+
--author <name> Byline for notes added in the viewer
|
|
132
|
+
--vault <dir> Enable "Send to vault" (writes review markdown here)
|
|
133
|
+
--config <file> Read project configuration from a JSON file
|
|
134
|
+
-h, --help Show this help
|
|
135
|
+
-v, --version Print version
|
|
136
|
+
|
|
137
|
+
Every option also reads SITEDRIFT_<NAME> (e.g. SITEDRIFT_DEV). Binds to
|
|
138
|
+
127.0.0.1 by default — it strips framing/isolation headers, so never expose it
|
|
139
|
+
publicly. See https://github.com/joeseverino/sitedrift`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resolveConfig(argv = process.argv.slice(2)) {
|
|
143
|
+
const { opts, positionals } = parseArgs(argv);
|
|
144
|
+
if (positionals.length > 1) throw new Error(`Unexpected argument: ${positionals[1]}`);
|
|
145
|
+
const fileConfig = readConfigFile(opts.config);
|
|
146
|
+
const port = Number(pick(opts, fileConfig, 'port', 'PORT', 4178));
|
|
147
|
+
if (!Number.isInteger(port) || port < 1 || port > 65533) {
|
|
148
|
+
throw new Error('Port must be an integer from 1 to 65533 (the next two ports isolate DEV and LIVE).');
|
|
149
|
+
}
|
|
150
|
+
const certFile = opts.http ? undefined : pick(opts, fileConfig, 'cert', 'CERT', undefined);
|
|
151
|
+
const keyFile = opts.http ? undefined : pick(opts, fileConfig, 'key', 'KEY', undefined);
|
|
152
|
+
if (!!certFile !== !!keyFile) throw new Error('--cert and --key must be provided together.');
|
|
153
|
+
const host = pick(opts, fileConfig, 'host', 'HOST', '127.0.0.1');
|
|
154
|
+
if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
|
|
155
|
+
throw new Error('Host must be loopback (127.0.0.1, localhost, or ::1).');
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
opts,
|
|
159
|
+
help: !!opts.help,
|
|
160
|
+
version: !!opts.version,
|
|
161
|
+
setupHttps: !!opts['setup-https'],
|
|
162
|
+
https: !opts.http && boolean(pick(opts, fileConfig, 'https', 'HTTPS', false), 'https'),
|
|
163
|
+
host,
|
|
164
|
+
port,
|
|
165
|
+
devBase: cleanBase(pick(opts, fileConfig, 'dev', 'DEV', 'http://127.0.0.1:4321')),
|
|
166
|
+
liveBase: cleanBase(pick(opts, fileConfig, 'live', 'LIVE', 'https://example.com')),
|
|
167
|
+
certFile,
|
|
168
|
+
keyFile,
|
|
169
|
+
notesFile: pick(opts, fileConfig, 'notes', 'NOTES', `${os.tmpdir()}/sitedrift-notes.json`),
|
|
170
|
+
brand: pick(opts, fileConfig, 'brand', 'BRAND', ''),
|
|
171
|
+
author: pick(opts, fileConfig, 'author', 'AUTHOR', 'you'),
|
|
172
|
+
vaultDir: pick(opts, fileConfig, 'vault', 'VAULT', ''),
|
|
173
|
+
open: boolean(pick(opts, fileConfig, 'open', 'OPEN', false), 'open'),
|
|
174
|
+
initialPath: positionals[0]
|
|
175
|
+
? '/' + String(positionals[0]).replace(/^\/+/, '')
|
|
176
|
+
: '',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function parseCommand(argv = process.argv.slice(2)) {
|
|
181
|
+
const name = argv[0];
|
|
182
|
+
if (name !== 'status' && name !== 'context' && name !== 'notes' && name !== 'mcp') return null;
|
|
183
|
+
if (name === 'mcp') {
|
|
184
|
+
if (argv.length > 1) throw new Error('Usage: sitedrift mcp');
|
|
185
|
+
return { command: { name }, argv: [] };
|
|
186
|
+
}
|
|
187
|
+
if (name === 'status' || name === 'context') {
|
|
188
|
+
return { command: { name }, argv: argv.slice(1) };
|
|
189
|
+
}
|
|
190
|
+
const action = argv[1];
|
|
191
|
+
if (!['list', 'add', 'resolve', 'reopen', 'remove', 'clear'].includes(action)) {
|
|
192
|
+
throw new Error('Usage: sitedrift notes list|add|resolve|reopen|remove|clear');
|
|
193
|
+
}
|
|
194
|
+
const tail = argv.slice(2);
|
|
195
|
+
let subject;
|
|
196
|
+
if (action === 'add' || ['resolve', 'reopen', 'remove'].includes(action)) {
|
|
197
|
+
subject = tail.shift();
|
|
198
|
+
if (!subject) throw new Error(`sitedrift notes ${action} requires ${action === 'add' ? 'text' : 'an id'}.`);
|
|
199
|
+
}
|
|
200
|
+
const { opts, positionals } = parseArgs(tail);
|
|
201
|
+
if (positionals.length) throw new Error(`Unexpected argument: ${positionals[0]}`);
|
|
202
|
+
if (opts.side && !['dev', 'live'].includes(opts.side)) throw new Error('--side must be dev or live.');
|
|
203
|
+
return {
|
|
204
|
+
command: {
|
|
205
|
+
name,
|
|
206
|
+
action,
|
|
207
|
+
text: action === 'add' ? subject : undefined,
|
|
208
|
+
id: action === 'add' ? undefined : subject,
|
|
209
|
+
route: opts.route,
|
|
210
|
+
side: opts.side,
|
|
211
|
+
author: opts.author,
|
|
212
|
+
},
|
|
213
|
+
argv: tail,
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/http.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Small shared HTTP helpers used by the request handler and proxy.
|
|
2
|
+
|
|
3
|
+
export function send(res, status, body, type = 'text/plain; charset=utf-8') {
|
|
4
|
+
res.writeHead(status, {
|
|
5
|
+
'Content-Type': type,
|
|
6
|
+
'Cache-Control': 'no-store',
|
|
7
|
+
});
|
|
8
|
+
res.end(body);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readBody(req) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let data = '';
|
|
14
|
+
req.on('data', (chunk) => {
|
|
15
|
+
data += chunk;
|
|
16
|
+
if (data.length > 1e6) req.destroy();
|
|
17
|
+
});
|
|
18
|
+
req.on('end', () => resolve(data));
|
|
19
|
+
req.on('error', () => resolve(data));
|
|
20
|
+
});
|
|
21
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { readVersion } from './cli.mjs';
|
|
2
|
+
import { requestSession } from './agent.mjs';
|
|
3
|
+
import { readSession } from './session.mjs';
|
|
4
|
+
|
|
5
|
+
const PROTOCOL_VERSIONS = new Set([
|
|
6
|
+
'2024-11-05',
|
|
7
|
+
'2025-03-26',
|
|
8
|
+
'2025-06-18',
|
|
9
|
+
'2025-11-25',
|
|
10
|
+
]);
|
|
11
|
+
const LATEST_PROTOCOL_VERSION = '2025-11-25';
|
|
12
|
+
|
|
13
|
+
const TOOLS = [
|
|
14
|
+
{
|
|
15
|
+
name: 'sitedrift_context',
|
|
16
|
+
title: 'Get sitedrift session context',
|
|
17
|
+
description: 'Get the active DEV/LIVE targets, viewer URL, capabilities, and session metadata. Call this first.',
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
|
|
22
|
+
},
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
},
|
|
25
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'sitedrift_notes_list',
|
|
29
|
+
title: 'List review notes',
|
|
30
|
+
description: 'List the current shared visual-review notes.',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
|
|
35
|
+
},
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'sitedrift_note_add',
|
|
42
|
+
title: 'Add a review note',
|
|
43
|
+
description: 'Add one concrete visual finding for the user or another agent.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
required: ['text'],
|
|
47
|
+
properties: {
|
|
48
|
+
text: { type: 'string', minLength: 1, maxLength: 2000 },
|
|
49
|
+
route: { type: 'string', default: '/' },
|
|
50
|
+
side: { type: ['string', 'null'], enum: ['dev', 'live', null] },
|
|
51
|
+
author: { type: 'string', default: 'agent' },
|
|
52
|
+
port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
|
|
53
|
+
},
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
},
|
|
56
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
57
|
+
},
|
|
58
|
+
...['resolve', 'reopen', 'remove'].map((action) => ({
|
|
59
|
+
name: `sitedrift_note_${action}`,
|
|
60
|
+
title: `${action[0].toUpperCase()}${action.slice(1)} a review note`,
|
|
61
|
+
description: `${action[0].toUpperCase()}${action.slice(1)} one shared review note by ID.`,
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
required: ['id'],
|
|
65
|
+
properties: {
|
|
66
|
+
id: { type: 'string', minLength: 1 },
|
|
67
|
+
port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
|
|
68
|
+
},
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
annotations: {
|
|
72
|
+
readOnlyHint: false,
|
|
73
|
+
destructiveHint: action === 'remove',
|
|
74
|
+
idempotentHint: action !== 'remove',
|
|
75
|
+
openWorldHint: false,
|
|
76
|
+
},
|
|
77
|
+
})),
|
|
78
|
+
{
|
|
79
|
+
name: 'sitedrift_notes_clear',
|
|
80
|
+
title: 'Clear all review notes',
|
|
81
|
+
description: 'Delete every note in the active review session. Use only when the user explicitly requests it.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
port: { type: 'integer', minimum: 1, maximum: 65533, default: 4178 },
|
|
86
|
+
},
|
|
87
|
+
additionalProperties: false,
|
|
88
|
+
},
|
|
89
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'sitedrift_setup',
|
|
93
|
+
title: 'Get setup instructions',
|
|
94
|
+
description: 'Return the shortest install, project configuration, launch, HTTPS, and MCP-client setup instructions.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
dev: { type: 'string', description: 'Local development origin.' },
|
|
99
|
+
live: { type: 'string', description: 'Production origin.' },
|
|
100
|
+
},
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
},
|
|
103
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
function jsonResult(value) {
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
110
|
+
structuredContent: value,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function setupInstructions(args = {}) {
|
|
115
|
+
const dev = args.dev || 'http://localhost:4321';
|
|
116
|
+
const live = args.live || 'https://example.com';
|
|
117
|
+
return {
|
|
118
|
+
install: 'npm install --global sitedrift',
|
|
119
|
+
configFile: 'sitedrift.config.json',
|
|
120
|
+
config: { dev, live, open: true },
|
|
121
|
+
launch: 'sitedrift',
|
|
122
|
+
https: ['sitedrift --setup-https', 'sitedrift --https'],
|
|
123
|
+
mcp: {
|
|
124
|
+
command: 'sitedrift-mcp',
|
|
125
|
+
alternative: 'npx -y sitedrift mcp',
|
|
126
|
+
config: { command: 'sitedrift-mcp', args: [] },
|
|
127
|
+
},
|
|
128
|
+
firstTool: 'sitedrift_context',
|
|
129
|
+
guide: 'Read the packaged AGENTS.md or the sitedrift://guide MCP resource.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function noteOperation(name, args) {
|
|
134
|
+
if (name === 'sitedrift_note_add') {
|
|
135
|
+
return {
|
|
136
|
+
op: 'add',
|
|
137
|
+
text: args.text,
|
|
138
|
+
route: args.route || '/',
|
|
139
|
+
side: args.side || null,
|
|
140
|
+
author: args.author || 'agent',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (name === 'sitedrift_notes_clear') return { op: 'clear' };
|
|
144
|
+
return { op: name.replace('sitedrift_note_', ''), id: args.id };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function callTool(name, args = {}) {
|
|
148
|
+
if (name === 'sitedrift_setup') return setupInstructions(args);
|
|
149
|
+
const session = readSession(args.port || 4178);
|
|
150
|
+
if (name === 'sitedrift_context') return requestSession(session, '/api/v1/session');
|
|
151
|
+
if (name === 'sitedrift_notes_list') return requestSession(session, '/api/v1/notes');
|
|
152
|
+
if (!TOOLS.some((tool) => tool.name === name)) throw new Error(`Unknown tool: ${name}`);
|
|
153
|
+
return requestSession(session, '/api/v1/notes', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
body: JSON.stringify(noteOperation(name, args)),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function guideText() {
|
|
160
|
+
return `# sitedrift agent workflow
|
|
161
|
+
|
|
162
|
+
1. Call sitedrift_context before doing review work.
|
|
163
|
+
2. Use the returned viewer URL and DEV/LIVE targets as the source of truth.
|
|
164
|
+
3. Record one concrete issue per sitedrift_note_add call. Include the route and side.
|
|
165
|
+
4. Re-list notes before changing code and after verification.
|
|
166
|
+
5. Resolve a note only after verifying the fix; remove notes only when explicitly asked.
|
|
167
|
+
6. If no session is running, call sitedrift_setup and help the user create sitedrift.config.json, then launch sitedrift.
|
|
168
|
+
|
|
169
|
+
The MCP server never receives browser credentials and only talks to a loopback sitedrift session using its private mode-0600 descriptor.`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function promptResult(args = {}) {
|
|
173
|
+
const route = args.route || '/';
|
|
174
|
+
return {
|
|
175
|
+
description: 'Review one route with sitedrift and leave actionable shared notes.',
|
|
176
|
+
messages: [{
|
|
177
|
+
role: 'user',
|
|
178
|
+
content: {
|
|
179
|
+
type: 'text',
|
|
180
|
+
text: `Review ${route} with sitedrift. Call sitedrift_context first, inspect DEV and LIVE, add one specific note per discrepancy, avoid duplicates, and resolve notes only after verification.`,
|
|
181
|
+
},
|
|
182
|
+
}],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function handleMcpRequest(message) {
|
|
187
|
+
const { id, method, params = {} } = message;
|
|
188
|
+
if (method === 'notifications/initialized' || method === 'notifications/cancelled') return null;
|
|
189
|
+
if (method === 'ping') return { jsonrpc: '2.0', id, result: {} };
|
|
190
|
+
if (method === 'initialize') {
|
|
191
|
+
const requested = params.protocolVersion;
|
|
192
|
+
if (requested && !PROTOCOL_VERSIONS.has(requested)) {
|
|
193
|
+
return {
|
|
194
|
+
jsonrpc: '2.0',
|
|
195
|
+
id,
|
|
196
|
+
error: {
|
|
197
|
+
code: -32602,
|
|
198
|
+
message: 'Unsupported protocol version',
|
|
199
|
+
data: { supported: [...PROTOCOL_VERSIONS], requested },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
jsonrpc: '2.0',
|
|
205
|
+
id,
|
|
206
|
+
result: {
|
|
207
|
+
protocolVersion: requested || LATEST_PROTOCOL_VERSION,
|
|
208
|
+
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
209
|
+
serverInfo: { name: 'sitedrift', version: readVersion() },
|
|
210
|
+
instructions: 'Call sitedrift_context first. If no session is running, call sitedrift_setup.',
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
|
|
215
|
+
if (method === 'tools/call') {
|
|
216
|
+
try {
|
|
217
|
+
return { jsonrpc: '2.0', id, result: jsonResult(await callTool(params.name, params.arguments)) };
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return {
|
|
220
|
+
jsonrpc: '2.0',
|
|
221
|
+
id,
|
|
222
|
+
result: {
|
|
223
|
+
content: [{ type: 'text', text: error.message }],
|
|
224
|
+
isError: true,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (method === 'resources/list') {
|
|
230
|
+
return {
|
|
231
|
+
jsonrpc: '2.0',
|
|
232
|
+
id,
|
|
233
|
+
result: {
|
|
234
|
+
resources: [
|
|
235
|
+
{ uri: 'sitedrift://guide', name: 'Agent guide', mimeType: 'text/markdown' },
|
|
236
|
+
{ uri: 'sitedrift://session', name: 'Active session', mimeType: 'application/json' },
|
|
237
|
+
{ uri: 'sitedrift://notes', name: 'Review notes', mimeType: 'application/json' },
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (method === 'resources/read') {
|
|
243
|
+
try {
|
|
244
|
+
let text;
|
|
245
|
+
let mimeType = 'application/json';
|
|
246
|
+
if (params.uri === 'sitedrift://guide') {
|
|
247
|
+
text = guideText();
|
|
248
|
+
mimeType = 'text/markdown';
|
|
249
|
+
} else {
|
|
250
|
+
const session = readSession(4178);
|
|
251
|
+
const pathname = params.uri === 'sitedrift://session'
|
|
252
|
+
? '/api/v1/session'
|
|
253
|
+
: params.uri === 'sitedrift://notes'
|
|
254
|
+
? '/api/v1/notes'
|
|
255
|
+
: null;
|
|
256
|
+
if (!pathname) throw new Error(`Unknown resource: ${params.uri}`);
|
|
257
|
+
text = JSON.stringify(await requestSession(session, pathname), null, 2);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
jsonrpc: '2.0',
|
|
261
|
+
id,
|
|
262
|
+
result: { contents: [{ uri: params.uri, mimeType, text }] },
|
|
263
|
+
};
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return { jsonrpc: '2.0', id, error: { code: -32002, message: error.message } };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (method === 'prompts/list') {
|
|
269
|
+
return {
|
|
270
|
+
jsonrpc: '2.0',
|
|
271
|
+
id,
|
|
272
|
+
result: {
|
|
273
|
+
prompts: [{
|
|
274
|
+
name: 'review_route',
|
|
275
|
+
title: 'Review a route',
|
|
276
|
+
description: 'Compare one route and record actionable findings.',
|
|
277
|
+
arguments: [{ name: 'route', description: 'Route to review, such as /pricing.', required: false }],
|
|
278
|
+
}],
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (method === 'prompts/get') {
|
|
283
|
+
if (params.name !== 'review_route') {
|
|
284
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown prompt: ${params.name}` } };
|
|
285
|
+
}
|
|
286
|
+
return { jsonrpc: '2.0', id, result: promptResult(params.arguments) };
|
|
287
|
+
}
|
|
288
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function processMessage(message) {
|
|
292
|
+
if (Array.isArray(message)) {
|
|
293
|
+
const responses = (await Promise.all(message.map(handleMcpRequest))).filter(Boolean);
|
|
294
|
+
return responses.length ? responses : null;
|
|
295
|
+
}
|
|
296
|
+
return handleMcpRequest(message);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function runMcpServer(input = process.stdin, output = process.stdout) {
|
|
300
|
+
let buffer = '';
|
|
301
|
+
input.setEncoding('utf8');
|
|
302
|
+
input.on('data', (chunk) => {
|
|
303
|
+
buffer += chunk;
|
|
304
|
+
let newline;
|
|
305
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
306
|
+
const line = buffer.slice(0, newline).trim();
|
|
307
|
+
buffer = buffer.slice(newline + 1);
|
|
308
|
+
if (!line) continue;
|
|
309
|
+
Promise.resolve()
|
|
310
|
+
.then(() => JSON.parse(line))
|
|
311
|
+
.then(processMessage)
|
|
312
|
+
.then((response) => {
|
|
313
|
+
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
314
|
+
})
|
|
315
|
+
.catch((error) => {
|
|
316
|
+
output.write(`${JSON.stringify({
|
|
317
|
+
jsonrpc: '2.0',
|
|
318
|
+
id: null,
|
|
319
|
+
error: { code: -32700, message: error.message },
|
|
320
|
+
})}\n`);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|