@harivatsa/sirius-mcp-proxy 0.1.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/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @harivatsa/sirius-mcp-proxy
2
+
3
+ MCP server that connects Claude to the Sirius index server and local repo-agent snippets.
4
+
5
+ Install from npm with:
6
+
7
+ ```bash
8
+ npm install -g @harivatsa/sirius-mcp-proxy
9
+ ```
10
+
11
+ Point Claude at `sirius-mcp-proxy` after configuring the Sirius index URL, repo-agent URL, and token environment variables described in the root README.
12
+
13
+ Snippet enrichment is bounded by `SIRIUS_MCP_ENRICH_CONCURRENCY`, which defaults to `16` and caps at `50`, matching the MCP tool `top_k` limit. Normal `top_k <= 16` searches fetch snippets in one wave; larger windows avoid unbounded local file-range fan-out.
14
+
15
+ `semantic_code_search`, `hybrid_search`, `symbol_search`, `get_call_chain`, and `impact_analysis` compare indexed file hashes with local snippet hashes when `include_code=true`. With `auto_sync_stale=true`, stale snippets trigger one local repo-agent sync and then rerun the lookup.
16
+
17
+ `get_call_chain` forwards `max_edges`, defaulting to `300`, and includes `edge_summary` when dense graph neighborhoods omit edges. It also forwards `max_unresolved`, defaulting to `200`; set it to `0` for compact graph-only responses when unresolved callees are not needed. `max_code_nodes` defaults to `50`, so large graph and impact-analysis responses keep all node metadata but only enrich the first N nodes with local source snippets.
18
+
19
+ The package is published publicly on npm as `@harivatsa/sirius-mcp-proxy`.
package/dist/enrich.js ADDED
@@ -0,0 +1,168 @@
1
+ import { agentPost } from "./http.js";
2
+ import { isSafeLocalFilePath, LOCAL_FILE_PATH_RULE } from "./validation.js";
3
+ // General rule: snippet enrichment should not fan out unbounded local file reads for large top_k calls.
4
+ const DEFAULT_MCP_ENRICH_CONCURRENCY = 16;
5
+ // General rule: callers may tune enrichment parallelism, but it should remain bounded by MCP tool limits.
6
+ const MAX_MCP_ENRICH_CONCURRENCY = 50;
7
+ // General rule: one snippet batch should fit the largest current MCP graph response without unbounded payloads.
8
+ const MAX_MCP_SNIPPET_BATCH_SIZE = 200;
9
+ // General rule: indexed file paths must be normalized repo-relative slash paths before local file lookup.
10
+ const UNSAFE_SNIPPET_PATH_ERROR = LOCAL_FILE_PATH_RULE;
11
+ // General rule: local snippet reads should be bound to the repo requested by the MCP tool call, not by index payloads.
12
+ const MISMATCHED_SNIPPET_REPO_ERROR = "indexed result repo_id does not match requested repo_id";
13
+ // General rule: local agent snippets must echo the requested repo and file before MCP returns source text.
14
+ const MISMATCHED_LOCAL_SNIPPET_ERROR = "local snippet response does not match requested repo_id or file_path";
15
+ export async function enrichSearchResults(repoId, results, includeCode) {
16
+ return enrichWithSnippets(results, includeCode, (result) => ({
17
+ repoId,
18
+ indexedRepoId: result.repo_id,
19
+ filePath: result.file_path,
20
+ startLine: result.start_line,
21
+ endLine: result.end_line,
22
+ expectedFileHash: result.file_hash
23
+ }));
24
+ }
25
+ export async function enrichSymbols(repoId, results, includeCode) {
26
+ return enrichWithSnippets(results, includeCode, (result) => ({
27
+ repoId,
28
+ indexedRepoId: result.repo_id,
29
+ filePath: result.file_path,
30
+ startLine: result.start_line,
31
+ endLine: result.end_line,
32
+ expectedFileHash: result.file_hash ?? undefined
33
+ }));
34
+ }
35
+ export async function enrichGraphNodes(repoId, nodes, includeCode, maxSnippetNodes = nodes.length) {
36
+ const snippetNodeCount = boundedItemCount(maxSnippetNodes, nodes.length);
37
+ const nodesWithSnippets = await enrichWithSnippets(nodes.slice(0, snippetNodeCount), includeCode, (node) => ({
38
+ repoId,
39
+ indexedRepoId: node.repo_id,
40
+ filePath: node.file_path,
41
+ startLine: node.start_line,
42
+ endLine: node.end_line,
43
+ expectedFileHash: node.file_hash ?? undefined
44
+ }));
45
+ return [...nodesWithSnippets, ...nodes.slice(snippetNodeCount)];
46
+ }
47
+ async function enrichWithSnippets(items, includeCode, lookupFor) {
48
+ if (!includeCode) {
49
+ return items;
50
+ }
51
+ const lookups = items.map(lookupFor);
52
+ const snippets = await getSnippetLookups(lookups);
53
+ return items.map((item, index) => {
54
+ const snippet = snippets[index]?.snippet;
55
+ if (snippet) {
56
+ return { ...item, snippet };
57
+ }
58
+ return { ...item, snippet_error: snippets[index]?.error ?? "local snippet lookup failed" };
59
+ });
60
+ }
61
+ async function getSnippetLookups(lookups) {
62
+ const results = new Array(lookups.length);
63
+ const safeIndexes = [];
64
+ lookups.forEach((lookup, index) => {
65
+ if (lookup.indexedRepoId !== undefined && lookup.indexedRepoId !== lookup.repoId) {
66
+ results[index] = { error: MISMATCHED_SNIPPET_REPO_ERROR };
67
+ }
68
+ else if (isSafeLocalFilePath(lookup.filePath)) {
69
+ safeIndexes.push(index);
70
+ }
71
+ else {
72
+ results[index] = { error: UNSAFE_SNIPPET_PATH_ERROR };
73
+ }
74
+ });
75
+ const batches = snippetBatches(lookups, safeIndexes);
76
+ await mapWithConcurrency(batches, mcpEnrichConcurrency(), async (batch) => {
77
+ try {
78
+ const response = await agentPost(`/repos/${encodeURIComponent(batch.repoId)}/file-ranges`, {
79
+ ranges: batch.indexes.map((index) => ({
80
+ file_path: lookups[index].filePath,
81
+ start_line: lookups[index].startLine,
82
+ end_line: lookups[index].endLine
83
+ }))
84
+ });
85
+ if (!Array.isArray(response.results) || response.results.length !== batch.indexes.length) {
86
+ throw new Error("local repo agent returned an invalid file-ranges response");
87
+ }
88
+ if (response.repo_id !== batch.repoId) {
89
+ throw new Error(MISMATCHED_LOCAL_SNIPPET_ERROR);
90
+ }
91
+ response.results.forEach((item, offset) => {
92
+ const index = batch.indexes[offset];
93
+ const lookup = lookups[index];
94
+ if (item.ok) {
95
+ if (item.range.repo_id !== batch.repoId || item.range.file_path !== lookup.filePath || !isSafeLocalFilePath(item.range.file_path)) {
96
+ results[index] = { error: MISMATCHED_LOCAL_SNIPPET_ERROR };
97
+ return;
98
+ }
99
+ results[index] = {
100
+ snippet: {
101
+ ...item.range,
102
+ expected_file_hash: lookup.expectedFileHash,
103
+ stale: lookup.expectedFileHash === undefined ? false : item.range.file_hash !== lookup.expectedFileHash
104
+ }
105
+ };
106
+ }
107
+ else {
108
+ results[index] = { error: item.error ?? "local snippet lookup failed" };
109
+ }
110
+ });
111
+ }
112
+ catch (error) {
113
+ for (const index of batch.indexes) {
114
+ results[index] = { error: error.message };
115
+ }
116
+ }
117
+ });
118
+ return results;
119
+ }
120
+ function snippetBatches(lookups, lookupIndexes) {
121
+ const grouped = new Map();
122
+ lookupIndexes.forEach((index) => {
123
+ const lookup = lookups[index];
124
+ const indexes = grouped.get(lookup.repoId);
125
+ if (indexes) {
126
+ indexes.push(index);
127
+ }
128
+ else {
129
+ grouped.set(lookup.repoId, [index]);
130
+ }
131
+ });
132
+ const batches = [];
133
+ for (const [repoId, indexes] of grouped) {
134
+ for (let start = 0; start < indexes.length; start += MAX_MCP_SNIPPET_BATCH_SIZE) {
135
+ batches.push({ repoId, indexes: indexes.slice(start, start + MAX_MCP_SNIPPET_BATCH_SIZE) });
136
+ }
137
+ }
138
+ return batches;
139
+ }
140
+ export function mcpEnrichConcurrency() {
141
+ const configured = Number(process.env.SIRIUS_MCP_ENRICH_CONCURRENCY ?? String(DEFAULT_MCP_ENRICH_CONCURRENCY));
142
+ if (!Number.isFinite(configured) || configured < 1) {
143
+ return DEFAULT_MCP_ENRICH_CONCURRENCY;
144
+ }
145
+ return Math.min(Math.floor(configured), MAX_MCP_ENRICH_CONCURRENCY);
146
+ }
147
+ function boundedItemCount(value, max) {
148
+ if (!Number.isFinite(value) || value <= 0) {
149
+ return 0;
150
+ }
151
+ return Math.min(Math.floor(value), max);
152
+ }
153
+ async function mapWithConcurrency(items, concurrency, mapper) {
154
+ if (items.length === 0) {
155
+ return [];
156
+ }
157
+ const results = new Array(items.length);
158
+ let nextIndex = 0;
159
+ const workerCount = Math.min(concurrency, items.length);
160
+ await Promise.all(Array.from({ length: workerCount }, async () => {
161
+ while (nextIndex < items.length) {
162
+ const index = nextIndex;
163
+ nextIndex += 1;
164
+ results[index] = await mapper(items[index], index);
165
+ }
166
+ }));
167
+ return results;
168
+ }
package/dist/http.js ADDED
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs/promises";
2
+ export const INDEX_URL = trimSlash(process.env.SIRIUS_INDEX_URL ?? "http://127.0.0.1:8000");
3
+ export const AGENT_URL = trimSlash(process.env.SIRIUS_AGENT_URL ?? "http://127.0.0.1:3987");
4
+ // General rule: MCP tool calls should fail fast enough for callers to recover from stalled index/agent HTTP paths.
5
+ const DEFAULT_MCP_HTTP_TIMEOUT_MS = 30_000;
6
+ // General rule: timeout overrides should remain bounded so a wedged dependency cannot pin the MCP process forever.
7
+ const MAX_MCP_HTTP_TIMEOUT_MS = 300_000;
8
+ // General rule: upstream error bodies may echo request credentials, so MCP-visible HTTP errors must redact them.
9
+ const API_TOKEN_REDACTION_TEXT = "[redacted-token]";
10
+ // General rule: local repo-agent calls should carry a non-CORS-simple header that browser forms/images cannot send.
11
+ const AGENT_CLIENT_HEADER = "x-sirius-agent-client";
12
+ const AGENT_CLIENT_HEADER_VALUE = "sirius";
13
+ export async function indexGet(route) {
14
+ return requestJson(`${INDEX_URL}${route}`, { token: await resolveIndexApiToken() });
15
+ }
16
+ export async function indexPost(route, body) {
17
+ return requestJson(`${INDEX_URL}${route}`, {
18
+ method: "POST",
19
+ token: await resolveIndexApiToken(),
20
+ body
21
+ });
22
+ }
23
+ export async function indexDelete(route) {
24
+ return requestJson(`${INDEX_URL}${route}`, {
25
+ method: "DELETE",
26
+ token: await resolveIndexApiToken()
27
+ });
28
+ }
29
+ export async function agentGet(route) {
30
+ return requestJson(`${AGENT_URL}${route}`, { agentClient: true });
31
+ }
32
+ export async function agentPost(route, body) {
33
+ return requestJson(`${AGENT_URL}${route}`, {
34
+ method: "POST",
35
+ body,
36
+ agentClient: true
37
+ });
38
+ }
39
+ async function requestJson(url, options = {}) {
40
+ const headers = {};
41
+ let body;
42
+ if (options.body !== undefined) {
43
+ headers["content-type"] = "application/json";
44
+ body = JSON.stringify(options.body);
45
+ }
46
+ if (options.token) {
47
+ headers.authorization = `Bearer ${options.token}`;
48
+ }
49
+ if (options.agentClient) {
50
+ headers[AGENT_CLIENT_HEADER] = AGENT_CLIENT_HEADER_VALUE;
51
+ }
52
+ const timeoutMs = mcpHttpTimeoutMs();
53
+ const controller = new AbortController();
54
+ let timedOut = false;
55
+ const timeout = setTimeout(() => {
56
+ timedOut = true;
57
+ controller.abort();
58
+ }, timeoutMs);
59
+ try {
60
+ const response = await fetch(url, {
61
+ method: options.method ?? "GET",
62
+ headers,
63
+ body,
64
+ signal: controller.signal
65
+ });
66
+ if (!response.ok) {
67
+ const statusText = redactApiToken(response.statusText, options.token);
68
+ const text = redactApiToken(await response.text(), options.token);
69
+ throw new Error(`${response.status} ${statusText}: ${text}`);
70
+ }
71
+ if (response.status === 204) {
72
+ return undefined;
73
+ }
74
+ return (await response.json());
75
+ }
76
+ catch (error) {
77
+ if (timedOut) {
78
+ throw new Error(`Sirius MCP HTTP request timed out after ${timeoutMs} ms: ${url}`);
79
+ }
80
+ throw error;
81
+ }
82
+ finally {
83
+ clearTimeout(timeout);
84
+ }
85
+ }
86
+ function redactApiToken(text, token) {
87
+ const trimmed = token?.trim();
88
+ if (!trimmed) {
89
+ return text;
90
+ }
91
+ return text
92
+ .split(`Bearer ${trimmed}`)
93
+ .join(`Bearer ${API_TOKEN_REDACTION_TEXT}`)
94
+ .split(trimmed)
95
+ .join(API_TOKEN_REDACTION_TEXT);
96
+ }
97
+ export async function resolveIndexApiToken() {
98
+ const configuredToken = firstNonEmpty(process.env.SIRIUS_API_TOKEN);
99
+ if (configuredToken) {
100
+ return configuredToken;
101
+ }
102
+ const configuredTokenFile = firstNonEmpty(process.env.SIRIUS_API_TOKEN_FILE);
103
+ if (!configuredTokenFile) {
104
+ return undefined;
105
+ }
106
+ return readApiTokenFile(configuredTokenFile);
107
+ }
108
+ async function readApiTokenFile(tokenFile) {
109
+ let raw;
110
+ try {
111
+ raw = await fs.readFile(tokenFile, "utf8");
112
+ }
113
+ catch {
114
+ throw new Error("Sirius API token file from SIRIUS_API_TOKEN_FILE is not readable");
115
+ }
116
+ const token = raw.trim();
117
+ if (!token) {
118
+ throw new Error("Sirius API token file from SIRIUS_API_TOKEN_FILE is empty");
119
+ }
120
+ return token;
121
+ }
122
+ function firstNonEmpty(...values) {
123
+ for (const value of values) {
124
+ const trimmed = value?.trim();
125
+ if (trimmed) {
126
+ return trimmed;
127
+ }
128
+ }
129
+ return undefined;
130
+ }
131
+ function mcpHttpTimeoutMs() {
132
+ const configured = Number(firstNonEmpty(process.env.SIRIUS_MCP_HTTP_TIMEOUT_MS, process.env.SIRIUS_API_TIMEOUT_MS) ??
133
+ String(DEFAULT_MCP_HTTP_TIMEOUT_MS));
134
+ if (!Number.isFinite(configured) || configured < 1) {
135
+ return DEFAULT_MCP_HTTP_TIMEOUT_MS;
136
+ }
137
+ return Math.min(Math.floor(configured), MAX_MCP_HTTP_TIMEOUT_MS);
138
+ }
139
+ function trimSlash(value) {
140
+ return value.replace(/\/+$/, "");
141
+ }
package/dist/impact.js ADDED
@@ -0,0 +1,76 @@
1
+ import { enrichGraphNodes, enrichSymbols } from "./enrich.js";
2
+ import { agentPost, indexPost } from "./http.js";
3
+ import { hasStaleSnippet } from "./search.js";
4
+ export async function impactAnalysisWithOptionalStaleSync(options, dependencies = {}) {
5
+ const load = dependencies.load ??
6
+ (() => indexPost("/v1/analysis/impact", {
7
+ repo_id: options.repoId,
8
+ symbol: options.symbol,
9
+ depth: options.depth
10
+ }));
11
+ const sync = dependencies.sync ?? (() => agentPost(`/repos/${encodeURIComponent(options.repoId)}/sync`, { full: false }));
12
+ const enrich = (response) => enrichImpactResponse(response, options, dependencies);
13
+ const initial = await load();
14
+ const initialEnriched = await enrich(initial);
15
+ if (!options.includeCode || !options.autoSyncStale || !impactHasStaleSnippet(initialEnriched)) {
16
+ return initialEnriched;
17
+ }
18
+ try {
19
+ const syncResult = await sync();
20
+ const refreshed = await load();
21
+ return {
22
+ ...(await enrich(refreshed)),
23
+ sync_result: syncResult
24
+ };
25
+ }
26
+ catch (error) {
27
+ return {
28
+ ...initialEnriched,
29
+ sync_error: error instanceof Error ? error.message : String(error)
30
+ };
31
+ }
32
+ }
33
+ async function enrichImpactResponse(response, options, dependencies) {
34
+ const enrichSymbol = dependencies.enrichSymbol ?? defaultEnrichSymbol;
35
+ const enrichNodes = dependencies.enrichNodes ?? enrichGraphNodes;
36
+ const callerNodes = [...response.direct_callers, ...response.indirect_callers];
37
+ const [symbol, enrichedCallers] = await Promise.all([
38
+ enrichSymbol(options.repoId, response.symbol, options.includeCode),
39
+ enrichNodes(options.repoId, callerNodes, options.includeCode, options.maxCodeNodes)
40
+ ]);
41
+ const directCount = response.direct_callers.length;
42
+ return {
43
+ ...response,
44
+ symbol,
45
+ direct_callers: enrichedCallers.slice(0, directCount),
46
+ indirect_callers: enrichedCallers.slice(directCount),
47
+ code_enrichment: impactCodeEnrichmentSummary(response, options)
48
+ };
49
+ }
50
+ async function defaultEnrichSymbol(repoId, symbol, includeCode) {
51
+ if (!symbol) {
52
+ return null;
53
+ }
54
+ const [enriched] = await enrichSymbols(repoId, [symbol], includeCode);
55
+ return enriched;
56
+ }
57
+ function impactHasStaleSnippet(response) {
58
+ const snippetAware = [
59
+ ...(response.symbol ? [response.symbol] : []),
60
+ ...response.direct_callers,
61
+ ...response.indirect_callers
62
+ ];
63
+ return hasStaleSnippet(snippetAware);
64
+ }
65
+ function impactCodeEnrichmentSummary(response, options) {
66
+ const maxCodeNodes = options.includeCode ? options.maxCodeNodes : 0;
67
+ const callerCount = response.direct_callers.length + response.indirect_callers.length;
68
+ const selectedCallers = Math.min(callerCount, maxCodeNodes);
69
+ const selectedRoot = response.symbol && options.includeCode ? 1 : 0;
70
+ return {
71
+ enabled: options.includeCode,
72
+ max_code_nodes: maxCodeNodes,
73
+ enriched_nodes: selectedRoot + selectedCallers,
74
+ omitted_nodes: Math.max(0, callerCount - selectedCallers)
75
+ };
76
+ }
package/dist/index.js ADDED
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { enrichGraphNodes, enrichSearchResults, enrichSymbols } from "./enrich.js";
6
+ import { agentGet, agentPost, indexDelete, indexGet, indexPost } from "./http.js";
7
+ import { impactAnalysisWithOptionalStaleSync } from "./impact.js";
8
+ import { hasStaleSnippet, searchWithOptionalStaleSync } from "./search.js";
9
+ import { localFilePathSchema, publicRepoIdSchema } from "./validation.js";
10
+ // General rule: package-installed CLIs need a no-session self-check path for install validation.
11
+ const MCP_PROXY_VERSION = "0.1.0";
12
+ // General rule: expanded call graphs can include many nodes, while source snippets dominate MCP payload size.
13
+ const DEFAULT_CALL_CHAIN_CODE_NODE_LIMIT = 50;
14
+ // General rule: graph snippet enrichment should stay bounded by the largest accepted call-chain graph window.
15
+ const MAX_CALL_CHAIN_CODE_NODE_LIMIT = 200;
16
+ // General rule: expanded call graphs should preserve a readable edge window without sending dense graph noise by default.
17
+ const DEFAULT_CALL_CHAIN_EDGE_LIMIT = 300;
18
+ // General rule: callers may inspect dense graph neighborhoods, but edge output should remain explicitly bounded.
19
+ const MAX_CALL_CHAIN_EDGE_LIMIT = 2000;
20
+ if (printCliMetadata(process.argv.slice(2))) {
21
+ process.exit(0);
22
+ }
23
+ const server = new McpServer({
24
+ name: "sirius-mcp",
25
+ version: MCP_PROXY_VERSION
26
+ });
27
+ server.registerTool("semantic_code_search", {
28
+ title: "Semantic Code Search",
29
+ description: "Find relevant code by meaning using the Sirius index, optionally returning local snippets.",
30
+ inputSchema: {
31
+ repo_id: publicRepoIdSchema,
32
+ query: z.string().min(1),
33
+ top_k: z.number().int().min(1).max(50).default(10),
34
+ include_code: z.boolean().default(true),
35
+ auto_sync_stale: z.boolean().default(true)
36
+ }
37
+ }, async ({ repo_id, query, top_k, include_code, auto_sync_stale }) => {
38
+ return asJson(await searchWithOptionalStaleSync({
39
+ includeCode: include_code,
40
+ autoSyncStale: auto_sync_stale,
41
+ search: () => indexPost("/v1/search/semantic", {
42
+ repo_id,
43
+ query,
44
+ top_k,
45
+ include_symbols: true
46
+ }),
47
+ enrich: (results, includeCode) => enrichSearchResults(repo_id, results, includeCode),
48
+ sync: () => agentPost(`/repos/${encodeURIComponent(repo_id)}/sync`, { full: false })
49
+ }));
50
+ });
51
+ server.registerTool("hybrid_search", {
52
+ title: "Hybrid Search",
53
+ description: "Combine semantic, symbol, and file-path relevance using the Sirius index, optionally returning local snippets.",
54
+ inputSchema: {
55
+ repo_id: publicRepoIdSchema,
56
+ query: z.string().min(1),
57
+ top_k: z.number().int().min(1).max(50).default(10),
58
+ include_code: z.boolean().default(true),
59
+ auto_sync_stale: z.boolean().default(true)
60
+ }
61
+ }, async ({ repo_id, query, top_k, include_code, auto_sync_stale }) => {
62
+ return asJson(await searchWithOptionalStaleSync({
63
+ includeCode: include_code,
64
+ autoSyncStale: auto_sync_stale,
65
+ search: () => indexPost("/v1/search/hybrid", {
66
+ repo_id,
67
+ query,
68
+ top_k,
69
+ include_graph_context: true
70
+ }),
71
+ enrich: (results, includeCode) => enrichSearchResults(repo_id, results, includeCode),
72
+ sync: () => agentPost(`/repos/${encodeURIComponent(repo_id)}/sync`, { full: false })
73
+ }));
74
+ });
75
+ server.registerTool("path_search", {
76
+ title: "Path Search",
77
+ description: "Find indexed files by path relevance.",
78
+ inputSchema: {
79
+ repo_id: publicRepoIdSchema,
80
+ query: z.string().min(1),
81
+ top_k: z.number().int().min(1).max(100).default(10)
82
+ }
83
+ }, async ({ repo_id, query, top_k }) => {
84
+ const response = await indexPost("/v1/search/path", { repo_id, query, top_k });
85
+ return asJson(response);
86
+ });
87
+ server.registerTool("symbol_search", {
88
+ title: "Symbol Search",
89
+ description: "Find functions, classes, types, methods, and modules by exact or fuzzy name.",
90
+ inputSchema: {
91
+ repo_id: publicRepoIdSchema,
92
+ symbol: z.string().min(1),
93
+ kind: z.string().optional(),
94
+ top_k: z.number().int().min(1).max(50).default(10),
95
+ include_code: z.boolean().default(true),
96
+ auto_sync_stale: z.boolean().default(true)
97
+ }
98
+ }, async ({ repo_id, symbol, kind, top_k, include_code, auto_sync_stale }) => {
99
+ return asJson(await searchWithOptionalStaleSync({
100
+ includeCode: include_code,
101
+ autoSyncStale: auto_sync_stale,
102
+ search: () => indexPost("/v1/search/symbol", {
103
+ repo_id,
104
+ symbol,
105
+ kind,
106
+ top_k
107
+ }),
108
+ enrich: (results, includeCode) => enrichSymbols(repo_id, results, includeCode),
109
+ sync: () => agentPost(`/repos/${encodeURIComponent(repo_id)}/sync`, { full: false })
110
+ }));
111
+ });
112
+ server.registerTool("get_file_range", {
113
+ title: "Get File Range",
114
+ description: "Fetch exact source lines from the local repo agent.",
115
+ inputSchema: {
116
+ repo_id: publicRepoIdSchema,
117
+ file_path: localFilePathSchema,
118
+ start_line: z.number().int().min(1),
119
+ end_line: z.number().int().min(1)
120
+ }
121
+ }, async ({ repo_id, file_path, start_line, end_line }) => {
122
+ const params = new URLSearchParams({
123
+ file_path,
124
+ start_line: String(start_line),
125
+ end_line: String(end_line)
126
+ });
127
+ const response = await agentGet(`/repos/${encodeURIComponent(repo_id)}/file-range?${params.toString()}`);
128
+ return asJson(response);
129
+ });
130
+ server.registerTool("get_call_chain", {
131
+ title: "Get Call Chain",
132
+ description: "Traverse caller/callee relationships for a symbol.",
133
+ inputSchema: {
134
+ repo_id: publicRepoIdSchema,
135
+ symbol_id: z.string().optional(),
136
+ symbol: z.string().optional(),
137
+ direction: z.enum(["callers", "callees", "both"]).default("callers"),
138
+ depth: z.number().int().min(1).max(8).default(3),
139
+ max_nodes: z.number().int().min(1).max(200).default(50),
140
+ max_edges: z.number().int().min(0).max(MAX_CALL_CHAIN_EDGE_LIMIT).default(DEFAULT_CALL_CHAIN_EDGE_LIMIT),
141
+ max_unresolved: z.number().int().min(0).max(1000).default(200),
142
+ max_code_nodes: z.number().int().min(0).max(MAX_CALL_CHAIN_CODE_NODE_LIMIT).default(DEFAULT_CALL_CHAIN_CODE_NODE_LIMIT),
143
+ include_code: z.boolean().default(true),
144
+ auto_sync_stale: z.boolean().default(true)
145
+ }
146
+ }, async ({ repo_id, symbol_id, symbol, direction, depth, max_nodes, max_edges, max_unresolved, max_code_nodes, include_code, auto_sync_stale }) => {
147
+ return asJson(await callChainWithOptionalStaleSync({
148
+ repoId: repo_id,
149
+ symbolId: symbol_id,
150
+ symbol,
151
+ direction,
152
+ depth,
153
+ maxNodes: max_nodes,
154
+ maxEdges: max_edges,
155
+ maxUnresolved: max_unresolved,
156
+ maxCodeNodes: max_code_nodes,
157
+ includeCode: include_code,
158
+ autoSyncStale: auto_sync_stale
159
+ }));
160
+ });
161
+ server.registerTool("impact_analysis", {
162
+ title: "Impact Analysis",
163
+ description: "Estimate blast radius for a symbol by traversing callers and related files.",
164
+ inputSchema: {
165
+ repo_id: publicRepoIdSchema,
166
+ symbol: z.string().min(1),
167
+ depth: z.number().int().min(1).max(8).default(3),
168
+ max_code_nodes: z.number().int().min(0).max(MAX_CALL_CHAIN_CODE_NODE_LIMIT).default(DEFAULT_CALL_CHAIN_CODE_NODE_LIMIT),
169
+ include_code: z.boolean().default(true),
170
+ auto_sync_stale: z.boolean().default(true)
171
+ }
172
+ }, async ({ repo_id, symbol, depth, max_code_nodes, include_code, auto_sync_stale }) => {
173
+ return asJson(await impactAnalysisWithOptionalStaleSync({
174
+ repoId: repo_id,
175
+ symbol,
176
+ depth,
177
+ maxCodeNodes: max_code_nodes,
178
+ includeCode: include_code,
179
+ autoSyncStale: auto_sync_stale
180
+ }));
181
+ });
182
+ server.registerTool("ripgrep_search", {
183
+ title: "Ripgrep Search",
184
+ description: "Run exact text search locally inside a registered repo.",
185
+ inputSchema: {
186
+ repo_id: publicRepoIdSchema,
187
+ pattern: z.string().min(1),
188
+ glob: z.string().optional(),
189
+ max_results: z.number().int().min(1).max(200).default(50)
190
+ }
191
+ }, async ({ repo_id, pattern, glob, max_results }) => {
192
+ const response = await agentPost(`/repos/${encodeURIComponent(repo_id)}/ripgrep`, {
193
+ pattern,
194
+ glob,
195
+ max_results
196
+ });
197
+ return asJson(response);
198
+ });
199
+ server.registerTool("sync_repo", {
200
+ title: "Sync Repo",
201
+ description: "Trigger a manual incremental or full sync through the local repo agent.",
202
+ inputSchema: {
203
+ repo_id: publicRepoIdSchema,
204
+ full: z.boolean().default(false)
205
+ }
206
+ }, async ({ repo_id, full }) => {
207
+ const response = await agentPost(`/repos/${encodeURIComponent(repo_id)}/sync`, { full });
208
+ return asJson(response);
209
+ });
210
+ server.registerTool("sync_status", {
211
+ title: "Sync Status",
212
+ description: "Return local repo-agent status and server index status.",
213
+ inputSchema: {
214
+ repo_id: publicRepoIdSchema
215
+ }
216
+ }, async ({ repo_id }) => {
217
+ const [serverStatus, agentStatus] = await Promise.allSettled([
218
+ indexGet(`/v1/repos/${encodeURIComponent(repo_id)}/status`),
219
+ agentGet(`/repos/${encodeURIComponent(repo_id)}/status`)
220
+ ]);
221
+ return asJson({
222
+ server: settledValue(serverStatus),
223
+ agent: settledValue(agentStatus)
224
+ });
225
+ });
226
+ server.registerTool("delete_repo_index", {
227
+ title: "Delete Repo Index",
228
+ description: "Purge derived index data for a repo from the index server.",
229
+ inputSchema: {
230
+ repo_id: publicRepoIdSchema
231
+ }
232
+ }, async ({ repo_id }) => {
233
+ const response = await indexDelete(`/v1/repos/${encodeURIComponent(repo_id)}`);
234
+ return asJson(response);
235
+ });
236
+ const transport = new StdioServerTransport();
237
+ await server.connect(transport);
238
+ function asJson(value) {
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text",
243
+ text: JSON.stringify(value, null, 2)
244
+ }
245
+ ]
246
+ };
247
+ }
248
+ function settledValue(result) {
249
+ if (result.status === "fulfilled") {
250
+ return result.value;
251
+ }
252
+ return { error: result.reason instanceof Error ? result.reason.message : String(result.reason) };
253
+ }
254
+ async function callChainWithOptionalStaleSync(options) {
255
+ const load = () => indexPost("/v1/graph/call-chain", {
256
+ repo_id: options.repoId,
257
+ symbol_id: options.symbolId,
258
+ symbol: options.symbol,
259
+ direction: options.direction,
260
+ depth: options.depth,
261
+ max_nodes: options.maxNodes,
262
+ max_edges: options.maxEdges,
263
+ max_unresolved: options.maxUnresolved
264
+ });
265
+ const initial = await load();
266
+ const initialNodes = await enrichGraphNodes(options.repoId, initial.nodes, options.includeCode, options.maxCodeNodes);
267
+ if (!options.includeCode || !options.autoSyncStale || !hasStaleSnippet(initialNodes)) {
268
+ return { ...initial, nodes: initialNodes, code_enrichment: codeEnrichmentSummary(initial.nodes.length, options) };
269
+ }
270
+ try {
271
+ const syncResult = await agentPost(`/repos/${encodeURIComponent(options.repoId)}/sync`, { full: false });
272
+ const refreshed = await load();
273
+ return {
274
+ ...refreshed,
275
+ nodes: await enrichGraphNodes(options.repoId, refreshed.nodes, options.includeCode, options.maxCodeNodes),
276
+ code_enrichment: codeEnrichmentSummary(refreshed.nodes.length, options),
277
+ sync_result: syncResult
278
+ };
279
+ }
280
+ catch (error) {
281
+ return {
282
+ ...initial,
283
+ nodes: initialNodes,
284
+ code_enrichment: codeEnrichmentSummary(initial.nodes.length, options),
285
+ sync_error: error instanceof Error ? error.message : String(error)
286
+ };
287
+ }
288
+ }
289
+ function codeEnrichmentSummary(nodeCount, options) {
290
+ const maxCodeNodes = options.includeCode ? options.maxCodeNodes : 0;
291
+ const enrichedNodes = Math.min(nodeCount, maxCodeNodes);
292
+ return {
293
+ enabled: options.includeCode,
294
+ max_code_nodes: maxCodeNodes,
295
+ enriched_nodes: enrichedNodes,
296
+ omitted_nodes: Math.max(0, nodeCount - enrichedNodes)
297
+ };
298
+ }
299
+ function printCliMetadata(args) {
300
+ if (args.includes("--version") || args.includes("-v")) {
301
+ console.log(MCP_PROXY_VERSION);
302
+ return true;
303
+ }
304
+ if (args.includes("--help") || args.includes("-h")) {
305
+ console.log([
306
+ "Usage: sirius-mcp-proxy [--version] [--help]",
307
+ "",
308
+ "Runs the Sirius MCP stdio proxy.",
309
+ "Configure SIRIUS_INDEX_URL, SIRIUS_AGENT_URL, and SIRIUS_API_TOKEN_FILE as needed."
310
+ ].join("\n"));
311
+ return true;
312
+ }
313
+ return false;
314
+ }
package/dist/search.js ADDED
@@ -0,0 +1,24 @@
1
+ export async function searchWithOptionalStaleSync(options) {
2
+ const initial = await options.search();
3
+ const initialResults = await options.enrich(initial.results, options.includeCode);
4
+ if (!options.includeCode || !options.autoSyncStale || !hasStaleSnippet(initialResults)) {
5
+ return { results: initialResults };
6
+ }
7
+ try {
8
+ const syncResult = await options.sync();
9
+ const refreshed = await options.search();
10
+ return {
11
+ results: await options.enrich(refreshed.results, options.includeCode),
12
+ sync_result: syncResult
13
+ };
14
+ }
15
+ catch (error) {
16
+ return {
17
+ results: initialResults,
18
+ sync_error: error instanceof Error ? error.message : String(error)
19
+ };
20
+ }
21
+ }
22
+ export function hasStaleSnippet(results) {
23
+ return results.some((result) => result.snippet?.stale === true);
24
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ // General rule: public repo ids must not collide with scoped storage keys or path routing.
3
+ const PUBLIC_REPO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
4
+ // General rule: local file reads must use normalized repo-relative slash paths.
5
+ const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/;
6
+ export const PUBLIC_REPO_ID_RULE = "use 1-128 ASCII letters, digits, dots, underscores, or hyphens, starting with a letter or digit";
7
+ export const LOCAL_FILE_PATH_RULE = "file_path must be a relative path inside the repo";
8
+ export const publicRepoIdSchema = z.string().regex(PUBLIC_REPO_ID_PATTERN, PUBLIC_REPO_ID_RULE);
9
+ export const localFilePathSchema = z.string().refine(isSafeLocalFilePath, LOCAL_FILE_PATH_RULE);
10
+ export function isSafeLocalFilePath(filePath) {
11
+ if (!filePath ||
12
+ filePath.includes("\0") ||
13
+ filePath.includes("\\") ||
14
+ filePath.startsWith("/") ||
15
+ WINDOWS_DRIVE_PATH_PATTERN.test(filePath)) {
16
+ return false;
17
+ }
18
+ return filePath.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
19
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@harivatsa/sirius-mcp-proxy",
3
+ "version": "0.1.0",
4
+ "description": "MCP proxy that joins Sirius index metadata with local source snippets.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "keywords": [
8
+ "sirius",
9
+ "mcp",
10
+ "code-search",
11
+ "claude"
12
+ ],
13
+ "bin": {
14
+ "sirius-mcp-proxy": "dist/index.js"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "dev": "tsx src/index.ts",
22
+ "prepack": "npm run build",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "test": "tsc -p tsconfig.test.json --noEmit && node --import tsx --test \"test/*.test.ts\""
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.17.0",
28
+ "zod": "^3.25.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^24.0.10",
32
+ "tsx": "^4.20.3",
33
+ "typescript": "^5.8.3"
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }