@ainecto/mcp 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +246 -86
- package/dist/bin/ainecto.js +2949 -251
- package/dist/bin/ainecto.js.map +1 -1
- package/dist/bin/mcp.js +190 -28
- package/dist/bin/mcp.js.map +1 -1
- package/package.json +3 -4
package/dist/bin/mcp.js
CHANGED
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
// src/core/config/endpoints.ts
|
|
4
4
|
var DEFAULT_ENDPOINTS = {
|
|
5
|
-
prod: "https://
|
|
5
|
+
prod: "https://ainecto.com/mcp",
|
|
6
6
|
dev: "https://dev.ai-erd.com/mcp"
|
|
7
7
|
};
|
|
8
|
+
var LEGACY_PROD_ENDPOINT = "https://ai-erd.com/mcp";
|
|
8
9
|
var LEGACY_DEFAULT_ENDPOINTS = [
|
|
9
|
-
|
|
10
|
+
LEGACY_PROD_ENDPOINT,
|
|
10
11
|
"https://dev.ainecto.com/mcp"
|
|
11
12
|
];
|
|
13
|
+
var TOKEN_SLOT_FALLBACKS = {
|
|
14
|
+
[DEFAULT_ENDPOINTS.prod]: [LEGACY_PROD_ENDPOINT]
|
|
15
|
+
};
|
|
16
|
+
function fallbackTokenEndpointsFor(endpoint) {
|
|
17
|
+
return TOKEN_SLOT_FALLBACKS[normalizeEndpoint(endpoint)] ?? [];
|
|
18
|
+
}
|
|
12
19
|
function resolveEndpoint(input = {}) {
|
|
13
20
|
const envVars = input.envVars ?? process.env;
|
|
14
21
|
const env = input.env ?? "prod";
|
|
@@ -65,6 +72,37 @@ import { createHash } from "crypto";
|
|
|
65
72
|
import { mkdir, readFile, rename, rm, stat, writeFile, chmod } from "fs/promises";
|
|
66
73
|
import { homedir } from "os";
|
|
67
74
|
import { dirname, join } from "path";
|
|
75
|
+
|
|
76
|
+
// src/core/harness/role.ts
|
|
77
|
+
var HARNESS_ROLES = ["design", "development", "test", "validation"];
|
|
78
|
+
var ROLE_SCOPE_PREFIX = "ai-erd:role:";
|
|
79
|
+
var ROLE_HEADER = "X-AI-ERD-Role";
|
|
80
|
+
var ROLE_ENV_VAR = "AINECTO_ROLE";
|
|
81
|
+
var LEGACY_ROLE_ENV_VAR = "AI_ERD_ROLE";
|
|
82
|
+
function resolveRole(input = {}) {
|
|
83
|
+
if (input.role !== void 0) {
|
|
84
|
+
return { role: assertRole(input.role), source: "flag" };
|
|
85
|
+
}
|
|
86
|
+
const envVars = input.envVars ?? process.env;
|
|
87
|
+
for (const envVar of [ROLE_ENV_VAR, LEGACY_ROLE_ENV_VAR]) {
|
|
88
|
+
const fromEnv = envVars[envVar];
|
|
89
|
+
if (fromEnv && fromEnv.trim()) {
|
|
90
|
+
return { role: assertRole(fromEnv), source: "env", envVar };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { source: "none" };
|
|
94
|
+
}
|
|
95
|
+
function assertRole(value) {
|
|
96
|
+
const normalized = value.trim().toLowerCase();
|
|
97
|
+
if (HARNESS_ROLES.includes(normalized)) {
|
|
98
|
+
return normalized;
|
|
99
|
+
}
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Invalid role "${value}". Expected one of: ${HARNESS_ROLES.join(", ")}.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/core/auth/tokenStore.ts
|
|
68
106
|
var FileTokenStore = class {
|
|
69
107
|
filePath;
|
|
70
108
|
constructor(rootDir = join(homedir(), ".ainecto")) {
|
|
@@ -75,6 +113,7 @@ var FileTokenStore = class {
|
|
|
75
113
|
return file[tokenKey(endpoint)];
|
|
76
114
|
}
|
|
77
115
|
async save(endpoint, token) {
|
|
116
|
+
assertNoRoleScope(token);
|
|
78
117
|
const file = await this.readFile();
|
|
79
118
|
file[tokenKey(endpoint)] = { ...token, endpoint, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
80
119
|
await this.writeFile(file);
|
|
@@ -119,6 +158,13 @@ var FileTokenStore = class {
|
|
|
119
158
|
function tokenKey(endpoint) {
|
|
120
159
|
return createHash("sha256").update(endpoint).digest("hex");
|
|
121
160
|
}
|
|
161
|
+
function assertNoRoleScope(token) {
|
|
162
|
+
if (token.scope?.split(/\s+/).some((scope) => scope.startsWith(ROLE_SCOPE_PREFIX))) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
"Refusing to store a role-scoped access token: sign-in is one per machine and the role comes from the repository. Run `ainecto auth login` again."
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
122
168
|
function isNodeError(error, code) {
|
|
123
169
|
return error instanceof Error && "code" in error && error.code === code;
|
|
124
170
|
}
|
|
@@ -150,7 +196,7 @@ async function registerPublicClient(registrationEndpoint, redirectUri, fetchImpl
|
|
|
150
196
|
method: "POST",
|
|
151
197
|
headers: { "content-type": "application/json", accept: "application/json" },
|
|
152
198
|
body: JSON.stringify({
|
|
153
|
-
client_name: "
|
|
199
|
+
client_name: "AiNecto CLI",
|
|
154
200
|
redirect_uris: [redirectUri],
|
|
155
201
|
token_endpoint_auth_method: "none",
|
|
156
202
|
grant_types: ["authorization_code", "refresh_token"],
|
|
@@ -174,6 +220,15 @@ function isRecord(value) {
|
|
|
174
220
|
}
|
|
175
221
|
|
|
176
222
|
// src/core/auth/oauth.ts
|
|
223
|
+
var DEFAULT_LOGIN_TIMEOUT_MS = 3e5;
|
|
224
|
+
var OAuthLoginError = class extends Error {
|
|
225
|
+
constructor(code, message) {
|
|
226
|
+
super(message);
|
|
227
|
+
this.code = code;
|
|
228
|
+
this.name = "OAuthLoginError";
|
|
229
|
+
}
|
|
230
|
+
code;
|
|
231
|
+
};
|
|
177
232
|
var OAuthClient = class {
|
|
178
233
|
constructor(options) {
|
|
179
234
|
this.options = options;
|
|
@@ -191,21 +246,27 @@ var OAuthClient = class {
|
|
|
191
246
|
assertEnvTokenEndpointAllowed(this.options.endpoint, this.envVars);
|
|
192
247
|
return envToken;
|
|
193
248
|
}
|
|
194
|
-
const stored = await this.
|
|
249
|
+
const stored = await this.loadStored();
|
|
195
250
|
if (!stored) {
|
|
196
251
|
return void 0;
|
|
197
252
|
}
|
|
198
253
|
if (!shouldRefresh(stored)) {
|
|
199
254
|
return stored.accessToken;
|
|
200
255
|
}
|
|
201
|
-
|
|
256
|
+
if (this.options.readOnly) {
|
|
257
|
+
return isExpired(stored) ? void 0 : stored.accessToken;
|
|
258
|
+
}
|
|
259
|
+
if (!stored.refreshToken) {
|
|
260
|
+
return isExpired(stored) ? void 0 : stored.accessToken;
|
|
261
|
+
}
|
|
262
|
+
return this.refreshStoredToken({ ...stored, refreshToken: stored.refreshToken });
|
|
202
263
|
}
|
|
203
264
|
async refreshAfterUnauthorized() {
|
|
204
|
-
const stored = await this.
|
|
205
|
-
if (!stored?.refreshToken) {
|
|
265
|
+
const stored = await this.loadStored();
|
|
266
|
+
if (this.options.readOnly || !stored?.refreshToken) {
|
|
206
267
|
return void 0;
|
|
207
268
|
}
|
|
208
|
-
return this.refreshStoredToken(stored);
|
|
269
|
+
return this.refreshStoredToken({ ...stored, refreshToken: stored.refreshToken });
|
|
209
270
|
}
|
|
210
271
|
async login() {
|
|
211
272
|
const pkce = generatePkcePair();
|
|
@@ -223,8 +284,13 @@ var OAuthClient = class {
|
|
|
223
284
|
authorizeUrl.searchParams.set("resource", this.options.endpoint);
|
|
224
285
|
authorizeUrl.searchParams.set("state", state);
|
|
225
286
|
assertTrustedEndpointUrl(authorizeUrl, "OAuth authorization URL");
|
|
226
|
-
|
|
227
|
-
|
|
287
|
+
const url = authorizeUrl.toString();
|
|
288
|
+
this.options.onAuthorizeUrl?.(url);
|
|
289
|
+
const browserFailure = this.openBrowserImpl(url).then(() => new Promise(() => void 0));
|
|
290
|
+
const code = await withTimeout(
|
|
291
|
+
Promise.race([loopback.waitForCode(), browserFailure]),
|
|
292
|
+
this.options.loginTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
|
|
293
|
+
);
|
|
228
294
|
const token = await exchangeToken(metadata.tokenEndpoint, {
|
|
229
295
|
grant_type: "authorization_code",
|
|
230
296
|
code,
|
|
@@ -241,13 +307,32 @@ var OAuthClient = class {
|
|
|
241
307
|
await loopback.close();
|
|
242
308
|
}
|
|
243
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* ★대체로 읽는 옛 칸도 함께 지운다. 새 칸만 지우면 다음 읽기가 곧바로 옛 칸으로 대체돼
|
|
312
|
+
* «로그아웃했는데 여전히 로그인됨»이 된다. 로그인은 기계당 한 번이므로 옛 CLI 의 칸도 같이 끝난다.
|
|
313
|
+
*/
|
|
244
314
|
async logout() {
|
|
245
315
|
await this.options.tokenStore.delete(this.options.endpoint);
|
|
316
|
+
for (const fallback of fallbackTokenEndpointsFor(this.options.endpoint)) {
|
|
317
|
+
await this.options.tokenStore.delete(fallback);
|
|
318
|
+
}
|
|
246
319
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
320
|
+
/** 이 주소의 칸, 비었으면 옛 기본 주소의 칸({@link fallbackTokenEndpointsFor}). 쓰기는 늘 이 주소의 칸이다. */
|
|
321
|
+
async loadStored() {
|
|
322
|
+
const own = await this.options.tokenStore.load(this.options.endpoint);
|
|
323
|
+
if (own) {
|
|
324
|
+
return own;
|
|
325
|
+
}
|
|
326
|
+
for (const fallback of fallbackTokenEndpointsFor(this.options.endpoint)) {
|
|
327
|
+
const stored = await this.options.tokenStore.load(fallback);
|
|
328
|
+
if (stored) {
|
|
329
|
+
return stored;
|
|
330
|
+
}
|
|
250
331
|
}
|
|
332
|
+
return void 0;
|
|
333
|
+
}
|
|
334
|
+
/** 부르는 쪽이 refresh token 이 있음을 이미 확인했다 — 타입이 그것을 요구한다. */
|
|
335
|
+
async refreshStoredToken(stored) {
|
|
251
336
|
const metadata = await discoverOAuthMetadata(this.options.endpoint, this.fetchImpl);
|
|
252
337
|
const token = await exchangeToken(metadata.tokenEndpoint, {
|
|
253
338
|
grant_type: "refresh_token",
|
|
@@ -269,6 +354,27 @@ function generatePkcePair() {
|
|
|
269
354
|
function generateOAuthState() {
|
|
270
355
|
return randomBytes(32).toString("base64url");
|
|
271
356
|
}
|
|
357
|
+
function isExpired(token, now = Date.now()) {
|
|
358
|
+
return token.expiresAt !== void 0 && token.expiresAt <= now;
|
|
359
|
+
}
|
|
360
|
+
async function withTimeout(work, timeoutMs) {
|
|
361
|
+
let timer;
|
|
362
|
+
const timeout = new Promise((_, reject) => {
|
|
363
|
+
timer = setTimeout(() => {
|
|
364
|
+
const minutes = timeoutMs / 6e4;
|
|
365
|
+
const span = Number.isInteger(minutes) ? `${minutes} minute${minutes === 1 ? "" : "s"}` : `${timeoutMs} ms`;
|
|
366
|
+
reject(new OAuthLoginError(
|
|
367
|
+
"LOGIN_TIMEOUT",
|
|
368
|
+
`Sign-in was not completed within ${span}. Nothing was saved \u2014 run the same command again.`
|
|
369
|
+
));
|
|
370
|
+
}, timeoutMs);
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
return await Promise.race([work, timeout]);
|
|
374
|
+
} finally {
|
|
375
|
+
clearTimeout(timer);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
272
378
|
function shouldRefresh(token, now = Date.now()) {
|
|
273
379
|
if (!token.expiresAt) {
|
|
274
380
|
return false;
|
|
@@ -361,7 +467,7 @@ function toStoredToken(endpoint, token, metadata, previous) {
|
|
|
361
467
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
362
468
|
};
|
|
363
469
|
}
|
|
364
|
-
async function createLoopbackReceiver(expectedState) {
|
|
470
|
+
async function createLoopbackReceiver(expectedState, host = "127.0.0.1") {
|
|
365
471
|
let resolveCode;
|
|
366
472
|
let rejectCode;
|
|
367
473
|
const codePromise = new Promise((resolve, reject) => {
|
|
@@ -376,13 +482,13 @@ async function createLoopbackReceiver(expectedState) {
|
|
|
376
482
|
const state = url.searchParams.get("state");
|
|
377
483
|
if (error) {
|
|
378
484
|
res.writeHead(400, { "content-type": "text/plain" });
|
|
379
|
-
res.end("
|
|
485
|
+
res.end("AiNecto CLI authorization failed. You can close this tab.");
|
|
380
486
|
rejectCode(new Error(`OAuth authorization failed: ${error}`));
|
|
381
487
|
return;
|
|
382
488
|
}
|
|
383
489
|
if (!state || state !== expectedState) {
|
|
384
490
|
res.writeHead(400, { "content-type": "text/plain" });
|
|
385
|
-
res.end("
|
|
491
|
+
res.end("AiNecto CLI authorization state mismatch. You can close this tab.");
|
|
386
492
|
rejectCode(new Error("OAuth state mismatch."));
|
|
387
493
|
return;
|
|
388
494
|
}
|
|
@@ -393,10 +499,16 @@ async function createLoopbackReceiver(expectedState) {
|
|
|
393
499
|
return;
|
|
394
500
|
}
|
|
395
501
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
396
|
-
res.end("
|
|
502
|
+
res.end("AiNecto CLI authorization complete. You can close this tab.");
|
|
397
503
|
resolveCode(code);
|
|
398
504
|
});
|
|
399
|
-
await new Promise((resolve) =>
|
|
505
|
+
await new Promise((resolve, reject) => {
|
|
506
|
+
server.once("error", (error) => reject(new OAuthLoginError(
|
|
507
|
+
"CALLBACK_UNAVAILABLE",
|
|
508
|
+
`Could not start the local sign-in callback on ${host} (${error.message}). If this command runs in a sandbox, allow local network binding or run it outside the sandbox.`
|
|
509
|
+
)));
|
|
510
|
+
server.listen(0, host, () => resolve());
|
|
511
|
+
});
|
|
400
512
|
const address = server.address();
|
|
401
513
|
if (!address || typeof address === "string") {
|
|
402
514
|
throw new Error("Loopback OAuth server did not bind to a TCP port.");
|
|
@@ -404,7 +516,10 @@ async function createLoopbackReceiver(expectedState) {
|
|
|
404
516
|
return {
|
|
405
517
|
redirectUri: `http://127.0.0.1:${address.port}/callback`,
|
|
406
518
|
waitForCode: () => codePromise,
|
|
407
|
-
close: () => new Promise((resolve, reject) =>
|
|
519
|
+
close: () => new Promise((resolve, reject) => {
|
|
520
|
+
server.closeAllConnections?.();
|
|
521
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
522
|
+
})
|
|
408
523
|
};
|
|
409
524
|
}
|
|
410
525
|
function parseStringArray(value) {
|
|
@@ -428,10 +543,39 @@ function getBrowserOpenCommand(url, platform = process.platform) {
|
|
|
428
543
|
}
|
|
429
544
|
return { command: "xdg-open", args: [url] };
|
|
430
545
|
}
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
|
|
546
|
+
function launchBrowser(url, deps = {}) {
|
|
547
|
+
const platform = deps.platform ?? process.platform;
|
|
548
|
+
const { command, args } = getBrowserOpenCommand(url, platform);
|
|
549
|
+
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
550
|
+
return new Promise((resolve, reject) => {
|
|
551
|
+
const unavailable = (reason) => new OAuthLoginError(
|
|
552
|
+
"BROWSER_UNAVAILABLE",
|
|
553
|
+
`Could not open a browser on this machine (${command}: ${reason}). Sign-in needs a browser here.`
|
|
554
|
+
);
|
|
555
|
+
let child;
|
|
556
|
+
try {
|
|
557
|
+
child = spawnImpl(command, args, { stdio: "ignore", detached: true });
|
|
558
|
+
} catch (error) {
|
|
559
|
+
reject(unavailable(error instanceof Error ? error.message : String(error)));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
child.once("error", (error) => reject(unavailable(error.message)));
|
|
563
|
+
if (platform === "win32") {
|
|
564
|
+
child.once("spawn", () => resolve());
|
|
565
|
+
} else {
|
|
566
|
+
child.once("exit", (code, signal) => {
|
|
567
|
+
if (code === 0) {
|
|
568
|
+
resolve();
|
|
569
|
+
} else {
|
|
570
|
+
reject(unavailable(code === null ? `stopped by ${signal ?? "a signal"}` : `exited with code ${code}`));
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
child.unref();
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
function openBrowser(url) {
|
|
578
|
+
return launchBrowser(url);
|
|
435
579
|
}
|
|
436
580
|
function assertEnvTokenEndpointAllowed(endpoint, envVars) {
|
|
437
581
|
if (envVars.AINECTO_ALLOW_CUSTOM_ENDPOINT_TOKEN === "1") {
|
|
@@ -443,6 +587,11 @@ function assertEnvTokenEndpointAllowed(endpoint, envVars) {
|
|
|
443
587
|
throw new Error("AINECTO_TOKEN can only be used with the default prod/dev endpoints. Set AINECTO_ALLOW_CUSTOM_ENDPOINT_TOKEN=1 only when you intentionally trust the custom endpoint.");
|
|
444
588
|
}
|
|
445
589
|
|
|
590
|
+
// src/core/version.ts
|
|
591
|
+
var CLI_VERSION = true ? "0.5.0" : "dev";
|
|
592
|
+
var PACKAGE_NAME = "@ainecto/mcp";
|
|
593
|
+
var CLI_USER_AGENT = `${PACKAGE_NAME} ${CLI_VERSION}`;
|
|
594
|
+
|
|
446
595
|
// src/core/mcp/rpcClient.ts
|
|
447
596
|
var McpRpcError = class extends Error {
|
|
448
597
|
constructor(message, code, details) {
|
|
@@ -465,7 +614,7 @@ var McpRpcClient = class {
|
|
|
465
614
|
return this.request("initialize", {
|
|
466
615
|
protocolVersion: "2024-11-05",
|
|
467
616
|
capabilities: {},
|
|
468
|
-
clientInfo: { name:
|
|
617
|
+
clientInfo: { name: PACKAGE_NAME, version: CLI_VERSION }
|
|
469
618
|
});
|
|
470
619
|
}
|
|
471
620
|
async toolsList() {
|
|
@@ -530,6 +679,9 @@ var McpRpcClient = class {
|
|
|
530
679
|
if (token) {
|
|
531
680
|
headers.authorization = `Bearer ${token}`;
|
|
532
681
|
}
|
|
682
|
+
if (this.options.role) {
|
|
683
|
+
headers[ROLE_HEADER.toLowerCase()] = this.options.role;
|
|
684
|
+
}
|
|
533
685
|
return this.fetchImpl(this.options.endpoint, {
|
|
534
686
|
method: "POST",
|
|
535
687
|
headers,
|
|
@@ -598,9 +750,12 @@ function isRecord3(value) {
|
|
|
598
750
|
|
|
599
751
|
// src/adapters/mcp/connector.ts
|
|
600
752
|
async function runConnector(options) {
|
|
601
|
-
const
|
|
602
|
-
const
|
|
603
|
-
|
|
753
|
+
const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore: new FileTokenStore() });
|
|
754
|
+
const client = new McpRpcClient({
|
|
755
|
+
endpoint: options.endpoint,
|
|
756
|
+
tokenProvider: auth,
|
|
757
|
+
role: options.role
|
|
758
|
+
});
|
|
604
759
|
await runStdioBridge({
|
|
605
760
|
input: options.input,
|
|
606
761
|
output: options.output,
|
|
@@ -676,6 +831,8 @@ function parseMcpArgs(argv) {
|
|
|
676
831
|
result.env = assertEnv(requireFlagValue(arg, argv[++index]));
|
|
677
832
|
} else if (arg === "--endpoint") {
|
|
678
833
|
result.endpoint = requireFlagValue(arg, argv[++index]);
|
|
834
|
+
} else if (arg === "--role") {
|
|
835
|
+
result.role = assertRole(requireFlagValue(arg, argv[++index]));
|
|
679
836
|
} else if (arg === "--help" || arg === "-h") {
|
|
680
837
|
result.help = true;
|
|
681
838
|
} else {
|
|
@@ -695,12 +852,17 @@ function requireFlagValue(flag, value) {
|
|
|
695
852
|
try {
|
|
696
853
|
const args = parseMcpArgs(process.argv.slice(2));
|
|
697
854
|
if (args.help) {
|
|
698
|
-
process.stdout.write(
|
|
855
|
+
process.stdout.write(
|
|
856
|
+
`Usage: mcp [--env prod|dev] [--endpoint URL] [--role ${HARNESS_ROLES.join("|")}]
|
|
857
|
+
`
|
|
858
|
+
);
|
|
699
859
|
process.exit(0);
|
|
700
860
|
}
|
|
701
861
|
const resolved = resolveEndpoint({ env: args.env, endpoint: args.endpoint });
|
|
862
|
+
const resolvedRole = resolveRole({ role: args.role });
|
|
702
863
|
await runConnector({
|
|
703
864
|
endpoint: resolved.endpoint,
|
|
865
|
+
role: resolvedRole.role ?? null,
|
|
704
866
|
input: process.stdin,
|
|
705
867
|
output: process.stdout,
|
|
706
868
|
errorOutput: process.stderr
|