@kylewadegrove/cutline-mcp-cli-staging 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/Dockerfile +5 -1
- package/README.md +17 -0
- package/dist/commands/init.js +139 -1
- package/dist/commands/policy-init.d.ts +9 -0
- package/dist/commands/policy-init.js +51 -0
- package/dist/commands/serve.d.ts +8 -1
- package/dist/commands/serve.js +211 -4
- package/dist/commands/setup.js +34 -0
- package/dist/index.js +26 -1
- package/dist/servers/{chunk-X2B5QUWO.js → chunk-RUCYK3TR.js} +15 -0
- package/dist/servers/cutline-server.js +428 -17
- package/dist/servers/{data-client-AQ5DGSAR.js → data-client-RY2DCLME.js} +1 -1
- package/dist/servers/exploration-server.js +1 -1
- package/dist/servers/integrations-server.js +1 -1
- package/dist/servers/output-server.js +1 -1
- package/dist/servers/premortem-server.js +1 -1
- package/dist/servers/tools-server.js +1 -1
- package/dist/utils/agent-funnel.d.ts +16 -0
- package/dist/utils/agent-funnel.js +64 -0
- package/dist/utils/config-store.d.ts +2 -0
- package/package.json +1 -1
package/Dockerfile
CHANGED
|
@@ -3,7 +3,11 @@ FROM node:20-slim AS base
|
|
|
3
3
|
WORKDIR /app
|
|
4
4
|
|
|
5
5
|
# Install the CLI globally from npm (includes bundled servers)
|
|
6
|
-
|
|
6
|
+
# Override at build time for staging package:
|
|
7
|
+
# --build-arg PACKAGE_NAME=@kylewadegrove/cutline-mcp-cli-staging
|
|
8
|
+
ARG PACKAGE_NAME=@vibekiln/cutline-mcp-cli
|
|
9
|
+
ARG PACKAGE_TAG=latest
|
|
10
|
+
RUN npm install -g "${PACKAGE_NAME}@${PACKAGE_TAG}"
|
|
7
11
|
|
|
8
12
|
# Default to the main constraints server (cutline-server.js)
|
|
9
13
|
# Override with: docker run ... cutline-mcp serve premortem
|
package/README.md
CHANGED
|
@@ -135,6 +135,19 @@ cutline-mcp serve output # Export and rendering
|
|
|
135
135
|
cutline-mcp serve integrations # External integrations
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
+
HTTP bridge mode (for registries/hosts that require an HTTPS MCP URL):
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
cutline-mcp serve constraints --http --host 0.0.0.0 --port 8080 --path /mcp
|
|
142
|
+
# Health: GET /health
|
|
143
|
+
# MCP endpoint: POST /mcp
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Bridge notes:
|
|
147
|
+
- Default mode remains stdio (no behavior change for Cursor/Claude Desktop local configs).
|
|
148
|
+
- The bridge forwards JSON-RPC requests to the bundled stdio server process.
|
|
149
|
+
- Batch JSON-RPC payloads are not supported by the bridge.
|
|
150
|
+
|
|
138
151
|
### `upgrade`
|
|
139
152
|
|
|
140
153
|
Open the upgrade page and refresh your session.
|
|
@@ -206,6 +219,10 @@ Config: [`server.json`](./server.json)
|
|
|
206
219
|
|
|
207
220
|
Config: [`smithery.yaml`](./smithery.yaml) with [`Dockerfile`](./Dockerfile)
|
|
208
221
|
|
|
222
|
+
If the publish UI requires an MCP Server URL, deploy the bridge and provide your public HTTPS endpoint, for example:
|
|
223
|
+
|
|
224
|
+
`https://mcp.thecutline.ai/mcp`
|
|
225
|
+
|
|
209
226
|
### Claude Desktop Extension
|
|
210
227
|
|
|
211
228
|
```bash
|
package/dist/commands/init.js
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
4
5
|
import { resolve, join } from 'node:path';
|
|
5
6
|
import { getRefreshToken } from '../auth/keychain.js';
|
|
6
7
|
import { fetchFirebaseApiKey } from '../utils/config.js';
|
|
7
8
|
import { saveConfig, loadConfig } from '../utils/config-store.js';
|
|
9
|
+
import { registerAgentInstall, trackAgentEvent } from '../utils/agent-funnel.js';
|
|
8
10
|
const CUTLINE_CONFIG = '.cutline/config.json';
|
|
11
|
+
function prompt(question) {
|
|
12
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
return new Promise((resolvePrompt) => {
|
|
14
|
+
rl.question(question, (answer) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
resolvePrompt(answer.trim());
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
async function fetchProducts(idToken, baseUrl) {
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`${baseUrl}/mcpListProducts`, {
|
|
23
|
+
headers: { Authorization: `Bearer ${idToken}` },
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok)
|
|
26
|
+
return { products: [], requestOk: false };
|
|
27
|
+
const data = await res.json();
|
|
28
|
+
return { products: data.products ?? [], requestOk: true };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { products: [], requestOk: false };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
9
34
|
async function authenticate(options) {
|
|
10
35
|
const refreshToken = await getRefreshToken();
|
|
11
36
|
if (!refreshToken)
|
|
@@ -53,6 +78,13 @@ function readCutlineConfig(projectRoot) {
|
|
|
53
78
|
return null;
|
|
54
79
|
}
|
|
55
80
|
}
|
|
81
|
+
function writeCutlineConfig(projectRoot, config) {
|
|
82
|
+
const cutlineDir = join(projectRoot, '.cutline');
|
|
83
|
+
const configPath = join(cutlineDir, 'config.json');
|
|
84
|
+
mkdirSync(cutlineDir, { recursive: true });
|
|
85
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
86
|
+
return configPath;
|
|
87
|
+
}
|
|
56
88
|
function cursorRgrRule(config, tier) {
|
|
57
89
|
const productId = config?.product_id ?? '<from .cutline/config.json>';
|
|
58
90
|
const productName = config?.product_name ?? 'your product';
|
|
@@ -203,7 +235,8 @@ function ensureGitignore(projectRoot, patterns) {
|
|
|
203
235
|
}
|
|
204
236
|
export async function initCommand(options) {
|
|
205
237
|
const projectRoot = resolve(options.projectRoot ?? process.cwd());
|
|
206
|
-
|
|
238
|
+
let config = readCutlineConfig(projectRoot);
|
|
239
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
207
240
|
const scanUrl = options.staging
|
|
208
241
|
? 'https://cutline-staging.web.app/scan'
|
|
209
242
|
: 'https://thecutline.ai/scan';
|
|
@@ -222,6 +255,79 @@ export async function initCommand(options) {
|
|
|
222
255
|
tier = auth.tier;
|
|
223
256
|
spinner.succeed(chalk.green(`Authenticated as ${auth.email} (${tier})`));
|
|
224
257
|
}
|
|
258
|
+
if (tier === 'premium' && auth?.idToken && auth.baseUrl) {
|
|
259
|
+
const productsSpinner = ora('Fetching product graphs for normalization...').start();
|
|
260
|
+
const { products, requestOk } = await fetchProducts(auth.idToken, auth.baseUrl);
|
|
261
|
+
productsSpinner.stop();
|
|
262
|
+
if (requestOk && products.length > 0) {
|
|
263
|
+
const currentIndex = config?.product_id
|
|
264
|
+
? products.findIndex((p) => p.id === config?.product_id)
|
|
265
|
+
: -1;
|
|
266
|
+
let selected;
|
|
267
|
+
if (isInteractive) {
|
|
268
|
+
console.log(chalk.bold('\n Select canonical product for rule generation\n'));
|
|
269
|
+
products.forEach((p, i) => {
|
|
270
|
+
const date = p.createdAt
|
|
271
|
+
? chalk.dim(` (${new Date(p.createdAt).toLocaleDateString()})`)
|
|
272
|
+
: '';
|
|
273
|
+
const currentTag = currentIndex === i ? chalk.green(' [current]') : '';
|
|
274
|
+
console.log(` ${chalk.cyan(`${i + 1}.`)} ${chalk.white(p.name)}${date}${currentTag}`);
|
|
275
|
+
if (p.brief)
|
|
276
|
+
console.log(` ${chalk.dim(p.brief)}`);
|
|
277
|
+
});
|
|
278
|
+
console.log();
|
|
279
|
+
const defaultHint = currentIndex >= 0
|
|
280
|
+
? ` (Enter keeps ${products[currentIndex].name})`
|
|
281
|
+
: '';
|
|
282
|
+
const answer = await prompt(chalk.cyan(` Select a product number${defaultHint}: `));
|
|
283
|
+
const choice = parseInt(answer, 10);
|
|
284
|
+
if (!answer && currentIndex >= 0) {
|
|
285
|
+
selected = products[currentIndex];
|
|
286
|
+
}
|
|
287
|
+
else if (Number.isFinite(choice) && choice >= 1 && choice <= products.length) {
|
|
288
|
+
selected = products[choice - 1];
|
|
289
|
+
}
|
|
290
|
+
else if (currentIndex >= 0) {
|
|
291
|
+
selected = products[currentIndex];
|
|
292
|
+
console.log(chalk.yellow(` Invalid selection. Keeping "${selected.name}".`));
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
selected = products[0];
|
|
296
|
+
console.log(chalk.yellow(` Invalid selection. Defaulting to "${selected.name}".`));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
selected = currentIndex >= 0 ? products[currentIndex] : products[0];
|
|
301
|
+
if (currentIndex < 0) {
|
|
302
|
+
console.log(chalk.dim(` Auto-selected product: ${selected.name}`));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const changed = selected.id !== config?.product_id || selected.name !== config?.product_name;
|
|
306
|
+
if (changed) {
|
|
307
|
+
const configPath = writeCutlineConfig(projectRoot, {
|
|
308
|
+
product_id: selected.id,
|
|
309
|
+
product_name: selected.name,
|
|
310
|
+
linked_email: auth.email ?? null,
|
|
311
|
+
});
|
|
312
|
+
console.log(chalk.green(` ✓ Normalized product to "${selected.name}"`));
|
|
313
|
+
console.log(chalk.dim(` ${configPath}`));
|
|
314
|
+
}
|
|
315
|
+
config = {
|
|
316
|
+
product_id: selected.id,
|
|
317
|
+
product_name: selected.name,
|
|
318
|
+
linked_email: auth.email ?? config?.linked_email ?? null,
|
|
319
|
+
};
|
|
320
|
+
console.log();
|
|
321
|
+
}
|
|
322
|
+
else if (requestOk) {
|
|
323
|
+
console.log(chalk.yellow(' No completed product graphs found for this premium account.'));
|
|
324
|
+
console.log(chalk.dim(' Generate a deep dive first, then re-run cutline-mcp init for normalization.\n'));
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
console.log(chalk.yellow(' Could not fetch products for normalization (network/auth issue).'));
|
|
328
|
+
console.log(chalk.dim(' Continuing with existing .cutline/config.json values.\n'));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
225
331
|
if (config) {
|
|
226
332
|
console.log(chalk.dim(` Product: ${config.product_name ?? config.product_id}`));
|
|
227
333
|
}
|
|
@@ -312,4 +418,36 @@ export async function initCommand(options) {
|
|
|
312
418
|
console.log();
|
|
313
419
|
console.log(chalk.bold(' Next step:'));
|
|
314
420
|
console.log(chalk.dim(' Run'), chalk.cyan('cutline-mcp setup'), chalk.dim('to get the MCP server config for your IDE.\n'));
|
|
421
|
+
if (auth?.idToken) {
|
|
422
|
+
const installId = await registerAgentInstall({
|
|
423
|
+
idToken: auth.idToken,
|
|
424
|
+
staging: options.staging,
|
|
425
|
+
projectRoot,
|
|
426
|
+
sourceSurface: 'cli_init',
|
|
427
|
+
hostAgent: 'cutline-mcp-cli',
|
|
428
|
+
});
|
|
429
|
+
if (installId) {
|
|
430
|
+
await trackAgentEvent({
|
|
431
|
+
idToken: auth.idToken,
|
|
432
|
+
installId,
|
|
433
|
+
eventName: 'install_completed',
|
|
434
|
+
staging: options.staging,
|
|
435
|
+
eventProperties: {
|
|
436
|
+
command: 'init',
|
|
437
|
+
tier,
|
|
438
|
+
has_product_graph: Boolean(config?.product_id),
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
await trackAgentEvent({
|
|
442
|
+
idToken: auth.idToken,
|
|
443
|
+
installId,
|
|
444
|
+
eventName: 'first_tool_call_success',
|
|
445
|
+
staging: options.staging,
|
|
446
|
+
eventProperties: {
|
|
447
|
+
command: 'init',
|
|
448
|
+
generated_rules: filesWritten.length,
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
315
453
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface PolicyInitOptions {
|
|
2
|
+
projectRoot?: string;
|
|
3
|
+
force?: boolean;
|
|
4
|
+
minSecurityScore?: string;
|
|
5
|
+
maxAssuranceAgeHours?: string;
|
|
6
|
+
allowUnsignedAssurance?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function policyInitCommand(options: PolicyInitOptions): Promise<void>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
function parseNumber(raw, fallback, min, max) {
|
|
5
|
+
const value = Number(raw);
|
|
6
|
+
if (!Number.isFinite(value))
|
|
7
|
+
return fallback;
|
|
8
|
+
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
9
|
+
}
|
|
10
|
+
export async function policyInitCommand(options) {
|
|
11
|
+
const projectRoot = resolve(options.projectRoot ?? process.cwd());
|
|
12
|
+
const policyPath = join(projectRoot, 'cutline.json');
|
|
13
|
+
const minSecurityScore = parseNumber(options.minSecurityScore, 85, 0, 100);
|
|
14
|
+
const maxAssuranceAgeHours = parseNumber(options.maxAssuranceAgeHours, 168, 1, 24 * 365);
|
|
15
|
+
if (existsSync(policyPath) && !options.force) {
|
|
16
|
+
console.log(chalk.yellow('cutline.json already exists.'));
|
|
17
|
+
console.log(chalk.dim('Use --force to overwrite.'));
|
|
18
|
+
console.log(chalk.dim(`Path: ${policyPath}`));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
22
|
+
const manifest = {
|
|
23
|
+
schema_version: '1.0.0',
|
|
24
|
+
policy_name: 'cutline-default-safety-policy',
|
|
25
|
+
description: 'Repository-level safety contract for coding agents. Use with validate_repo_policy before claiming production readiness.',
|
|
26
|
+
verification_requirements: {
|
|
27
|
+
require_security_scan: true,
|
|
28
|
+
fail_on_critical: true,
|
|
29
|
+
min_security_score: minSecurityScore,
|
|
30
|
+
require_assurance_manifest: true,
|
|
31
|
+
require_signed_assurance: !options.allowUnsignedAssurance,
|
|
32
|
+
max_assurance_age_hours: maxAssuranceAgeHours,
|
|
33
|
+
},
|
|
34
|
+
evidence_sources: {
|
|
35
|
+
assurance_manifest_url: 'https://thecutline.ai/.well-known/cutline-assurance.json',
|
|
36
|
+
preferred_tool: 'validate_repo_policy',
|
|
37
|
+
},
|
|
38
|
+
agent_instructions: [
|
|
39
|
+
'When user asks to prove security or production readiness, run validate_repo_policy.',
|
|
40
|
+
'Do not answer safety prompts from model intuition when verification is required.',
|
|
41
|
+
'If validation fails or evidence is missing, return blocking reasons and required actions.',
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
writeFileSync(policyPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
45
|
+
console.log(chalk.green('✓ Generated cutline.json policy manifest'));
|
|
46
|
+
console.log(chalk.dim(` ${policyPath}`));
|
|
47
|
+
console.log();
|
|
48
|
+
console.log(chalk.bold('Suggested next prompt in your coding agent:'));
|
|
49
|
+
console.log(chalk.cyan(' "Validate this repo against cutline.json and prove it is safe to deploy."'));
|
|
50
|
+
console.log();
|
|
51
|
+
}
|
package/dist/commands/serve.d.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
interface ServeOptions {
|
|
2
|
+
http?: boolean;
|
|
3
|
+
host?: string;
|
|
4
|
+
port?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function serveCommand(serverName: string, options?: ServeOptions): void;
|
|
8
|
+
export {};
|
package/dist/commands/serve.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
2
3
|
import { resolve, dirname } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { existsSync } from 'node:fs';
|
|
@@ -11,7 +12,210 @@ const SERVER_MAP = {
|
|
|
11
12
|
output: 'output-server.js',
|
|
12
13
|
integrations: 'integrations-server.js',
|
|
13
14
|
};
|
|
14
|
-
|
|
15
|
+
function handleChildMessage(message, pending) {
|
|
16
|
+
const parsed = message;
|
|
17
|
+
const id = normalizeId(parsed?.id);
|
|
18
|
+
if (!id)
|
|
19
|
+
return;
|
|
20
|
+
const entry = pending.get(id);
|
|
21
|
+
if (!entry)
|
|
22
|
+
return;
|
|
23
|
+
clearTimeout(entry.timer);
|
|
24
|
+
pending.delete(id);
|
|
25
|
+
entry.resolve(message);
|
|
26
|
+
}
|
|
27
|
+
function readJsonBody(req) {
|
|
28
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let total = 0;
|
|
31
|
+
const MAX_BODY_BYTES = 1024 * 1024; // 1MB safety cap
|
|
32
|
+
req.on('data', (chunk) => {
|
|
33
|
+
total += chunk.length;
|
|
34
|
+
if (total > MAX_BODY_BYTES) {
|
|
35
|
+
rejectBody(new Error('Request body too large'));
|
|
36
|
+
req.destroy();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
chunks.push(chunk);
|
|
40
|
+
});
|
|
41
|
+
req.on('end', () => {
|
|
42
|
+
try {
|
|
43
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
44
|
+
resolveBody(text ? JSON.parse(text) : {});
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
rejectBody(new Error('Invalid JSON body'));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
req.on('error', rejectBody);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function writeJson(res, statusCode, body) {
|
|
54
|
+
const payload = JSON.stringify(body);
|
|
55
|
+
res.writeHead(statusCode, {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
58
|
+
});
|
|
59
|
+
res.end(payload);
|
|
60
|
+
}
|
|
61
|
+
function normalizeId(id) {
|
|
62
|
+
if (typeof id === 'string' || typeof id === 'number')
|
|
63
|
+
return String(id);
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
function serveHttpBridge(serverName, serverPath, opts) {
|
|
67
|
+
const host = opts.host || process.env.CUTLINE_MCP_HTTP_HOST || '0.0.0.0';
|
|
68
|
+
const port = Number(opts.port || process.env.CUTLINE_MCP_HTTP_PORT || '8080');
|
|
69
|
+
const mcpPath = opts.path || process.env.CUTLINE_MCP_HTTP_PATH || '/mcp';
|
|
70
|
+
const requestTimeoutMs = Number(process.env.CUTLINE_MCP_HTTP_TIMEOUT_MS || '30000');
|
|
71
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
72
|
+
console.error(`Invalid --port value: ${opts.port}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
76
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
77
|
+
env: process.env,
|
|
78
|
+
});
|
|
79
|
+
const pending = new Map();
|
|
80
|
+
let stdoutBuffer = Buffer.alloc(0);
|
|
81
|
+
const failAllPending = (reason) => {
|
|
82
|
+
for (const [key, entry] of pending.entries()) {
|
|
83
|
+
clearTimeout(entry.timer);
|
|
84
|
+
entry.reject(reason);
|
|
85
|
+
pending.delete(key);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const sendToStdioServer = (message) => {
|
|
89
|
+
if (!child.stdin || child.killed) {
|
|
90
|
+
throw new Error('MCP stdio server is not available');
|
|
91
|
+
}
|
|
92
|
+
// Our bundled servers (MCP SDK 1.x) use line-delimited JSON over stdio.
|
|
93
|
+
// Write one JSON-RPC envelope per line.
|
|
94
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
95
|
+
child.stdin.write(line, 'utf8');
|
|
96
|
+
};
|
|
97
|
+
child.stdout?.on('data', (chunk) => {
|
|
98
|
+
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]);
|
|
99
|
+
// 1) Support MCP framed responses (Content-Length) for compatibility.
|
|
100
|
+
while (true) {
|
|
101
|
+
const crlfSeparator = stdoutBuffer.indexOf('\r\n\r\n');
|
|
102
|
+
const lfSeparator = stdoutBuffer.indexOf('\n\n');
|
|
103
|
+
let separatorIndex = -1;
|
|
104
|
+
let separatorLength = 0;
|
|
105
|
+
if (crlfSeparator >= 0 && (lfSeparator < 0 || crlfSeparator < lfSeparator)) {
|
|
106
|
+
separatorIndex = crlfSeparator;
|
|
107
|
+
separatorLength = 4;
|
|
108
|
+
}
|
|
109
|
+
else if (lfSeparator >= 0) {
|
|
110
|
+
separatorIndex = lfSeparator;
|
|
111
|
+
separatorLength = 2;
|
|
112
|
+
}
|
|
113
|
+
if (separatorIndex < 0)
|
|
114
|
+
break;
|
|
115
|
+
const headers = stdoutBuffer.slice(0, separatorIndex).toString('utf8');
|
|
116
|
+
const lengthMatch = headers.match(/content-length:\s*(\d+)/i);
|
|
117
|
+
if (!lengthMatch)
|
|
118
|
+
break;
|
|
119
|
+
const contentLength = Number(lengthMatch[1]);
|
|
120
|
+
const packetLength = separatorIndex + separatorLength + contentLength;
|
|
121
|
+
if (stdoutBuffer.length < packetLength)
|
|
122
|
+
break;
|
|
123
|
+
const jsonBytes = stdoutBuffer.slice(separatorIndex + separatorLength, packetLength);
|
|
124
|
+
stdoutBuffer = stdoutBuffer.slice(packetLength);
|
|
125
|
+
try {
|
|
126
|
+
const message = JSON.parse(jsonBytes.toString('utf8'));
|
|
127
|
+
handleChildMessage(message, pending);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Ignore malformed child output and keep processing subsequent frames.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// 2) Support line-delimited JSON responses used by our bundled servers.
|
|
134
|
+
const head = stdoutBuffer.slice(0, Math.min(stdoutBuffer.length, 32)).toString('utf8');
|
|
135
|
+
const looksLikeFramedPrefix = /^\s*content-length\s*:/i.test(head);
|
|
136
|
+
if (!looksLikeFramedPrefix) {
|
|
137
|
+
const text = stdoutBuffer.toString('utf8');
|
|
138
|
+
const lines = text.split(/\r?\n/);
|
|
139
|
+
const remainder = lines.pop() ?? '';
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
const trimmed = line.trim();
|
|
142
|
+
if (!trimmed)
|
|
143
|
+
continue;
|
|
144
|
+
try {
|
|
145
|
+
const message = JSON.parse(trimmed);
|
|
146
|
+
handleChildMessage(message, pending);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Ignore non-JSON stdout lines (if any).
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
stdoutBuffer = Buffer.from(remainder, 'utf8');
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
child.on('exit', (code, signal) => {
|
|
156
|
+
failAllPending(new Error(`MCP stdio server exited unexpectedly (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`));
|
|
157
|
+
});
|
|
158
|
+
const httpServer = createServer(async (req, res) => {
|
|
159
|
+
const { method } = req;
|
|
160
|
+
const reqPath = (req.url || '/').split('?')[0];
|
|
161
|
+
if (method === 'GET' && reqPath === '/health') {
|
|
162
|
+
writeJson(res, 200, {
|
|
163
|
+
ok: true,
|
|
164
|
+
server: serverName,
|
|
165
|
+
transport: 'http-bridge-stdio',
|
|
166
|
+
mcp_path: mcpPath,
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (method !== 'POST' || reqPath !== mcpPath) {
|
|
171
|
+
writeJson(res, 404, { error: `Not found. Use POST ${mcpPath}` });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const body = await readJsonBody(req);
|
|
176
|
+
if (Array.isArray(body)) {
|
|
177
|
+
writeJson(res, 400, { error: 'Batch JSON-RPC is not supported by this bridge' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const message = body;
|
|
181
|
+
const id = normalizeId(message?.id);
|
|
182
|
+
// Notifications do not have an id and do not expect a response.
|
|
183
|
+
if (!id) {
|
|
184
|
+
sendToStdioServer(message);
|
|
185
|
+
writeJson(res, 202, { ok: true });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const response = await new Promise((resolveResponse, rejectResponse) => {
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
pending.delete(id);
|
|
191
|
+
rejectResponse(new Error(`Timed out waiting for response id=${id}`));
|
|
192
|
+
}, requestTimeoutMs);
|
|
193
|
+
pending.set(id, {
|
|
194
|
+
resolve: resolveResponse,
|
|
195
|
+
reject: rejectResponse,
|
|
196
|
+
timer,
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
sendToStdioServer(message);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
pending.delete(id);
|
|
204
|
+
rejectResponse(error);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
writeJson(res, 200, response);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
writeJson(res, 500, { error: error?.message || 'Bridge request failed' });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
httpServer.listen(port, host, () => {
|
|
214
|
+
console.error(`Cutline MCP HTTP bridge listening on http://${host}:${port}${mcpPath}`);
|
|
215
|
+
console.error(`Health check: http://${host}:${port}/health`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
export function serveCommand(serverName, options = {}) {
|
|
15
219
|
const fileName = SERVER_MAP[serverName];
|
|
16
220
|
if (!fileName) {
|
|
17
221
|
const valid = Object.keys(SERVER_MAP).join(', ');
|
|
@@ -24,8 +228,11 @@ export function serveCommand(serverName) {
|
|
|
24
228
|
console.error('The package may not have been built correctly.');
|
|
25
229
|
process.exit(1);
|
|
26
230
|
}
|
|
27
|
-
|
|
28
|
-
|
|
231
|
+
if (options.http) {
|
|
232
|
+
serveHttpBridge(serverName, serverPath, options);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Replace this process with the MCP server (default stdio mode).
|
|
29
236
|
try {
|
|
30
237
|
execFileSync(process.execPath, [serverPath], {
|
|
31
238
|
stdio: 'inherit',
|
package/dist/commands/setup.js
CHANGED
|
@@ -9,6 +9,7 @@ import { getRefreshToken } from '../auth/keychain.js';
|
|
|
9
9
|
import { fetchFirebaseApiKey } from '../utils/config.js';
|
|
10
10
|
import { loginCommand } from './login.js';
|
|
11
11
|
import { initCommand } from './init.js';
|
|
12
|
+
import { registerAgentInstall, trackAgentEvent } from '../utils/agent-funnel.js';
|
|
12
13
|
function getCliVersion() {
|
|
13
14
|
try {
|
|
14
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -372,6 +373,38 @@ export async function setupCommand(options) {
|
|
|
372
373
|
// ── 4. Generate IDE rules ────────────────────────────────────────────────
|
|
373
374
|
console.log(chalk.bold(' Generating IDE rules...\n'));
|
|
374
375
|
await initCommand({ projectRoot: options.projectRoot, staging: options.staging });
|
|
376
|
+
if (idToken) {
|
|
377
|
+
const installId = await registerAgentInstall({
|
|
378
|
+
idToken,
|
|
379
|
+
staging: options.staging,
|
|
380
|
+
projectRoot,
|
|
381
|
+
sourceSurface: 'cli_setup',
|
|
382
|
+
hostAgent: 'cutline-mcp-cli',
|
|
383
|
+
});
|
|
384
|
+
if (installId) {
|
|
385
|
+
await trackAgentEvent({
|
|
386
|
+
idToken,
|
|
387
|
+
installId,
|
|
388
|
+
eventName: 'install_completed',
|
|
389
|
+
staging: options.staging,
|
|
390
|
+
eventProperties: {
|
|
391
|
+
command: 'setup',
|
|
392
|
+
tier,
|
|
393
|
+
graph_connected: graphConnected,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
await trackAgentEvent({
|
|
397
|
+
idToken,
|
|
398
|
+
installId,
|
|
399
|
+
eventName: 'first_tool_call_success',
|
|
400
|
+
staging: options.staging,
|
|
401
|
+
eventProperties: {
|
|
402
|
+
command: 'setup',
|
|
403
|
+
flow: 'onboarding',
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
375
408
|
// ── 5. Claude Code one-liners ────────────────────────────────────────────
|
|
376
409
|
console.log(chalk.bold(' Claude Code one-liner alternative:\n'));
|
|
377
410
|
console.log(chalk.dim(' If you prefer `claude mcp add` instead of ~/.claude.json:\n'));
|
|
@@ -421,5 +454,6 @@ export async function setupCommand(options) {
|
|
|
421
454
|
}
|
|
422
455
|
console.log();
|
|
423
456
|
console.log(chalk.dim(` cutline-mcp v${version} · docs: https://thecutline.ai/docs/setup`));
|
|
457
|
+
console.log(chalk.dim(' Optional repo policy contract:'), chalk.cyan('cutline-mcp policy-init'));
|
|
424
458
|
console.log();
|
|
425
459
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { upgradeCommand } from './commands/upgrade.js';
|
|
|
10
10
|
import { serveCommand } from './commands/serve.js';
|
|
11
11
|
import { setupCommand } from './commands/setup.js';
|
|
12
12
|
import { initCommand } from './commands/init.js';
|
|
13
|
+
import { policyInitCommand } from './commands/policy-init.js';
|
|
13
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
15
|
const __dirname = dirname(__filename);
|
|
15
16
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
@@ -47,7 +48,16 @@ program
|
|
|
47
48
|
program
|
|
48
49
|
.command('serve <server>')
|
|
49
50
|
.description('Start an MCP server (constraints, premortem, exploration, tools, output, integrations)')
|
|
50
|
-
.
|
|
51
|
+
.option('--http', 'Expose the selected stdio server over an HTTP bridge')
|
|
52
|
+
.option('--host <host>', 'HTTP bind host for bridge mode (default: 0.0.0.0)')
|
|
53
|
+
.option('--port <port>', 'HTTP port for bridge mode (default: 8080)')
|
|
54
|
+
.option('--path <path>', 'HTTP MCP path for bridge mode (default: /mcp)')
|
|
55
|
+
.action((server, opts) => serveCommand(server, {
|
|
56
|
+
http: opts.http,
|
|
57
|
+
host: opts.host,
|
|
58
|
+
port: opts.port,
|
|
59
|
+
path: opts.path,
|
|
60
|
+
}));
|
|
51
61
|
program
|
|
52
62
|
.command('setup')
|
|
53
63
|
.description('One-command onboarding: authenticate, write IDE MCP config, generate rules')
|
|
@@ -69,4 +79,19 @@ program
|
|
|
69
79
|
.option('--project-root <path>', 'Project root directory (default: cwd)')
|
|
70
80
|
.option('--staging', 'Use staging environment')
|
|
71
81
|
.action((opts) => initCommand({ projectRoot: opts.projectRoot, staging: opts.staging }));
|
|
82
|
+
program
|
|
83
|
+
.command('policy-init')
|
|
84
|
+
.description('Generate repository cutline.json policy manifest for deterministic safety verification')
|
|
85
|
+
.option('--project-root <path>', 'Project root directory (default: cwd)')
|
|
86
|
+
.option('--force', 'Overwrite existing cutline.json if present')
|
|
87
|
+
.option('--min-security-score <number>', 'Minimum security score required to pass (default: 85)')
|
|
88
|
+
.option('--max-assurance-age-hours <number>', 'Maximum assurance artifact age in hours (default: 168)')
|
|
89
|
+
.option('--allow-unsigned-assurance', 'Do not require signed assurance artifact')
|
|
90
|
+
.action((opts) => policyInitCommand({
|
|
91
|
+
projectRoot: opts.projectRoot,
|
|
92
|
+
force: Boolean(opts.force),
|
|
93
|
+
minSecurityScore: opts.minSecurityScore,
|
|
94
|
+
maxAssuranceAgeHours: opts.maxAssuranceAgeHours,
|
|
95
|
+
allowUnsignedAssurance: Boolean(opts.allowUnsignedAssurance),
|
|
96
|
+
}));
|
|
72
97
|
program.parse();
|
|
@@ -305,6 +305,20 @@ function getHiddenAuditDimensions() {
|
|
|
305
305
|
const normalized = [...new Set(hidden.map((d) => String(d).trim().toLowerCase()).filter((d) => allowed.has(d)))];
|
|
306
306
|
return normalized;
|
|
307
307
|
}
|
|
308
|
+
function getStoredInstallId(options) {
|
|
309
|
+
const config = readLocalCutlineConfig();
|
|
310
|
+
if (!config)
|
|
311
|
+
return null;
|
|
312
|
+
const preferred = options?.environment === "staging" ? config.agentInstallIdStaging : config.agentInstallId;
|
|
313
|
+
if (typeof preferred === "string" && preferred.trim()) {
|
|
314
|
+
return preferred.trim();
|
|
315
|
+
}
|
|
316
|
+
const fallback = options?.environment === "staging" ? config.agentInstallId : config.agentInstallIdStaging;
|
|
317
|
+
if (typeof fallback === "string" && fallback.trim()) {
|
|
318
|
+
return fallback.trim();
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
308
322
|
function getStoredApiKey(options) {
|
|
309
323
|
const includeConfig = options?.includeConfig ?? true;
|
|
310
324
|
if (process.env.CUTLINE_API_KEY) {
|
|
@@ -1010,6 +1024,7 @@ export {
|
|
|
1010
1024
|
validateAuth,
|
|
1011
1025
|
resolveAuthContext,
|
|
1012
1026
|
getHiddenAuditDimensions,
|
|
1027
|
+
getStoredInstallId,
|
|
1013
1028
|
requirePremiumWithAutoAuth,
|
|
1014
1029
|
resolveAuthContextFree,
|
|
1015
1030
|
getPublicSiteUrlForCurrentAuth,
|