Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,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 (23 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), chat v2, repositories (list, index, status, readme), files, history, statistics, API keys, and authentication. Also includes legacy Fundi chat + model catalog (`POST /v1/fundi/chat`, `GET /v1/fundi/models`) for backward compatibility.

### Search

Expand Down Expand Up @@ -197,6 +197,7 @@ Covers the Code-Fundi **V2** API: search (including search-with-chat / research)

| Tool | Description |
|------|-------------|
| `code-fundi-chat-v2` | Primary v2 chat endpoint (`POST /v2/chat`) with normalized JSON/NDJSON responses |
| `code-fundi-chat` | Fundi AI chat (`POST /v1/fundi/chat`; streamed responses are collected to text) |
| `code-fundi-list-models` | List available AI models |

Expand Down
200 changes: 192 additions & 8 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import type {
V2AuthVerifyResponse, V2AuthResendResponse, V2AuthMode,
ChatRequest, ChatResponse, ModelsResponse, SearchFieldsParam,
SortOrder, StatsRange, RepoScope, HistoryCategory, ErrorResponse,
V2AuthClientRequestOptions, V2ChatRequest, V2ChatResult,
} from "./types.js";

export const CODEFUNDI_DEFAULT_CHAT_MODEL_ID = "openai/gpt-oss-120b:free" as const;

// ============================================================================
// Error
// ============================================================================
Expand Down Expand Up @@ -146,6 +149,16 @@ export class CodeFundiClient {
return Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined && v !== null));
}

private mergeV2AuthClientHeaders(
base: Record<string, string>,
options?: V2AuthClientRequestOptions,
): Record<string, string> {
const out = { ...base };
if (options?.idempotencyKey) out["Idempotency-Key"] = options.idempotencyKey;
if (options?.fingerprint) out["X-Fingerprint"] = options.fingerprint;
return out;
}

private async postUnauth<T>(endpoint: string, body: unknown, extra?: Record<string, string>): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const res = await fetch(url, {
Expand All @@ -164,6 +177,64 @@ export class CodeFundiClient {
return json as T;
}

private extractStringCandidate(...candidates: unknown[]): string | undefined {
for (const value of candidates) {
if (typeof value === "string" && value.trim()) {
return value;
}
}
return undefined;
}

private normalizeV2ChatJson(payload: unknown): V2ChatResult {
if (!payload || typeof payload !== "object") {
return { text: "", raw: payload };
}

const root = payload as Record<string, unknown>;
const data = (root.data && typeof root.data === "object")
? root.data as Record<string, unknown>
: undefined;

const text = this.extractStringCandidate(
root.response, root.text, root.message, root.content,
data?.response, data?.text, data?.message, data?.content,
) ?? "";

const model = this.extractStringCandidate(root.model, data?.model);
const conversationId = this.extractStringCandidate(
root.conversation_id,
root.conversationId,
data?.conversation_id,
data?.conversationId,
);

const searchResultsRaw =
Array.isArray(root.search_results) ? root.search_results
: Array.isArray(root.results) ? root.results
: Array.isArray(data?.search_results) ? data.search_results
: Array.isArray(data?.results) ? data.results
: undefined;

const searchResults = Array.isArray(searchResultsRaw)
? searchResultsRaw as SearchResult[]
: undefined;

const contextFilesRaw =
typeof root.context_files === "number" ? root.context_files
: typeof data?.context_files === "number" ? data.context_files
: undefined;

return {
text,
model,
conversationId,
contextFiles: contextFilesRaw,
searchResults,
raw: payload,
};
}

// ---- NDJSON ----

async collectNdjsonStream(stream: ReadableStream<Uint8Array>): Promise<ResearchResult> {
Expand Down Expand Up @@ -202,6 +273,19 @@ export class CodeFundiClient {
return { text, searchResults, searchMeta, model, contextFiles };
}

async collectV2ChatNdjsonStream(stream: ReadableStream<Uint8Array>): Promise<V2ChatResult> {
const research = await this.collectNdjsonStream(stream);
return {
text: research.text,
model: research.model,
contextFiles: research.contextFiles,
searchResults: research.searchResults,
raw: {
searchMeta: research.searchMeta,
},
};
}

// ==== V2 Search ====

async search(req: SearchRequest): Promise<SearchResponse> {
Expand Down Expand Up @@ -308,20 +392,120 @@ export class CodeFundiClient {

// ==== V2 Auth (unauthenticated) ====

async authAuthenticate(p: { email: string; auth_mode: V2AuthMode; should_create_user?: boolean; authPassword?: string }): Promise<V2AuthAuthenticateResponse> {
async authAuthenticate(
p: {
email: string;
auth_mode?: V2AuthMode;
mode?: V2AuthMode;
should_create_user?: boolean;
authPassword?: string;
data?: Record<string, unknown>;
},
request?: V2AuthClientRequestOptions,
): Promise<V2AuthAuthenticateResponse> {
const authMode = p.auth_mode ?? p.mode;
if (!authMode) {
throw new TypeError("authAuthenticate requires auth_mode or mode");
}
const passHeader =
request?.passwordHeader === "x-auth-password"
? "X-Auth-Password"
: "X-CodeFundi-Auth-Password";
const h: Record<string, string> = {};
if (p.auth_mode === "password" && p.authPassword) h["X-CodeFundi-Auth-Password"] = p.authPassword;
if (authMode === "password" && p.authPassword) h[passHeader] = p.authPassword;
return this.postUnauth<V2AuthAuthenticateResponse>("/v2/auth/authenticate", {
auth_mode: p.auth_mode, email: p.email, should_create_user: p.should_create_user ?? false,
}, h);
auth_mode: authMode,
email: p.email,
should_create_user: p.should_create_user ?? false,
...(p.data ? { data: p.data } : {}),
}, this.mergeV2AuthClientHeaders(h, request));
}

async authVerify(
p: { email: string; token: string },
request?: V2AuthClientRequestOptions,
): Promise<V2AuthVerifyResponse> {
return this.postUnauth<V2AuthVerifyResponse>(
"/v2/auth/verify",
p,
this.mergeV2AuthClientHeaders({}, request),
);
}

async authVerify(p: { email: string; token: string }): Promise<V2AuthVerifyResponse> {
return this.postUnauth<V2AuthVerifyResponse>("/v2/auth/verify", p);
async authResend(
p: { email: string; type?: "signup" | "email_change" | "email" },
request?: V2AuthClientRequestOptions,
): Promise<V2AuthResendResponse> {
return this.postUnauth<V2AuthResendResponse>(
"/v2/auth/resend",
{ email: p.email, type: p.type ?? "signup" },
this.mergeV2AuthClientHeaders({}, request),
);
}

async authResend(p: { email: string; type?: "signup" | "email_change" | "email" }): Promise<V2AuthResendResponse> {
return this.postUnauth<V2AuthResendResponse>("/v2/auth/resend", { email: p.email, type: p.type ?? "signup" });
// ==== V2 Chat ====

async chatV2(
req: V2ChatRequest,
options?: { fallbackToV1?: boolean },
): Promise<V2ChatResult> {
this.requireApiKey();
const body: Record<string, unknown> = {
prompt: req.prompt,
model: req.model ?? CODEFUNDI_DEFAULT_CHAT_MODEL_ID,
conversation_id: req.conversation_id,
repo_ids: req.repo_ids,
repo_urls: req.repo_urls,
context: req.context,
stream: req.stream,
...(req.extra || {}),
};

const normalizedBody = Object.fromEntries(
Object.entries(body).filter(([, value]) => value !== undefined && value !== null),
);

try {
const res = await fetch(`${this.baseUrl}/v2/chat`, {
method: "POST",
headers: this.authHeaders(),
body: JSON.stringify(normalizedBody),
});
if (!res.ok) {
const { msg, code, retryAfter } = await this.readHttpError(res);
throw new CodeFundiApiError(msg, res.status, { code, retryAfter });
}

const ct = (res.headers.get("content-type") || "").toLowerCase();
if (ct.includes("ndjson")) {
if (!res.body) return { text: "" };
return this.collectV2ChatNdjsonStream(res.body);
}
if (ct.includes("application/json")) {
const payload = await res.json();
return this.normalizeV2ChatJson(payload);
}
const text = await res.text();
return { text, raw: text };
} catch (err) {
const canFallback =
options?.fallbackToV1 !== false &&
err instanceof CodeFundiApiError &&
[404, 405, 501].includes(err.statusCode);
if (!canFallback) throw err;

const legacy = await this.chat({
prompt: req.prompt,
model: req.model,
conversation: req.conversation_id,
context: req.context,
});
return {
text: legacy.response || "",
model: legacy.model,
raw: legacy,
};
}
}

// ==== V1 Chat & Models (OpenAPI: AI Chat; server body uses `question`, not `prompt`) ====
Expand Down
17 changes: 17 additions & 0 deletions src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
UsageByType, ActivityByDay, LanguageStat,
ApiKey, AIModel, ReadmeData, RepoStatusData,
RepositoryIndexInitRepo, Pagination, TierName,
V2ChatResult,
} from "./types.js";
import { CodeFundiApiError } from "./client.js";

Expand Down Expand Up @@ -75,6 +76,22 @@ export function formatResearchResult(result: ResearchResult): string {
return parts.join("\n");
}

export function formatV2ChatResult(result: V2ChatResult): string {
const parts: string[] = [];
if (result.text.trim()) {
parts.push(result.text.trim());
} else {
parts.push("_No response generated._");
}
if (result.model) parts.push(`\n_Model: ${result.model}_`);
if (result.conversationId) parts.push(`_Conversation: ${result.conversationId}_`);
if (result.contextFiles !== undefined) parts.push(`_Context files: ${result.contextFiles}_`);
if (result.searchResults?.length) {
parts.push(`\n_Context sources: ${result.searchResults.length} file(s)._`);
}
return parts.join("\n");
}

// ============================================================================
// Repos
// ============================================================================
Expand Down
21 changes: 19 additions & 2 deletions src/tools/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export function registerAuthTools(server: FastMCP): void {
auth_mode: z.enum(["otp", "password"]).describe("Authentication mode: 'otp' for email code, 'password' for password-based"),
should_create_user: z.boolean().optional().describe("Set to true for new user registration (default: false)"),
password: z.string().optional().describe("Password (only used when auth_mode is 'password')"),
idempotency_key: z.string().optional().describe("Optional Idempotency-Key header value"),
fingerprint: z.string().optional().describe("Optional X-Fingerprint header for rate-limit identity pairing"),
password_header: z.enum(["x-codefundi-auth-password", "x-auth-password"]).optional().describe(
"Header to carry password when auth_mode=password (default: x-codefundi-auth-password)",
),
}),
annotations: { title: "Authenticate", readOnlyHint: false, openWorldHint: true },
execute: async (args) => {
Expand All @@ -35,6 +40,10 @@ export function registerAuthTools(server: FastMCP): void {
auth_mode: args.auth_mode,
should_create_user: args.should_create_user,
authPassword: args.password,
}, {
idempotencyKey: args.idempotency_key,
fingerprint: args.fingerprint,
passwordHeader: args.password_header,
});

const d = res.data;
Expand Down Expand Up @@ -76,12 +85,16 @@ export function registerAuthTools(server: FastMCP): void {
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"),
fingerprint: z.string().optional().describe("Optional X-Fingerprint header for rate-limit identity pairing"),
}),
annotations: { title: "Verify OTP", readOnlyHint: false },
execute: async (args) => {
try {
const client = getClient();
const res = await client.authVerify({ email: args.email, token: args.token });
const res = await client.authVerify(
{ email: args.email, token: args.token },
{ fingerprint: args.fingerprint },
);
const d = res.data;

const parts: string[] = [];
Expand Down Expand Up @@ -112,12 +125,16 @@ export function registerAuthTools(server: FastMCP): void {
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)"),
fingerprint: z.string().optional().describe("Optional X-Fingerprint header for rate-limit identity pairing"),
}),
annotations: { title: "Resend OTP", readOnlyHint: false },
execute: async (args) => {
try {
const client = getClient();
await client.authResend({ email: args.email, type: args.type });
await client.authResend(
{ email: args.email, type: args.type },
{ fingerprint: args.fingerprint },
);
return `📧 Verification code resent to **${args.email}**. Use \`code-fundi-auth-verify\` with the new code.`;
} catch (err) { return formatError(err); }
},
Expand Down
Loading
Loading