specvector 0.0.1 → 0.1.2

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,382 @@
1
+ /**
2
+ * MCP Client - Unified wrapper for MCP server connections.
3
+ *
4
+ * Uses subprocess/stdio transport with JSON-RPC 2.0 protocol.
5
+ * Designed for ephemeral connections in CI/CD environments.
6
+ */
7
+
8
+ import { spawn, type Subprocess, type FileSink } from "bun";
9
+ import type { Result } from "../types/result";
10
+ import { ok, err } from "../types/result";
11
+ import type {
12
+ MCPServerConfig,
13
+ MCPError,
14
+ MCPRequest,
15
+ MCPResponse,
16
+ MCPTool,
17
+ MCPToolsListResult,
18
+ MCPToolCallResult,
19
+ } from "./types";
20
+
21
+ // ============================================================================
22
+ // Constants
23
+ // ============================================================================
24
+
25
+ const DEFAULT_TIMEOUT = 30_000; // 30 seconds
26
+ const INIT_TIMEOUT = 10_000; // 10 seconds for initialization
27
+
28
+ // ============================================================================
29
+ // MCP Client Interface
30
+ // ============================================================================
31
+
32
+ export interface MCPClient {
33
+ /** Server name from config */
34
+ readonly name: string;
35
+ /** Call a tool on the MCP server */
36
+ callTool(name: string, args: Record<string, unknown>): Promise<Result<MCPToolCallResult, MCPError>>;
37
+ /** List available tools */
38
+ listTools(): Promise<Result<MCPTool[], MCPError>>;
39
+ /** Close the connection and kill subprocess */
40
+ close(): Promise<void>;
41
+ }
42
+
43
+ // ============================================================================
44
+ // Internal State
45
+ // ============================================================================
46
+
47
+ interface MCPClientState {
48
+ config: MCPServerConfig;
49
+ proc: Subprocess;
50
+ requestId: number;
51
+ pendingRequests: Map<number, {
52
+ resolve: (response: MCPResponse) => void;
53
+ reject: (error: Error) => void;
54
+ timeout: ReturnType<typeof setTimeout>;
55
+ }>;
56
+ buffer: string;
57
+ initialized: boolean;
58
+ }
59
+
60
+ // ============================================================================
61
+ // MCP Client Factory
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Create and initialize an MCP client.
66
+ *
67
+ * Spawns the MCP server subprocess and performs the initialization handshake.
68
+ */
69
+ export async function createMCPClient(
70
+ config: MCPServerConfig
71
+ ): Promise<Result<MCPClient, MCPError>> {
72
+ // Spawn the subprocess
73
+ let proc: Subprocess;
74
+ try {
75
+ proc = spawn([config.command, ...config.args], {
76
+ env: { ...process.env, ...config.env },
77
+ stdin: "pipe",
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ });
81
+ } catch (error) {
82
+ return err({
83
+ code: "SPAWN_ERROR",
84
+ message: `Failed to spawn MCP server: ${error instanceof Error ? error.message : "Unknown error"}`,
85
+ });
86
+ }
87
+
88
+ // Create client state
89
+ const state: MCPClientState = {
90
+ config,
91
+ proc,
92
+ requestId: 0,
93
+ pendingRequests: new Map(),
94
+ buffer: "",
95
+ initialized: false,
96
+ };
97
+
98
+ // Start reading stdout
99
+ startReadingStdout(state);
100
+
101
+ // Start reading stderr (for debugging)
102
+ startReadingStderr(state);
103
+
104
+ // Perform MCP initialization handshake
105
+ const initResult = await initializeMCP(state);
106
+ if (!initResult.ok) {
107
+ await killProcess(state);
108
+ return initResult;
109
+ }
110
+
111
+ state.initialized = true;
112
+
113
+ // Create the client interface
114
+ const client: MCPClient = {
115
+ name: config.name,
116
+
117
+ async callTool(name: string, args: Record<string, unknown>): Promise<Result<MCPToolCallResult, MCPError>> {
118
+ if (!state.initialized) {
119
+ return err({ code: "CONNECTION_FAILED", message: "MCP client not initialized" });
120
+ }
121
+
122
+ const response = await sendRequest(state, "tools/call", {
123
+ name,
124
+ arguments: args,
125
+ });
126
+
127
+ if (!response.ok) {
128
+ return response;
129
+ }
130
+
131
+ if (response.value.error) {
132
+ return err({
133
+ code: "TOOL_ERROR",
134
+ message: response.value.error.message,
135
+ });
136
+ }
137
+
138
+ return ok(response.value.result as MCPToolCallResult);
139
+ },
140
+
141
+ async listTools(): Promise<Result<MCPTool[], MCPError>> {
142
+ if (!state.initialized) {
143
+ return err({ code: "CONNECTION_FAILED", message: "MCP client not initialized" });
144
+ }
145
+
146
+ const response = await sendRequest(state, "tools/list", {});
147
+
148
+ if (!response.ok) {
149
+ return response;
150
+ }
151
+
152
+ if (response.value.error) {
153
+ return err({
154
+ code: "SERVER_ERROR",
155
+ message: response.value.error.message,
156
+ });
157
+ }
158
+
159
+ const result = response.value.result as MCPToolsListResult;
160
+ return ok(result.tools);
161
+ },
162
+
163
+ async close(): Promise<void> {
164
+ await killProcess(state);
165
+ },
166
+ };
167
+
168
+ return ok(client);
169
+ }
170
+
171
+ // ============================================================================
172
+ // Internal Functions
173
+ // ============================================================================
174
+
175
+ /**
176
+ * Perform MCP initialization handshake.
177
+ */
178
+ async function initializeMCP(state: MCPClientState): Promise<Result<void, MCPError>> {
179
+ // Send initialize request
180
+ const initResponse = await sendRequest(state, "initialize", {
181
+ protocolVersion: "2024-11-05",
182
+ capabilities: {},
183
+ clientInfo: {
184
+ name: "specvector",
185
+ version: "1.0.0",
186
+ },
187
+ }, INIT_TIMEOUT);
188
+
189
+ if (!initResponse.ok) {
190
+ return err(initResponse.error);
191
+ }
192
+
193
+ if (initResponse.value.error) {
194
+ return err({
195
+ code: "PROTOCOL_ERROR",
196
+ message: `MCP initialization failed: ${initResponse.value.error.message}`,
197
+ });
198
+ }
199
+
200
+ // Send initialized notification (no response expected)
201
+ const notification: MCPRequest = {
202
+ jsonrpc: "2.0",
203
+ id: 0, // Notifications don't have IDs, but we include for consistency
204
+ method: "notifications/initialized",
205
+ params: {},
206
+ };
207
+
208
+ try {
209
+ const writer = state.proc.stdin as FileSink | null;
210
+ if (writer && typeof writer.write === "function") {
211
+ writer.write(JSON.stringify(notification) + "\n");
212
+ }
213
+ } catch {
214
+ // Notification failures are non-fatal
215
+ }
216
+
217
+ return ok(undefined);
218
+ }
219
+
220
+ /**
221
+ * Send a JSON-RPC request and await response.
222
+ */
223
+ async function sendRequest(
224
+ state: MCPClientState,
225
+ method: string,
226
+ params: Record<string, unknown>,
227
+ timeout?: number
228
+ ): Promise<Result<MCPResponse, MCPError>> {
229
+ const id = ++state.requestId;
230
+ const request: MCPRequest = {
231
+ jsonrpc: "2.0",
232
+ id,
233
+ method,
234
+ params,
235
+ };
236
+
237
+ const requestTimeout = timeout ?? state.config.timeout ?? DEFAULT_TIMEOUT;
238
+
239
+ return new Promise((resolve) => {
240
+ // Set up timeout
241
+ const timeoutHandle = setTimeout(() => {
242
+ state.pendingRequests.delete(id);
243
+ resolve(err({
244
+ code: "TIMEOUT",
245
+ message: `Request timed out after ${requestTimeout}ms`,
246
+ }));
247
+ }, requestTimeout);
248
+
249
+ // Store pending request
250
+ state.pendingRequests.set(id, {
251
+ resolve: (response: MCPResponse) => {
252
+ clearTimeout(timeoutHandle);
253
+ state.pendingRequests.delete(id);
254
+ resolve(ok(response));
255
+ },
256
+ reject: (error: Error) => {
257
+ clearTimeout(timeoutHandle);
258
+ state.pendingRequests.delete(id);
259
+ resolve(err({
260
+ code: "PROTOCOL_ERROR",
261
+ message: error.message,
262
+ }));
263
+ },
264
+ timeout: timeoutHandle,
265
+ });
266
+
267
+ // Send the request
268
+ try {
269
+ const writer = state.proc.stdin as FileSink | null;
270
+ if (writer && typeof writer.write === "function") {
271
+ writer.write(JSON.stringify(request) + "\n");
272
+ } else {
273
+ state.pendingRequests.get(id)?.reject(new Error("stdin not available"));
274
+ }
275
+ } catch (error) {
276
+ state.pendingRequests.get(id)?.reject(
277
+ error instanceof Error ? error : new Error("Failed to write to stdin")
278
+ );
279
+ }
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Start reading stdout and dispatch responses.
285
+ */
286
+ function startReadingStdout(state: MCPClientState): void {
287
+ const reader = state.proc.stdout;
288
+ if (!reader || typeof reader === "number") return;
289
+
290
+ const stream = reader as ReadableStream<Uint8Array>;
291
+ const textReader = stream.getReader();
292
+
293
+ (async () => {
294
+ const decoder = new TextDecoder();
295
+ try {
296
+ while (true) {
297
+ const { done, value } = await textReader.read();
298
+ if (done) break;
299
+ state.buffer += decoder.decode(value, { stream: true });
300
+ processBuffer(state);
301
+ }
302
+ } catch {
303
+ // Stream ended or error - reject all pending requests
304
+ for (const [, pending] of state.pendingRequests) {
305
+ pending.reject(new Error("Connection closed"));
306
+ }
307
+ state.pendingRequests.clear();
308
+ }
309
+ })();
310
+ }
311
+
312
+ /**
313
+ * Process buffered data, extracting complete JSON lines.
314
+ */
315
+ function processBuffer(state: MCPClientState): void {
316
+ let newlineIndex: number;
317
+ while ((newlineIndex = state.buffer.indexOf("\n")) !== -1) {
318
+ const line = state.buffer.slice(0, newlineIndex).trim();
319
+ state.buffer = state.buffer.slice(newlineIndex + 1);
320
+
321
+ if (!line) continue;
322
+
323
+ try {
324
+ const response = JSON.parse(line) as MCPResponse;
325
+ const pending = state.pendingRequests.get(response.id);
326
+ if (pending) {
327
+ pending.resolve(response);
328
+ }
329
+ } catch {
330
+ // Invalid JSON - ignore
331
+ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Start reading stderr for debugging.
337
+ */
338
+ function startReadingStderr(state: MCPClientState): void {
339
+ const reader = state.proc.stderr;
340
+ if (!reader || typeof reader === "number") return;
341
+
342
+ const stream = reader as ReadableStream<Uint8Array>;
343
+ const textReader = stream.getReader();
344
+
345
+ (async () => {
346
+ const decoder = new TextDecoder();
347
+ try {
348
+ while (true) {
349
+ const { done, value } = await textReader.read();
350
+ if (done) break;
351
+ const text = decoder.decode(value, { stream: true });
352
+ // Log stderr for debugging (could be made configurable)
353
+ if (text.trim()) {
354
+ console.error(`[MCP:${state.config.name}] ${text.trim()}`);
355
+ }
356
+ }
357
+ } catch {
358
+ // Ignore stderr errors
359
+ }
360
+ })();
361
+ }
362
+
363
+ /**
364
+ * Kill the subprocess and clean up.
365
+ */
366
+ async function killProcess(state: MCPClientState): Promise<void> {
367
+ // Clear all pending requests
368
+ for (const [, pending] of state.pendingRequests) {
369
+ clearTimeout(pending.timeout);
370
+ pending.reject(new Error("Client closed"));
371
+ }
372
+ state.pendingRequests.clear();
373
+
374
+ // Kill the process
375
+ try {
376
+ state.proc.kill();
377
+ } catch {
378
+ // Ignore kill errors
379
+ }
380
+
381
+ state.initialized = false;
382
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * MCP (Model Context Protocol) Type Definitions
3
+ *
4
+ * Types for the JSON-RPC 2.0 protocol used by MCP servers.
5
+ */
6
+
7
+ // ============================================================================
8
+ // Error Types
9
+ // ============================================================================
10
+
11
+ export type MCPErrorCode =
12
+ | "CONNECTION_FAILED"
13
+ | "SPAWN_ERROR"
14
+ | "TIMEOUT"
15
+ | "PROTOCOL_ERROR"
16
+ | "TOOL_ERROR"
17
+ | "SERVER_ERROR";
18
+
19
+ export interface MCPError {
20
+ code: MCPErrorCode;
21
+ message: string;
22
+ }
23
+
24
+ // ============================================================================
25
+ // Server Configuration
26
+ // ============================================================================
27
+
28
+ export interface MCPServerConfig {
29
+ /** Server identifier (e.g., 'linear', 'filesystem') */
30
+ name: string;
31
+ /** Command to spawn (e.g., 'bunx', 'npx', or path to binary) */
32
+ command: string;
33
+ /** Arguments for the command (e.g., ['-y', '@anthropic-ai/mcp-server-linear']) */
34
+ args: string[];
35
+ /** Environment variables to pass (merged with process.env) */
36
+ env?: Record<string, string>;
37
+ /** Timeout in ms for requests (default: 30000) */
38
+ timeout?: number;
39
+ }
40
+
41
+ // ============================================================================
42
+ // MCP Protocol Types (JSON-RPC 2.0)
43
+ // ============================================================================
44
+
45
+ export interface MCPRequest {
46
+ jsonrpc: "2.0";
47
+ id: number;
48
+ method: string;
49
+ params?: Record<string, unknown>;
50
+ }
51
+
52
+ export interface MCPResponse {
53
+ jsonrpc: "2.0";
54
+ id: number;
55
+ result?: unknown;
56
+ error?: MCPRPCError;
57
+ }
58
+
59
+ export interface MCPRPCError {
60
+ code: number;
61
+ message: string;
62
+ data?: unknown;
63
+ }
64
+
65
+ // ============================================================================
66
+ // MCP Tool Types
67
+ // ============================================================================
68
+
69
+ export interface MCPTool {
70
+ name: string;
71
+ description: string;
72
+ inputSchema: MCPInputSchema;
73
+ }
74
+
75
+ export interface MCPInputSchema {
76
+ type: "object";
77
+ properties?: Record<string, MCPSchemaProperty>;
78
+ required?: string[];
79
+ }
80
+
81
+ export interface MCPSchemaProperty {
82
+ type: string;
83
+ description?: string;
84
+ }
85
+
86
+ // ============================================================================
87
+ // MCP Result Types (for tools/list and tools/call)
88
+ // ============================================================================
89
+
90
+ export interface MCPToolsListResult {
91
+ tools: MCPTool[];
92
+ }
93
+
94
+ export interface MCPToolCallResult {
95
+ content: MCPContent[];
96
+ isError?: boolean;
97
+ }
98
+
99
+ export interface MCPContent {
100
+ type: "text" | "image" | "resource";
101
+ text?: string;
102
+ data?: string;
103
+ mimeType?: string;
104
+ }
File without changes
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Diff parser for unified diff format.
3
+ * Converts raw `gh pr diff` output into structured types.
4
+ */
5
+
6
+ import type {
7
+ DiffChange,
8
+ DiffFile,
9
+ DiffHunk,
10
+ ParsedDiff,
11
+ } from "../types/diff";
12
+
13
+ /**
14
+ * Parse a unified diff string into structured format.
15
+ */
16
+ export function parseDiff(diffText: string): ParsedDiff {
17
+ const files: DiffFile[] = [];
18
+ let totalAdditions = 0;
19
+ let totalDeletions = 0;
20
+
21
+ // Split by file boundaries (diff --git)
22
+ const fileSections = diffText.split(/^diff --git /m).filter(Boolean);
23
+
24
+ for (const section of fileSections) {
25
+ const file = parseFileSection(section);
26
+ if (file) {
27
+ files.push(file);
28
+ totalAdditions += file.additions;
29
+ totalDeletions += file.deletions;
30
+ }
31
+ }
32
+
33
+ return {
34
+ files,
35
+ totalAdditions,
36
+ totalDeletions,
37
+ filesChanged: files.length,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Parse a single file section from the diff.
43
+ */
44
+ function parseFileSection(section: string): DiffFile | null {
45
+ const lines = section.split("\n");
46
+ if (lines.length === 0) return null;
47
+
48
+ // First line is the file paths: a/path b/path
49
+ const headerMatch = lines[0]?.match(/^a\/(.+?) b\/(.+?)$/);
50
+ if (!headerMatch) return null;
51
+
52
+ const oldPath = headerMatch[1] ?? null;
53
+ const newPath = headerMatch[2] ?? null;
54
+
55
+ // Check for binary file
56
+ const isBinary = lines.some((line) => line.startsWith("Binary files"));
57
+ if (isBinary) {
58
+ return {
59
+ oldPath,
60
+ newPath,
61
+ status: determineStatus(oldPath, newPath, lines),
62
+ binary: true,
63
+ hunks: [],
64
+ additions: 0,
65
+ deletions: 0,
66
+ };
67
+ }
68
+
69
+ // Parse hunks
70
+ const hunks: DiffHunk[] = [];
71
+ let currentHunk: DiffHunk | null = null;
72
+ let additions = 0;
73
+ let deletions = 0;
74
+
75
+ for (const line of lines) {
76
+ // Hunk header: @@ -1,3 +1,4 @@
77
+ const hunkMatch = line.match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
78
+ if (hunkMatch) {
79
+ if (currentHunk) {
80
+ hunks.push(currentHunk);
81
+ }
82
+ currentHunk = {
83
+ oldStart: parseInt(hunkMatch[1] ?? "0", 10),
84
+ oldLines: parseInt(hunkMatch[2] ?? "1", 10) || 1,
85
+ newStart: parseInt(hunkMatch[3] ?? "0", 10),
86
+ newLines: parseInt(hunkMatch[4] ?? "1", 10) || 1,
87
+ content: line,
88
+ changes: [],
89
+ };
90
+ continue;
91
+ }
92
+
93
+ // Content lines
94
+ if (currentHunk && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
95
+ const change: DiffChange = {
96
+ type: line.startsWith("+") ? "add" : line.startsWith("-") ? "delete" : "normal",
97
+ content: line.slice(1),
98
+ };
99
+ currentHunk.changes.push(change);
100
+ currentHunk.content += "\n" + line;
101
+
102
+ if (line.startsWith("+") && !line.startsWith("+++")) {
103
+ additions++;
104
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
105
+ deletions++;
106
+ }
107
+ }
108
+ }
109
+
110
+ // Push last hunk
111
+ if (currentHunk) {
112
+ hunks.push(currentHunk);
113
+ }
114
+
115
+ return {
116
+ oldPath,
117
+ newPath,
118
+ status: determineStatus(oldPath, newPath, lines),
119
+ binary: false,
120
+ hunks,
121
+ additions,
122
+ deletions,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Determine the status of a file change.
128
+ */
129
+ function determineStatus(
130
+ oldPath: string | null,
131
+ newPath: string | null,
132
+ lines: string[]
133
+ ): DiffFile["status"] {
134
+ // Check for new file
135
+ if (lines.some((l) => l.startsWith("new file mode"))) {
136
+ return "added";
137
+ }
138
+ // Check for deleted file
139
+ if (lines.some((l) => l.startsWith("deleted file mode"))) {
140
+ return "deleted";
141
+ }
142
+ // Check for rename
143
+ if (lines.some((l) => l.startsWith("rename from")) || oldPath !== newPath) {
144
+ return "renamed";
145
+ }
146
+ return "modified";
147
+ }
148
+
149
+ /**
150
+ * Get a summary of the diff for logging.
151
+ */
152
+ export function getDiffSummary(diff: ParsedDiff): string {
153
+ const lines = [
154
+ `Files changed: ${diff.filesChanged}`,
155
+ `Additions: +${diff.totalAdditions}`,
156
+ `Deletions: -${diff.totalDeletions}`,
157
+ "",
158
+ "Files:",
159
+ ];
160
+
161
+ for (const file of diff.files) {
162
+ const path = file.newPath ?? file.oldPath ?? "unknown";
163
+ const stats = file.binary ? "(binary)" : `+${file.additions}/-${file.deletions}`;
164
+ lines.push(` ${file.status}: ${path} ${stats}`);
165
+ }
166
+
167
+ return lines.join("\n");
168
+ }