pi-gitnexus-fork 0.7.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.
Files changed (117) hide show
  1. package/.github/workflows/ci.yml +20 -0
  2. package/.gitnexusignore +11 -0
  3. package/.sg-rules/async-function-must-await-or-return.yml +55 -0
  4. package/.sg-rules/catch-must-log-error.yml +78 -0
  5. package/.sg-rules/class-must-implement-or-extend.yml +61 -0
  6. package/.sg-rules/class-property-must-be-readonly.yml +61 -0
  7. package/.sg-rules/error-must-extend-base.yml +56 -0
  8. package/.sg-rules/generic-must-be-constrained.yml +60 -0
  9. package/.sg-rules/import-reexport-risk.yml +9 -0
  10. package/.sg-rules/missing-session-id-in-api.yml +16 -0
  11. package/.sg-rules/no-any-in-generic-args.yml +57 -0
  12. package/.sg-rules/no-await-in-promise-all.yml +28 -0
  13. package/.sg-rules/no-barrel-export.yml +17 -0
  14. package/.sg-rules/no-bq-write-in-module.yml +65 -0
  15. package/.sg-rules/no-console-except-error.yml +27 -0
  16. package/.sg-rules/no-console-in-server.yml +42 -0
  17. package/.sg-rules/no-empty-catch.yml +20 -0
  18. package/.sg-rules/no-empty-function.yml +24 -0
  19. package/.sg-rules/no-eval.yml +28 -0
  20. package/.sg-rules/no-explicit-any.yml +34 -0
  21. package/.sg-rules/no-hardcoded-placeholder-string.yml +23 -0
  22. package/.sg-rules/no-hardcoded-secrets.yml +32 -0
  23. package/.sg-rules/no-innerHTML.yml +22 -0
  24. package/.sg-rules/no-json-parse-without-trycatch.yml +33 -0
  25. package/.sg-rules/no-magic-numbers.yml +25 -0
  26. package/.sg-rules/no-nested-ternary.yml +21 -0
  27. package/.sg-rules/no-non-null-assertion.yml +25 -0
  28. package/.sg-rules/no-stub-implementation.yml +44 -0
  29. package/.sg-rules/no-throw-literal.yml +50 -0
  30. package/.sg-rules/no-todo-comment.yml +24 -0
  31. package/.sg-rules/no-ts-ignore-comment.yml +48 -0
  32. package/.sg-rules/no-type-assertion-in-jsx.yml +23 -0
  33. package/.sg-rules/no-unguarded-trim.yml +24 -0
  34. package/.sg-rules/no-unknown-without-narrowing.yml +76 -0
  35. package/.sg-rules/no-unsafe-bracket-access.yml +58 -0
  36. package/.sg-rules/no-unsafe-type-assertion.yml +45 -0
  37. package/.sg-rules/switch-must-be-exhaustive.yml +62 -0
  38. package/.sg-rules/zod-async-refine-without-abort.yml +62 -0
  39. package/.sg-rules/zod-enum-unsafe-access.yml +59 -0
  40. package/.sg-rules/zod-nested-object-deep-path.yml +70 -0
  41. package/.sg-rules/zod-optional-without-default-in-route.yml +50 -0
  42. package/.sg-rules/zod-parse-not-safe.yml +42 -0
  43. package/.sg-rules/zod-preprocess-without-fallback.yml +58 -0
  44. package/.sg-rules/zod-refine-no-return-undefined.yml +54 -0
  45. package/.sg-rules/zod-transform-without-output-type.yml +52 -0
  46. package/.sg-sha +1 -0
  47. package/.sgignore +4 -0
  48. package/AGENTS.md +1 -0
  49. package/CHANGELOG.md +99 -0
  50. package/LICENSE +21 -0
  51. package/README.md +113 -0
  52. package/biome.json +25 -0
  53. package/coverage/base.css +224 -0
  54. package/coverage/block-navigation.js +87 -0
  55. package/coverage/clover.xml +890 -0
  56. package/coverage/coverage-final.json +12 -0
  57. package/coverage/favicon.png +0 -0
  58. package/coverage/index.html +131 -0
  59. package/coverage/prettify.css +1 -0
  60. package/coverage/prettify.js +2 -0
  61. package/coverage/sort-arrow-sprite.png +0 -0
  62. package/coverage/sorter.js +210 -0
  63. package/coverage/src/augment-remote.ts.html +274 -0
  64. package/coverage/src/gitnexus.ts.html +1363 -0
  65. package/coverage/src/index.html +236 -0
  66. package/coverage/src/index.ts.html +1561 -0
  67. package/coverage/src/mcp-client-factory.ts.html +367 -0
  68. package/coverage/src/mcp-client-stdio.ts.html +736 -0
  69. package/coverage/src/mcp-client.ts.html +568 -0
  70. package/coverage/src/remote-mcp-client.ts.html +709 -0
  71. package/coverage/src/repo-resolver.ts.html +526 -0
  72. package/coverage/src/tools.ts.html +970 -0
  73. package/coverage/src/ui/index.html +131 -0
  74. package/coverage/src/ui/main-menu.ts.html +502 -0
  75. package/coverage/src/ui/settings-menu.ts.html +460 -0
  76. package/dist/augment-remote.d.ts +11 -0
  77. package/dist/augment-remote.js +55 -0
  78. package/dist/gitnexus.d.ts +103 -0
  79. package/dist/gitnexus.js +410 -0
  80. package/dist/index.d.ts +2 -0
  81. package/dist/index.js +479 -0
  82. package/dist/mcp-client-factory.d.ts +19 -0
  83. package/dist/mcp-client-factory.js +78 -0
  84. package/dist/mcp-client-stdio.d.ts +35 -0
  85. package/dist/mcp-client-stdio.js +186 -0
  86. package/dist/mcp-client.d.ts +45 -0
  87. package/dist/mcp-client.js +145 -0
  88. package/dist/remote-mcp-client.d.ts +43 -0
  89. package/dist/remote-mcp-client.js +181 -0
  90. package/dist/repo-resolver.d.ts +47 -0
  91. package/dist/repo-resolver.js +123 -0
  92. package/dist/tools.d.ts +6 -0
  93. package/dist/tools.js +230 -0
  94. package/dist/ui/main-menu.d.ts +33 -0
  95. package/dist/ui/main-menu.js +102 -0
  96. package/dist/ui/settings-menu.d.ts +16 -0
  97. package/dist/ui/settings-menu.js +95 -0
  98. package/docs/design/remote-mcp-backend.md +153 -0
  99. package/media/screenshot.png +0 -0
  100. package/package.json +61 -0
  101. package/sgconfig.yml +4 -0
  102. package/skills/gitnexus-debugging/SKILL.md +84 -0
  103. package/skills/gitnexus-exploring/SKILL.md +73 -0
  104. package/skills/gitnexus-impact-analysis/SKILL.md +93 -0
  105. package/skills/gitnexus-pr-review/SKILL.md +109 -0
  106. package/skills/gitnexus-refactoring/SKILL.md +85 -0
  107. package/src/augment-remote.ts +63 -0
  108. package/src/gitnexus.ts +426 -0
  109. package/src/index.ts +492 -0
  110. package/src/mcp-client-factory.ts +94 -0
  111. package/src/mcp-client-stdio.ts +217 -0
  112. package/src/mcp-client.ts +208 -0
  113. package/src/remote-mcp-client.ts +250 -0
  114. package/src/repo-resolver.ts +147 -0
  115. package/src/tools.ts +295 -0
  116. package/src/ui/main-menu.ts +139 -0
  117. package/src/ui/settings-menu.ts +125 -0
@@ -0,0 +1,217 @@
1
+ import type { ChildProcess } from 'child_process';
2
+ import spawn from 'cross-spawn';
3
+ import { gitnexusCmd, MAX_OUTPUT_CHARS, spawnEnv } from './gitnexus';
4
+ import type { McpClient } from './mcp-client';
5
+ import { createMcpError } from './mcp-client';
6
+
7
+ // ── JSON-RPC / MCP types ──────────────────────────────────────────────────────
8
+
9
+ interface JsonRpcResponse {
10
+ jsonrpc: '2.0';
11
+ id?: number;
12
+ result?: unknown;
13
+ error?: { code: number; message: string };
14
+ }
15
+
16
+ interface McpContent {
17
+ type: string;
18
+ text?: string;
19
+ isError?: boolean;
20
+ }
21
+
22
+ interface McpToolResult {
23
+ content: McpContent[];
24
+ isError?: boolean;
25
+ }
26
+
27
+ // ── StdioMcpClient ────────────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Thin stdio JSON-RPC 2.0 client for `gitnexus mcp`.
31
+ *
32
+ * Communication is exclusively over the spawned process's stdin/stdout pipe —
33
+ * no network socket, no port. Only our process can write to the pipe.
34
+ *
35
+ * The MCP process is started lazily on the first callTool() invocation and
36
+ * kept alive for the session lifetime. stop() terminates it; the next callTool()
37
+ * re-spawns with the new cwd.
38
+ */
39
+ export class StdioMcpClient implements McpClient {
40
+ private proc: ChildProcess | null = null;
41
+ private buffer = '';
42
+ private pending = new Map<number, { resolve: (raw: string) => void; reject: (e: Error) => void }>();
43
+ private nextId = 2; // id 1 is reserved for the initialize handshake
44
+ private startPromise: Promise<void> | null = null;
45
+
46
+ /**
47
+ * Probe the local gitnexus binary. Returns true if it responds to --version.
48
+ * Static method for use by AutoMcpClient without instantiating.
49
+ */
50
+ static async probeLocalBinary(): Promise<boolean> {
51
+ return new Promise((resolve) => {
52
+ const [bin, ...baseArgs] = gitnexusCmd;
53
+ const proc = spawn(bin, [...baseArgs, '--version'], {
54
+ stdio: 'ignore',
55
+ env: spawnEnv,
56
+ });
57
+ proc.on('close', (code: number | null) => resolve(code === 0));
58
+ proc.on('error', () => resolve(false));
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Lazily spawn `gitnexus mcp` and complete the MCP initialize handshake.
64
+ * Idempotent — concurrent calls await the same promise; only one process spawns.
65
+ */
66
+ private ensureStarted(cwd: string): Promise<void> {
67
+ if (this.proc) return Promise.resolve();
68
+ if (this.startPromise) return this.startPromise;
69
+
70
+ this.startPromise = new Promise<void>((resolve_, reject) => {
71
+ const [bin, ...baseArgs] = gitnexusCmd;
72
+ const proc = spawn(bin, [...baseArgs, 'mcp'], {
73
+ cwd,
74
+ stdio: ['pipe', 'pipe', 'ignore'],
75
+ env: spawnEnv,
76
+ });
77
+
78
+ proc.on('error', (err) => {
79
+ this.startPromise = null;
80
+ reject(err);
81
+ });
82
+
83
+ proc.stdout!.setEncoding('utf8');
84
+ proc.stdout!.on('data', (chunk: string) => {
85
+ this.buffer += chunk;
86
+ const lines = this.buffer.split('\n');
87
+ this.buffer = lines.pop() ?? '';
88
+ for (const line of lines) {
89
+ if (!line.trim()) continue;
90
+ try {
91
+ const msg = JSON.parse(line) as JsonRpcResponse;
92
+ if (msg.id !== undefined) {
93
+ const p = this.pending.get(msg.id);
94
+ if (p) { this.pending.delete(msg.id); p.resolve(line); }
95
+ }
96
+ } catch { /* ignore malformed lines */ }
97
+ }
98
+ });
99
+
100
+ proc.on('close', () => {
101
+ this.proc = null;
102
+ this.startPromise = null;
103
+ for (const p of this.pending.values()) {
104
+ p.reject(new Error('gitnexus mcp process exited'));
105
+ }
106
+ this.pending.clear();
107
+ });
108
+
109
+ // MCP initialize handshake
110
+ const initMsg = JSON.stringify({
111
+ jsonrpc: '2.0',
112
+ id: 1,
113
+ method: 'initialize',
114
+ params: {
115
+ protocolVersion: '2024-11-05',
116
+ capabilities: {},
117
+ clientInfo: { name: 'pi-gitnexus', version: '0.1.0' },
118
+ },
119
+ });
120
+
121
+ this.pending.set(1, {
122
+ resolve: () => {
123
+ proc.stdin!.write(
124
+ JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'
125
+ );
126
+ this.proc = proc;
127
+ resolve_();
128
+ },
129
+ reject: (err) => {
130
+ this.startPromise = null;
131
+ reject(err);
132
+ },
133
+ });
134
+
135
+ proc.stdin!.write(initMsg + '\n');
136
+ });
137
+
138
+ return this.startPromise;
139
+ }
140
+
141
+ /**
142
+ * Call a gitnexus MCP tool and return its formatted text response.
143
+ * Starts the MCP process lazily if not already running.
144
+ */
145
+ async callTool(name: string, args: Record<string, unknown>, cwd: string): Promise<string> {
146
+ try {
147
+ await this.ensureStarted(cwd);
148
+ } catch (error) {
149
+ throw createMcpError(error, 'Failed to start gitnexus mcp');
150
+ }
151
+
152
+ if (!this.proc) throw createMcpError('GitNexus MCP is not running.');
153
+
154
+ const id = this.nextId++;
155
+ return new Promise<string>((resolve_, reject_) => {
156
+ this.pending.set(id, {
157
+ resolve: (raw: string) => {
158
+ try {
159
+ const msg = JSON.parse(raw) as JsonRpcResponse;
160
+ if (msg.error) {
161
+ reject_(createMcpError(msg.error.message));
162
+ return;
163
+ }
164
+ const result = msg.result as McpToolResult | undefined;
165
+ if (!result?.content) {
166
+ reject_(createMcpError('No response content returned from GitNexus MCP.'));
167
+ return;
168
+ }
169
+ const text = result.content
170
+ .filter((c) => c.type === 'text' && c.text)
171
+ .map((c) => c.text!)
172
+ .join('\n');
173
+ if (result.isError) {
174
+ reject_(createMcpError(text || 'GitNexus MCP reported an error with no text payload.'));
175
+ return;
176
+ }
177
+ if (!text) {
178
+ reject_(createMcpError('GitNexus MCP returned an empty response.'));
179
+ return;
180
+ }
181
+ resolve_('[GitNexus]\n' + text.slice(0, MAX_OUTPUT_CHARS));
182
+ } catch (error) {
183
+ reject_(createMcpError(error, 'Malformed response from GitNexus MCP.'));
184
+ }
185
+ },
186
+ reject: (error) => reject_(createMcpError(error)),
187
+ });
188
+
189
+ const msg = JSON.stringify({
190
+ jsonrpc: '2.0',
191
+ id,
192
+ method: 'tools/call',
193
+ params: { name, arguments: args },
194
+ });
195
+
196
+ try {
197
+ this.proc!.stdin!.write(msg + '\n');
198
+ } catch (error) {
199
+ this.pending.delete(id);
200
+ reject_(createMcpError(error, 'Failed to write request to GitNexus MCP.'));
201
+ }
202
+ });
203
+ }
204
+
205
+ /** Terminate the MCP process. Called on session_start so the next session gets a fresh process. */
206
+ stop(): void {
207
+ if (this.proc) {
208
+ this.proc.kill('SIGTERM');
209
+ this.proc = null;
210
+ }
211
+ this.startPromise = null;
212
+ for (const p of this.pending.values()) {
213
+ p.reject(new Error('MCP client stopped'));
214
+ }
215
+ this.pending.clear();
216
+ }
217
+ }
@@ -0,0 +1,208 @@
1
+ // ── JSON-RPC / MCP types ──────────────────────────────────────────────────────
2
+
3
+ interface JsonRpcResponse {
4
+ jsonrpc: "2.0";
5
+ id?: number;
6
+ result?: unknown;
7
+ error?: { code: number; message: string };
8
+ }
9
+
10
+ interface McpContent {
11
+ type: string;
12
+ text?: string;
13
+ isError?: boolean;
14
+ }
15
+
16
+ interface McpToolResult {
17
+ content: McpContent[];
18
+ isError?: boolean;
19
+ }
20
+
21
+ // ── McpClient interface ───────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Abstraction over MCP transport mechanisms (stdio, remote HTTP).
25
+ * Implementations must be safe for concurrent `callTool()` invocations.
26
+ */
27
+ export interface McpClient {
28
+ /** Call a named MCP tool with the given arguments, scoped to `cwd`. Returns formatted text. */
29
+ callTool(
30
+ name: string,
31
+ args: Record<string, unknown>,
32
+ cwd: string,
33
+ ): Promise<string>;
34
+ /** Terminate any open connections / processes. Idempotent. */
35
+ stop(): void;
36
+ }
37
+
38
+ // ── Error helper ──────────────────────────────────────────────────────────────
39
+
40
+ /** Create a consistently-prefixed error from an unknown value. */
41
+ export function createMcpError(
42
+ error: unknown,
43
+ fallback = "GitNexus MCP request failed.",
44
+ ): Error {
45
+ const message =
46
+ error instanceof Error
47
+ ? error.message
48
+ : typeof error === "string"
49
+ ? error
50
+ : fallback;
51
+ return new Error(`[GitNexus] ${message || fallback}`);
52
+ }
53
+
54
+ // ── Re-exports (lazy — avoids circular dependency) ─────────────────────────────
55
+ export { StdioMcpClient } from "./mcp-client-stdio";
56
+
57
+ // ── AutoMcpClient ─────────────────────────────────────────────────────────────
58
+
59
+ import type { McpMode } from "./gitnexus";
60
+ import { RemoteMcpClient } from "./remote-mcp-client";
61
+
62
+ /**
63
+ * Auto-detecting MCP client that probes the local binary on first use.
64
+ * Falls back to remote HTTP if the local binary fails.
65
+ * Once selected, the chosen transport is cached for the session.
66
+ */
67
+ export class AutoMcpClient implements McpClient {
68
+ private delegate: McpClient | null = null;
69
+ private probePromise: Promise<McpClient> | null = null;
70
+
71
+ /**
72
+ * Probe the local gitnexus binary. Returns true if it responds to --version.
73
+ */
74
+ static async probeLocalBinary(): Promise<boolean> {
75
+ const { StdioMcpClient } = await import("./mcp-client-stdio");
76
+ return StdioMcpClient.probeLocalBinary();
77
+ }
78
+
79
+ /** Resolve the transport. Probes local binary once, then caches the result. */
80
+ private async resolveClient(): Promise<McpClient> {
81
+ if (this.delegate) return this.delegate;
82
+ if (this.probePromise) return this.probePromise;
83
+
84
+ this.probePromise = (async () => {
85
+ const { McpClientFactory } = await import("./mcp-client-factory");
86
+ const { serverUrl } = McpClientFactory.loadConfig();
87
+
88
+ const localWorks = await AutoMcpClient.probeLocalBinary();
89
+ const client = localWorks
90
+ ? await McpClientFactory.createClient({ mode: "local" })
91
+ : new RemoteMcpClient({ serverUrl: serverUrl! });
92
+ this.delegate = client;
93
+ return client;
94
+ })();
95
+
96
+ return this.probePromise;
97
+ }
98
+
99
+ async callTool(
100
+ name: string,
101
+ args: Record<string, unknown>,
102
+ cwd: string,
103
+ ): Promise<string> {
104
+ const client = await this.resolveClient();
105
+ return client.callTool(name, args, cwd);
106
+ }
107
+
108
+ stop(): void {
109
+ if (this.delegate) {
110
+ this.delegate.stop();
111
+ this.delegate = null;
112
+ }
113
+ this.probePromise = null;
114
+ }
115
+ }
116
+
117
+ // ── Factory ───────────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Create an McpClient based on the configured mode.
121
+ *
122
+ * @param mode - Transport mode from config.
123
+ * @param serverUrl - Remote server URL (required for 'remote', optional for 'auto').
124
+ */
125
+ export function createMcpClient(mode: McpMode, serverUrl: string): McpClient {
126
+ switch (mode) {
127
+ case "remote":
128
+ return new RemoteMcpClient({ serverUrl });
129
+ case "auto":
130
+ return new AutoMcpClient();
131
+ default:
132
+ // Return a lazy proxy that defers StdioMcpClient instantiation.
133
+ // This avoids circular dependency: mcp-client-stdio imports from this module,
134
+ // so we can't eagerly import it here.
135
+ return createLazyStdioClient();
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Create a lazy proxy McpClient that defers StdioMcpClient instantiation
141
+ * until the first method call. Avoids circular dependency issues.
142
+ */
143
+ function createLazyStdioClient(): McpClient {
144
+ let _instance: McpClient | null = null;
145
+ let _pending: Promise<McpClient> | null = null;
146
+
147
+ const getInstance = async (): Promise<McpClient> => {
148
+ if (_instance) return _instance;
149
+ if (_pending) return _pending;
150
+ _pending = import("./mcp-client-stdio").then((mod) => {
151
+ _instance = new mod.StdioMcpClient();
152
+ return _instance;
153
+ });
154
+ return _pending;
155
+ };
156
+
157
+ return new Proxy({} as McpClient, {
158
+ get(_, prop) {
159
+ if (prop === "stop") {
160
+ return () => {
161
+ _instance?.stop();
162
+ };
163
+ }
164
+ if (prop === "callTool") {
165
+ return (name: string, args: Record<string, unknown>, cwd: string) =>
166
+ getInstance().then((c) => c.callTool(name, args, cwd));
167
+ }
168
+ return undefined;
169
+ },
170
+ });
171
+ }
172
+
173
+ // ── Singleton (config-aware, lazy) ─────────────────────────────────────────────
174
+
175
+ /**
176
+ * Singleton MCP client. Reads config on first use and selects transport:
177
+ * - mode=remote → RemoteMcpClient
178
+ * - mode=auto → AutoMcpClient (probe local, fallback remote)
179
+ * - mode=local (or missing) → StdioMcpClient
180
+ */
181
+ export const mcpClient: McpClient = new Proxy({} as McpClient, {
182
+ get(_, prop) {
183
+ if (prop === "stop") {
184
+ return () => {
185
+ _singletonInstance?.stop();
186
+ };
187
+ }
188
+ if (prop === "callTool") {
189
+ return (name: string, args: Record<string, unknown>, cwd: string) =>
190
+ getSingletonClient().then((c) => c.callTool(name, args, cwd));
191
+ }
192
+ return undefined;
193
+ },
194
+ });
195
+
196
+ let _singletonInstance: McpClient | null = null;
197
+ let _singletonPending: Promise<McpClient> | null = null;
198
+
199
+ function getSingletonClient(): Promise<McpClient> {
200
+ if (_singletonInstance) return Promise.resolve(_singletonInstance);
201
+ if (_singletonPending) return _singletonPending;
202
+ _singletonPending = import("./mcp-client-factory").then((mod) => {
203
+ const { mode, serverUrl } = mod.McpClientFactory.loadConfig();
204
+ _singletonInstance = createMcpClient(mode, serverUrl ?? "");
205
+ return _singletonInstance;
206
+ });
207
+ return _singletonPending;
208
+ }
@@ -0,0 +1,250 @@
1
+ import { MAX_OUTPUT_CHARS } from "./gitnexus";
2
+
3
+ const PREFIX = "[GitNexus]\n";
4
+
5
+ export interface RemoteMcpClientConfig {
6
+ serverUrl: string;
7
+ timeout?: number;
8
+ }
9
+
10
+ interface JsonRpcResponse {
11
+ jsonrpc: "2.0";
12
+ id?: number;
13
+ result?: unknown;
14
+ error?: { code: number; message: string };
15
+ }
16
+
17
+ interface McpContent {
18
+ type: string;
19
+ text?: string;
20
+ isError?: boolean;
21
+ }
22
+
23
+ interface McpToolResult {
24
+ content: McpContent[];
25
+ isError?: boolean;
26
+ }
27
+
28
+ /** Default timeout for a single MCP request (ms). */
29
+ const DEFAULT_TIMEOUT_MS = 30_000;
30
+
31
+ /**
32
+ * RemoteMcpClient — StreamableHTTP transport to a GitNexus MCP server.
33
+ *
34
+ * Uses native fetch() for all communication. Completes the MCP initialize
35
+ * handshake on the first callTool() invocation and reuses the session.
36
+ */
37
+ export class RemoteMcpClient {
38
+ private serverUrl: string;
39
+ private initialized = false;
40
+ private stopped = false;
41
+ private timeoutMs: number;
42
+ private initPromise: Promise<void> | null = null;
43
+ private sessionId: string | null = null;
44
+
45
+ /**
46
+ * @param configOrUrl - Either a config object `{ serverUrl, timeout? }` or a plain URL string.
47
+ */
48
+ constructor(configOrUrl: RemoteMcpClientConfig | string) {
49
+ if (typeof configOrUrl === "string") {
50
+ this.serverUrl = configOrUrl.replace(/\/+$/, "");
51
+ this.timeoutMs = DEFAULT_TIMEOUT_MS;
52
+ } else {
53
+ this.serverUrl = (configOrUrl.serverUrl ?? "").replace(/\/+$/, "");
54
+ this.timeoutMs = configOrUrl.timeout ?? DEFAULT_TIMEOUT_MS;
55
+ }
56
+ }
57
+
58
+ /** Returns the configured server URL. */
59
+ getServerUrl(): string {
60
+ return this.serverUrl;
61
+ }
62
+
63
+ /** Returns whether the MCP initialize handshake has been completed. */
64
+ isInitialized(): boolean {
65
+ return this.initialized;
66
+ }
67
+
68
+ /**
69
+ * POST a JSON-RPC message and return the parsed response.
70
+ * Throws on network errors, timeouts, or MCP-level errors.
71
+ */
72
+ private async rpcSend(
73
+ method: string,
74
+ params?: Record<string, unknown>,
75
+ ): Promise<JsonRpcResponse> {
76
+ if (this.stopped)
77
+ throw new Error("[GitNexus] Remote MCP client is stopped.");
78
+
79
+ const body = {
80
+ jsonrpc: "2.0" as const,
81
+ id: 1,
82
+ method,
83
+ ...(params !== undefined ? { params } : {}),
84
+ };
85
+
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
88
+
89
+ try {
90
+ const headers: Record<string, string> = {
91
+ "Content-Type": "application/json",
92
+ Accept: "application/json, text/event-stream",
93
+ };
94
+ if (this.sessionId) headers["Mcp-Session-Id"] = this.sessionId;
95
+
96
+ const res = await Promise.race([
97
+ fetch(this.serverUrl, {
98
+ method: "POST",
99
+ headers,
100
+ body: JSON.stringify(body),
101
+ signal: controller.signal,
102
+ }),
103
+ new Promise<never>((_, reject) =>
104
+ setTimeout(() => {
105
+ controller.abort();
106
+ reject(
107
+ new Error(
108
+ `[GitNexus] Remote MCP request timed out after ${this.timeoutMs}ms`,
109
+ ),
110
+ );
111
+ }, this.timeoutMs),
112
+ ),
113
+ ]);
114
+
115
+ if (!res.ok) {
116
+ throw new Error(`[GitNexus] HTTP ${res.status}`);
117
+ }
118
+
119
+ // Capture session ID for StreamableHTTP session stickiness
120
+ const sid = res.headers.get("mcp-session-id");
121
+ if (sid) this.sessionId = sid;
122
+
123
+ let data: JsonRpcResponse;
124
+ try {
125
+ data = (await res.json()) as JsonRpcResponse;
126
+ } catch {
127
+ throw new Error("[GitNexus] Malformed JSON response from remote MCP.");
128
+ }
129
+
130
+ if (data.error) {
131
+ throw new Error(
132
+ `[GitNexus] ${data.error.message || `MCP error ${data.error.code}`}`,
133
+ );
134
+ }
135
+
136
+ return data;
137
+ } catch (err) {
138
+ if (err instanceof Error && err.message.includes("timed out")) throw err;
139
+ if (err instanceof Error && err.message.startsWith("[GitNexus]"))
140
+ throw err;
141
+ throw new Error(
142
+ `[GitNexus] ${err instanceof Error ? err.message : String(err)}`,
143
+ );
144
+ } finally {
145
+ clearTimeout(timer);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Complete MCP initialize handshake (idempotent for concurrent callers).
151
+ * Only runs once per client lifetime until stop() is called.
152
+ */
153
+ private ensureInitialized(): Promise<void> {
154
+ if (this.initialized) return Promise.resolve();
155
+ if (this.stopped)
156
+ return Promise.reject(
157
+ new Error("[GitNexus] Remote MCP client is stopped."),
158
+ );
159
+
160
+ if (this.initPromise) return this.initPromise;
161
+
162
+ this.initPromise = (async () => {
163
+ try {
164
+ await this.rpcSend("initialize", {
165
+ protocolVersion: "2024-11-05",
166
+ capabilities: {},
167
+ clientInfo: { name: "pi-gitnexus", version: "0.1.0" },
168
+ });
169
+
170
+ // Send initialized notification (fire-and-forget)
171
+ try {
172
+ await fetch(this.serverUrl, {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify({
176
+ jsonrpc: "2.0",
177
+ method: "notifications/initialized",
178
+ }),
179
+ });
180
+ } catch {
181
+ /* best-effort */
182
+ }
183
+
184
+ this.initialized = true;
185
+ } catch (err) {
186
+ this.initPromise = null;
187
+ throw new Error(
188
+ `[GitNexus] Remote MCP initialization failed: ${err instanceof Error ? err.message : String(err)}`,
189
+ );
190
+ }
191
+ })();
192
+
193
+ return this.initPromise;
194
+ }
195
+
196
+ /**
197
+ * Call an MCP tool by name and return its formatted text response.
198
+ * Handles text content extraction, truncation, and error mapping.
199
+ */
200
+ async callTool(
201
+ name: string,
202
+ args: Record<string, unknown>,
203
+ _cwd?: string,
204
+ ): Promise<string> {
205
+ await this.ensureInitialized();
206
+
207
+ if (this.stopped)
208
+ throw new Error("[GitNexus] Remote MCP client is stopped.");
209
+
210
+ const response = await this.rpcSend("tools/call", {
211
+ name,
212
+ arguments: args,
213
+ });
214
+
215
+ const result = response.result as McpToolResult | undefined;
216
+
217
+ if (!result?.content) {
218
+ throw new Error(
219
+ "[GitNexus] No response content returned from remote MCP.",
220
+ );
221
+ }
222
+
223
+ const text = result.content
224
+ .filter((c) => c.type === "text" && c.text)
225
+ .map((c) => c.text!)
226
+ .join("\n");
227
+
228
+ if (result.isError) {
229
+ throw new Error(
230
+ `[GitNexus] ${text || "Remote MCP reported an error with no text payload."}`,
231
+ );
232
+ }
233
+
234
+ if (!text) {
235
+ throw new Error("[GitNexus] Remote MCP returned an empty response.");
236
+ }
237
+
238
+ // Truncate including the prefix so total stays within MAX_OUTPUT_CHARS
239
+ const available = MAX_OUTPUT_CHARS - PREFIX.length;
240
+ return PREFIX + text.slice(0, Math.max(0, available));
241
+ }
242
+
243
+ /** Mark as stopped. Resets initialization state so next call re-initializes. */
244
+ stop(): void {
245
+ this.stopped = false; // Allow re-use after stop
246
+ this.initialized = false;
247
+ this.initPromise = null;
248
+ this.sessionId = null;
249
+ }
250
+ }