@homenshum/convex-mcp-nodebench 0.4.1 → 0.7.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.
@@ -20,7 +20,12 @@ function runPreDeployChecks(projectDir) {
20
20
  if (!convexDir) {
21
21
  checks.push({ name: "convex_dir_exists", passed: false, message: "No convex/ directory found" });
22
22
  blockers.push("No convex/ directory found");
23
- return { passed: false, checks, blockers };
23
+ return {
24
+ passed: false,
25
+ checks,
26
+ blockers: blockers.map((b, i) => ({ priority: i + 1, blocker: b, fixFirst: i === 0 })),
27
+ fixOrder: "Fix #1: Create a convex/ directory. Then re-run convex_pre_deploy_gate.",
28
+ };
24
29
  }
25
30
  checks.push({ name: "convex_dir_exists", passed: true, message: `Found at ${convexDir}` });
26
31
  // Check 2: schema.ts exists
@@ -101,10 +106,19 @@ function runPreDeployChecks(projectDir) {
101
106
  checks.push({ name: "generated_dir_exists", passed: false, message: "_generated/ not found - run 'npx convex dev' first" });
102
107
  blockers.push("Run 'npx convex dev' to initialize the project before deploying");
103
108
  }
109
+ // Add priority ordering to blockers
110
+ const prioritizedBlockers = blockers.map((b, i) => ({
111
+ priority: i + 1,
112
+ blocker: b,
113
+ fixFirst: i === 0,
114
+ }));
104
115
  return {
105
116
  passed: blockers.length === 0,
106
117
  checks,
107
- blockers,
118
+ blockers: prioritizedBlockers,
119
+ fixOrder: blockers.length > 0
120
+ ? `Fix ${blockers.length} blocker(s) in order: ${blockers.map((_, i) => `#${i + 1}`).join(" → ")}. Then re-run convex_pre_deploy_gate.`
121
+ : "All checks passed. Safe to deploy.",
108
122
  };
109
123
  }
110
124
  function checkEnvVars(projectDir) {
@@ -222,8 +236,34 @@ export const deploymentTools = [
222
236
  handler: async (args) => {
223
237
  const projectDir = resolve(args.projectDir);
224
238
  const result = checkEnvVars(projectDir);
239
+ // Group missing vars by service for actionable output
240
+ const serviceGroups = {};
241
+ for (const v of result.missingInEnvFile) {
242
+ const vUp = v.toUpperCase();
243
+ const svc = vUp.includes("OPENAI") ? "OpenAI" :
244
+ vUp.includes("GEMINI") || vUp.includes("GOOGLE") ? "Google" :
245
+ vUp.includes("OPENBB") ? "OpenBB" :
246
+ vUp.includes("TWILIO") ? "Twilio" :
247
+ vUp.includes("LINKEDIN") ? "LinkedIn" :
248
+ vUp.includes("GITHUB") ? "GitHub" :
249
+ vUp.includes("STRIPE") ? "Stripe" :
250
+ vUp.includes("OPENROUTER") ? "OpenRouter" :
251
+ vUp.includes("RESEARCH") ? "Research MCP" :
252
+ vUp.includes("MCP") ? "MCP" :
253
+ vUp.includes("NTFY") ? "Ntfy" :
254
+ vUp.includes("XAI") ? "xAI" :
255
+ vUp.includes("CLERK") ? "Clerk" :
256
+ vUp.includes("CONVEX") ? "Convex" :
257
+ "Other";
258
+ if (!serviceGroups[svc])
259
+ serviceGroups[svc] = [];
260
+ serviceGroups[svc].push(v);
261
+ }
225
262
  return {
226
263
  ...result,
264
+ missingByService: Object.entries(serviceGroups)
265
+ .sort(([, a], [, b]) => b.length - a.length)
266
+ .map(([service, vars]) => ({ service, count: vars.length, vars })),
227
267
  quickRef: getQuickRef("convex_check_env_vars"),
228
268
  };
229
269
  },
@@ -0,0 +1,2 @@
1
+ import type { McpTool } from "../types.js";
2
+ export declare const devSetupTools: McpTool[];
@@ -0,0 +1,170 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { getDb, genId } from "../db.js";
4
+ import { getQuickRef } from "./toolRegistry.js";
5
+ function auditDevSetup(projectDir) {
6
+ const issues = [];
7
+ const checks = [];
8
+ // Check 1: .gitignore includes _generated/
9
+ const gitignorePath = join(projectDir, ".gitignore");
10
+ if (existsSync(gitignorePath)) {
11
+ const gitignore = readFileSync(gitignorePath, "utf-8");
12
+ if (gitignore.includes("_generated") || gitignore.includes("convex/_generated")) {
13
+ checks.push({ area: "gitignore", status: "pass", detail: "_generated/ is in .gitignore" });
14
+ }
15
+ else {
16
+ checks.push({ area: "gitignore", status: "warn", detail: "_generated/ not in .gitignore — generated files may be committed" });
17
+ issues.push({
18
+ severity: "warning",
19
+ area: "gitignore",
20
+ message: "_generated/ directory is not in .gitignore. These files are auto-generated and should not be committed.",
21
+ fix: "Add `convex/_generated/` to .gitignore",
22
+ });
23
+ }
24
+ }
25
+ else {
26
+ checks.push({ area: "gitignore", status: "fail", detail: "No .gitignore file found" });
27
+ issues.push({
28
+ severity: "warning",
29
+ area: "gitignore",
30
+ message: "No .gitignore file found. Generated and environment files may be committed.",
31
+ fix: "Create a .gitignore with at least: node_modules/, convex/_generated/, .env.local",
32
+ });
33
+ }
34
+ // Check 2: .env.example exists
35
+ const envExamplePath = join(projectDir, ".env.example");
36
+ if (existsSync(envExamplePath)) {
37
+ checks.push({ area: "env_example", status: "pass", detail: ".env.example exists for onboarding" });
38
+ }
39
+ else {
40
+ // Check if there are env vars in use
41
+ const envLocalPath = join(projectDir, ".env.local");
42
+ const envPath = join(projectDir, ".env");
43
+ if (existsSync(envLocalPath) || existsSync(envPath)) {
44
+ checks.push({ area: "env_example", status: "warn", detail: ".env files exist but no .env.example for new developers" });
45
+ issues.push({
46
+ severity: "info",
47
+ area: "env_example",
48
+ message: "No .env.example file. New developers won't know which env vars to set.",
49
+ fix: "Create .env.example with placeholder values for all required environment variables",
50
+ });
51
+ }
52
+ }
53
+ // Check 3: convex.json exists and points to valid deployment
54
+ const convexJsonPath = join(projectDir, "convex.json");
55
+ if (existsSync(convexJsonPath)) {
56
+ try {
57
+ const convexJson = JSON.parse(readFileSync(convexJsonPath, "utf-8"));
58
+ if (convexJson.project) {
59
+ checks.push({ area: "convex_json", status: "pass", detail: `convex.json configured for project: ${convexJson.project}` });
60
+ }
61
+ else {
62
+ checks.push({ area: "convex_json", status: "warn", detail: "convex.json exists but no project configured" });
63
+ }
64
+ }
65
+ catch {
66
+ checks.push({ area: "convex_json", status: "fail", detail: "convex.json exists but is invalid JSON" });
67
+ issues.push({
68
+ severity: "critical",
69
+ area: "convex_json",
70
+ message: "convex.json is invalid JSON. Convex CLI won't work.",
71
+ fix: "Fix the JSON syntax in convex.json or delete and run `npx convex dev` to regenerate",
72
+ });
73
+ }
74
+ }
75
+ // Check 4: package.json has convex as dependency
76
+ const pkgJsonPath = join(projectDir, "package.json");
77
+ if (existsSync(pkgJsonPath)) {
78
+ try {
79
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
80
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
81
+ if (deps.convex) {
82
+ checks.push({ area: "convex_dep", status: "pass", detail: `convex@${deps.convex} installed` });
83
+ }
84
+ else {
85
+ checks.push({ area: "convex_dep", status: "fail", detail: "convex not in dependencies" });
86
+ issues.push({
87
+ severity: "critical",
88
+ area: "convex_dep",
89
+ message: "convex package is not in dependencies. Install it first.",
90
+ fix: "Run: npm install convex",
91
+ });
92
+ }
93
+ }
94
+ catch { /* ignore parse errors */ }
95
+ }
96
+ // Check 5: tsconfig.json configured for Convex
97
+ const tsconfigPath = join(projectDir, "tsconfig.json");
98
+ if (existsSync(tsconfigPath)) {
99
+ const tsconfig = readFileSync(tsconfigPath, "utf-8");
100
+ if (/\"strict\"\s*:\s*true/.test(tsconfig)) {
101
+ checks.push({ area: "tsconfig", status: "pass", detail: "TypeScript strict mode enabled" });
102
+ }
103
+ else {
104
+ checks.push({ area: "tsconfig", status: "info", detail: "TypeScript strict mode not enabled — recommended for Convex" });
105
+ }
106
+ }
107
+ // Check 6: _generated/ directory exists (project initialized)
108
+ const convexDir = join(projectDir, "convex");
109
+ const generatedDir = join(convexDir, "_generated");
110
+ if (existsSync(generatedDir)) {
111
+ checks.push({ area: "initialization", status: "pass", detail: "_generated/ exists — project is initialized" });
112
+ }
113
+ else if (existsSync(convexDir)) {
114
+ checks.push({ area: "initialization", status: "warn", detail: "_generated/ not found — run `npx convex dev` to initialize" });
115
+ issues.push({
116
+ severity: "warning",
117
+ area: "initialization",
118
+ message: "Convex project not initialized — _generated/ directory is missing.",
119
+ fix: "Run `npx convex dev` to generate types and initialize the project",
120
+ });
121
+ }
122
+ // Check 7: Node modules installed
123
+ const nodeModulesPath = join(projectDir, "node_modules", "convex");
124
+ if (existsSync(nodeModulesPath)) {
125
+ checks.push({ area: "node_modules", status: "pass", detail: "convex package installed in node_modules" });
126
+ }
127
+ else if (existsSync(pkgJsonPath)) {
128
+ checks.push({ area: "node_modules", status: "warn", detail: "convex not in node_modules — run npm install" });
129
+ }
130
+ return { issues, checks };
131
+ }
132
+ // ── Tool Definition ─────────────────────────────────────────────────
133
+ export const devSetupTools = [
134
+ {
135
+ name: "convex_audit_dev_setup",
136
+ description: "Audit Convex project development setup: .gitignore includes _generated/, .env.example exists, convex.json is valid, convex is in dependencies, TypeScript strict mode, project initialization status.",
137
+ inputSchema: {
138
+ type: "object",
139
+ properties: {
140
+ projectDir: {
141
+ type: "string",
142
+ description: "Absolute path to the project root",
143
+ },
144
+ },
145
+ required: ["projectDir"],
146
+ },
147
+ handler: async (args) => {
148
+ const projectDir = resolve(args.projectDir);
149
+ const { issues, checks } = auditDevSetup(projectDir);
150
+ const db = getDb();
151
+ db.prepare("INSERT INTO audit_results (id, project_dir, audit_type, issues_json, issue_count) VALUES (?, ?, ?, ?, ?)").run(genId("audit"), projectDir, "dev_setup", JSON.stringify(issues), issues.length);
152
+ const passed = checks.filter((c) => c.status === "pass").length;
153
+ const warned = checks.filter((c) => c.status === "warn").length;
154
+ const failed = checks.filter((c) => c.status === "fail").length;
155
+ return {
156
+ summary: {
157
+ totalChecks: checks.length,
158
+ passed,
159
+ warned,
160
+ failed,
161
+ totalIssues: issues.length,
162
+ },
163
+ checks,
164
+ issues,
165
+ quickRef: getQuickRef("convex_audit_dev_setup"),
166
+ };
167
+ },
168
+ },
169
+ ];
170
+ //# sourceMappingURL=devSetupTools.js.map
@@ -11,19 +11,25 @@ export interface EmbeddingProvider {
11
11
  dimensions: number;
12
12
  embed(texts: string[]): Promise<Float32Array[]>;
13
13
  }
14
+ /** Node type in the bipartite graph: tool nodes vs domain (agent) nodes */
15
+ export type GraphNodeType = "tool" | "domain";
14
16
  interface EmbeddingIndexEntry {
15
17
  name: string;
16
18
  vector: Float32Array;
19
+ /** Node type for Agent-as-a-Graph bipartite scoring */
20
+ nodeType: GraphNodeType;
17
21
  }
18
22
  export declare function getEmbeddingProvider(): Promise<EmbeddingProvider | null>;
19
23
  export declare function initEmbeddingIndex(corpus: Array<{
20
24
  name: string;
21
25
  text: string;
26
+ nodeType?: GraphNodeType;
22
27
  }>): Promise<void>;
23
28
  export declare function embedQuery(text: string): Promise<Float32Array | null>;
24
29
  export declare function embeddingSearch(queryVec: Float32Array, limit?: number): Array<{
25
30
  name: string;
26
31
  similarity: number;
32
+ nodeType: GraphNodeType;
27
33
  }>;
28
34
  export declare function isEmbeddingReady(): boolean;
29
35
  export declare function getProviderName(): string | null;
@@ -120,6 +120,7 @@ async function _doInit(corpus) {
120
120
  _embeddingIndex = corpus.map((c) => ({
121
121
  name: c.name,
122
122
  vector: new Float32Array(cached.entries[c.name]),
123
+ nodeType: c.nodeType ?? "tool",
123
124
  }));
124
125
  return;
125
126
  }
@@ -129,6 +130,7 @@ async function _doInit(corpus) {
129
130
  _embeddingIndex = corpus.map((c, i) => ({
130
131
  name: c.name,
131
132
  vector: vectors[i],
133
+ nodeType: c.nodeType ?? "tool",
132
134
  }));
133
135
  const cacheData = {
134
136
  providerName: provider.name,
@@ -165,6 +167,7 @@ export function embeddingSearch(queryVec, limit = 30) {
165
167
  const scored = _embeddingIndex.map((entry) => ({
166
168
  name: entry.name,
167
169
  similarity: cosineSim(queryVec, entry.vector),
170
+ nodeType: entry.nodeType,
168
171
  }));
169
172
  scored.sort((a, b) => b.similarity - a.similarity);
170
173
  return scored.slice(0, limit);
@@ -287,6 +287,22 @@ export const functionTools = [
287
287
  db.prepare("INSERT INTO audit_results (id, project_dir, audit_type, issues_json, issue_count) VALUES (?, ?, ?, ?, ?)").run(genId("audit"), projectDir, "function_audit", JSON.stringify(issues), issues.length);
288
288
  const critical = issues.filter((i) => i.severity === "critical");
289
289
  const warnings = issues.filter((i) => i.severity === "warning");
290
+ // Aggregate issues by category for cleaner output
291
+ const categories = {};
292
+ for (const issue of issues) {
293
+ const cat = issue.message.includes("missing args") ? "missing_args_validator" :
294
+ issue.message.includes("missing returns") ? "missing_returns_validator" :
295
+ issue.message.includes("missing handler") ? "missing_handler_old_syntax" :
296
+ issue.message.includes("sensitive") ? "sensitive_function_public" :
297
+ issue.message.includes("queries cannot") ? "query_cross_call_violation" :
298
+ issue.message.includes("multiple actions") ? "action_from_action" :
299
+ "other";
300
+ if (!categories[cat])
301
+ categories[cat] = { severity: issue.severity, count: 0, examples: [] };
302
+ categories[cat].count++;
303
+ if (categories[cat].examples.length < 5)
304
+ categories[cat].examples.push(issue);
305
+ }
290
306
  return {
291
307
  summary: {
292
308
  totalFunctions: functions.length,
@@ -296,7 +312,14 @@ export const functionTools = [
296
312
  critical: critical.length,
297
313
  warnings: warnings.length,
298
314
  },
299
- issues,
315
+ issuesByCategory: Object.entries(categories)
316
+ .sort(([, a], [, b]) => (b.severity === "critical" ? 1 : 0) - (a.severity === "critical" ? 1 : 0) || b.count - a.count)
317
+ .map(([cat, data]) => ({
318
+ category: cat,
319
+ severity: data.severity,
320
+ count: data.count,
321
+ examples: data.examples,
322
+ })),
300
323
  quickRef: getQuickRef("convex_audit_functions"),
301
324
  };
302
325
  },
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { join, resolve, dirname } from "node:path";
3
3
  import { getQuickRef } from "./toolRegistry.js";
4
4
  // ── Helpers ──────────────────────────────────────────────────────────
5
5
  function findConvexDir(projectDir) {
@@ -10,21 +10,28 @@ function findConvexDir(projectDir) {
10
10
  }
11
11
  return null;
12
12
  }
13
- function analyzeHttpEndpoints(convexDir) {
14
- const httpPath = join(convexDir, "http.ts");
15
- if (!existsSync(httpPath)) {
16
- return { hasHttp: false, routes: [], issues: [], hasCors: false, hasOptionsHandler: false };
13
+ /** Resolve a relative import to a .ts file path */
14
+ function resolveImport(fromFile, importPath) {
15
+ const dir = dirname(fromFile);
16
+ const candidates = [
17
+ join(dir, importPath + ".ts"),
18
+ join(dir, importPath, "index.ts"),
19
+ join(dir, importPath + ".tsx"),
20
+ ];
21
+ for (const c of candidates) {
22
+ if (existsSync(c))
23
+ return c;
17
24
  }
18
- const content = readFileSync(httpPath, "utf-8");
19
- const lines = content.split("\n");
25
+ return null;
26
+ }
27
+ /** Extract http.route() calls from a file's content, supporting both path and pathPrefix */
28
+ function extractRoutesFromContent(content, sourceFile) {
20
29
  const routes = [];
21
- const issues = [];
22
- // Extract routes: http.route({ path: "...", method: "...", handler: ... })
23
- const routeBlockPattern = /http\.route\s*\(\s*\{/g;
30
+ // Match any variable name followed by .route({ ... })
31
+ const routeBlockPattern = /\w+\.route\s*\(\s*\{/g;
24
32
  let routeMatch;
25
33
  while ((routeMatch = routeBlockPattern.exec(content)) !== null) {
26
34
  const startIdx = routeMatch.index;
27
- // Find the closing of this route block (rough — find next })
28
35
  let depth = 0;
29
36
  let endIdx = startIdx;
30
37
  for (let i = startIdx; i < content.length; i++) {
@@ -39,21 +46,91 @@ function analyzeHttpEndpoints(convexDir) {
39
46
  }
40
47
  }
41
48
  const block = content.slice(startIdx, endIdx + 1);
42
- const pathMatch = block.match(/path\s*:\s*["']([^"']+)["']/);
49
+ // Support both path: and pathPrefix:
50
+ const pathMatch = block.match(/(?:path|pathPrefix)\s*:\s*["']([^"']+)["']/);
43
51
  const methodMatch = block.match(/method\s*:\s*["']([^"']+)["']/);
52
+ const isWildcard = /pathPrefix\s*:/.test(block) || /\/\*/.test(block) || /:\w+/.test(pathMatch?.[1] || "");
44
53
  const line = content.slice(0, startIdx).split("\n").length;
45
54
  if (pathMatch && methodMatch) {
46
55
  routes.push({
47
56
  path: pathMatch[1],
48
57
  method: methodMatch[1].toUpperCase(),
49
58
  line,
59
+ sourceFile,
50
60
  handlerType: /handler\s*:\s*httpAction/.test(block) ? "inline" : "imported",
61
+ isWildcard,
51
62
  });
52
63
  }
53
64
  }
65
+ return routes;
66
+ }
67
+ /** Follow imports from http.ts to find all router files */
68
+ function findRouterFiles(convexDir, httpPath) {
69
+ const files = [httpPath];
70
+ const visited = new Set([httpPath]);
71
+ const content = readFileSync(httpPath, "utf-8");
72
+ // Find relative imports that look like router sources
73
+ const importPattern = /import\s+(?:[\w{},\s]+\s+from\s+)?["'](\.[^"']+)["']/g;
74
+ let m;
75
+ while ((m = importPattern.exec(content)) !== null) {
76
+ const resolved = resolveImport(httpPath, m[1]);
77
+ if (resolved && !visited.has(resolved)) {
78
+ visited.add(resolved);
79
+ // Only include files that contain .route( or httpRouter or httpAction
80
+ try {
81
+ const fc = readFileSync(resolved, "utf-8");
82
+ if (/\.route\s*\(/.test(fc) || /httpRouter/.test(fc) || /httpAction/.test(fc)) {
83
+ files.push(resolved);
84
+ }
85
+ }
86
+ catch { /* skip unreadable */ }
87
+ }
88
+ }
89
+ return files;
90
+ }
91
+ /** Detect .registerRoutes(http), .addHttpRoutes(http) and similar composite calls */
92
+ function detectCompositeRouteSources(content, sourceFile) {
93
+ const composites = [];
94
+ const pattern = /(\w+)\.(registerRoutes|addHttpRoutes)\s*\(\s*\w+\s*\)/g;
95
+ let m;
96
+ while ((m = pattern.exec(content)) !== null) {
97
+ const line = content.slice(0, m.index).split("\n").length;
98
+ composites.push({
99
+ callee: `${m[1]}.${m[2]}()`,
100
+ sourceFile,
101
+ line,
102
+ });
103
+ }
104
+ return composites;
105
+ }
106
+ function analyzeHttpEndpoints(convexDir) {
107
+ const httpPath = join(convexDir, "http.ts");
108
+ if (!existsSync(httpPath)) {
109
+ return { hasHttp: false, routes: [], compositeRouteSources: [], issues: [], hasCors: false, hasOptionsHandler: false, filesScanned: [] };
110
+ }
111
+ const routerFiles = findRouterFiles(convexDir, httpPath);
112
+ const allRoutes = [];
113
+ const allComposites = [];
114
+ const issues = [];
115
+ let hasCorsInAny = false;
116
+ for (const filePath of routerFiles) {
117
+ const content = readFileSync(filePath, "utf-8");
118
+ const relPath = filePath.replace(convexDir, "").replace(/^[\\/]/, "");
119
+ // Extract routes
120
+ const routes = extractRoutesFromContent(content, relPath);
121
+ allRoutes.push(...routes);
122
+ // Detect composite route sources
123
+ const composites = detectCompositeRouteSources(content, relPath);
124
+ allComposites.push(...composites);
125
+ // Check for CORS in any file
126
+ if (/Access-Control-Allow-Origin/i.test(content) || /cors/i.test(content)) {
127
+ hasCorsInAny = true;
128
+ }
129
+ }
130
+ const hasOptionsHandler = allRoutes.some((r) => r.method === "OPTIONS");
54
131
  // Check for duplicate routes (same path + method)
55
132
  const routeKeys = new Map();
56
- for (const route of routes) {
133
+ for (const route of allRoutes) {
57
134
  const key = `${route.method} ${route.path}`;
58
135
  const count = (routeKeys.get(key) || 0) + 1;
59
136
  routeKeys.set(key, count);
@@ -61,57 +138,46 @@ function analyzeHttpEndpoints(convexDir) {
61
138
  issues.push({
62
139
  severity: "critical",
63
140
  message: `Duplicate route: ${key} — only the last registration will be used`,
64
- location: `http.ts:${route.line}`,
141
+ location: `${route.sourceFile}:${route.line}`,
65
142
  });
66
143
  }
67
144
  }
68
- // Check for CORS handling
69
- const hasCors = /Access-Control-Allow-Origin/i.test(content) ||
70
- /cors/i.test(content);
71
- const hasOptionsHandler = routes.some((r) => r.method === "OPTIONS");
72
- if (!hasCors && routes.length > 0) {
145
+ if (!hasCorsInAny && allRoutes.length > 0) {
73
146
  issues.push({
74
147
  severity: "warning",
75
- message: "No CORS headers detected. Browser requests from different origins will fail. Add Access-Control-Allow-Origin headers.",
148
+ message: "No CORS headers detected in any HTTP router file. Browser requests from different origins will fail.",
76
149
  });
77
150
  }
78
- if (hasCors && !hasOptionsHandler) {
151
+ if (hasCorsInAny && !hasOptionsHandler) {
79
152
  issues.push({
80
153
  severity: "warning",
81
- message: "CORS headers found but no OPTIONS handler registered. Preflight requests will fail. Add http.route({ path: '...', method: 'OPTIONS', handler: ... }).",
154
+ message: "CORS headers found but no OPTIONS handler registered. Preflight requests will fail.",
82
155
  });
83
156
  }
84
- // Check for paths that look like they should be grouped
85
- const pathPrefixes = new Map();
86
- for (const route of routes) {
87
- const parts = route.path.split("/").filter(Boolean);
88
- if (parts.length >= 2) {
89
- const prefix = `/${parts[0]}/${parts[1]}`;
90
- pathPrefixes.set(prefix, (pathPrefixes.get(prefix) || 0) + 1);
91
- }
92
- }
93
- // Check for missing httpRouter import
94
- if (!/httpRouter/.test(content)) {
157
+ // Check http.ts specifically for required exports
158
+ const httpContent = readFileSync(httpPath, "utf-8");
159
+ if (!/export\s+default/.test(httpContent)) {
95
160
  issues.push({
96
161
  severity: "critical",
97
- message: "Missing httpRouter import. HTTP endpoints require: import { httpRouter } from 'convex/server';",
162
+ message: "Missing 'export default' in http.ts. The httpRouter must be exported as default.",
98
163
  });
99
164
  }
100
- // Check for export default
101
- if (!/export\s+default\s+http/.test(content)) {
165
+ // Info about composite routes that can't be statically analyzed
166
+ if (allComposites.length > 0) {
102
167
  issues.push({
103
- severity: "critical",
104
- message: "Missing 'export default http'. The httpRouter must be exported as default.",
168
+ severity: "info",
169
+ message: `${allComposites.length} composite route source(s) detected (${allComposites.map(c => c.callee).join(", ")}). These add routes dynamically — actual route count may be higher than statically detected.`,
105
170
  });
106
171
  }
107
- // Check for httpAction import
108
- if (!/httpAction/.test(content) && routes.length > 0) {
109
- issues.push({
110
- severity: "warning",
111
- message: "No httpAction usage found. HTTP route handlers should use httpAction().",
112
- });
113
- }
114
- return { hasHttp: true, routes, issues, hasCors, hasOptionsHandler };
172
+ return {
173
+ hasHttp: true,
174
+ routes: allRoutes,
175
+ compositeRouteSources: allComposites,
176
+ issues,
177
+ hasCors: hasCorsInAny,
178
+ hasOptionsHandler,
179
+ filesScanned: routerFiles.map(f => f.replace(convexDir, "").replace(/^[\\/]/, "")),
180
+ };
115
181
  }
116
182
  // ── Tool Definitions ────────────────────────────────────────────────
117
183
  export const httpTools = [
@@ -142,18 +208,32 @@ export const httpTools = [
142
208
  quickRef: getQuickRef("convex_analyze_http"),
143
209
  };
144
210
  }
145
- // Group routes by path prefix
211
+ // Group routes by method
146
212
  const byMethod = {};
147
213
  for (const r of result.routes) {
148
214
  byMethod[r.method] = (byMethod[r.method] || 0) + 1;
149
215
  }
216
+ // Group routes by source file
217
+ const byFile = {};
218
+ for (const r of result.routes) {
219
+ byFile[r.sourceFile] = (byFile[r.sourceFile] || 0) + 1;
220
+ }
150
221
  return {
151
222
  hasHttp: true,
152
- totalRoutes: result.routes.length,
223
+ totalStaticRoutes: result.routes.length,
224
+ compositeRouteSources: result.compositeRouteSources.length,
225
+ estimatedTotalRoutes: result.compositeRouteSources.length > 0
226
+ ? `${result.routes.length}+ (${result.compositeRouteSources.length} dynamic source(s) add additional routes)`
227
+ : result.routes.length,
228
+ filesScanned: result.filesScanned,
153
229
  byMethod,
230
+ byFile,
154
231
  hasCors: result.hasCors,
155
232
  hasOptionsHandler: result.hasOptionsHandler,
156
233
  routes: result.routes,
234
+ composites: result.compositeRouteSources.length > 0
235
+ ? result.compositeRouteSources
236
+ : undefined,
157
237
  issues: {
158
238
  total: result.issues.length,
159
239
  critical: result.issues.filter((i) => i.severity === "critical").length,
@@ -0,0 +1,2 @@
1
+ import type { McpTool } from "../types.js";
2
+ export declare const migrationTools: McpTool[];