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 { 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
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