@athsra/cli 1.0.2 → 1.0.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.
@@ -22,32 +22,42 @@
22
22
  * - athsra_manifest_init — sibling manifest 생성 (file write)
23
23
  * - athsra_manifest_modify — manifest add/remove keys
24
24
  *
25
- * 정공법: McpServer high-level zod generic tsc inference OOM 유발 (SIGABRT).
26
- * low-level `Server` + manual `setRequestHandler` dispatch JSON Schema literal,
27
- * zod 의존성 제거. 단순 + 통제 + 메모리 안전.
25
+ * 2026-06-02 리팩토링: tool 정의(defs)·결과 헬퍼(result)·인자 파서(args)·핸들러(read/write)를
26
+ * lib/mcp-tools/ 추출. 파일은 orchestration (dispatch/buildServer/mcpCmd/__test) 유지.
27
+ *
28
+ * 정공법: McpServer high-level 의 zod generic 이 tsc inference OOM 유발 (SIGABRT). low-level
29
+ * `Server` + manual `setRequestHandler` 로 dispatch — JSON Schema literal, zod 의존성 제거.
28
30
  *
29
31
  * 보안:
30
32
  * - secret 값 자체는 노출 안 함 (Phase 2.7 v0). AI 가 값을 직접 보지 않음.
31
- * - write 동작 (set/unset/adopt) 은 Phase 2.7.1 에서 명시적 permission 추가.
33
+ * - write 동작 (set/unset/adopt) 은 ATHSRA_MCP_WRITE=1 opt-in.
32
34
  * - stdio MCP 는 local-only — process.stdout 으로 protocol JSON 만 통신.
33
35
  * 모든 로그/에러는 console.error (stderr) 로.
34
36
  */
35
37
 
36
38
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
37
39
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
40
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
41
+ import { READ_TOOLS, type ToolDef, WRITE_TOOLS } from '../lib/mcp-tools/defs.ts';
42
+ import {
43
+ handleGetProjectKeys,
44
+ handleListProjects,
45
+ handleShowManifest,
46
+ handleValidateManifest,
47
+ handleWhoami,
48
+ } from '../lib/mcp-tools/read.ts';
38
49
  import {
39
- CallToolRequestSchema,
40
- ListToolsRequestSchema,
41
- } from '@modelcontextprotocol/sdk/types.js';
42
- import { inferAdoptContext, type WrangerWorker } from '../lib/adopt-context.ts';
43
- import { loadAuthContext } from '../lib/auth-context.ts';
44
- import { readPlain, writePlain } from '../lib/envelope.ts';
50
+ isAuthError,
51
+ jsonError,
52
+ notLoggedIn,
53
+ type ToolTextResult,
54
+ } from '../lib/mcp-tools/result.ts';
45
55
  import {
46
- applyManifest,
47
- createManifest,
48
- loadManifest,
49
- saveManifest,
50
- } from '../lib/secrets-manifest.ts';
56
+ handleManifestInit,
57
+ handleManifestModify,
58
+ handleSetSecret,
59
+ handleUnsetSecret,
60
+ } from '../lib/mcp-tools/write.ts';
51
61
 
52
62
  const USAGE = [
53
63
  'usage: athsra mcp [--help]',
@@ -60,11 +70,14 @@ const USAGE = [
60
70
  ' "athsra": { "command": "athsra", "args": ["mcp"] }',
61
71
  ' }',
62
72
  '',
63
- 'Tools (v0 read-only):',
73
+ 'Tools (read-only, 항상 노출):',
74
+ ' athsra_whoami — 인증 상태 + 신원 (미인증 시 `athsra login --sso` 안내). 온보딩 시 먼저 호출',
64
75
  ' athsra_list_projects — envelope 이름 목록',
65
- ' athsra_get_project_keys — envelope 키 목록 (값 X)',
76
+ ' athsra_get_project_keys — envelope 키 목록 (값 X — 값은 `athsra run` 으로 주입)',
66
77
  ' athsra_show_manifest — sibling worker manifest 조회',
67
78
  ' athsra_validate_manifest — envelope vs manifest diff',
79
+ 'Tools (write, ATHSRA_MCP_WRITE=1 opt-in):',
80
+ ' athsra_set_secret / athsra_unset_secret / athsra_manifest_init / athsra_manifest_modify',
68
81
  ].join('\n');
69
82
 
70
83
  const VERSION = '1.0.0';
@@ -72,454 +85,10 @@ const VERSION = '1.0.0';
72
85
  /** Write tools opt-in via env var. 기본 false — secret 변경은 명시적 ack 필요. */
73
86
  const WRITE_ENABLED = process.env.ATHSRA_MCP_WRITE === '1';
74
87
 
75
- interface ToolDef {
76
- name: string;
77
- description: string;
78
- inputSchema: Record<string, unknown>;
79
- annotations?: { destructiveHint?: boolean; readOnlyHint?: boolean };
80
- }
81
-
82
- const READ_TOOLS: ToolDef[] = [
83
- {
84
- name: 'athsra_list_projects',
85
- description:
86
- 'List all envelope (project) names accessible to the current athsra user. ' +
87
- 'Requires prior `athsra login`. Returns string array of project names.',
88
- inputSchema: {
89
- type: 'object',
90
- properties: {
91
- detail: {
92
- type: 'boolean',
93
- description: 'If true, return extended rows (versions, last update, etc.).',
94
- },
95
- },
96
- additionalProperties: false,
97
- },
98
- },
99
- {
100
- name: 'athsra_get_project_keys',
101
- description:
102
- 'Return the list of secret keys stored in a given envelope. ' +
103
- 'Values are NOT returned for security — only key names. ' +
104
- 'Use `athsra run <project> -- <cmd>` (outside MCP) to inject values into a process.',
105
- inputSchema: {
106
- type: 'object',
107
- properties: {
108
- project: { type: 'string', minLength: 1, description: 'Envelope name.' },
109
- },
110
- required: ['project'],
111
- additionalProperties: false,
112
- },
113
- },
114
- {
115
- name: 'athsra_show_manifest',
116
- description:
117
- 'Read the secrets manifest (.athsra/secrets.json) for a sibling worker. ' +
118
- 'The manifest enumerates the keys that opt-in to wrangler secrets sync ' +
119
- '(Phase 2.6 default-deny / Option γ). Resolves worker by `cwd` argument; ' +
120
- 'falls back to process cwd if absent.',
121
- inputSchema: {
122
- type: 'object',
123
- properties: {
124
- cwd: { type: 'string', description: 'Path inside the sibling repo (default: process cwd).' },
125
- worker: {
126
- type: 'string',
127
- description: 'CF worker name if multiple wrangler.jsonc are present.',
128
- },
129
- },
130
- additionalProperties: false,
131
- },
132
- },
133
- {
134
- name: 'athsra_validate_manifest',
135
- description:
136
- 'Compare a sibling worker manifest against the envelope to identify onboarding gaps. ' +
137
- 'Returns three sets: `allowed` (manifest ∩ envelope — will sync), ' +
138
- '`missing` (manifest \\ envelope — onboarding gap, sibling needs `athsra set`), ' +
139
- '`excluded` (envelope \\ manifest — intentionally not synced).',
140
- inputSchema: {
141
- type: 'object',
142
- properties: {
143
- project: { type: 'string', minLength: 1 },
144
- cwd: { type: 'string' },
145
- worker: { type: 'string' },
146
- },
147
- required: ['project'],
148
- additionalProperties: false,
149
- },
150
- },
151
- ];
152
-
153
- const WRITE_TOOLS: ToolDef[] = [
154
- {
155
- name: 'athsra_set_secret',
156
- description:
157
- 'Add or overwrite a secret key=value in an envelope (E2EE encrypted with master pw). ' +
158
- 'DESTRUCTIVE — invoke only with explicit user intent. Value is NEVER echoed back. ' +
159
- 'Use this to onboard new keys identified by athsra_validate_manifest missing list.',
160
- inputSchema: {
161
- type: 'object',
162
- properties: {
163
- project: { type: 'string', minLength: 1 },
164
- key: { type: 'string', minLength: 1, pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
165
- value: { type: 'string', description: 'Secret value (not logged, not echoed).' },
166
- },
167
- required: ['project', 'key', 'value'],
168
- additionalProperties: false,
169
- },
170
- annotations: { destructiveHint: true, readOnlyHint: false },
171
- },
172
- {
173
- name: 'athsra_unset_secret',
174
- description:
175
- 'Remove a secret key from an envelope. DESTRUCTIVE. Value is irrecoverable unless ' +
176
- 'a prior version is restored via `athsra rollback`.',
177
- inputSchema: {
178
- type: 'object',
179
- properties: {
180
- project: { type: 'string', minLength: 1 },
181
- key: { type: 'string', minLength: 1 },
182
- },
183
- required: ['project', 'key'],
184
- additionalProperties: false,
185
- },
186
- annotations: { destructiveHint: true, readOnlyHint: false },
187
- },
188
- {
189
- name: 'athsra_manifest_init',
190
- description:
191
- 'Create a new sibling worker manifest (.athsra/secrets.json) with the given keys. ' +
192
- 'Fails if manifest already exists (use `athsra_manifest_modify` for updates). ' +
193
- 'Honors host repo biome.json indent style (Phase 2.6.1).',
194
- inputSchema: {
195
- type: 'object',
196
- properties: {
197
- cwd: { type: 'string' },
198
- worker: { type: 'string' },
199
- keys: {
200
- type: 'array',
201
- items: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
202
- minItems: 1,
203
- },
204
- },
205
- required: ['keys'],
206
- additionalProperties: false,
207
- },
208
- annotations: { destructiveHint: true, readOnlyHint: false },
209
- },
210
- {
211
- name: 'athsra_manifest_modify',
212
- description:
213
- "Add or remove keys from an existing sibling manifest. `op` = 'add' or 'remove'.",
214
- inputSchema: {
215
- type: 'object',
216
- properties: {
217
- op: { type: 'string', enum: ['add', 'remove'] },
218
- cwd: { type: 'string' },
219
- worker: { type: 'string' },
220
- keys: {
221
- type: 'array',
222
- items: { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_]*$' },
223
- minItems: 1,
224
- },
225
- },
226
- required: ['op', 'keys'],
227
- additionalProperties: false,
228
- },
229
- annotations: { destructiveHint: true, readOnlyHint: false },
230
- },
231
- ];
232
-
233
88
  const TOOLS: ToolDef[] = WRITE_ENABLED ? [...READ_TOOLS, ...WRITE_TOOLS] : READ_TOOLS;
234
89
 
235
90
  const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS.map((t) => t.name));
236
91
 
237
- /**
238
- * MCP `CallToolResult` 의 subset — content + isError. SDK 의 zod-derived 타입을
239
- * 직접 import 하면 tsc inference OOM (SIGABRT) 가 발생하므로 manual 정의.
240
- * MCP spec 2025-06-18 의 CallToolResult 와 호환.
241
- */
242
- interface ToolTextResult {
243
- content: Array<{ type: 'text'; text: string }>;
244
- isError?: boolean;
245
- structuredContent?: Record<string, unknown>;
246
- _meta?: Record<string, unknown>;
247
- [key: string]: unknown;
248
- }
249
-
250
- function jsonOk(data: unknown): ToolTextResult {
251
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
252
- }
253
-
254
- function jsonError(message: string): ToolTextResult {
255
- return { isError: true, content: [{ type: 'text', text: `error: ${message}` }] };
256
- }
257
-
258
- function pickWorker(
259
- workers: WrangerWorker[],
260
- explicit?: string,
261
- ): WrangerWorker | { error: string } {
262
- if (explicit) {
263
- const found = workers.find((w) => w.name === explicit);
264
- if (found) return found;
265
- const names = workers.map((w) => w.name).join(', ') || '(none)';
266
- return { error: `worker '${explicit}' not found. available: ${names}` };
267
- }
268
- if (workers.length === 0) {
269
- return { error: 'no wrangler.jsonc found in cwd' };
270
- }
271
- if (workers.length === 1) {
272
- const [only] = workers;
273
- if (!only) return { error: 'no wrangler.jsonc found' };
274
- return only;
275
- }
276
- return {
277
- error: `multiple workers found. specify worker: ${workers.map((w) => w.name).join(', ')}`,
278
- };
279
- }
280
-
281
- function readString(args: Record<string, unknown> | undefined, key: string): string | undefined {
282
- const v = args?.[key];
283
- return typeof v === 'string' ? v : undefined;
284
- }
285
-
286
- function readBoolean(args: Record<string, unknown> | undefined, key: string): boolean | undefined {
287
- const v = args?.[key];
288
- return typeof v === 'boolean' ? v : undefined;
289
- }
290
-
291
- async function handleListProjects(args: Record<string, unknown> | undefined): Promise<ToolTextResult> {
292
- const ctx = await loadAuthContext();
293
- if (!ctx) {
294
- return jsonError('athsra not logged in — run `athsra login` first');
295
- }
296
- const detail = readBoolean(args, 'detail');
297
- if (detail) {
298
- const rows = await ctx.client.listProjectsExtended();
299
- return jsonOk({ projects: rows });
300
- }
301
- const names = await ctx.client.listProjects();
302
- return jsonOk({ projects: names, count: names.length });
303
- }
304
-
305
- async function handleGetProjectKeys(args: Record<string, unknown> | undefined): Promise<ToolTextResult> {
306
- const project = readString(args, 'project');
307
- if (!project) return jsonError('missing required arg: project');
308
- const ctx = await loadAuthContext();
309
- if (!ctx) {
310
- return jsonError('athsra not logged in — run `athsra login` first');
311
- }
312
- const env = await readPlain(ctx, project);
313
- if (!env) {
314
- return jsonError(`envelope '${project}' not found`);
315
- }
316
- const keys = Object.entries(env)
317
- .filter(([, v]) => typeof v === 'string' && v.length > 0)
318
- .map(([k]) => k)
319
- .sort();
320
- return jsonOk({ project, keys, count: keys.length });
321
- }
322
-
323
- function handleShowManifest(args: Record<string, unknown> | undefined): ToolTextResult {
324
- const at = readString(args, 'cwd') ?? process.cwd();
325
- const worker = readString(args, 'worker');
326
- const inferred = inferAdoptContext(at);
327
- const picked = pickWorker(inferred.workers, worker);
328
- if ('error' in picked) {
329
- return jsonError(picked.error);
330
- }
331
- const loaded = loadManifest({ workerCwd: picked.cwd });
332
- if (loaded.error) {
333
- return jsonError(`manifest invalid (${loaded.path}): ${loaded.error}`);
334
- }
335
- if (!loaded.manifest) {
336
- return jsonError(
337
- `no manifest at ${loaded.path}. create via: athsra manifest init --keys=K1,K2`,
338
- );
339
- }
340
- return jsonOk({
341
- worker: picked.name,
342
- manifestPath: loaded.path,
343
- secrets: loaded.manifest.secrets,
344
- count: loaded.manifest.secrets.length,
345
- });
346
- }
347
-
348
- async function handleValidateManifest(
349
- args: Record<string, unknown> | undefined,
350
- ): Promise<ToolTextResult> {
351
- const project = readString(args, 'project');
352
- if (!project) return jsonError('missing required arg: project');
353
- const ctx = await loadAuthContext();
354
- if (!ctx) {
355
- return jsonError('athsra not logged in — run `athsra login` first');
356
- }
357
- const at = readString(args, 'cwd') ?? process.cwd();
358
- const worker = readString(args, 'worker');
359
- const inferred = inferAdoptContext(at);
360
- const picked = pickWorker(inferred.workers, worker);
361
- if ('error' in picked) {
362
- return jsonError(picked.error);
363
- }
364
- const loaded = loadManifest({ workerCwd: picked.cwd });
365
- if (loaded.error) {
366
- return jsonError(`manifest invalid (${loaded.path}): ${loaded.error}`);
367
- }
368
- if (!loaded.manifest) {
369
- return jsonError(`no manifest at ${loaded.path}`);
370
- }
371
- const env = await readPlain(ctx, project);
372
- if (!env) {
373
- return jsonError(`envelope '${project}' not found`);
374
- }
375
- const envelopeKeys = Object.entries(env)
376
- .filter(([, v]) => typeof v === 'string' && v.length > 0)
377
- .map(([k]) => k);
378
- const applied = applyManifest(loaded.manifest, envelopeKeys);
379
- return jsonOk({
380
- worker: picked.name,
381
- project,
382
- manifestPath: loaded.path,
383
- allowed: applied.allowed,
384
- missing: applied.missing,
385
- excluded: applied.excluded,
386
- counts: {
387
- allowed: applied.allowed.length,
388
- missing: applied.missing.length,
389
- excluded: applied.excluded.length,
390
- },
391
- });
392
- }
393
-
394
- function readStringArray(
395
- args: Record<string, unknown> | undefined,
396
- key: string,
397
- ): string[] | undefined {
398
- const v = args?.[key];
399
- if (!Array.isArray(v)) return undefined;
400
- const out: string[] = [];
401
- for (const item of v) {
402
- if (typeof item !== 'string') return undefined;
403
- out.push(item);
404
- }
405
- return out;
406
- }
407
-
408
- async function handleSetSecret(
409
- args: Record<string, unknown> | undefined,
410
- ): Promise<ToolTextResult> {
411
- const project = readString(args, 'project');
412
- const key = readString(args, 'key');
413
- const value = readString(args, 'value');
414
- if (!project || !key || value === undefined) {
415
- return jsonError('missing required args: project, key, value');
416
- }
417
- const ctx = await loadAuthContext();
418
- if (!ctx) return jsonError('athsra not logged in — run `athsra login` first');
419
- if (ctx.kind !== 'user') {
420
- return jsonError('service token cannot write — user token (master pw) required');
421
- }
422
- const existing = (await readPlain(ctx, project)) ?? {};
423
- const updated: Record<string, string> = { ...existing, [key]: value };
424
- await writePlain(ctx, project, updated);
425
- // 보안: value 는 응답에 절대 포함 X — 키 이름만 confirm
426
- return jsonOk({ project, key, action: 'set', total_keys: Object.keys(updated).length });
427
- }
428
-
429
- async function handleUnsetSecret(
430
- args: Record<string, unknown> | undefined,
431
- ): Promise<ToolTextResult> {
432
- const project = readString(args, 'project');
433
- const key = readString(args, 'key');
434
- if (!project || !key) return jsonError('missing required args: project, key');
435
- const ctx = await loadAuthContext();
436
- if (!ctx) return jsonError('athsra not logged in — run `athsra login` first');
437
- if (ctx.kind !== 'user') {
438
- return jsonError('service token cannot write — user token (master pw) required');
439
- }
440
- const existing = await readPlain(ctx, project);
441
- if (!existing) return jsonError(`envelope '${project}' not found`);
442
- if (!(key in existing)) {
443
- return jsonOk({ project, key, action: 'noop', reason: 'key not present' });
444
- }
445
- const updated: Record<string, string> = { ...existing };
446
- delete updated[key];
447
- await writePlain(ctx, project, updated);
448
- return jsonOk({ project, key, action: 'unset', total_keys: Object.keys(updated).length });
449
- }
450
-
451
- function handleManifestInit(args: Record<string, unknown> | undefined): ToolTextResult {
452
- const keys = readStringArray(args, 'keys');
453
- if (!keys || keys.length === 0) return jsonError('missing required arg: keys (non-empty array)');
454
- const at = readString(args, 'cwd') ?? process.cwd();
455
- const worker = readString(args, 'worker');
456
- const inferred = inferAdoptContext(at);
457
- const picked = pickWorker(inferred.workers, worker);
458
- if ('error' in picked) return jsonError(picked.error);
459
- const existing = loadManifest({ workerCwd: picked.cwd });
460
- if (existing.manifest) {
461
- return jsonError(`manifest already exists at ${existing.path} — use athsra_manifest_modify`);
462
- }
463
- if (existing.error) {
464
- return jsonError(`manifest invalid (${existing.path}): ${existing.error}`);
465
- }
466
- try {
467
- const manifest = createManifest(keys);
468
- const saved = saveManifest(picked.cwd, manifest);
469
- return jsonOk({
470
- worker: picked.name,
471
- manifestPath: saved,
472
- secrets: manifest.secrets,
473
- count: manifest.secrets.length,
474
- });
475
- } catch (err) {
476
- return jsonError(`createManifest failed: ${(err as Error).message}`);
477
- }
478
- }
479
-
480
- function handleManifestModify(args: Record<string, unknown> | undefined): ToolTextResult {
481
- const op = readString(args, 'op');
482
- const keys = readStringArray(args, 'keys');
483
- if (!op || (op !== 'add' && op !== 'remove')) {
484
- return jsonError("invalid 'op' — must be 'add' or 'remove'");
485
- }
486
- if (!keys || keys.length === 0) return jsonError('missing required arg: keys (non-empty array)');
487
- const at = readString(args, 'cwd') ?? process.cwd();
488
- const worker = readString(args, 'worker');
489
- const inferred = inferAdoptContext(at);
490
- const picked = pickWorker(inferred.workers, worker);
491
- if ('error' in picked) return jsonError(picked.error);
492
- const existing = loadManifest({ workerCwd: picked.cwd });
493
- if (existing.error) {
494
- return jsonError(`manifest invalid (${existing.path}): ${existing.error}`);
495
- }
496
- if (!existing.manifest) {
497
- return jsonError(`no manifest at ${existing.path} — use athsra_manifest_init`);
498
- }
499
- const set = new Set(existing.manifest.secrets);
500
- const before = set.size;
501
- for (const k of keys) {
502
- if (op === 'add') set.add(k);
503
- else set.delete(k);
504
- }
505
- if (set.size === before) {
506
- return jsonOk({ worker: picked.name, op, action: 'noop', secrets: existing.manifest.secrets });
507
- }
508
- try {
509
- const manifest = createManifest(Array.from(set));
510
- const saved = saveManifest(picked.cwd, manifest);
511
- return jsonOk({
512
- worker: picked.name,
513
- op,
514
- manifestPath: saved,
515
- secrets: manifest.secrets,
516
- count: manifest.secrets.length,
517
- });
518
- } catch (err) {
519
- return jsonError(`createManifest failed: ${(err as Error).message}`);
520
- }
521
- }
522
-
523
92
  async function dispatch(
524
93
  name: string,
525
94
  args: Record<string, unknown> | undefined,
@@ -530,33 +99,39 @@ async function dispatch(
530
99
  `write tool '${name}' disabled. Set ATHSRA_MCP_WRITE=1 in the MCP server env to enable.`,
531
100
  );
532
101
  }
533
- switch (name) {
534
- case 'athsra_list_projects':
535
- return handleListProjects(args);
536
- case 'athsra_get_project_keys':
537
- return handleGetProjectKeys(args);
538
- case 'athsra_show_manifest':
539
- return handleShowManifest(args);
540
- case 'athsra_validate_manifest':
541
- return handleValidateManifest(args);
542
- case 'athsra_set_secret':
543
- return handleSetSecret(args);
544
- case 'athsra_unset_secret':
545
- return handleUnsetSecret(args);
546
- case 'athsra_manifest_init':
547
- return handleManifestInit(args);
548
- case 'athsra_manifest_modify':
549
- return handleManifestModify(args);
550
- default:
551
- return jsonError(`unknown tool: ${name}`);
102
+ try {
103
+ switch (name) {
104
+ case 'athsra_whoami':
105
+ return await handleWhoami();
106
+ case 'athsra_list_projects':
107
+ return await handleListProjects(args);
108
+ case 'athsra_get_project_keys':
109
+ return await handleGetProjectKeys(args);
110
+ case 'athsra_show_manifest':
111
+ return handleShowManifest(args);
112
+ case 'athsra_validate_manifest':
113
+ return await handleValidateManifest(args);
114
+ case 'athsra_set_secret':
115
+ return await handleSetSecret(args);
116
+ case 'athsra_unset_secret':
117
+ return await handleUnsetSecret(args);
118
+ case 'athsra_manifest_init':
119
+ return handleManifestInit(args);
120
+ case 'athsra_manifest_modify':
121
+ return handleManifestModify(args);
122
+ default:
123
+ return jsonError(`unknown tool: ${name}`);
124
+ }
125
+ } catch (err) {
126
+ // 핸들러 throw (네트워크·401 등) 를 MCP error result 로 환원 — 미처리 throw → 프로토콜 에러 방지.
127
+ // 토큰 있어도 세션 만료(401) 면 재로그인 안내로 전환(AI 가 막히지 않게).
128
+ if (isAuthError(err)) return notLoggedIn();
129
+ return jsonError(err instanceof Error ? err.message : String(err));
552
130
  }
553
131
  }
554
132
 
555
133
  function buildServer(): Server {
556
- const server = new Server(
557
- { name: 'athsra', version: VERSION },
558
- { capabilities: { tools: {} } },
559
- );
134
+ const server = new Server({ name: 'athsra', version: VERSION }, { capabilities: { tools: {} } });
560
135
 
561
136
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
562
137
  tools: TOOLS,
@@ -580,7 +155,16 @@ export async function mcpCmd(args: string[]): Promise<number> {
580
155
  const server = buildServer();
581
156
  const transport = new StdioServerTransport();
582
157
  await server.connect(transport);
583
- // server runs until transport closes (parent process exits)
158
+ // connect() stdin 리스너 등록 즉시 resolve — 여기서 return 하면 호출부 index.ts 의
159
+ // `.then((code) => process.exit(code))` 가 프로세스를 죽여 stdin 을 못 읽는다 (MCP 무응답).
160
+ // transport 가 닫힐 때까지 (MCP 클라이언트가 stdin 종료 = 부모 프로세스 종료) 살려둔다.
161
+ await new Promise<void>((resolve) => {
162
+ const prevOnClose = transport.onclose;
163
+ transport.onclose = () => {
164
+ prevOnClose?.();
165
+ resolve();
166
+ };
167
+ });
584
168
  return 0;
585
169
  }
586
170
 
@@ -1,4 +1,4 @@
1
- import { generatePhrase, wordCount } from '../lib/bip39.ts';
1
+ import { generatePhrase, wordCount } from '@athsra/crypto';
2
2
  import { promptConfirm } from '../lib/prompt.ts';
3
3
 
4
4
  const USAGE = [