gitnexus 1.6.13-rc.44 → 1.6.13-rc.45
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/README.md +4 -2
- package/dist/mcp/local/local-backend.d.ts +21 -0
- package/dist/mcp/local/local-backend.js +187 -1
- package/dist/mcp/read-only-policy.js +2 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +5 -1
- package/dist/mcp/tools.d.ts +7 -1
- package/dist/mcp/tools.js +97 -1
- package/dist/server/grep-params.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -210,7 +210,7 @@ Note that the bundled Graphology path is no longer the slow option it once was:
|
|
|
210
210
|
|
|
211
211
|
## MCP Tools
|
|
212
212
|
|
|
213
|
-
Your AI agent gets **
|
|
213
|
+
Your AI agent gets **19 tools** (17 per-repo + 2 group) automatically:
|
|
214
214
|
|
|
215
215
|
| Tool | What It Does |
|
|
216
216
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -229,10 +229,12 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
|
|
|
229
229
|
| `api_impact` | Pre-change impact report for an API route handler |
|
|
230
230
|
| `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) |
|
|
231
231
|
| `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) |
|
|
232
|
+
| `read_file` | Read a checkout file (optional 0-indexed slice; `maxLines` cap) |
|
|
233
|
+
| `grep` | Regex search of the working tree for indexed files (1-based hits; optional `caseSensitive` / `literal`) |
|
|
232
234
|
| `group_list` | List configured repository groups |
|
|
233
235
|
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
|
234
236
|
|
|
235
|
-
> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch
|
|
237
|
+
> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`, except `read_file` and `grep`, which read the checkout and do not accept `branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
|
236
238
|
|
|
237
239
|
## MCP Resources
|
|
238
240
|
|
|
@@ -941,6 +941,27 @@ export declare class LocalBackend {
|
|
|
941
941
|
* Single query instead of N+1.
|
|
942
942
|
*/
|
|
943
943
|
private fetchLinkedFlowsBatch;
|
|
944
|
+
/**
|
|
945
|
+
* Same predicate as HTTP `getSourceAvailability`: full retention plus a live
|
|
946
|
+
* checkout. Meta is the graph directory so a published shared-store commit
|
|
947
|
+
* still carries `contentRetention`. Wording matches HTTP 410.
|
|
948
|
+
*/
|
|
949
|
+
private fullSourceUnavailable;
|
|
950
|
+
/**
|
|
951
|
+
* MCP read_file — repo-contained checkout read with an optional 0-indexed
|
|
952
|
+
* line slice. The realpath re-check matches GET /api/file. The lexical
|
|
953
|
+
* barrier stays inline for CodeQL and is narrowed to the `..` segment, so
|
|
954
|
+
* a file named `..config` is not a traversal. ENOENT is file-not-found
|
|
955
|
+
* only after the checkout directory is known to exist.
|
|
956
|
+
*/
|
|
957
|
+
private readFile;
|
|
958
|
+
/**
|
|
959
|
+
* MCP grep — HTTP GET /api/grep twin. The file list is indexed File nodes
|
|
960
|
+
* that still have content; bytes come from the live checkout (same worker).
|
|
961
|
+
* Fails like HTTP 410 when full source is unavailable instead of returning
|
|
962
|
+
* an empty hit list.
|
|
963
|
+
*/
|
|
964
|
+
private grep;
|
|
944
965
|
private routeMap;
|
|
945
966
|
private shapeCheck;
|
|
946
967
|
private toolMap;
|
|
@@ -29,6 +29,8 @@ import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/l
|
|
|
29
29
|
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
|
|
30
30
|
import { parseDiffHunksResult, coalesceHunksByPath, hunksOverlapRange, findGitRootByDotGit, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
|
|
31
31
|
import { realpathSync } from 'fs';
|
|
32
|
+
import { parseGrepQuery, GREP_TIME_BUDGET_MS } from '../../server/grep-params.js';
|
|
33
|
+
import { runGrepScanInWorker } from '../../server/grep-scan.js';
|
|
32
34
|
import { listRegisteredRepos, canonicalizePath, getStoragePaths, loadMeta, RegistryAmbiguousTargetError, } from '../../storage/repo-manager.js';
|
|
33
35
|
import { GroupService, } from '../../core/group/service.js';
|
|
34
36
|
import { resolveAtGroupMemberRepoPath } from '../../core/group/resolve-at-member.js';
|
|
@@ -58,7 +60,7 @@ import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js'
|
|
|
58
60
|
import { stalenessPayload, } from '../../core/staleness-status.js';
|
|
59
61
|
import { logger } from '../../core/logger.js';
|
|
60
62
|
import { isLocalEmbeddingRuntimeBlockerMessage, isLocalEmbeddingSidecarAbortMessage, isMissingLocalEmbeddingStackMessage, } from '../../core/embeddings/runtime-support.js';
|
|
61
|
-
import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT, EXPLAIN_DEFAULT_LIMIT, EXPLAIN_MAX_LIMIT, PDG_QUERY_DEFAULT_LIMIT, PDG_QUERY_MAX_LIMIT, QUERY_DEFAULT_LIMIT, QUERY_DEFAULT_MAX_SYMBOLS, QUERY_MAX_LIMIT, QUERY_MAX_MAX_SYMBOLS, CONTEXT_CHAIN_MAX_DEPTH, } from '../tools.js';
|
|
63
|
+
import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT, EXPLAIN_DEFAULT_LIMIT, EXPLAIN_MAX_LIMIT, PDG_QUERY_DEFAULT_LIMIT, PDG_QUERY_MAX_LIMIT, QUERY_DEFAULT_LIMIT, QUERY_DEFAULT_MAX_SYMBOLS, QUERY_MAX_LIMIT, QUERY_MAX_MAX_SYMBOLS, CONTEXT_CHAIN_MAX_DEPTH, CHECKOUT_SOURCE_TOOLS, READ_FILE_DEFAULT_MAX_LINES, } from '../tools.js';
|
|
62
64
|
import { foldNumericToolArgumentAliases } from '../tool-arguments.js';
|
|
63
65
|
import { findImportCycles, IMPORT_CYCLE_LIMIT } from '../../core/graph/import-cycles.js';
|
|
64
66
|
import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js';
|
|
@@ -2170,6 +2172,14 @@ export class LocalBackend {
|
|
|
2170
2172
|
p.repo.startsWith('@')) {
|
|
2171
2173
|
return this.callToolAtGroupRepo(method, p);
|
|
2172
2174
|
}
|
|
2175
|
+
// These tools read the checkout, not a pinned index. `branch` would still
|
|
2176
|
+
// rewrite lbugPath/lastCommit and withToolStaleness would label checkout
|
|
2177
|
+
// bytes with that pin. Reject before selectToolRepository applies it.
|
|
2178
|
+
if (CHECKOUT_SOURCE_TOOLS.has(method) && p.branch !== undefined && p.branch !== '') {
|
|
2179
|
+
return {
|
|
2180
|
+
error: `${method} follows the checked-out working tree and does not accept "branch". Omit it.`,
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2173
2183
|
// Resolve repo from optional param (re-reads registry on miss). An optional
|
|
2174
2184
|
// `branch` param scopes the resolved handle to that branch's index (#2106).
|
|
2175
2185
|
const repo = await this.selectToolRepository(p.repo, p.branch, { allowCwdDefault: method !== 'rename' });
|
|
@@ -2214,6 +2224,10 @@ export class LocalBackend {
|
|
|
2214
2224
|
return this.apiImpact(repo, p);
|
|
2215
2225
|
case 'trace':
|
|
2216
2226
|
return this.trace(repo, p);
|
|
2227
|
+
case 'read_file':
|
|
2228
|
+
return this.withToolStaleness(repo, await this.readFile(repo, p));
|
|
2229
|
+
case 'grep':
|
|
2230
|
+
return this.withToolStaleness(repo, await this.grep(repo, p));
|
|
2217
2231
|
default:
|
|
2218
2232
|
throw new Error(`Unknown tool: ${method}`);
|
|
2219
2233
|
}
|
|
@@ -7783,6 +7797,178 @@ export class LocalBackend {
|
|
|
7783
7797
|
}
|
|
7784
7798
|
return result;
|
|
7785
7799
|
}
|
|
7800
|
+
/**
|
|
7801
|
+
* Same predicate as HTTP `getSourceAvailability`: full retention plus a live
|
|
7802
|
+
* checkout. Meta is the graph directory so a published shared-store commit
|
|
7803
|
+
* still carries `contentRetention`. Wording matches HTTP 410.
|
|
7804
|
+
*/
|
|
7805
|
+
async fullSourceUnavailable(repo) {
|
|
7806
|
+
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
|
7807
|
+
const retention = contentRetentionFromMeta(meta);
|
|
7808
|
+
const checkoutIsDir = retention === 'full' ? await checkoutIsDirectory(repo.repoPath) : false;
|
|
7809
|
+
if (isFullSourceAvailable(retention, checkoutIsDir))
|
|
7810
|
+
return null;
|
|
7811
|
+
const reason = retention !== 'full' ? 'content-retention' : 'checkout-missing';
|
|
7812
|
+
const because = reason === 'content-retention' ? 'content retention' : 'source checkout';
|
|
7813
|
+
return {
|
|
7814
|
+
error: `Full source is unavailable because the ${because} is unavailable.`,
|
|
7815
|
+
code: 'source-unavailable',
|
|
7816
|
+
reason,
|
|
7817
|
+
};
|
|
7818
|
+
}
|
|
7819
|
+
/**
|
|
7820
|
+
* MCP read_file — repo-contained checkout read with an optional 0-indexed
|
|
7821
|
+
* line slice. The realpath re-check matches GET /api/file. The lexical
|
|
7822
|
+
* barrier stays inline for CodeQL and is narrowed to the `..` segment, so
|
|
7823
|
+
* a file named `..config` is not a traversal. ENOENT is file-not-found
|
|
7824
|
+
* only after the checkout directory is known to exist.
|
|
7825
|
+
*/
|
|
7826
|
+
async readFile(repo, params) {
|
|
7827
|
+
const rawPath = params?.path;
|
|
7828
|
+
if (typeof rawPath !== 'string' || rawPath === '') {
|
|
7829
|
+
return { error: 'Missing required argument "path" (repo-relative file path).' };
|
|
7830
|
+
}
|
|
7831
|
+
const unavailable = await this.fullSourceUnavailable(repo);
|
|
7832
|
+
if (unavailable)
|
|
7833
|
+
return unavailable;
|
|
7834
|
+
const toInteger = (v) => typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : undefined;
|
|
7835
|
+
const startLine = toInteger(params?.startLine);
|
|
7836
|
+
const endLine = toInteger(params?.endLine);
|
|
7837
|
+
if (endLine !== undefined && startLine === undefined) {
|
|
7838
|
+
return { error: '"endLine" requires "startLine".' };
|
|
7839
|
+
}
|
|
7840
|
+
const repoRoot = path.resolve(repo.repoPath);
|
|
7841
|
+
const fullPath = path.resolve(repoRoot, rawPath);
|
|
7842
|
+
const fullRel = path.relative(repoRoot, fullPath);
|
|
7843
|
+
// `startsWith('..')` is the CodeQL path-injection sanitizer. Narrow it to
|
|
7844
|
+
// the `..` segment so a repo file named `..config` is not a traversal.
|
|
7845
|
+
if (path.isAbsolute(fullRel) ||
|
|
7846
|
+
(fullRel.startsWith('..') && (fullRel === '..' || fullRel.startsWith(`..${path.sep}`)))) {
|
|
7847
|
+
return { error: 'Path traversal denied.' };
|
|
7848
|
+
}
|
|
7849
|
+
let realRoot;
|
|
7850
|
+
let realFull;
|
|
7851
|
+
try {
|
|
7852
|
+
[realRoot, realFull] = await Promise.all([fs.realpath(repoRoot), fs.realpath(fullPath)]);
|
|
7853
|
+
}
|
|
7854
|
+
catch (err) {
|
|
7855
|
+
if (err?.code === 'ENOENT')
|
|
7856
|
+
return { error: `File not found: ${rawPath}` };
|
|
7857
|
+
throw err;
|
|
7858
|
+
}
|
|
7859
|
+
const realRel = path.relative(realRoot, realFull);
|
|
7860
|
+
if (realRel === '..' || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
|
|
7861
|
+
return { error: 'Path traversal denied.' };
|
|
7862
|
+
}
|
|
7863
|
+
const raw = await fs.readFile(realFull, 'utf-8');
|
|
7864
|
+
const lines = raw.split('\n');
|
|
7865
|
+
if (startLine !== undefined) {
|
|
7866
|
+
if (endLine !== undefined && endLine < 0) {
|
|
7867
|
+
return { error: '"endLine" must be an integer >= 0.' };
|
|
7868
|
+
}
|
|
7869
|
+
const start = Math.max(0, startLine);
|
|
7870
|
+
const end = endLine !== undefined ? Math.min(lines.length, endLine + 1) : lines.length;
|
|
7871
|
+
return {
|
|
7872
|
+
path: fullRel,
|
|
7873
|
+
content: lines.slice(start, end).join('\n'),
|
|
7874
|
+
startLine: start,
|
|
7875
|
+
endLine: end - 1,
|
|
7876
|
+
totalLines: lines.length,
|
|
7877
|
+
};
|
|
7878
|
+
}
|
|
7879
|
+
const requestedMaxLines = toInteger(params?.maxLines);
|
|
7880
|
+
if (requestedMaxLines !== undefined && requestedMaxLines < 0) {
|
|
7881
|
+
return { error: '"maxLines" must be an integer >= 0 (0 = no cap).' };
|
|
7882
|
+
}
|
|
7883
|
+
const maxLines = requestedMaxLines ?? READ_FILE_DEFAULT_MAX_LINES;
|
|
7884
|
+
if (maxLines > 0 && lines.length > maxLines) {
|
|
7885
|
+
return {
|
|
7886
|
+
path: fullRel,
|
|
7887
|
+
content: lines.slice(0, maxLines).join('\n'),
|
|
7888
|
+
startLine: 0,
|
|
7889
|
+
endLine: maxLines - 1,
|
|
7890
|
+
totalLines: lines.length,
|
|
7891
|
+
truncated: true,
|
|
7892
|
+
suggestion: 'Re-issue with startLine/endLine for the window you need.',
|
|
7893
|
+
};
|
|
7894
|
+
}
|
|
7895
|
+
return { path: fullRel, content: raw, totalLines: lines.length };
|
|
7896
|
+
}
|
|
7897
|
+
/**
|
|
7898
|
+
* MCP grep — HTTP GET /api/grep twin. The file list is indexed File nodes
|
|
7899
|
+
* that still have content; bytes come from the live checkout (same worker).
|
|
7900
|
+
* Fails like HTTP 410 when full source is unavailable instead of returning
|
|
7901
|
+
* an empty hit list.
|
|
7902
|
+
*/
|
|
7903
|
+
async grep(repo, params) {
|
|
7904
|
+
const unavailable = await this.fullSourceUnavailable(repo);
|
|
7905
|
+
if (unavailable)
|
|
7906
|
+
return unavailable;
|
|
7907
|
+
await this.ensureInitialized(repo);
|
|
7908
|
+
let parsed;
|
|
7909
|
+
try {
|
|
7910
|
+
parsed = parseGrepQuery({
|
|
7911
|
+
pattern: params?.pattern,
|
|
7912
|
+
fileFilter: params?.fileFilter,
|
|
7913
|
+
limit: params?.limit,
|
|
7914
|
+
caseSensitive: params?.caseSensitive,
|
|
7915
|
+
literal: params?.literal,
|
|
7916
|
+
});
|
|
7917
|
+
}
|
|
7918
|
+
catch (err) {
|
|
7919
|
+
return { error: err?.message || 'Invalid grep query.' };
|
|
7920
|
+
}
|
|
7921
|
+
const fileRows = await executeQuery(repo.lbugPath, `MATCH (n:File) WHERE n.content IS NOT NULL RETURN n.filePath AS filePath`);
|
|
7922
|
+
const filePaths = [];
|
|
7923
|
+
for (const row of fileRows) {
|
|
7924
|
+
const filePath = row.filePath || '';
|
|
7925
|
+
if (parsed.fileFilter && !filePath.toLowerCase().includes(parsed.fileFilter))
|
|
7926
|
+
continue;
|
|
7927
|
+
filePaths.push(filePath);
|
|
7928
|
+
}
|
|
7929
|
+
// The shared scanner only checks a lexical prefix, then readFile follows
|
|
7930
|
+
// symlinks. Drop paths whose realpath leaves the checkout, same as read_file.
|
|
7931
|
+
const repoRoot = path.resolve(repo.repoPath);
|
|
7932
|
+
const realRoot = await fs.realpath(repoRoot);
|
|
7933
|
+
const containedPaths = [];
|
|
7934
|
+
for (const filePath of filePaths) {
|
|
7935
|
+
const fullPath = path.resolve(repoRoot, filePath);
|
|
7936
|
+
const fullRel = path.relative(repoRoot, fullPath);
|
|
7937
|
+
if (path.isAbsolute(fullRel) ||
|
|
7938
|
+
(fullRel.startsWith('..') && (fullRel === '..' || fullRel.startsWith(`..${path.sep}`)))) {
|
|
7939
|
+
continue;
|
|
7940
|
+
}
|
|
7941
|
+
let realFull;
|
|
7942
|
+
try {
|
|
7943
|
+
realFull = await fs.realpath(fullPath);
|
|
7944
|
+
}
|
|
7945
|
+
catch {
|
|
7946
|
+
continue;
|
|
7947
|
+
}
|
|
7948
|
+
const realRel = path.relative(realRoot, realFull);
|
|
7949
|
+
if (realRel === '..' || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
|
|
7950
|
+
continue;
|
|
7951
|
+
}
|
|
7952
|
+
containedPaths.push(filePath);
|
|
7953
|
+
}
|
|
7954
|
+
const { results, timedOut } = await runGrepScanInWorker({
|
|
7955
|
+
repoRoot,
|
|
7956
|
+
filePaths: containedPaths,
|
|
7957
|
+
pattern: parsed.regex.source,
|
|
7958
|
+
flags: parsed.regex.flags,
|
|
7959
|
+
limit: parsed.limit,
|
|
7960
|
+
deadlineMs: Date.now() + GREP_TIME_BUDGET_MS,
|
|
7961
|
+
});
|
|
7962
|
+
return {
|
|
7963
|
+
results,
|
|
7964
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
7965
|
+
...(timedOut
|
|
7966
|
+
? {
|
|
7967
|
+
suggestion: 'Wall-clock budget expired — re-issue narrower (fileFilter or a tighter pattern).',
|
|
7968
|
+
}
|
|
7969
|
+
: {}),
|
|
7970
|
+
};
|
|
7971
|
+
}
|
|
7786
7972
|
async routeMap(repo, params) {
|
|
7787
7973
|
await this.ensureInitialized(repo);
|
|
7788
7974
|
const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Supports multiple indexed repositories via the global registry.
|
|
9
9
|
*
|
|
10
|
-
* Tools: list_repos, query, cypher, context, impact, detect_changes, rename
|
|
10
|
+
* Tools: list_repos, query, cypher, context, read_file, grep, impact, detect_changes, rename
|
|
11
11
|
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
|
|
12
12
|
*/
|
|
13
13
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
package/dist/mcp/server.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Supports multiple indexed repositories via the global registry.
|
|
9
9
|
*
|
|
10
|
-
* Tools: list_repos, query, cypher, context, impact, detect_changes, rename
|
|
10
|
+
* Tools: list_repos, query, cypher, context, read_file, grep, impact, detect_changes, rename
|
|
11
11
|
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
|
|
12
12
|
*/
|
|
13
13
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
@@ -49,6 +49,10 @@ function getNextStepHint(toolName, args) {
|
|
|
49
49
|
return `\n\n---\n**Next:** Run detect_changes(${repoParam ? `{repo: "${repo}"}` : ''}) to verify no unexpected side effects from the rename.`;
|
|
50
50
|
case 'cypher':
|
|
51
51
|
return `\n\n---\n**Next:** To explore a result symbol, use context({name: "<name>"${repoParam}}). For schema reference, READ gitnexus://repo/${repoPath}/schema.`;
|
|
52
|
+
case 'read_file':
|
|
53
|
+
return `\n\n---\n**Next:** To pin a symbol seen in the file, use context({name: "<symbol>"${repoParam}}). To find other occurrences, use grep({pattern: "<token>"${repoParam}}).`;
|
|
54
|
+
case 'grep':
|
|
55
|
+
return `\n\n---\n**Next:** Read the hit window with read_file({path: "<file>"${repoParam}, startLine: <hit.line - 1>, endLine: <hit.line - 1>}). Grep line is 1-based; read_file is 0-based. Or pin the symbol with context({name: "<symbol>"${repoParam}}).`;
|
|
52
56
|
// Legacy tool names — still return useful hints
|
|
53
57
|
case 'search':
|
|
54
58
|
return `\n\n---\n**Next:** To understand a result in context, use context({name: "<symbol_name>"${repoParam}}).`;
|
package/dist/mcp/tools.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ToolDefinition {
|
|
|
37
37
|
*/
|
|
38
38
|
export declare const LIST_REPOS_DEFAULT_LIMIT = 50;
|
|
39
39
|
export declare const LIST_REPOS_MAX_LIMIT = 200;
|
|
40
|
+
/** Whole-file `read_file` cap. The schema default and the handler fallback share this. */
|
|
41
|
+
export declare const READ_FILE_DEFAULT_MAX_LINES = 2000;
|
|
40
42
|
/**
|
|
41
43
|
* Pagination bounds for the `explain` tool (#2083 M3 U6). Findings are sparse
|
|
42
44
|
* and capped per function at analyze time, but a large repo can still
|
|
@@ -62,6 +64,10 @@ export declare const GITNEXUS_TOOLS: ToolDefinition[];
|
|
|
62
64
|
* of truth: the schema property is injected here so it cannot drift from the
|
|
63
65
|
* server-side default in `local-backend.ts` (`resolveRepo(repo, branch)`).
|
|
64
66
|
* `list_repos` and the `group_*` tools are intentionally excluded — they are
|
|
65
|
-
* not single-repo, single-branch operations.
|
|
67
|
+
* not single-repo, single-branch operations. `read_file` and `grep` are in
|
|
68
|
+
* this set so `repo` stays required with the other per-repo tools, and the
|
|
69
|
+
* loop below skips `branch` for `CHECKOUT_SOURCE_TOOLS` — a pin would label
|
|
70
|
+
* checkout bytes with another commit.
|
|
66
71
|
*/
|
|
72
|
+
export declare const CHECKOUT_SOURCE_TOOLS: Set<string>;
|
|
67
73
|
export declare const REPO_SCOPED_TOOLS: Set<string>;
|
package/dist/mcp/tools.js
CHANGED
|
@@ -33,6 +33,8 @@ const DESTRUCTIVE_TOOL_ANNOTATIONS = {
|
|
|
33
33
|
*/
|
|
34
34
|
export const LIST_REPOS_DEFAULT_LIMIT = 50;
|
|
35
35
|
export const LIST_REPOS_MAX_LIMIT = 200;
|
|
36
|
+
/** Whole-file `read_file` cap. The schema default and the handler fallback share this. */
|
|
37
|
+
export const READ_FILE_DEFAULT_MAX_LINES = 2000;
|
|
36
38
|
/**
|
|
37
39
|
* Pagination bounds for the `explain` tool (#2083 M3 U6). Findings are sparse
|
|
38
40
|
* and capped per function at analyze time, but a large repo can still
|
|
@@ -941,15 +943,106 @@ DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_fil
|
|
|
941
943
|
required: [],
|
|
942
944
|
},
|
|
943
945
|
},
|
|
946
|
+
{
|
|
947
|
+
name: 'read_file',
|
|
948
|
+
description: `Read a file from the repository checkout, optionally sliced to a 0-indexed line range.
|
|
949
|
+
Returns checkout bytes plus totalLines and the slice bounds. The realpath re-check matches HTTP GET /api/file. The lexical barrier is the CodeQL \`startsWith('..')\` form narrowed to the \`..\` segment, so a file named \`..config\` stays readable. A whole-file read is capped by maxLines (default ${READ_FILE_DEFAULT_MAX_LINES}, 0 = no cap), which is stricter than the uncapped HTTP body.
|
|
950
|
+
|
|
951
|
+
WHEN TO USE: After query()/cypher()/context() gave you a filePath (or file:line), read the surrounding source: header context (open/variable/import lines), a full declaration, or any line window. Prefer context({name, include_content: true}) when you already have the symbol — it returns the symbol span plus call edges in one call.
|
|
952
|
+
AFTER THIS: Use the read text to ground signatures verbatim; never invent names from memory.
|
|
953
|
+
|
|
954
|
+
Paths that escape the repository are refused. A missing file returns a not-found error only when the checkout directory exists. When full source is unavailable (content retention is not "full", or the checkout directory is gone), the result is code "source-unavailable" — not an empty body and not "file not found". This tool reads the checkout and does not accept branch.`,
|
|
955
|
+
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
956
|
+
inputSchema: {
|
|
957
|
+
type: 'object',
|
|
958
|
+
properties: {
|
|
959
|
+
path: {
|
|
960
|
+
type: 'string',
|
|
961
|
+
description: 'Repository-contained file path (e.g. "Mathlib/Analysis/SpecificLimits/Basic.lean"). Paths that escape the repository, including ".." escapes, are refused.',
|
|
962
|
+
},
|
|
963
|
+
startLine: {
|
|
964
|
+
type: 'integer',
|
|
965
|
+
description: 'Optional 0-indexed first line of the slice (inclusive).',
|
|
966
|
+
minimum: 0,
|
|
967
|
+
},
|
|
968
|
+
endLine: {
|
|
969
|
+
type: 'integer',
|
|
970
|
+
description: 'Optional 0-indexed last line of the slice (inclusive). Requires startLine.',
|
|
971
|
+
minimum: 0,
|
|
972
|
+
},
|
|
973
|
+
maxLines: {
|
|
974
|
+
type: 'integer',
|
|
975
|
+
description: `Maximum lines returned for a whole-file read (default ${READ_FILE_DEFAULT_MAX_LINES}, 0 = no cap). Ignored when startLine is set. Negative values are rejected.`,
|
|
976
|
+
default: READ_FILE_DEFAULT_MAX_LINES,
|
|
977
|
+
minimum: 0,
|
|
978
|
+
},
|
|
979
|
+
repo: {
|
|
980
|
+
type: 'string',
|
|
981
|
+
description: `Indexed repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
required: ['path'],
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
name: 'grep',
|
|
989
|
+
description: `Regex search of the live checkout for files the index retained — the MCP twin of HTTP GET /api/grep.
|
|
990
|
+
The file list is indexed File nodes that still have content. Bytes are read from the working tree, so edits since the last analyze are visible. Hits are 1-based. read_file startLine/endLine are 0-based.
|
|
991
|
+
|
|
992
|
+
WHEN TO USE: Only after graph tools came back empty or ambiguous — exact-name pinning, docstring fallback, or literal tokens the index does not model (e.g. tactic names inside proof bodies, notation). Graph first (query/context/cypher); grep is the offline-capable fallback, never the default.
|
|
993
|
+
AFTER THIS: Read the matching line with read_file({path, startLine: hit.line - 1, endLine: hit.line - 1}) or pin the symbol with context({name}).
|
|
994
|
+
|
|
995
|
+
Optional caseSensitive and literal match HTTP /api/grep (default: case-insensitive regex). When full source is unavailable the result is code "source-unavailable", not an empty hit list. This tool reads the checkout and does not accept branch. Results carry timedOut: true when the wall-clock budget expired first — re-issue narrower (fileFilter or a tighter pattern).`,
|
|
996
|
+
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
997
|
+
inputSchema: {
|
|
998
|
+
type: 'object',
|
|
999
|
+
properties: {
|
|
1000
|
+
pattern: {
|
|
1001
|
+
type: 'string',
|
|
1002
|
+
description: 'Regex pattern (max 200 chars) matched against file content lines.',
|
|
1003
|
+
},
|
|
1004
|
+
fileFilter: {
|
|
1005
|
+
type: 'string',
|
|
1006
|
+
description: 'Optional case-insensitive substring filter on file paths.',
|
|
1007
|
+
},
|
|
1008
|
+
limit: {
|
|
1009
|
+
type: 'number',
|
|
1010
|
+
description: 'Maximum hits returned (default 50, max 200).',
|
|
1011
|
+
default: 50,
|
|
1012
|
+
minimum: 1,
|
|
1013
|
+
maximum: 200,
|
|
1014
|
+
},
|
|
1015
|
+
caseSensitive: {
|
|
1016
|
+
type: 'boolean',
|
|
1017
|
+
description: 'Optional. When true, match case. Default is case-insensitive, matching HTTP /api/grep.',
|
|
1018
|
+
},
|
|
1019
|
+
literal: {
|
|
1020
|
+
type: 'boolean',
|
|
1021
|
+
description: 'Optional. When true, treat pattern as a literal substring (escaped), matching HTTP /api/grep literal=1. Default is a regex.',
|
|
1022
|
+
},
|
|
1023
|
+
repo: {
|
|
1024
|
+
type: 'string',
|
|
1025
|
+
description: `Indexed repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
required: ['pattern'],
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
944
1031
|
];
|
|
945
1032
|
/**
|
|
946
1033
|
* Per-repo tools that accept an optional `branch` scope (#2106). Single source
|
|
947
1034
|
* of truth: the schema property is injected here so it cannot drift from the
|
|
948
1035
|
* server-side default in `local-backend.ts` (`resolveRepo(repo, branch)`).
|
|
949
1036
|
* `list_repos` and the `group_*` tools are intentionally excluded — they are
|
|
950
|
-
* not single-repo, single-branch operations.
|
|
1037
|
+
* not single-repo, single-branch operations. `read_file` and `grep` are in
|
|
1038
|
+
* this set so `repo` stays required with the other per-repo tools, and the
|
|
1039
|
+
* loop below skips `branch` for `CHECKOUT_SOURCE_TOOLS` — a pin would label
|
|
1040
|
+
* checkout bytes with another commit.
|
|
951
1041
|
*/
|
|
1042
|
+
export const CHECKOUT_SOURCE_TOOLS = new Set(['read_file', 'grep']);
|
|
952
1043
|
export const REPO_SCOPED_TOOLS = new Set([
|
|
1044
|
+
'read_file',
|
|
1045
|
+
'grep',
|
|
953
1046
|
'query',
|
|
954
1047
|
'cypher',
|
|
955
1048
|
'context',
|
|
@@ -978,6 +1071,9 @@ for (const tool of GITNEXUS_TOOLS) {
|
|
|
978
1071
|
tool.inputSchema.additionalProperties = false;
|
|
979
1072
|
if (!REPO_SCOPED_TOOLS.has(tool.name))
|
|
980
1073
|
continue;
|
|
1074
|
+
// Checkout reads follow the working tree. Do not advertise `branch`.
|
|
1075
|
+
if (CHECKOUT_SOURCE_TOOLS.has(tool.name))
|
|
1076
|
+
continue;
|
|
981
1077
|
if (tool.inputSchema.properties.branch)
|
|
982
1078
|
continue;
|
|
983
1079
|
// Optional — `required` is left unchanged so omitting `branch` keeps today's
|
|
@@ -29,6 +29,10 @@ export const GREP_TIME_BUDGET_MS = 5_000;
|
|
|
29
29
|
export const GREP_DEFAULT_LIMIT = 50;
|
|
30
30
|
export const GREP_MAX_LIMIT = 200;
|
|
31
31
|
const isFlagTrue = (value, name) => {
|
|
32
|
+
// MCP passes booleans. HTTP query strings stay '1' / 'true'. Null and
|
|
33
|
+
// undefined stay an empty string so an omitted flag is still false.
|
|
34
|
+
if (typeof value === 'boolean')
|
|
35
|
+
return value;
|
|
32
36
|
const s = assertString(value ?? '', name).toLowerCase();
|
|
33
37
|
return s === '1' || s === 'true';
|
|
34
38
|
};
|
package/package.json
CHANGED