@athsra/cli 1.0.1 → 1.0.3
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/package.json +1 -2
- package/src/commands/admin.ts +11 -33
- package/src/commands/adopt.ts +19 -1
- package/src/commands/dr.ts +217 -0
- package/src/commands/login.ts +223 -179
- package/src/commands/manifest.ts +66 -9
- package/src/commands/mcp.ts +69 -485
- package/src/commands/new-phrase.ts +1 -1
- package/src/commands/org.ts +363 -0
- package/src/commands/rotate-master.ts +26 -7
- package/src/commands/run.ts +2 -1
- package/src/commands/service-token.ts +17 -6
- package/src/index.ts +7 -0
- package/src/lib/adopt-context.ts +1 -43
- package/src/lib/auth-context.ts +77 -18
- package/src/lib/client.ts +396 -31
- package/src/lib/colors.ts +17 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/env-format.ts +5 -53
- package/src/lib/envelope.ts +89 -3
- package/src/lib/identity-key.ts +59 -0
- package/src/lib/jsonc.ts +48 -0
- package/src/lib/keyring.ts +26 -0
- package/src/lib/mcp-tools/args.ts +60 -0
- package/src/lib/mcp-tools/defs.ts +179 -0
- package/src/lib/mcp-tools/read.ts +156 -0
- package/src/lib/mcp-tools/result.ts +46 -0
- package/src/lib/mcp-tools/write.ts +127 -0
- package/src/lib/oidc-flow.ts +200 -0
- package/src/lib/org-rewrap.ts +230 -0
- package/src/lib/secrets-manifest.ts +1 -4
- package/src/lib/wrangler-scan.ts +218 -0
- package/src/lib/bip39.ts +0 -45
package/src/commands/mcp.ts
CHANGED
|
@@ -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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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) 은
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} from '../lib/
|
|
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 (
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
//
|
|
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
|
|