ctxman 3.0.0 → 3.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.
Files changed (223) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +380 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -13
  18. package/bin/cm-audit.js +124 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +683 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +675 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +113 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +294 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +52 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/sources/FileDataSource.js +6 -6
  108. package/lib/rag/stores/BaseVectorStore.js +5 -5
  109. package/lib/rag/stores/ChromaDBStore.js +10 -10
  110. package/lib/rag/stores/LanceDBStore.js +29 -19
  111. package/lib/rag/stores/PineconeStore.js +11 -9
  112. package/lib/search/bm25.js +433 -0
  113. package/lib/search/code-units.js +201 -0
  114. package/lib/search/symbols.js +189 -0
  115. package/lib/ui/dashboard.js +120 -89
  116. package/lib/ui/index.js +1 -6
  117. package/lib/ui/init-wizard-ui.js +550 -0
  118. package/lib/ui/progress-bar.js +94 -88
  119. package/lib/ui/select-input.js +40 -32
  120. package/lib/ui/template-selector.js +99 -0
  121. package/lib/ui/wizard.js +374 -329
  122. package/lib/utils/claude-token-counter.js +151 -0
  123. package/lib/utils/cli-output.js +17 -0
  124. package/lib/utils/clipboard-utils.js +49 -50
  125. package/lib/utils/config-utils.js +81 -66
  126. package/lib/utils/context-versioning.js +532 -0
  127. package/lib/utils/error-handler.js +112 -112
  128. package/lib/utils/file-utils.js +153 -69
  129. package/lib/utils/format-converter.js +356 -283
  130. package/lib/utils/git-utils.js +366 -294
  131. package/lib/utils/llm-cost-estimator.js +281 -0
  132. package/lib/utils/llm-detector.js +242 -78
  133. package/lib/utils/logger.js +243 -237
  134. package/lib/utils/messages.js +355 -0
  135. package/lib/utils/multi-repo-manager.js +364 -0
  136. package/lib/utils/profile-manager.js +380 -0
  137. package/lib/utils/safe-read.js +61 -0
  138. package/lib/utils/secret-redactor.js +61 -0
  139. package/lib/utils/snapshot-manager.js +679 -0
  140. package/lib/utils/template-manager.js +378 -0
  141. package/lib/utils/token-utils.js +201 -170
  142. package/lib/utils/tokenizer-adapter.js +421 -377
  143. package/lib/utils/updater.js +539 -506
  144. package/lib/watch/FileWatcher.js +12 -8
  145. package/lib/watch/IncrementalAnalyzer.js +7 -7
  146. package/lib/why/coupling.js +81 -0
  147. package/lib/why/errors.js +23 -0
  148. package/lib/why/git.js +169 -0
  149. package/lib/why/history.js +108 -0
  150. package/lib/why/index.js +720 -0
  151. package/lib/why/ownership.js +79 -0
  152. package/lib/why/paths.js +207 -0
  153. package/lib/why/references.js +259 -0
  154. package/lib/why/render.js +722 -0
  155. package/lib/why/search.js +358 -0
  156. package/lib/why/target.js +389 -0
  157. package/lib/wizards/config-generator.js +255 -0
  158. package/lib/wizards/project-detector.js +571 -0
  159. package/package.json +44 -10
  160. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  161. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  223. 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 express from "express";
2
- import cors from "cors";
3
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
- import { randomUUID } from "crypto";
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
- constructor(options = {}) {
8
- this.port = options.port || 3000;
9
- this.endpoint = options.endpoint || "/mcp";
10
- this.app = express();
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
- // Initialize StreamableHTTPServerTransport
27
- this.transport = new StreamableHTTPServerTransport({
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
- this.setupRoutes();
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
- setupRoutes() {
37
- // Handle all requests to the endpoint via the transport
38
- this.app.all(this.endpoint, async (req, res) => {
39
- await this.transport.handleRequest(req, res);
40
- });
41
-
42
- // Support for separate messages endpoint if needed, but StreamableHTTPServerTransport handles it
43
- this.app.post(`${this.endpoint}/messages`, async (req, res) => {
44
- await this.transport.handleRequest(req, res);
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
- async start() {
49
- return new Promise((resolve) => {
50
- this.server = this.app.listen(this.port, () => {
51
- console.error(`MCP HTTP/SSE Server running on port ${this.port} at ${this.endpoint}`);
52
- resolve(this.transport);
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
- async close() {
58
- if (this.server) {
59
- this.server.close();
60
- }
61
- await this.transport.close();
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
  }
@@ -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
- cors: true,
31
- ...options
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 (error) {
372
- reject(new Error('Invalid JSON'));
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(res, {
399
- error: message,
400
- statusCode: statusCode
401
- }, statusCode);
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: this.options.strategy === 'memory'
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 (error) {
316
+ } catch (_error) {
325
317
  return 0;
326
318
  }
327
319
  }