@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/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,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: "Ainecto CLI",
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.options.tokenStore.load(this.options.endpoint);
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
- 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 });
202
263
  }
203
264
  async refreshAfterUnauthorized() {
204
- const stored = await this.options.tokenStore.load(this.options.endpoint);
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
- await this.openBrowserImpl(authorizeUrl.toString());
227
- 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
+ );
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
- async refreshStoredToken(stored) {
248
- if (!stored.refreshToken) {
249
- 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;
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("Ainecto CLI authorization failed. You can close this tab.");
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("Ainecto CLI authorization state mismatch. You can close this tab.");
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("Ainecto CLI authorization complete. You can close this tab.");
502
+ res.end("AiNecto CLI authorization complete. You can close this tab.");
397
503
  resolveCode(code);
398
504
  });
399
- 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
+ });
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) => 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
+ })
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
- async function openBrowser(url) {
432
- const { command, args } = getBrowserOpenCommand(url);
433
- const child = spawn(command, args, { stdio: "ignore", detached: true });
434
- 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);
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: "@ainecto/mcp", version: "0.1.0" }
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 tokenStore = new FileTokenStore();
602
- const auth = new OAuthClient({ endpoint: options.endpoint, tokenStore });
603
- const client = new McpRpcClient({ endpoint: options.endpoint, tokenProvider: auth });
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("Usage: mcp [--env prod|dev] [--endpoint URL]\n");
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