@ainecto/mcp 0.3.0 → 0.6.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,9 @@
2
2
 
3
3
  // src/core/config/endpoints.ts
4
4
  var DEFAULT_ENDPOINTS = {
5
- prod: "https://ai-erd.com/mcp",
6
- dev: "https://dev.ai-erd.com/mcp"
5
+ prod: "https://ainecto.com/mcp",
6
+ dev: "https://dev.ainecto.com/mcp"
7
7
  };
8
- var LEGACY_DEFAULT_ENDPOINTS = [
9
- "https://ainecto.com/mcp",
10
- "https://dev.ainecto.com/mcp"
11
- ];
12
8
  function resolveEndpoint(input = {}) {
13
9
  const envVars = input.envVars ?? process.env;
14
10
  const env = input.env ?? "prod";
@@ -28,7 +24,7 @@ function normalizeEndpoint(endpoint) {
28
24
  }
29
25
  function isDefaultEndpoint(endpoint) {
30
26
  const normalized = normalizeEndpoint(endpoint);
31
- return Object.values(DEFAULT_ENDPOINTS).includes(normalized) || LEGACY_DEFAULT_ENDPOINTS.includes(normalized);
27
+ return Object.values(DEFAULT_ENDPOINTS).includes(normalized);
32
28
  }
33
29
  function assertTrustedEndpointUrl(value, label = "URL") {
34
30
  const url = typeof value === "string" ? new URL(value) : value;
@@ -65,23 +61,52 @@ import { createHash } from "crypto";
65
61
  import { mkdir, readFile, rename, rm, stat, writeFile, chmod } from "fs/promises";
66
62
  import { homedir } from "os";
67
63
  import { dirname, join } from "path";
64
+
65
+ // src/core/harness/role.ts
66
+ var HARNESS_ROLES = ["design", "development", "test", "validation"];
67
+ var ROLE_SCOPE_PREFIX = "ainecto:role:";
68
+ var ROLE_HEADER = "X-AiNecto-Role";
69
+ var ROLE_ENV_VAR = "AINECTO_ROLE";
70
+ function resolveRole(input = {}) {
71
+ if (input.role !== void 0) {
72
+ return { role: assertRole(input.role), source: "flag" };
73
+ }
74
+ const envVars = input.envVars ?? process.env;
75
+ const fromEnv = envVars[ROLE_ENV_VAR];
76
+ if (fromEnv && fromEnv.trim()) {
77
+ return { role: assertRole(fromEnv), source: "env" };
78
+ }
79
+ return { source: "none" };
80
+ }
81
+ function assertRole(value) {
82
+ const normalized = value.trim().toLowerCase();
83
+ if (HARNESS_ROLES.includes(normalized)) {
84
+ return normalized;
85
+ }
86
+ throw new Error(
87
+ `Invalid role "${value}". Expected one of: ${HARNESS_ROLES.join(", ")}.`
88
+ );
89
+ }
90
+
91
+ // src/core/auth/tokenStore.ts
68
92
  var FileTokenStore = class {
69
93
  filePath;
70
94
  constructor(rootDir = join(homedir(), ".ainecto")) {
71
95
  this.filePath = join(rootDir, "tokens.json");
72
96
  }
73
- async load(endpoint, role) {
97
+ async load(endpoint) {
74
98
  const file = await this.readFile();
75
- return file[tokenKey(endpoint, role)];
99
+ return file[tokenKey(endpoint)];
76
100
  }
77
- async save(endpoint, token, role) {
101
+ async save(endpoint, token) {
102
+ assertNoRoleScope(token);
78
103
  const file = await this.readFile();
79
- file[tokenKey(endpoint, role)] = { ...token, endpoint, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
104
+ file[tokenKey(endpoint)] = { ...token, endpoint, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
80
105
  await this.writeFile(file);
81
106
  }
82
- async delete(endpoint, role) {
107
+ async delete(endpoint) {
83
108
  const file = await this.readFile();
84
- delete file[tokenKey(endpoint, role)];
109
+ delete file[tokenKey(endpoint)];
85
110
  await this.writeFile(file);
86
111
  }
87
112
  async readFile() {
@@ -116,9 +141,15 @@ var FileTokenStore = class {
116
141
  await chmodIfPermissive(this.filePath, 384);
117
142
  }
118
143
  };
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");
144
+ function tokenKey(endpoint) {
145
+ return createHash("sha256").update(endpoint).digest("hex");
146
+ }
147
+ function assertNoRoleScope(token) {
148
+ if (token.scope?.split(/\s+/).some((scope) => scope.startsWith(ROLE_SCOPE_PREFIX))) {
149
+ throw new Error(
150
+ "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."
151
+ );
152
+ }
122
153
  }
123
154
  function isNodeError(error, code) {
124
155
  return error instanceof Error && "code" in error && error.code === code;
@@ -151,7 +182,7 @@ async function registerPublicClient(registrationEndpoint, redirectUri, fetchImpl
151
182
  method: "POST",
152
183
  headers: { "content-type": "application/json", accept: "application/json" },
153
184
  body: JSON.stringify({
154
- client_name: "Ainecto CLI",
185
+ client_name: "AiNecto CLI",
155
186
  redirect_uris: [redirectUri],
156
187
  token_endpoint_auth_method: "none",
157
188
  grant_types: ["authorization_code", "refresh_token"],
@@ -174,32 +205,16 @@ function isRecord(value) {
174
205
  return typeof value === "object" && value !== null && !Array.isArray(value);
175
206
  }
176
207
 
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
208
  // src/core/auth/oauth.ts
209
+ var DEFAULT_LOGIN_TIMEOUT_MS = 3e5;
210
+ var OAuthLoginError = class extends Error {
211
+ constructor(code, message) {
212
+ super(message);
213
+ this.code = code;
214
+ this.name = "OAuthLoginError";
215
+ }
216
+ code;
217
+ };
203
218
  var OAuthClient = class {
204
219
  constructor(options) {
205
220
  this.options = options;
@@ -217,21 +232,27 @@ var OAuthClient = class {
217
232
  assertEnvTokenEndpointAllowed(this.options.endpoint, this.envVars);
218
233
  return envToken;
219
234
  }
220
- const stored = await this.options.tokenStore.load(this.options.endpoint, this.options.role);
235
+ const stored = await this.options.tokenStore.load(this.options.endpoint);
221
236
  if (!stored) {
222
237
  return void 0;
223
238
  }
224
239
  if (!shouldRefresh(stored)) {
225
240
  return stored.accessToken;
226
241
  }
227
- return this.refreshStoredToken(stored);
242
+ if (this.options.readOnly) {
243
+ return isExpired(stored) ? void 0 : stored.accessToken;
244
+ }
245
+ if (!stored.refreshToken) {
246
+ return isExpired(stored) ? void 0 : stored.accessToken;
247
+ }
248
+ return this.refreshStoredToken({ ...stored, refreshToken: stored.refreshToken });
228
249
  }
229
250
  async refreshAfterUnauthorized() {
230
- const stored = await this.options.tokenStore.load(this.options.endpoint, this.options.role);
231
- if (!stored?.refreshToken) {
251
+ const stored = await this.options.tokenStore.load(this.options.endpoint);
252
+ if (this.options.readOnly || !stored?.refreshToken) {
232
253
  return void 0;
233
254
  }
234
- return this.refreshStoredToken(stored);
255
+ return this.refreshStoredToken({ ...stored, refreshToken: stored.refreshToken });
235
256
  }
236
257
  async login() {
237
258
  const pkce = generatePkcePair();
@@ -248,12 +269,14 @@ var OAuthClient = class {
248
269
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
249
270
  authorizeUrl.searchParams.set("resource", this.options.endpoint);
250
271
  authorizeUrl.searchParams.set("state", state);
251
- if (this.options.role) {
252
- authorizeUrl.searchParams.set("scope", `mcp ${ROLE_SCOPE_PREFIX}${this.options.role}`);
253
- }
254
272
  assertTrustedEndpointUrl(authorizeUrl, "OAuth authorization URL");
255
- await this.openBrowserImpl(authorizeUrl.toString());
256
- const code = await loopback.waitForCode();
273
+ const url = authorizeUrl.toString();
274
+ this.options.onAuthorizeUrl?.(url);
275
+ const browserFailure = this.openBrowserImpl(url).then(() => new Promise(() => void 0));
276
+ const code = await withTimeout(
277
+ Promise.race([loopback.waitForCode(), browserFailure]),
278
+ this.options.loginTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
279
+ );
257
280
  const token = await exchangeToken(metadata.tokenEndpoint, {
258
281
  grant_type: "authorization_code",
259
282
  code,
@@ -264,19 +287,17 @@ var OAuthClient = class {
264
287
  ...registration.clientSecret ? { client_secret: registration.clientSecret } : {}
265
288
  }, this.fetchImpl);
266
289
  const stored = toStoredToken(this.options.endpoint, token, metadata, registration);
267
- await this.options.tokenStore.save(this.options.endpoint, stored, this.options.role);
290
+ await this.options.tokenStore.save(this.options.endpoint, stored);
268
291
  return stored;
269
292
  } finally {
270
293
  await loopback.close();
271
294
  }
272
295
  }
273
296
  async logout() {
274
- await this.options.tokenStore.delete(this.options.endpoint, this.options.role);
297
+ await this.options.tokenStore.delete(this.options.endpoint);
275
298
  }
299
+ /** 부르는 쪽이 refresh token 이 있음을 이미 확인했다 — 타입이 그것을 요구한다. */
276
300
  async refreshStoredToken(stored) {
277
- if (!stored.refreshToken) {
278
- return stored.accessToken;
279
- }
280
301
  const metadata = await discoverOAuthMetadata(this.options.endpoint, this.fetchImpl);
281
302
  const token = await exchangeToken(metadata.tokenEndpoint, {
282
303
  grant_type: "refresh_token",
@@ -286,7 +307,7 @@ var OAuthClient = class {
286
307
  resource: this.options.endpoint
287
308
  }, this.fetchImpl);
288
309
  const next = toStoredToken(this.options.endpoint, token, metadata, stored);
289
- await this.options.tokenStore.save(this.options.endpoint, next, this.options.role);
310
+ await this.options.tokenStore.save(this.options.endpoint, next);
290
311
  return next.accessToken;
291
312
  }
292
313
  };
@@ -298,6 +319,27 @@ function generatePkcePair() {
298
319
  function generateOAuthState() {
299
320
  return randomBytes(32).toString("base64url");
300
321
  }
322
+ function isExpired(token, now = Date.now()) {
323
+ return token.expiresAt !== void 0 && token.expiresAt <= now;
324
+ }
325
+ async function withTimeout(work, timeoutMs) {
326
+ let timer;
327
+ const timeout = new Promise((_, reject) => {
328
+ timer = setTimeout(() => {
329
+ const minutes = timeoutMs / 6e4;
330
+ const span = Number.isInteger(minutes) ? `${minutes} minute${minutes === 1 ? "" : "s"}` : `${timeoutMs} ms`;
331
+ reject(new OAuthLoginError(
332
+ "LOGIN_TIMEOUT",
333
+ `Sign-in was not completed within ${span}. Nothing was saved \u2014 run the same command again.`
334
+ ));
335
+ }, timeoutMs);
336
+ });
337
+ try {
338
+ return await Promise.race([work, timeout]);
339
+ } finally {
340
+ clearTimeout(timer);
341
+ }
342
+ }
301
343
  function shouldRefresh(token, now = Date.now()) {
302
344
  if (!token.expiresAt) {
303
345
  return false;
@@ -390,7 +432,7 @@ function toStoredToken(endpoint, token, metadata, previous) {
390
432
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
391
433
  };
392
434
  }
393
- async function createLoopbackReceiver(expectedState) {
435
+ async function createLoopbackReceiver(expectedState, host = "127.0.0.1") {
394
436
  let resolveCode;
395
437
  let rejectCode;
396
438
  const codePromise = new Promise((resolve, reject) => {
@@ -405,13 +447,13 @@ async function createLoopbackReceiver(expectedState) {
405
447
  const state = url.searchParams.get("state");
406
448
  if (error) {
407
449
  res.writeHead(400, { "content-type": "text/plain" });
408
- res.end("Ainecto CLI authorization failed. You can close this tab.");
450
+ res.end("AiNecto CLI authorization failed. You can close this tab.");
409
451
  rejectCode(new Error(`OAuth authorization failed: ${error}`));
410
452
  return;
411
453
  }
412
454
  if (!state || state !== expectedState) {
413
455
  res.writeHead(400, { "content-type": "text/plain" });
414
- res.end("Ainecto CLI authorization state mismatch. You can close this tab.");
456
+ res.end("AiNecto CLI authorization state mismatch. You can close this tab.");
415
457
  rejectCode(new Error("OAuth state mismatch."));
416
458
  return;
417
459
  }
@@ -422,10 +464,16 @@ async function createLoopbackReceiver(expectedState) {
422
464
  return;
423
465
  }
424
466
  res.writeHead(200, { "content-type": "text/plain" });
425
- res.end("Ainecto CLI authorization complete. You can close this tab.");
467
+ res.end("AiNecto CLI authorization complete. You can close this tab.");
426
468
  resolveCode(code);
427
469
  });
428
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
470
+ await new Promise((resolve, reject) => {
471
+ server.once("error", (error) => reject(new OAuthLoginError(
472
+ "CALLBACK_UNAVAILABLE",
473
+ `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.`
474
+ )));
475
+ server.listen(0, host, () => resolve());
476
+ });
429
477
  const address = server.address();
430
478
  if (!address || typeof address === "string") {
431
479
  throw new Error("Loopback OAuth server did not bind to a TCP port.");
@@ -433,7 +481,10 @@ async function createLoopbackReceiver(expectedState) {
433
481
  return {
434
482
  redirectUri: `http://127.0.0.1:${address.port}/callback`,
435
483
  waitForCode: () => codePromise,
436
- close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
484
+ close: () => new Promise((resolve, reject) => {
485
+ server.closeAllConnections?.();
486
+ server.close((error) => error ? reject(error) : resolve());
487
+ })
437
488
  };
438
489
  }
439
490
  function parseStringArray(value) {
@@ -457,10 +508,39 @@ function getBrowserOpenCommand(url, platform = process.platform) {
457
508
  }
458
509
  return { command: "xdg-open", args: [url] };
459
510
  }
460
- async function openBrowser(url) {
461
- const { command, args } = getBrowserOpenCommand(url);
462
- const child = spawn(command, args, { stdio: "ignore", detached: true });
463
- child.unref();
511
+ function launchBrowser(url, deps = {}) {
512
+ const platform = deps.platform ?? process.platform;
513
+ const { command, args } = getBrowserOpenCommand(url, platform);
514
+ const spawnImpl = deps.spawnImpl ?? spawn;
515
+ return new Promise((resolve, reject) => {
516
+ const unavailable = (reason) => new OAuthLoginError(
517
+ "BROWSER_UNAVAILABLE",
518
+ `Could not open a browser on this machine (${command}: ${reason}). Sign-in needs a browser here.`
519
+ );
520
+ let child;
521
+ try {
522
+ child = spawnImpl(command, args, { stdio: "ignore", detached: true });
523
+ } catch (error) {
524
+ reject(unavailable(error instanceof Error ? error.message : String(error)));
525
+ return;
526
+ }
527
+ child.once("error", (error) => reject(unavailable(error.message)));
528
+ if (platform === "win32") {
529
+ child.once("spawn", () => resolve());
530
+ } else {
531
+ child.once("exit", (code, signal) => {
532
+ if (code === 0) {
533
+ resolve();
534
+ } else {
535
+ reject(unavailable(code === null ? `stopped by ${signal ?? "a signal"}` : `exited with code ${code}`));
536
+ }
537
+ });
538
+ }
539
+ child.unref();
540
+ });
541
+ }
542
+ function openBrowser(url) {
543
+ return launchBrowser(url);
464
544
  }
465
545
  function assertEnvTokenEndpointAllowed(endpoint, envVars) {
466
546
  if (envVars.AINECTO_ALLOW_CUSTOM_ENDPOINT_TOKEN === "1") {
@@ -472,6 +552,11 @@ function assertEnvTokenEndpointAllowed(endpoint, envVars) {
472
552
  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
553
  }
474
554
 
555
+ // src/core/version.ts
556
+ var CLI_VERSION = true ? "0.6.0" : "dev";
557
+ var PACKAGE_NAME = "@ainecto/mcp";
558
+ var CLI_USER_AGENT = `${PACKAGE_NAME} ${CLI_VERSION}`;
559
+
475
560
  // src/core/mcp/rpcClient.ts
476
561
  var McpRpcError = class extends Error {
477
562
  constructor(message, code, details) {
@@ -494,7 +579,7 @@ var McpRpcClient = class {
494
579
  return this.request("initialize", {
495
580
  protocolVersion: "2024-11-05",
496
581
  capabilities: {},
497
- clientInfo: { name: "@ainecto/mcp", version: "0.1.0" }
582
+ clientInfo: { name: PACKAGE_NAME, version: CLI_VERSION }
498
583
  });
499
584
  }
500
585
  async toolsList() {
@@ -630,8 +715,7 @@ function isRecord3(value) {
630
715
 
631
716
  // src/adapters/mcp/connector.ts
632
717
  async function runConnector(options) {
633
- const tokenStore = new FileTokenStore();
634
- const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore, role: options.role });
718
+ const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore: new FileTokenStore() });
635
719
  const client = new McpRpcClient({
636
720
  endpoint: options.endpoint,
637
721
  tokenProvider: auth,
@@ -743,7 +827,7 @@ try {
743
827
  const resolvedRole = resolveRole({ role: args.role });
744
828
  await runConnector({
745
829
  endpoint: resolved.endpoint,
746
- role: resolvedRole.role,
830
+ role: resolvedRole.role ?? null,
747
831
  input: process.stdin,
748
832
  output: process.stdout,
749
833
  errorOutput: process.stderr