Add CUD operations to homelab-mcp server

Add upsert_context, update_context, and delete_context tools to
complete CRUD functionality. Upserts use deterministic IDs based on
source+section+host for idempotency. Updates support metadata-only
patches or full re-embedding when text changes.
This commit is contained in:
2026-03-01 17:34:53 +00:00
commit 8353280bbf
16 changed files with 2331 additions and 0 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.git
*.md

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/\ndist/\n*.js.map\n*.d.ts.map

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /build
COPY package.json package-lock.json* ./
RUN npm install
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /build/package.json ./
COPY --from=build /build/node_modules ./node_modules
COPY --from=build /build/dist ./dist
USER node
CMD ["node", "dist/index.js"]

1753
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "homelab-mcp",
"version": "1.0.0",
"description": "MCP server for homelab context search via Qdrant",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"@qdrant/js-client-rest": "^1.13.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}

87
src/index.ts Normal file
View File

@@ -0,0 +1,87 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { searchContext, searchContextSchema } from "./tools/search-context.js";
import { listHosts } from "./tools/list-hosts.js";
import { getHostContext, getHostContextSchema } from "./tools/get-host-context.js";
import { searchIssues, searchIssuesSchema } from "./tools/search-issues.js";
import { upsertContext, upsertContextSchema } from "./tools/upsert-context.js";
import { updateContext, updateContextSchema } from "./tools/update-context.js";
import { deleteContext, deleteContextSchema } from "./tools/delete-context.js";
const server = new McpServer({
name: "homelab-mcp",
version: "1.0.0",
});
server.tool(
"search_context",
"Semantic search across all homelab documentation. Returns the most relevant sections for a natural language query. Optionally filter by document type (device, infrastructure, network, automation, tool) or hostname.",
searchContextSchema.shape,
async (input) => ({
content: [{ type: "text", text: await searchContext(searchContextSchema.parse(input)) }],
}),
);
server.tool(
"list_hosts",
"List all documented hosts/devices in the homelab context database. Returns a table of hostnames, source files, and section counts.",
{},
async () => ({
content: [{ type: "text", text: await listHosts() }],
}),
);
server.tool(
"get_host_context",
"Retrieve the full documentation for a specific host. Returns all sections from the host's context file, assembled in order.",
getHostContextSchema.shape,
async (input) => ({
content: [{ type: "text", text: await getHostContext(getHostContextSchema.parse(input)) }],
}),
);
server.tool(
"search_issues",
"Search for operational notes, troubleshooting info, known issues, and incident reports across all homelab documentation.",
searchIssuesSchema.shape,
async (input) => ({
content: [{ type: "text", text: await searchIssues(searchIssuesSchema.parse(input)) }],
}),
);
server.tool(
"upsert_context",
"Create or overwrite a homelab documentation chunk. Uses a deterministic ID based on source, section, and host so repeated upserts are idempotent.",
upsertContextSchema.shape,
async (input) => ({
content: [{ type: "text", text: await upsertContext(upsertContextSchema.parse(input)) }],
}),
);
server.tool(
"update_context",
"Update metadata fields on existing homelab documentation chunk(s). Filter by host, source, or section. Optionally provide new text to trigger re-embedding.",
updateContextSchema._def.schema.shape,
async (input) => ({
content: [{ type: "text", text: await updateContext(updateContextSchema.parse(input)) }],
}),
);
server.tool(
"delete_context",
"Delete homelab documentation chunks matching a filter. At least one filter field (host, source, section) is required.",
deleteContextSchema._def.schema.shape,
async (input) => ({
content: [{ type: "text", text: await deleteContext(deleteContextSchema.parse(input)) }],
}),
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

16
src/lib/embeddings.ts Normal file
View File

@@ -0,0 +1,16 @@
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
const EMBED_MODEL = process.env.EMBED_MODEL || "snowflake-arctic-embed:335m";
const MAX_EMBED_CHARS = 1000;
export async function embed(text: string): Promise<number[]> {
const resp = await fetch(`${OLLAMA_URL}/api/embed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: EMBED_MODEL, input: [text.slice(0, MAX_EMBED_CHARS)] }),
});
if (!resp.ok) {
throw new Error(`Ollama embed failed: ${resp.status} ${await resp.text()}`);
}
const data = (await resp.json()) as { embeddings: number[][] };
return data.embeddings[0];
}

40
src/lib/qdrant.ts Normal file
View File

@@ -0,0 +1,40 @@
import { createHash } from "crypto";
import { QdrantClient } from "@qdrant/js-client-rest";
const QDRANT_URL = process.env.QDRANT_URL || "http://localhost:6333";
const QDRANT_API_KEY = process.env.QDRANT_API_KEY || "";
export const COLLECTION = process.env.QDRANT_COLLECTION || "homelab_context";
let client: QdrantClient | null = null;
export function getClient(): QdrantClient {
if (!client) {
client = new QdrantClient({
url: QDRANT_URL,
...(QDRANT_API_KEY ? { apiKey: QDRANT_API_KEY } : {}),
});
}
return client;
}
export function deterministicId(source: string, section: string, host: string): string {
const hash = createHash("sha256").update(`${source}\0${section}\0${host}`).digest("hex");
return [
hash.slice(0, 8),
hash.slice(8, 12),
hash.slice(12, 16),
hash.slice(16, 20),
hash.slice(20, 32),
].join("-");
}
export interface ChunkPayload {
source: string;
section: string;
type: string;
host: string;
tags: string[];
updated: string;
text: string;
}

View File

@@ -0,0 +1,49 @@
import { z } from "zod";
import { getClient, COLLECTION } from "../lib/qdrant.js";
export const deleteContextSchema = z
.object({
host: z.string().optional().describe("Filter by hostname"),
source: z.string().optional().describe("Filter by source path"),
section: z.string().optional().describe("Filter by section heading"),
})
.refine((d) => d.host || d.source || d.section, {
message: "At least one filter field (host, source, section) is required",
});
export type DeleteContextInput = z.infer<typeof deleteContextSchema>;
export async function deleteContext(input: DeleteContextInput): Promise<string> {
const client = getClient();
const must: Array<Record<string, unknown>> = [];
if (input.host) must.push({ key: "host", match: { value: input.host } });
if (input.source) must.push({ key: "source", match: { value: input.source } });
if (input.section) must.push({ key: "section", match: { value: input.section } });
const filter = { must };
// Count matches first
let count = 0;
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
while (true) {
const result = await client.scroll(COLLECTION, {
limit: 100,
offset,
with_payload: false,
with_vector: false,
filter,
});
count += result.points.length;
if (!result.next_page_offset) break;
offset = result.next_page_offset;
}
if (count === 0) {
return "No matching points found.";
}
await client.delete(COLLECTION, { filter });
return `Deleted ${count} point(s).`;
}

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
export const getHostContextSchema = z.object({
host: z.string().describe("Hostname to retrieve full context for"),
});
export type GetHostContextInput = z.infer<typeof getHostContextSchema>;
export async function getHostContext(input: GetHostContextInput): Promise<string> {
const client = getClient();
const chunks: ChunkPayload[] = [];
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
while (true) {
const result = await client.scroll(COLLECTION, {
limit: 100,
offset,
with_payload: true,
with_vector: false,
filter: { must: [{ key: "host", match: { value: input.host } }] },
});
for (const point of result.points) {
chunks.push(point.payload as unknown as ChunkPayload);
}
if (!result.next_page_offset) break;
offset = result.next_page_offset;
}
if (chunks.length === 0) {
return `No context found for host "${input.host}". Use list_hosts to see available hosts.`;
}
// Sort by source then section to reconstruct document order
chunks.sort((a, b) => a.source.localeCompare(b.source) || a.section.localeCompare(b.section));
return chunks.map((c) => c.text).join("\n\n---\n\n");
}

42
src/tools/list-hosts.ts Normal file
View File

@@ -0,0 +1,42 @@
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
export async function listHosts(): Promise<string> {
const client = getClient();
const hosts = new Map<string, { source: string; sections: number }>();
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
while (true) {
const result = await client.scroll(COLLECTION, {
limit: 100,
offset,
with_payload: ["source", "host", "type"],
with_vector: false,
filter: { must: [{ key: "type", match: { value: "device" } }] },
});
for (const point of result.points) {
const p = point.payload as unknown as ChunkPayload;
if (p.host) {
const existing = hosts.get(p.host);
if (existing) {
existing.sections++;
} else {
hosts.set(p.host, { source: p.source, sections: 1 });
}
}
}
if (!result.next_page_offset) break;
offset = result.next_page_offset;
}
if (hosts.size === 0) {
return "No hosts found in the context database.";
}
const lines = ["| Host | Source | Sections |", "|---|---|---|"];
for (const [host, info] of [...hosts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
lines.push(`| ${host} | ${info.source} | ${info.sections} |`);
}
return lines.join("\n");
}

View File

@@ -0,0 +1,52 @@
import { z } from "zod";
import { embed } from "../lib/embeddings.js";
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
export const searchContextSchema = z.object({
query: z.string().describe("Natural language search query"),
type: z
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
.optional()
.describe("Filter by document type"),
host: z.string().optional().describe("Filter by hostname"),
limit: z.number().min(1).max(20).default(5).describe("Number of results"),
});
export type SearchContextInput = z.infer<typeof searchContextSchema>;
export async function searchContext(input: SearchContextInput): Promise<string> {
const vector = await embed(input.query);
const client = getClient();
const must: Array<Record<string, unknown>> = [];
if (input.type) {
must.push({ key: "type", match: { value: input.type } });
}
if (input.host) {
must.push({ key: "host", match: { value: input.host } });
}
const results = await client.search(COLLECTION, {
vector,
limit: input.limit,
with_payload: true,
...(must.length > 0 ? { filter: { must } } : {}),
});
if (results.length === 0) {
return "No results found.";
}
return results
.map((r, i) => {
const p = r.payload as unknown as ChunkPayload;
return [
`### Result ${i + 1} (score: ${r.score.toFixed(3)})`,
`**Source**: ${p.source} | **Section**: ${p.section} | **Type**: ${p.type}${p.host ? ` | **Host**: ${p.host}` : ""}`,
`**Tags**: ${p.tags.length > 0 ? p.tags.join(", ") : "none"} | **Updated**: ${p.updated}`,
"",
p.text,
].join("\n");
})
.join("\n\n---\n\n");
}

View File

@@ -0,0 +1,55 @@
import { z } from "zod";
import { embed } from "../lib/embeddings.js";
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
export const searchIssuesSchema = z.object({
query: z.string().describe("Search query for operational notes and issues"),
limit: z.number().min(1).max(20).default(5).describe("Number of results"),
});
export type SearchIssuesInput = z.infer<typeof searchIssuesSchema>;
export async function searchIssues(input: SearchIssuesInput): Promise<string> {
const vector = await embed(input.query);
const client = getClient();
// Search across infrastructure and device docs, filtered to sections
// that are likely operational notes, issues, or troubleshooting
const results = await client.search(COLLECTION, {
vector,
limit: input.limit * 2, // over-fetch then filter
with_payload: true,
});
// Filter to operational/issue-related sections
const issuePatterns = /operational|issue|troubleshoot|problem|fix|error|outage|rca|incident|quirk|gotcha|known/i;
const filtered = results.filter((r) => {
const p = r.payload as unknown as ChunkPayload;
return issuePatterns.test(p.section) || issuePatterns.test(p.text);
});
const final = filtered.slice(0, input.limit);
if (final.length === 0) {
// Fall back to top results even if not explicitly issue-tagged
const fallback = results.slice(0, input.limit);
if (fallback.length === 0) return "No results found.";
return (
"*No explicitly operational sections found. Showing best general matches:*\n\n" +
fallback
.map((r, i) => {
const p = r.payload as unknown as ChunkPayload;
return `### Result ${i + 1} (score: ${r.score.toFixed(3)})\n**Source**: ${p.source} | **Section**: ${p.section}\n\n${p.text}`;
})
.join("\n\n---\n\n")
);
}
return final
.map((r, i) => {
const p = r.payload as unknown as ChunkPayload;
return `### Result ${i + 1} (score: ${r.score.toFixed(3)})\n**Source**: ${p.source} | **Section**: ${p.section}\n\n${p.text}`;
})
.join("\n\n---\n\n");
}

View File

@@ -0,0 +1,94 @@
import { z } from "zod";
import { embed } from "../lib/embeddings.js";
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
export const updateContextSchema = z
.object({
host: z.string().optional().describe("Filter by hostname"),
source: z.string().optional().describe("Filter by source path"),
section: z.string().optional().describe("Filter by section heading"),
new_tags: z.array(z.string()).optional().describe("Set tags"),
new_type: z
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
.optional()
.describe("Set document type"),
new_source: z.string().optional().describe("Set source path"),
new_section: z.string().optional().describe("Set section heading"),
new_host: z.string().optional().describe("Set hostname"),
text: z.string().optional().describe("New content text (triggers re-embedding)"),
})
.refine((d) => d.host || d.source || d.section, {
message: "At least one filter field (host, source, section) is required",
});
export type UpdateContextInput = z.infer<typeof updateContextSchema>;
export async function updateContext(input: UpdateContextInput): Promise<string> {
const client = getClient();
const must: Array<Record<string, unknown>> = [];
if (input.host) must.push({ key: "host", match: { value: input.host } });
if (input.source) must.push({ key: "source", match: { value: input.source } });
if (input.section) must.push({ key: "section", match: { value: input.section } });
const filter = { must };
// Scroll to find matching points
const points: Array<{ id: string | number; payload: ChunkPayload }> = [];
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
while (true) {
const result = await client.scroll(COLLECTION, {
limit: 100,
offset,
with_payload: true,
with_vector: false,
filter,
});
for (const point of result.points) {
points.push({
id: point.id,
payload: point.payload as unknown as ChunkPayload,
});
}
if (!result.next_page_offset) break;
offset = result.next_page_offset;
}
if (points.length === 0) {
return "No matching points found.";
}
// Build payload patch from new_* fields
const patch: Record<string, unknown> = {};
if (input.new_tags !== undefined) patch.tags = input.new_tags;
if (input.new_type !== undefined) patch.type = input.new_type;
if (input.new_source !== undefined) patch.source = input.new_source;
if (input.new_section !== undefined) patch.section = input.new_section;
if (input.new_host !== undefined) patch.host = input.new_host;
if (input.text !== undefined) {
// Re-embed and full upsert for each point
const vector = await embed(input.text);
const upsertPoints = points.map((p) => ({
id: p.id,
vector,
payload: {
...p.payload,
...patch,
text: input.text,
updated: new Date().toISOString(),
},
}));
await client.upsert(COLLECTION, { points: upsertPoints });
} else if (Object.keys(patch).length > 0) {
patch.updated = new Date().toISOString();
await client.setPayload(COLLECTION, {
payload: patch,
filter,
});
} else {
return `Found ${points.length} matching point(s) but no update fields provided.`;
}
return `Updated ${points.length} point(s).`;
}

View File

@@ -0,0 +1,43 @@
import { z } from "zod";
import { embed } from "../lib/embeddings.js";
import {
getClient,
COLLECTION,
deterministicId,
type ChunkPayload,
} from "../lib/qdrant.js";
export const upsertContextSchema = z.object({
source: z.string().describe("Source path, e.g. 'devices/proxmox.md'"),
section: z.string().describe("Section heading"),
type: z
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
.describe("Document type"),
host: z.string().describe("Hostname"),
tags: z.array(z.string()).default([]).describe("Optional tags"),
text: z.string().describe("Content text"),
});
export type UpsertContextInput = z.infer<typeof upsertContextSchema>;
export async function upsertContext(input: UpsertContextInput): Promise<string> {
const pointId = deterministicId(input.source, input.section, input.host);
const vector = await embed(input.text);
const client = getClient();
const payload: ChunkPayload = {
source: input.source,
section: input.section,
type: input.type,
host: input.host,
tags: input.tags,
updated: new Date().toISOString(),
text: input.text,
};
await client.upsert(COLLECTION, {
points: [{ id: pointId, vector, payload: payload as unknown as Record<string, unknown> }],
});
return `Upserted point ${pointId} (source: ${input.source}, section: ${input.section}, host: ${input.host}).`;
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}