ctxman 3.0.0 → 3.1.1
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/.ctxman/llm-profiles.json +1067 -39
- package/.ctxman/profiles/backend-team.json +13 -0
- package/.ctxman/profiles/default.json +12 -0
- package/.ctxman/profiles/devops-team.json +21 -0
- package/.ctxman/profiles/frontend-team.json +20 -0
- package/.ctxman/templates/bug-fix.json +17 -0
- package/.ctxman/templates/code-review.json +9 -0
- package/.ctxman/templates/documentation.json +9 -0
- package/.ctxman/templates/feature.json +9 -0
- package/.ctxman/templates/full-context.json +9 -0
- package/.ctxman/templates/refactoring.json +18 -0
- package/.ctxman/templates/testing.json +9 -0
- package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
- package/LICENSE +21 -0
- package/README.md +381 -136
- package/bin/cli.js +1880 -529
- package/bin/cm-ask.js +18 -67
- package/bin/cm-audit.js +121 -0
- package/bin/cm-count.js +151 -0
- package/bin/cm-gitingest.js +128 -115
- package/bin/cm-map.js +242 -0
- package/bin/cm-mcp.js +293 -0
- package/bin/cm-pack.js +405 -0
- package/bin/cm-policy.js +170 -0
- package/bin/cm-pr.js +248 -0
- package/bin/cm-search.js +178 -0
- package/bin/cm-update.js +128 -128
- package/bin/cm-why.js +197 -0
- package/bin/mcp-server.js +8 -24
- package/ctxman.js +237 -183
- package/examples/README.md +19 -4
- package/examples/github-actions/ctxman-check.yml +49 -0
- package/examples/wizard-profiles/security-audit/profile.json +7 -1
- package/index.d.ts +1422 -0
- package/index.js +55 -31
- package/lib/agents/pr-failure-agent.js +48 -45
- package/lib/analyzers/ai-suggester.js +451 -0
- package/lib/analyzers/dependency-scanner.js +505 -0
- package/lib/analyzers/duplicate-detector.js +264 -0
- package/lib/analyzers/go-method-analyzer.js +151 -110
- package/lib/analyzers/import-tracker.js +431 -0
- package/lib/analyzers/method-analyzer.js +1055 -434
- package/lib/analyzers/performance-dashboard.js +687 -0
- package/lib/analyzers/token-calculator.js +895 -504
- package/lib/analyzers/unused-exports-detector.js +336 -0
- package/lib/api/mcp/install.js +424 -0
- package/lib/api/mcp/prompts.js +281 -281
- package/lib/api/mcp/resources.js +300 -241
- package/lib/api/mcp/sandbox.js +89 -0
- package/lib/api/mcp/server.js +772 -312
- package/lib/api/mcp/start.js +125 -0
- package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
- package/lib/api/rest/server.js +105 -28
- package/lib/cache/CacheManager.js +9 -17
- package/lib/core/Analyzer.js +16 -10
- package/lib/core/ContextBuilder.js +19 -28
- package/lib/core/Reporter.js +15 -14
- package/lib/core/Scanner.js +81 -12
- package/lib/count/index.js +100 -0
- package/lib/formatters/format-registry.js +394 -372
- package/lib/formatters/gitingest-formatter.js +656 -621
- package/lib/formatters/toon-formatter-v1.3.js +637 -632
- package/lib/formatters/toon-formatter.js +239 -240
- package/lib/integrations/git/BlameTracker.js +4 -4
- package/lib/integrations/git/DiffAnalyzer.js +38 -31
- package/lib/integrations/git/GitClient.js +71 -53
- package/lib/lsp/server.js +311 -0
- package/lib/map/graph.js +299 -0
- package/lib/map/imports.js +233 -0
- package/lib/map/index.js +33 -0
- package/lib/map/rank.js +253 -0
- package/lib/map/render.js +346 -0
- package/lib/map/resolver.js +574 -0
- package/lib/pack/budget.js +111 -0
- package/lib/pack/code-lexer.js +360 -0
- package/lib/pack/compress.js +414 -0
- package/lib/pack/index.js +354 -0
- package/lib/pack/renderers/common.js +134 -0
- package/lib/pack/renderers/index.js +24 -0
- package/lib/pack/renderers/json.js +47 -0
- package/lib/pack/renderers/markdown.js +54 -0
- package/lib/pack/renderers/plain.js +38 -0
- package/lib/pack/renderers/xml.js +85 -0
- package/lib/pack/select.js +177 -0
- package/lib/pack/split.js +182 -0
- package/lib/pack/tree.js +35 -0
- package/lib/parsers/gitignore-parser.js +116 -107
- package/lib/parsers/method-filter-parser.js +50 -33
- package/lib/plugins/ExporterPlugin.js +3 -3
- package/lib/plugins/LanguagePlugin.js +3 -3
- package/lib/plugins/PluginManager.js +7 -13
- package/lib/policy/audit.js +312 -0
- package/lib/policy/enforce.js +125 -0
- package/lib/policy/ignore-files.js +169 -0
- package/lib/policy/index.js +53 -0
- package/lib/policy/policy.js +489 -0
- package/lib/pr/diff-parser.js +267 -0
- package/lib/pr/method-mapper.js +148 -0
- package/lib/pr/pr-context.js +829 -0
- package/lib/pr/pr-git.js +149 -0
- package/lib/pr/pr-renderer.js +644 -0
- package/lib/pr/related-tests.js +296 -0
- package/lib/rag/DataSourcePlugin.js +5 -5
- package/lib/rag/EmbeddingProvider.js +19 -8
- package/lib/rag/Indexer.js +14 -5
- package/lib/rag/VectorStoreFactory.js +3 -3
- package/lib/rag/ask.js +122 -0
- package/lib/rag/sources/FileDataSource.js +12 -9
- package/lib/rag/stores/BaseVectorStore.js +5 -5
- package/lib/rag/stores/ChromaDBStore.js +10 -10
- package/lib/rag/stores/LanceDBStore.js +29 -19
- package/lib/rag/stores/PineconeStore.js +11 -9
- package/lib/search/bm25.js +433 -0
- package/lib/search/code-units.js +201 -0
- package/lib/search/symbols.js +189 -0
- package/lib/ui/dashboard.js +120 -89
- package/lib/ui/index.js +1 -6
- package/lib/ui/init-wizard-ui.js +550 -0
- package/lib/ui/progress-bar.js +94 -88
- package/lib/ui/select-input.js +40 -32
- package/lib/ui/template-selector.js +99 -0
- package/lib/ui/wizard.js +374 -329
- package/lib/utils/claude-token-counter.js +151 -0
- package/lib/utils/cli-output.js +17 -0
- package/lib/utils/clipboard-utils.js +49 -50
- package/lib/utils/config-utils.js +81 -66
- package/lib/utils/context-versioning.js +532 -0
- package/lib/utils/error-handler.js +112 -112
- package/lib/utils/file-utils.js +153 -69
- package/lib/utils/format-converter.js +356 -283
- package/lib/utils/git-utils.js +366 -294
- package/lib/utils/llm-cost-estimator.js +281 -0
- package/lib/utils/llm-detector.js +242 -78
- package/lib/utils/logger.js +246 -237
- package/lib/utils/messages.js +355 -0
- package/lib/utils/multi-repo-manager.js +364 -0
- package/lib/utils/profile-manager.js +380 -0
- package/lib/utils/safe-read.js +61 -0
- package/lib/utils/secret-redactor.js +61 -0
- package/lib/utils/snapshot-manager.js +679 -0
- package/lib/utils/template-manager.js +378 -0
- package/lib/utils/token-utils.js +201 -170
- package/lib/utils/tokenizer-adapter.js +421 -377
- package/lib/utils/updater.js +539 -506
- package/lib/watch/FileWatcher.js +12 -8
- package/lib/watch/IncrementalAnalyzer.js +7 -7
- package/lib/why/coupling.js +81 -0
- package/lib/why/errors.js +23 -0
- package/lib/why/git.js +169 -0
- package/lib/why/history.js +108 -0
- package/lib/why/index.js +720 -0
- package/lib/why/ownership.js +79 -0
- package/lib/why/paths.js +207 -0
- package/lib/why/references.js +259 -0
- package/lib/why/render.js +722 -0
- package/lib/why/search.js +358 -0
- package/lib/why/target.js +389 -0
- package/lib/wizards/config-generator.js +257 -0
- package/lib/wizards/project-detector.js +572 -0
- package/package.json +44 -10
- package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
- package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
- package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server startup shared by `ctxman mcp` (bin/cm-mcp.js) and bin/mcp-server.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseArgs } from 'util';
|
|
6
|
+
import MCPServer from './server.js';
|
|
7
|
+
import { resolveRootDir } from './sandbox.js';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_PORT = 3000;
|
|
10
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
11
|
+
export const TRANSPORTS = ['stdio', 'sse'];
|
|
12
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];
|
|
13
|
+
|
|
14
|
+
const SERVER_OPTIONS = {
|
|
15
|
+
transport: { type: 'string' },
|
|
16
|
+
port: { type: 'string' },
|
|
17
|
+
host: { type: 'string' },
|
|
18
|
+
'api-key': { type: 'string' },
|
|
19
|
+
cors: { type: 'boolean' },
|
|
20
|
+
root: { type: 'string' },
|
|
21
|
+
help: { type: 'boolean', short: 'h' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse the server options. Both `--port=3000` and `--port 3000` are accepted.
|
|
26
|
+
*
|
|
27
|
+
* @param {string[]} argv - Arguments after the command name
|
|
28
|
+
* @param {Object} [options]
|
|
29
|
+
* @param {boolean} [options.strict=true] - Reject unknown options, positionals and invalid
|
|
30
|
+
* values. bin/mcp-server.js passes false to keep its old tolerance: unknown options are
|
|
31
|
+
* ignored and an unknown transport means stdio.
|
|
32
|
+
* @returns {{transport: string, port: number, host: string, apiKey?: string, cors: boolean,
|
|
33
|
+
* root?: string, help: boolean}}
|
|
34
|
+
* @throws {Error} In strict mode, on unknown options or invalid values
|
|
35
|
+
*/
|
|
36
|
+
export function parseServerArgs(argv, { strict = true } = {}) {
|
|
37
|
+
const { values } = parseArgs({
|
|
38
|
+
args: argv,
|
|
39
|
+
options: SERVER_OPTIONS,
|
|
40
|
+
strict,
|
|
41
|
+
allowPositionals: !strict,
|
|
42
|
+
});
|
|
43
|
+
// Lenient parsing turns a string option without a value into `true`
|
|
44
|
+
const text = (value) => (typeof value === 'string' ? value : undefined);
|
|
45
|
+
|
|
46
|
+
let transport = text(values.transport) ?? 'stdio';
|
|
47
|
+
if (!TRANSPORTS.includes(transport)) {
|
|
48
|
+
if (strict) {
|
|
49
|
+
throw new Error(`Unknown transport "${transport}" (expected ${TRANSPORTS.join(' or ')})`);
|
|
50
|
+
}
|
|
51
|
+
transport = 'stdio';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const portText = text(values.port);
|
|
55
|
+
let port = DEFAULT_PORT;
|
|
56
|
+
// Like before, bin/mcp-server.js treats an empty `--port=` as the default
|
|
57
|
+
if (portText !== undefined && (strict || portText)) {
|
|
58
|
+
port = strict ? Number(portText) : parseInt(portText, 10);
|
|
59
|
+
// Lenient mode only cares about the port when it is going to listen on it
|
|
60
|
+
if (
|
|
61
|
+
!(Number.isInteger(port) && port >= 0 && port <= 65535) &&
|
|
62
|
+
(strict || transport === 'sse')
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(`Invalid port "${portText}"`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
transport,
|
|
70
|
+
port,
|
|
71
|
+
host: text(values.host) ?? DEFAULT_HOST,
|
|
72
|
+
apiKey: text(values['api-key']),
|
|
73
|
+
cors: values.cors === true,
|
|
74
|
+
root: text(values.root),
|
|
75
|
+
help: values.help === true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Start the MCP server.
|
|
81
|
+
*
|
|
82
|
+
* With the stdio transport stdout is the JSON-RPC channel, so console.log/info/debug (logger
|
|
83
|
+
* lines, ignore-rule notices) are redirected to stderr for the rest of the process.
|
|
84
|
+
*
|
|
85
|
+
* @param {ReturnType<typeof parseServerArgs>} options
|
|
86
|
+
* @param {Object} [context]
|
|
87
|
+
* @param {Object} [context.env=process.env] - Read for CTXMAN_MCP_ROOT when --root is absent
|
|
88
|
+
* @returns {Promise<{server: MCPServer, root: string|null, transport?: Object}>}
|
|
89
|
+
*/
|
|
90
|
+
export async function startMcpServer(options, { env = process.env } = {}) {
|
|
91
|
+
const rootOption = options.root ?? (env.CTXMAN_MCP_ROOT || undefined);
|
|
92
|
+
const root = rootOption ? resolveRootDir(rootOption) : null;
|
|
93
|
+
|
|
94
|
+
if (options.transport !== 'sse') {
|
|
95
|
+
console.log = console.info = console.debug = console.error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const server = new MCPServer({ root });
|
|
99
|
+
if (root) {
|
|
100
|
+
console.error(`Ctxman MCP Server confined to ${root}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (options.transport === 'sse') {
|
|
104
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
105
|
+
if (!options.apiKey && !LOOPBACK_HOSTS.includes(host)) {
|
|
106
|
+
console.error(
|
|
107
|
+
`Warning: listening on ${host} without --api-key; anyone who can reach it can read the project`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
// Loaded only for this transport: it pulls in express
|
|
111
|
+
const { HttpSseTransport } = await import('./transports/HttpSseTransport.js');
|
|
112
|
+
const transport = new HttpSseTransport({
|
|
113
|
+
port: options.port ?? DEFAULT_PORT,
|
|
114
|
+
host,
|
|
115
|
+
cors: options.cors,
|
|
116
|
+
auth: options.apiKey ? { apiKey: options.apiKey } : undefined,
|
|
117
|
+
});
|
|
118
|
+
await transport.start();
|
|
119
|
+
await server.start(transport.transport);
|
|
120
|
+
return { server, root, transport };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await server.start();
|
|
124
|
+
return { server, root };
|
|
125
|
+
}
|
|
@@ -1,63 +1,71 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import { StreamableHTTPServerTransport } from
|
|
4
|
-
import { randomUUID } from
|
|
1
|
+
import cors from 'cors';
|
|
2
|
+
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
5
|
|
|
6
6
|
export class HttpSseTransport {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
this.app.use(cors());
|
|
13
|
-
this.app.use(express.json());
|
|
14
|
-
|
|
15
|
-
// Authentication Middleware
|
|
16
|
-
if (options.auth && options.auth.apiKey) {
|
|
17
|
-
this.app.use((req, res, next) => {
|
|
18
|
-
const apiKey = req.headers["x-api-key"] || req.query.apiKey;
|
|
19
|
-
if (!apiKey || apiKey !== options.auth.apiKey) {
|
|
20
|
-
return res.status(401).json({ error: "Unauthorized" });
|
|
21
|
-
}
|
|
22
|
-
next();
|
|
23
|
-
});
|
|
24
|
-
}
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.port = options.port || 3000;
|
|
9
|
+
this.host = options.host || '127.0.0.1';
|
|
10
|
+
this.endpoint = options.endpoint || '/mcp';
|
|
25
11
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
sessionIdGenerator: () => randomUUID(),
|
|
29
|
-
enableJsonResponse: false, // Prefer SSE
|
|
30
|
-
...options.transportOptions
|
|
31
|
-
});
|
|
12
|
+
// JSON body parser plus, on a loopback host, Host header validation against DNS rebinding
|
|
13
|
+
this.app = createMcpExpressApp({ host: this.host });
|
|
32
14
|
|
|
33
|
-
|
|
15
|
+
// Off by default: with `Access-Control-Allow-Origin: *` any web page the user opens
|
|
16
|
+
// could call the tools and read the project
|
|
17
|
+
if (options.cors) {
|
|
18
|
+
this.app.use(cors({ exposedHeaders: ['Mcp-Session-Id'] }));
|
|
34
19
|
}
|
|
35
20
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
21
|
+
// Authentication Middleware
|
|
22
|
+
if (options.auth && options.auth.apiKey) {
|
|
23
|
+
this.app.use((req, res, next) => {
|
|
24
|
+
const apiKey = req.headers['x-api-key'] || req.query.apiKey;
|
|
25
|
+
if (!apiKey || apiKey !== options.auth.apiKey) {
|
|
26
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
27
|
+
}
|
|
28
|
+
next();
|
|
29
|
+
});
|
|
46
30
|
}
|
|
47
31
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
});
|
|
55
|
-
}
|
|
32
|
+
// Initialize StreamableHTTPServerTransport
|
|
33
|
+
this.transport = new StreamableHTTPServerTransport({
|
|
34
|
+
sessionIdGenerator: () => randomUUID(),
|
|
35
|
+
enableJsonResponse: false, // Prefer SSE
|
|
36
|
+
...options.transportOptions,
|
|
37
|
+
});
|
|
56
38
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
39
|
+
this.setupRoutes();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setupRoutes() {
|
|
43
|
+
// express.json() has already consumed the request stream, so hand over the parsed body
|
|
44
|
+
this.app.all(this.endpoint, async (req, res) => {
|
|
45
|
+
await this.transport.handleRequest(req, res, req.body);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Support for separate messages endpoint if needed, but StreamableHTTPServerTransport handles it
|
|
49
|
+
this.app.post(`${this.endpoint}/messages`, async (req, res) => {
|
|
50
|
+
await this.transport.handleRequest(req, res, req.body);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async start() {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
this.server = this.app.listen(this.port, this.host, () => {
|
|
57
|
+
console.error(
|
|
58
|
+
`MCP HTTP/SSE Server running at http://${this.host}:${this.port}${this.endpoint}`
|
|
59
|
+
);
|
|
60
|
+
resolve(this.transport);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async close() {
|
|
66
|
+
if (this.server) {
|
|
67
|
+
this.server.close();
|
|
62
68
|
}
|
|
69
|
+
await this.transport.close();
|
|
70
|
+
}
|
|
63
71
|
}
|
package/lib/api/rest/server.js
CHANGED
|
@@ -9,26 +9,32 @@
|
|
|
9
9
|
* - Authentication (optional)
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import fs from 'fs';
|
|
12
13
|
import http from 'http';
|
|
14
|
+
import path from 'path';
|
|
13
15
|
import { URL } from 'url';
|
|
14
16
|
import { getLogger } from '../../utils/logger.js';
|
|
15
17
|
import Scanner from '../../core/Scanner.js';
|
|
16
18
|
import Analyzer from '../../core/Analyzer.js';
|
|
17
19
|
import ContextBuilder from '../../core/ContextBuilder.js';
|
|
18
|
-
import Reporter from '../../core/Reporter.js';
|
|
19
|
-
import GitClient from '../../integrations/git/GitClient.js';
|
|
20
20
|
import DiffAnalyzer from '../../integrations/git/DiffAnalyzer.js';
|
|
21
|
+
import { isValidGitRef } from '../../integrations/git/GitClient.js';
|
|
22
|
+
import { loadPolicy } from '../../policy/policy.js';
|
|
23
|
+
import { guardExport, PolicyViolationError } from '../../policy/enforce.js';
|
|
21
24
|
|
|
22
25
|
const logger = getLogger('APIServer');
|
|
23
26
|
|
|
27
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
28
|
+
|
|
24
29
|
export class APIServer {
|
|
25
30
|
constructor(options = {}) {
|
|
26
31
|
this.options = {
|
|
27
32
|
port: 3000,
|
|
28
33
|
host: 'localhost',
|
|
29
34
|
authToken: null,
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
// Off by default: with CORS on, any web page the user opens can read the responses
|
|
36
|
+
cors: false,
|
|
37
|
+
...options,
|
|
32
38
|
};
|
|
33
39
|
|
|
34
40
|
this.server = null;
|
|
@@ -88,6 +94,12 @@ export class APIServer {
|
|
|
88
94
|
* @param {http.ServerResponse} res
|
|
89
95
|
*/
|
|
90
96
|
async handleRequest(req, res) {
|
|
97
|
+
// DNS-rebinding guard: a server bound to loopback only answers to loopback host names
|
|
98
|
+
if (LOOPBACK_HOSTS.has(this.options.host) && !this.isLoopbackHostHeader(req.headers.host)) {
|
|
99
|
+
this.sendError(res, 403, 'Forbidden host');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
91
103
|
// CORS headers
|
|
92
104
|
if (this.options.cors) {
|
|
93
105
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
@@ -122,7 +134,7 @@ export class APIServer {
|
|
|
122
134
|
}
|
|
123
135
|
} catch (error) {
|
|
124
136
|
logger.error(`Request error: ${error.message}`);
|
|
125
|
-
this.sendError(res, 500, error.message);
|
|
137
|
+
this.sendError(res, error.statusCode || 500, error.message);
|
|
126
138
|
}
|
|
127
139
|
}
|
|
128
140
|
|
|
@@ -201,21 +213,31 @@ export class APIServer {
|
|
|
201
213
|
return;
|
|
202
214
|
}
|
|
203
215
|
|
|
216
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
217
|
+
this.sendError(res, 404, `File not found: ${filePath}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
204
221
|
// Analyze single file
|
|
205
222
|
const analyzer = new Analyzer({ methodLevel: true });
|
|
206
223
|
const fileInfo = {
|
|
207
224
|
path: filePath,
|
|
208
225
|
relativePath: filePath,
|
|
209
226
|
name: path.basename(filePath),
|
|
210
|
-
extension: path.extname(filePath)
|
|
227
|
+
extension: path.extname(filePath),
|
|
211
228
|
};
|
|
212
229
|
|
|
213
230
|
const analysis = await analyzer.analyzeFile(fileInfo);
|
|
231
|
+
// analyzeFile() returns null for binary and other non-source files
|
|
232
|
+
if (!analysis) {
|
|
233
|
+
this.sendError(res, 400, `Not a text source file: ${filePath}`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
214
236
|
|
|
215
237
|
this.sendJSON(res, {
|
|
216
238
|
file: filePath,
|
|
217
239
|
methods: analysis.methods || [],
|
|
218
|
-
totalMethods: analysis.methodCount || 0
|
|
240
|
+
totalMethods: analysis.methodCount || 0,
|
|
219
241
|
});
|
|
220
242
|
}
|
|
221
243
|
|
|
@@ -243,6 +265,11 @@ export class APIServer {
|
|
|
243
265
|
const projectPath = params.path || process.cwd();
|
|
244
266
|
const since = params.since || null;
|
|
245
267
|
|
|
268
|
+
if (since && !isValidGitRef(since)) {
|
|
269
|
+
this.sendError(res, 400, 'Invalid "since" parameter: expected a branch, tag or commit');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
246
273
|
const diffAnalyzer = new DiffAnalyzer(projectPath);
|
|
247
274
|
const analysis = diffAnalyzer.analyzeChanges(since);
|
|
248
275
|
|
|
@@ -260,11 +287,15 @@ export class APIServer {
|
|
|
260
287
|
methodLevel: body.methodLevel || false,
|
|
261
288
|
targetModel: body.targetModel || null,
|
|
262
289
|
targetTokens: body.targetTokens || null,
|
|
263
|
-
useCase: body.useCase || 'custom'
|
|
290
|
+
useCase: body.useCase || 'custom',
|
|
264
291
|
};
|
|
265
292
|
|
|
293
|
+
// Export policy of the project: denied files are dropped by the scanner, the rest of the
|
|
294
|
+
// rules are checked before the context is sent
|
|
295
|
+
const policy = loadPolicy(projectPath);
|
|
296
|
+
|
|
266
297
|
// Build context
|
|
267
|
-
const scanner = new Scanner(projectPath);
|
|
298
|
+
const scanner = new Scanner(projectPath, { policy });
|
|
268
299
|
const files = scanner.scan();
|
|
269
300
|
|
|
270
301
|
const analyzer = new Analyzer(options);
|
|
@@ -273,6 +304,32 @@ export class APIServer {
|
|
|
273
304
|
const builder = new ContextBuilder(options);
|
|
274
305
|
const context = builder.build(result);
|
|
275
306
|
|
|
307
|
+
if (policy.active) {
|
|
308
|
+
context.metadata.removedByPolicy = scanner.getStats().filesDenied;
|
|
309
|
+
const exported = Object.values(context.files || {}).flat();
|
|
310
|
+
try {
|
|
311
|
+
guardExport(policy, {
|
|
312
|
+
command: 'rest POST /api/v1/context',
|
|
313
|
+
root: projectPath,
|
|
314
|
+
destination: 'rest',
|
|
315
|
+
format: 'json',
|
|
316
|
+
targetModel: options.targetModel,
|
|
317
|
+
files: exported,
|
|
318
|
+
removedByPolicy: context.metadata.removedByPolicy,
|
|
319
|
+
// Same serialization as sendJSON(), so the hash matches the response body
|
|
320
|
+
text: JSON.stringify(context, null, 2),
|
|
321
|
+
});
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (!(error instanceof PolicyViolationError)) throw error;
|
|
324
|
+
this.sendJSON(
|
|
325
|
+
res,
|
|
326
|
+
{ error: error.message, statusCode: 403, violations: error.violations },
|
|
327
|
+
403
|
|
328
|
+
);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
276
333
|
this.sendJSON(res, context);
|
|
277
334
|
}
|
|
278
335
|
|
|
@@ -289,24 +346,24 @@ export class APIServer {
|
|
|
289
346
|
description: 'Analyze project files and get token counts',
|
|
290
347
|
parameters: {
|
|
291
348
|
path: 'Project path (optional, defaults to cwd)',
|
|
292
|
-
methods: 'Include method-level analysis (true/false)'
|
|
293
|
-
}
|
|
349
|
+
methods: 'Include method-level analysis (true/false)',
|
|
350
|
+
},
|
|
294
351
|
},
|
|
295
352
|
{
|
|
296
353
|
path: '/api/v1/methods',
|
|
297
354
|
method: 'GET',
|
|
298
355
|
description: 'Extract methods from a file',
|
|
299
356
|
parameters: {
|
|
300
|
-
file: 'File path (required)'
|
|
301
|
-
}
|
|
357
|
+
file: 'File path (required)',
|
|
358
|
+
},
|
|
302
359
|
},
|
|
303
360
|
{
|
|
304
361
|
path: '/api/v1/stats',
|
|
305
362
|
method: 'GET',
|
|
306
363
|
description: 'Get project statistics',
|
|
307
364
|
parameters: {
|
|
308
|
-
path: 'Project path (optional)'
|
|
309
|
-
}
|
|
365
|
+
path: 'Project path (optional)',
|
|
366
|
+
},
|
|
310
367
|
},
|
|
311
368
|
{
|
|
312
369
|
path: '/api/v1/diff',
|
|
@@ -314,8 +371,8 @@ export class APIServer {
|
|
|
314
371
|
description: 'Analyze git diff',
|
|
315
372
|
parameters: {
|
|
316
373
|
path: 'Project path (optional)',
|
|
317
|
-
since: 'Git reference (optional, e.g., "main", "HEAD~5")'
|
|
318
|
-
}
|
|
374
|
+
since: 'Git reference (optional, e.g., "main", "HEAD~5")',
|
|
375
|
+
},
|
|
319
376
|
},
|
|
320
377
|
{
|
|
321
378
|
path: '/api/v1/context',
|
|
@@ -326,10 +383,10 @@ export class APIServer {
|
|
|
326
383
|
methodLevel: 'Include methods (boolean)',
|
|
327
384
|
targetModel: 'Target LLM model',
|
|
328
385
|
targetTokens: 'Target token budget',
|
|
329
|
-
useCase: 'Use case template'
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
]
|
|
386
|
+
useCase: 'Use case template',
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
],
|
|
333
390
|
};
|
|
334
391
|
|
|
335
392
|
this.sendJSON(res, docs);
|
|
@@ -340,6 +397,21 @@ export class APIServer {
|
|
|
340
397
|
* @param {http.IncomingMessage} req
|
|
341
398
|
* @returns {boolean}
|
|
342
399
|
*/
|
|
400
|
+
/**
|
|
401
|
+
* Whether a Host header names this machine's loopback interface
|
|
402
|
+
* @param {string} hostHeader - e.g. "localhost:3000" or "[::1]:3000"
|
|
403
|
+
* @returns {boolean}
|
|
404
|
+
*/
|
|
405
|
+
isLoopbackHostHeader(hostHeader) {
|
|
406
|
+
if (!hostHeader) return false;
|
|
407
|
+
try {
|
|
408
|
+
const { hostname } = new URL(`http://${hostHeader}`);
|
|
409
|
+
return LOOPBACK_HOSTS.has(hostname.replace(/^\[|\]$/g, ''));
|
|
410
|
+
} catch (_error) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
343
415
|
checkAuth(req) {
|
|
344
416
|
const authHeader = req.headers.authorization;
|
|
345
417
|
|
|
@@ -360,7 +432,7 @@ export class APIServer {
|
|
|
360
432
|
return new Promise((resolve, reject) => {
|
|
361
433
|
let body = '';
|
|
362
434
|
|
|
363
|
-
req.on('data', chunk => {
|
|
435
|
+
req.on('data', (chunk) => {
|
|
364
436
|
body += chunk.toString();
|
|
365
437
|
});
|
|
366
438
|
|
|
@@ -368,8 +440,9 @@ export class APIServer {
|
|
|
368
440
|
try {
|
|
369
441
|
const parsed = JSON.parse(body);
|
|
370
442
|
resolve(parsed);
|
|
371
|
-
} catch (
|
|
372
|
-
|
|
443
|
+
} catch (_error) {
|
|
444
|
+
// A malformed body is the client's fault, not a server error
|
|
445
|
+
reject(Object.assign(new Error('Invalid JSON'), { statusCode: 400 }));
|
|
373
446
|
}
|
|
374
447
|
});
|
|
375
448
|
|
|
@@ -395,10 +468,14 @@ export class APIServer {
|
|
|
395
468
|
* @param {string} message
|
|
396
469
|
*/
|
|
397
470
|
sendError(res, statusCode, message) {
|
|
398
|
-
this.sendJSON(
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
471
|
+
this.sendJSON(
|
|
472
|
+
res,
|
|
473
|
+
{
|
|
474
|
+
error: message,
|
|
475
|
+
statusCode: statusCode,
|
|
476
|
+
},
|
|
477
|
+
statusCode
|
|
478
|
+
);
|
|
402
479
|
}
|
|
403
480
|
}
|
|
404
481
|
|
|
@@ -24,7 +24,7 @@ export class CacheManager {
|
|
|
24
24
|
ttl: 3600, // 1 hour in seconds
|
|
25
25
|
path: '.context-cache',
|
|
26
26
|
maxSize: 100 * 1024 * 1024, // 100MB
|
|
27
|
-
...options
|
|
27
|
+
...options,
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
this.memoryCache = new Map();
|
|
@@ -33,7 +33,7 @@ export class CacheManager {
|
|
|
33
33
|
misses: 0,
|
|
34
34
|
writes: 0,
|
|
35
35
|
evictions: 0,
|
|
36
|
-
errors: 0
|
|
36
|
+
errors: 0,
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// Initialize cache directory
|
|
@@ -139,8 +139,7 @@ export class CacheManager {
|
|
|
139
139
|
this.stats.hits++;
|
|
140
140
|
logger.debug(`Cache hit (disk): ${key}`);
|
|
141
141
|
return cached.data;
|
|
142
|
-
|
|
143
|
-
} catch (error) {
|
|
142
|
+
} catch (_error) {
|
|
144
143
|
this.stats.misses++;
|
|
145
144
|
return null;
|
|
146
145
|
}
|
|
@@ -161,7 +160,7 @@ export class CacheManager {
|
|
|
161
160
|
const cacheEntry = {
|
|
162
161
|
data: analysis,
|
|
163
162
|
timestamp: Date.now(),
|
|
164
|
-
modifiedTime: modifiedTime
|
|
163
|
+
modifiedTime: modifiedTime,
|
|
165
164
|
};
|
|
166
165
|
|
|
167
166
|
try {
|
|
@@ -173,7 +172,6 @@ export class CacheManager {
|
|
|
173
172
|
|
|
174
173
|
this.stats.writes++;
|
|
175
174
|
logger.debug(`Cache set: ${key}`);
|
|
176
|
-
|
|
177
175
|
} catch (error) {
|
|
178
176
|
logger.error(`Cache set error: ${error.message}`);
|
|
179
177
|
this.stats.errors++;
|
|
@@ -232,10 +230,7 @@ export class CacheManager {
|
|
|
232
230
|
* @returns {string}
|
|
233
231
|
*/
|
|
234
232
|
getCacheKey(filePath) {
|
|
235
|
-
return crypto
|
|
236
|
-
.createHash('md5')
|
|
237
|
-
.update(filePath)
|
|
238
|
-
.digest('hex');
|
|
233
|
+
return crypto.createHash('md5').update(filePath).digest('hex');
|
|
239
234
|
}
|
|
240
235
|
|
|
241
236
|
/**
|
|
@@ -296,17 +291,14 @@ export class CacheManager {
|
|
|
296
291
|
*/
|
|
297
292
|
getStats() {
|
|
298
293
|
const totalRequests = this.stats.hits + this.stats.misses;
|
|
299
|
-
const hitRate = totalRequests > 0
|
|
300
|
-
? (this.stats.hits / totalRequests * 100).toFixed(1)
|
|
301
|
-
: 0;
|
|
294
|
+
const hitRate = totalRequests > 0 ? ((this.stats.hits / totalRequests) * 100).toFixed(1) : 0;
|
|
302
295
|
|
|
303
296
|
return {
|
|
304
297
|
...this.stats,
|
|
305
298
|
totalRequests,
|
|
306
299
|
hitRate: parseFloat(hitRate),
|
|
307
|
-
cacheSize:
|
|
308
|
-
? this.memoryCache.size
|
|
309
|
-
: this.getDiskCacheSize()
|
|
300
|
+
cacheSize:
|
|
301
|
+
this.options.strategy === 'memory' ? this.memoryCache.size : this.getDiskCacheSize(),
|
|
310
302
|
};
|
|
311
303
|
}
|
|
312
304
|
|
|
@@ -321,7 +313,7 @@ export class CacheManager {
|
|
|
321
313
|
|
|
322
314
|
const files = fs.readdirSync(cachePath);
|
|
323
315
|
return files.length;
|
|
324
|
-
} catch (
|
|
316
|
+
} catch (_error) {
|
|
325
317
|
return 0;
|
|
326
318
|
}
|
|
327
319
|
}
|