@imenam/mcp-feature-analyzer 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ import { type RepoAccess } from "./git.js";
2
+ import { type Run } from "./gateway-exec.js";
3
+ /** Accès au dépôt `repoPath` de la machine de l'agent, par les commandes que `run` y fait exécuter. */
4
+ export declare function agentRepo(repoPath: string, run: Run): RepoAccess;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Accès au dépôt placé sur la machine de l'agent, quand ce serveur tourne derrière
3
+ * mcp-http-gateway : chaque commande est exécutée par le relais agent (voir
4
+ * gateway-exec.ts), dans `repoPath`, et sa sortie revient dans le rappel de l'outil.
5
+ *
6
+ * - git lit le dépôt ; `node` lit les fichiers de la copie de travail.
7
+ * - La sortie d'une commande est limitée à 1 Mio par le relais : le diff est demandé
8
+ * fichier par fichier, et un contenu tronqué est rendu `null`.
9
+ * - Les commandes d'une même demande sont groupées par paquets de STEPS_PER_ROUND, pour
10
+ * borner la taille de chaque rappel.
11
+ */
12
+ import path from "path";
13
+ import { toPosixPath } from "./diff-parser.js";
14
+ import { GitError } from "./errors.js";
15
+ import { DIFF_ARGS, assertRefShape } from "./git.js";
16
+ import { RELAY_MAX_OUTPUT_BYTES, isTruncated } from "./gateway-exec.js";
17
+ /** Nombre maximal de commandes par demande d'exécution. */
18
+ const STEPS_PER_ROUND = 25;
19
+ /** Variable d'environnement qui porte le chemin du fichier à lire. */
20
+ const FILE_ENV = "FEATURE_ANALYZER_FILE";
21
+ /** Écrit sur stdout le contenu du fichier FILE_ENV, ou la cible POSIX d'un lien symbolique. */
22
+ const READ_FILE_SCRIPT = [
23
+ 'const fs = require("fs");',
24
+ `const file = process.env.${FILE_ENV};`,
25
+ "process.stdout.write(fs.lstatSync(file).isSymbolicLink()",
26
+ ' ? fs.readlinkSync(file).split(String.fromCharCode(92)).join("/")',
27
+ " : fs.readFileSync(file));",
28
+ ].join("\n");
29
+ const MIB = `${RELAY_MAX_OUTPUT_BYTES / (1024 * 1024)} MiB`;
30
+ function detail(result) {
31
+ return (result.error ?? (result.stderr.trim() || result.stdout.trim())) || "no output";
32
+ }
33
+ /** Accès au dépôt `repoPath` de la machine de l'agent, par les commandes que `run` y fait exécuter. */
34
+ export function agentRepo(repoPath, run) {
35
+ function gitStep(args) {
36
+ return {
37
+ command: "git",
38
+ args: ["-c", "core.quotepath=false", ...args],
39
+ cwd: repoPath,
40
+ env: { GIT_LITERAL_PATHSPECS: "1", GIT_TERMINAL_PROMPT: "0" },
41
+ };
42
+ }
43
+ function readFileStep(filePath) {
44
+ return {
45
+ command: "node",
46
+ args: ["--input-type=commonjs", "-e", READ_FILE_SCRIPT],
47
+ cwd: repoPath,
48
+ env: { [FILE_ENV]: filePath },
49
+ };
50
+ }
51
+ /** Échec de lancement d'une commande : dossier absent ou exécutable introuvable. */
52
+ function launchError(step, result) {
53
+ return new GitError(`Could not run ${step.command} in "${repoPath}" on the agent's machine (${result.error}). ` +
54
+ `Check that repo_path exists on that machine and that ${step.command} is on its PATH.`);
55
+ }
56
+ /** Exécute une seule commande et rend son résultat ; un échec de lancement lève une erreur. */
57
+ async function runOne(step) {
58
+ const [result] = await run([step]);
59
+ if (!result)
60
+ throw new GitError(`The agent relay returned no result for ${step.command} ${step.args.join(" ")}.`);
61
+ if (result.error !== undefined)
62
+ throw launchError(step, result);
63
+ return result;
64
+ }
65
+ /** Exécute une commande git qui doit réussir, avec une sortie complète. */
66
+ async function runGit(args) {
67
+ const result = await runOne(gitStep(args));
68
+ if (result.code !== 0)
69
+ throw new GitError(`git ${args.join(" ")} failed in ${repoPath} on the agent's machine: ${detail(result)}`);
70
+ if (isTruncated(result.stdout)) {
71
+ throw new GitError(`The output of git ${args.join(" ")} exceeds ${MIB}, the output limit of the agent relay.`);
72
+ }
73
+ return result.stdout;
74
+ }
75
+ /**
76
+ * Exécute une commande par élément, par paquets. Le relais s'arrête à la première
77
+ * commande en échec : `onFailure` rend l'erreur à lever pour cet élément.
78
+ */
79
+ async function runEach(items, toStep, onFailure) {
80
+ const all = [];
81
+ for (let start = 0; start < items.length; start += STEPS_PER_ROUND) {
82
+ const chunk = items.slice(start, start + STEPS_PER_ROUND);
83
+ const steps = chunk.map(toStep);
84
+ const results = await run(steps);
85
+ chunk.forEach((item, index) => {
86
+ const result = results[index];
87
+ if (!result)
88
+ throw new GitError(`The agent relay returned no result for ${steps[index].command} ${steps[index].args.join(" ")}.`);
89
+ if (result.error !== undefined)
90
+ throw launchError(steps[index], result);
91
+ if (result.code !== 0)
92
+ throw onFailure(item, result);
93
+ all.push(result);
94
+ });
95
+ }
96
+ return all;
97
+ }
98
+ /** Contenu rendu par le relais, ou null s'il a été tronqué. */
99
+ function contentOf(result) {
100
+ return isTruncated(result.stdout) ? null : Buffer.from(result.stdout, "utf-8");
101
+ }
102
+ return {
103
+ async assertRepoRoot() {
104
+ if (!path.posix.isAbsolute(repoPath) && !path.win32.isAbsolute(repoPath)) {
105
+ throw new GitError(`repo_path must be an absolute path on the agent's machine, got "${repoPath}".`);
106
+ }
107
+ const args = ["rev-parse", "--show-toplevel", "--show-cdup"];
108
+ const result = await runOne(gitStep(args));
109
+ if (result.code !== 0) {
110
+ if (/not a git repository/i.test(result.stderr)) {
111
+ throw new GitError(`repo_path "${repoPath}" is not inside a git repository.`);
112
+ }
113
+ throw new GitError(`git ${args.join(" ")} failed in ${repoPath} on the agent's machine: ${detail(result)}`);
114
+ }
115
+ const [topLevel, cdup] = result.stdout.split("\n");
116
+ if (cdup.trim() !== "") {
117
+ throw new GitError(`repo_path "${repoPath}" is not the root of its git repository. Use the repository root: ${topLevel.trim()}`);
118
+ }
119
+ },
120
+ async resolveCommit(ref, role) {
121
+ assertRefShape(ref, role);
122
+ const result = await runOne(gitStep(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]));
123
+ if (result.code !== 0) {
124
+ throw new GitError(`Unknown ${role} ref "${ref}" in ${repoPath}. Use a branch name, a tag or a commit sha that exists in this repository.`);
125
+ }
126
+ return result.stdout.trim();
127
+ },
128
+ async diff(range) {
129
+ const listing = await runGit([
130
+ "diff",
131
+ "--no-color",
132
+ "--no-ext-diff",
133
+ "--no-relative",
134
+ "--find-renames",
135
+ "--name-status",
136
+ "-z",
137
+ ...range,
138
+ "--",
139
+ ]);
140
+ const tokens = listing.split("\0");
141
+ const files = [];
142
+ for (let index = 0; index < tokens.length && tokens[index] !== "";) {
143
+ const pathCount = /^[RC]/.test(tokens[index]) ? 2 : 1;
144
+ files.push(tokens.slice(index + 1, index + 1 + pathCount));
145
+ index += 1 + pathCount;
146
+ }
147
+ const results = await runEach(files, (paths) => gitStep([...DIFF_ARGS, ...range, "--", ...paths]), (paths, result) => new GitError(`git diff of "${paths.join('" -> "')}" failed on the agent's machine: ${detail(result)}`));
148
+ return results
149
+ .map((result, index) => {
150
+ if (isTruncated(result.stdout)) {
151
+ throw new GitError(`The diff of "${files[index].at(-1)}" exceeds ${MIB}, the output limit of the agent relay: this change cannot be analysed through the gateway.`);
152
+ }
153
+ return result.stdout;
154
+ })
155
+ .join("");
156
+ },
157
+ async readCommitFiles(commit, paths) {
158
+ const results = await runEach(paths, (filePath) => gitStep(["cat-file", "blob", `${commit}:${filePath}`]), (filePath) => new GitError(`"${filePath}" is in the diff but is not a file of commit ${commit} (submodule or unreadable entry).`));
159
+ return new Map(paths.map((filePath, index) => [filePath, contentOf(results[index])]));
160
+ },
161
+ async untrackedFiles() {
162
+ return (await runGit(["ls-files", "--others", "--exclude-standard", "-z"]))
163
+ .split("\0")
164
+ .filter((entry) => entry !== "")
165
+ .map(toPosixPath);
166
+ },
167
+ async readWorkingFiles(paths) {
168
+ const results = await runEach(paths, readFileStep, (filePath, result) => new GitError(`Could not read "${filePath}" in ${repoPath} on the agent's machine: ${detail(result)}`));
169
+ return new Map(paths.map((filePath, index) => [filePath, contentOf(results[index])]));
170
+ },
171
+ };
172
+ }
@@ -0,0 +1,27 @@
1
+ import type { ToolResult } from "../tools/types.js";
2
+ export declare const EXEC_ARG = "_gateway_exec";
3
+ export declare const EXEC_META = "gateway/exec";
4
+ export declare const EXEC_VERSION = 1;
5
+ /** Taille maximale de la sortie d'une étape conservée par le relais agent. */
6
+ export declare const RELAY_MAX_OUTPUT_BYTES: number;
7
+ export interface ExecStep {
8
+ command: string;
9
+ args: string[];
10
+ cwd?: string;
11
+ env?: Record<string, string>;
12
+ }
13
+ export interface StepResult {
14
+ code: number | null;
15
+ stdout: string;
16
+ stderr: string;
17
+ error?: string;
18
+ }
19
+ export type Run = (steps: ExecStep[]) => Promise<StepResult[]>;
20
+ /** Vrai si le relais agent a tronqué cette sortie. */
21
+ export declare function isTruncated(output: string): boolean;
22
+ /**
23
+ * Point d'entrée d'un outil qui s'exécute sur la machine de l'agent. Au premier appel,
24
+ * démarre `tool` ; aux rappels du relais agent, reprend la fonction là où elle attend.
25
+ * `exec` est la valeur de l'argument `_gateway_exec` de l'appel.
26
+ */
27
+ export declare function handleAgentExec(exec: unknown, tool: (run: Run) => Promise<ToolResult>): Promise<ToolResult>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Exécution de commandes sur la machine de l'agent, par le relais agent de
3
+ * mcp-http-gateway (`mcp-http-gateway --mcp`).
4
+ *
5
+ * Convention, entre le relais agent et un MCP placé derrière le gateway :
6
+ *
7
+ * - Quand la route de ce MCP figure dans sa liste blanche (GATEWAY_EXEC_ROUTES), le
8
+ * relais agent ajoute à chaque appel d'outil l'argument réservé `_gateway_exec` :
9
+ * `{ version, cwd, gatewayToken }`. `cwd` est son dossier de lancement.
10
+ * - Le MCP peut répondre par une demande d'exécution au lieu d'un résultat final :
11
+ * `_meta["gateway/exec"] = { id, steps }`. Chaque étape est `{ command, args, cwd, env }`,
12
+ * lancée sans shell ; `cwd` est résolu par rapport au dossier de lancement du relais.
13
+ * - Le relais agent exécute les étapes dans l'ordre et s'arrête à la première qui
14
+ * échoue. Il rappelle alors le même outil avec
15
+ * `_gateway_exec.continuation = { id, results }`, un résultat par étape exécutée.
16
+ * La sortie standard de chaque étape est tronquée au-delà de 1 Mio.
17
+ *
18
+ * Un outil s'écrit comme une fonction asynchrone qui appelle `run(steps)` : chaque
19
+ * appel suspend la fonction jusqu'au rappel du relais agent qui porte les résultats.
20
+ */
21
+ import { randomUUID } from "crypto";
22
+ export const EXEC_ARG = "_gateway_exec";
23
+ export const EXEC_META = "gateway/exec";
24
+ export const EXEC_VERSION = 1;
25
+ /** Taille maximale de la sortie d'une étape conservée par le relais agent. */
26
+ export const RELAY_MAX_OUTPUT_BYTES = 1024 * 1024;
27
+ /** Mention ajoutée par le relais agent à la fin d'une sortie tronquée. */
28
+ const TRUNCATION_MARK = /\n\[sortie tronquée à \d+ octets\]$/;
29
+ /** Délai laissé au relais agent pour rendre les résultats d'une demande. */
30
+ const CONTINUATION_TTL_MS = 30 * 60 * 1000;
31
+ /** Vrai si le relais agent a tronqué cette sortie. */
32
+ export function isTruncated(output) {
33
+ return TRUNCATION_MARK.test(output);
34
+ }
35
+ const flows = new Map();
36
+ function nextEvent(flow) {
37
+ return new Promise((resolve) => {
38
+ flow.emit = resolve;
39
+ });
40
+ }
41
+ function toToolResult(flow, event) {
42
+ if (event.kind === "done") {
43
+ flows.delete(flow.id);
44
+ return event.result;
45
+ }
46
+ if (event.kind === "failed") {
47
+ flows.delete(flow.id);
48
+ throw event.error;
49
+ }
50
+ flow.steps = event.steps;
51
+ flow.timer = setTimeout(() => {
52
+ flows.delete(flow.id);
53
+ flow.abort?.(new Error("Timed out waiting for the agent relay to return the command results."));
54
+ }, CONTINUATION_TTL_MS);
55
+ flow.timer.unref();
56
+ return {
57
+ content: [{ type: "text", text: "Command execution request for the agent's machine, to be handled by the agent relay." }],
58
+ _meta: { [EXEC_META]: { id: flow.id, steps: event.steps } },
59
+ };
60
+ }
61
+ function assertAnnounced(raw) {
62
+ if (!raw || typeof raw !== "object") {
63
+ const route = process.env.MCP_SERVER_ROUTE?.trim() || "the route of this MCP";
64
+ throw new Error("This tool runs git on the agent's machine, through the agent relay (mcp-http-gateway --mcp), " +
65
+ `and the relay did not offer command execution: add ${route} to GATEWAY_EXEC_ROUTES in the "env" block ` +
66
+ "of the relay configuration, with a version of mcp-http-gateway that supports it.");
67
+ }
68
+ if (raw.version !== EXEC_VERSION) {
69
+ throw new Error(`Unsupported agent relay execution version: ${raw.version} (expected ${EXEC_VERSION}).`);
70
+ }
71
+ }
72
+ function parseResults(raw, steps) {
73
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > steps.length) {
74
+ throw new Error("Invalid command results from the agent relay: one result is expected per step run.");
75
+ }
76
+ return raw.map((r) => ({
77
+ code: typeof r?.code === "number" ? r.code : null,
78
+ stdout: typeof r?.stdout === "string" ? r.stdout : "",
79
+ stderr: typeof r?.stderr === "string" ? r.stderr : "",
80
+ ...(typeof r?.error === "string" ? { error: r.error } : {}),
81
+ }));
82
+ }
83
+ /**
84
+ * Point d'entrée d'un outil qui s'exécute sur la machine de l'agent. Au premier appel,
85
+ * démarre `tool` ; aux rappels du relais agent, reprend la fonction là où elle attend.
86
+ * `exec` est la valeur de l'argument `_gateway_exec` de l'appel.
87
+ */
88
+ export async function handleAgentExec(exec, tool) {
89
+ assertAnnounced(exec);
90
+ const continuation = exec.continuation;
91
+ if (continuation) {
92
+ const flow = flows.get(continuation.id);
93
+ if (!flow || !flow.resume || !flow.steps) {
94
+ throw new Error("Unknown or expired execution continuation (the MCP server may have restarted): call the tool again.");
95
+ }
96
+ const results = parseResults(continuation.results, flow.steps);
97
+ clearTimeout(flow.timer);
98
+ const resume = flow.resume;
99
+ flow.resume = undefined;
100
+ flow.abort = undefined;
101
+ const event = nextEvent(flow);
102
+ resume(results);
103
+ return toToolResult(flow, await event);
104
+ }
105
+ const flow = { id: randomUUID(), emit: () => { } };
106
+ flows.set(flow.id, flow);
107
+ const event = nextEvent(flow);
108
+ const run = (steps) => new Promise((resolve, reject) => {
109
+ flow.resume = resolve;
110
+ flow.abort = reject;
111
+ flow.emit({ kind: "exec", steps });
112
+ });
113
+ tool(run).then((result) => flow.emit({ kind: "done", result }), (error) => flow.emit({ kind: "failed", error }));
114
+ return toToolResult(flow, await event);
115
+ }
@@ -26,10 +26,35 @@ export interface ComputedSnapshot {
26
26
  diff: FileDiff[];
27
27
  }
28
28
  /**
29
- * Calcule le snapshot d'une analyse avec git.
29
+ * Accès au dépôt sur la machine qui le porte : git et copie de travail.
30
+ * Un contenu `null` dépasse ce que l'accès sait transférer.
31
+ */
32
+ export interface RepoAccess {
33
+ /** Vérifie que le chemin du dépôt désigne la racine d'un dépôt git. */
34
+ assertRepoRoot(): Promise<void>;
35
+ /** Résout une ref en sha de commit ; erreur explicite si elle est inconnue. */
36
+ resolveCommit(ref: string, role: string): Promise<string>;
37
+ /** Sortie de `git diff` (DIFF_ARGS) pour `range`, par ex. `["<base>...<head>"]` ou `["HEAD"]`. */
38
+ diff(range: string[]): Promise<string>;
39
+ /** Contenu des fichiers `paths` dans le commit `commit`. */
40
+ readCommitFiles(commit: string, paths: string[]): Promise<Map<string, Buffer | null>>;
41
+ /** Fichiers non suivis et non ignorés, chemins POSIX relatifs au dépôt. */
42
+ untrackedFiles(): Promise<string[]>;
43
+ /** Contenu des fichiers `paths` de la copie de travail ; un lien symbolique donne sa cible. */
44
+ readWorkingFiles(paths: string[]): Promise<Map<string, Buffer | null>>;
45
+ }
46
+ export declare const DIFF_ARGS: string[];
47
+ /** Vrai si le contenu contient un octet nul dans ses 8000 premiers octets (même règle que git). */
48
+ export declare function looksBinary(content: Buffer): boolean;
49
+ /** Accès direct au dépôt, sur la machine de ce serveur. */
50
+ export declare function localRepo(repoPath: string): RepoAccess;
51
+ /** Vérifie une ref fournie par l'agent avant de la passer à git. */
52
+ export declare function assertRefShape(ref: string, role: string): void;
53
+ /**
54
+ * Calcule le snapshot d'une analyse avec git, par `repo` (accès local par défaut).
30
55
  * - `branch` : diff `base...head` (head vaut `HEAD` par défaut), contenu lu dans le commit de tête ;
31
56
  * - `working_tree` : diff de HEAD vers la copie de travail, index compris, fichiers non suivis
32
57
  * présentés comme ajoutés, contenu lu sur disque.
33
58
  * Les fichiers sont triés par chemin ; `reviewed` vaut false partout.
34
59
  */
35
- export declare function computeSnapshot(request: SnapshotRequest): Promise<ComputedSnapshot>;
60
+ export declare function computeSnapshot(request: SnapshotRequest, repo?: RepoAccess): Promise<ComputedSnapshot>;
@@ -57,9 +57,7 @@ async function assertRepoRoot(repoPath) {
57
57
  }
58
58
  /** Résout une ref en sha de commit ; erreur explicite si elle est inconnue. */
59
59
  async function resolveCommit(repoPath, ref, role) {
60
- if (ref.startsWith("-")) {
61
- throw new GitError(`Invalid ${role} ref "${ref}": a ref cannot start with "-".`);
62
- }
60
+ assertRefShape(ref, role);
63
61
  try {
64
62
  return (await runGit(repoPath, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], "utf-8")).trim();
65
63
  }
@@ -67,7 +65,7 @@ async function resolveCommit(repoPath, ref, role) {
67
65
  throw new GitError(`Unknown ${role} ref "${ref}" in ${repoPath}. Use a branch name, a tag or a commit sha that exists in this repository.`);
68
66
  }
69
67
  }
70
- const DIFF_ARGS = [
68
+ export const DIFF_ARGS = [
71
69
  "-c",
72
70
  "diff.noprefix=false",
73
71
  "-c",
@@ -82,7 +80,7 @@ const DIFF_ARGS = [
82
80
  "--dst-prefix=b/",
83
81
  ];
84
82
  /** Vrai si le contenu contient un octet nul dans ses 8000 premiers octets (même règle que git). */
85
- function looksBinary(content) {
83
+ export function looksBinary(content) {
86
84
  return content.subarray(0, 8000).includes(0);
87
85
  }
88
86
  /** Hunk unique d'un fichier ajouté, toutes lignes en ajout. */
@@ -108,9 +106,9 @@ function readWorkingFile(repoPath, filePath) {
108
106
  return Buffer.from(toPosixPath(fs.readlinkSync(absolute)), "utf-8");
109
107
  return fs.readFileSync(absolute);
110
108
  }
111
- /** Texte conservé dans le snapshot, ou null quand le contenu dépasse MAX_SNAPSHOT_FILE_BYTES. */
109
+ /** Texte conservé dans le snapshot, ou null quand le contenu est absent ou dépasse MAX_SNAPSHOT_FILE_BYTES. */
112
110
  function snapshotText(content) {
113
- if (content.length > MAX_SNAPSHOT_FILE_BYTES)
111
+ if (content == null || content.length > MAX_SNAPSHOT_FILE_BYTES)
114
112
  return null;
115
113
  return content.toString("utf-8");
116
114
  }
@@ -172,33 +170,49 @@ async function readCommitFiles(repoPath, commit, paths) {
172
170
  });
173
171
  return contents;
174
172
  }
175
- async function branchSnapshot(repoPath, base, head) {
176
- const baseCommit = await resolveCommit(repoPath, base, "base");
177
- const headCommit = await resolveCommit(repoPath, head, "head");
178
- const output = await runGit(repoPath, [...DIFF_ARGS, `${baseCommit}...${headCommit}`, "--"], "utf-8");
173
+ /** Accès direct au dépôt, sur la machine de ce serveur. */
174
+ export function localRepo(repoPath) {
175
+ return {
176
+ assertRepoRoot: () => assertRepoRoot(repoPath),
177
+ resolveCommit: (ref, role) => resolveCommit(repoPath, ref, role),
178
+ diff: (range) => runGit(repoPath, [...DIFF_ARGS, ...range, "--"], "utf-8"),
179
+ readCommitFiles: (commit, paths) => readCommitFiles(repoPath, commit, paths),
180
+ untrackedFiles: async () => (await runGit(repoPath, ["ls-files", "--others", "--exclude-standard", "-z"], "utf-8"))
181
+ .split("\0")
182
+ .filter((entry) => entry !== "")
183
+ .map(toPosixPath),
184
+ readWorkingFiles: async (paths) => new Map(paths.map((filePath) => [filePath, readWorkingFile(repoPath, filePath)])),
185
+ };
186
+ }
187
+ /** Vérifie une ref fournie par l'agent avant de la passer à git. */
188
+ export function assertRefShape(ref, role) {
189
+ if (ref.startsWith("-")) {
190
+ throw new GitError(`Invalid ${role} ref "${ref}": a ref cannot start with "-".`);
191
+ }
192
+ }
193
+ async function branchSnapshot(repo, base, head) {
194
+ const baseCommit = await repo.resolveCommit(base, "base");
195
+ const headCommit = await repo.resolveCommit(head, "head");
196
+ const output = await repo.diff([`${baseCommit}...${headCommit}`]);
179
197
  const parsedFiles = parseUnifiedDiff(output);
180
198
  const withContent = parsedFiles.filter((parsed) => parsed.status !== "deleted" && !parsed.binary).map((parsed) => parsed.path);
181
- const contents = await readCommitFiles(repoPath, headCommit, withContent);
182
- const results = parsedFiles.map((parsed) => {
183
- const content = contents.get(parsed.path);
184
- return toEntry(parsed, content === undefined ? null : snapshotText(content));
185
- });
199
+ const contents = await repo.readCommitFiles(headCommit, withContent);
200
+ const results = parsedFiles.map((parsed) => toEntry(parsed, snapshotText(contents.get(parsed.path))));
186
201
  return { baseCommit, headCommit, results };
187
202
  }
188
- async function workingTreeSnapshot(repoPath) {
189
- const baseCommit = await resolveCommit(repoPath, "HEAD", "base");
190
- const output = await runGit(repoPath, [...DIFF_ARGS, "HEAD", "--"], "utf-8");
191
- const results = [];
192
- for (const parsed of parseUnifiedDiff(output)) {
193
- const content = parsed.status !== "deleted" && !parsed.binary ? snapshotText(readWorkingFile(repoPath, parsed.path)) : null;
194
- results.push(toEntry(parsed, content));
195
- }
196
- const untracked = (await runGit(repoPath, ["ls-files", "--others", "--exclude-standard", "-z"], "utf-8"))
197
- .split("\0")
198
- .filter((entry) => entry !== "")
199
- .map(toPosixPath);
203
+ async function workingTreeSnapshot(repo) {
204
+ const baseCommit = await repo.resolveCommit("HEAD", "base");
205
+ const parsedFiles = parseUnifiedDiff(await repo.diff(["HEAD"]));
206
+ const withContent = parsedFiles.filter((parsed) => parsed.status !== "deleted" && !parsed.binary).map((parsed) => parsed.path);
207
+ const contents = await repo.readWorkingFiles(withContent);
208
+ const results = parsedFiles.map((parsed) => toEntry(parsed, snapshotText(contents.get(parsed.path))));
209
+ const untracked = await repo.untrackedFiles();
210
+ const untrackedContents = await repo.readWorkingFiles(untracked);
200
211
  for (const filePath of untracked) {
201
- const raw = readWorkingFile(repoPath, filePath);
212
+ const raw = untrackedContents.get(filePath);
213
+ if (raw == null) {
214
+ throw new GitError(`The untracked file "${filePath}" is too large to be transferred from the repository's machine. Add it to .gitignore or remove it, then try again.`);
215
+ }
202
216
  const binary = looksBinary(raw);
203
217
  const content = binary ? null : snapshotText(raw);
204
218
  const lines = binary ? [] : splitLines(raw.toString("utf-8"));
@@ -208,15 +222,15 @@ async function workingTreeSnapshot(repoPath) {
208
222
  return { baseCommit, headCommit: null, results };
209
223
  }
210
224
  /**
211
- * Calcule le snapshot d'une analyse avec git.
225
+ * Calcule le snapshot d'une analyse avec git, par `repo` (accès local par défaut).
212
226
  * - `branch` : diff `base...head` (head vaut `HEAD` par défaut), contenu lu dans le commit de tête ;
213
227
  * - `working_tree` : diff de HEAD vers la copie de travail, index compris, fichiers non suivis
214
228
  * présentés comme ajoutés, contenu lu sur disque.
215
229
  * Les fichiers sont triés par chemin ; `reviewed` vaut false partout.
216
230
  */
217
- export async function computeSnapshot(request) {
231
+ export async function computeSnapshot(request, repo = localRepo(request.repoPath)) {
218
232
  const { repoPath, mode } = request;
219
- await assertRepoRoot(repoPath);
233
+ await repo.assertRepoRoot();
220
234
  let base;
221
235
  let head;
222
236
  let computed;
@@ -226,7 +240,7 @@ export async function computeSnapshot(request) {
226
240
  throw new GitError(`"base" is required in branch mode: give the ref the feature branched from.`);
227
241
  base = requestedBase;
228
242
  head = request.head?.trim() || DEFAULT_HEAD_REF;
229
- computed = await branchSnapshot(repoPath, base, head);
243
+ computed = await branchSnapshot(repo, base, head);
230
244
  }
231
245
  else if (mode === "working_tree") {
232
246
  if (request.head != null && request.head.trim() !== "") {
@@ -237,7 +251,7 @@ export async function computeSnapshot(request) {
237
251
  }
238
252
  base = "HEAD";
239
253
  head = null;
240
- computed = await workingTreeSnapshot(repoPath);
254
+ computed = await workingTreeSnapshot(repo);
241
255
  }
242
256
  else {
243
257
  throw new GitError(`Unknown mode "${String(mode)}": use "branch" or "working_tree".`);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Rôle du serveur, fixé par la variable MCP_FEATURE_ANALYZER_ROLE.
3
+ *
4
+ * - `standalone` (défaut) : le serveur tourne sur la machine qui porte les dépôts et y
5
+ * exécute git lui-même.
6
+ * - `remote` : le serveur tourne derrière mcp-http-gateway, sur une autre machine ; git
7
+ * et la lecture des fichiers s'exécutent sur la machine de l'agent, par le relais agent
8
+ * (`mcp-http-gateway --mcp`, route listée dans GATEWAY_EXEC_ROUTES).
9
+ */
10
+ export type Role = "standalone" | "remote";
11
+ export declare function resolveRole(): Role;
@@ -0,0 +1,9 @@
1
+ const ROLES = ["standalone", "remote"];
2
+ export function resolveRole() {
3
+ const raw = process.env.MCP_FEATURE_ANALYZER_ROLE?.trim();
4
+ if (!raw)
5
+ return "standalone";
6
+ if (ROLES.includes(raw))
7
+ return raw;
8
+ throw new Error(`MCP_FEATURE_ANALYZER_ROLE="${raw}" is invalid (expected: ${ROLES.join(" | ")}).`);
9
+ }
package/dist/src/index.js CHANGED
@@ -12,7 +12,10 @@ import { resolveDataDir } from "./core/data-dir.js";
12
12
  import { resolveLogDir } from "./core/log-dir.js";
13
13
  import { AnalysisStore } from "./core/analysis-store.js";
14
14
  import { AnalysisError, NotFoundError, RevisionConflictError } from "./core/errors.js";
15
- import { DEFAULT_HEAD_REF } from "./core/git.js";
15
+ import { DEFAULT_HEAD_REF, localRepo } from "./core/git.js";
16
+ import { agentRepo } from "./core/agent-repo.js";
17
+ import { EXEC_ARG, handleAgentExec } from "./core/gateway-exec.js";
18
+ import { resolveRole } from "./core/role.js";
16
19
  import { addNote, deleteNote, setFileReviewed, setFindingStatus, submitReview } from "./core/review-actions.js";
17
20
  import { GUI_REQUEST_TYPES, IPCMessageSchema } from "../shared/schemas/ipc.schema.js";
18
21
  import { AddNoteRequestSchema, AnalysisRequestSchema, DeleteNoteRequestSchema, SetFileReviewedRequestSchema, SetFindingStatusRequestSchema, SubmitReviewRequestSchema, } from "../shared/schemas/api.schema.js";
@@ -66,6 +69,15 @@ function getConfig() {
66
69
  return { proxyUrl: process.env.PROXY_URL, appPort: process.env.APP_PORT, version: APP_VERSION };
67
70
  }
68
71
  getConfig();
72
+ let role;
73
+ try {
74
+ role = resolveRole();
75
+ }
76
+ catch (err) {
77
+ logger.error(err.message);
78
+ console.error(err.message);
79
+ process.exit(1);
80
+ }
69
81
  const dataDir = resolveDataDir(rootDir);
70
82
  const store = new AnalysisStore(dataDir);
71
83
  /** Présentation du serveur transmise au client MCP : concepts, déroulé et règles. */
@@ -334,7 +346,12 @@ const TOOLS = [
334
346
  inputSchema: {
335
347
  type: "object",
336
348
  properties: {
337
- repo_path: { type: "string", description: "Absolute path of the root of the git repository." },
349
+ repo_path: {
350
+ type: "string",
351
+ description: role === "remote"
352
+ ? "Absolute path of the root of the git repository on your machine: git runs there, through the agent relay of mcp-http-gateway."
353
+ : "Absolute path of the root of the git repository.",
354
+ },
338
355
  project: { type: "string", description: PROJECT_DOC },
339
356
  title: { type: "string", description: "Short title of the feature, e.g. \"OAuth token refresh\"." },
340
357
  mode: {
@@ -592,9 +609,20 @@ function afterMutation({ result, analysisId }) {
592
609
  notifyAnalysisChanged(analysisId);
593
610
  return { ...result, content: [{ type: "text", text: result.content[0].text + guiHint(analysisId) }] };
594
611
  }
612
+ /**
613
+ * Donne à `fn` l'accès aux dépôts du rôle : git local en `standalone` ; en `remote`,
614
+ * commandes exécutées sur la machine de l'agent par le relais, qui annonce cette
615
+ * possibilité dans l'argument `exec`.
616
+ */
617
+ function withRepoAccess(exec, fn) {
618
+ if (role === "standalone")
619
+ return fn(localRepo);
620
+ return handleAgentExec(exec, (run) => fn((repoPath) => agentRepo(repoPath, run)));
621
+ }
595
622
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
596
623
  const { name, arguments: args } = request.params;
597
- const params = (args ?? {});
624
+ // L'argument réservé du relais agent accompagne tous les appels d'une route listée dans GATEWAY_EXEC_ROUTES.
625
+ const { [EXEC_ARG]: exec, ...params } = (args ?? {});
598
626
  try {
599
627
  switch (name) {
600
628
  case "list_analyses":
@@ -605,11 +633,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
605
633
  return getDiff(store, params);
606
634
  case "get_review_feedback":
607
635
  return getReviewFeedback(store, params);
608
- case "create_analysis": {
609
- const mutation = await createAnalysis(store, params);
610
- notifyAnalysesListChanged();
611
- return afterMutation(mutation);
612
- }
636
+ case "create_analysis":
637
+ return await withRepoAccess(exec, async (repoFor) => {
638
+ const mutation = await createAnalysis(store, params, repoFor);
639
+ notifyAnalysesListChanged();
640
+ return afterMutation(mutation);
641
+ });
613
642
  case "update_analysis":
614
643
  return afterMutation(updateAnalysis(store, params));
615
644
  case "add_findings":
@@ -623,7 +652,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
623
652
  case "delete_diagram":
624
653
  return afterMutation(deleteDiagram(store, params));
625
654
  case "refresh_analysis":
626
- return afterMutation(await refreshAnalysis(store, params));
655
+ return await withRepoAccess(exec, async (repoFor) => afterMutation(await refreshAnalysis(store, params, repoFor)));
627
656
  case "delete_analysis": {
628
657
  const { result, analysisId } = deleteAnalysis(store, params);
629
658
  notifyAnalysisChanged(analysisId);
@@ -643,7 +672,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
643
672
  async function main() {
644
673
  const transport = new StdioServerTransport();
645
674
  await server.connect(transport);
646
- logger.info(`MCP Server connected and running on stdio (data dir: ${dataDir})`);
675
+ logger.info(`MCP Server connected and running on stdio (role: ${role}, data dir: ${dataDir})`);
647
676
  launchGUI();
648
677
  }
649
678
  main().catch((err) => {
@@ -1,4 +1,5 @@
1
1
  import type { AnalysisStore } from "../core/analysis-store.js";
2
+ import { type RepoAccess } from "../core/git.js";
2
3
  import { type Args, type MutationResult } from "./types.js";
3
- /** Calcule et fige le diff du dépôt, puis crée l'analyse. */
4
- export declare function createAnalysis(store: AnalysisStore, args: Args): Promise<MutationResult>;
4
+ /** Calcule et fige le diff du dépôt, lu par `repoFor(repo_path)` (git local par défaut), puis crée l'analyse. */
5
+ export declare function createAnalysis(store: AnalysisStore, args: Args, repoFor?: (repoPath: string) => RepoAccess): Promise<MutationResult>;