@tiny-fish/cli 0.37.1-next.298 → 0.37.1-next.300
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/dist/commands/connect.js +43 -22
- package/dist/lib/cursor-config.d.ts +5 -18
- package/dist/lib/cursor-config.js +15 -131
- package/dist/lib/harness.js +2 -0
- package/dist/lib/mcp-json-config.d.ts +30 -0
- package/dist/lib/mcp-json-config.js +132 -0
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -8,7 +8,7 @@ import { installWebSkill } from '../lib/skill-install.js';
|
|
|
8
8
|
import { ConnectInterruptedError, ConnectStepError, createConnectTelemetry, finishSetupCommand, requireCommandSupport, runGuarded, settle, SignInTimeoutError, spawnStepError, stageDurationOf, withStageDuration, spawnRemoval, } from '../lib/connect-runtime.js';
|
|
9
9
|
import { cursorInstallDeeplink, cursorMcpPath, readCursorTinyfishEntry, writeCursorMcpConfig, } from '../lib/cursor-config.js';
|
|
10
10
|
import { runConnectAll } from '../lib/connect-all.js';
|
|
11
|
-
import { ALL_HARNESSES, AuthMode, RELOAD_ACTION } from '../lib/harness-detect.js';
|
|
11
|
+
import { ALL_HARNESSES, AuthMode, HARNESS_DISPLAY_NAMES, RELOAD_ACTION, } from '../lib/harness-detect.js';
|
|
12
12
|
import { detectHumanInitiated } from '../lib/harness.js';
|
|
13
13
|
import { TINYFISH_API_KEY_VAR } from '../lib/constants.js';
|
|
14
14
|
import { emitNotice } from '../lib/notice.js';
|
|
@@ -506,10 +506,28 @@ export async function connectOpenClaw(options) {
|
|
|
506
506
|
}
|
|
507
507
|
});
|
|
508
508
|
}
|
|
509
|
-
|
|
510
|
-
|
|
509
|
+
const CONFIG_FILE_HARNESSES = {
|
|
510
|
+
cursor: {
|
|
511
|
+
skillTarget: CURSOR_SKILL_TARGET,
|
|
512
|
+
registeredUrl: () => readCursorTinyfishEntry().url,
|
|
513
|
+
write: writeCursorMcpConfig,
|
|
514
|
+
configPath: cursorMcpPath,
|
|
515
|
+
tryLaunch: launchCursorDeeplink,
|
|
516
|
+
copy: {
|
|
517
|
+
verified: "TinyFish is connected and verified (health+auth). Reload the Cursor window (or restart cursor-agent) and it's live.",
|
|
518
|
+
authFailed: (reason) => `TinyFish is configured, but the authenticated check failed (${reason}). ` +
|
|
519
|
+
'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect cursor',
|
|
520
|
+
launched: 'Cursor should pop a TinyFish install confirmation — approve it, then sign in when prompted.',
|
|
521
|
+
reload: 'TinyFish is connected. Reload the Cursor window, then approve/sign in under Settings → MCP.',
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
/** Writes the harness's MCP config file itself; no `mcp add` exists. */
|
|
526
|
+
async function connectConfigFileHarness(harness, options) {
|
|
527
|
+
const spec = CONFIG_FILE_HARNESSES[harness];
|
|
528
|
+
const displayName = HARNESS_DISPLAY_NAMES[harness];
|
|
511
529
|
let fallbackAttemptId;
|
|
512
|
-
const registeredUrl =
|
|
530
|
+
const registeredUrl = spec.registeredUrl();
|
|
513
531
|
if (registeredUrl) {
|
|
514
532
|
try {
|
|
515
533
|
const registered = new URL(registeredUrl);
|
|
@@ -525,7 +543,7 @@ export async function connectCursor(options) {
|
|
|
525
543
|
requested.searchParams.sort();
|
|
526
544
|
if (registered.toString() === requested.toString() &&
|
|
527
545
|
registeredSource === CONNECT_SOURCE &&
|
|
528
|
-
registeredClient ===
|
|
546
|
+
registeredClient === harness &&
|
|
529
547
|
z.uuid().safeParse(registeredId).success) {
|
|
530
548
|
fallbackAttemptId = registeredId ?? undefined;
|
|
531
549
|
}
|
|
@@ -534,12 +552,12 @@ export async function connectCursor(options) {
|
|
|
534
552
|
// A hand-edited invalid URL is repaired below with a fresh attempt id.
|
|
535
553
|
}
|
|
536
554
|
}
|
|
537
|
-
const telemetry = createConnectTelemetry(options.mcpUrl,
|
|
555
|
+
const telemetry = createConnectTelemetry(options.mcpUrl, harness, {
|
|
538
556
|
...options,
|
|
539
557
|
fallbackAttemptId,
|
|
540
558
|
});
|
|
541
559
|
// Never degraded: no sign-in command to be missing. False, not absent, so a `= false`
|
|
542
|
-
// filter still catches
|
|
560
|
+
// filter still catches these harnesses.
|
|
543
561
|
const state = {
|
|
544
562
|
stage: 'prerequisite_check',
|
|
545
563
|
settled: false,
|
|
@@ -549,11 +567,11 @@ export async function connectCursor(options) {
|
|
|
549
567
|
telemetry.track('started');
|
|
550
568
|
telemetry.track('checkpoint', { phase: 'prerequisite_ok' });
|
|
551
569
|
state.stage = 'skill_install';
|
|
552
|
-
installWebSkill(
|
|
570
|
+
installWebSkill(spec.skillTarget, { verbose: options.verbose ?? false });
|
|
553
571
|
telemetry.track('checkpoint', { phase: 'skill_installed' });
|
|
554
572
|
// Auth first: a key lets the config carry an X-API-Key header, removing the sign-in hand-back.
|
|
555
573
|
state.stage = 'authentication';
|
|
556
|
-
ensureCliAuthenticated(
|
|
574
|
+
ensureCliAuthenticated(harness, options.apiKey);
|
|
557
575
|
telemetry.track('checkpoint', { phase: 'authenticated' });
|
|
558
576
|
const resolvedKey = validatedApiKey(options.apiKey);
|
|
559
577
|
// ensureCliAuthenticated leaves a key stored, and `auth status` exits 0 on a malformed one.
|
|
@@ -561,45 +579,48 @@ export async function connectCursor(options) {
|
|
|
561
579
|
errLine('Ignoring the stored API key: invalid format. Run: tinyfish auth login');
|
|
562
580
|
}
|
|
563
581
|
state.stage = 'registration';
|
|
564
|
-
errLine(
|
|
582
|
+
errLine(`Adding TinyFish to ${displayName}...`);
|
|
565
583
|
const mcpUrl = new URL(options.mcpUrl);
|
|
566
584
|
mcpUrl.searchParams.set('source', CONNECT_SOURCE);
|
|
567
|
-
mcpUrl.searchParams.set('client',
|
|
585
|
+
mcpUrl.searchParams.set('client', harness);
|
|
568
586
|
mcpUrl.searchParams.set('connect_attempt_id', telemetry.attemptId);
|
|
569
|
-
const result =
|
|
587
|
+
const result = spec.write(mcpUrl.toString(), resolvedKey);
|
|
570
588
|
if (result.status === 'corrupt_skip') {
|
|
571
589
|
// The message fallback would leak the config path; tag instead.
|
|
572
|
-
throw new ConnectStepError(`Could not update ${
|
|
573
|
-
`Fix the file, then re-run: tinyfish connect
|
|
590
|
+
throw new ConnectStepError(`Could not update ${spec.configPath()}: existing file could not be read or is not valid JSON (${result.error}). ` +
|
|
591
|
+
`Fix the file, then re-run: tinyfish connect ${harness}`, 'invalid_config', { failureDetail: `${harness}_mcp_json_corrupt` });
|
|
574
592
|
}
|
|
575
593
|
if (result.backupPath) {
|
|
576
|
-
errLine(`Backed up existing
|
|
594
|
+
errLine(`Backed up existing ${displayName} MCP config to ${result.backupPath}`);
|
|
577
595
|
}
|
|
578
596
|
if (result.repaired) {
|
|
579
597
|
errLine('Repaired the existing TinyFish entry (updated auth/config to current).');
|
|
580
598
|
}
|
|
581
599
|
telemetry.track('checkpoint', { phase: 'registered' });
|
|
582
|
-
saveConnectContext(
|
|
600
|
+
saveConnectContext(harness, telemetry.attemptId);
|
|
583
601
|
if (resolvedKey) {
|
|
584
602
|
// Deeplink would embed the key in a URL (process args, LaunchServices logs) — reload instead.
|
|
585
603
|
const verify = await verifyMcpAuth(resolvedKey);
|
|
586
604
|
if (verify.ok) {
|
|
587
|
-
errLine(
|
|
605
|
+
errLine(spec.copy.verified);
|
|
588
606
|
}
|
|
589
607
|
else {
|
|
590
|
-
errLine(
|
|
591
|
-
'Fix: rotate/re-enter your key with `tinyfish auth login`, then re-run: tinyfish connect cursor');
|
|
608
|
+
errLine(spec.copy.authFailed(verify.reason ?? 'unknown'));
|
|
592
609
|
}
|
|
593
610
|
}
|
|
594
|
-
else if (detectHumanInitiated() &&
|
|
595
|
-
errLine(
|
|
611
|
+
else if (detectHumanInitiated() && spec.tryLaunch(mcpUrl.toString())) {
|
|
612
|
+
errLine(spec.copy.launched);
|
|
596
613
|
}
|
|
597
614
|
else {
|
|
598
|
-
errLine(
|
|
615
|
+
errLine(spec.copy.reload);
|
|
599
616
|
}
|
|
600
617
|
settle(state, telemetry, 'completed', { authMode: resolvedKey ? AuthMode.ApiKey : 'deferred' });
|
|
601
618
|
});
|
|
602
619
|
}
|
|
620
|
+
/** Writes mcp.json directly — no `cursor mcp add` exists. */
|
|
621
|
+
export async function connectCursor(options) {
|
|
622
|
+
await connectConfigFileHarness('cursor', options);
|
|
623
|
+
}
|
|
603
624
|
/** Best-effort: false when no handler/open fails — caller falls back to reload copy. */
|
|
604
625
|
function launchCursorDeeplink(mcpUrl) {
|
|
605
626
|
const result = openExternalUrl(cursorInstallDeeplink(mcpUrl));
|
|
@@ -1,27 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import { type McpJsonServerEntry, type McpJsonWriteResult } from './mcp-json-config.js';
|
|
2
|
+
export type CursorMcpWriteResult = McpJsonWriteResult;
|
|
3
|
+
export type CursorTinyfishEntry = McpJsonServerEntry;
|
|
2
4
|
export declare function cursorMcpPath(): string;
|
|
3
|
-
export interface CursorMcpWriteResult {
|
|
4
|
-
status: 'written' | 'unchanged' | 'corrupt_skip';
|
|
5
|
-
backupPath?: string;
|
|
6
|
-
error?: string;
|
|
7
|
-
/** A pre-existing tinyfish entry was replaced — a repair, not a fresh install. */
|
|
8
|
-
repaired?: boolean;
|
|
9
|
-
}
|
|
10
5
|
/** Dry-run description of the pending write; touches nothing. */
|
|
11
6
|
export declare function planCursorWrite(mcpUrl: string, apiKey?: string): string;
|
|
12
|
-
export interface CursorTinyfishEntry {
|
|
13
|
-
present: boolean;
|
|
14
|
-
hasApiKeyHeader: boolean;
|
|
15
|
-
/** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
|
|
16
|
-
url?: string;
|
|
17
|
-
keyMatchesCliKey?: boolean;
|
|
18
|
-
error?: string;
|
|
19
|
-
}
|
|
20
7
|
/** Reports the header's shape, never its value. */
|
|
21
8
|
export declare function readCursorTinyfishEntry(): CursorTinyfishEntry;
|
|
22
9
|
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
23
10
|
export declare function writeCursorMcpConfig(mcpUrl: string, apiKey?: string): CursorMcpWriteResult;
|
|
24
|
-
/** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
|
|
25
|
-
export declare function cursorInstallDeeplink(mcpUrl: string): string;
|
|
26
11
|
/** Removes only the `tinyfish` key. */
|
|
27
12
|
export declare function removeCursorMcpServer(): CursorMcpWriteResult;
|
|
13
|
+
/** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
|
|
14
|
+
export declare function cursorInstallDeeplink(mcpUrl: string): string;
|
|
@@ -1,148 +1,32 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
1
|
import * as os from 'os';
|
|
3
2
|
import * as path from 'path';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
3
|
+
import { buildTinyfishServerEntry, planWrite, readTinyfishEntry, removeServer, writeMcpConfig, } from './mcp-json-config.js';
|
|
4
|
+
const CURSOR_TARGET = {
|
|
5
|
+
serverKey: 'tinyfish',
|
|
6
|
+
dir: () => path.join(os.homedir(), '.cursor'),
|
|
7
|
+
file: () => path.join(os.homedir(), '.cursor', 'mcp.json'),
|
|
8
|
+
};
|
|
10
9
|
export function cursorMcpPath() {
|
|
11
|
-
return
|
|
12
|
-
}
|
|
13
|
-
function isPlainRecord(value) {
|
|
14
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
-
}
|
|
16
|
-
// Footgun: the key lands plaintext in mcp.json (0600) — per-device keys are the GA hardening.
|
|
17
|
-
function buildTinyfishServerEntry(mcpUrl, apiKey) {
|
|
18
|
-
return apiKey ? { url: mcpUrl, headers: { 'X-API-Key': apiKey } } : { url: mcpUrl };
|
|
19
|
-
}
|
|
20
|
-
function parseMcpJson(raw) {
|
|
21
|
-
try {
|
|
22
|
-
const parsed = JSON.parse(raw);
|
|
23
|
-
if (!isPlainRecord(parsed))
|
|
24
|
-
return { ok: false, error: 'mcp.json root is not a JSON object' };
|
|
25
|
-
if ('mcpServers' in parsed && !isPlainRecord(parsed.mcpServers)) {
|
|
26
|
-
return { ok: false, error: 'mcp.json mcpServers is not a JSON object' };
|
|
27
|
-
}
|
|
28
|
-
return { ok: true, value: parsed };
|
|
29
|
-
}
|
|
30
|
-
catch (e) {
|
|
31
|
-
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
// Non-ENOENT read failures count as corruption: skip, never clobber.
|
|
35
|
-
function readExisting() {
|
|
36
|
-
let raw;
|
|
37
|
-
try {
|
|
38
|
-
raw = fs.readFileSync(cursorMcpPath(), 'utf8');
|
|
39
|
-
}
|
|
40
|
-
catch (e) {
|
|
41
|
-
if (e?.code === 'ENOENT')
|
|
42
|
-
return { parsed: {} };
|
|
43
|
-
return { error: e instanceof Error ? e.message : String(e) };
|
|
44
|
-
}
|
|
45
|
-
const parsed = parseMcpJson(raw);
|
|
46
|
-
if (!parsed.ok)
|
|
47
|
-
return { error: parsed.error };
|
|
48
|
-
return { raw, parsed: parsed.value };
|
|
10
|
+
return CURSOR_TARGET.file();
|
|
49
11
|
}
|
|
50
12
|
/** Dry-run description of the pending write; touches nothing. */
|
|
51
13
|
export function planCursorWrite(mcpUrl, apiKey) {
|
|
52
|
-
|
|
53
|
-
const filePath = cursorMcpPath();
|
|
54
|
-
if ('error' in existing) {
|
|
55
|
-
return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
|
|
56
|
-
}
|
|
57
|
-
const authNote = apiKey
|
|
58
|
-
? ' with an API-key header — the key is stored in plaintext in that file (value not shown here)'
|
|
59
|
-
: '';
|
|
60
|
-
const servers = existing.parsed.mcpServers;
|
|
61
|
-
const current = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
|
|
62
|
-
if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl, apiKey))) {
|
|
63
|
-
return `${filePath}: already has the tinyfish MCP entry — no change`;
|
|
64
|
-
}
|
|
65
|
-
return existing.raw === undefined
|
|
66
|
-
? `${filePath}: would create with a "tinyfish" MCP server entry${authNote}`
|
|
67
|
-
: `${filePath}: would back up to a timestamped copy, then merge in the "tinyfish" MCP server entry${authNote}`;
|
|
14
|
+
return planWrite(CURSOR_TARGET, mcpUrl, apiKey);
|
|
68
15
|
}
|
|
69
16
|
/** Reports the header's shape, never its value. */
|
|
70
17
|
export function readCursorTinyfishEntry() {
|
|
71
|
-
|
|
72
|
-
if ('error' in existing)
|
|
73
|
-
return { present: false, hasApiKeyHeader: false, error: existing.error };
|
|
74
|
-
const servers = existing.parsed.mcpServers;
|
|
75
|
-
const entry = isPlainRecord(servers) ? servers[TINYFISH_SERVER_KEY] : undefined;
|
|
76
|
-
if (!isPlainRecord(entry))
|
|
77
|
-
return { present: false, hasApiKeyHeader: false };
|
|
78
|
-
const headers = entry.headers;
|
|
79
|
-
// Header names are case-insensitive, and this file is hand-editable — matching only the
|
|
80
|
-
// casing we write would understate auth mode for a user who typed it differently.
|
|
81
|
-
const keyHeader = isPlainRecord(headers)
|
|
82
|
-
? Object.entries(headers).find((entry) => entry[0].toLowerCase() === 'x-api-key' && typeof entry[1] === 'string')
|
|
83
|
-
: undefined;
|
|
84
|
-
return {
|
|
85
|
-
present: true,
|
|
86
|
-
hasApiKeyHeader: keyHeader !== undefined,
|
|
87
|
-
...(matchesCliKey(keyHeader?.[1]) ? { keyMatchesCliKey: true } : {}),
|
|
88
|
-
...(typeof entry.url === 'string' ? { url: entry.url } : {}),
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
// Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
|
|
92
|
-
function commitServers(filePath, existing, servers) {
|
|
93
|
-
fs.mkdirSync(cursorConfigDir(), { recursive: true, mode: 0o700 });
|
|
94
|
-
let backupPath;
|
|
95
|
-
if (existing.raw !== undefined) {
|
|
96
|
-
backupPath = `${filePath}.bak-${Date.now()}-${process.pid}`;
|
|
97
|
-
fs.writeFileSync(backupPath, existing.raw, { mode: 0o600 });
|
|
98
|
-
}
|
|
99
|
-
const next = { ...existing.parsed, mcpServers: servers };
|
|
100
|
-
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
101
|
-
fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
102
|
-
try {
|
|
103
|
-
fs.renameSync(tmpPath, filePath);
|
|
104
|
-
}
|
|
105
|
-
catch (e) {
|
|
106
|
-
fs.rmSync(tmpPath, { force: true });
|
|
107
|
-
throw e;
|
|
108
|
-
}
|
|
109
|
-
return { status: 'written', backupPath };
|
|
18
|
+
return readTinyfishEntry(CURSOR_TARGET);
|
|
110
19
|
}
|
|
111
20
|
/** Merges only the `tinyfish` key; skips unreadable/corrupt files rather than clobber. */
|
|
112
21
|
export function writeCursorMcpConfig(mcpUrl, apiKey) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
? { ...existing.parsed.mcpServers }
|
|
119
|
-
: {};
|
|
120
|
-
const nextEntry = buildTinyfishServerEntry(mcpUrl, apiKey);
|
|
121
|
-
if (JSON.stringify(servers[TINYFISH_SERVER_KEY]) === JSON.stringify(nextEntry)) {
|
|
122
|
-
return { status: 'unchanged' };
|
|
123
|
-
}
|
|
124
|
-
const repaired = TINYFISH_SERVER_KEY in servers;
|
|
125
|
-
servers[TINYFISH_SERVER_KEY] = nextEntry;
|
|
126
|
-
return { ...commitServers(filePath, existing, servers), repaired };
|
|
22
|
+
return writeMcpConfig(CURSOR_TARGET, mcpUrl, apiKey);
|
|
23
|
+
}
|
|
24
|
+
/** Removes only the `tinyfish` key. */
|
|
25
|
+
export function removeCursorMcpServer() {
|
|
26
|
+
return removeServer(CURSOR_TARGET);
|
|
127
27
|
}
|
|
128
28
|
/** Cursor deeplink format: cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64 */
|
|
129
29
|
export function cursorInstallDeeplink(mcpUrl) {
|
|
130
30
|
const config = Buffer.from(JSON.stringify(buildTinyfishServerEntry(mcpUrl))).toString('base64');
|
|
131
|
-
return `cursor://anysphere.cursor-deeplink/mcp/install?name=${
|
|
132
|
-
}
|
|
133
|
-
/** Removes only the `tinyfish` key. */
|
|
134
|
-
export function removeCursorMcpServer() {
|
|
135
|
-
const filePath = cursorMcpPath();
|
|
136
|
-
const existing = readExisting();
|
|
137
|
-
if ('error' in existing)
|
|
138
|
-
return { status: 'corrupt_skip', error: existing.error };
|
|
139
|
-
if (existing.raw === undefined)
|
|
140
|
-
return { status: 'unchanged' };
|
|
141
|
-
const servers = isPlainRecord(existing.parsed.mcpServers)
|
|
142
|
-
? { ...existing.parsed.mcpServers }
|
|
143
|
-
: {};
|
|
144
|
-
if (!(TINYFISH_SERVER_KEY in servers))
|
|
145
|
-
return { status: 'unchanged' };
|
|
146
|
-
delete servers[TINYFISH_SERVER_KEY];
|
|
147
|
-
return commitServers(filePath, existing, servers);
|
|
31
|
+
return `cursor://anysphere.cursor-deeplink/mcp/install?name=${CURSOR_TARGET.serverKey}&config=${encodeURIComponent(config)}`;
|
|
148
32
|
}
|
package/dist/lib/harness.js
CHANGED
|
@@ -14,6 +14,8 @@ const hasVarWithPrefix = (env, prefix) => Object.keys(env).some((key) => key.sta
|
|
|
14
14
|
// First match wins. Base agents (claude-code) go last so embedding wrappers win.
|
|
15
15
|
const HARNESS_FINGERPRINTS = [
|
|
16
16
|
{ name: 'openclaw', matches: (env) => hasVarWithPrefix(env, 'OPENCLAW_MCP_') },
|
|
17
|
+
// omp hardcodes CLAUDECODE=1, so it must precede claude-code.
|
|
18
|
+
{ name: 'omp', matches: (env) => env['OMPCODE'] === '1' },
|
|
17
19
|
{ name: 'grok', matches: (env) => env['GROK_AGENT'] === '1' },
|
|
18
20
|
// Cursor's VS Code host sets CURSOR_* broadly; CURSOR_AGENT=1 flags the
|
|
19
21
|
// agent specifically, so gate on it rather than the prefix.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface McpJsonTarget {
|
|
2
|
+
serverKey: string;
|
|
3
|
+
/** Functions so paths resolve at call time. */
|
|
4
|
+
dir(): string;
|
|
5
|
+
file(): string;
|
|
6
|
+
}
|
|
7
|
+
export interface McpJsonWriteResult {
|
|
8
|
+
status: 'written' | 'unchanged' | 'corrupt_skip';
|
|
9
|
+
backupPath?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
/** A pre-existing entry was replaced — a repair, not a fresh install. */
|
|
12
|
+
repaired?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildTinyfishServerEntry(mcpUrl: string, apiKey?: string): Record<string, unknown>;
|
|
15
|
+
/** Dry-run description of the pending write; touches nothing. */
|
|
16
|
+
export declare function planWrite(target: McpJsonTarget, mcpUrl: string, apiKey?: string): string;
|
|
17
|
+
export interface McpJsonServerEntry {
|
|
18
|
+
present: boolean;
|
|
19
|
+
hasApiKeyHeader: boolean;
|
|
20
|
+
/** Registered endpoint, so a caller can tell "registered" from "registered at the right place". */
|
|
21
|
+
url?: string;
|
|
22
|
+
keyMatchesCliKey?: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Reports the header's shape, never its value. */
|
|
26
|
+
export declare function readTinyfishEntry(target: McpJsonTarget): McpJsonServerEntry;
|
|
27
|
+
/** Merges only the target's key; skips unreadable/corrupt files rather than clobber. */
|
|
28
|
+
export declare function writeMcpConfig(target: McpJsonTarget, mcpUrl: string, apiKey?: string): McpJsonWriteResult;
|
|
29
|
+
/** Removes only the target's key. */
|
|
30
|
+
export declare function removeServer(target: McpJsonTarget): McpJsonWriteResult;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { matchesCliKey } from './auth.js';
|
|
3
|
+
function isPlainRecord(value) {
|
|
4
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
// Footgun: the key lands plaintext in mcp.json (0600) — per-device keys are the GA hardening.
|
|
7
|
+
export function buildTinyfishServerEntry(mcpUrl, apiKey) {
|
|
8
|
+
return apiKey ? { url: mcpUrl, headers: { 'X-API-Key': apiKey } } : { url: mcpUrl };
|
|
9
|
+
}
|
|
10
|
+
function parseMcpJson(raw) {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (!isPlainRecord(parsed))
|
|
14
|
+
return { ok: false, error: 'mcp.json root is not a JSON object' };
|
|
15
|
+
if ('mcpServers' in parsed && !isPlainRecord(parsed.mcpServers)) {
|
|
16
|
+
return { ok: false, error: 'mcp.json mcpServers is not a JSON object' };
|
|
17
|
+
}
|
|
18
|
+
return { ok: true, value: parsed };
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Non-ENOENT read failures count as corruption: skip, never clobber.
|
|
25
|
+
function readExisting(target) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = fs.readFileSync(target.file(), 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
if (e?.code === 'ENOENT')
|
|
32
|
+
return { parsed: {} };
|
|
33
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
34
|
+
}
|
|
35
|
+
const parsed = parseMcpJson(raw);
|
|
36
|
+
if (!parsed.ok)
|
|
37
|
+
return { error: parsed.error };
|
|
38
|
+
return { raw, parsed: parsed.value };
|
|
39
|
+
}
|
|
40
|
+
/** Dry-run description of the pending write; touches nothing. */
|
|
41
|
+
export function planWrite(target, mcpUrl, apiKey) {
|
|
42
|
+
const existing = readExisting(target);
|
|
43
|
+
const filePath = target.file();
|
|
44
|
+
if ('error' in existing) {
|
|
45
|
+
return `${filePath}: existing file is corrupt (${existing.error}) — would skip and leave it untouched`;
|
|
46
|
+
}
|
|
47
|
+
const authNote = apiKey
|
|
48
|
+
? ' with an API-key header — the key is stored in plaintext in that file (value not shown here)'
|
|
49
|
+
: '';
|
|
50
|
+
const servers = existing.parsed.mcpServers;
|
|
51
|
+
const current = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
|
|
52
|
+
if (JSON.stringify(current) === JSON.stringify(buildTinyfishServerEntry(mcpUrl, apiKey))) {
|
|
53
|
+
return `${filePath}: already has the ${target.serverKey} MCP entry — no change`;
|
|
54
|
+
}
|
|
55
|
+
return existing.raw === undefined
|
|
56
|
+
? `${filePath}: would create with a "${target.serverKey}" MCP server entry${authNote}`
|
|
57
|
+
: `${filePath}: would back up to a timestamped copy, then merge in the "${target.serverKey}" MCP server entry${authNote}`;
|
|
58
|
+
}
|
|
59
|
+
/** Reports the header's shape, never its value. */
|
|
60
|
+
export function readTinyfishEntry(target) {
|
|
61
|
+
const existing = readExisting(target);
|
|
62
|
+
if ('error' in existing)
|
|
63
|
+
return { present: false, hasApiKeyHeader: false, error: existing.error };
|
|
64
|
+
const servers = existing.parsed.mcpServers;
|
|
65
|
+
const entry = isPlainRecord(servers) ? servers[target.serverKey] : undefined;
|
|
66
|
+
if (!isPlainRecord(entry))
|
|
67
|
+
return { present: false, hasApiKeyHeader: false };
|
|
68
|
+
const headers = entry.headers;
|
|
69
|
+
// Header names are case-insensitive, and this file is hand-editable — matching only the
|
|
70
|
+
// casing we write would understate auth mode for a user who typed it differently.
|
|
71
|
+
const keyHeader = isPlainRecord(headers)
|
|
72
|
+
? Object.entries(headers).find((entry) => entry[0].toLowerCase() === 'x-api-key' && typeof entry[1] === 'string')
|
|
73
|
+
: undefined;
|
|
74
|
+
return {
|
|
75
|
+
present: true,
|
|
76
|
+
hasApiKeyHeader: keyHeader !== undefined,
|
|
77
|
+
...(matchesCliKey(keyHeader?.[1]) ? { keyMatchesCliKey: true } : {}),
|
|
78
|
+
...(typeof entry.url === 'string' ? { url: entry.url } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Backup, then atomic temp+rename; pid avoids same-millisecond backup collisions.
|
|
82
|
+
function commitServers(target, existing, servers) {
|
|
83
|
+
const filePath = target.file();
|
|
84
|
+
fs.mkdirSync(target.dir(), { recursive: true, mode: 0o700 });
|
|
85
|
+
let backupPath;
|
|
86
|
+
if (existing.raw !== undefined) {
|
|
87
|
+
backupPath = `${filePath}.bak-${Date.now()}-${process.pid}`;
|
|
88
|
+
fs.writeFileSync(backupPath, existing.raw, { mode: 0o600 });
|
|
89
|
+
}
|
|
90
|
+
const next = { ...existing.parsed, mcpServers: servers };
|
|
91
|
+
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
92
|
+
fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
93
|
+
try {
|
|
94
|
+
fs.renameSync(tmpPath, filePath);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
fs.rmSync(tmpPath, { force: true });
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
return { status: 'written', backupPath };
|
|
101
|
+
}
|
|
102
|
+
/** Merges only the target's key; skips unreadable/corrupt files rather than clobber. */
|
|
103
|
+
export function writeMcpConfig(target, mcpUrl, apiKey) {
|
|
104
|
+
const existing = readExisting(target);
|
|
105
|
+
if ('error' in existing)
|
|
106
|
+
return { status: 'corrupt_skip', error: existing.error };
|
|
107
|
+
const servers = isPlainRecord(existing.parsed.mcpServers)
|
|
108
|
+
? { ...existing.parsed.mcpServers }
|
|
109
|
+
: {};
|
|
110
|
+
const nextEntry = buildTinyfishServerEntry(mcpUrl, apiKey);
|
|
111
|
+
if (JSON.stringify(servers[target.serverKey]) === JSON.stringify(nextEntry)) {
|
|
112
|
+
return { status: 'unchanged' };
|
|
113
|
+
}
|
|
114
|
+
const repaired = target.serverKey in servers;
|
|
115
|
+
servers[target.serverKey] = nextEntry;
|
|
116
|
+
return { ...commitServers(target, existing, servers), repaired };
|
|
117
|
+
}
|
|
118
|
+
/** Removes only the target's key. */
|
|
119
|
+
export function removeServer(target) {
|
|
120
|
+
const existing = readExisting(target);
|
|
121
|
+
if ('error' in existing)
|
|
122
|
+
return { status: 'corrupt_skip', error: existing.error };
|
|
123
|
+
if (existing.raw === undefined)
|
|
124
|
+
return { status: 'unchanged' };
|
|
125
|
+
const servers = isPlainRecord(existing.parsed.mcpServers)
|
|
126
|
+
? { ...existing.parsed.mcpServers }
|
|
127
|
+
: {};
|
|
128
|
+
if (!(target.serverKey in servers))
|
|
129
|
+
return { status: 'unchanged' };
|
|
130
|
+
delete servers[target.serverKey];
|
|
131
|
+
return commitServers(target, existing, servers);
|
|
132
|
+
}
|