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