@ainecto/mcp 0.3.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/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://ai-erd.com/mcp",
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
- "https://ainecto.com/mcp",
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,23 +72,55 @@ 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")) {
71
109
  this.filePath = join(rootDir, "tokens.json");
72
110
  }
73
- async load(endpoint, role) {
111
+ async load(endpoint) {
74
112
  const file = await this.readFile();
75
- return file[tokenKey(endpoint, role)];
113
+ return file[tokenKey(endpoint)];
76
114
  }
77
- async save(endpoint, token, role) {
115
+ async save(endpoint, token) {
116
+ assertNoRoleScope(token);
78
117
  const file = await this.readFile();
79
- file[tokenKey(endpoint, role)] = { ...token, endpoint, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
118
+ file[tokenKey(endpoint)] = { ...token, endpoint, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
80
119
  await this.writeFile(file);
81
120
  }
82
- async delete(endpoint, role) {
121
+ async delete(endpoint) {
83
122
  const file = await this.readFile();
84
- delete file[tokenKey(endpoint, role)];
123
+ delete file[tokenKey(endpoint)];
85
124
  await this.writeFile(file);
86
125
  }
87
126
  async readFile() {
@@ -116,9 +155,15 @@ var FileTokenStore = class {
116
155
  await chmodIfPermissive(this.filePath, 384);
117
156
  }
118
157
  };
119
- function tokenKey(endpoint, role) {
120
- const material = role && role.trim() ? `${endpoint}#role=${role.trim().toLowerCase()}` : endpoint;
121
- return createHash("sha256").update(material).digest("hex");
158
+ function tokenKey(endpoint) {
159
+ return createHash("sha256").update(endpoint).digest("hex");
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
+ }
122
167
  }
123
168
  function isNodeError(error, code) {
124
169
  return error instanceof Error && "code" in error && error.code === code;
@@ -151,7 +196,7 @@ async function registerPublicClient(registrationEndpoint, redirectUri, fetchImpl
151
196
  method: "POST",
152
197
  headers: { "content-type": "application/json", accept: "application/json" },
153
198
  body: JSON.stringify({
154
- client_name: "Ainecto CLI",
199
+ client_name: "AiNecto CLI",
155
200
  redirect_uris: [redirectUri],
156
201
  token_endpoint_auth_method: "none",
157
202
  grant_types: ["authorization_code", "refresh_token"],
@@ -174,32 +219,16 @@ function isRecord(value) {
174
219
  return typeof value === "object" && value !== null && !Array.isArray(value);
175
220
  }
176
221
 
177
- // src/core/harness/role.ts
178
- var HARNESS_ROLES = ["design", "development", "test", "validation"];
179
- var ROLE_SCOPE_PREFIX = "ai-erd:role:";
180
- var ROLE_HEADER = "X-AI-ERD-Role";
181
- var ROLE_ENV_VAR = "AI_ERD_ROLE";
182
- function resolveRole(input = {}) {
183
- if (input.role !== void 0) {
184
- return { role: assertRole(input.role), source: "flag" };
185
- }
186
- const fromEnv = (input.envVars ?? process.env)[ROLE_ENV_VAR];
187
- if (fromEnv && fromEnv.trim()) {
188
- return { role: assertRole(fromEnv), source: "env" };
189
- }
190
- return { source: "none" };
191
- }
192
- function assertRole(value) {
193
- const normalized = value.trim().toLowerCase();
194
- if (HARNESS_ROLES.includes(normalized)) {
195
- return normalized;
196
- }
197
- throw new Error(
198
- `Invalid role "${value}". Expected one of: ${HARNESS_ROLES.join(", ")}.`
199
- );
200
- }
201
-
202
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
+ };
203
232
  var OAuthClient = class {
204
233
  constructor(options) {
205
234
  this.options = options;
@@ -217,21 +246,27 @@ var OAuthClient = class {
217
246
  assertEnvTokenEndpointAllowed(this.options.endpoint, this.envVars);
218
247
  return envToken;
219
248
  }
220
- const stored = await this.options.tokenStore.load(this.options.endpoint, this.options.role);
249
+ const stored = await this.loadStored();
221
250
  if (!stored) {
222
251
  return void 0;
223
252
  }
224
253
  if (!shouldRefresh(stored)) {
225
254
  return stored.accessToken;
226
255
  }
227
- return this.refreshStoredToken(stored);
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 });
228
263
  }
229
264
  async refreshAfterUnauthorized() {
230
- const stored = await this.options.tokenStore.load(this.options.endpoint, this.options.role);
231
- if (!stored?.refreshToken) {
265
+ const stored = await this.loadStored();
266
+ if (this.options.readOnly || !stored?.refreshToken) {
232
267
  return void 0;
233
268
  }
234
- return this.refreshStoredToken(stored);
269
+ return this.refreshStoredToken({ ...stored, refreshToken: stored.refreshToken });
235
270
  }
236
271
  async login() {
237
272
  const pkce = generatePkcePair();
@@ -248,12 +283,14 @@ var OAuthClient = class {
248
283
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
249
284
  authorizeUrl.searchParams.set("resource", this.options.endpoint);
250
285
  authorizeUrl.searchParams.set("state", state);
251
- if (this.options.role) {
252
- authorizeUrl.searchParams.set("scope", `mcp ${ROLE_SCOPE_PREFIX}${this.options.role}`);
253
- }
254
286
  assertTrustedEndpointUrl(authorizeUrl, "OAuth authorization URL");
255
- await this.openBrowserImpl(authorizeUrl.toString());
256
- const code = await loopback.waitForCode();
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
+ );
257
294
  const token = await exchangeToken(metadata.tokenEndpoint, {
258
295
  grant_type: "authorization_code",
259
296
  code,
@@ -264,19 +301,38 @@ var OAuthClient = class {
264
301
  ...registration.clientSecret ? { client_secret: registration.clientSecret } : {}
265
302
  }, this.fetchImpl);
266
303
  const stored = toStoredToken(this.options.endpoint, token, metadata, registration);
267
- await this.options.tokenStore.save(this.options.endpoint, stored, this.options.role);
304
+ await this.options.tokenStore.save(this.options.endpoint, stored);
268
305
  return stored;
269
306
  } finally {
270
307
  await loopback.close();
271
308
  }
272
309
  }
310
+ /**
311
+ * ★대체로 읽는 옛 칸도 함께 지운다. 새 칸만 지우면 다음 읽기가 곧바로 옛 칸으로 대체돼
312
+ * «로그아웃했는데 여전히 로그인됨»이 된다. 로그인은 기계당 한 번이므로 옛 CLI 의 칸도 같이 끝난다.
313
+ */
273
314
  async logout() {
274
- await this.options.tokenStore.delete(this.options.endpoint, this.options.role);
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
+ }
275
319
  }
276
- async refreshStoredToken(stored) {
277
- if (!stored.refreshToken) {
278
- return stored.accessToken;
320
+ /** 이 주소의 칸, 비었으면 옛 기본 주소의 칸({@link fallbackTokenEndpointsFor}). 쓰기는 늘 이 주소의 칸이다. */
321
+ async loadStored() {
322
+ const own = await this.options.tokenStore.load(this.options.endpoint);
323
+ if (own) {
324
+ return own;
279
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
+ }
331
+ }
332
+ return void 0;
333
+ }
334
+ /** 부르는 쪽이 refresh token 이 있음을 이미 확인했다 — 타입이 그것을 요구한다. */
335
+ async refreshStoredToken(stored) {
280
336
  const metadata = await discoverOAuthMetadata(this.options.endpoint, this.fetchImpl);
281
337
  const token = await exchangeToken(metadata.tokenEndpoint, {
282
338
  grant_type: "refresh_token",
@@ -286,7 +342,7 @@ var OAuthClient = class {
286
342
  resource: this.options.endpoint
287
343
  }, this.fetchImpl);
288
344
  const next = toStoredToken(this.options.endpoint, token, metadata, stored);
289
- await this.options.tokenStore.save(this.options.endpoint, next, this.options.role);
345
+ await this.options.tokenStore.save(this.options.endpoint, next);
290
346
  return next.accessToken;
291
347
  }
292
348
  };
@@ -298,6 +354,27 @@ function generatePkcePair() {
298
354
  function generateOAuthState() {
299
355
  return randomBytes(32).toString("base64url");
300
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
+ }
301
378
  function shouldRefresh(token, now = Date.now()) {
302
379
  if (!token.expiresAt) {
303
380
  return false;
@@ -390,7 +467,7 @@ function toStoredToken(endpoint, token, metadata, previous) {
390
467
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
391
468
  };
392
469
  }
393
- async function createLoopbackReceiver(expectedState) {
470
+ async function createLoopbackReceiver(expectedState, host = "127.0.0.1") {
394
471
  let resolveCode;
395
472
  let rejectCode;
396
473
  const codePromise = new Promise((resolve, reject) => {
@@ -405,13 +482,13 @@ async function createLoopbackReceiver(expectedState) {
405
482
  const state = url.searchParams.get("state");
406
483
  if (error) {
407
484
  res.writeHead(400, { "content-type": "text/plain" });
408
- res.end("Ainecto CLI authorization failed. You can close this tab.");
485
+ res.end("AiNecto CLI authorization failed. You can close this tab.");
409
486
  rejectCode(new Error(`OAuth authorization failed: ${error}`));
410
487
  return;
411
488
  }
412
489
  if (!state || state !== expectedState) {
413
490
  res.writeHead(400, { "content-type": "text/plain" });
414
- res.end("Ainecto CLI authorization state mismatch. You can close this tab.");
491
+ res.end("AiNecto CLI authorization state mismatch. You can close this tab.");
415
492
  rejectCode(new Error("OAuth state mismatch."));
416
493
  return;
417
494
  }
@@ -422,10 +499,16 @@ async function createLoopbackReceiver(expectedState) {
422
499
  return;
423
500
  }
424
501
  res.writeHead(200, { "content-type": "text/plain" });
425
- res.end("Ainecto CLI authorization complete. You can close this tab.");
502
+ res.end("AiNecto CLI authorization complete. You can close this tab.");
426
503
  resolveCode(code);
427
504
  });
428
- await new Promise((resolve) => server.listen(0, "127.0.0.1", 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
+ });
429
512
  const address = server.address();
430
513
  if (!address || typeof address === "string") {
431
514
  throw new Error("Loopback OAuth server did not bind to a TCP port.");
@@ -433,7 +516,10 @@ async function createLoopbackReceiver(expectedState) {
433
516
  return {
434
517
  redirectUri: `http://127.0.0.1:${address.port}/callback`,
435
518
  waitForCode: () => codePromise,
436
- close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
519
+ close: () => new Promise((resolve, reject) => {
520
+ server.closeAllConnections?.();
521
+ server.close((error) => error ? reject(error) : resolve());
522
+ })
437
523
  };
438
524
  }
439
525
  function parseStringArray(value) {
@@ -457,10 +543,39 @@ function getBrowserOpenCommand(url, platform = process.platform) {
457
543
  }
458
544
  return { command: "xdg-open", args: [url] };
459
545
  }
460
- async function openBrowser(url) {
461
- const { command, args } = getBrowserOpenCommand(url);
462
- const child = spawn(command, args, { stdio: "ignore", detached: true });
463
- child.unref();
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);
464
579
  }
465
580
  function assertEnvTokenEndpointAllowed(endpoint, envVars) {
466
581
  if (envVars.AINECTO_ALLOW_CUSTOM_ENDPOINT_TOKEN === "1") {
@@ -472,6 +587,11 @@ function assertEnvTokenEndpointAllowed(endpoint, envVars) {
472
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.");
473
588
  }
474
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
+
475
595
  // src/core/mcp/rpcClient.ts
476
596
  var McpRpcError = class extends Error {
477
597
  constructor(message, code, details) {
@@ -494,7 +614,7 @@ var McpRpcClient = class {
494
614
  return this.request("initialize", {
495
615
  protocolVersion: "2024-11-05",
496
616
  capabilities: {},
497
- clientInfo: { name: "@ainecto/mcp", version: "0.1.0" }
617
+ clientInfo: { name: PACKAGE_NAME, version: CLI_VERSION }
498
618
  });
499
619
  }
500
620
  async toolsList() {
@@ -630,8 +750,7 @@ function isRecord3(value) {
630
750
 
631
751
  // src/adapters/mcp/connector.ts
632
752
  async function runConnector(options) {
633
- const tokenStore = new FileTokenStore();
634
- const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore, role: options.role });
753
+ const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore: new FileTokenStore() });
635
754
  const client = new McpRpcClient({
636
755
  endpoint: options.endpoint,
637
756
  tokenProvider: auth,
@@ -743,7 +862,7 @@ try {
743
862
  const resolvedRole = resolveRole({ role: args.role });
744
863
  await runConnector({
745
864
  endpoint: resolved.endpoint,
746
- role: resolvedRole.role,
865
+ role: resolvedRole.role ?? null,
747
866
  input: process.stdin,
748
867
  output: process.stdout,
749
868
  errorOutput: process.stderr