@paradigma-inc/flywheel 0.1.0 → 0.1.4
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 +15 -34
- package/package.json +19 -2
- package/src/agents.mjs +220 -61
- package/src/cli.mjs +482 -330
- package/src/mcp-writer.mjs +171 -80
- package/src/setup-auth.mjs +90 -9
- package/tests/mcp-writer.test.mjs +0 -117
package/src/mcp-writer.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { load as loadYaml, dump as dumpYaml } from "js-yaml";
|
|
3
4
|
|
|
4
5
|
function stripJsonComments(text) {
|
|
5
6
|
let result = "";
|
|
@@ -8,32 +9,40 @@ function stripJsonComments(text) {
|
|
|
8
9
|
if (text[i] === '"') {
|
|
9
10
|
const start = i++;
|
|
10
11
|
while (i < text.length && text[i] !== '"') {
|
|
11
|
-
if (text[i] === "\\") i
|
|
12
|
-
i
|
|
12
|
+
if (text[i] === "\\") i += 1;
|
|
13
|
+
i += 1;
|
|
13
14
|
}
|
|
14
15
|
result += text.slice(start, ++i);
|
|
15
16
|
} else if (text[i] === "/" && text[i + 1] === "/") {
|
|
16
17
|
i += 2;
|
|
17
|
-
while (i < text.length && text[i] !== "\n") i
|
|
18
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
18
19
|
} else if (text[i] === "/" && text[i + 1] === "*") {
|
|
19
20
|
i += 2;
|
|
20
|
-
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
21
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
22
|
+
i += 1;
|
|
21
23
|
i += 2;
|
|
22
24
|
} else {
|
|
23
|
-
result += text[i
|
|
25
|
+
result += text[i];
|
|
26
|
+
i += 1;
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
return result;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
function normalizeLineEndings(text) {
|
|
33
|
+
return text.replace(/\r\n/g, "\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export async function readJsonConfig(filePath) {
|
|
37
|
+
let raw;
|
|
30
38
|
try {
|
|
31
|
-
|
|
32
|
-
if (!raw) return {};
|
|
33
|
-
return JSON.parse(stripJsonComments(raw));
|
|
39
|
+
raw = await readFile(filePath, "utf8");
|
|
34
40
|
} catch {
|
|
35
41
|
return {};
|
|
36
42
|
}
|
|
43
|
+
const trimmed = raw.trim();
|
|
44
|
+
if (!trimmed) return {};
|
|
45
|
+
return JSON.parse(stripJsonComments(trimmed));
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export async function writeJsonConfig(filePath, config) {
|
|
@@ -44,79 +53,190 @@ export async function writeJsonConfig(filePath, config) {
|
|
|
44
53
|
});
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
function
|
|
48
|
-
|
|
56
|
+
export async function readYamlConfig(filePath) {
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = await readFile(filePath, "utf8");
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
const trimmed = raw.trim();
|
|
64
|
+
if (!trimmed) return {};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const parsed = loadYaml(trimmed);
|
|
68
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
return {};
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
49
75
|
}
|
|
50
76
|
|
|
51
|
-
export function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
77
|
+
export async function writeYamlConfig(filePath, config) {
|
|
78
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
79
|
+
await writeFile(filePath, dumpYaml(config, { noRefs: true }), {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
mode: 0o600,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function resolveMcpPath(candidates) {
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
try {
|
|
88
|
+
// eslint-disable-next-line no-await-in-loop
|
|
89
|
+
await access(candidate);
|
|
90
|
+
return candidate;
|
|
91
|
+
} catch {
|
|
92
|
+
// keep searching
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return candidates[0];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function keyPath(configKey) {
|
|
99
|
+
return Array.isArray(configKey) ? configKey : [configKey];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readConfigSection(config, configKey) {
|
|
103
|
+
const pathParts = keyPath(configKey);
|
|
104
|
+
let current = config && typeof config === "object" ? config : {};
|
|
105
|
+
for (const part of pathParts) {
|
|
106
|
+
if (
|
|
107
|
+
!current ||
|
|
108
|
+
typeof current !== "object" ||
|
|
109
|
+
Array.isArray(current) ||
|
|
110
|
+
!(part in current)
|
|
111
|
+
) {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
const next = current[part];
|
|
115
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
current = next;
|
|
119
|
+
}
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeConfigSection(config, configKey, section) {
|
|
124
|
+
const pathParts = keyPath(configKey);
|
|
125
|
+
if (pathParts.length === 0) return config || {};
|
|
126
|
+
|
|
127
|
+
const root =
|
|
128
|
+
config && typeof config === "object" && !Array.isArray(config)
|
|
129
|
+
? { ...config }
|
|
55
130
|
: {};
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
[
|
|
61
|
-
|
|
131
|
+
let cursor = root;
|
|
132
|
+
|
|
133
|
+
for (let index = 0; index < pathParts.length - 1; index += 1) {
|
|
134
|
+
const part = pathParts[index];
|
|
135
|
+
const existing = cursor[part];
|
|
136
|
+
cursor[part] =
|
|
137
|
+
existing && typeof existing === "object" && !Array.isArray(existing)
|
|
138
|
+
? { ...existing }
|
|
139
|
+
: {};
|
|
140
|
+
cursor = cursor[part];
|
|
141
|
+
}
|
|
142
|
+
cursor[pathParts[pathParts.length - 1]] = section;
|
|
143
|
+
return root;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function mergeServerEntry(existing, configKey, serverName, entry) {
|
|
147
|
+
const section = readConfigSection(existing, configKey);
|
|
148
|
+
|
|
149
|
+
if (serverName in section) {
|
|
150
|
+
return { config: existing, alreadyExists: true };
|
|
151
|
+
}
|
|
62
152
|
|
|
63
|
-
const changed = stableJson(previous) !== stableJson(entry);
|
|
64
153
|
return {
|
|
65
|
-
config:
|
|
66
|
-
|
|
67
|
-
|
|
154
|
+
config: writeConfigSection(existing, configKey, {
|
|
155
|
+
...section,
|
|
156
|
+
[serverName]: entry,
|
|
157
|
+
}),
|
|
158
|
+
alreadyExists: false,
|
|
68
159
|
};
|
|
69
160
|
}
|
|
70
161
|
|
|
71
162
|
export function removeJsonServerEntry({ config, configKey, serverName }) {
|
|
72
|
-
const section =
|
|
73
|
-
|
|
74
|
-
? { ...config[configKey] }
|
|
75
|
-
: {};
|
|
163
|
+
const section = { ...readConfigSection(config, configKey) };
|
|
164
|
+
|
|
76
165
|
if (!(serverName in section)) {
|
|
77
|
-
return {
|
|
78
|
-
config: config || {},
|
|
79
|
-
changed: false,
|
|
80
|
-
};
|
|
166
|
+
return { config: config || {}, changed: false };
|
|
81
167
|
}
|
|
168
|
+
|
|
82
169
|
delete section[serverName];
|
|
83
|
-
const nextConfig = {
|
|
84
|
-
...(config || {}),
|
|
85
|
-
[configKey]: section,
|
|
86
|
-
};
|
|
87
170
|
return {
|
|
88
|
-
config:
|
|
171
|
+
config: writeConfigSection(config, configKey, section),
|
|
89
172
|
changed: true,
|
|
90
173
|
};
|
|
91
174
|
}
|
|
92
175
|
|
|
93
|
-
function
|
|
94
|
-
|
|
176
|
+
export async function readTomlServerExists(filePath, serverName) {
|
|
177
|
+
try {
|
|
178
|
+
const raw = await readFile(filePath, "utf8");
|
|
179
|
+
return raw.includes(`[mcp_servers.${serverName}]`);
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
95
183
|
}
|
|
96
184
|
|
|
97
|
-
export function
|
|
185
|
+
export function buildTomlServerBlock(serverName, entry) {
|
|
98
186
|
const lines = [`[mcp_servers.${serverName}]`];
|
|
99
|
-
|
|
100
|
-
|
|
187
|
+
const headers =
|
|
188
|
+
entry && typeof entry.headers === "object" && entry.headers !== null
|
|
189
|
+
? entry.headers
|
|
190
|
+
: null;
|
|
191
|
+
|
|
192
|
+
for (const [key, value] of Object.entries(entry || {})) {
|
|
193
|
+
if (key === "headers") continue;
|
|
194
|
+
lines.push(`${key} = ${JSON.stringify(value)}`);
|
|
195
|
+
}
|
|
101
196
|
|
|
102
|
-
|
|
103
|
-
const headerEntries = Object.entries(headers);
|
|
104
|
-
if (headerEntries.length > 0) {
|
|
197
|
+
if (headers && Object.keys(headers).length > 0) {
|
|
105
198
|
lines.push("");
|
|
106
199
|
lines.push(`[mcp_servers.${serverName}.http_headers]`);
|
|
107
|
-
for (const [
|
|
108
|
-
lines.push(`${
|
|
200
|
+
for (const [headerKey, headerValue] of Object.entries(headers)) {
|
|
201
|
+
lines.push(`${headerKey} = ${JSON.stringify(String(headerValue))}`);
|
|
109
202
|
}
|
|
110
203
|
}
|
|
111
204
|
|
|
112
205
|
return `${lines.join("\n")}\n`;
|
|
113
206
|
}
|
|
114
207
|
|
|
208
|
+
export async function appendTomlServer(filePath, serverName, entry) {
|
|
209
|
+
if (await readTomlServerExists(filePath, serverName)) {
|
|
210
|
+
return { alreadyExists: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const block = buildTomlServerBlock(serverName, entry);
|
|
214
|
+
|
|
215
|
+
let existing = "";
|
|
216
|
+
try {
|
|
217
|
+
existing = await readFile(filePath, "utf8");
|
|
218
|
+
} catch {
|
|
219
|
+
existing = "";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const separator =
|
|
223
|
+
existing.length > 0 && !existing.endsWith("\n")
|
|
224
|
+
? "\n\n"
|
|
225
|
+
: existing.length > 0
|
|
226
|
+
? "\n"
|
|
227
|
+
: "";
|
|
228
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
229
|
+
await writeFile(filePath, `${existing}${separator}${block}`, {
|
|
230
|
+
encoding: "utf8",
|
|
231
|
+
mode: 0o600,
|
|
232
|
+
});
|
|
233
|
+
return { alreadyExists: false };
|
|
234
|
+
}
|
|
235
|
+
|
|
115
236
|
function stripCodexTomlServerBlock({ tomlText, serverName }) {
|
|
116
237
|
const normalized = normalizeLineEndings(tomlText || "");
|
|
117
238
|
const lines = normalized.split("\n");
|
|
118
239
|
const output = [];
|
|
119
|
-
|
|
120
240
|
let skipping = false;
|
|
121
241
|
let removed = false;
|
|
122
242
|
|
|
@@ -146,37 +266,10 @@ function stripCodexTomlServerBlock({ tomlText, serverName }) {
|
|
|
146
266
|
|
|
147
267
|
let stripped = output.join("\n");
|
|
148
268
|
stripped = stripped.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
149
|
-
if (stripped.length > 0)
|
|
150
|
-
stripped += "\n";
|
|
151
|
-
}
|
|
269
|
+
if (stripped.length > 0) stripped += "\n";
|
|
152
270
|
return { stripped, removed };
|
|
153
271
|
}
|
|
154
272
|
|
|
155
|
-
export async function upsertCodexTomlServer({ filePath, serverName, entry }) {
|
|
156
|
-
let existing = "";
|
|
157
|
-
try {
|
|
158
|
-
existing = await readFile(filePath, "utf8");
|
|
159
|
-
} catch {
|
|
160
|
-
existing = "";
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const { stripped, removed } = stripCodexTomlServerBlock({
|
|
164
|
-
tomlText: existing,
|
|
165
|
-
serverName,
|
|
166
|
-
});
|
|
167
|
-
const block = buildCodexTomlServerBlock({ serverName, entry });
|
|
168
|
-
const separator = stripped.length > 0 ? "\n" : "";
|
|
169
|
-
const next = `${stripped}${separator}${block}`;
|
|
170
|
-
|
|
171
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
172
|
-
await writeFile(filePath, next, { encoding: "utf8", mode: 0o600 });
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
changed: normalizeLineEndings(existing) !== normalizeLineEndings(next),
|
|
176
|
-
hadExisting: removed,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
273
|
export async function removeCodexTomlServer({ filePath, serverName }) {
|
|
181
274
|
let existing = "";
|
|
182
275
|
try {
|
|
@@ -189,9 +282,7 @@ export async function removeCodexTomlServer({ filePath, serverName }) {
|
|
|
189
282
|
tomlText: existing,
|
|
190
283
|
serverName,
|
|
191
284
|
});
|
|
192
|
-
if (!removed) {
|
|
193
|
-
return { changed: false };
|
|
194
|
-
}
|
|
285
|
+
if (!removed) return { changed: false };
|
|
195
286
|
|
|
196
287
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
197
288
|
await writeFile(filePath, stripped, { encoding: "utf8", mode: 0o600 });
|
package/src/setup-auth.mjs
CHANGED
|
@@ -32,10 +32,67 @@ function renderCallbackPage({ ok, message }) {
|
|
|
32
32
|
</html>`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
async function redeemSetupExchangeToken({
|
|
36
|
+
baseUrl,
|
|
37
|
+
exchangeToken,
|
|
38
|
+
state,
|
|
39
|
+
redirectUri,
|
|
40
|
+
}) {
|
|
41
|
+
const redeemUrl = new URL(
|
|
42
|
+
"/api/auth/mcp-api-keys/setup-exchange/redeem",
|
|
43
|
+
baseUrl,
|
|
44
|
+
);
|
|
45
|
+
const response = await fetch(redeemUrl, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"content-type": "application/json",
|
|
49
|
+
"Idempotency-Key": `setup-redeem:${state}`,
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
exchange_token: exchangeToken,
|
|
53
|
+
state,
|
|
54
|
+
redirect_uri: redirectUri,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
let payload = {};
|
|
58
|
+
try {
|
|
59
|
+
payload = await response.json();
|
|
60
|
+
} catch {
|
|
61
|
+
payload = {};
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const detail = resolveRedeemErrorMessage(payload);
|
|
65
|
+
throw new Error(detail);
|
|
66
|
+
}
|
|
67
|
+
const key = (payload?.key || "").trim();
|
|
68
|
+
if (!key) {
|
|
69
|
+
throw new Error("Setup exchange response missing API key.");
|
|
70
|
+
}
|
|
71
|
+
return { apiKey: key };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveRedeemErrorMessage(payload) {
|
|
75
|
+
if (!payload || typeof payload !== "object") {
|
|
76
|
+
return "Setup exchange redemption failed.";
|
|
77
|
+
}
|
|
78
|
+
const detail = payload.detail;
|
|
79
|
+
if (typeof detail === "string" && detail.trim()) {
|
|
80
|
+
return detail.trim();
|
|
81
|
+
}
|
|
82
|
+
if (detail && typeof detail === "object") {
|
|
83
|
+
const message = detail.message;
|
|
84
|
+
if (typeof message === "string" && message.trim()) {
|
|
85
|
+
return message.trim();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return "Setup exchange redemption failed.";
|
|
89
|
+
}
|
|
90
|
+
|
|
35
91
|
export async function acquireApiKeyViaBrowserBridge({
|
|
36
92
|
baseUrl,
|
|
37
93
|
keyName,
|
|
38
94
|
timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
|
|
95
|
+
openUrl = openUrlInBrowser,
|
|
39
96
|
}) {
|
|
40
97
|
const state = crypto.randomUUID();
|
|
41
98
|
|
|
@@ -43,6 +100,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
43
100
|
let settled = false;
|
|
44
101
|
let timeout = null;
|
|
45
102
|
let server = null;
|
|
103
|
+
let callbackUrl = "";
|
|
46
104
|
|
|
47
105
|
const cleanup = () => {
|
|
48
106
|
if (timeout) {
|
|
@@ -51,6 +109,8 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
51
109
|
}
|
|
52
110
|
if (server) {
|
|
53
111
|
server.close();
|
|
112
|
+
server.closeAllConnections?.();
|
|
113
|
+
server.closeIdleConnections?.();
|
|
54
114
|
server = null;
|
|
55
115
|
}
|
|
56
116
|
};
|
|
@@ -69,7 +129,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
69
129
|
resolve(result);
|
|
70
130
|
};
|
|
71
131
|
|
|
72
|
-
server = http.createServer((req, res) => {
|
|
132
|
+
server = http.createServer(async (req, res) => {
|
|
73
133
|
const reqUrl = new URL(req.url || "/", "http://127.0.0.1");
|
|
74
134
|
if (reqUrl.pathname !== "/callback") {
|
|
75
135
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
@@ -78,7 +138,9 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
78
138
|
}
|
|
79
139
|
|
|
80
140
|
const callbackState = (reqUrl.searchParams.get("state") || "").trim();
|
|
81
|
-
const
|
|
141
|
+
const exchangeToken = (
|
|
142
|
+
reqUrl.searchParams.get("exchange_token") || ""
|
|
143
|
+
).trim();
|
|
82
144
|
const error = (reqUrl.searchParams.get("error") || "").trim();
|
|
83
145
|
|
|
84
146
|
if (callbackState !== state) {
|
|
@@ -100,15 +162,32 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
100
162
|
return;
|
|
101
163
|
}
|
|
102
164
|
|
|
103
|
-
if (!
|
|
165
|
+
if (!exchangeToken) {
|
|
104
166
|
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
105
167
|
res.end(
|
|
106
168
|
renderCallbackPage({
|
|
107
169
|
ok: false,
|
|
108
|
-
message: "No
|
|
170
|
+
message: "No setup exchange token was returned.",
|
|
109
171
|
}),
|
|
110
172
|
);
|
|
111
|
-
fail(new Error("Setup callback missing
|
|
173
|
+
fail(new Error("Setup callback missing exchange token."));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let redeemed;
|
|
178
|
+
try {
|
|
179
|
+
redeemed = await redeemSetupExchangeToken({
|
|
180
|
+
baseUrl,
|
|
181
|
+
exchangeToken,
|
|
182
|
+
state,
|
|
183
|
+
redirectUri: callbackUrl,
|
|
184
|
+
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const message =
|
|
187
|
+
err instanceof Error ? err.message : "Failed to redeem setup exchange.";
|
|
188
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
189
|
+
res.end(renderCallbackPage({ ok: false, message }));
|
|
190
|
+
fail(new Error(message));
|
|
112
191
|
return;
|
|
113
192
|
}
|
|
114
193
|
|
|
@@ -119,7 +198,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
119
198
|
message: "Flywheel MCP was authorized successfully.",
|
|
120
199
|
}),
|
|
121
200
|
);
|
|
122
|
-
succeed({ apiKey:
|
|
201
|
+
succeed({ apiKey: redeemed.apiKey });
|
|
123
202
|
});
|
|
124
203
|
|
|
125
204
|
server.once("error", (error) => {
|
|
@@ -133,7 +212,7 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
133
212
|
return;
|
|
134
213
|
}
|
|
135
214
|
|
|
136
|
-
|
|
215
|
+
callbackUrl = `http://127.0.0.1:${address.port}/callback`;
|
|
137
216
|
const setupUrl = new URL("/auth/mcp/setup", baseUrl);
|
|
138
217
|
setupUrl.searchParams.set("state", state);
|
|
139
218
|
setupUrl.searchParams.set("redirect_uri", callbackUrl);
|
|
@@ -143,8 +222,10 @@ export async function acquireApiKeyViaBrowserBridge({
|
|
|
143
222
|
console.log(`If it does not open, use this URL:\n${setupUrl.toString()}\n`);
|
|
144
223
|
|
|
145
224
|
try {
|
|
146
|
-
const child =
|
|
147
|
-
child.unref
|
|
225
|
+
const child = openUrl(setupUrl.toString());
|
|
226
|
+
if (child && typeof child.unref === "function") {
|
|
227
|
+
child.unref();
|
|
228
|
+
}
|
|
148
229
|
} catch {
|
|
149
230
|
// User can still open URL manually.
|
|
150
231
|
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import assert from "node:assert/strict";
|
|
5
|
-
import test from "node:test";
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
removeCodexTomlServer,
|
|
9
|
-
removeJsonServerEntry,
|
|
10
|
-
upsertCodexTomlServer,
|
|
11
|
-
upsertJsonServerEntry,
|
|
12
|
-
writeJsonConfig,
|
|
13
|
-
readJsonConfig,
|
|
14
|
-
} from "../src/mcp-writer.mjs";
|
|
15
|
-
|
|
16
|
-
test("upsertJsonServerEntry installs and removes server entries", async () => {
|
|
17
|
-
const initial = {
|
|
18
|
-
mcpServers: {
|
|
19
|
-
other: {
|
|
20
|
-
url: "http://localhost/other",
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const inserted = upsertJsonServerEntry({
|
|
26
|
-
config: initial,
|
|
27
|
-
configKey: "mcpServers",
|
|
28
|
-
serverName: "flywheel",
|
|
29
|
-
entry: {
|
|
30
|
-
type: "http",
|
|
31
|
-
url: "https://flywheel.paradigma.inc/mcp-server",
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
assert.equal(inserted.changed, true);
|
|
36
|
-
assert.equal(inserted.hadExisting, false);
|
|
37
|
-
assert.equal(inserted.config.mcpServers.flywheel.type, "http");
|
|
38
|
-
|
|
39
|
-
const removed = removeJsonServerEntry({
|
|
40
|
-
config: inserted.config,
|
|
41
|
-
configKey: "mcpServers",
|
|
42
|
-
serverName: "flywheel",
|
|
43
|
-
});
|
|
44
|
-
assert.equal(removed.changed, true);
|
|
45
|
-
assert.equal("flywheel" in removed.config.mcpServers, false);
|
|
46
|
-
assert.equal("other" in removed.config.mcpServers, true);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("codex toml writer replaces flywheel block idempotently", async () => {
|
|
50
|
-
const tempDir = await mkdtemp(path.join(os.tmpdir(), "flywheel-mcp-writer-"));
|
|
51
|
-
const filePath = path.join(tempDir, "config.toml");
|
|
52
|
-
|
|
53
|
-
const first = await upsertCodexTomlServer({
|
|
54
|
-
filePath,
|
|
55
|
-
serverName: "flywheel",
|
|
56
|
-
entry: {
|
|
57
|
-
type: "http",
|
|
58
|
-
url: "https://flywheel.paradigma.inc/mcp-server",
|
|
59
|
-
headers: {
|
|
60
|
-
Authorization: "Bearer one",
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
assert.equal(first.changed, true);
|
|
65
|
-
|
|
66
|
-
const second = await upsertCodexTomlServer({
|
|
67
|
-
filePath,
|
|
68
|
-
serverName: "flywheel",
|
|
69
|
-
entry: {
|
|
70
|
-
type: "http",
|
|
71
|
-
url: "https://flywheel.paradigma.inc/mcp-server",
|
|
72
|
-
headers: {
|
|
73
|
-
Authorization: "Bearer two",
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
assert.equal(second.changed, true);
|
|
78
|
-
assert.equal(second.hadExisting, true);
|
|
79
|
-
|
|
80
|
-
const text = await readFile(filePath, "utf8");
|
|
81
|
-
assert.equal(
|
|
82
|
-
text.includes("Authorization = \"Bearer two\""),
|
|
83
|
-
true,
|
|
84
|
-
"updated token should be present",
|
|
85
|
-
);
|
|
86
|
-
assert.equal(
|
|
87
|
-
text.includes("Authorization = \"Bearer one\""),
|
|
88
|
-
false,
|
|
89
|
-
"old token should not survive repeated setup",
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
const removed = await removeCodexTomlServer({
|
|
93
|
-
filePath,
|
|
94
|
-
serverName: "flywheel",
|
|
95
|
-
});
|
|
96
|
-
assert.equal(removed.changed, true);
|
|
97
|
-
const afterRemove = await readFile(filePath, "utf8");
|
|
98
|
-
assert.equal(afterRemove.includes("[mcp_servers.flywheel]"), false);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("readJsonConfig/writeJsonConfig round-trip", async () => {
|
|
102
|
-
const tempDir = await mkdtemp(path.join(os.tmpdir(), "flywheel-mcp-json-"));
|
|
103
|
-
const filePath = path.join(tempDir, "mcp.json");
|
|
104
|
-
|
|
105
|
-
const config = {
|
|
106
|
-
mcpServers: {
|
|
107
|
-
flywheel: {
|
|
108
|
-
type: "http",
|
|
109
|
-
url: "https://flywheel.paradigma.inc/mcp-server",
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
await writeJsonConfig(filePath, config);
|
|
115
|
-
const loaded = await readJsonConfig(filePath);
|
|
116
|
-
assert.deepEqual(loaded, config);
|
|
117
|
-
});
|