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:
20
src/index.ts
20
src/index.ts
@@ -12,6 +12,8 @@ import { getProject, getProjectSchema } from "./tools/get-project.js";
|
||||
import { upsertProject, upsertProjectSchema } from "./tools/upsert-project.js";
|
||||
import { updateProject, updateProjectSchema } from "./tools/update-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({
|
||||
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() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
89
src/tools/rebuild-host.ts
Normal file
89
src/tools/rebuild-host.ts
Normal 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
114
src/tools/update-section.ts
Normal 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}".`;
|
||||
}
|
||||
Reference in New Issue
Block a user