Add rebuild_host and update_section tools

rebuild_host: wipe-and-replace for full host re-profiles. Deletes
all chunks for a hostname then bulk-ingests fresh sections. Supports
confirm: false for dry-run preview.

update_section: surgical in-place update with fuzzy section matching
(case-insensitive substring, then vector similarity fallback).
Preserves metadata, replaces text, re-embeds. Returns existing
section list on no match.

Addresses duplicate chunk problem caused by inconsistent section
naming across profiling runs.
This commit is contained in:
2026-03-01 19:41:30 +00:00
parent 518cef51bc
commit 6846774b41
3 changed files with 223 additions and 0 deletions

View File

@@ -12,6 +12,8 @@ import { getProject, getProjectSchema } from "./tools/get-project.js";
import { upsertProject, upsertProjectSchema } from "./tools/upsert-project.js"; import { upsertProject, upsertProjectSchema } from "./tools/upsert-project.js";
import { updateProject, updateProjectSchema } from "./tools/update-project.js"; import { updateProject, updateProjectSchema } from "./tools/update-project.js";
import { deleteProject, deleteProjectSchema } from "./tools/delete-project.js"; import { deleteProject, deleteProjectSchema } from "./tools/delete-project.js";
import { rebuildHost, rebuildHostSchema } from "./tools/rebuild-host.js";
import { updateSection, updateSectionSchema } from "./tools/update-section.js";
const server = new McpServer({ const server = new McpServer({
name: "homelab-mcp", name: "homelab-mcp",
@@ -126,6 +128,24 @@ server.tool(
}), }),
); );
server.tool(
"rebuild_host",
'Nuclear option for re-profiling a host. Deletes ALL existing chunks for the hostname, then bulk-ingests fresh sections. Use when doing a full re-profile of a machine to avoid stale/duplicate chunks. Set confirm: false first to preview what will be deleted. Example: {host: "usco-dc-nas01", source: "devices/usco-dc-nas01.md", sections: [{section: "System", text: "...", tags: ["nas"]}, ...], confirm: true}. Do NOT use for single-section updates — use update_section instead.',
rebuildHostSchema.shape,
async (input) => ({
content: [{ type: "text", text: await rebuildHost(rebuildHostSchema.parse(input)) }],
}),
);
server.tool(
"update_section",
'Surgical in-place update of a single section for a host. Fuzzy-matches the section name against existing sections (case-insensitive substring, then vector similarity). Preserves source/tags/type metadata, replaces text and re-embeds. If no match is found, returns the list of existing sections so you can pick the right one. Example: {host: "usco-dc-nas01", section: "Known Constraints", text: "new content..."}. Do NOT use for full re-profiles — use rebuild_host instead.',
updateSectionSchema.shape,
async (input) => ({
content: [{ type: "text", text: await updateSection(updateSectionSchema.parse(input)) }],
}),
);
async function main() { async function main() {
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);

89
src/tools/rebuild-host.ts Normal file
View File

@@ -0,0 +1,89 @@
import { z } from "zod";
import { embed } from "../lib/embeddings.js";
import { getClient, COLLECTION, deterministicId, type ChunkPayload } from "../lib/qdrant.js";
const sectionSchema = z.object({
section: z.string().describe("Section heading"),
text: z.string().describe("Content text"),
tags: z.array(z.string()).default([]).describe("Optional tags"),
type: z
.enum(["device", "infrastructure", "network", "automation", "tool", "general", "project"])
.default("device")
.describe("Document type (default: device)"),
});
export const rebuildHostSchema = z.object({
host: z.string().describe("Hostname to rebuild"),
source: z.string().describe("Source path for all sections, e.g. 'devices/usco-dc-nas01.md'"),
sections: z.array(sectionSchema).min(1).describe("New sections to ingest"),
confirm: z
.boolean()
.default(false)
.describe("Must be true to proceed. When false, returns a preview of what would be deleted and ingested."),
});
export type RebuildHostInput = z.infer<typeof rebuildHostSchema>;
export async function rebuildHost(input: RebuildHostInput): Promise<string> {
const client = getClient();
const filter = { must: [{ key: "host" as const, match: { value: input.host } }] };
// Count existing chunks
let existingCount = 0;
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
while (true) {
const result = await client.scroll(COLLECTION, {
limit: 100,
offset,
with_payload: ["section"],
with_vector: false,
filter,
});
existingCount += result.points.length;
if (!result.next_page_offset) break;
offset = result.next_page_offset;
}
if (!input.confirm) {
const sectionList = input.sections.map((s) => ` - ${s.section}`).join("\n");
return [
`**Rebuild preview for "${input.host}"**`,
`Will delete: ${existingCount} existing chunk(s)`,
`Will ingest: ${input.sections.length} new section(s)`,
`Source: ${input.source}`,
`Sections:`,
sectionList,
"",
"Set confirm: true to proceed.",
].join("\n");
}
// Delete all existing chunks for this host
if (existingCount > 0) {
await client.delete(COLLECTION, { filter });
}
// Ingest new sections
const now = new Date().toISOString();
const points = await Promise.all(
input.sections.map(async (s) => {
const pointId = deterministicId(input.source, s.section, input.host);
const vector = await embed(s.text);
const payload: ChunkPayload = {
source: input.source,
section: s.section,
type: s.type,
host: input.host,
tags: s.tags,
updated: now,
text: s.text,
};
return { id: pointId, vector, payload: payload as unknown as Record<string, unknown> };
}),
);
// Batch upsert (Qdrant handles batches fine)
await client.upsert(COLLECTION, { points });
return `Rebuilt "${input.host}": deleted ${existingCount} old chunk(s), ingested ${input.sections.length} new section(s).`;
}

114
src/tools/update-section.ts Normal file
View File

@@ -0,0 +1,114 @@
import { z } from "zod";
import { embed } from "../lib/embeddings.js";
import { getClient, COLLECTION, deterministicId, type ChunkPayload } from "../lib/qdrant.js";
export const updateSectionSchema = z.object({
host: z.string().describe("Hostname to update"),
section: z.string().describe("Section name (fuzzy matched against existing sections for this host)"),
text: z.string().describe("New content text (replaces existing text, triggers re-embedding)"),
new_tags: z.array(z.string()).optional().describe("Replace tags (omit to keep existing)"),
});
export type UpdateSectionInput = z.infer<typeof updateSectionSchema>;
export async function updateSection(input: UpdateSectionInput): Promise<string> {
const client = getClient();
// Gather all chunks for this host
const chunks: 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: { must: [{ key: "host", match: { value: input.host } }] },
});
for (const point of result.points) {
chunks.push({ id: point.id, payload: point.payload as unknown as ChunkPayload });
}
if (!result.next_page_offset) break;
offset = result.next_page_offset;
}
if (chunks.length === 0) {
return `No chunks found for host "${input.host}". Use list_hosts to see available hosts.`;
}
// Try exact match first
let match = chunks.find((c) => c.payload.section === input.section);
// Case-insensitive exact match
if (!match) {
const lower = input.section.toLowerCase();
match = chunks.find((c) => c.payload.section.toLowerCase() === lower);
}
// Substring match (input is substring of existing section name, or vice versa)
if (!match) {
const lower = input.section.toLowerCase();
const substringMatches = chunks.filter(
(c) =>
c.payload.section.toLowerCase().includes(lower) ||
lower.includes(c.payload.section.toLowerCase()),
);
if (substringMatches.length === 1) {
match = substringMatches[0];
} else if (substringMatches.length > 1) {
const names = substringMatches.map((c) => ` - "${c.payload.section}"`).join("\n");
return `Multiple sections match "${input.section}":\n${names}\n\nBe more specific.`;
}
}
// Vector similarity fallback
if (!match) {
const queryVector = await embed(input.section);
const results = await client.search(COLLECTION, {
vector: queryVector,
limit: 1,
with_payload: true,
filter: { must: [{ key: "host", match: { value: input.host } }] },
});
if (results.length > 0 && results[0].score > 0.7) {
const candidate = results[0].payload as unknown as ChunkPayload;
const candidateChunk = chunks.find((c) => c.payload.section === candidate.section);
if (candidateChunk) {
match = candidateChunk;
}
}
}
if (!match) {
const existing = chunks
.map((c) => c.payload.section)
.sort()
.map((s) => ` - "${s}"`)
.join("\n");
return `No section matching "${input.section}" found for host "${input.host}".\n\nExisting sections:\n${existing}`;
}
// Update the matched chunk: preserve metadata, replace text, re-embed
const payload = match.payload;
const newPayload: ChunkPayload = {
...payload,
text: input.text,
updated: new Date().toISOString(),
...(input.new_tags !== undefined ? { tags: input.new_tags } : {}),
};
const vector = await embed(input.text);
const pointId = deterministicId(payload.source, payload.section, payload.host, payload.project ?? "");
// Delete the old point (in case the stored ID differs from the computed one due to legacy data)
await client.delete(COLLECTION, { points: [match.id] });
// Upsert with the correct deterministic ID
await client.upsert(COLLECTION, {
points: [{ id: pointId, vector, payload: newPayload as unknown as Record<string, unknown> }],
});
const fuzzyNote = payload.section !== input.section ? ` (matched: "${payload.section}")` : "";
return `Updated section "${payload.section}"${fuzzyNote} for host "${input.host}".`;
}