From 9146e40044faef2f64c7e17df02de1baa601ba50 Mon Sep 17 00:00:00 2001 From: FelixWaweru Date: Wed, 15 Jul 2026 17:25:25 +0300 Subject: [PATCH 1/6] feat: added additional v2 endpoints --- README.md | 26 ++- package.json | 57 +++++-- src/client.ts | 101 ++++++++++- src/formatters.ts | 351 ++++++++++++++++++++++++++++++++++++- src/index.ts | 2 + src/tools/chat.ts | 24 ++- src/tools/intel.ts | 183 ++++++++++++++++++++ src/tools/repos.ts | 29 +++- src/types.ts | 361 ++++++++++++++++++++++++++++++++++++++- tests/client.test.ts | 162 ++++++++++++++++++ tests/formatters.test.ts | 191 ++++++++++++++++++++- 11 files changed, 1446 insertions(+), 41 deletions(-) create mode 100644 src/tools/intel.ts diff --git a/README.md b/README.md index 541b3fd..76c85bf 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,12 @@ Built with [FastMCP](https://github.com/punkpeye/fastmcp) (TypeScript) and [Zod] - 🔍 **Semantic & grep code search** across indexed repositories - 🧠 **AI-powered research** — search + AI analysis in a single call -- 📦 **Repository management** — index, status, README, listing +- 📦 **Repository management** — index, status, README, listing, public catalog +- 🛰️ **Repository intelligence** — scope (dependencies/functions/variables), cross-repo dependency map, blueprint, blast radius, code review, and test-gap analysis - 📄 **File documentation** — AI-generated docs for any indexed file - 📊 **Usage statistics** — query usage, activity, language breakdowns - 🔐 **Agent-driven authentication** — sign up/sign in via OTP without pre-configured keys -- 💬 **AI chat** — direct conversation with Code-Fundi AI +- 💬 **AI chat & model insight** — direct conversation with Code-Fundi AI, model catalog, and per-tier limits ## Quick Start @@ -139,9 +140,9 @@ npx fastmcp inspect src/index.ts # Open MCP Inspector UI npx fastmcp dev src/index.ts # Test with MCP CLI ``` -## Tools Reference (22 tools) +## Tools Reference (30 tools) -Covers the Code-Fundi **V2** API: search (including search-with-chat / research), repositories (list, index, status, readme), files, history, statistics, API keys, authentication, plus **Fundi chat** and the model catalog (`POST /v1/fundi/chat`, `GET /v1/fundi/models` — there is no separate `/v2/chat` in the published OpenAPI). +Covers the Code-Fundi **V2** API: search (including search-with-chat / research), repositories (list, index, status, readme, public catalog), repository intelligence (scope, map, blueprint, radius, review, test-gaps), files, history, statistics, API keys, authentication, plus **Fundi chat** (`POST /v1/fundi/chat`) and the V2 model catalog / limits (`GET /v2/models`, `GET /v2/models/limits`). ### Search @@ -157,7 +158,19 @@ Covers the Code-Fundi **V2** API: search (including search-with-chat / research) | `code-fundi-list-repos` | List indexed repositories with pagination | | `code-fundi-index-repo` | Index a new GitHub repository | | `code-fundi-repo-status` | Check repository indexing status | -| `code-fundi-repo-readme` | Get repository README documentation | +| `code-fundi-repo-readme` | Get repository README documentation (deprecated — prefer blueprint) | +| `code-fundi-list-public-repos` | Browse the global catalog of indexed public repositories (no key needed) | + +### Repository Intelligence + +| Tool | Description | +|------|-------------| +| `code-fundi-repo-scope` | Aggregated dependencies, functions, and variables (with drill-down and cross-repo comparison) | +| `code-fundi-repo-map` | Cross-repository dependency map | +| `code-fundi-repo-blueprint` | README + dependency/convention overview (successor to repo-readme) | +| `code-fundi-repo-radius` | Blast radius / file impact analysis (PRO+) | +| `code-fundi-repo-review` | Per-file code review signals: verdict, risk, complexity, coverage, debt (PRO+) | +| `code-fundi-repo-test-gaps` | Test coverage gap analysis with suggested test cases (PRO+) | ### Files @@ -198,7 +211,8 @@ Covers the Code-Fundi **V2** API: search (including search-with-chat / research) | Tool | Description | |------|-------------| | `code-fundi-chat` | Fundi AI chat (`POST /v1/fundi/chat`; streamed responses are collected to text) | -| `code-fundi-list-models` | List available AI models | +| `code-fundi-list-models` | List the curated chat model catalog (`GET /v2/models`) | +| `code-fundi-model-limits` | Get AI model limits and tier configuration (`GET /v2/models/limits`) | ## Authentication diff --git a/package.json b/package.json index d1d0d9d..2804134 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,48 @@ { "name": "@codefundi/code-fundi-mcp", - "version": "0.1.0", - "description": "MCP server for the Code-Fundi API — search, research, index, and manage code repositories via any MCP-compatible AI assistant.", + "version": "0.1.1", + "description": "MCP server for CodeFundi: the codebase map and blast-radius guard for AI coding agents like Claude Code, Cursor, and Copilot.", + "keywords": [ + "mcp", + "mcp-server", + "model-context-protocol", + "fastmcp", + "code-fundi", + "codefundi", + "codebase-map", + "blast-radius", + "code-intelligence", + "call-graph", + "dependency-graph", + "code-search", + "context-api", + "rag", + "llm-context", + "ai-agent", + "ai-coding-agent", + "claude-code", + "cursor", + "github-copilot" + ], + "homepage": "https://codefundi.app", + "bugs": { + "url": "https://github.com/Code-Fundi/code-fundi-mcp/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Code-Fundi/code-fundi-mcp" + }, + "license": "MIT", + "author": "Code Fundi ", "type": "module", "main": "dist/index.js", "bin": { "code-fundi-mcp": "dist/index.js" }, + "files": [ + "dist", + "README.md" + ], "scripts": { "build": "tsc", "prepublishOnly": "npm run build", @@ -15,22 +51,9 @@ "typecheck": "tsc --noEmit", "test": "vitest" }, - "files": [ - "dist", - "README.md" - ], - "keywords": [ - "mcp", - "code-fundi", - "codefundi", - "ai", - "code-search", - "fastmcp" - ], - "license": "MIT", "dependencies": { "fastmcp": "^4.0.0", - "zod": "^3.24.0" + "zod": "^4.0.0" }, "devDependencies": { "@types/node": "^22.0.0", @@ -41,4 +64,4 @@ "engines": { "node": ">=20.0.0" } -} +} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index 807877b..9357f8c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,8 +15,13 @@ import type { ActivityStatsResponse, LanguageStatsResponse, ApiKeysListResponse, ApiKeyRegenerateResponse, ApiKeyDeleteResponse, V2AuthAuthenticateResponse, V2AuthVerifyResponse, V2AuthResendResponse, V2AuthMode, - ChatRequest, ChatResponse, ModelsResponse, SearchFieldsParam, + ChatRequest, ChatResponse, ModelsResponse, V2ModelsListResponse, + ModelLimitsResponse, SearchFieldsParam, SortOrder, StatsRange, RepoScope, HistoryCategory, ErrorResponse, + PublicRepoListResponse, RepoScopeRequest, RepoScopeResponse, + RepoMapOptions, RepoMapResponse, RepoBlueprintResponse, + RepoRadiusRequest, RepoRadiusResponse, RepoReviewResponse, RepoReviewOrder, + RepoTestGapsResponse, TestGapPriority, } from "./types.js"; // ============================================================================ @@ -86,6 +91,14 @@ export class CodeFundiClient { } } + /** + * Require an API key unless the caller opts into demo mode (pre-signup, IP-based credits). + * Endpoints that expose the OpenAPI `demo` flag can execute anonymously in demo mode. + */ + private requireKeyUnlessDemo(demo?: boolean): void { + if (!demo) this.requireApiKey(); + } + private authHeaders(): Record { const h: Record = { "Content-Type": "application/json" }; if (this.apiKey) h["X-API-Key"] = this.apiKey; @@ -239,6 +252,76 @@ export class CodeFundiClient { return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/readme`, { method: "GET" }); } + /** + * `GET /v2/public/repos` — global public repository catalog. No API key required + * (anonymous callers are IP rate limited; pass `internalSecret` for unlimited enumeration). + */ + async listPublicRepos( + opts: { limit?: number; offset?: number; since?: string; file_count?: number; internalSecret?: string } = {}, + ): Promise { + const { internalSecret, ...query } = opts; + const headers: Record = {}; + if (internalSecret) headers["X-Internal-Secret"] = internalSecret; + return this.request(`/v2/public/repos${buildQuery(query)}`, { method: "GET", headers }); + } + + // ==== V2 Repository Intelligence ==== + + async getRepoScope(repoKey: string, body: RepoScopeRequest = {}, demo?: boolean): Promise { + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ demo: demo || undefined }); + return this.request( + `/v2/repos/${encodeRepoKey(repoKey)}/scope${qs}`, + { method: "POST", body: JSON.stringify(body) }, + ); + } + + async getRepoMap(repoKey: string, opts: RepoMapOptions = {}): Promise { + const { demo, ...rest } = opts; + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ ...rest, demo: demo || undefined }); + return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/map${qs}`, { method: "GET" }); + } + + async getRepoBlueprint( + repoKey: string, + opts: { conventions_path_prefix?: string; demo?: boolean } = {}, + ): Promise { + const { demo, ...rest } = opts; + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ ...rest, demo: demo || undefined }); + return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/blueprint${qs}`, { method: "GET" }); + } + + async getRepoRadius(repoKey: string, body: RepoRadiusRequest, demo?: boolean): Promise { + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ demo: demo || undefined }); + return this.request( + `/v2/repos/${encodeRepoKey(repoKey)}/radius${qs}`, + { method: "POST", body: JSON.stringify(body) }, + ); + } + + async getRepoReview( + repoKey: string, + opts: { verdict?: string; order?: RepoReviewOrder; limit?: number; offset?: number; demo?: boolean } = {}, + ): Promise { + const { demo, ...rest } = opts; + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ ...rest, demo: demo || undefined }); + return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/review${qs}`, { method: "GET" }); + } + + async getRepoTestGaps( + repoKey: string, + opts: { priority?: TestGapPriority; min_risk?: number; untested_only?: boolean; limit?: number; offset?: number; demo?: boolean } = {}, + ): Promise { + const { demo, ...rest } = opts; + this.requireKeyUnlessDemo(demo); + const qs = buildQuery({ ...rest, demo: demo || undefined }); + return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/test-gaps${qs}`, { method: "GET" }); + } + // ==== V2 Files ==== async listFiles(repoKey: string, opts: { search?: string | null; limit?: number; offset?: number; order_by?: string; order?: SortOrder } = {}): Promise { @@ -324,7 +407,7 @@ export class CodeFundiClient { return this.postUnauth("/v2/auth/resend", { email: p.email, type: p.type ?? "signup" }); } - // ==== V1 Chat & Models (OpenAPI: AI Chat; server body uses `question`, not `prompt`) ==== + // ==== V1 Chat (OpenAPI: AI Chat; server body uses `question`, not `prompt`) ==== async chat(req: ChatRequest): Promise { this.requireApiKey(); @@ -341,9 +424,21 @@ export class CodeFundiClient { return this.parseFundiChatResponse(res); } + // ==== V2 Models ==== + + /** + * `GET /v2/models` — curated CodeFundi chat model catalog. The API wraps the list under + * `data.models`; this method normalizes it to `{ models }` for MCP tools. + */ async getModels(): Promise { this.requireApiKey(); - return this.request("/v1/fundi/models", { method: "GET" }); + const res = await this.request("/v2/models", { method: "GET" }); + return { models: res.data?.models ?? [] }; + } + + async getModelLimits(): Promise { + this.requireApiKey(); + return this.request("/v2/models/limits", { method: "GET" }); } } diff --git a/src/formatters.ts b/src/formatters.ts index ebdb665..49e0382 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -10,9 +10,28 @@ import type { UsageByType, ActivityByDay, LanguageStat, ApiKey, AIModel, ReadmeData, RepoStatusData, RepositoryIndexInitRepo, Pagination, TierName, + PublicRepoListItem, RepoScopeData, RepoScopePagination, + RepoMapData, RepoBlueprintData, RepoBlueprintMeta, + RepoRadiusData, RepoReviewData, RepoTestGapsData, + ModelLimitsData, } from "./types.js"; import { CodeFundiApiError } from "./client.js"; +// ============================================================================ +// Internal helpers +// ============================================================================ + +/** Render an opaque JSON value as a fenced code block for faithful display. */ +function jsonBlock(value: unknown): string { + return "```json\n" + JSON.stringify(value, null, 2) + "\n```"; +} + +/** Read a string field from a loosely-typed record, if present. */ +function readString(obj: Record | undefined, key: string): string | undefined { + const v = obj?.[key]; + return typeof v === "string" ? v : undefined; +} + // ============================================================================ // Search // ============================================================================ @@ -145,6 +164,292 @@ export function formatReadme(data: ReadmeData): string { return parts.join("\n"); } +export function formatPublicRepoList(repos: PublicRepoListItem[], pagination?: Pagination): string { + const parts: string[] = []; + parts.push(`## Public Repositories (${pagination?.total ?? repos.length})\n`); + + if (repos.length === 0) { + parts.push("No public repositories found."); + return parts.join("\n"); + } + + for (const r of repos) { + parts.push(`### ${r.name}`); + parts.push(`- **ID:** \`${r.id}\``); + parts.push(`- **Link:** ${r.link}`); + if (r.updated_at) parts.push(`- **Updated:** ${r.updated_at}`); + if (r.top_files?.length) { + parts.push(`- **Top files:**`); + for (const f of r.top_files) { + const size = f.file_size_kb != null ? ` (${f.file_size_kb} KB)` : ""; + parts.push(` - \`${f.file_path}\`${size}`); + } + } + parts.push(""); + } + + if (pagination?.has_more) { + parts.push(`_Showing ${repos.length} of ${pagination.total}. Use offset=${pagination.offset + pagination.limit} for next page._`); + } + + return parts.join("\n"); +} + +// ============================================================================ +// Repository Intelligence (scope, map, blueprint, radius, review, test-gaps) +// ============================================================================ + +export function formatRepoScope(data: RepoScopeData, pagination?: RepoScopePagination, gatedKinds?: string[]): string { + const parts: string[] = []; + const repoName = readString(data.repo, "name"); + parts.push(`## Repository Scope${repoName ? `: ${repoName}` : ""}\n`); + + const s = data.summary; + if (s) { + const info: string[] = []; + if (s.total_files !== undefined) info.push(`Files: ${s.total_files}`); + if (s.dependency_count !== undefined) info.push(`Dependencies: ${s.dependency_count}`); + if (s.function_count !== undefined) info.push(`Functions: ${s.function_count}`); + if (s.variable_count !== undefined) info.push(`Variables: ${s.variable_count}`); + if (s.compared_repo_count) info.push(`Compared repos: ${s.compared_repo_count}`); + if (info.length) parts.push(info.join(" | ") + "\n"); + } + if (gatedKinds?.length) parts.push(`_Gated (upgrade to unlock): ${gatedKinds.join(", ")}._\n`); + + const deps = data.scope?.dependencies ?? []; + if (deps.length) { + parts.push(`### Dependencies (${deps.length})`); + for (const d of deps) { + const ver = d.version ? `@${d.version}` : ""; + const typ = d.type ? ` [${d.type}]` : ""; + const usage = d.usage?.file_count !== undefined + ? ` — used in ${d.usage.file_count} file(s)${d.usage.percentage !== undefined ? ` (${d.usage.percentage}%)` : ""}` + : ""; + parts.push(`- **${d.name}**${ver}${typ}${usage}`); + if (d.comparison?.status) parts.push(` - Comparison: ${d.comparison.status}`); + } + parts.push(""); + } + + const fns = data.scope?.functions ?? []; + if (fns.length) { + parts.push(`### Functions (${fns.length})`); + for (const f of fns) { + const usage = f.usage?.file_count !== undefined ? ` — ${f.usage.file_count} file(s)` : ""; + parts.push(`- \`${f.name}\`${f.type ? ` [${f.type}]` : ""}${usage}`); + } + parts.push(""); + } + + const vars = data.scope?.variables ?? []; + if (vars.length) { + parts.push(`### Variables (${vars.length})`); + for (const v of vars) { + const usage = v.usage?.file_count !== undefined ? ` — ${v.usage.file_count} file(s)` : ""; + parts.push(`- \`${v.name}\`${v.type ? ` [${v.type}]` : ""}${usage}`); + } + parts.push(""); + } + + if (!deps.length && !fns.length && !vars.length) parts.push("No scope data found."); + + const more: string[] = []; + const paginate = (label: string, pg?: Pagination) => { + if (pg?.has_more) more.push(`${label} (offset ${pg.offset + pg.limit})`); + }; + paginate("dependencies", pagination?.dependencies); + paginate("functions", pagination?.functions); + paginate("variables", pagination?.variables); + if (more.length) parts.push(`_More available: ${more.join(", ")}._`); + + return parts.join("\n"); +} + +export function formatRepoMap(data: RepoMapData): string { + const parts: string[] = []; + const srcName = readString(data.source_repo, "name"); + parts.push(`## Cross-Repository Dependency Map${srcName ? `: ${srcName}` : ""}\n`); + + if (data.scope && Object.keys(data.scope).length) { + parts.push("### Scope"); + parts.push(jsonBlock(data.scope)); + parts.push(""); + } + + const deps = data.dependencies ?? []; + if (!deps.length) { + parts.push("No dependency map entries found."); + return parts.join("\n"); + } + + parts.push(`### Dependencies (${deps.length})`); + for (const d of deps) { + const name = readString(d, "name") ?? "(unnamed)"; + const type = readString(d, "type"); + parts.push(`- **${name}**${type ? ` [${type}]` : ""}`); + const repos = Array.isArray(d.repos) ? d.repos : undefined; + if (repos?.length) parts.push(` - Present in ${repos.length} repo(s)`); + } + + return parts.join("\n"); +} + +export function formatRepoBlueprint(data: RepoBlueprintData, meta?: RepoBlueprintMeta): string { + const parts: string[] = []; + const name = data.repo?.name ?? data.file_name ?? "Repository"; + parts.push(`## ${name} — Blueprint\n`); + if (meta?.deprecated) { + parts.push("_Note: the `/readme` endpoint is deprecated; prefer `code-fundi-repo-blueprint`._\n"); + } + if (data.github_url) parts.push(`- **URL:** ${data.github_url}`); + + const deps = data.dependencies ?? []; + if (deps.length) { + parts.push(`\n### Top Dependencies (${deps.length})`); + for (const d of deps.slice(0, 25)) { + const ver = d.version ? `@${d.version}` : ""; + const typ = d.type ? ` [${d.type}]` : ""; + const usage = d.usage?.file_count !== undefined ? ` — ${d.usage.file_count} file(s)` : ""; + parts.push(`- **${d.name}**${ver}${typ}${usage}`); + } + if (deps.length > 25) parts.push(`_...and ${deps.length - 25} more_`); + } + + const tf = data.symbols?.top_functions ?? []; + if (tf.length) { + parts.push(`\n### Top Functions (${tf.length})`); + for (const f of tf.slice(0, 25)) parts.push(`- \`${f.name}\`${f.type ? ` [${f.type}]` : ""}`); + } + + const tv = data.symbols?.top_variables ?? []; + if (tv.length) { + parts.push(`\n### Top Variables (${tv.length})`); + for (const v of tv.slice(0, 25)) parts.push(`- \`${v.name}\`${v.type ? ` [${v.type}]` : ""}`); + } + + if (data.conventions && Object.keys(data.conventions).length) { + parts.push(`\n### Coding Conventions`); + parts.push(jsonBlock(data.conventions)); + } else if (meta?.conventions_tier_gated) { + parts.push(`\n_Coding conventions require PRO tier or higher._`); + } + + if (data.documentation) { + parts.push(`\n### README\n`); + parts.push(data.documentation); + } + + return parts.join("\n"); +} + +export function formatRepoRadius(data: RepoRadiusData): string { + const parts: string[] = []; + parts.push("## Blast Radius Analysis\n"); + + if (data.summary && Object.keys(data.summary).length) { + parts.push("### Summary"); + parts.push(jsonBlock(data.summary)); + parts.push(""); + } + + const targets = data.targets ?? []; + if (!targets.length) { + parts.push("No impact targets found."); + return parts.join("\n"); + } + + targets.forEach((t, i) => { + parts.push(`### Target ${i + 1}`); + if (t.entry_points?.length) parts.push(`- **Entry points:** ${t.entry_points.join(", ")}`); + if (t.data_flows?.length) { + parts.push(`- **Data flows (${t.data_flows.length}):**`); + for (const f of t.data_flows) { + parts.push(` - ${f.source_kind ?? "?"} → ${f.sink_kind ?? "?"} (risk: ${f.risk_level ?? "n/a"})`); + } + } + if (t.call_edges?.length) { + parts.push(`- **Call edges (${t.call_edges.length}):**`); + for (const e of t.call_edges) { + parts.push(` - ${e.caller ?? "?"} → ${e.callee ?? "?"}${e.call_type ? ` (${e.call_type})` : ""}`); + } + } + if (t.admin_enrichment && Object.keys(t.admin_enrichment).length) { + parts.push(`- **Admin enrichment:**`); + parts.push(jsonBlock(t.admin_enrichment)); + } + parts.push(""); + }); + + return parts.join("\n"); +} + +export function formatRepoReview(data: RepoReviewData): string { + const parts: string[] = []; + parts.push("## Code Review Signals\n"); + + if (data.summary && Object.keys(data.summary).length) { + parts.push("### Summary"); + parts.push(jsonBlock(data.summary)); + parts.push(""); + } + + const files = data.files ?? []; + if (!files.length) { + parts.push("No review signals found."); + return parts.join("\n"); + } + + parts.push(`### Files (${files.length})`); + parts.push("| File | Verdict | Risk | Cyclomatic | Coverage | Debt (min) |"); + parts.push("|------|---------|------|-----------|----------|-----------|"); + for (const f of files) { + const label = f.file_path ?? f.file_name ?? f.id ?? "?"; + const cov = f.estimated_coverage === null || f.estimated_coverage === undefined ? "N/A" : `${f.estimated_coverage}`; + parts.push(`| ${label} | ${f.verdict ?? "?"} | ${f.risk_score ?? "?"} | ${f.cyclomatic_complexity ?? "?"} | ${cov} | ${f.technical_debt_minutes ?? "?"} |`); + } + + return parts.join("\n"); +} + +export function formatRepoTestGaps(data: RepoTestGapsData): string { + const parts: string[] = []; + parts.push("## Test Coverage Gaps\n"); + + if (data.summary && Object.keys(data.summary).length) { + parts.push("### Summary"); + parts.push(jsonBlock(data.summary)); + parts.push(""); + } + + const gaps = data.gaps ?? []; + if (!gaps.length) { + parts.push("No test gaps found."); + return parts.join("\n"); + } + + for (const g of gaps) { + parts.push(`### ${g.file_path ?? g.file_name ?? g.id ?? "file"}`); + const info: string[] = []; + if (g.risk_score !== undefined) info.push(`Risk: ${g.risk_score}`); + if (g.estimated_coverage !== undefined) info.push(`Coverage: ${g.estimated_coverage}`); + if (g.security_risk_level) info.push(`Security: ${g.security_risk_level}`); + if (g.churn_rate) info.push(`Churn: ${g.churn_rate}`); + if (info.length) parts.push(`- ${info.join(" | ")}`); + if (g.suggested_test_cases?.length) { + parts.push(`- **Suggested tests:**`); + for (const c of g.suggested_test_cases) { + const fn = c.target_function ? ` (\`${c.target_function}\`)` : ""; + parts.push(` - [${c.priority ?? "?"}] ${c.description ?? ""}${fn}`); + } + } + if (g.mockable_dependencies?.length) parts.push(`- **Mockable deps:** ${g.mockable_dependencies.join(", ")}`); + if (g.hard_to_test_reasons?.length) parts.push(`- **Hard to test:** ${g.hard_to_test_reasons.join("; ")}`); + parts.push(""); + } + + return parts.join("\n"); +} + // ============================================================================ // Files // ============================================================================ @@ -301,17 +606,57 @@ export function formatApiKeys(keys: ApiKey[]): string { export function formatModels(models: AIModel[]): string { const parts: string[] = []; parts.push(`## Available Models (${models.length})\n`); - parts.push("| Name | Provider | Tier |"); - parts.push("|------|----------|------|"); + parts.push("| Name | Provider | Tier | Context |"); + parts.push("|------|----------|------|---------|"); for (const m of models) { const tier = m.tier_required ?? (m.tier_requirements?.premium_only ? "PRO" as TierName : "FREE" as TierName); - parts.push(`| ${m.name} | ${m.provider} | ${tier} |`); + const ctx = m.context_length != null ? m.context_length.toLocaleString() : "—"; + parts.push(`| ${m.name} | ${m.provider} | ${tier} | ${ctx} |`); } return parts.join("\n"); } +export function formatModelLimits(data: ModelLimitsData): string { + const parts: string[] = []; + parts.push(`## Model Limits & Tier${data.tier ? `: ${data.tier}` : ""}\n`); + + const sub = data.subscription; + if (sub) { + const info: string[] = []; + if (sub.tokens !== undefined) info.push(`Tokens: ${sub.tokens}`); + if (sub.bonus_tokens !== undefined) info.push(`Bonus: ${sub.bonus_tokens}`); + if (sub.expiry_date) info.push(`Expires: ${sub.expiry_date}`); + if (info.length) parts.push(`**Subscription:** ${info.join(" | ")}`); + } + + const m = data.model; + if (m) { + parts.push(`\n### Active Model`); + if (m.name) parts.push(`- **Name:** ${m.name}${m.provider ? ` (${m.provider})` : ""}`); + if (m.max_tokens !== undefined) parts.push(`- **Max tokens:** ${m.max_tokens}`); + if (m.context_length !== undefined) parts.push(`- **Context length:** ${m.context_length}`); + if (m.vector_search_length !== undefined) parts.push(`- **Vector search length:** ${m.vector_search_length}`); + if (m.knowledge_search_length !== undefined) parts.push(`- **Knowledge search length:** ${m.knowledge_search_length}`); + if (m.knowledge_storage_limit !== undefined) parts.push(`- **Knowledge storage limit:** ${m.knowledge_storage_limit}`); + } + + const l = data.limits; + if (l) { + parts.push(`\n### Limits`); + if (l.history_days !== undefined) parts.push(`- **History days:** ${l.history_days}`); + if (l.history_max_records !== undefined) parts.push(`- **History max records:** ${l.history_max_records}`); + if (l.repos_max !== undefined) parts.push(`- **Max repos:** ${l.repos_max}`); + if (l.files_per_repo_max !== undefined) parts.push(`- **Files per repo:** ${l.files_per_repo_max}`); + if (l.stats_range_max_days !== undefined) parts.push(`- **Stats range max days:** ${l.stats_range_max_days}`); + if (l.can_access_org_repos !== undefined) parts.push(`- **Can access org repos:** ${l.can_access_org_repos}`); + if (l.can_share_to_org !== undefined) parts.push(`- **Can share to org:** ${l.can_share_to_org}`); + } + + return parts.join("\n"); +} + // ============================================================================ // Errors // ============================================================================ diff --git a/src/index.ts b/src/index.ts index 3635737..ac8aa24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { FastMCP } from "fastmcp"; import { registerSearchTools } from "./tools/search.js"; import { registerRepoTools } from "./tools/repos.js"; +import { registerRepoIntelTools } from "./tools/intel.js"; import { registerFileTools } from "./tools/files.js"; import { registerHistoryTools } from "./tools/history.js"; import { registerStatsTools } from "./tools/stats.js"; @@ -28,6 +29,7 @@ const server = new FastMCP({ // Register all tool groups registerSearchTools(server); registerRepoTools(server); +registerRepoIntelTools(server); registerFileTools(server); registerHistoryTools(server); registerStatsTools(server); diff --git a/src/tools/chat.ts b/src/tools/chat.ts index 3478b76..ed6e040 100644 --- a/src/tools/chat.ts +++ b/src/tools/chat.ts @@ -3,13 +3,13 @@ * * Chat uses `POST /v1/fundi/chat` (OpenAPI "AI Chat"). The production API streams HTML; * the client collects the full body into a single string for MCP tools. - * Model catalog uses `GET /v1/fundi/models` (no separate `/v2/chat` in the published OpenAPI). + * The model catalog uses `GET /v2/models` and per-tier limits use `GET /v2/models/limits`. */ import type { FastMCP } from "fastmcp"; import { z } from "zod"; import { getClient } from "../client.js"; -import { formatModels, formatError } from "../formatters.js"; +import { formatModels, formatModelLimits, formatError } from "../formatters.js"; export function registerChatTools(server: FastMCP): void { server.addTool({ @@ -65,7 +65,8 @@ export function registerChatTools(server: FastMCP): void { server.addTool({ name: "code-fundi-list-models", - description: "List all available AI models on Code-Fundi with their providers and required tier.", + description: + "List the curated Code-Fundi chat model catalog (GET /v2/models) with providers, required tier, and context length.", parameters: z.object({}), annotations: { title: "List AI Models", readOnlyHint: true }, execute: async () => { @@ -76,4 +77,21 @@ export function registerChatTools(server: FastMCP): void { } catch (err) { return formatError(err); } }, }); + + server.addTool({ + name: "code-fundi-model-limits", + description: + "Get the AI model limits and tier configuration for your account (GET /v2/models/limits): " + + "subscription tokens, the active model's context/knowledge limits, and account limits such as " + + "max repositories, files per repo, history retention, and organization access.", + parameters: z.object({}), + annotations: { title: "Model Limits", readOnlyHint: true }, + execute: async () => { + try { + const client = getClient(); + const res = await client.getModelLimits(); + return res.data ? formatModelLimits(res.data) : "No model limit data available."; + } catch (err) { return formatError(err); } + }, + }); } diff --git a/src/tools/intel.ts b/src/tools/intel.ts new file mode 100644 index 0000000..e3e591f --- /dev/null +++ b/src/tools/intel.ts @@ -0,0 +1,183 @@ +/** + * Code-Fundi MCP — Repository Intelligence Tools + * + * Advanced V2 repository intelligence endpoints: + * - scope (POST /v2/repos/{repo_id}/scope) dependencies / functions / variables + * - map (GET /v2/repos/{repo_id}/map) cross-repository dependency map + * - blueprint (GET /v2/repos/{repo_id}/blueprint) README + dependency overview + * - radius (POST /v2/repos/{repo_id}/radius) blast radius / file impact analysis + * - review (GET /v2/repos/{repo_id}/review) code review signals (PRO+) + * - test-gaps (GET /v2/repos/{repo_id}/test-gaps) test coverage gap analysis (PRO+) + * + * These endpoints consume credits and several require PRO tier or higher. + */ + +import type { FastMCP } from "fastmcp"; +import { z } from "zod"; +import { getClient } from "../client.js"; +import { + formatRepoScope, formatRepoMap, formatRepoBlueprint, + formatRepoRadius, formatRepoReview, formatRepoTestGaps, formatError, +} from "../formatters.js"; + +export function registerRepoIntelTools(server: FastMCP): void { + server.addTool({ + name: "code-fundi-repo-scope", + description: + "Get aggregated dependencies, functions, and/or variable names for a repository in a single call. " + + "Use `include` to restrict kinds (default: all three). Provide `name` (plus `expand`) to drill into a " + + "single dependency's files/functions, or `compare_repos` (up to 25 UUIDs) to compare dependencies across repos. " + + "Consumes credits; functions/variables may be tier-gated.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + include: z.array(z.enum(["dependencies", "functions", "variables"])).optional().describe("Kinds to return (default: all three)"), + name: z.string().optional().describe("Exact name filter (dependency key or symbol name)"), + type: z.string().optional().describe("Type/role filter (dependency type, function type, or variable role)"), + expand: z.array(z.enum(["files", "functions"])).optional().describe("Drill-down expansions; requires `name`"), + compare_repos: z.array(z.string()).optional().describe("Up to 25 repository UUIDs for dependency comparison"), + limit: z.number().int().min(1).max(200).optional().describe("Max items per kind (default 50)"), + offset: z.number().int().min(0).optional().describe("Pagination offset"), + order: z.enum(["usage", "name"]).optional().describe("Ordering (default: usage)"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Scope", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, demo, ...body } = args; + const res = await client.getRepoScope(repo_key, body, demo); + return res.data ? formatRepoScope(res.data, res.pagination, res.meta?.gated_kinds) : "No scope data available."; + } catch (err) { return formatError(err); } + }, + }); + + server.addTool({ + name: "code-fundi-repo-map", + description: + "Build a cross-repository dependency map showing where a dependency appears across the source repo, " + + "optional compared repos, and (optionally) the public index. Returns structured JSON. Consumes credits.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + dependency: z.string().optional().describe("Dependency name to map"), + type: z.enum(["package", "import", "export", "env"]).optional().describe("Dependency type filter"), + compare_repos: z.array(z.string()).optional().describe("Repository UUIDs to compare against (max 10)"), + include_public_index: z.boolean().optional().describe("Include matches from the public index (default false)"), + limit: z.number().int().min(1).max(50).optional().describe("Max dependencies (default 25)"), + files_per_repo: z.number().int().min(1).max(100).optional().describe("Max files per repo (default 25)"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Dependency Map", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, compare_repos, ...rest } = args; + const res = await client.getRepoMap(repo_key, { + ...rest, + compare_repos: compare_repos?.length ? compare_repos.join(",") : undefined, + }); + return res.data ? formatRepoMap(res.data) : "No dependency map available."; + } catch (err) { return formatError(err); } + }, + }); + + server.addTool({ + name: "code-fundi-repo-blueprint", + description: + "Get the canonical repository intelligence overview: README documentation, top dependencies, " + + "top functions/variables, and aggregated coding conventions (PRO+ only). " + + "This is the successor to `code-fundi-repo-readme`. Consumes credits.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + conventions_path_prefix: z.string().optional().describe("Optional path prefix filter for conventions aggregation (PRO+)"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Blueprint", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, ...opts } = args; + const res = await client.getRepoBlueprint(repo_key, opts); + return res.data ? formatRepoBlueprint(res.data, res.meta) : "No blueprint available."; + } catch (err) { return formatError(err); } + }, + }); + + server.addTool({ + name: "code-fundi-repo-radius", + description: + "Run blast-radius / file impact analysis for one or more files or dependencies. " + + "Returns projected data flows, entry points, and call edges. Requires PRO, ENTERPRISE, or ADMIN tier " + + "(STARTUP is not eligible) and consumes credits.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + file: z.string().optional().describe("Single file path to analyze"), + files: z.array(z.string()).optional().describe("Multiple file paths to analyze"), + dependency: z.string().optional().describe("Single dependency name to analyze"), + dependencies: z.array(z.string()).optional().describe("Multiple dependency names to analyze"), + include: z.array(z.string()).optional().describe("Projection kinds to include (e.g. data_flows, entry_points, call_edges)"), + limit: z.number().int().min(1).optional().describe("Max targets to return"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Blast Radius", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, demo, ...body } = args; + const res = await client.getRepoRadius(repo_key, body, demo); + return res.data ? formatRepoRadius(res.data) : "No blast radius data available."; + } catch (err) { return formatError(err); } + }, + }); + + server.addTool({ + name: "code-fundi-repo-review", + description: + "Get code review signals per file: verdict (pass/warn/block), risk score, complexity, duplication, " + + "estimated coverage, and technical debt. Requires PRO tier or higher and consumes credits.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + verdict: z.array(z.enum(["pass", "warn", "block"])).optional().describe("Filter by verdict(s)"), + order: z.enum(["risk", "complexity", "coverage", "debt"]).optional().describe("Sort order (default: risk)"), + limit: z.number().int().min(1).optional().describe("Max files (default 50)"), + offset: z.number().int().min(0).optional().describe("Pagination offset"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Code Review", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, verdict, ...rest } = args; + const res = await client.getRepoReview(repo_key, { + ...rest, + verdict: verdict?.length ? verdict.join(",") : undefined, + }); + return res.data ? formatRepoReview(res.data) : "No review signals available."; + } catch (err) { return formatError(err); } + }, + }); + + server.addTool({ + name: "code-fundi-repo-test-gaps", + description: + "Analyze test coverage gaps: per-file risk score, estimated coverage, suggested test cases, mockable " + + "dependencies, and reasons code is hard to test. Requires PRO tier or higher and consumes credits.", + parameters: z.object({ + repo_key: z.string().describe("Repository UUID or clone URL"), + priority: z.enum(["high", "medium", "low"]).optional().describe("Filter by gap priority"), + min_risk: z.number().min(0).max(1).optional().describe("Minimum risk score (0-1)"), + untested_only: z.boolean().optional().describe("Only return untested files (default true)"), + limit: z.number().int().min(1).optional().describe("Max gaps (default 50)"), + offset: z.number().int().min(0).optional().describe("Pagination offset"), + demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), + }), + annotations: { title: "Repository Test Gaps", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { repo_key, ...opts } = args; + const res = await client.getRepoTestGaps(repo_key, opts); + return res.data ? formatRepoTestGaps(res.data) : "No test gap data available."; + } catch (err) { return formatError(err); } + }, + }); +} diff --git a/src/tools/repos.ts b/src/tools/repos.ts index 7eb0629..9efba9b 100644 --- a/src/tools/repos.ts +++ b/src/tools/repos.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { getClient } from "../client.js"; import { formatRepoList, formatIndexResult, formatRepoStatus, - formatReadme, formatError, + formatReadme, formatPublicRepoList, formatError, } from "../formatters.js"; export function registerRepoTools(server: FastMCP): void { @@ -77,7 +77,8 @@ export function registerRepoTools(server: FastMCP): void { name: "code-fundi-repo-readme", description: "Get the parsed README documentation for a repository. " + - "Provide either a repository UUID or a clone URL.", + "Provide either a repository UUID or a clone URL. " + + "Deprecated in favor of code-fundi-repo-blueprint, which returns the same README plus dependency and convention intelligence.", parameters: z.object({ repo_key: z.string().describe("Repository UUID or clone URL"), }), @@ -90,4 +91,28 @@ export function registerRepoTools(server: FastMCP): void { } catch (err) { return formatError(err); } }, }); + + server.addTool({ + name: "code-fundi-list-public-repos", + description: + "Browse the global catalog of indexed public repositories (across all Code-Fundi users). " + + "Supports pagination, an `updated_since` cursor, and optional embedding of each repo's largest files. " + + "This endpoint does not require an API key.", + parameters: z.object({ + limit: z.number().int().min(1).max(500).optional().describe("Max results per page (default 100)"), + offset: z.number().int().min(0).optional().describe("Pagination offset"), + since: z.string().optional().describe("Return repos updated strictly after this ISO 8601 timestamp"), + file_count: z.number().int().min(0).max(50).optional().describe("When > 0, embed this many largest files per repo (default 0)"), + internal_secret: z.string().optional().describe("Internal sitemap secret for unlimited server-side enumeration (optional)"), + }), + annotations: { title: "List Public Repositories", readOnlyHint: true, openWorldHint: true }, + execute: async (args) => { + try { + const client = getClient(); + const { internal_secret, ...opts } = args; + const res = await client.listPublicRepos({ ...opts, internalSecret: internal_secret }); + return formatPublicRepoList(res.data || [], res.pagination); + } catch (err) { return formatError(err); } + }, + }); } diff --git a/src/types.ts b/src/types.ts index 03a3467..fdf3e39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,7 @@ // Common Types // ============================================================================ -export type TierName = "FREE" | "DEV" | "PRO" | "ENTERPRISE"; +export type TierName = "FREE" | "DEV" | "STARTUP" | "PRO" | "ENTERPRISE"; export type SearchScope = "all" | "repos" | "files" | "code" | "functions"; @@ -268,6 +268,302 @@ export interface ReadmeData { export interface ReadmeResponse extends BaseResponse {} +// ============================================================================ +// Public Repository Catalog Types (`GET /v2/public/repos`) +// ============================================================================ + +export interface PublicRepoTopFile { + id: string; + file_name: string; + file_path: string; + file_size_kb?: number | null; + updated_at?: string | null; +} + +export interface PublicRepoListItem { + id: string; + link: string; + name: string; + updated_at?: string | null; + /** Present only when the `file_count` query param is > 0. */ + top_files?: PublicRepoTopFile[]; +} + +export interface PublicRepoListMeta extends Meta { + scope?: string; + file_count?: number; +} + +export interface PublicRepoListResponse extends PaginatedResponse { + meta?: PublicRepoListMeta; +} + +// ============================================================================ +// Repository Intelligence Types +// (scope, map, blueprint, radius, review, test-gaps) +// ============================================================================ + +export type DependencyType = "package" | "import" | "export" | "env"; + +export interface DependencyUsage { + file_count?: number; + total_files?: number; + percentage?: number; + occurrence_count?: number; +} + +export interface DependencyComparison { + status?: "source_only" | "shared" | "comparison_only"; + repos?: Record[]; +} + +export interface DependencyItem { + name: string; + version?: string | null; + type?: string; + symbols?: string[]; + usage?: DependencyUsage; + comparison?: DependencyComparison; + /** Present only when `include=files`; includes Markdown documentation per file. */ + files?: Record[]; + /** Present only when `include=functions`; no control_flow fields. */ + functions?: Record[]; +} + +export interface ScopeSymbolItem { + name: string; + type?: string; + usage?: DependencyUsage; + detail?: Record | null; +} + +// ---- Scope (`POST /v2/repos/{repo_id}/scope`) ---- + +export type RepoScopeKind = "dependencies" | "functions" | "variables"; +export type RepoScopeExpand = "files" | "functions"; +export type RepoScopeOrder = "usage" | "name"; + +export interface RepoScopeRequest { + include?: RepoScopeKind[] | string; + name?: string; + type?: string; + expand?: RepoScopeExpand[] | string; + compare_repos?: string[] | string; + limit?: number; + offset?: number; + order?: RepoScopeOrder; +} + +export interface RepoScopeSummary { + total_files?: number; + files_with_dependencies?: number; + dependency_count?: number; + function_count?: number; + variable_count?: number; + compared_repo_count?: number; +} + +export interface RepoScopeCollections { + dependencies?: DependencyItem[]; + functions?: ScopeSymbolItem[]; + variables?: ScopeSymbolItem[]; +} + +export interface RepoScopeData { + repo?: Record; + summary?: RepoScopeSummary; + scope?: RepoScopeCollections; +} + +export interface RepoScopePagination { + dependencies?: Pagination; + functions?: Pagination; + variables?: Pagination; +} + +export interface RepoScopeMeta extends Meta { + gated_kinds?: Array<"functions" | "variables">; +} + +export interface RepoScopeResponse extends BaseResponse { + pagination?: RepoScopePagination; + meta?: RepoScopeMeta; +} + +// ---- Cross-repository dependency map (`GET /v2/repos/{repo_id}/map`) ---- + +export interface RepoMapOptions { + dependency?: string; + type?: DependencyType; + /** Comma-separated repository UUIDs (max 10). */ + compare_repos?: string; + include_public_index?: boolean; + limit?: number; + files_per_repo?: number; + demo?: boolean; +} + +export interface RepoMapData { + source_repo?: Record; + scope?: Record; + dependencies?: Record[]; +} + +export interface RepoMapResponse extends BaseResponse {} + +// ---- Blueprint (`GET /v2/repos/{repo_id}/blueprint`) ---- + +export interface RepoBlueprintRepo { + id?: string; + name?: string; + link?: string; + description?: string | null; + [key: string]: unknown; +} + +export interface RepoBlueprintSymbols { + top_functions?: ScopeSymbolItem[]; + top_variables?: ScopeSymbolItem[]; +} + +export interface RepoBlueprintData { + id?: string | null; + file_name?: string; + file_path?: string; + github_url?: string | null; + repo?: RepoBlueprintRepo; + /** Markdown README documentation. */ + documentation?: string; + /** Raw README file content. */ + data?: string | null; + dependencies?: DependencyItem[]; + symbols?: RepoBlueprintSymbols; + /** Aggregated conventions (null below PRO tier). */ + conventions?: Record | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface RepoBlueprintMeta extends Meta { + conventions_tier_gated?: boolean; + /** true on the `/readme` alias only. */ + deprecated?: boolean; +} + +export interface RepoBlueprintResponse extends BaseResponse { + meta?: RepoBlueprintMeta; +} + +// ---- Blast radius (`POST /v2/repos/{repo_id}/radius`) ---- + +export type DataFlowKind = "http_input" | "env_config" | "filesystem" | "cli_input" | "internal"; + +export interface DataFlowProjection { + id?: string | null; + risk_level?: string; + source_kind?: DataFlowKind; + sink_kind?: DataFlowKind; +} + +export interface CallEdgeProjection { + caller?: string; + callee?: string; + call_type?: string; +} + +export interface RepoRadiusRequest { + file?: string; + files?: string[]; + dependency?: string; + dependencies?: string[]; + include?: string[]; + limit?: number; +} + +export interface RepoRadiusTarget { + data_flows?: DataFlowProjection[]; + entry_points?: string[]; + call_edges?: CallEdgeProjection[]; + /** ADMIN only. */ + admin_enrichment?: Record; + [key: string]: unknown; +} + +export interface RepoRadiusData { + summary?: Record; + targets?: RepoRadiusTarget[]; +} + +export interface RepoRadiusResponse extends BaseResponse {} + +// ---- Code review (`GET /v2/repos/{repo_id}/review`) ---- + +export type RepoReviewOrder = "risk" | "complexity" | "coverage" | "debt"; + +export interface ReviewFileMetrics { + cognitive_complexity?: number; + cyclomatic_complexity?: number; + duplication_percentage?: number; + duplicate_block_count?: number; + technical_debt_minutes?: number; + estimated_coverage?: number | null; + churn_rate?: string; +} + +export interface ReviewFile extends ReviewFileMetrics { + id?: string; + file_name?: string; + file_path?: string; + verdict?: string; + risk_score?: number; + triggered_rules?: Record[]; + admin_enrichment?: Record; +} + +export interface RepoReviewData { + summary?: Record; + files?: ReviewFile[]; +} + +export interface RepoReviewResponse extends BaseResponse { + pagination?: Pagination; +} + +// ---- Test gaps (`GET /v2/repos/{repo_id}/test-gaps`) ---- + +export type TestGapPriority = "high" | "medium" | "low"; + +export interface TestCaseProjection { + priority?: TestGapPriority; + description?: string; + target_function?: string; +} + +export interface TestGapProjection { + risk_score?: number; + estimated_coverage?: number; + churn_rate?: string; + security_risk_level?: string; + suggested_test_cases?: TestCaseProjection[]; + mockable_dependencies?: string[]; + hard_to_test_reasons?: string[]; +} + +export interface TestGapFile extends TestGapProjection { + id?: string; + file_name?: string; + file_path?: string; +} + +export interface RepoTestGapsData { + summary?: Record; + gaps?: TestGapFile[]; +} + +export interface RepoTestGapsResponse extends BaseResponse { + pagination?: Pagination; +} + // ============================================================================ // File Types // ============================================================================ @@ -592,24 +888,77 @@ export interface ChatResponse extends BaseResponse { } // ============================================================================ -// V1 Models Types (no V2 equivalent) +// Models Types (`GET /v2/models`, `GET /v2/models/limits`) // ============================================================================ +export interface AIModelCreditCost { + /** null means use tier credit_search_base from GET /v2/models/limits. */ + search_base?: number | null; + search_base_from_tier?: boolean; + chat_min?: number | null; + chat_min_from_tier?: boolean; +} + export interface AIModel { id: string; name: string; provider: string; - /** Present on normalized MCP catalog entries. */ + /** Curated tier requirement for the model. */ tier_required?: TierName; - /** Raw `/v1/fundi/models` entries from OpenRouter catalog mapping. */ + /** Raw catalog entries from the OpenRouter catalog mapping. */ tier_requirements?: { premium_only?: boolean; default_model?: boolean; - preview_mode?: unknown; + preview_mode?: Record | null; }; - context_length?: number; + context_length?: number | null; + /** Search/research credit costs (present on the V2 curated catalog). */ + credit_cost?: AIModelCreditCost; } +/** `GET /v2/models` returns the curated catalog wrapped under `data.models`. */ +export interface V2ModelsListData { + models: AIModel[]; +} + +export interface V2ModelsListResponse extends BaseResponse {} + +/** Normalized shape returned by the client for MCP tools. */ export interface ModelsResponse { models: AIModel[]; } + +export interface ModelLimitsSubscription { + tokens?: number; + bonus_tokens?: number; + expiry_date?: string; +} + +export interface ModelLimitsModel { + name?: string; + provider?: string; + max_tokens?: number; + context_length?: number; + vector_search_length?: number; + knowledge_search_length?: number; + knowledge_storage_limit?: number; +} + +export interface ModelLimitsLimits { + history_days?: number; + history_max_records?: number; + repos_max?: number; + files_per_repo_max?: number; + stats_range_max_days?: number; + can_access_org_repos?: boolean; + can_share_to_org?: boolean; +} + +export interface ModelLimitsData { + tier?: string; + subscription?: ModelLimitsSubscription; + model?: ModelLimitsModel | null; + limits?: ModelLimitsLimits; +} + +export interface ModelLimitsResponse extends BaseResponse {} diff --git a/tests/client.test.ts b/tests/client.test.ts index 3f53663..c2a4696 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -109,6 +109,168 @@ describe("CodeFundiClient", () => { it("getModels should throw without API key", async () => { await expect(noKeyClient.getModels()).rejects.toThrow(CodeFundiApiError); }); + + it("getModelLimits should throw without API key", async () => { + await expect(noKeyClient.getModelLimits()).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoScope should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoScope("repo-id")).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoMap should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoMap("repo-id")).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoBlueprint should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoBlueprint("repo-id")).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoRadius should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoRadius("repo-id", { file: "src/index.ts" })).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoReview should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoReview("repo-id")).rejects.toThrow(CodeFundiApiError); + }); + + it("getRepoTestGaps should throw without API key (non-demo)", async () => { + await expect(noKeyClient.getRepoTestGaps("repo-id")).rejects.toThrow(CodeFundiApiError); + }); + }); + + // ==== V2 repository intelligence & demo mode ==== + + describe("V2 repository intelligence", () => { + it("listPublicRepos should not require an API key", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", data: [], pagination: { total: 0, limit: 100, offset: 0, has_more: false } }), + }); + vi.stubGlobal("fetch", mockFetch); + + const noKeyClient = new CodeFundiClient("https://api.test.codefundi.app"); + await noKeyClient.listPublicRepos({ limit: 10, file_count: 3 }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain("/v2/public/repos"); + expect(url).toContain("limit=10"); + expect(url).toContain("file_count=3"); + expect(options.method).toBe("GET"); + expect(options.headers["X-API-Key"]).toBeUndefined(); + + vi.unstubAllGlobals(); + }); + + it("listPublicRepos should send X-Internal-Secret header when provided", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", data: [] }), + }); + vi.stubGlobal("fetch", mockFetch); + + const noKeyClient = new CodeFundiClient("https://api.test.codefundi.app"); + await noKeyClient.listPublicRepos({ internalSecret: "s3cr3t" }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).not.toContain("internalSecret"); + expect(options.headers["X-Internal-Secret"]).toBe("s3cr3t"); + + vi.unstubAllGlobals(); + }); + + it("getRepoScope should POST to the scope endpoint with a JSON body", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", data: { scope: {} } }), + }); + vi.stubGlobal("fetch", mockFetch); + + await client.getRepoScope("repo-id", { include: ["dependencies"], name: "express" }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://api.test.codefundi.app/v2/repos/repo-id/scope"); + expect(options.method).toBe("POST"); + const body = JSON.parse(options.body as string); + expect(body.include).toEqual(["dependencies"]); + expect(body.name).toBe("express"); + + vi.unstubAllGlobals(); + }); + + it("getRepoScope should run in demo mode without an API key", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", data: { scope: {} } }), + }); + vi.stubGlobal("fetch", mockFetch); + + const noKeyClient = new CodeFundiClient("https://api.test.codefundi.app"); + await noKeyClient.getRepoScope("repo-id", {}, true); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain("/v2/repos/repo-id/scope"); + expect(url).toContain("demo=true"); + expect(options.method).toBe("POST"); + + vi.unstubAllGlobals(); + }); + + it("getRepoReview should build query parameters", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", data: { files: [] } }), + }); + vi.stubGlobal("fetch", mockFetch); + + await client.getRepoReview("repo-id", { verdict: "warn,block", order: "risk", limit: 10 }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain("/v2/repos/repo-id/review"); + expect(url).toContain("verdict=warn%2Cblock"); + expect(url).toContain("order=risk"); + expect(url).toContain("limit=10"); + expect(options.method).toBe("GET"); + + vi.unstubAllGlobals(); + }); + + it("getRepoTestGaps should include min_risk=0 and untested_only=false", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "success", data: { gaps: [] } }), + }); + vi.stubGlobal("fetch", mockFetch); + + await client.getRepoTestGaps("repo-id", { min_risk: 0, untested_only: false }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("min_risk=0"); + expect(url).toContain("untested_only=false"); + + vi.unstubAllGlobals(); + }); + + it("getModels should GET /v2/models and normalize data.models", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: "success", + data: { models: [{ id: "gpt-4", name: "GPT-4", provider: "openai", tier_required: "PRO" }] }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const res = await client.getModels(); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://api.test.codefundi.app/v2/models"); + expect(options.method).toBe("GET"); + expect(res.models).toHaveLength(1); + expect(res.models[0].id).toBe("gpt-4"); + + vi.unstubAllGlobals(); + }); }); // ==== Auth methods do NOT require API key ==== diff --git a/tests/formatters.test.ts b/tests/formatters.test.ts index 167d8f8..87caf86 100644 --- a/tests/formatters.test.ts +++ b/tests/formatters.test.ts @@ -9,12 +9,17 @@ import { formatFileList, formatFileDocumentation, formatHistoryList, formatHistoryDetail, formatConversation, formatUsageStats, formatActivityStats, formatLanguageStats, - formatApiKeys, formatModels, formatError, + formatApiKeys, formatModels, formatModelLimits, formatError, + formatPublicRepoList, formatRepoScope, formatRepoMap, + formatRepoBlueprint, formatRepoRadius, formatRepoReview, + formatRepoTestGaps, } from "../src/formatters.js"; import { CodeFundiApiError } from "../src/client.js"; import type { SearchResult, Repository, FileListItem, HistoryItem, FileDocumentationData, ReadmeData, RepositoryIndexInitRepo, + PublicRepoListItem, RepoScopeData, RepoMapData, RepoBlueprintData, + RepoRadiusData, RepoReviewData, RepoTestGapsData, ModelLimitsData, } from "../src/types.js"; // ==== Search ==== @@ -228,6 +233,190 @@ describe("formatModels", () => { }); }); +describe("formatModelLimits", () => { + it("should format tier, subscription, model, and limits", () => { + const data: ModelLimitsData = { + tier: "PRO", + subscription: { tokens: 100000, bonus_tokens: 5000, expiry_date: "2026-12-31T00:00:00Z" }, + model: { name: "GPT-4o", provider: "openai", max_tokens: 4096, context_length: 128000 }, + limits: { repos_max: 50, files_per_repo_max: 2000, history_days: 90, can_share_to_org: true }, + }; + const result = formatModelLimits(data); + expect(result).toContain("PRO"); + expect(result).toContain("100000"); + expect(result).toContain("GPT-4o"); + expect(result).toContain("Max repos:** 50"); + expect(result).toContain("Can share to org:** true"); + }); +}); + +// ==== Public Repos ==== + +describe("formatPublicRepoList", () => { + it("should format empty catalog", () => { + const result = formatPublicRepoList([]); + expect(result).toContain("No public repositories found"); + }); + + it("should format public repos with top files", () => { + const repos: PublicRepoListItem[] = [{ + id: "p1", name: "public-repo", link: "https://github.com/org/public-repo", + updated_at: "2026-01-01T00:00:00Z", + top_files: [{ id: "f1", file_name: "index.ts", file_path: "src/index.ts", file_size_kb: 12 }], + }]; + const result = formatPublicRepoList(repos, { total: 1, limit: 100, offset: 0, has_more: false }); + expect(result).toContain("public-repo"); + expect(result).toContain("p1"); + expect(result).toContain("src/index.ts"); + expect(result).toContain("12 KB"); + }); +}); + +// ==== Repository Intelligence ==== + +describe("formatRepoScope", () => { + it("should format dependencies, functions, variables and pagination hints", () => { + const data: RepoScopeData = { + repo: { name: "my-repo" }, + summary: { total_files: 100, dependency_count: 3, function_count: 2, variable_count: 1 }, + scope: { + dependencies: [{ name: "express", version: "4.18.0", type: "package", usage: { file_count: 10, percentage: 25 } }], + functions: [{ name: "handler", type: "function", usage: { file_count: 4 } }], + variables: [{ name: "PORT", type: "env" }], + }, + }; + const result = formatRepoScope(data, { dependencies: { total: 20, limit: 1, offset: 0, has_more: true } }, ["variables"]); + expect(result).toContain("my-repo"); + expect(result).toContain("**express**@4.18.0"); + expect(result).toContain("used in 10 file(s)"); + expect(result).toContain("handler"); + expect(result).toContain("PORT"); + expect(result).toContain("Gated"); + expect(result).toContain("More available"); + }); + + it("should handle empty scope", () => { + const result = formatRepoScope({ scope: {} }); + expect(result).toContain("No scope data found"); + }); +}); + +describe("formatRepoMap", () => { + it("should format map dependencies", () => { + const data: RepoMapData = { + source_repo: { name: "core" }, + dependencies: [{ name: "lodash", type: "package", repos: [{ id: "r1" }, { id: "r2" }] }], + }; + const result = formatRepoMap(data); + expect(result).toContain("core"); + expect(result).toContain("lodash"); + expect(result).toContain("Present in 2 repo(s)"); + }); + + it("should handle empty map", () => { + const result = formatRepoMap({ dependencies: [] }); + expect(result).toContain("No dependency map entries found"); + }); +}); + +describe("formatRepoBlueprint", () => { + it("should format dependencies, symbols, conventions gating, and README", () => { + const data: RepoBlueprintData = { + file_name: "README.md", + github_url: "https://github.com/org/repo", + repo: { name: "repo" }, + documentation: "# My Project", + dependencies: [{ name: "react", version: "18.0.0", type: "package", usage: { file_count: 30 } }], + symbols: { top_functions: [{ name: "main" }], top_variables: [{ name: "config" }] }, + conventions: null, + }; + const result = formatRepoBlueprint(data, { conventions_tier_gated: true }); + expect(result).toContain("repo — Blueprint"); + expect(result).toContain("**react**@18.0.0"); + expect(result).toContain("main"); + expect(result).toContain("config"); + expect(result).toContain("require PRO tier"); + expect(result).toContain("# My Project"); + }); + + it("should note deprecation when meta.deprecated is set", () => { + const result = formatRepoBlueprint({ repo: { name: "repo" } }, { deprecated: true }); + expect(result).toContain("deprecated"); + }); +}); + +describe("formatRepoRadius", () => { + it("should format targets with data flows and call edges", () => { + const data: RepoRadiusData = { + summary: { impacted_files: 3 }, + targets: [{ + entry_points: ["src/server.ts"], + data_flows: [{ source_kind: "http_input", sink_kind: "filesystem", risk_level: "high" }], + call_edges: [{ caller: "handler", callee: "writeFile", call_type: "direct" }], + }], + }; + const result = formatRepoRadius(data); + expect(result).toContain("Blast Radius Analysis"); + expect(result).toContain("src/server.ts"); + expect(result).toContain("http_input → filesystem"); + expect(result).toContain("handler → writeFile"); + }); + + it("should handle no targets", () => { + const result = formatRepoRadius({ targets: [] }); + expect(result).toContain("No impact targets found"); + }); +}); + +describe("formatRepoReview", () => { + it("should format the file metrics table", () => { + const data: RepoReviewData = { + summary: { blockers: 1 }, + files: [{ + id: "rf1", file_path: "src/app.ts", verdict: "block", risk_score: 0.9, + cyclomatic_complexity: 22, estimated_coverage: null, technical_debt_minutes: 120, + }], + }; + const result = formatRepoReview(data); + expect(result).toContain("Code Review Signals"); + expect(result).toContain("src/app.ts"); + expect(result).toContain("block"); + expect(result).toContain("N/A"); + expect(result).toContain("120"); + }); + + it("should handle no files", () => { + const result = formatRepoReview({ files: [] }); + expect(result).toContain("No review signals found"); + }); +}); + +describe("formatRepoTestGaps", () => { + it("should format gaps with suggested test cases", () => { + const data: RepoTestGapsData = { + gaps: [{ + id: "g1", file_path: "src/auth.ts", risk_score: 0.8, estimated_coverage: 0.2, + security_risk_level: "high", + suggested_test_cases: [{ priority: "high", description: "Test invalid token", target_function: "verify" }], + mockable_dependencies: ["jwt"], + hard_to_test_reasons: ["network calls"], + }], + }; + const result = formatRepoTestGaps(data); + expect(result).toContain("Test Coverage Gaps"); + expect(result).toContain("src/auth.ts"); + expect(result).toContain("Test invalid token"); + expect(result).toContain("verify"); + expect(result).toContain("jwt"); + expect(result).toContain("network calls"); + }); + + it("should handle no gaps", () => { + const result = formatRepoTestGaps({ gaps: [] }); + expect(result).toContain("No test gaps found"); + }); +}); + // ==== Errors ==== describe("formatError", () => { From d24010a91c89c9a53d13f931a884fd71730dbacb Mon Sep 17 00:00:00 2001 From: FelixWaweru Date: Wed, 15 Jul 2026 17:26:58 +0300 Subject: [PATCH 2/6] fix: build fixes --- pnpm-lock.yaml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e94acc..a0bf03a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^4.0.0 version: 4.0.1 zod: - specifier: ^3.24.0 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.6 devDependencies: '@types/node': specifier: ^22.0.0 @@ -1327,9 +1327,6 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2506,6 +2503,4 @@ snapshots: dependencies: zod: 4.3.6 - zod@3.25.76: {} - zod@4.3.6: {} From 5ef321d97a69ed741a5e5ede23b3797684866aee Mon Sep 17 00:00:00 2001 From: FelixWaweru Date: Wed, 15 Jul 2026 17:44:48 +0300 Subject: [PATCH 3/6] feat: updated docs in README --- README.md | 50 ++++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 24faa94..8597348 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

Code Fundi

-

The Codebase Context Layer for Agents, Teams and Applications.

+

The Codebase Map for AI Agents, Teams and Applications.

@@ -29,20 +29,22 @@ # Code-Fundi MCP Server -A production-grade [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for the [Code-Fundi](https://api.codefundi.app) API. Enables any MCP-compatible AI assistant — Claude, Cursor, Windsurf, VS Code Copilot, and more — to search, research, index, and scan code repositories through natural language. +A production-grade [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for the [Code-Fundi](https://api.codefundi.app) API: a codebase map and blast-radius engine for AI coding agents. Enables any MCP-compatible AI assistant (Claude, Claude Code, Cursor, Windsurf, VS Code Copilot, and more) to search, research, index, and scan code repositories through natural language, and see what a change breaks before it ships. Built with [FastMCP](https://github.com/punkpeye/fastmcp) (TypeScript) and [Zod](https://zod.dev) for schema validation. ## Features -- 🔍 **Semantic & grep code search** across indexed repositories -- 🧠 **AI-powered research** — search + AI analysis in a single call -- 📦 **Repository management** — index, status, README, listing, public catalog -- 🛰️ **Repository intelligence** — scope (dependencies/functions/variables), cross-repo dependency map, blueprint, blast radius, code review, and test-gap analysis -- 📄 **File documentation** — AI-generated docs for any indexed file -- 📊 **Usage statistics** — query usage, activity, language breakdowns -- 🔐 **Agent-driven authentication** — sign up/sign in via OTP without pre-configured keys -- 💬 **AI chat & model insight** — direct conversation with Code-Fundi AI, model catalog, and per-tier limits +Every tool below is backed by the same codebase map: structural dependencies, call graph, and blast radius, indexed once and queried in milliseconds. + +- 🔍 **Semantic & grep code search** across your indexed codebase map +- 🧠 **AI-powered research**: search plus AI analysis in a single call +- 📦 **Repository management**: index, status, README, listing, public catalog +- 🛰️ **Repository intelligence**: scope (dependencies/functions/variables), cross-repo dependency map, blueprint, Blast-Radius Guard (impact analysis before you merge), code review, and test-gap analysis +- 📄 **File documentation**: AI-generated docs for any indexed file +- 📊 **Usage statistics**: query usage, activity, language breakdowns +- 🔐 **Agent-driven authentication**: sign up/sign in via OTP without pre-configured keys +- 💬 **AI chat & model insight**: direct conversation with Code-Fundi AI, model catalog, and per-tier limits ## Quick Start @@ -77,11 +79,11 @@ Set your API key as an environment variable: export CODEFUNDI_API_KEY=your_api_key_here ``` -Or skip this step — agents can authenticate dynamically using the `code-fundi-auth-*` tools. +Or skip this step, agents can authenticate dynamically using the `code-fundi-auth-*` tools. ### Use with Claude Desktop -After a global install (`npm i -g @codefundi/code-fundi-mcp`), point MCP at the published binary — **no path to `dist/index.js` required**: +After a global install (`npm i -g @codefundi/code-fundi-mcp`), point MCP at the published binary (no path to `dist/index.js` required): ```json { @@ -114,7 +116,7 @@ If the binary is not on your `PATH`, use `npx` (downloads or uses the local pack ### Use with Cursor -Same pattern as Claude — `command` + optional `args` only; no manual path to the repo: +Same pattern as Claude: `command` plus optional `args` only, no manual path to the repo: ```json { @@ -142,33 +144,33 @@ npx fastmcp dev src/index.ts # Test with MCP CLI ## Tools Reference (30 tools) -Covers the Code-Fundi **V2** API: search (including search-with-chat / research), repositories (list, index, status, readme, public catalog), repository intelligence (scope, map, blueprint, radius, review, test-gaps), files, history, statistics, API keys, authentication, plus **Fundi chat** (`POST /v1/fundi/chat`) and the V2 model catalog / limits (`GET /v2/models`, `GET /v2/models/limits`). +Covers the Code-Fundi **V2** API for codebase mapping and blast-radius analysis: search (including search-with-chat / research), repositories (list, index, status, readme, public catalog), repository intelligence (scope, map, blueprint, radius, review, test-gaps), files, history, statistics, API keys, authentication, plus **Fundi chat** (`POST /v1/fundi/chat`) and the V2 model catalog / limits (`GET /v2/models`, `GET /v2/models/limits`). ### Search | Tool | Description | |------|-------------| -| `code-fundi-search` | Semantic/grep search across repositories with filters | -| `code-fundi-research` | Search + AI-synthesized analysis of matching code | +| `code-fundi-search` | Semantic and grep search across your indexed codebase map, with filters | +| `code-fundi-research` | Search plus AI-synthesized analysis of matching code | ### Repositories | Tool | Description | |------|-------------| | `code-fundi-list-repos` | List indexed repositories with pagination | -| `code-fundi-index-repo` | Index a new GitHub repository | +| `code-fundi-index-repo` | Index a new GitHub repository into your codebase map | | `code-fundi-repo-status` | Check repository indexing status | -| `code-fundi-repo-readme` | Get repository README documentation (deprecated — prefer blueprint) | +| `code-fundi-repo-readme` | Get repository README documentation (deprecated, prefer blueprint) | | `code-fundi-list-public-repos` | Browse the global catalog of indexed public repositories (no key needed) | ### Repository Intelligence | Tool | Description | |------|-------------| -| `code-fundi-repo-scope` | Aggregated dependencies, functions, and variables (with drill-down and cross-repo comparison) | -| `code-fundi-repo-map` | Cross-repository dependency map | -| `code-fundi-repo-blueprint` | README + dependency/convention overview (successor to repo-readme) | -| `code-fundi-repo-radius` | Blast radius / file impact analysis (PRO+) | +| `code-fundi-repo-scope` | Aggregated dependencies, functions, and variables across the codebase map (with drill-down and cross-repo comparison) | +| `code-fundi-repo-map` | Cross-repository dependency map: how services and packages actually connect | +| `code-fundi-repo-blueprint` | README plus dependency and convention overview (successor to repo-readme) | +| `code-fundi-repo-radius` | Blast-Radius Guard: every file and function that breaks before you merge (PRO+) | | `code-fundi-repo-review` | Per-file code review signals: verdict, risk, complexity, coverage, debt (PRO+) | | `code-fundi-repo-test-gaps` | Test coverage gap analysis with suggested test cases (PRO+) | @@ -229,7 +231,7 @@ When no API key is set, agents can self-authenticate: 1. Call `code-fundi-auth-authenticate` with email and `auth_mode: "otp"` 2. User receives an OTP code via email 3. Call `code-fundi-auth-verify` with the 6-digit code -4. API key is automatically configured — all tools now work +4. API key is automatically configured, all tools now work This enables fully autonomous agent setup without manual configuration. @@ -244,4 +246,4 @@ This enables fully autonomous agent setup without manual configuration. ## License -MIT +MIT \ No newline at end of file From d4676bb7cc79dffc3d6846666230a3b09884412c Mon Sep 17 00:00:00 2001 From: FelixWaweru Date: Wed, 15 Jul 2026 17:46:09 +0300 Subject: [PATCH 4/6] feat: updated package --- package.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/package.json b/package.json index 1638838..e49d21a 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,6 @@ "author": "Code Fundi ", "type": "module", "main": "dist/index.js", - "repository": { - "type": "git", - "url": "https://github.com/Code-Fundi/code-fundi-mcp" - }, - "homepage": "https://codefundi.app", - "author": "Code Fundi", "bin": { "code-fundi-mcp": "dist/index.js" }, From 231e87f5b93fb011c3f1c48ae8b0292040488ebe Mon Sep 17 00:00:00 2001 From: FelixWaweru Date: Wed, 15 Jul 2026 18:09:57 +0300 Subject: [PATCH 5/6] feat: updated documentation --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8597348..4810ad0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,75 @@ A production-grade [Model Context Protocol (MCP)](https://modelcontextprotocol.i Built with [FastMCP](https://github.com/punkpeye/fastmcp) (TypeScript) and [Zod](https://zod.dev) for schema validation. +## Getting started for AI agents + +If you are an AI agent (or configuring one) and **no `CODEFUNDI_API_KEY` is set**, you must self-authenticate before using search, repos, chat, or other authenticated tools. The auth tools (`code-fundi-auth-*`) work without a pre-existing key. + +### Which path to use + +| Situation | Action | +|-----------|--------| +| `CODEFUNDI_API_KEY` is set in MCP config | Use any tool immediately — the server sends it as `X-API-Key` on every request | +| No API key configured | Run the **OTP self-auth flow** below (recommended) or **password sign-in** | + +### Zero-config MCP setup (no API key) + +You can start the MCP server without `CODEFUNDI_API_KEY` and authenticate at runtime: + +```json +{ + "mcpServers": { + "code-fundi": { + "command": "npx", + "args": ["-y", "@codefundi/code-fundi-mcp"] + } + } +} +``` + +### Self-authenticate with OTP (recommended) + +Code-Fundi uses Supabase-backed auth (`POST /v2/auth/authenticate`, `/v2/auth/verify`, `/v2/auth/resend`). OTP emails contain a **6-digit code** — magic links are not supported on this path. + +1. **Ask the human user for their email address.** +2. Call **`code-fundi-auth-authenticate`** with: + - `auth_mode`: `"otp"` + - `email`: the user's email + - `should_create_user`: `true` for a **new** account, `false` for a **returning** user +3. Tell the user to check their inbox for a 6-digit code. The API may return `verification_required: true` and `api_key.key_state: "agent_pending"` until verification completes. +4. **Ask the user for the 6-digit OTP** (human-in-the-loop — you cannot guess or bypass this step). +5. Call **`code-fundi-auth-verify`** with the same `email` and the `token` (6 digits). +6. On success, the MCP server **automatically configures the API key in memory** for all subsequent tool calls in this session. + +If the code expired or was not received, call **`code-fundi-auth-resend`** with the same `email`, then repeat step 5. + +**Example dialogue:** + +``` +Agent: What email should I use to sign in to Code-Fundi? +User: dev@example.com +Agent: [calls code-fundi-auth-authenticate] I've sent a 6-digit code to dev@example.com. Please paste it here. +User: 482913 +Agent: [calls code-fundi-auth-verify] You're signed in. I can now search and index your repositories. +``` + +### Password sign-in (alternative) + +For existing accounts with a password, call **`code-fundi-auth-authenticate`** with `auth_mode: "password"`, the user's `email`, `should_create_user: false`, and the `password` parameter. The MCP client sends the password only in the `X-CodeFundi-Auth-Password` header (never in the JSON body). Production requires HTTPS. Returning users may receive an active API key immediately without a separate verify step. + +### After authentication + +- The API key is held **in memory** for the lifetime of the MCP server process. It is **not** persisted across IDE or MCP restarts. +- Recommend the user add the key to their MCP config as `CODEFUNDI_API_KEY` so future sessions start authenticated. +- A FREE-tier account and API key are created automatically on first signup. + +### Errors + +| HTTP status | What to do | +|-------------|------------| +| **401** Unauthorized | No valid key — run the OTP flow above, or set `CODEFUNDI_API_KEY` | +| **429** Too many requests | Auth endpoints are rate-limited per IP; wait for `Retry-After` seconds, then retry | + ## Features Every tool below is backed by the same codebase map: structural dependencies, call graph, and blast radius, indexed once and queried in milliseconds. @@ -73,13 +142,26 @@ npm run build ### Configure -Set your API key as an environment variable: +**Option A — API key (fastest):** set your key as an environment variable: ```bash export CODEFUNDI_API_KEY=your_api_key_here ``` -Or skip this step, agents can authenticate dynamically using the `code-fundi-auth-*` tools. +**Option B — no API key:** skip the env var and let the agent self-authenticate at runtime. See [Getting started for AI agents](#getting-started-for-ai-agents). + +Zero-config MCP example (no `env` block): + +```json +{ + "mcpServers": { + "code-fundi": { + "command": "npx", + "args": ["-y", "@codefundi/code-fundi-mcp"] + } + } +} +``` ### Use with Claude Desktop @@ -218,22 +300,7 @@ Covers the Code-Fundi **V2** API for codebase mapping and blast-radius analysis: ## Authentication -The server supports two authentication modes: - -### Pre-configured API Key (Recommended) - -Set `CODEFUNDI_API_KEY` in your MCP config environment. All tools work immediately. - -### Agent-Driven Auth (Dynamic) - -When no API key is set, agents can self-authenticate: - -1. Call `code-fundi-auth-authenticate` with email and `auth_mode: "otp"` -2. User receives an OTP code via email -3. Call `code-fundi-auth-verify` with the 6-digit code -4. API key is automatically configured, all tools now work - -This enables fully autonomous agent setup without manual configuration. +Two modes: **pre-configured API key** (`CODEFUNDI_API_KEY` in MCP config) or **agent-driven OTP/password auth** at runtime. Full step-by-step instructions, tool names, and error handling are in [Getting started for AI agents](#getting-started-for-ai-agents) at the top of this README. ## Environment Variables From 669c6dcc317af93ddeb366076c41782ee1dada15 Mon Sep 17 00:00:00 2001 From: FelixWaweru Date: Fri, 17 Jul 2026 12:46:29 +0300 Subject: [PATCH 6/6] feat: scope refinement --- README.md | 9 +-- src/client.ts | 34 +--------- src/formatters.ts | 141 ++------------------------------------- src/tools/auth.ts | 20 +++--- src/tools/intel.ts | 96 +------------------------- src/types.ts | 126 +--------------------------------- tests/client.test.ts | 84 ----------------------- tests/formatters.test.ts | 87 ++---------------------- 8 files changed, 31 insertions(+), 566 deletions(-) diff --git a/README.md b/README.md index 4810ad0..8f80bec 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Every tool below is backed by the same codebase map: structural dependencies, ca - 🔍 **Semantic & grep code search** across your indexed codebase map - 🧠 **AI-powered research**: search plus AI analysis in a single call - 📦 **Repository management**: index, status, README, listing, public catalog -- 🛰️ **Repository intelligence**: scope (dependencies/functions/variables), cross-repo dependency map, blueprint, Blast-Radius Guard (impact analysis before you merge), code review, and test-gap analysis +- 🛰️ **Repository intelligence**: cross-repo dependency map, blueprint, Blast-Radius Guard (impact analysis before you merge) - 📄 **File documentation**: AI-generated docs for any indexed file - 📊 **Usage statistics**: query usage, activity, language breakdowns - 🔐 **Agent-driven authentication**: sign up/sign in via OTP without pre-configured keys @@ -224,9 +224,9 @@ npx fastmcp inspect src/index.ts # Open MCP Inspector UI npx fastmcp dev src/index.ts # Test with MCP CLI ``` -## Tools Reference (30 tools) +## Tools Reference (27 tools) -Covers the Code-Fundi **V2** API for codebase mapping and blast-radius analysis: search (including search-with-chat / research), repositories (list, index, status, readme, public catalog), repository intelligence (scope, map, blueprint, radius, review, test-gaps), files, history, statistics, API keys, authentication, plus **Fundi chat** (`POST /v1/fundi/chat`) and the V2 model catalog / limits (`GET /v2/models`, `GET /v2/models/limits`). +Covers the Code-Fundi **V2** API for codebase mapping and blast-radius analysis: search (including search-with-chat / research), repositories (list, index, status, readme, public catalog), repository intelligence (map, blueprint, radius), files, history, statistics, API keys, authentication, plus **Fundi chat** (`POST /v1/fundi/chat`) and the V2 model catalog / limits (`GET /v2/models`, `GET /v2/models/limits`). ### Search @@ -249,12 +249,9 @@ Covers the Code-Fundi **V2** API for codebase mapping and blast-radius analysis: | Tool | Description | |------|-------------| -| `code-fundi-repo-scope` | Aggregated dependencies, functions, and variables across the codebase map (with drill-down and cross-repo comparison) | | `code-fundi-repo-map` | Cross-repository dependency map: how services and packages actually connect | | `code-fundi-repo-blueprint` | README plus dependency and convention overview (successor to repo-readme) | | `code-fundi-repo-radius` | Blast-Radius Guard: every file and function that breaks before you merge (PRO+) | -| `code-fundi-repo-review` | Per-file code review signals: verdict, risk, complexity, coverage, debt (PRO+) | -| `code-fundi-repo-test-gaps` | Test coverage gap analysis with suggested test cases (PRO+) | ### Files diff --git a/src/client.ts b/src/client.ts index 9357f8c..50d580a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,10 +18,9 @@ import type { ChatRequest, ChatResponse, ModelsResponse, V2ModelsListResponse, ModelLimitsResponse, SearchFieldsParam, SortOrder, StatsRange, RepoScope, HistoryCategory, ErrorResponse, - PublicRepoListResponse, RepoScopeRequest, RepoScopeResponse, + PublicRepoListResponse, RepoMapOptions, RepoMapResponse, RepoBlueprintResponse, - RepoRadiusRequest, RepoRadiusResponse, RepoReviewResponse, RepoReviewOrder, - RepoTestGapsResponse, TestGapPriority, + RepoRadiusRequest, RepoRadiusResponse, } from "./types.js"; // ============================================================================ @@ -267,15 +266,6 @@ export class CodeFundiClient { // ==== V2 Repository Intelligence ==== - async getRepoScope(repoKey: string, body: RepoScopeRequest = {}, demo?: boolean): Promise { - this.requireKeyUnlessDemo(demo); - const qs = buildQuery({ demo: demo || undefined }); - return this.request( - `/v2/repos/${encodeRepoKey(repoKey)}/scope${qs}`, - { method: "POST", body: JSON.stringify(body) }, - ); - } - async getRepoMap(repoKey: string, opts: RepoMapOptions = {}): Promise { const { demo, ...rest } = opts; this.requireKeyUnlessDemo(demo); @@ -302,26 +292,6 @@ export class CodeFundiClient { ); } - async getRepoReview( - repoKey: string, - opts: { verdict?: string; order?: RepoReviewOrder; limit?: number; offset?: number; demo?: boolean } = {}, - ): Promise { - const { demo, ...rest } = opts; - this.requireKeyUnlessDemo(demo); - const qs = buildQuery({ ...rest, demo: demo || undefined }); - return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/review${qs}`, { method: "GET" }); - } - - async getRepoTestGaps( - repoKey: string, - opts: { priority?: TestGapPriority; min_risk?: number; untested_only?: boolean; limit?: number; offset?: number; demo?: boolean } = {}, - ): Promise { - const { demo, ...rest } = opts; - this.requireKeyUnlessDemo(demo); - const qs = buildQuery({ ...rest, demo: demo || undefined }); - return this.request(`/v2/repos/${encodeRepoKey(repoKey)}/test-gaps${qs}`, { method: "GET" }); - } - // ==== V2 Files ==== async listFiles(repoKey: string, opts: { search?: string | null; limit?: number; offset?: number; order_by?: string; order?: SortOrder } = {}): Promise { diff --git a/src/formatters.ts b/src/formatters.ts index 49e0382..03567d7 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -10,9 +10,9 @@ import type { UsageByType, ActivityByDay, LanguageStat, ApiKey, AIModel, ReadmeData, RepoStatusData, RepositoryIndexInitRepo, Pagination, TierName, - PublicRepoListItem, RepoScopeData, RepoScopePagination, + PublicRepoListItem, RepoMapData, RepoBlueprintData, RepoBlueprintMeta, - RepoRadiusData, RepoReviewData, RepoTestGapsData, + RepoRadiusData, ModelLimitsData, } from "./types.js"; import { CodeFundiApiError } from "./client.js"; @@ -196,75 +196,9 @@ export function formatPublicRepoList(repos: PublicRepoListItem[], pagination?: P } // ============================================================================ -// Repository Intelligence (scope, map, blueprint, radius, review, test-gaps) +// Repository Intelligence (map, blueprint, radius) // ============================================================================ -export function formatRepoScope(data: RepoScopeData, pagination?: RepoScopePagination, gatedKinds?: string[]): string { - const parts: string[] = []; - const repoName = readString(data.repo, "name"); - parts.push(`## Repository Scope${repoName ? `: ${repoName}` : ""}\n`); - - const s = data.summary; - if (s) { - const info: string[] = []; - if (s.total_files !== undefined) info.push(`Files: ${s.total_files}`); - if (s.dependency_count !== undefined) info.push(`Dependencies: ${s.dependency_count}`); - if (s.function_count !== undefined) info.push(`Functions: ${s.function_count}`); - if (s.variable_count !== undefined) info.push(`Variables: ${s.variable_count}`); - if (s.compared_repo_count) info.push(`Compared repos: ${s.compared_repo_count}`); - if (info.length) parts.push(info.join(" | ") + "\n"); - } - if (gatedKinds?.length) parts.push(`_Gated (upgrade to unlock): ${gatedKinds.join(", ")}._\n`); - - const deps = data.scope?.dependencies ?? []; - if (deps.length) { - parts.push(`### Dependencies (${deps.length})`); - for (const d of deps) { - const ver = d.version ? `@${d.version}` : ""; - const typ = d.type ? ` [${d.type}]` : ""; - const usage = d.usage?.file_count !== undefined - ? ` — used in ${d.usage.file_count} file(s)${d.usage.percentage !== undefined ? ` (${d.usage.percentage}%)` : ""}` - : ""; - parts.push(`- **${d.name}**${ver}${typ}${usage}`); - if (d.comparison?.status) parts.push(` - Comparison: ${d.comparison.status}`); - } - parts.push(""); - } - - const fns = data.scope?.functions ?? []; - if (fns.length) { - parts.push(`### Functions (${fns.length})`); - for (const f of fns) { - const usage = f.usage?.file_count !== undefined ? ` — ${f.usage.file_count} file(s)` : ""; - parts.push(`- \`${f.name}\`${f.type ? ` [${f.type}]` : ""}${usage}`); - } - parts.push(""); - } - - const vars = data.scope?.variables ?? []; - if (vars.length) { - parts.push(`### Variables (${vars.length})`); - for (const v of vars) { - const usage = v.usage?.file_count !== undefined ? ` — ${v.usage.file_count} file(s)` : ""; - parts.push(`- \`${v.name}\`${v.type ? ` [${v.type}]` : ""}${usage}`); - } - parts.push(""); - } - - if (!deps.length && !fns.length && !vars.length) parts.push("No scope data found."); - - const more: string[] = []; - const paginate = (label: string, pg?: Pagination) => { - if (pg?.has_more) more.push(`${label} (offset ${pg.offset + pg.limit})`); - }; - paginate("dependencies", pagination?.dependencies); - paginate("functions", pagination?.functions); - paginate("variables", pagination?.variables); - if (more.length) parts.push(`_More available: ${more.join(", ")}._`); - - return parts.join("\n"); -} - export function formatRepoMap(data: RepoMapData): string { const parts: string[] = []; const srcName = readString(data.source_repo, "name"); @@ -383,73 +317,6 @@ export function formatRepoRadius(data: RepoRadiusData): string { return parts.join("\n"); } -export function formatRepoReview(data: RepoReviewData): string { - const parts: string[] = []; - parts.push("## Code Review Signals\n"); - - if (data.summary && Object.keys(data.summary).length) { - parts.push("### Summary"); - parts.push(jsonBlock(data.summary)); - parts.push(""); - } - - const files = data.files ?? []; - if (!files.length) { - parts.push("No review signals found."); - return parts.join("\n"); - } - - parts.push(`### Files (${files.length})`); - parts.push("| File | Verdict | Risk | Cyclomatic | Coverage | Debt (min) |"); - parts.push("|------|---------|------|-----------|----------|-----------|"); - for (const f of files) { - const label = f.file_path ?? f.file_name ?? f.id ?? "?"; - const cov = f.estimated_coverage === null || f.estimated_coverage === undefined ? "N/A" : `${f.estimated_coverage}`; - parts.push(`| ${label} | ${f.verdict ?? "?"} | ${f.risk_score ?? "?"} | ${f.cyclomatic_complexity ?? "?"} | ${cov} | ${f.technical_debt_minutes ?? "?"} |`); - } - - return parts.join("\n"); -} - -export function formatRepoTestGaps(data: RepoTestGapsData): string { - const parts: string[] = []; - parts.push("## Test Coverage Gaps\n"); - - if (data.summary && Object.keys(data.summary).length) { - parts.push("### Summary"); - parts.push(jsonBlock(data.summary)); - parts.push(""); - } - - const gaps = data.gaps ?? []; - if (!gaps.length) { - parts.push("No test gaps found."); - return parts.join("\n"); - } - - for (const g of gaps) { - parts.push(`### ${g.file_path ?? g.file_name ?? g.id ?? "file"}`); - const info: string[] = []; - if (g.risk_score !== undefined) info.push(`Risk: ${g.risk_score}`); - if (g.estimated_coverage !== undefined) info.push(`Coverage: ${g.estimated_coverage}`); - if (g.security_risk_level) info.push(`Security: ${g.security_risk_level}`); - if (g.churn_rate) info.push(`Churn: ${g.churn_rate}`); - if (info.length) parts.push(`- ${info.join(" | ")}`); - if (g.suggested_test_cases?.length) { - parts.push(`- **Suggested tests:**`); - for (const c of g.suggested_test_cases) { - const fn = c.target_function ? ` (\`${c.target_function}\`)` : ""; - parts.push(` - [${c.priority ?? "?"}] ${c.description ?? ""}${fn}`); - } - } - if (g.mockable_dependencies?.length) parts.push(`- **Mockable deps:** ${g.mockable_dependencies.join(", ")}`); - if (g.hard_to_test_reasons?.length) parts.push(`- **Hard to test:** ${g.hard_to_test_reasons.join("; ")}`); - parts.push(""); - } - - return parts.join("\n"); -} - // ============================================================================ // Files // ============================================================================ @@ -665,7 +532,7 @@ export function formatError(err: unknown): string { if (err instanceof CodeFundiApiError) { let msg = `**Code-Fundi API Error (${err.statusCode}):** ${err.message}`; if (err.statusCode === 401) { - msg += "\n\n_Tip: Use `code-fundi-auth-authenticate` and `code-fundi-auth-verify` to sign in, or set the CODEFUNDI_API_KEY environment variable._"; + msg += "\n\n_Tip: Use `code-fundi-auth-authenticate` and `code-fundi-auth-verify` to sign in, or set the CODEFUNDI_API_KEY environment variable. After auth, persist the key in MCP config so future sessions stay signed in._"; } if (err.retryAfter) msg += `\n_Retry after: ${err.retryAfter}s_`; return msg; diff --git a/src/tools/auth.ts b/src/tools/auth.ts index 9de3ffa..3fa7c69 100644 --- a/src/tools/auth.ts +++ b/src/tools/auth.ts @@ -16,10 +16,11 @@ export function registerAuthTools(server: FastMCP): void { server.addTool({ name: "code-fundi-auth-authenticate", description: - "Start a Code-Fundi authentication flow. Supports OTP (email code) or password modes. " + - "For new users, set should_create_user to true. " + - "After calling this, use code-fundi-auth-verify to complete the sign-in with the OTP code. " + - "This tool does NOT require an existing API key.", + "Start Code-Fundi authentication (POST /v2/auth/authenticate). No existing API key required. " + + "Use auth_mode otp for new or returning users: set should_create_user true on first signup, false on sign-in. " + + "Sends a 6-digit email OTP (not a magic link); then call code-fundi-auth-verify after the human user provides the code. " + + "Password mode: pass password here (sent as X-CodeFundi-Auth-Password header only); should_create_user false for sign-in. " + + "On success with an active key, the API key is configured in-memory for this MCP session only.", parameters: z.object({ email: z.string().email().describe("Email address to authenticate"), auth_mode: z.enum(["otp", "password"]).describe("Authentication mode: 'otp' for email code, 'password' for password-based"), @@ -70,9 +71,10 @@ export function registerAuthTools(server: FastMCP): void { server.addTool({ name: "code-fundi-auth-verify", description: - "Verify a Code-Fundi OTP code to complete authentication. " + - "Use the 6-digit code sent to the email from code-fundi-auth-authenticate. " + - "On success, the API key is automatically configured for all subsequent tool calls.", + "Complete OTP sign-in (POST /v2/auth/verify). Requires the 6-digit code from the user's email after code-fundi-auth-authenticate. " + + "The human user must supply the token — ask them explicitly. " + + "On success, activates the API key and configures it in-memory for all subsequent tools in this MCP session. " + + "Persist CODEFUNDI_API_KEY in MCP config if the user wants auth to survive restarts.", parameters: z.object({ email: z.string().email().describe("Email address used in the authenticate step"), token: z.string().min(6).max(6).describe("6-digit OTP verification code"), @@ -108,7 +110,9 @@ export function registerAuthTools(server: FastMCP): void { server.addTool({ name: "code-fundi-auth-resend", - description: "Resend the OTP verification email if the previous one expired or wasn't received.", + description: + "Resend the OTP email (POST /v2/auth/resend) when the previous code expired or was not received. " + + "Use after code-fundi-auth-authenticate, before code-fundi-auth-verify. Default type is signup for new accounts.", parameters: z.object({ email: z.string().email().describe("Email address to resend the code to"), type: z.enum(["signup", "email_change", "email"]).optional().describe("Resend type (default: signup)"), diff --git a/src/tools/intel.ts b/src/tools/intel.ts index e3e591f..a1ab3cf 100644 --- a/src/tools/intel.ts +++ b/src/tools/intel.ts @@ -1,56 +1,18 @@ /** * Code-Fundi MCP — Repository Intelligence Tools * - * Advanced V2 repository intelligence endpoints: - * - scope (POST /v2/repos/{repo_id}/scope) dependencies / functions / variables - * - map (GET /v2/repos/{repo_id}/map) cross-repository dependency map - * - blueprint (GET /v2/repos/{repo_id}/blueprint) README + dependency overview - * - radius (POST /v2/repos/{repo_id}/radius) blast radius / file impact analysis - * - review (GET /v2/repos/{repo_id}/review) code review signals (PRO+) - * - test-gaps (GET /v2/repos/{repo_id}/test-gaps) test coverage gap analysis (PRO+) - * - * These endpoints consume credits and several require PRO tier or higher. + * V2 repository intelligence: dependency map, blueprint, blast radius. */ import type { FastMCP } from "fastmcp"; import { z } from "zod"; import { getClient } from "../client.js"; import { - formatRepoScope, formatRepoMap, formatRepoBlueprint, - formatRepoRadius, formatRepoReview, formatRepoTestGaps, formatError, + formatRepoMap, formatRepoBlueprint, + formatRepoRadius, formatError, } from "../formatters.js"; export function registerRepoIntelTools(server: FastMCP): void { - server.addTool({ - name: "code-fundi-repo-scope", - description: - "Get aggregated dependencies, functions, and/or variable names for a repository in a single call. " + - "Use `include` to restrict kinds (default: all three). Provide `name` (plus `expand`) to drill into a " + - "single dependency's files/functions, or `compare_repos` (up to 25 UUIDs) to compare dependencies across repos. " + - "Consumes credits; functions/variables may be tier-gated.", - parameters: z.object({ - repo_key: z.string().describe("Repository UUID or clone URL"), - include: z.array(z.enum(["dependencies", "functions", "variables"])).optional().describe("Kinds to return (default: all three)"), - name: z.string().optional().describe("Exact name filter (dependency key or symbol name)"), - type: z.string().optional().describe("Type/role filter (dependency type, function type, or variable role)"), - expand: z.array(z.enum(["files", "functions"])).optional().describe("Drill-down expansions; requires `name`"), - compare_repos: z.array(z.string()).optional().describe("Up to 25 repository UUIDs for dependency comparison"), - limit: z.number().int().min(1).max(200).optional().describe("Max items per kind (default 50)"), - offset: z.number().int().min(0).optional().describe("Pagination offset"), - order: z.enum(["usage", "name"]).optional().describe("Ordering (default: usage)"), - demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), - }), - annotations: { title: "Repository Scope", readOnlyHint: true, openWorldHint: true }, - execute: async (args) => { - try { - const client = getClient(); - const { repo_key, demo, ...body } = args; - const res = await client.getRepoScope(repo_key, body, demo); - return res.data ? formatRepoScope(res.data, res.pagination, res.meta?.gated_kinds) : "No scope data available."; - } catch (err) { return formatError(err); } - }, - }); - server.addTool({ name: "code-fundi-repo-map", description: @@ -128,56 +90,4 @@ export function registerRepoIntelTools(server: FastMCP): void { } catch (err) { return formatError(err); } }, }); - - server.addTool({ - name: "code-fundi-repo-review", - description: - "Get code review signals per file: verdict (pass/warn/block), risk score, complexity, duplication, " + - "estimated coverage, and technical debt. Requires PRO tier or higher and consumes credits.", - parameters: z.object({ - repo_key: z.string().describe("Repository UUID or clone URL"), - verdict: z.array(z.enum(["pass", "warn", "block"])).optional().describe("Filter by verdict(s)"), - order: z.enum(["risk", "complexity", "coverage", "debt"]).optional().describe("Sort order (default: risk)"), - limit: z.number().int().min(1).optional().describe("Max files (default 50)"), - offset: z.number().int().min(0).optional().describe("Pagination offset"), - demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), - }), - annotations: { title: "Repository Code Review", readOnlyHint: true, openWorldHint: true }, - execute: async (args) => { - try { - const client = getClient(); - const { repo_key, verdict, ...rest } = args; - const res = await client.getRepoReview(repo_key, { - ...rest, - verdict: verdict?.length ? verdict.join(",") : undefined, - }); - return res.data ? formatRepoReview(res.data) : "No review signals available."; - } catch (err) { return formatError(err); } - }, - }); - - server.addTool({ - name: "code-fundi-repo-test-gaps", - description: - "Analyze test coverage gaps: per-file risk score, estimated coverage, suggested test cases, mockable " + - "dependencies, and reasons code is hard to test. Requires PRO tier or higher and consumes credits.", - parameters: z.object({ - repo_key: z.string().describe("Repository UUID or clone URL"), - priority: z.enum(["high", "medium", "low"]).optional().describe("Filter by gap priority"), - min_risk: z.number().min(0).max(1).optional().describe("Minimum risk score (0-1)"), - untested_only: z.boolean().optional().describe("Only return untested files (default true)"), - limit: z.number().int().min(1).optional().describe("Max gaps (default 50)"), - offset: z.number().int().min(0).optional().describe("Pagination offset"), - demo: z.boolean().optional().describe("Run in demo mode (pre-signup, public data, IP-based credits)"), - }), - annotations: { title: "Repository Test Gaps", readOnlyHint: true, openWorldHint: true }, - execute: async (args) => { - try { - const client = getClient(); - const { repo_key, ...opts } = args; - const res = await client.getRepoTestGaps(repo_key, opts); - return res.data ? formatRepoTestGaps(res.data) : "No test gap data available."; - } catch (err) { return formatError(err); } - }, - }); } diff --git a/src/types.ts b/src/types.ts index fdf3e39..b122796 100644 --- a/src/types.ts +++ b/src/types.ts @@ -299,8 +299,7 @@ export interface PublicRepoListResponse extends PaginatedResponse[]; - /** Present only when `include=functions`; no control_flow fields. */ functions?: Record[]; } @@ -337,59 +334,6 @@ export interface ScopeSymbolItem { detail?: Record | null; } -// ---- Scope (`POST /v2/repos/{repo_id}/scope`) ---- - -export type RepoScopeKind = "dependencies" | "functions" | "variables"; -export type RepoScopeExpand = "files" | "functions"; -export type RepoScopeOrder = "usage" | "name"; - -export interface RepoScopeRequest { - include?: RepoScopeKind[] | string; - name?: string; - type?: string; - expand?: RepoScopeExpand[] | string; - compare_repos?: string[] | string; - limit?: number; - offset?: number; - order?: RepoScopeOrder; -} - -export interface RepoScopeSummary { - total_files?: number; - files_with_dependencies?: number; - dependency_count?: number; - function_count?: number; - variable_count?: number; - compared_repo_count?: number; -} - -export interface RepoScopeCollections { - dependencies?: DependencyItem[]; - functions?: ScopeSymbolItem[]; - variables?: ScopeSymbolItem[]; -} - -export interface RepoScopeData { - repo?: Record; - summary?: RepoScopeSummary; - scope?: RepoScopeCollections; -} - -export interface RepoScopePagination { - dependencies?: Pagination; - functions?: Pagination; - variables?: Pagination; -} - -export interface RepoScopeMeta extends Meta { - gated_kinds?: Array<"functions" | "variables">; -} - -export interface RepoScopeResponse extends BaseResponse { - pagination?: RepoScopePagination; - meta?: RepoScopeMeta; -} - // ---- Cross-repository dependency map (`GET /v2/repos/{repo_id}/map`) ---- export interface RepoMapOptions { @@ -496,74 +440,6 @@ export interface RepoRadiusData { export interface RepoRadiusResponse extends BaseResponse {} -// ---- Code review (`GET /v2/repos/{repo_id}/review`) ---- - -export type RepoReviewOrder = "risk" | "complexity" | "coverage" | "debt"; - -export interface ReviewFileMetrics { - cognitive_complexity?: number; - cyclomatic_complexity?: number; - duplication_percentage?: number; - duplicate_block_count?: number; - technical_debt_minutes?: number; - estimated_coverage?: number | null; - churn_rate?: string; -} - -export interface ReviewFile extends ReviewFileMetrics { - id?: string; - file_name?: string; - file_path?: string; - verdict?: string; - risk_score?: number; - triggered_rules?: Record[]; - admin_enrichment?: Record; -} - -export interface RepoReviewData { - summary?: Record; - files?: ReviewFile[]; -} - -export interface RepoReviewResponse extends BaseResponse { - pagination?: Pagination; -} - -// ---- Test gaps (`GET /v2/repos/{repo_id}/test-gaps`) ---- - -export type TestGapPriority = "high" | "medium" | "low"; - -export interface TestCaseProjection { - priority?: TestGapPriority; - description?: string; - target_function?: string; -} - -export interface TestGapProjection { - risk_score?: number; - estimated_coverage?: number; - churn_rate?: string; - security_risk_level?: string; - suggested_test_cases?: TestCaseProjection[]; - mockable_dependencies?: string[]; - hard_to_test_reasons?: string[]; -} - -export interface TestGapFile extends TestGapProjection { - id?: string; - file_name?: string; - file_path?: string; -} - -export interface RepoTestGapsData { - summary?: Record; - gaps?: TestGapFile[]; -} - -export interface RepoTestGapsResponse extends BaseResponse { - pagination?: Pagination; -} - // ============================================================================ // File Types // ============================================================================ diff --git a/tests/client.test.ts b/tests/client.test.ts index c2a4696..c73700a 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -114,10 +114,6 @@ describe("CodeFundiClient", () => { await expect(noKeyClient.getModelLimits()).rejects.toThrow(CodeFundiApiError); }); - it("getRepoScope should throw without API key (non-demo)", async () => { - await expect(noKeyClient.getRepoScope("repo-id")).rejects.toThrow(CodeFundiApiError); - }); - it("getRepoMap should throw without API key (non-demo)", async () => { await expect(noKeyClient.getRepoMap("repo-id")).rejects.toThrow(CodeFundiApiError); }); @@ -129,14 +125,6 @@ describe("CodeFundiClient", () => { it("getRepoRadius should throw without API key (non-demo)", async () => { await expect(noKeyClient.getRepoRadius("repo-id", { file: "src/index.ts" })).rejects.toThrow(CodeFundiApiError); }); - - it("getRepoReview should throw without API key (non-demo)", async () => { - await expect(noKeyClient.getRepoReview("repo-id")).rejects.toThrow(CodeFundiApiError); - }); - - it("getRepoTestGaps should throw without API key (non-demo)", async () => { - await expect(noKeyClient.getRepoTestGaps("repo-id")).rejects.toThrow(CodeFundiApiError); - }); }); // ==== V2 repository intelligence & demo mode ==== @@ -179,78 +167,6 @@ describe("CodeFundiClient", () => { vi.unstubAllGlobals(); }); - it("getRepoScope should POST to the scope endpoint with a JSON body", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ status: "success", data: { scope: {} } }), - }); - vi.stubGlobal("fetch", mockFetch); - - await client.getRepoScope("repo-id", { include: ["dependencies"], name: "express" }); - - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe("https://api.test.codefundi.app/v2/repos/repo-id/scope"); - expect(options.method).toBe("POST"); - const body = JSON.parse(options.body as string); - expect(body.include).toEqual(["dependencies"]); - expect(body.name).toBe("express"); - - vi.unstubAllGlobals(); - }); - - it("getRepoScope should run in demo mode without an API key", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ status: "success", data: { scope: {} } }), - }); - vi.stubGlobal("fetch", mockFetch); - - const noKeyClient = new CodeFundiClient("https://api.test.codefundi.app"); - await noKeyClient.getRepoScope("repo-id", {}, true); - - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toContain("/v2/repos/repo-id/scope"); - expect(url).toContain("demo=true"); - expect(options.method).toBe("POST"); - - vi.unstubAllGlobals(); - }); - - it("getRepoReview should build query parameters", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ status: "success", data: { files: [] } }), - }); - vi.stubGlobal("fetch", mockFetch); - - await client.getRepoReview("repo-id", { verdict: "warn,block", order: "risk", limit: 10 }); - - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toContain("/v2/repos/repo-id/review"); - expect(url).toContain("verdict=warn%2Cblock"); - expect(url).toContain("order=risk"); - expect(url).toContain("limit=10"); - expect(options.method).toBe("GET"); - - vi.unstubAllGlobals(); - }); - - it("getRepoTestGaps should include min_risk=0 and untested_only=false", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ status: "success", data: { gaps: [] } }), - }); - vi.stubGlobal("fetch", mockFetch); - - await client.getRepoTestGaps("repo-id", { min_risk: 0, untested_only: false }); - - const [url] = mockFetch.mock.calls[0]; - expect(url).toContain("min_risk=0"); - expect(url).toContain("untested_only=false"); - - vi.unstubAllGlobals(); - }); - it("getModels should GET /v2/models and normalize data.models", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/tests/formatters.test.ts b/tests/formatters.test.ts index 87caf86..386aa8e 100644 --- a/tests/formatters.test.ts +++ b/tests/formatters.test.ts @@ -10,16 +10,15 @@ import { formatHistoryDetail, formatConversation, formatUsageStats, formatActivityStats, formatLanguageStats, formatApiKeys, formatModels, formatModelLimits, formatError, - formatPublicRepoList, formatRepoScope, formatRepoMap, - formatRepoBlueprint, formatRepoRadius, formatRepoReview, - formatRepoTestGaps, + formatPublicRepoList, formatRepoMap, + formatRepoBlueprint, formatRepoRadius, } from "../src/formatters.js"; import { CodeFundiApiError } from "../src/client.js"; import type { SearchResult, Repository, FileListItem, HistoryItem, FileDocumentationData, ReadmeData, RepositoryIndexInitRepo, - PublicRepoListItem, RepoScopeData, RepoMapData, RepoBlueprintData, - RepoRadiusData, RepoReviewData, RepoTestGapsData, ModelLimitsData, + PublicRepoListItem, RepoMapData, RepoBlueprintData, + RepoRadiusData, ModelLimitsData, } from "../src/types.js"; // ==== Search ==== @@ -274,33 +273,6 @@ describe("formatPublicRepoList", () => { // ==== Repository Intelligence ==== -describe("formatRepoScope", () => { - it("should format dependencies, functions, variables and pagination hints", () => { - const data: RepoScopeData = { - repo: { name: "my-repo" }, - summary: { total_files: 100, dependency_count: 3, function_count: 2, variable_count: 1 }, - scope: { - dependencies: [{ name: "express", version: "4.18.0", type: "package", usage: { file_count: 10, percentage: 25 } }], - functions: [{ name: "handler", type: "function", usage: { file_count: 4 } }], - variables: [{ name: "PORT", type: "env" }], - }, - }; - const result = formatRepoScope(data, { dependencies: { total: 20, limit: 1, offset: 0, has_more: true } }, ["variables"]); - expect(result).toContain("my-repo"); - expect(result).toContain("**express**@4.18.0"); - expect(result).toContain("used in 10 file(s)"); - expect(result).toContain("handler"); - expect(result).toContain("PORT"); - expect(result).toContain("Gated"); - expect(result).toContain("More available"); - }); - - it("should handle empty scope", () => { - const result = formatRepoScope({ scope: {} }); - expect(result).toContain("No scope data found"); - }); -}); - describe("formatRepoMap", () => { it("should format map dependencies", () => { const data: RepoMapData = { @@ -368,55 +340,6 @@ describe("formatRepoRadius", () => { }); }); -describe("formatRepoReview", () => { - it("should format the file metrics table", () => { - const data: RepoReviewData = { - summary: { blockers: 1 }, - files: [{ - id: "rf1", file_path: "src/app.ts", verdict: "block", risk_score: 0.9, - cyclomatic_complexity: 22, estimated_coverage: null, technical_debt_minutes: 120, - }], - }; - const result = formatRepoReview(data); - expect(result).toContain("Code Review Signals"); - expect(result).toContain("src/app.ts"); - expect(result).toContain("block"); - expect(result).toContain("N/A"); - expect(result).toContain("120"); - }); - - it("should handle no files", () => { - const result = formatRepoReview({ files: [] }); - expect(result).toContain("No review signals found"); - }); -}); - -describe("formatRepoTestGaps", () => { - it("should format gaps with suggested test cases", () => { - const data: RepoTestGapsData = { - gaps: [{ - id: "g1", file_path: "src/auth.ts", risk_score: 0.8, estimated_coverage: 0.2, - security_risk_level: "high", - suggested_test_cases: [{ priority: "high", description: "Test invalid token", target_function: "verify" }], - mockable_dependencies: ["jwt"], - hard_to_test_reasons: ["network calls"], - }], - }; - const result = formatRepoTestGaps(data); - expect(result).toContain("Test Coverage Gaps"); - expect(result).toContain("src/auth.ts"); - expect(result).toContain("Test invalid token"); - expect(result).toContain("verify"); - expect(result).toContain("jwt"); - expect(result).toContain("network calls"); - }); - - it("should handle no gaps", () => { - const result = formatRepoTestGaps({ gaps: [] }); - expect(result).toContain("No test gaps found"); - }); -}); - // ==== Errors ==== describe("formatError", () => { @@ -425,6 +348,8 @@ describe("formatError", () => { const result = formatError(err); expect(result).toContain("401"); expect(result).toContain("code-fundi-auth-authenticate"); + expect(result).toContain("CODEFUNDI_API_KEY"); + expect(result).toContain("persist"); }); it("should format rate limit error with retry info", () => {