cli4ai 0.8.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,106 @@
1
+ /**
2
+ * MCP Config Generator - Generate Claude Code MCP configuration
3
+ */
4
+
5
+ import { resolve } from 'path';
6
+ import { type Manifest, tryLoadManifest } from '../core/manifest.js';
7
+ import { findPackage, getGlobalPackages, getLocalPackages, type InstalledPackage } from '../core/config.js';
8
+
9
+ export interface McpServerConfig {
10
+ command: string;
11
+ args: string[];
12
+ env?: Record<string, string>;
13
+ }
14
+
15
+ export interface ClaudeCodeConfig {
16
+ mcpServers: Record<string, McpServerConfig>;
17
+ }
18
+
19
+ /**
20
+ * Generate MCP server config for a single package
21
+ */
22
+ export function generateServerConfig(
23
+ manifest: Manifest,
24
+ _packagePath: string
25
+ ): McpServerConfig {
26
+ // Use cli4ai start command which handles the MCP server
27
+ return {
28
+ command: 'cli4ai',
29
+ args: ['start', manifest.name]
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Load manifest for an installed package
35
+ */
36
+ function loadPackageWithManifest(pkg: InstalledPackage): { name: string; path: string; manifest: Manifest } | null {
37
+ const manifest = tryLoadManifest(pkg.path);
38
+ if (!manifest) return null;
39
+ return { name: pkg.name, path: pkg.path, manifest };
40
+ }
41
+
42
+ /**
43
+ * Generate Claude Code config for installed packages
44
+ */
45
+ export function generateClaudeCodeConfig(
46
+ cwd: string,
47
+ options: { global?: boolean; packages?: string[] } = {}
48
+ ): ClaudeCodeConfig {
49
+ const mcpServers: Record<string, McpServerConfig> = {};
50
+
51
+ // Get installed packages
52
+ let installedPackages: InstalledPackage[] = [];
53
+
54
+ if (options.packages && options.packages.length > 0) {
55
+ // Specific packages requested
56
+ for (const pkgName of options.packages) {
57
+ const pkg = findPackage(pkgName, cwd);
58
+ if (pkg) {
59
+ installedPackages.push(pkg);
60
+ }
61
+ }
62
+ } else {
63
+ // All installed packages
64
+ if (options.global) {
65
+ installedPackages = getGlobalPackages();
66
+ } else {
67
+ installedPackages = [
68
+ ...getLocalPackages(cwd),
69
+ ...getGlobalPackages()
70
+ ];
71
+ }
72
+ }
73
+
74
+ // Load manifests and filter to MCP-enabled packages
75
+ for (const pkg of installedPackages) {
76
+ const pkgWithManifest = loadPackageWithManifest(pkg);
77
+ if (pkgWithManifest && pkgWithManifest.manifest.mcp?.enabled) {
78
+ mcpServers[`cli4ai-${pkgWithManifest.name}`] = generateServerConfig(
79
+ pkgWithManifest.manifest,
80
+ pkgWithManifest.path
81
+ );
82
+ }
83
+ }
84
+
85
+ return { mcpServers };
86
+ }
87
+
88
+ /**
89
+ * Format config as JSON for Claude Code
90
+ */
91
+ export function formatClaudeCodeConfig(config: ClaudeCodeConfig): string {
92
+ return JSON.stringify(config, null, 2);
93
+ }
94
+
95
+ /**
96
+ * Generate config snippet for adding to existing claude_desktop_config.json
97
+ */
98
+ export function generateConfigSnippet(
99
+ manifest: Manifest,
100
+ packagePath: string
101
+ ): string {
102
+ const serverConfig = generateServerConfig(manifest, packagePath);
103
+ const serverName = `cli4ai-${manifest.name}`;
104
+
105
+ return `"${serverName}": ${JSON.stringify(serverConfig, null, 2)}`;
106
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * MCP Server - Expose CLI tools as MCP tools
3
+ *
4
+ * Implements the Model Context Protocol (MCP) over stdio
5
+ * https://modelcontextprotocol.io/
6
+ *
7
+ * SECURITY: Includes execution safeguards:
8
+ * - Audit logging of all tool calls
9
+ * - Rate limiting to prevent abuse
10
+ * - Trust level tracking for packages
11
+ */
12
+
13
+ import { spawn } from 'child_process';
14
+ import { resolve } from 'path';
15
+ import { appendFileSync, existsSync, mkdirSync } from 'fs';
16
+ import { homedir } from 'os';
17
+ import { type Manifest } from '../core/manifest.js';
18
+ import { manifestToMcpTools, type McpTool } from './adapter.js';
19
+ import { getSecret } from '../core/secrets.js';
20
+
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+ // SECURITY: Audit logging and rate limiting
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+
25
+ const AUDIT_LOG_DIR = resolve(homedir(), '.cli4ai', 'logs');
26
+ const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
27
+ const RATE_LIMIT_MAX_CALLS = 100; // Max calls per minute per tool
28
+
29
+ interface RateLimitEntry {
30
+ count: number;
31
+ windowStart: number;
32
+ }
33
+
34
+ /**
35
+ * Audit log an MCP tool call for security tracking
36
+ */
37
+ function auditLog(
38
+ packageName: string,
39
+ toolName: string,
40
+ args: Record<string, unknown>,
41
+ result: 'success' | 'error' | 'rate_limited',
42
+ errorMessage?: string
43
+ ): void {
44
+ try {
45
+ if (!existsSync(AUDIT_LOG_DIR)) {
46
+ mkdirSync(AUDIT_LOG_DIR, { recursive: true });
47
+ }
48
+
49
+ const logEntry = {
50
+ timestamp: new Date().toISOString(),
51
+ package: packageName,
52
+ tool: toolName,
53
+ // Redact potentially sensitive argument values, keep keys
54
+ argKeys: Object.keys(args),
55
+ result,
56
+ error: errorMessage,
57
+ pid: process.pid
58
+ };
59
+
60
+ const logFile = resolve(AUDIT_LOG_DIR, `mcp-audit-${new Date().toISOString().slice(0, 10)}.log`);
61
+ appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
62
+ } catch {
63
+ // Don't fail tool execution if logging fails
64
+ }
65
+ }
66
+
67
+ interface JsonRpcRequest {
68
+ jsonrpc: '2.0';
69
+ id: number | string;
70
+ method: string;
71
+ params?: Record<string, unknown>;
72
+ }
73
+
74
+ interface JsonRpcResponse {
75
+ jsonrpc: '2.0';
76
+ id: number | string;
77
+ result?: unknown;
78
+ error?: { code: number; message: string; data?: unknown };
79
+ }
80
+
81
+ /**
82
+ * MCP Server that wraps a CLI tool
83
+ */
84
+ export class McpServer {
85
+ private manifest: Manifest;
86
+ private packagePath: string;
87
+ private tools: McpTool[];
88
+ private rateLimits: Map<string, RateLimitEntry> = new Map();
89
+ private totalCallCount: number = 0;
90
+
91
+ constructor(manifest: Manifest, packagePath: string) {
92
+ this.manifest = manifest;
93
+ this.packagePath = packagePath;
94
+ this.tools = manifestToMcpTools(manifest);
95
+ }
96
+
97
+ /**
98
+ * SECURITY: Check if a tool call should be rate limited
99
+ */
100
+ private checkRateLimit(toolName: string): { allowed: boolean; retryAfterMs?: number } {
101
+ const now = Date.now();
102
+ const entry = this.rateLimits.get(toolName);
103
+
104
+ if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
105
+ // Start new window
106
+ this.rateLimits.set(toolName, { count: 1, windowStart: now });
107
+ return { allowed: true };
108
+ }
109
+
110
+ if (entry.count >= RATE_LIMIT_MAX_CALLS) {
111
+ const retryAfterMs = RATE_LIMIT_WINDOW_MS - (now - entry.windowStart);
112
+ return { allowed: false, retryAfterMs };
113
+ }
114
+
115
+ entry.count++;
116
+ return { allowed: true };
117
+ }
118
+
119
+ /**
120
+ * Start the MCP server (stdio mode)
121
+ */
122
+ async start(): Promise<void> {
123
+ process.stdin.setEncoding('utf8');
124
+
125
+ let buffer = '';
126
+
127
+ process.stdin.on('data', (chunk: string) => {
128
+ buffer += chunk;
129
+
130
+ // Try to parse complete JSON-RPC messages
131
+ const lines = buffer.split('\n');
132
+ buffer = lines.pop() || '';
133
+
134
+ for (const line of lines) {
135
+ if (line.trim()) {
136
+ this.handleMessage(line.trim());
137
+ }
138
+ }
139
+ });
140
+
141
+ process.stdin.on('end', () => {
142
+ process.exit(0);
143
+ });
144
+ }
145
+
146
+ private handleMessage(message: string): void {
147
+ try {
148
+ const request = JSON.parse(message) as JsonRpcRequest;
149
+ this.handleRequest(request);
150
+ } catch (err) {
151
+ this.sendError(null, -32700, 'Parse error');
152
+ }
153
+ }
154
+
155
+ private async handleRequest(request: JsonRpcRequest): Promise<void> {
156
+ const { id, method, params } = request;
157
+
158
+ try {
159
+ switch (method) {
160
+ case 'initialize':
161
+ this.sendResult(id, {
162
+ protocolVersion: '2024-11-05',
163
+ capabilities: {
164
+ tools: {}
165
+ },
166
+ serverInfo: {
167
+ name: `cli4ai-${this.manifest.name}`,
168
+ version: this.manifest.version
169
+ }
170
+ });
171
+ break;
172
+
173
+ case 'initialized':
174
+ // No response needed
175
+ break;
176
+
177
+ case 'tools/list':
178
+ this.sendResult(id, {
179
+ tools: this.tools.map(t => ({
180
+ name: t.name,
181
+ description: t.description,
182
+ inputSchema: t.inputSchema
183
+ }))
184
+ });
185
+ break;
186
+
187
+ case 'tools/call':
188
+ await this.handleToolCall(id, params as { name: string; arguments?: Record<string, string> });
189
+ break;
190
+
191
+ case 'ping':
192
+ this.sendResult(id, {});
193
+ break;
194
+
195
+ default:
196
+ this.sendError(id, -32601, `Method not found: ${method}`);
197
+ }
198
+ } catch (err) {
199
+ this.sendError(id, -32603, (err as Error).message);
200
+ }
201
+ }
202
+
203
+ private async handleToolCall(
204
+ id: number | string,
205
+ params: { name: string; arguments?: Record<string, string> }
206
+ ): Promise<void> {
207
+ const { name, arguments: args = {} } = params;
208
+
209
+ // SECURITY: Rate limiting
210
+ const rateCheck = this.checkRateLimit(name);
211
+ if (!rateCheck.allowed) {
212
+ auditLog(this.manifest.name, name, args, 'rate_limited');
213
+ this.sendError(id, -32000, `Rate limit exceeded. Retry after ${Math.ceil((rateCheck.retryAfterMs || 0) / 1000)}s`);
214
+ return;
215
+ }
216
+
217
+ // Track total calls for monitoring
218
+ this.totalCallCount++;
219
+
220
+ // Parse tool name: package_command
221
+ const parts = name.split('_');
222
+ if (parts.length < 2 || parts[0] !== this.manifest.name) {
223
+ auditLog(this.manifest.name, name, args, 'error', 'Unknown tool');
224
+ this.sendError(id, -32602, `Unknown tool: ${name}`);
225
+ return;
226
+ }
227
+
228
+ const command = parts.slice(1).join('_');
229
+ const cmdDef = this.manifest.commands?.[command];
230
+
231
+ if (!cmdDef) {
232
+ auditLog(this.manifest.name, name, args, 'error', 'Unknown command');
233
+ this.sendError(id, -32602, `Unknown command: ${command}`);
234
+ return;
235
+ }
236
+
237
+ // Build command arguments in order
238
+ const cmdArgs: string[] = [command];
239
+ if (cmdDef.args) {
240
+ for (const argDef of cmdDef.args) {
241
+ const value = args[argDef.name];
242
+ if (value !== undefined && value !== '') {
243
+ cmdArgs.push(String(value));
244
+ } else if (argDef.required) {
245
+ auditLog(this.manifest.name, name, args, 'error', `Missing required argument: ${argDef.name}`);
246
+ this.sendError(id, -32602, `Missing required argument: ${argDef.name}`);
247
+ return;
248
+ }
249
+ }
250
+ }
251
+
252
+ // Execute the CLI tool
253
+ const entryPath = resolve(this.packagePath, this.manifest.entry);
254
+ const runtime = this.manifest.runtime || 'bun';
255
+
256
+ try {
257
+ const result = await this.executeCommand(runtime, entryPath, cmdArgs);
258
+ auditLog(this.manifest.name, name, args, 'success');
259
+ this.sendResult(id, {
260
+ content: [{ type: 'text', text: result }]
261
+ });
262
+ } catch (err) {
263
+ const errorMessage = (err as Error).message;
264
+ auditLog(this.manifest.name, name, args, 'error', errorMessage);
265
+ this.sendResult(id, {
266
+ content: [{ type: 'text', text: errorMessage }],
267
+ isError: true
268
+ });
269
+ }
270
+ }
271
+
272
+ private executeCommand(runtime: string, entryPath: string, args: string[]): Promise<string> {
273
+ return new Promise((resolve, reject) => {
274
+ // Build runtime-specific command and arguments
275
+ // - bun: bun run <file> [args]
276
+ // - node: node <file> [args]
277
+ let cmd: string;
278
+ let cmdArgs: string[];
279
+ switch (runtime) {
280
+ case 'node':
281
+ cmd = 'node';
282
+ cmdArgs = [entryPath, ...args];
283
+ break;
284
+ case 'bun':
285
+ default:
286
+ cmd = 'bun';
287
+ cmdArgs = ['run', entryPath, ...args];
288
+ break;
289
+ }
290
+
291
+ // Inject secrets from manifest env definitions
292
+ const secretsEnv: Record<string, string> = {};
293
+ if (this.manifest.env) {
294
+ for (const key of Object.keys(this.manifest.env)) {
295
+ const value = getSecret(key);
296
+ if (value) {
297
+ secretsEnv[key] = value;
298
+ }
299
+ }
300
+ }
301
+
302
+ const proc = spawn(cmd, cmdArgs, {
303
+ stdio: ['pipe', 'pipe', 'pipe'],
304
+ env: { ...process.env, ...secretsEnv }
305
+ });
306
+
307
+ let stdout = '';
308
+ let stderr = '';
309
+
310
+ proc.stdout.on('data', (data) => {
311
+ stdout += data.toString();
312
+ });
313
+
314
+ proc.stderr.on('data', (data) => {
315
+ stderr += data.toString();
316
+ });
317
+
318
+ proc.on('close', (code) => {
319
+ if (code !== 0) {
320
+ reject(new Error(stderr || `Exit code ${code}`));
321
+ } else {
322
+ resolve(stdout);
323
+ }
324
+ });
325
+
326
+ proc.on('error', (err) => {
327
+ reject(err);
328
+ });
329
+ });
330
+ }
331
+
332
+ private sendResult(id: number | string | null, result: unknown): void {
333
+ if (id === null) return;
334
+
335
+ const response: JsonRpcResponse = {
336
+ jsonrpc: '2.0',
337
+ id,
338
+ result
339
+ };
340
+ console.log(JSON.stringify(response));
341
+ }
342
+
343
+ private sendError(id: number | string | null, code: number, message: string): void {
344
+ // Per JSON-RPC 2.0 spec, error responses for parse errors should include id: null
345
+ // For other errors where id is null (shouldn't happen), we skip the response
346
+ if (id === null && code !== -32700) return;
347
+
348
+ const response: JsonRpcResponse = {
349
+ jsonrpc: '2.0',
350
+ id: id as number | string, // For parse errors (-32700), this will be cast from null
351
+ error: { code, message }
352
+ };
353
+ console.log(JSON.stringify(response));
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Start MCP server for a package
359
+ */
360
+ export async function startMcpServer(manifest: Manifest, packagePath: string): Promise<void> {
361
+ const server = new McpServer(manifest, packagePath);
362
+ await server.start();
363
+ }