@cerefox/memory 0.7.2 → 0.8.1
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/dist/bin/cerefox.js +1357 -361
- package/dist/frontend/assets/{index-BzAPcCXA.js → index-CAp2_lFX.js} +2 -2
- package/dist/frontend/assets/index-CAp2_lFX.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-meta/index.ts +97 -0
- package/dist/server-assets/_shared/embeddings/index.ts +175 -0
- package/dist/server-assets/_shared/mcp-tools/_chunker.ts +187 -0
- package/dist/server-assets/_shared/mcp-tools/_projects.ts +121 -0
- package/dist/server-assets/_shared/mcp-tools/_utils.ts +73 -0
- package/dist/server-assets/_shared/mcp-tools/audit-log.ts +95 -0
- package/dist/server-assets/_shared/mcp-tools/get-document.ts +73 -0
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +26 -0
- package/dist/server-assets/_shared/mcp-tools/get-help.ts +90 -0
- package/dist/server-assets/_shared/mcp-tools/index.ts +67 -0
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +315 -0
- package/dist/server-assets/_shared/mcp-tools/list-metadata-keys.ts +55 -0
- package/dist/server-assets/_shared/mcp-tools/list-projects.ts +59 -0
- package/dist/server-assets/_shared/mcp-tools/list-versions.ts +72 -0
- package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +154 -0
- package/dist/server-assets/_shared/mcp-tools/search.ts +193 -0
- package/dist/server-assets/_shared/mcp-tools/set-document-projects.ts +163 -0
- package/dist/server-assets/_shared/mcp-tools/types.ts +92 -0
- package/dist/server-assets/db/migrations/0003_add_document_versions.sql +91 -0
- package/dist/server-assets/db/migrations/0004_add_audit_log_review_status_archived.sql +71 -0
- package/dist/server-assets/db/migrations/0005_metadata_search.sql +628 -0
- package/dist/server-assets/db/migrations/0006_usage_log.sql +255 -0
- package/dist/server-assets/db/migrations/0007_usage_log_requestor.sql +178 -0
- package/dist/server-assets/db/migrations/0008_soft_delete.sql +130 -0
- package/dist/server-assets/db/migrations/0009_audit_log_restore_operation.sql +20 -0
- package/dist/server-assets/db/migrations/0010_requestor_enforcement_config.sql +12 -0
- package/dist/server-assets/db/migrations/0011_title_boosting.sql +48 -0
- package/dist/server-assets/db/rpcs.sql +1723 -0
- package/dist/server-assets/db/schema.sql +380 -0
- package/dist/server-assets/supabase/functions/cerefox-get-audit-log/index.ts +117 -0
- package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +138 -0
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +819 -0
- package/dist/server-assets/supabase/functions/cerefox-list-projects/index.ts +96 -0
- package/dist/server-assets/supabase/functions/cerefox-list-versions/index.ts +113 -0
- package/dist/server-assets/supabase/functions/cerefox-mcp/index.ts +294 -0
- package/dist/server-assets/supabase/functions/cerefox-mcp/shared.ts +42 -0
- package/dist/server-assets/supabase/functions/cerefox-metadata/index.ts +99 -0
- package/dist/server-assets/supabase/functions/cerefox-metadata-search/index.ts +146 -0
- package/dist/server-assets/supabase/functions/cerefox-search/index.ts +382 -0
- package/docs/guides/connect-agents.md +78 -3
- package/docs/guides/migration-v0.5.md +50 -0
- package/docs/guides/quickstart.md +6 -2
- package/package.json +3 -2
- package/dist/frontend/assets/index-BzAPcCXA.js.map +0 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cerefox_ingest` — save a note or document to the knowledge base.
|
|
3
|
+
*
|
|
4
|
+
* Three code paths:
|
|
5
|
+
* - `document_id` set → ID-based update (preferred; deterministic).
|
|
6
|
+
* - `update_if_exists: true` + title matches existing → update by title.
|
|
7
|
+
* - Otherwise → create (with hash-dedup short-circuit).
|
|
8
|
+
*
|
|
9
|
+
* Project-membership semantics on update (issue #38 / v0.1.20):
|
|
10
|
+
* - `project_names: [...]` → destructive replace.
|
|
11
|
+
* - `project_name: "..."` → non-destructive add (preserves other memberships).
|
|
12
|
+
* - Neither → no change.
|
|
13
|
+
*
|
|
14
|
+
* Mirrors `supabase/functions/cerefox-mcp/tools/ingest.ts` for v0.4.0
|
|
15
|
+
* extraction (no behaviour change).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { MCPSupabaseClient } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
import { chunkMarkdown, normalizeContent, sha256hex } from "./_chunker.ts";
|
|
21
|
+
import { embedBatch, OPENAI_MODEL } from "../embeddings/index.ts";
|
|
22
|
+
import { ensureDocumentInProject, setDocumentProjectsByName } from "./_projects.ts";
|
|
23
|
+
import { logUsage } from "./_utils.ts";
|
|
24
|
+
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
async function handler(
|
|
27
|
+
supabase: MCPSupabaseClient,
|
|
28
|
+
args: Record<string, unknown>,
|
|
29
|
+
ctx: ToolContext,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
const title = (args.title as string | undefined)?.trim();
|
|
32
|
+
const content = args.content as string | undefined;
|
|
33
|
+
const document_id = (args.document_id as string | undefined) ?? null;
|
|
34
|
+
const project_name = args.project_name as string | undefined;
|
|
35
|
+
const project_names_raw = args.project_names;
|
|
36
|
+
const source = (args.source as string | undefined) ?? "agent";
|
|
37
|
+
const metadata = (args.metadata as Record<string, unknown> | undefined) ?? {};
|
|
38
|
+
const update_if_exists = (args.update_if_exists as boolean | undefined) ?? false;
|
|
39
|
+
const author = (args.author as string | undefined) ?? "mcp-agent";
|
|
40
|
+
const author_type = "agent"; // MCP path is always agent
|
|
41
|
+
|
|
42
|
+
if (!title || !content?.trim()) {
|
|
43
|
+
throw new McpInvalidParams("title and content are required");
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
project_names_raw !== undefined &&
|
|
47
|
+
project_names_raw !== null &&
|
|
48
|
+
!Array.isArray(project_names_raw)
|
|
49
|
+
) {
|
|
50
|
+
throw new McpInvalidParams(
|
|
51
|
+
"project_names must be a JSON array of strings; for a single project use project_name (string)",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const project_names: string[] | null = Array.isArray(project_names_raw)
|
|
55
|
+
? project_names_raw.filter((s): s is string => typeof s === "string" && s.length > 0)
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
if (!ctx.openaiApiKey) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"OpenAI API key not configured. Set OPENAI_API_KEY (Edge Function) or CEREFOX_OPENAI_API_KEY (.env, local).",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const contentHash = await sha256hex(normalizeContent(content));
|
|
65
|
+
const reviewStatus = author_type === "agent" ? "pending_review" : "approved";
|
|
66
|
+
|
|
67
|
+
// ── ID-based update path ─────────────────────────────────────────────────
|
|
68
|
+
if (document_id) {
|
|
69
|
+
const { data: existing } = await supabase
|
|
70
|
+
.from("cerefox_documents")
|
|
71
|
+
.select("id, title, content_hash")
|
|
72
|
+
.eq("id", document_id)
|
|
73
|
+
.is("deleted_at", null)
|
|
74
|
+
.limit(1);
|
|
75
|
+
|
|
76
|
+
if (!existing?.length) {
|
|
77
|
+
throw new Error(`Document not found: ${document_id}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const existingDoc = existing[0];
|
|
81
|
+
|
|
82
|
+
if (existingDoc.content_hash === contentHash) {
|
|
83
|
+
const note = update_if_exists
|
|
84
|
+
? ""
|
|
85
|
+
: " Note: update_if_exists flag was overridden by document_id.";
|
|
86
|
+
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged.${note}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const chunks = chunkMarkdown(content);
|
|
90
|
+
if (chunks.length === 0) throw new Error("Content produced no chunks");
|
|
91
|
+
|
|
92
|
+
const texts = chunks.map((c) => `# ${title}\n${c.content}`);
|
|
93
|
+
const embeddings = await embedBatch(texts, ctx.openaiApiKey);
|
|
94
|
+
const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
|
|
95
|
+
|
|
96
|
+
const chunkData = chunks.map((chunk, i) => ({
|
|
97
|
+
chunk_index: i,
|
|
98
|
+
heading_path: chunk.heading_path,
|
|
99
|
+
heading_level: chunk.heading_level,
|
|
100
|
+
title: chunk.title,
|
|
101
|
+
content: chunk.content,
|
|
102
|
+
char_count: chunk.char_count,
|
|
103
|
+
embedding: embeddings[i],
|
|
104
|
+
embedder: OPENAI_MODEL,
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
const { error: ingestErr } = await supabase.rpc("cerefox_ingest_document", {
|
|
108
|
+
p_document_id: existingDoc.id,
|
|
109
|
+
p_title: title,
|
|
110
|
+
p_source: source,
|
|
111
|
+
p_content_hash: contentHash,
|
|
112
|
+
p_metadata: metadata,
|
|
113
|
+
p_review_status: reviewStatus,
|
|
114
|
+
p_chunks: chunkData,
|
|
115
|
+
p_author: author,
|
|
116
|
+
p_author_type: author_type,
|
|
117
|
+
p_source_label: source,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (ingestErr) throw new Error(`Ingest RPC failed: ${ingestErr.message}`);
|
|
121
|
+
|
|
122
|
+
logUsage(supabase, {
|
|
123
|
+
operation: "ingest",
|
|
124
|
+
accessPath: ctx.accessPath,
|
|
125
|
+
requestor: author,
|
|
126
|
+
document_id: existingDoc.id,
|
|
127
|
+
result_count: chunks.length,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (project_names !== null) {
|
|
131
|
+
await setDocumentProjectsByName(supabase, existingDoc.id, project_names);
|
|
132
|
+
} else if (project_name) {
|
|
133
|
+
await ensureDocumentInProject(supabase, existingDoc.id, project_name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const note = update_if_exists
|
|
137
|
+
? ""
|
|
138
|
+
: " Note: update_if_exists flag was overridden by document_id.";
|
|
139
|
+
return `Document updated: "${title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars.${note}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Update-existing path ─────────────────────────────────────────────────
|
|
143
|
+
if (update_if_exists) {
|
|
144
|
+
const { data: existing } = await supabase
|
|
145
|
+
.from("cerefox_documents")
|
|
146
|
+
.select("id, title, content_hash")
|
|
147
|
+
.eq("title", title)
|
|
148
|
+
.order("updated_at", { ascending: false })
|
|
149
|
+
.limit(1);
|
|
150
|
+
|
|
151
|
+
if (existing?.length) {
|
|
152
|
+
const existingDoc = existing[0];
|
|
153
|
+
|
|
154
|
+
if (existingDoc.content_hash === contentHash) {
|
|
155
|
+
return `Document already up-to-date: "${existingDoc.title}" (id: ${existingDoc.id}). Content hash unchanged.`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const chunks = chunkMarkdown(content);
|
|
159
|
+
if (chunks.length === 0) throw new Error("Content produced no chunks");
|
|
160
|
+
|
|
161
|
+
const texts = chunks.map((c) => `# ${title}\n${c.content}`);
|
|
162
|
+
const embeddings = await embedBatch(texts, ctx.openaiApiKey);
|
|
163
|
+
const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
|
|
164
|
+
|
|
165
|
+
const chunkData = chunks.map((chunk, i) => ({
|
|
166
|
+
chunk_index: i,
|
|
167
|
+
heading_path: chunk.heading_path,
|
|
168
|
+
heading_level: chunk.heading_level,
|
|
169
|
+
title: chunk.title,
|
|
170
|
+
content: chunk.content,
|
|
171
|
+
char_count: chunk.char_count,
|
|
172
|
+
embedding: embeddings[i],
|
|
173
|
+
embedder: OPENAI_MODEL,
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const { error: ingestErr } = await supabase.rpc("cerefox_ingest_document", {
|
|
177
|
+
p_document_id: existingDoc.id,
|
|
178
|
+
p_title: existingDoc.title,
|
|
179
|
+
p_source: source,
|
|
180
|
+
p_content_hash: contentHash,
|
|
181
|
+
p_metadata: metadata,
|
|
182
|
+
p_review_status: reviewStatus,
|
|
183
|
+
p_chunks: chunkData,
|
|
184
|
+
p_author: author,
|
|
185
|
+
p_author_type: author_type,
|
|
186
|
+
p_source_label: source,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (ingestErr) throw new Error(`Ingest RPC failed: ${ingestErr.message}`);
|
|
190
|
+
|
|
191
|
+
logUsage(supabase, {
|
|
192
|
+
operation: "ingest",
|
|
193
|
+
accessPath: ctx.accessPath,
|
|
194
|
+
requestor: author,
|
|
195
|
+
document_id: existingDoc.id,
|
|
196
|
+
result_count: chunks.length,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (project_names !== null) {
|
|
200
|
+
await setDocumentProjectsByName(supabase, existingDoc.id, project_names);
|
|
201
|
+
} else if (project_name) {
|
|
202
|
+
await ensureDocumentInProject(supabase, existingDoc.id, project_name);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return `Document updated: "${existingDoc.title}" (id: ${existingDoc.id}), ${chunks.length} chunk(s), ${totalChars} chars.`;
|
|
206
|
+
}
|
|
207
|
+
// Fall through to create path
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Hash deduplication (create path) ─────────────────────────────────────
|
|
211
|
+
const { data: hashMatch } = await supabase
|
|
212
|
+
.from("cerefox_documents")
|
|
213
|
+
.select("id, title")
|
|
214
|
+
.eq("content_hash", contentHash)
|
|
215
|
+
.limit(1);
|
|
216
|
+
|
|
217
|
+
if (hashMatch?.length) {
|
|
218
|
+
return `Document already up-to-date: "${hashMatch[0].title}" (id: ${hashMatch[0].id}). Content hash unchanged.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const chunks = chunkMarkdown(content);
|
|
222
|
+
if (chunks.length === 0) throw new Error("Content produced no chunks");
|
|
223
|
+
|
|
224
|
+
const texts = chunks.map((c) => `# ${title}\n${c.content}`);
|
|
225
|
+
const embeddings = await embedBatch(texts, ctx.openaiApiKey);
|
|
226
|
+
const totalChars = chunks.reduce((s, c) => s + c.char_count, 0);
|
|
227
|
+
|
|
228
|
+
const chunkData = chunks.map((chunk, i) => ({
|
|
229
|
+
chunk_index: i,
|
|
230
|
+
heading_path: chunk.heading_path,
|
|
231
|
+
heading_level: chunk.heading_level,
|
|
232
|
+
title: chunk.title,
|
|
233
|
+
content: chunk.content,
|
|
234
|
+
char_count: chunk.char_count,
|
|
235
|
+
embedding: embeddings[i],
|
|
236
|
+
embedder: OPENAI_MODEL,
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
const { data: ingestResult, error: ingestErr } = await supabase.rpc("cerefox_ingest_document", {
|
|
240
|
+
p_document_id: null,
|
|
241
|
+
p_title: title,
|
|
242
|
+
p_source: source,
|
|
243
|
+
p_content_hash: contentHash,
|
|
244
|
+
p_metadata: metadata,
|
|
245
|
+
p_review_status: reviewStatus,
|
|
246
|
+
p_chunks: chunkData,
|
|
247
|
+
p_author: author,
|
|
248
|
+
p_author_type: author_type,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (ingestErr || !ingestResult?.length) {
|
|
252
|
+
throw new Error(`Ingest RPC failed: ${ingestErr?.message ?? "no data returned"}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const documentId = ingestResult[0].document_id;
|
|
256
|
+
|
|
257
|
+
if (project_names !== null && project_names.length > 0) {
|
|
258
|
+
await setDocumentProjectsByName(supabase, documentId, project_names);
|
|
259
|
+
} else if (project_name) {
|
|
260
|
+
await ensureDocumentInProject(supabase, documentId, project_name);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
logUsage(supabase, {
|
|
264
|
+
operation: "ingest",
|
|
265
|
+
accessPath: ctx.accessPath,
|
|
266
|
+
requestor: author,
|
|
267
|
+
document_id: documentId,
|
|
268
|
+
result_count: chunks.length,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const projectInfo = project_name ? `, project: "${project_name}"` : "";
|
|
272
|
+
return `Document saved: "${title}" (id: ${documentId}), ${chunks.length} chunk(s), ${totalChars} chars${projectInfo}.`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export const ingestTool: ToolDefinition = {
|
|
276
|
+
name: "cerefox_ingest",
|
|
277
|
+
description: "Save a note or document to the Cerefox knowledge base.",
|
|
278
|
+
inputSchema: {
|
|
279
|
+
type: "object",
|
|
280
|
+
required: ["title", "content"],
|
|
281
|
+
properties: {
|
|
282
|
+
title: { type: "string", description: "Document title" },
|
|
283
|
+
content: { type: "string", description: "Markdown content" },
|
|
284
|
+
document_id: {
|
|
285
|
+
type: "string",
|
|
286
|
+
description:
|
|
287
|
+
"UUID of an existing document to update. When provided, updates that specific document regardless of update_if_exists. Returns an error if the document does not exist. Workflow: cerefox_search → note the [id: ...] → pass here for deterministic update.",
|
|
288
|
+
},
|
|
289
|
+
project_name: {
|
|
290
|
+
type: "string",
|
|
291
|
+
description:
|
|
292
|
+
"Optional: single project name (created if absent). On update: non-destructive add — ensures this membership exists; preserves other memberships an operator may have added via the web UI. For explicit set-the-full-list semantics, use project_names (list) instead, or call cerefox_set_document_projects.",
|
|
293
|
+
},
|
|
294
|
+
project_names: {
|
|
295
|
+
type: "array",
|
|
296
|
+
items: { type: "string" },
|
|
297
|
+
description:
|
|
298
|
+
"Optional: explicit list of project names (each created if absent). Full-set semantics — on update this REPLACES the document's project memberships with exactly this set. Use when you want to set multiple projects at once or deliberately change the membership list. Wins over project_name when both are passed.",
|
|
299
|
+
},
|
|
300
|
+
source: { type: "string", description: 'Origin label (default: "agent")' },
|
|
301
|
+
update_if_exists: {
|
|
302
|
+
type: "boolean",
|
|
303
|
+
description:
|
|
304
|
+
"When true, update an existing document with the same title instead of creating a new one (default: false). Ignored when document_id is provided.",
|
|
305
|
+
},
|
|
306
|
+
metadata: { type: "object", description: "Arbitrary JSON metadata (optional)" },
|
|
307
|
+
author: {
|
|
308
|
+
type: "string",
|
|
309
|
+
description:
|
|
310
|
+
'Name of the agent or tool performing the ingestion (e.g., "Claude Code", "archiver"). Recorded in the audit log for attribution. Defaults to "mcp-agent" if not provided. May be enforced via server config.',
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
handler,
|
|
315
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cerefox_list_metadata_keys` — discover the metadata vocabulary in use
|
|
3
|
+
* across all documents. Returns each key with doc count + up to 5 example
|
|
4
|
+
* values. Agents call this before `cerefox_metadata_search` or before
|
|
5
|
+
* supplying their own metadata on `cerefox_ingest`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MCPSupabaseClient } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
import { logUsage } from "./_utils.ts";
|
|
11
|
+
import type { ToolContext, ToolDefinition } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
async function handler(
|
|
14
|
+
supabase: MCPSupabaseClient,
|
|
15
|
+
args: Record<string, unknown>,
|
|
16
|
+
ctx: ToolContext,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const { data, error } = await supabase.rpc("cerefox_list_metadata_keys");
|
|
19
|
+
|
|
20
|
+
if (error) throw new Error(`RPC error: ${error.message}`);
|
|
21
|
+
|
|
22
|
+
const keys = (data ?? []) as Array<{
|
|
23
|
+
key: string;
|
|
24
|
+
doc_count: number;
|
|
25
|
+
example_values: string[];
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
logUsage(supabase, {
|
|
29
|
+
operation: "list_metadata_keys",
|
|
30
|
+
accessPath: ctx.accessPath,
|
|
31
|
+
requestor: args.requestor as string | undefined,
|
|
32
|
+
result_count: keys.length,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (keys.length === 0) return "No metadata keys found across documents.";
|
|
36
|
+
|
|
37
|
+
return JSON.stringify(keys, null, 2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const listMetadataKeysTool: ToolDefinition = {
|
|
41
|
+
name: "cerefox_list_metadata_keys",
|
|
42
|
+
description:
|
|
43
|
+
"List all metadata keys currently in use across documents in the Cerefox knowledge base. Returns each key with its document count and up to 5 example values.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
requestor: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description:
|
|
50
|
+
'Name of the agent or user making this request. Recorded in the usage log. Defaults to "mcp-agent" if not provided. May be enforced via server config.',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
handler,
|
|
55
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cerefox_list_projects` — list all projects so agents can discover names
|
|
3
|
+
* before filtering by `project_name` in other tools.
|
|
4
|
+
*
|
|
5
|
+
* Calls `cerefox_list_projects` RPC. Trivial; usage logged.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MCPSupabaseClient } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
import { logUsage } from "./_utils.ts";
|
|
11
|
+
import type { ToolContext, ToolDefinition } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
async function handler(
|
|
14
|
+
supabase: MCPSupabaseClient,
|
|
15
|
+
args: Record<string, unknown>,
|
|
16
|
+
ctx: ToolContext,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const { data, error } = await supabase.rpc("cerefox_list_projects");
|
|
19
|
+
|
|
20
|
+
if (error) throw new Error(`RPC error: ${error.message}`);
|
|
21
|
+
|
|
22
|
+
const projects = (data ?? []) as Array<{
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description: string | null;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
logUsage(supabase, {
|
|
29
|
+
operation: "list_projects",
|
|
30
|
+
accessPath: ctx.accessPath,
|
|
31
|
+
requestor: args.requestor as string | undefined,
|
|
32
|
+
result_count: projects.length,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (projects.length === 0) return "No projects found.";
|
|
36
|
+
|
|
37
|
+
const lines = projects.map((p) => {
|
|
38
|
+
const desc = p.description ? ` -- ${p.description}` : "";
|
|
39
|
+
return `- ${p.name} (id: ${p.id})${desc}`;
|
|
40
|
+
});
|
|
41
|
+
return `Projects (${projects.length}):\n\n${lines.join("\n")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const listProjectsTool: ToolDefinition = {
|
|
45
|
+
name: "cerefox_list_projects",
|
|
46
|
+
description:
|
|
47
|
+
"List all projects with their names and IDs. Use this to discover available projects before filtering by project_name in other tools.",
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
requestor: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description:
|
|
54
|
+
'Name of the agent or user making this request. Recorded in the usage log. Defaults to "mcp-agent" if not provided. May be enforced via server config.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
handler,
|
|
59
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cerefox_list_versions` — list a document's archived version history
|
|
3
|
+
* (newest first). Returns version_id (use with `cerefox_get_document`),
|
|
4
|
+
* version_number, source, chunk_count, total_chars, created_at.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MCPSupabaseClient } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
import { logUsage } from "./_utils.ts";
|
|
10
|
+
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
async function handler(
|
|
13
|
+
supabase: MCPSupabaseClient,
|
|
14
|
+
args: Record<string, unknown>,
|
|
15
|
+
ctx: ToolContext,
|
|
16
|
+
): Promise<string> {
|
|
17
|
+
const document_id = args.document_id as string | undefined;
|
|
18
|
+
if (!document_id) throw new McpInvalidParams("document_id is required");
|
|
19
|
+
|
|
20
|
+
const { data, error } = await supabase.rpc("cerefox_list_document_versions", {
|
|
21
|
+
p_document_id: document_id,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (error) throw new Error(`RPC error: ${error.message}`);
|
|
25
|
+
|
|
26
|
+
const versions = (data ?? []) as Array<{
|
|
27
|
+
version_id: string;
|
|
28
|
+
version_number: number;
|
|
29
|
+
source: string;
|
|
30
|
+
chunk_count: number;
|
|
31
|
+
total_chars: number;
|
|
32
|
+
created_at: string;
|
|
33
|
+
}>;
|
|
34
|
+
|
|
35
|
+
logUsage(supabase, {
|
|
36
|
+
operation: "list_versions",
|
|
37
|
+
accessPath: ctx.accessPath,
|
|
38
|
+
requestor: args.requestor as string | undefined,
|
|
39
|
+
document_id,
|
|
40
|
+
result_count: versions.length,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!versions.length) return "No archived versions found for this document.";
|
|
44
|
+
|
|
45
|
+
const lines = versions.map(
|
|
46
|
+
(v) =>
|
|
47
|
+
`v${v.version_number} | ${v.created_at.slice(0, 10)} | ${v.source} | ${v.chunk_count} chunks / ${v.total_chars.toLocaleString()} chars | id: ${v.version_id}`,
|
|
48
|
+
);
|
|
49
|
+
return `Archived versions (newest first):\n\n${lines.join("\n")}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const listVersionsTool: ToolDefinition = {
|
|
53
|
+
name: "cerefox_list_versions",
|
|
54
|
+
description:
|
|
55
|
+
"List all archived versions of a document, newest first. Returns version_id (use with cerefox_get_document), version_number, source, chunk_count, total_chars, and created_at.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
required: ["document_id"],
|
|
59
|
+
properties: {
|
|
60
|
+
document_id: {
|
|
61
|
+
type: "string",
|
|
62
|
+
description: "UUID of the document whose version history to list",
|
|
63
|
+
},
|
|
64
|
+
requestor: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description:
|
|
67
|
+
'Name of the agent or user making this request. Recorded in the usage log. Defaults to "mcp-agent" if not provided. May be enforced via server config.',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
handler,
|
|
72
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cerefox_metadata_search` — find documents by metadata key-value
|
|
3
|
+
* criteria without a text search term. JSONB containment filter with AND
|
|
4
|
+
* semantics; optional project + time-range filters; optional content
|
|
5
|
+
* inclusion with a byte budget.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MCPSupabaseClient } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
import { applyByteBudget, logUsage, MAX_RESPONSE_BYTES } from "./_utils.ts";
|
|
11
|
+
import { lookupProjectId } from "./_projects.ts";
|
|
12
|
+
import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
async function handler(
|
|
15
|
+
supabase: MCPSupabaseClient,
|
|
16
|
+
args: Record<string, unknown>,
|
|
17
|
+
ctx: ToolContext,
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const metadata_filter = args.metadata_filter as Record<string, string> | undefined;
|
|
20
|
+
const project_name = args.project_name as string | undefined;
|
|
21
|
+
const updated_since = args.updated_since as string | undefined;
|
|
22
|
+
const created_since = args.created_since as string | undefined;
|
|
23
|
+
const limit = (args.limit as number | undefined) ?? 10;
|
|
24
|
+
const include_content = (args.include_content as boolean | undefined) ?? false;
|
|
25
|
+
const requested_max_bytes = args.max_bytes as number | undefined;
|
|
26
|
+
|
|
27
|
+
if (!metadata_filter || typeof metadata_filter !== "object" || Array.isArray(metadata_filter)) {
|
|
28
|
+
throw new McpInvalidParams("metadata_filter is required and must be a JSON object");
|
|
29
|
+
}
|
|
30
|
+
if (Object.keys(metadata_filter).length === 0) {
|
|
31
|
+
throw new McpInvalidParams("metadata_filter must contain at least one key-value pair");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Resolve project name to UUID if provided
|
|
35
|
+
let projectId: string | null = null;
|
|
36
|
+
if (project_name) {
|
|
37
|
+
projectId = await lookupProjectId(supabase, project_name);
|
|
38
|
+
if (!projectId) throw new Error(`Project not found: ${project_name}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Enforce byte ceiling for content mode
|
|
42
|
+
const max_bytes = include_content
|
|
43
|
+
? Math.min(requested_max_bytes ?? MAX_RESPONSE_BYTES, MAX_RESPONSE_BYTES)
|
|
44
|
+
: null;
|
|
45
|
+
|
|
46
|
+
const params: Record<string, unknown> = {
|
|
47
|
+
p_metadata_filter: metadata_filter,
|
|
48
|
+
p_project_id: projectId,
|
|
49
|
+
p_updated_since: updated_since ?? null,
|
|
50
|
+
p_created_since: created_since ?? null,
|
|
51
|
+
p_limit: limit,
|
|
52
|
+
p_include_content: include_content,
|
|
53
|
+
};
|
|
54
|
+
if (max_bytes !== null) params.p_max_bytes = max_bytes;
|
|
55
|
+
|
|
56
|
+
const { data, error } = await supabase.rpc("cerefox_metadata_search", params);
|
|
57
|
+
|
|
58
|
+
if (error) throw new Error(`RPC error: ${error.message}`);
|
|
59
|
+
|
|
60
|
+
const rows = (data ?? []) as Array<{
|
|
61
|
+
document_id: string;
|
|
62
|
+
title: string;
|
|
63
|
+
doc_metadata: Record<string, unknown>;
|
|
64
|
+
review_status: string;
|
|
65
|
+
source: string | null;
|
|
66
|
+
created_at: string;
|
|
67
|
+
updated_at: string;
|
|
68
|
+
total_chars: number;
|
|
69
|
+
chunk_count: number;
|
|
70
|
+
project_ids: string[];
|
|
71
|
+
project_names: string[];
|
|
72
|
+
version_count: number;
|
|
73
|
+
content: string | null;
|
|
74
|
+
}>;
|
|
75
|
+
|
|
76
|
+
logUsage(supabase, {
|
|
77
|
+
operation: "metadata_search",
|
|
78
|
+
accessPath: ctx.accessPath,
|
|
79
|
+
requestor: args.requestor as string | undefined,
|
|
80
|
+
query_text: JSON.stringify(metadata_filter),
|
|
81
|
+
project_id: projectId,
|
|
82
|
+
result_count: rows.length,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (rows.length === 0) return "No documents match the metadata filter.";
|
|
86
|
+
|
|
87
|
+
// Note: when include_content is true the RPC already respects p_max_bytes
|
|
88
|
+
// server-side. The applyByteBudget helper is retained here only for
|
|
89
|
+
// parity with the EF implementation and as a defensive trim — see the
|
|
90
|
+
// EF original for the same shape.
|
|
91
|
+
void applyByteBudget; // referenced for symmetry; kept for v0.5 work
|
|
92
|
+
|
|
93
|
+
const parts: string[] = rows.map((row) => {
|
|
94
|
+
const projects = row.project_names?.length
|
|
95
|
+
? ` | projects: ${row.project_names.join(", ")}`
|
|
96
|
+
: "";
|
|
97
|
+
const meta = Object.entries(row.doc_metadata ?? {})
|
|
98
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
99
|
+
.join(", ");
|
|
100
|
+
const header =
|
|
101
|
+
`## ${row.title} [id: ${row.document_id}]\n` +
|
|
102
|
+
`${meta}${projects} | ${row.total_chars} chars | ${row.review_status} | updated ${row.updated_at?.slice(0, 10) ?? "?"}`;
|
|
103
|
+
|
|
104
|
+
if (include_content && row.content) {
|
|
105
|
+
return `${header}\n\n${row.content}`;
|
|
106
|
+
}
|
|
107
|
+
return header;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return parts.join("\n\n---\n\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const metadataSearchTool: ToolDefinition = {
|
|
114
|
+
name: "cerefox_metadata_search",
|
|
115
|
+
description:
|
|
116
|
+
"Find documents by metadata key-value criteria without a text search term. Use to discover documents tagged with specific attributes, browse by taxonomy, or retrieve messages/tasks by type and status.",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: "object",
|
|
119
|
+
required: ["metadata_filter"],
|
|
120
|
+
properties: {
|
|
121
|
+
metadata_filter: {
|
|
122
|
+
type: "object",
|
|
123
|
+
description:
|
|
124
|
+
'Key-value pairs; ALL must match (AND semantics). Example: {"type": "decision", "status": "active"}. Call cerefox_list_metadata_keys first to discover available keys.',
|
|
125
|
+
additionalProperties: { type: "string" },
|
|
126
|
+
},
|
|
127
|
+
project_name: { type: "string", description: "Restrict to a project by name (optional)" },
|
|
128
|
+
updated_since: {
|
|
129
|
+
type: "string",
|
|
130
|
+
description: "ISO-8601 timestamp; only docs updated on/after (optional)",
|
|
131
|
+
},
|
|
132
|
+
created_since: {
|
|
133
|
+
type: "string",
|
|
134
|
+
description: "ISO-8601 timestamp; only docs created on/after (optional)",
|
|
135
|
+
},
|
|
136
|
+
limit: { type: "integer", description: "Max results (default 10)" },
|
|
137
|
+
include_content: {
|
|
138
|
+
type: "boolean",
|
|
139
|
+
description: "Include full document text (default false)",
|
|
140
|
+
},
|
|
141
|
+
max_bytes: {
|
|
142
|
+
type: "integer",
|
|
143
|
+
description:
|
|
144
|
+
"Soft cap on total response bytes when include_content is true. Defaults to server maximum (200000).",
|
|
145
|
+
},
|
|
146
|
+
requestor: {
|
|
147
|
+
type: "string",
|
|
148
|
+
description:
|
|
149
|
+
'Name of the agent or user making this request. Recorded in the usage log. Defaults to "mcp-agent" if not provided. May be enforced via server config.',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
handler,
|
|
154
|
+
};
|