@athsra/cli 0.1.0 → 1.0.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.
@@ -0,0 +1,595 @@
1
+ /**
2
+ * mcp.ts — `athsra mcp` 명령 (Model Context Protocol stdio server).
3
+ *
4
+ * Phase 2.7 (2026-05-26): Claude Code / 다른 MCP client 가 athsra envelope 을
5
+ * 도구로 직접 사용. AI agent 가 사용자 prompt 없이 envelope 메타데이터 조회 +
6
+ * manifest 검증 가능.
7
+ *
8
+ * 등록 (Claude Code `.mcp.json` 또는 `~/.claude/settings.json`):
9
+ * "mcpServers": {
10
+ * "athsra": { "command": "athsra", "args": ["mcp"] }
11
+ * }
12
+ *
13
+ * Tools (v0 — read-only, 항상 노출):
14
+ * - athsra_list_projects — 모든 envelope 이름 + RBAC 적용된 접근 권한
15
+ * - athsra_get_project_keys — 특정 envelope 의 키 목록 (값 X — 보안)
16
+ * - athsra_show_manifest — sibling worker manifest (.athsra/secrets.json)
17
+ * - athsra_validate_manifest — envelope vs manifest diff (onboarding gap)
18
+ *
19
+ * Tools (v1 — write, `ATHSRA_MCP_WRITE=1` env opt-in 시에만 노출):
20
+ * - athsra_set_secret — envelope KEY=value 추가/수정 (destructive)
21
+ * - athsra_unset_secret — envelope KEY 제거 (destructive)
22
+ * - athsra_manifest_init — sibling manifest 생성 (file write)
23
+ * - athsra_manifest_modify — manifest add/remove keys
24
+ *
25
+ * 정공법: McpServer high-level 의 zod generic 이 tsc inference OOM 유발 (SIGABRT).
26
+ * low-level `Server` + manual `setRequestHandler` 로 dispatch — JSON Schema literal,
27
+ * zod 의존성 제거. 단순 + 통제 + 메모리 안전.
28
+ *
29
+ * 보안:
30
+ * - secret 값 자체는 노출 안 함 (Phase 2.7 v0). AI 가 값을 직접 보지 않음.
31
+ * - write 동작 (set/unset/adopt) 은 Phase 2.7.1 에서 명시적 permission 추가.
32
+ * - stdio MCP 는 local-only — process.stdout 으로 protocol JSON 만 통신.
33
+ * 모든 로그/에러는 console.error (stderr) 로.
34
+ */
35
+
36
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
37
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
38
+ 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';
45
+ import {
46
+ applyManifest,
47
+ createManifest,
48
+ loadManifest,
49
+ saveManifest,
50
+ } from '../lib/secrets-manifest.ts';
51
+
52
+ const USAGE = [
53
+ 'usage: athsra mcp [--help]',
54
+ '',
55
+ 'Model Context Protocol stdio server — Claude Code / MCP client 가 envelope',
56
+ '을 도구로 사용. stdio JSON-RPC. 다른 출력 X (logs → stderr).',
57
+ '',
58
+ 'Claude Code 등록 예시 (~/.claude/settings.json or .mcp.json):',
59
+ ' "mcpServers": {',
60
+ ' "athsra": { "command": "athsra", "args": ["mcp"] }',
61
+ ' }',
62
+ '',
63
+ 'Tools (v0 read-only):',
64
+ ' athsra_list_projects — envelope 이름 목록',
65
+ ' athsra_get_project_keys — envelope 키 목록 (값 X)',
66
+ ' athsra_show_manifest — sibling worker manifest 조회',
67
+ ' athsra_validate_manifest — envelope vs manifest diff',
68
+ ].join('\n');
69
+
70
+ const VERSION = '1.0.0';
71
+
72
+ /** Write tools opt-in via env var. 기본 false — secret 변경은 명시적 ack 필요. */
73
+ const WRITE_ENABLED = process.env.ATHSRA_MCP_WRITE === '1';
74
+
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
+ const TOOLS: ToolDef[] = WRITE_ENABLED ? [...READ_TOOLS, ...WRITE_TOOLS] : READ_TOOLS;
234
+
235
+ const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS.map((t) => t.name));
236
+
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
+ async function dispatch(
524
+ name: string,
525
+ args: Record<string, unknown> | undefined,
526
+ writeEnabled: boolean = WRITE_ENABLED,
527
+ ): Promise<ToolTextResult> {
528
+ if (WRITE_TOOL_NAMES.has(name) && !writeEnabled) {
529
+ return jsonError(
530
+ `write tool '${name}' disabled. Set ATHSRA_MCP_WRITE=1 in the MCP server env to enable.`,
531
+ );
532
+ }
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}`);
552
+ }
553
+ }
554
+
555
+ function buildServer(): Server {
556
+ const server = new Server(
557
+ { name: 'athsra', version: VERSION },
558
+ { capabilities: { tools: {} } },
559
+ );
560
+
561
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
562
+ tools: TOOLS,
563
+ }));
564
+
565
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
566
+ const { name, arguments: args } = request.params;
567
+ return dispatch(name, args);
568
+ });
569
+
570
+ return server;
571
+ }
572
+
573
+ export async function mcpCmd(args: string[]): Promise<number> {
574
+ if (args.includes('--help') || args.includes('-h')) {
575
+ console.error(USAGE);
576
+ return 0;
577
+ }
578
+ // stdio MCP — all logs to stderr; stdout reserved for protocol JSON
579
+ console.error(`athsra MCP v${VERSION} starting on stdio…`);
580
+ const server = buildServer();
581
+ const transport = new StdioServerTransport();
582
+ await server.connect(transport);
583
+ // server runs until transport closes (parent process exits)
584
+ return 0;
585
+ }
586
+
587
+ /** test helpers — buildServer + dispatch exposed for in-process MCP testing. */
588
+ export const __test = {
589
+ buildServer,
590
+ dispatch,
591
+ TOOLS,
592
+ READ_TOOLS,
593
+ WRITE_TOOLS,
594
+ WRITE_ENABLED,
595
+ };
@@ -0,0 +1,113 @@
1
+ import { migrateV1ToV2 } from '@athsra/crypto';
2
+ import { loadAuthContext } from '../lib/auth-context.ts';
3
+
4
+ const USAGE = [
5
+ 'usage: athsra migrate-envelopes [--apply] [--include=p1,p2,...]',
6
+ '',
7
+ '모든 v1 envelope 를 v2 (DEK + master recipient) 로 일괄 마이그레이션.',
8
+ ' - default dry-run — read-only survey, 어떤 project 가 마이그레이션될지만 출력.',
9
+ ' - --apply — 실제 PUT. 각 project 는 새 version 으로 기록 (versions 보존).',
10
+ ' - --include=... — 특정 project 만 (쉼표 구분, 점진 적용용).',
11
+ '',
12
+ 'service token 추가 전 사전 작업으로 권장 (자동 migrate 도 동작하지만 일괄 처리가 운영',
13
+ '가시성 ↑). 이미 v2 인 project 는 자동 skip — idempotent.',
14
+ ].join('\n');
15
+
16
+ export async function migrateEnvelopesCmd(args: string[]): Promise<number> {
17
+ if (args.includes('--help') || args.includes('-h')) {
18
+ console.log(USAGE);
19
+ return 0;
20
+ }
21
+
22
+ const apply = args.includes('--apply');
23
+ let include: Set<string> | null = null;
24
+ for (const a of args) {
25
+ if (a.startsWith('--include=')) {
26
+ const list = a
27
+ .slice('--include='.length)
28
+ .split(',')
29
+ .map((s) => s.trim())
30
+ .filter(Boolean);
31
+ include = new Set(list);
32
+ } else if (a.startsWith('-') && a !== '--apply') {
33
+ console.error(`Unknown flag: ${a}`);
34
+ console.error(USAGE);
35
+ return 2;
36
+ }
37
+ }
38
+
39
+ const ctx = await loadAuthContext();
40
+ if (!ctx) return 1;
41
+ if (ctx.kind !== 'user') {
42
+ console.error('athsra migrate-envelopes 는 user token (master pw) 가 필요합니다.');
43
+ return 1;
44
+ }
45
+ const { client, masterPw } = ctx;
46
+
47
+ const projects = (await client.listProjects()).sort();
48
+ const targets = include ? projects.filter((p) => include.has(p)) : projects;
49
+ if (include) {
50
+ const missing = [...include].filter((p) => !projects.includes(p));
51
+ if (missing.length > 0) {
52
+ console.error(`--include 의 일부 project 가 존재하지 않습니다: ${missing.join(', ')}`);
53
+ return 1;
54
+ }
55
+ }
56
+
57
+ console.log(`${apply ? 'APPLY' : 'DRY-RUN'} — migrate-envelopes, ${targets.length} projects\n`);
58
+
59
+ let v1Count = 0;
60
+ let v2Count = 0;
61
+ let migrated = 0;
62
+ let errored = 0;
63
+ const errors: string[] = [];
64
+
65
+ for (const project of targets) {
66
+ let env: Awaited<ReturnType<typeof client.getEnvelope>>;
67
+ try {
68
+ env = await client.getEnvelope(project);
69
+ } catch (err) {
70
+ errored++;
71
+ errors.push(`${project}: getEnvelope ${(err as Error).message}`);
72
+ console.log(` ✗ ${project}: getEnvelope error — ${(err as Error).message}`);
73
+ continue;
74
+ }
75
+ if (!env) {
76
+ console.log(` = ${project}: envelope 없음 — skip`);
77
+ continue;
78
+ }
79
+ if (env.version === 2) {
80
+ v2Count++;
81
+ console.log(` = ${project}: already v2 — skip`);
82
+ continue;
83
+ }
84
+ v1Count++;
85
+ if (!apply) {
86
+ console.log(` ○ ${project}: v1 → v2 (dry-run)`);
87
+ continue;
88
+ }
89
+ try {
90
+ const v2 = await migrateV1ToV2(env, masterPw);
91
+ await client.putEnvelope(project, v2);
92
+ migrated++;
93
+ console.log(` ✏ ${project}: v1 → v2 (version ${v2.version_id.slice(0, 12)}…)`);
94
+ } catch (err) {
95
+ errored++;
96
+ errors.push(`${project}: ${(err as Error).message}`);
97
+ console.log(` ✗ ${project}: migrate error — ${(err as Error).message}`);
98
+ }
99
+ }
100
+
101
+ console.log(
102
+ `\n요약: v1=${v1Count} v2=${v2Count} ${apply ? `migrated=${migrated}` : '(dry-run — 변경 없음)'} errored=${errored}`,
103
+ );
104
+ if (errored > 0) {
105
+ console.log('\n실패:');
106
+ for (const e of errors) console.log(` ${e}`);
107
+ return 1;
108
+ }
109
+ if (!apply && v1Count > 0) {
110
+ console.log('\n적용: athsra migrate-envelopes --apply');
111
+ }
112
+ return 0;
113
+ }