embarsy-qdrant-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # embarsy-qdrant-mcp
2
+
3
+ Codebase-indexing MCP server for **[Embarsy](https://github.com/arsenyganov-pixel/embarsy-embedder)** — semantic code search for Claude Code and Codex, backed by a local Qdrant + OpenAI-compatible embeddings stack.
4
+
5
+ It indexes a directory of source into Qdrant and exposes a `search_code` MCP tool. Everything runs through Embarsy's proxy on `localhost:8000`, so the API keys are honored and both the embedding **and** Qdrant counters move in Embarsy → Monitoring. Pure JS — no native build.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g embarsy-qdrant-mcp
11
+ ```
12
+
13
+ Provides two commands: `embarsy-index` (index a codebase) and `embarsy-mcp` (the MCP server + config helpers).
14
+
15
+ ## Configuration
16
+
17
+ Set via environment variables. Only the two API keys are required — everything else defaults to Embarsy.
18
+
19
+ | Variable | Default | Notes |
20
+ |---|---|---|
21
+ | `OPENAI_API_KEY` | — | **required** — Embarsy → Status → API Key |
22
+ | `QDRANT_API_KEY` | — | **required** — Embarsy → Status → Qdrant API Key |
23
+ | `QDRANT_COLLECTION_NAME` | `embarsy-<dir>` | collection name (or `--collection`) |
24
+ | `OPENAI_BASE_URL` | `http://localhost:8000/v1` | OpenAI-compatible embeddings endpoint |
25
+ | `EMBEDDING_MODEL` | `qwen3-embedding` | |
26
+ | `EMBEDDING_DIMENSION` | `1024` | must match the model |
27
+ | `QDRANT_URL` | `http://localhost:8000/qdrant` | Qdrant REST (Embarsy proxy) |
28
+
29
+ ## Index a codebase
30
+
31
+ ```bash
32
+ OPENAI_API_KEY=<API Key> QDRANT_API_KEY=<Qdrant API Key> \
33
+ embarsy-index ~/projects/my-app --collection my-app
34
+ ```
35
+
36
+ Re-run any time — it's incremental (unchanged files are skipped, deleted files are pruned), respects `.gitignore`, and skips vendored/binary/oversized files.
37
+
38
+ ## Wire it into your editor
39
+
40
+ The setup helpers write the config with **absolute** `node` + script paths, so the desktop apps (which don't inherit your shell `PATH`) can always launch the server.
41
+
42
+ **Codex** — appends to `~/.codex/config.toml`:
43
+
44
+ ```bash
45
+ OPENAI_API_KEY=<API Key> QDRANT_API_KEY=<Qdrant API Key> \
46
+ QDRANT_COLLECTION_NAME=my-app embarsy-mcp --setup-codex
47
+ ```
48
+
49
+ Restart Codex, run `/mcp` → `embarsy-qdrant` appears.
50
+
51
+ **Claude Code** — run in your project folder (writes `.mcp.json`):
52
+
53
+ ```bash
54
+ OPENAI_API_KEY=<API Key> QDRANT_API_KEY=<Qdrant API Key> \
55
+ QDRANT_COLLECTION_NAME=my-app embarsy-mcp --setup-claude
56
+ ```
57
+
58
+ ## MCP tools
59
+
60
+ - **`search_code`** — semantic search; returns the most relevant chunks with `file:line` and a snippet.
61
+ - **`index_status`** — how many chunks are indexed in the collection.
62
+
63
+ ## License
64
+
65
+ PolyForm Strict 1.0.0 — see the Embarsy repository.
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { loadConfig, requireConfig } from "../config.js";
4
+ import { indexRepo } from "../indexer.js";
5
+ function usage() {
6
+ process.stdout.write(`embarsy-index — index a codebase into Embarsy's Qdrant for semantic search.
7
+
8
+ Usage:
9
+ embarsy-index <path> [--collection <name>]
10
+
11
+ Options:
12
+ --collection <name> Qdrant collection (default: $QDRANT_COLLECTION_NAME, else embarsy-<dir>).
13
+ -h, --help Show this help.
14
+
15
+ Configuration (env, with Embarsy defaults):
16
+ OPENAI_BASE_URL default http://localhost:8000/v1
17
+ OPENAI_API_KEY Embarsy → Status → API Key (required)
18
+ EMBEDDING_MODEL default qwen3-embedding
19
+ EMBEDDING_DIMENSION default 1024
20
+ QDRANT_URL default http://localhost:8000/qdrant
21
+ QDRANT_API_KEY Embarsy → Status → Qdrant API Key (required)
22
+ `);
23
+ }
24
+ function sanitize(name) {
25
+ return name.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "project";
26
+ }
27
+ async function main() {
28
+ const argv = process.argv.slice(2);
29
+ let target;
30
+ let collection;
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === "-h" || a === "--help") {
34
+ usage();
35
+ return;
36
+ }
37
+ if (a === "--collection") {
38
+ collection = argv[++i];
39
+ continue;
40
+ }
41
+ if (a.startsWith("--collection=")) {
42
+ collection = a.slice("--collection=".length);
43
+ continue;
44
+ }
45
+ if (!a.startsWith("-") && target === undefined) {
46
+ target = a;
47
+ continue;
48
+ }
49
+ }
50
+ if (!target) {
51
+ usage();
52
+ process.exitCode = 2;
53
+ return;
54
+ }
55
+ const rootAbs = path.resolve(target);
56
+ const finalCollection = collection || process.env.QDRANT_COLLECTION_NAME || `embarsy-${sanitize(path.basename(rootAbs))}`;
57
+ const cfg = loadConfig({ collection: finalCollection });
58
+ requireConfig(cfg, { needCollection: true });
59
+ process.stdout.write(`Indexing ${rootAbs}\n collection: ${cfg.collection}\n embeddings: ${cfg.openaiBaseUrl} (${cfg.embeddingModel}, dim ${cfg.embeddingDimension})\n qdrant: ${cfg.qdrantUrl}\n\n`);
60
+ const started = Date.now();
61
+ const result = await indexRepo(rootAbs, cfg, { onProgress: (m) => process.stdout.write(m + "\n") });
62
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
63
+ process.stdout.write(`\nDone in ${secs}s — ${result.indexed} indexed, ${result.skipped} unchanged, ${result.removed} removed; ${result.chunks} chunks upserted into "${cfg.collection}".\n`);
64
+ }
65
+ main().catch((err) => {
66
+ process.stderr.write(`\nembarsy-index failed: ${err instanceof Error ? err.message : String(err)}\n`);
67
+ process.exitCode = 1;
68
+ });
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { loadConfig, requireConfig } from "../config.js";
3
+ import { runMcpServer } from "../mcp-server.js";
4
+ import { setupCodex, setupClaude } from "../setup.js";
5
+ // NOTE: stdout is the MCP transport — never write logs there. Errors go to stderr.
6
+ async function main() {
7
+ const args = process.argv.slice(2);
8
+ if (args.includes("-h") || args.includes("--help")) {
9
+ process.stdout.write(`embarsy-mcp — MCP server exposing semantic code search over Embarsy's Qdrant.
10
+
11
+ Usage:
12
+ embarsy-mcp Run the MCP stdio server (this is what editors spawn).
13
+ embarsy-mcp --setup-codex Write the server into ~/.codex/config.toml (absolute paths).
14
+ embarsy-mcp --setup-claude Write the server into ./.mcp.json for Claude Code (run in your project).
15
+
16
+ Both setup commands read the current OPENAI_API_KEY / QDRANT_API_KEY / QDRANT_COLLECTION_NAME
17
+ (and the OPENAI_BASE_URL / QDRANT_URL defaults) and bake them into the editor config.
18
+ `);
19
+ return;
20
+ }
21
+ // Setup helpers write the editor config with absolute node + script paths, so the desktop
22
+ // apps (which don't inherit your shell PATH) can always launch the server.
23
+ const doCodex = args.includes("--setup-codex");
24
+ const doClaude = args.includes("--setup-claude");
25
+ if (doCodex || doClaude) {
26
+ const cfg = loadConfig();
27
+ requireConfig(cfg, { needCollection: true });
28
+ if (doCodex) {
29
+ const file = await setupCodex(cfg);
30
+ process.stdout.write(`✓ Added [mcp_servers.embarsy-qdrant] to ${file}\n Restart Codex, then run /mcp — "embarsy-qdrant" should appear.\n`);
31
+ }
32
+ if (doClaude) {
33
+ const file = await setupClaude(cfg);
34
+ process.stdout.write(`✓ Wrote embarsy-qdrant server to ${file}\n Open this project in Claude Code and approve the MCP server when prompted.\n`);
35
+ }
36
+ return;
37
+ }
38
+ const cfg = loadConfig();
39
+ requireConfig(cfg, { needCollection: true });
40
+ await runMcpServer(cfg);
41
+ }
42
+ main().catch((err) => {
43
+ process.stderr.write(`embarsy-mcp failed: ${err instanceof Error ? err.message : String(err)}\n`);
44
+ process.exit(1);
45
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Line-oriented chunking with a character budget and overlap. Chunks prefer to start at a
3
+ * "definition-ish" line (function/class/etc.) when the current chunk is already sizeable, so
4
+ * chunks tend to align with logical code boundaries. Pure-JS, no native parser.
5
+ */
6
+ const BOUNDARY = /^\s*(export\s+)?(async\s+)?(function|class|struct|enum|interface|trait|impl|def|func|fn|type|module|namespace|public|private|protected|static)\b/;
7
+ export function chunkFile(content, cfg) {
8
+ const lines = content.split(/\r?\n/);
9
+ const chunks = [];
10
+ const maxChars = Math.max(400, cfg.chunkMaxChars);
11
+ const overlapChars = Math.max(0, Math.min(cfg.chunkOverlapChars, maxChars - 100));
12
+ let startIdx = 0; // 0-based line index where the current chunk starts
13
+ let curChars = 0;
14
+ let i = 0;
15
+ const flush = (endIdxExclusive) => {
16
+ if (endIdxExclusive <= startIdx)
17
+ return;
18
+ const slice = lines.slice(startIdx, endIdxExclusive);
19
+ const text = slice.join("\n").trim();
20
+ if (text.length > 0) {
21
+ chunks.push({ startLine: startIdx + 1, endLine: endIdxExclusive, text: slice.join("\n") });
22
+ }
23
+ };
24
+ while (i < lines.length) {
25
+ const line = lines[i] ?? "";
26
+ const lineChars = line.length + 1;
27
+ // Break BEFORE a definition line if the current chunk already has real content.
28
+ if (i > startIdx && curChars >= maxChars * 0.5 && BOUNDARY.test(line)) {
29
+ flush(i);
30
+ startIdx = backfillOverlap(lines, i, overlapChars);
31
+ curChars = charsBetween(lines, startIdx, i);
32
+ }
33
+ curChars += lineChars;
34
+ i++;
35
+ if (curChars >= maxChars) {
36
+ flush(i);
37
+ startIdx = backfillOverlap(lines, i, overlapChars);
38
+ curChars = charsBetween(lines, startIdx, i);
39
+ }
40
+ }
41
+ flush(lines.length);
42
+ return chunks;
43
+ }
44
+ /** Walk back from `idx` to include ~overlapChars of trailing context; returns new start index. */
45
+ function backfillOverlap(lines, idx, overlapChars) {
46
+ if (overlapChars <= 0)
47
+ return idx;
48
+ let chars = 0;
49
+ let j = idx;
50
+ while (j > 0 && chars < overlapChars) {
51
+ j--;
52
+ chars += (lines[j]?.length ?? 0) + 1;
53
+ }
54
+ return j;
55
+ }
56
+ function charsBetween(lines, from, to) {
57
+ let c = 0;
58
+ for (let k = from; k < to; k++)
59
+ c += (lines[k]?.length ?? 0) + 1;
60
+ return c;
61
+ }
package/dist/config.js ADDED
@@ -0,0 +1,39 @@
1
+ function intEnv(value, fallback) {
2
+ if (value === undefined || value.trim() === "")
3
+ return fallback;
4
+ const n = Number.parseInt(value, 10);
5
+ return Number.isFinite(n) ? n : fallback;
6
+ }
7
+ const stripTrailingSlash = (s) => s.replace(/\/+$/, "");
8
+ export function loadConfig(overrides = {}) {
9
+ const env = process.env;
10
+ return {
11
+ openaiBaseUrl: stripTrailingSlash(env.OPENAI_BASE_URL ?? "http://localhost:8000/v1"),
12
+ openaiApiKey: env.OPENAI_API_KEY ?? "",
13
+ embeddingModel: env.EMBEDDING_MODEL ?? "qwen3-embedding",
14
+ embeddingDimension: intEnv(env.EMBEDDING_DIMENSION, 1024),
15
+ qdrantUrl: stripTrailingSlash(env.QDRANT_URL ?? "http://localhost:8000/qdrant"),
16
+ qdrantApiKey: env.QDRANT_API_KEY ?? "",
17
+ collection: env.QDRANT_COLLECTION_NAME ?? env.QDRANT_COLLECTION ?? "",
18
+ chunkMaxChars: intEnv(env.EMBARSY_CHUNK_CHARS, 1500),
19
+ chunkOverlapChars: intEnv(env.EMBARSY_CHUNK_OVERLAP, 200),
20
+ embedBatch: intEnv(env.EMBARSY_EMBED_BATCH, 64),
21
+ maxFileBytes: intEnv(env.EMBARSY_MAX_FILE_BYTES, 1_000_000),
22
+ ...overrides,
23
+ };
24
+ }
25
+ /** Throw a clear, actionable error if a required value is missing. */
26
+ export function requireConfig(cfg, opts) {
27
+ const missing = [];
28
+ if (!cfg.openaiApiKey)
29
+ missing.push("OPENAI_API_KEY (Embarsy → Status → API Key)");
30
+ if (!cfg.qdrantApiKey)
31
+ missing.push("QDRANT_API_KEY (Embarsy → Status → Qdrant API Key)");
32
+ if (opts.needCollection && !cfg.collection) {
33
+ missing.push("QDRANT_COLLECTION_NAME (or pass --collection)");
34
+ }
35
+ if (missing.length > 0) {
36
+ throw new Error("Missing required configuration:\n - " + missing.join("\n - ") +
37
+ "\n\nSet them as environment variables. Copy the API keys from Embarsy → Status.");
38
+ }
39
+ }
@@ -0,0 +1,36 @@
1
+ import { requestJSON } from "./http.js";
2
+ /**
3
+ * Embed a batch of texts through the OpenAI-COMPATIBLE endpoint (Embarsy's /v1 proxy).
4
+ * This is the piece other tools get wrong: we send a bearer API key and use the standard
5
+ * POST /v1/embeddings shape, so Embarsy authenticates the call and counts it in Monitoring.
6
+ */
7
+ export async function embedBatch(texts, cfg) {
8
+ if (texts.length === 0)
9
+ return [];
10
+ const json = await requestJSON(`${cfg.openaiBaseUrl}/embeddings`, {
11
+ method: "POST",
12
+ headers: {
13
+ "Content-Type": "application/json",
14
+ Authorization: `Bearer ${cfg.openaiApiKey}`,
15
+ },
16
+ body: JSON.stringify({ model: cfg.embeddingModel, input: texts }),
17
+ }, { label: "embeddings" });
18
+ const data = json?.data;
19
+ if (!Array.isArray(data) || data.length !== texts.length) {
20
+ throw new Error(`Unexpected embeddings response (got ${Array.isArray(data) ? data.length : "no"} vectors for ${texts.length} inputs).`);
21
+ }
22
+ // Respect the `index` field so ordering is guaranteed to match the input.
23
+ const ordered = [...data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
24
+ return ordered.map((d) => {
25
+ const v = d?.embedding;
26
+ if (!Array.isArray(v))
27
+ throw new Error("Embeddings response item missing `embedding` array.");
28
+ return v;
29
+ });
30
+ }
31
+ export async function embedOne(text, cfg) {
32
+ const [v] = await embedBatch([text], cfg);
33
+ if (!v)
34
+ throw new Error("No embedding returned.");
35
+ return v;
36
+ }
package/dist/http.js ADDED
@@ -0,0 +1,33 @@
1
+ /** Small fetch wrapper: retries transient failures and surfaces the response body in errors. */
2
+ export async function requestJSON(url, init, opts = {}) {
3
+ const retries = opts.retries ?? 2;
4
+ const label = opts.label ?? init.method ?? "request";
5
+ let lastErr;
6
+ for (let attempt = 0; attempt <= retries; attempt++) {
7
+ try {
8
+ const res = await fetch(url, init);
9
+ const text = await res.text();
10
+ if (!res.ok) {
11
+ // 4xx are not retryable (bad key / bad request) — fail fast with the server's message.
12
+ const detail = text.slice(0, 500);
13
+ const err = new Error(`${label} → HTTP ${res.status} at ${url}\n${detail}`);
14
+ if (res.status >= 400 && res.status < 500)
15
+ throw err;
16
+ lastErr = err;
17
+ }
18
+ else {
19
+ return text ? JSON.parse(text) : {};
20
+ }
21
+ }
22
+ catch (e) {
23
+ lastErr = e;
24
+ // Don't retry explicit 4xx errors we threw above.
25
+ if (e instanceof Error && /HTTP 4\d\d/.test(e.message))
26
+ throw e;
27
+ }
28
+ if (attempt < retries)
29
+ await sleep(300 * (attempt + 1));
30
+ }
31
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
32
+ }
33
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -0,0 +1,94 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { embedBatch } from "./embeddings.js";
4
+ import { chunkFile } from "./chunker.js";
5
+ import { discoverFiles } from "./walk.js";
6
+ import { ensureCollection, upsertPoints, deleteByFilePath, scrollFileHashes, } from "./qdrant.js";
7
+ import { sha256, pointId, languageForExtension } from "./util.js";
8
+ /** Crude binary guard: a NUL byte early in the file means it isn't source text. */
9
+ function looksBinary(s) {
10
+ const n = Math.min(s.length, 8192);
11
+ for (let i = 0; i < n; i++) {
12
+ if (s.charCodeAt(i) === 0)
13
+ return true;
14
+ }
15
+ return false;
16
+ }
17
+ /** Full/incremental index of a directory into the configured Qdrant collection. */
18
+ export async function indexRepo(root, cfg, opts = {}) {
19
+ const log = opts.onProgress ?? (() => { });
20
+ const rootAbs = path.resolve(root);
21
+ await ensureCollection(cfg);
22
+ const files = await discoverFiles(rootAbs, cfg);
23
+ log(`Found ${files.length} indexable files in ${rootAbs}`);
24
+ const existing = await scrollFileHashes(cfg);
25
+ const seen = new Set();
26
+ const result = { files: files.length, indexed: 0, skipped: 0, removed: 0, chunks: 0 };
27
+ // Pending chunk buffer, flushed in embedding batches across files.
28
+ let pending = [];
29
+ const flush = async () => {
30
+ if (pending.length === 0)
31
+ return;
32
+ const vectors = await embedBatch(pending.map((p) => p.text), cfg);
33
+ const points = pending.map((p, i) => ({
34
+ id: pointId(p.key),
35
+ vector: vectors[i],
36
+ payload: p.payload,
37
+ }));
38
+ await upsertPoints(cfg, points);
39
+ result.chunks += points.length;
40
+ pending = [];
41
+ };
42
+ for (const file of files) {
43
+ seen.add(file.rel);
44
+ let content;
45
+ try {
46
+ content = await fs.readFile(file.abs, "utf8");
47
+ }
48
+ catch {
49
+ continue;
50
+ }
51
+ if (looksBinary(content))
52
+ continue;
53
+ const hash = sha256(content);
54
+ if (existing.get(file.rel) === hash) {
55
+ result.skipped++;
56
+ continue;
57
+ }
58
+ // Changed or new: drop the file's old points, then re-chunk.
59
+ if (existing.has(file.rel))
60
+ await deleteByFilePath(cfg, file.rel);
61
+ const ext = path.extname(file.rel).replace(/^\./, "").toLowerCase();
62
+ const language = languageForExtension(ext);
63
+ const chunks = chunkFile(content, cfg);
64
+ chunks.forEach((c, idx) => {
65
+ pending.push({
66
+ key: `${file.rel}:${idx}`,
67
+ text: `${file.rel}\n\n${c.text}`, // path gives the embedder useful context
68
+ payload: {
69
+ file_path: file.rel,
70
+ language,
71
+ start_line: c.startLine,
72
+ end_line: c.endLine,
73
+ file_hash: hash,
74
+ chunk_index: idx,
75
+ text: c.text.length > 4000 ? c.text.slice(0, 4000) : c.text,
76
+ },
77
+ });
78
+ });
79
+ result.indexed++;
80
+ if (pending.length >= cfg.embedBatch)
81
+ await flush();
82
+ if (result.indexed % 25 === 0)
83
+ log(` indexed ${result.indexed} files, ${result.chunks + pending.length} chunks…`);
84
+ }
85
+ await flush();
86
+ // Prune files that no longer exist on disk.
87
+ for (const rel of existing.keys()) {
88
+ if (!seen.has(rel)) {
89
+ await deleteByFilePath(cfg, rel);
90
+ result.removed++;
91
+ }
92
+ }
93
+ return result;
94
+ }
@@ -0,0 +1,45 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { embedOne } from "./embeddings.js";
5
+ import { search, collectionInfo } from "./qdrant.js";
6
+ /** Build and run the Embarsy MCP server over stdio (for Claude Code / Codex). */
7
+ export async function runMcpServer(cfg) {
8
+ const server = new McpServer({ name: "embarsy-qdrant", version: "0.1.0" });
9
+ server.tool("search_code", "Semantic search over the indexed codebase. Returns the most relevant code chunks with " +
10
+ "their file path and line range. Use natural language (e.g. 'where is auth handled').", {
11
+ query: z.string().describe("Natural-language description of the code you are looking for."),
12
+ limit: z.number().int().min(1).max(50).optional().describe("Max results (default 8)."),
13
+ path_contains: z.string().optional().describe("Only return chunks whose file path contains this substring."),
14
+ }, async ({ query, limit, path_contains }) => {
15
+ const want = limit ?? 8;
16
+ const vector = await embedOne(query, cfg);
17
+ // Over-fetch and filter by path client-side (substring match works regardless of index type).
18
+ const raw = await search(cfg, vector, path_contains ? want * 6 : want);
19
+ const hits = (path_contains
20
+ ? raw.filter((h) => String(h.payload?.file_path ?? "").includes(path_contains))
21
+ : raw).slice(0, want);
22
+ if (hits.length === 0) {
23
+ return { content: [{ type: "text", text: "No matches. Has the codebase been indexed with `embarsy-index`?" }] };
24
+ }
25
+ const text = hits
26
+ .map((h, i) => {
27
+ const p = h.payload ?? {};
28
+ const loc = `${p.file_path}:${p.start_line}-${p.end_line}`;
29
+ const lang = p.language ? ` [${p.language}]` : "";
30
+ const snippet = String(p.text ?? "").trimEnd();
31
+ return `### ${i + 1}. ${loc}${lang} (score ${h.score.toFixed(3)})\n\n\`\`\`\n${snippet}\n\`\`\``;
32
+ })
33
+ .join("\n\n");
34
+ return { content: [{ type: "text", text }] };
35
+ });
36
+ server.tool("index_status", "Report how many chunks are indexed in the current collection.", {}, async () => {
37
+ const info = await collectionInfo(cfg);
38
+ const text = info
39
+ ? `Collection "${cfg.collection}": ${info.pointsCount} indexed chunks.`
40
+ : `Collection "${cfg.collection}" does not exist yet. Run: embarsy-index <path> --collection ${cfg.collection}`;
41
+ return { content: [{ type: "text", text }] };
42
+ });
43
+ const transport = new StdioServerTransport();
44
+ await server.connect(transport);
45
+ }
package/dist/qdrant.js ADDED
@@ -0,0 +1,86 @@
1
+ import { requestJSON } from "./http.js";
2
+ /** Qdrant authenticates with the `api-key` header; Embarsy's proxy validates the same header. */
3
+ function headers(cfg) {
4
+ return { "Content-Type": "application/json", "api-key": cfg.qdrantApiKey };
5
+ }
6
+ function base(cfg) {
7
+ return `${cfg.qdrantUrl}/collections/${encodeURIComponent(cfg.collection)}`;
8
+ }
9
+ /** Create the collection (Cosine, configured dimension) if it doesn't exist; add a file_path index. */
10
+ export async function ensureCollection(cfg) {
11
+ const res = await fetch(base(cfg), { headers: headers(cfg) });
12
+ if (res.status === 200) {
13
+ await res.text();
14
+ return;
15
+ }
16
+ const body = await res.text();
17
+ if (res.status !== 404) {
18
+ throw new Error(`Qdrant collection check failed → HTTP ${res.status}\n${body.slice(0, 300)}`);
19
+ }
20
+ await requestJSON(base(cfg), {
21
+ method: "PUT",
22
+ headers: headers(cfg),
23
+ body: JSON.stringify({ vectors: { size: cfg.embeddingDimension, distance: "Cosine" } }),
24
+ }, { label: "create collection", retries: 0 });
25
+ // Payload index on file_path enables fast delete-by-file for incremental reindex.
26
+ try {
27
+ await requestJSON(`${base(cfg)}/index`, {
28
+ method: "PUT",
29
+ headers: headers(cfg),
30
+ body: JSON.stringify({ field_name: "file_path", field_schema: "keyword" }),
31
+ }, { label: "create index", retries: 0 });
32
+ }
33
+ catch {
34
+ /* index is an optimization; ignore if the Qdrant build rejects it */
35
+ }
36
+ }
37
+ export async function upsertPoints(cfg, points) {
38
+ if (points.length === 0)
39
+ return;
40
+ await requestJSON(`${base(cfg)}/points?wait=true`, { method: "PUT", headers: headers(cfg), body: JSON.stringify({ points }) }, { label: "upsert" });
41
+ }
42
+ export async function search(cfg, vector, limit) {
43
+ const body = { vector, limit, with_payload: true };
44
+ const json = await requestJSON(`${base(cfg)}/points/search`, { method: "POST", headers: headers(cfg), body: JSON.stringify(body) }, { label: "search" });
45
+ return (json?.result ?? []);
46
+ }
47
+ export async function deleteByFilePath(cfg, filePath) {
48
+ await requestJSON(`${base(cfg)}/points/delete?wait=true`, {
49
+ method: "POST",
50
+ headers: headers(cfg),
51
+ body: JSON.stringify({ filter: { must: [{ key: "file_path", match: { value: filePath } }] } }),
52
+ }, { label: "delete points" });
53
+ }
54
+ /** Map of file_path -> file_hash for every indexed file, used to skip unchanged files. */
55
+ export async function scrollFileHashes(cfg) {
56
+ const out = new Map();
57
+ let offset = undefined;
58
+ for (let guard = 0; guard < 10000; guard++) {
59
+ const body = {
60
+ limit: 256,
61
+ with_payload: { include: ["file_path", "file_hash"] },
62
+ with_vector: false,
63
+ };
64
+ if (offset !== undefined && offset !== null)
65
+ body.offset = offset;
66
+ const json = await requestJSON(`${base(cfg)}/points/scroll`, { method: "POST", headers: headers(cfg), body: JSON.stringify(body) }, { label: "scroll" });
67
+ const points = json?.result?.points ?? [];
68
+ for (const p of points) {
69
+ const fp = p?.payload?.file_path;
70
+ const fh = p?.payload?.file_hash;
71
+ if (typeof fp === "string" && typeof fh === "string" && !out.has(fp))
72
+ out.set(fp, fh);
73
+ }
74
+ offset = json?.result?.next_page_offset ?? null;
75
+ if (!offset)
76
+ break;
77
+ }
78
+ return out;
79
+ }
80
+ export async function collectionInfo(cfg) {
81
+ const res = await fetch(base(cfg), { headers: headers(cfg) });
82
+ if (res.status === 404)
83
+ return null;
84
+ const json = JSON.parse(await res.text());
85
+ return { pointsCount: json?.result?.points_count ?? 0 };
86
+ }
package/dist/setup.js ADDED
@@ -0,0 +1,85 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /**
6
+ * The editor apps (Codex / Claude Code desktop) spawn MCP servers with a minimal PATH that
7
+ * does NOT include nvm/homebrew bin dirs — so a bare `embarsy-mcp` command fails to launch.
8
+ * We sidestep that entirely by writing ABSOLUTE paths: the current `node` binary + the
9
+ * absolute path to this package's compiled mcp.js.
10
+ */
11
+ function resolveSpawn() {
12
+ const mcpJs = fileURLToPath(new URL("./bin/mcp.js", import.meta.url));
13
+ return { command: process.execPath, args: [mcpJs] };
14
+ }
15
+ /** Env block written into the editor config so the server has everything it needs. */
16
+ function serverEnv(cfg) {
17
+ return {
18
+ OPENAI_BASE_URL: cfg.openaiBaseUrl,
19
+ OPENAI_API_KEY: cfg.openaiApiKey,
20
+ EMBEDDING_MODEL: cfg.embeddingModel,
21
+ EMBEDDING_DIMENSION: String(cfg.embeddingDimension),
22
+ QDRANT_URL: cfg.qdrantUrl,
23
+ QDRANT_API_KEY: cfg.qdrantApiKey,
24
+ QDRANT_COLLECTION_NAME: cfg.collection,
25
+ };
26
+ }
27
+ const SERVER_NAME = "embarsy-qdrant";
28
+ /** Append an `[mcp_servers.embarsy-qdrant]` block to ~/.codex/config.toml (respects $CODEX_HOME). */
29
+ export async function setupCodex(cfg) {
30
+ const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
31
+ await fs.mkdir(home, { recursive: true });
32
+ const file = path.join(home, "config.toml");
33
+ const existing = await readOrEmpty(file);
34
+ if (existing.includes(`[mcp_servers.${SERVER_NAME}]`)) {
35
+ throw new Error(`~/.codex/config.toml already has [mcp_servers.${SERVER_NAME}]. Remove it first to re-run setup.`);
36
+ }
37
+ const { command, args } = resolveSpawn();
38
+ const env = serverEnv(cfg);
39
+ const lines = [
40
+ "",
41
+ `[mcp_servers.${SERVER_NAME}]`,
42
+ `command = ${toml(command)}`,
43
+ `args = [${args.map(toml).join(", ")}]`,
44
+ "",
45
+ `[mcp_servers.${SERVER_NAME}.env]`,
46
+ ...Object.entries(env).map(([k, v]) => `${k} = ${toml(v)}`),
47
+ "",
48
+ ];
49
+ const next = (existing.trimEnd() + "\n" + lines.join("\n")).replace(/^\n+/, "");
50
+ await fs.writeFile(file, next, "utf8");
51
+ return file;
52
+ }
53
+ /** Merge an `embarsy-qdrant` server into ./.mcp.json (project-scoped Claude Code config). */
54
+ export async function setupClaude(cfg) {
55
+ const file = path.resolve(".mcp.json");
56
+ let json = {};
57
+ const existing = await readOrEmpty(file);
58
+ if (existing.trim()) {
59
+ try {
60
+ json = JSON.parse(existing);
61
+ }
62
+ catch {
63
+ throw new Error(`${file} is not valid JSON — fix or remove it first.`);
64
+ }
65
+ }
66
+ if (typeof json !== "object" || json === null)
67
+ json = {};
68
+ json.mcpServers = json.mcpServers ?? {};
69
+ const { command, args } = resolveSpawn();
70
+ json.mcpServers[SERVER_NAME] = { command, args, env: serverEnv(cfg) };
71
+ await fs.writeFile(file, JSON.stringify(json, null, 2) + "\n", "utf8");
72
+ return file;
73
+ }
74
+ async function readOrEmpty(file) {
75
+ try {
76
+ return await fs.readFile(file, "utf8");
77
+ }
78
+ catch {
79
+ return "";
80
+ }
81
+ }
82
+ /** Minimal TOML basic-string quoting (JSON escaping is a valid superset for our values). */
83
+ function toml(value) {
84
+ return JSON.stringify(value);
85
+ }
package/dist/util.js ADDED
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ export function sha256(input) {
3
+ return createHash("sha256").update(input, "utf8").digest("hex");
4
+ }
5
+ /**
6
+ * Deterministic Qdrant point ID (UUID string) from a stable key, so re-indexing a chunk
7
+ * overwrites its previous point instead of piling up duplicates.
8
+ */
9
+ export function pointId(key) {
10
+ const h = createHash("md5").update(key, "utf8").digest("hex"); // 32 hex chars
11
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
12
+ }
13
+ export function languageForExtension(ext) {
14
+ const map = {
15
+ ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
16
+ py: "Python", rb: "Ruby", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", swift: "Swift",
17
+ c: "C", h: "C", cc: "C++", cpp: "C++", hpp: "C++", cs: "C#", php: "PHP", scala: "Scala",
18
+ sh: "Shell", bash: "Shell", zsh: "Shell", sql: "SQL", md: "Markdown", mdx: "Markdown",
19
+ json: "JSON", yaml: "YAML", yml: "YAML", toml: "TOML", html: "HTML", css: "CSS", scss: "CSS",
20
+ vue: "Vue", svelte: "Svelte", lua: "Lua", ex: "Elixir", exs: "Elixir", dart: "Dart", r: "R",
21
+ };
22
+ return map[ext.toLowerCase()] ?? (ext ? ext.toUpperCase() : "Text");
23
+ }
package/dist/walk.js ADDED
@@ -0,0 +1,84 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import ignoreImport from "ignore";
4
+ function createIgnorer() {
5
+ const f = ignoreImport;
6
+ return typeof f === "function" ? f() : f.default();
7
+ }
8
+ /** Directories never worth indexing (in addition to whatever .gitignore says). */
9
+ const SKIP_DIRS = new Set([
10
+ ".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__", ".mypy_cache",
11
+ ".pytest_cache", "dist", "build", "out", "target", ".next", ".nuxt", ".svelte-kit",
12
+ ".turbo", ".cache", "coverage", ".idea", ".vscode", ".gradle", "Pods", ".terraform",
13
+ "vendor", "bin", "obj", ".DS_Store",
14
+ ]);
15
+ /** Extensions we treat as indexable source/text. */
16
+ const CODE_EXT = new Set([
17
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java", "kt", "kts",
18
+ "swift", "c", "h", "cc", "cpp", "hpp", "cs", "php", "scala", "sh", "bash", "zsh", "sql",
19
+ "md", "mdx", "json", "yaml", "yml", "toml", "html", "htm", "css", "scss", "sass", "less",
20
+ "vue", "svelte", "lua", "ex", "exs", "dart", "r", "pl", "pm", "proto", "graphql", "gql",
21
+ "tf", "gradle", "groovy", "m", "mm", "clj", "cljs", "edn", "elm", "erl", "hs", "ml", "nim",
22
+ "txt", "cfg", "ini", "env",
23
+ ]);
24
+ export async function discoverFiles(root, cfg) {
25
+ const rootAbs = path.resolve(root);
26
+ const ig = createIgnorer();
27
+ await loadGitignore(ig, rootAbs);
28
+ const out = [];
29
+ await walk(rootAbs, rootAbs, ig, cfg, out);
30
+ out.sort((a, b) => a.rel.localeCompare(b.rel));
31
+ return out;
32
+ }
33
+ async function loadGitignore(ig, rootAbs) {
34
+ try {
35
+ const text = await fs.readFile(path.join(rootAbs, ".gitignore"), "utf8");
36
+ ig.add(text);
37
+ }
38
+ catch {
39
+ /* no .gitignore — fine */
40
+ }
41
+ }
42
+ async function walk(dir, rootAbs, ig, cfg, out) {
43
+ let entries;
44
+ try {
45
+ entries = await fs.readdir(dir, { withFileTypes: true });
46
+ }
47
+ catch {
48
+ return;
49
+ }
50
+ for (const entry of entries) {
51
+ const abs = path.join(dir, entry.name);
52
+ const rel = toPosix(path.relative(rootAbs, abs));
53
+ if (!rel || rel.startsWith(".."))
54
+ continue;
55
+ if (entry.isSymbolicLink())
56
+ continue;
57
+ if (entry.isDirectory()) {
58
+ if (SKIP_DIRS.has(entry.name))
59
+ continue;
60
+ if (ig.ignores(rel + "/"))
61
+ continue;
62
+ await walk(abs, rootAbs, ig, cfg, out);
63
+ continue;
64
+ }
65
+ if (!entry.isFile())
66
+ continue;
67
+ if (ig.ignores(rel))
68
+ continue;
69
+ const ext = path.extname(entry.name).replace(/^\./, "").toLowerCase();
70
+ // Allow dotfiles like ".env" (no extension but a known name) — otherwise require a known ext.
71
+ if (!CODE_EXT.has(ext))
72
+ continue;
73
+ try {
74
+ const st = await fs.stat(abs);
75
+ if (st.size > cfg.maxFileBytes || st.size === 0)
76
+ continue;
77
+ }
78
+ catch {
79
+ continue;
80
+ }
81
+ out.push({ abs, rel });
82
+ }
83
+ }
84
+ const toPosix = (p) => p.split(path.sep).join("/");
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "embarsy-qdrant-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Codebase-indexing MCP server for Embarsy — semantic code search over a local Qdrant + OpenAI-compatible embeddings stack. Routes embeddings and Qdrant through Embarsy's proxy so auth and Monitoring counters just work.",
5
+ "type": "module",
6
+ "bin": {
7
+ "embarsy-index": "dist/bin/index.js",
8
+ "embarsy-mcp": "dist/bin/mcp.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "embarsy",
23
+ "mcp",
24
+ "qdrant",
25
+ "codebase",
26
+ "semantic-search",
27
+ "embeddings",
28
+ "claude-code",
29
+ "codex"
30
+ ],
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.12.0",
33
+ "ignore": "^5.3.2",
34
+ "zod": "^3.23.8"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.5.0",
38
+ "typescript": "^5.6.2"
39
+ },
40
+ "license": "PolyForm-Strict-1.0.0",
41
+ "publishConfig": {
42
+ "registry": "https://registry.npmjs.org/",
43
+ "access": "public"
44
+ }
45
+ }