@iwo-szapar/data-mcp 0.3.3 → 0.5.0
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/adapter/factory.js +4 -0
- package/dist/adapter/markdown.d.ts +29 -0
- package/dist/adapter/markdown.js +406 -0
- package/dist/adapter/types.d.ts +1 -1
- package/dist/config.js +13 -3
- package/dist/server.js +2 -2
- package/dist/tools/memory/link-create.js +2 -2
- package/dist/tools/memory/link-delete.js +1 -1
- package/dist/tools/memory/link-related.js +1 -1
- package/dist/tools/memory/link-suggest.js +1 -1
- package/dist/tools/register.js +3 -1
- package/dist/tools/setup/setup-bootstrap.js +71 -0
- package/dist/tools/setup/setup-migrate.js +20 -16
- package/dist/tools/setup/setup-status.js +11 -6
- package/migrations/pocketbase/001_core_schema.js +24 -28
- package/migrations/pocketbase/002_goals_tasks.js +15 -18
- package/migrations/pocketbase/003_contacts.js +9 -12
- package/migrations/pocketbase/004_entity_aliases.js +9 -12
- package/migrations/pocketbase/005_prospects.js +11 -14
- package/migrations/pocketbase/006_business.js +29 -33
- package/migrations/pocketbase/007_newsletter_affiliates.js +16 -19
- package/migrations/pocketbase/008_knowledge_links.js +34 -37
- package/migrations/supabase/009_align_to_production.sql +14 -11
- package/package.json +1 -1
- package/README.md +0 -286
package/dist/adapter/factory.js
CHANGED
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { PocketBaseAdapter } from './pocketbase.js';
|
|
7
7
|
import { SupabaseAdapter } from './supabase.js';
|
|
8
|
+
import { MarkdownAdapter } from './markdown.js';
|
|
8
9
|
import { SchemaMap, SchemaMapProxy } from './schema-map.js';
|
|
9
10
|
export function createAdapter(config) {
|
|
10
11
|
let adapter;
|
|
11
12
|
if (config.backend === 'pocketbase') {
|
|
12
13
|
adapter = new PocketBaseAdapter(config.pocketbaseUrl, config.pocketbaseAdminEmail, config.pocketbaseAdminPassword);
|
|
13
14
|
}
|
|
15
|
+
else if (config.backend === 'markdown') {
|
|
16
|
+
adapter = new MarkdownAdapter(config.markdownRoot);
|
|
17
|
+
}
|
|
14
18
|
else {
|
|
15
19
|
adapter = new SupabaseAdapter(config.supabaseUrl, config.supabaseKey);
|
|
16
20
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DataAdapter, ListResult, PageOptions, SortClause, Filter } from './types.js';
|
|
2
|
+
export declare class MarkdownAdapter implements DataAdapter {
|
|
3
|
+
private root;
|
|
4
|
+
readonly backend: 'markdown';
|
|
5
|
+
constructor(root: string);
|
|
6
|
+
private collectionDir;
|
|
7
|
+
private recordPath;
|
|
8
|
+
private ensureCollection;
|
|
9
|
+
private readRecord;
|
|
10
|
+
private writeRecord;
|
|
11
|
+
create<T extends Record<string, unknown>>(collection: string, data: Record<string, unknown>): Promise<T>;
|
|
12
|
+
getOne<T extends Record<string, unknown>>(collection: string, id: string): Promise<T>;
|
|
13
|
+
list<T extends Record<string, unknown>>(collection: string, options?: {
|
|
14
|
+
filter?: Filter;
|
|
15
|
+
sort?: SortClause[];
|
|
16
|
+
page?: PageOptions;
|
|
17
|
+
}): Promise<ListResult<T>>;
|
|
18
|
+
textSearch<T extends Record<string, unknown>>(collection: string, query: string, options?: {
|
|
19
|
+
fields?: string[];
|
|
20
|
+
filter?: Filter;
|
|
21
|
+
limit?: number;
|
|
22
|
+
}): Promise<T[]>;
|
|
23
|
+
update<T extends Record<string, unknown>>(collection: string, id: string, data: Record<string, unknown>): Promise<T>;
|
|
24
|
+
delete(collection: string, id: string): Promise<void>;
|
|
25
|
+
upsert<T extends Record<string, unknown>>(collection: string, data: Record<string, unknown>, uniqueFields: string[]): Promise<T>;
|
|
26
|
+
count(collection: string, filter?: Filter): Promise<number>;
|
|
27
|
+
collectionExists(collection: string): Promise<boolean>;
|
|
28
|
+
listCollections(): Promise<string[]>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown adapter — stores records as YAML-frontmatter markdown files.
|
|
3
|
+
*
|
|
4
|
+
* Per-collection layout:
|
|
5
|
+
* <root>/<collection>/<id>.md
|
|
6
|
+
*
|
|
7
|
+
* Soft-deletes move files to <root>/_archive/<collection>/<id>.md.
|
|
8
|
+
*
|
|
9
|
+
* No external YAML library — we ship a minimal frontmatter parser inline
|
|
10
|
+
* to avoid a new dependency. The frontmatter format we support is a strict
|
|
11
|
+
* subset of YAML (scalar key:value, string arrays, simple lists). Anything
|
|
12
|
+
* the brain writes via this adapter is round-trippable; arbitrary
|
|
13
|
+
* hand-edited YAML is best-effort.
|
|
14
|
+
*
|
|
15
|
+
* Spec: docs/prds/active/PRD-SB3-DUAL-MODE-A3-DATA-MCP-MARKDOWN.md
|
|
16
|
+
* (factory-dev repo)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { promises as fs } from 'node:fs';
|
|
20
|
+
import { join, basename } from 'node:path';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { AdapterError } from '../errors/adapter-error.js';
|
|
23
|
+
|
|
24
|
+
export class MarkdownAdapter {
|
|
25
|
+
root;
|
|
26
|
+
backend = 'markdown';
|
|
27
|
+
|
|
28
|
+
constructor(root) {
|
|
29
|
+
if (!root) {
|
|
30
|
+
throw new AdapterError('config', 'MarkdownAdapter requires a non-empty root path');
|
|
31
|
+
}
|
|
32
|
+
this.root = root;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
collectionDir(collection) {
|
|
36
|
+
validateName(collection, 'collection');
|
|
37
|
+
return join(this.root, collection);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
recordPath(collection, id) {
|
|
41
|
+
validateName(id, 'id');
|
|
42
|
+
return join(this.collectionDir(collection), `${id}.md`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async ensureCollection(collection) {
|
|
46
|
+
await fs.mkdir(this.collectionDir(collection), { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async readRecord(collection, id) {
|
|
50
|
+
let raw;
|
|
51
|
+
try {
|
|
52
|
+
raw = await fs.readFile(this.recordPath(collection, id), 'utf8');
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err && err.code === 'ENOENT') {
|
|
56
|
+
throw new AdapterError('not_found', `record ${collection}/${id} not found`);
|
|
57
|
+
}
|
|
58
|
+
throw new AdapterError('io', `failed to read ${collection}/${id}: ${err?.message || err}`);
|
|
59
|
+
}
|
|
60
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
61
|
+
return { ...frontmatter, id, body };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async writeRecord(collection, id, data) {
|
|
65
|
+
await this.ensureCollection(collection);
|
|
66
|
+
const { body = '', id: _ignored, ...frontmatter } = data;
|
|
67
|
+
const payload = stringifyFrontmatter({ ...frontmatter, id }, String(body ?? ''));
|
|
68
|
+
try {
|
|
69
|
+
await fs.writeFile(this.recordPath(collection, id), payload, 'utf8');
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
throw new AdapterError('io', `failed to write ${collection}/${id}: ${err?.message || err}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async create(collection, data) {
|
|
77
|
+
const id = (data.id && String(data.id)) || randomUUID();
|
|
78
|
+
validateName(id, 'id');
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const record = {
|
|
81
|
+
...data,
|
|
82
|
+
id,
|
|
83
|
+
created: data.created || now,
|
|
84
|
+
updated: now,
|
|
85
|
+
};
|
|
86
|
+
await this.writeRecord(collection, id, record);
|
|
87
|
+
return record;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getOne(collection, id) {
|
|
91
|
+
return this.readRecord(collection, id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async list(collection, options = {}) {
|
|
95
|
+
const dir = this.collectionDir(collection);
|
|
96
|
+
const limit = options.page?.limit ?? 50;
|
|
97
|
+
const offset = options.page?.offset ?? 0;
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = (await fs.readdir(dir)).filter((f) => f.endsWith('.md'));
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err && err.code === 'ENOENT') {
|
|
104
|
+
return { items: [], totalItems: 0, page: 0, perPage: limit };
|
|
105
|
+
}
|
|
106
|
+
throw new AdapterError('io', `failed to list ${collection}: ${err?.message || err}`);
|
|
107
|
+
}
|
|
108
|
+
const all = [];
|
|
109
|
+
for (const f of entries) {
|
|
110
|
+
const id = basename(f, '.md');
|
|
111
|
+
try {
|
|
112
|
+
all.push(await this.readRecord(collection, id));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Skip malformed file, continue
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const filtered = options.filter ? applyFilter(all, options.filter) : all;
|
|
119
|
+
const sorted = options.sort ? applySort(filtered, options.sort) : filtered;
|
|
120
|
+
const items = sorted.slice(offset, offset + limit);
|
|
121
|
+
return {
|
|
122
|
+
items,
|
|
123
|
+
totalItems: sorted.length,
|
|
124
|
+
page: Math.floor(offset / limit),
|
|
125
|
+
perPage: limit,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async textSearch(collection, query, options = {}) {
|
|
130
|
+
const all = await this.list(collection, { filter: options.filter });
|
|
131
|
+
const q = String(query).toLowerCase();
|
|
132
|
+
if (q.length === 0) return [];
|
|
133
|
+
const fields = options.fields;
|
|
134
|
+
const ranked = all.items
|
|
135
|
+
.map((item) => ({ item, score: scoreItem(item, q, fields) }))
|
|
136
|
+
.filter((r) => r.score > 0)
|
|
137
|
+
.sort((a, b) => b.score - a.score)
|
|
138
|
+
.slice(0, options.limit ?? 50)
|
|
139
|
+
.map((r) => r.item);
|
|
140
|
+
return ranked;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async update(collection, id, data) {
|
|
144
|
+
const existing = await this.readRecord(collection, id);
|
|
145
|
+
const merged = { ...existing, ...data, id, updated: new Date().toISOString() };
|
|
146
|
+
await this.writeRecord(collection, id, merged);
|
|
147
|
+
return merged;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async delete(collection, id) {
|
|
151
|
+
const src = this.recordPath(collection, id);
|
|
152
|
+
const archiveDir = join(this.root, '_archive', collection);
|
|
153
|
+
try {
|
|
154
|
+
await fs.mkdir(archiveDir, { recursive: true });
|
|
155
|
+
await fs.rename(src, join(archiveDir, `${id}.md`));
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (err && err.code === 'ENOENT') {
|
|
159
|
+
throw new AdapterError('not_found', `record ${collection}/${id} not found`);
|
|
160
|
+
}
|
|
161
|
+
throw new AdapterError('io', `failed to delete ${collection}/${id}: ${err?.message || err}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async upsert(collection, data, uniqueFields) {
|
|
166
|
+
if (!uniqueFields || uniqueFields.length === 0) {
|
|
167
|
+
throw new AdapterError('validation', 'upsert requires at least one uniqueField');
|
|
168
|
+
}
|
|
169
|
+
const filter = [
|
|
170
|
+
uniqueFields.map((field) => ({ field, op: 'eq', value: data[field] ?? null })),
|
|
171
|
+
];
|
|
172
|
+
const existing = await this.list(collection, { filter, page: { limit: 1 } });
|
|
173
|
+
if (existing.items.length > 0) {
|
|
174
|
+
return this.update(collection, existing.items[0].id, data);
|
|
175
|
+
}
|
|
176
|
+
return this.create(collection, data);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async count(collection, filter) {
|
|
180
|
+
// Lightweight count — reuses list() so it respects the same filter semantics.
|
|
181
|
+
const all = await this.list(collection, { filter });
|
|
182
|
+
return all.totalItems;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async collectionExists(collection) {
|
|
186
|
+
try {
|
|
187
|
+
const s = await fs.stat(this.collectionDir(collection));
|
|
188
|
+
return s.isDirectory();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async listCollections() {
|
|
196
|
+
try {
|
|
197
|
+
const entries = await fs.readdir(this.root, { withFileTypes: true });
|
|
198
|
+
return entries
|
|
199
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('_'))
|
|
200
|
+
.map((e) => e.name);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
if (err && err.code === 'ENOENT') return [];
|
|
204
|
+
throw new AdapterError('io', `failed to list collections: ${err?.message || err}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
// Path-traversal guard: ids and collection names cannot contain separators,
|
|
212
|
+
// leading dots, or null bytes. Match PocketBase's record-id constraints
|
|
213
|
+
// loosely; we just need to refuse anything that could escape <root>.
|
|
214
|
+
function validateName(value, label) {
|
|
215
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
216
|
+
throw new AdapterError('validation', `${label} must be a non-empty string`);
|
|
217
|
+
}
|
|
218
|
+
if (value.includes('/') || value.includes('\\') || value.includes('\0') || value.startsWith('.')) {
|
|
219
|
+
throw new AdapterError('validation', `${label} contains forbidden characters: ${value}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function scoreItem(item, q, fields) {
|
|
224
|
+
// Default ranking: tag-match (3pts) > title-match (2pts) > body-match (1pt).
|
|
225
|
+
// If `fields` is specified, only those are searched (with title>body>everything-else fallback).
|
|
226
|
+
if (fields && fields.length > 0) {
|
|
227
|
+
for (const f of fields) {
|
|
228
|
+
const v = item[f];
|
|
229
|
+
if (typeof v === 'string' && v.toLowerCase().includes(q)) return f === 'tags' ? 3 : f === 'title' ? 2 : 1;
|
|
230
|
+
if (Array.isArray(v) && v.some((x) => String(x).toLowerCase().includes(q))) return 3;
|
|
231
|
+
}
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
const tags = Array.isArray(item.tags) ? item.tags.map((t) => String(t).toLowerCase()) : [];
|
|
235
|
+
if (tags.some((t) => t.includes(q))) return 3;
|
|
236
|
+
if (typeof item.title === 'string' && item.title.toLowerCase().includes(q)) return 2;
|
|
237
|
+
if (typeof item.body === 'string' && item.body.toLowerCase().includes(q)) return 1;
|
|
238
|
+
if (typeof item.content === 'string' && item.content.toLowerCase().includes(q)) return 1;
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function applyFilter(items, filter) {
|
|
243
|
+
// Filter is OR-of-AND groups (matching the existing Filter type).
|
|
244
|
+
return items.filter((item) =>
|
|
245
|
+
filter.some((andGroup) => andGroup.every((clause) => matchClause(item, clause))),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function matchClause(item, clause) {
|
|
250
|
+
const v = item[clause.field];
|
|
251
|
+
const target = clause.value;
|
|
252
|
+
switch (clause.op) {
|
|
253
|
+
case 'eq': return v === target;
|
|
254
|
+
case 'neq': return v !== target;
|
|
255
|
+
case 'gt': return typeof v === 'number' && typeof target === 'number' && v > target;
|
|
256
|
+
case 'gte': return typeof v === 'number' && typeof target === 'number' && v >= target;
|
|
257
|
+
case 'lt': return typeof v === 'number' && typeof target === 'number' && v < target;
|
|
258
|
+
case 'lte': return typeof v === 'number' && typeof target === 'number' && v <= target;
|
|
259
|
+
case 'like':
|
|
260
|
+
return typeof v === 'string' && typeof target === 'string'
|
|
261
|
+
&& v.toLowerCase().includes(target.toLowerCase());
|
|
262
|
+
case 'in':
|
|
263
|
+
return Array.isArray(target) && target.includes(v);
|
|
264
|
+
case 'contains':
|
|
265
|
+
return Array.isArray(v) && v.includes(target);
|
|
266
|
+
default: return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applySort(items, sort) {
|
|
271
|
+
return [...items].sort((a, b) => {
|
|
272
|
+
for (const s of sort) {
|
|
273
|
+
const av = a[s.field];
|
|
274
|
+
const bv = b[s.field];
|
|
275
|
+
if (av === bv) continue;
|
|
276
|
+
if (av == null) return s.direction === 'desc' ? 1 : -1;
|
|
277
|
+
if (bv == null) return s.direction === 'desc' ? -1 : 1;
|
|
278
|
+
const cmp = av < bv ? -1 : 1;
|
|
279
|
+
return s.direction === 'desc' ? -cmp : cmp;
|
|
280
|
+
}
|
|
281
|
+
return 0;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── minimal YAML frontmatter parser/serializer ────────────────────────────
|
|
286
|
+
|
|
287
|
+
function parseFrontmatter(raw) {
|
|
288
|
+
// Format: `---\n<yaml>\n---\n<body>` (LF or CRLF tolerated).
|
|
289
|
+
// If no opening `---`, treat entire content as body with no frontmatter.
|
|
290
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
291
|
+
if (!normalized.startsWith('---\n') && normalized !== '---' && !normalized.startsWith('---\r')) {
|
|
292
|
+
return { frontmatter: {}, body: normalized.trim() };
|
|
293
|
+
}
|
|
294
|
+
const rest = normalized.slice(4);
|
|
295
|
+
const endIdx = rest.indexOf('\n---');
|
|
296
|
+
if (endIdx === -1) {
|
|
297
|
+
return { frontmatter: {}, body: normalized.trim() };
|
|
298
|
+
}
|
|
299
|
+
const yamlBlock = rest.slice(0, endIdx);
|
|
300
|
+
let body = rest.slice(endIdx + 4);
|
|
301
|
+
if (body.startsWith('\n')) body = body.slice(1);
|
|
302
|
+
return { frontmatter: parseSimpleYaml(yamlBlock), body: body.trim() };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parseSimpleYaml(block) {
|
|
306
|
+
const out = {};
|
|
307
|
+
const lines = block.split('\n');
|
|
308
|
+
let i = 0;
|
|
309
|
+
while (i < lines.length) {
|
|
310
|
+
const line = lines[i];
|
|
311
|
+
const trimmed = line.replace(/\s+$/, '');
|
|
312
|
+
if (!trimmed || trimmed.startsWith('#')) { i++; continue; }
|
|
313
|
+
const colonIdx = trimmed.indexOf(':');
|
|
314
|
+
if (colonIdx === -1) { i++; continue; }
|
|
315
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
316
|
+
const rawValue = trimmed.slice(colonIdx + 1).trim();
|
|
317
|
+
if (rawValue === '') {
|
|
318
|
+
// List (next lines starting with `- `) or empty value
|
|
319
|
+
const list = [];
|
|
320
|
+
let j = i + 1;
|
|
321
|
+
while (j < lines.length && /^\s*-\s+/.test(lines[j])) {
|
|
322
|
+
list.push(parseScalar(lines[j].replace(/^\s*-\s+/, '').trim()));
|
|
323
|
+
j++;
|
|
324
|
+
}
|
|
325
|
+
if (list.length > 0) {
|
|
326
|
+
out[key] = list;
|
|
327
|
+
i = j;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
out[key] = null;
|
|
331
|
+
i++;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (rawValue.startsWith('[') && rawValue.endsWith(']')) {
|
|
335
|
+
// Inline array: [a, b, c]
|
|
336
|
+
const inner = rawValue.slice(1, -1).trim();
|
|
337
|
+
out[key] = inner === '' ? [] : inner.split(',').map((s) => parseScalar(s.trim()));
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
out[key] = parseScalar(rawValue);
|
|
341
|
+
}
|
|
342
|
+
i++;
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parseScalar(v) {
|
|
348
|
+
if (v === '' || v === '~' || v === 'null') return null;
|
|
349
|
+
if (v === 'true') return true;
|
|
350
|
+
if (v === 'false') return false;
|
|
351
|
+
// Quoted string
|
|
352
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
353
|
+
return v.slice(1, -1);
|
|
354
|
+
}
|
|
355
|
+
// Number
|
|
356
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) {
|
|
357
|
+
const n = Number(v);
|
|
358
|
+
if (!Number.isNaN(n)) return n;
|
|
359
|
+
}
|
|
360
|
+
return v;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function stringifyFrontmatter(frontmatter, body) {
|
|
364
|
+
const lines = ['---'];
|
|
365
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
366
|
+
if (value === undefined) continue;
|
|
367
|
+
if (value === null) {
|
|
368
|
+
lines.push(`${key}: null`);
|
|
369
|
+
}
|
|
370
|
+
else if (Array.isArray(value)) {
|
|
371
|
+
if (value.length === 0) {
|
|
372
|
+
lines.push(`${key}: []`);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
lines.push(`${key}:`);
|
|
376
|
+
for (const item of value) {
|
|
377
|
+
lines.push(` - ${stringifyScalar(item)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else if (typeof value === 'object') {
|
|
382
|
+
// Nested object — serialize as JSON to preserve roundtrip.
|
|
383
|
+
// The brain's frontmatter rarely uses nested objects; this is a
|
|
384
|
+
// pragmatic fallback that the parser will read back as a string.
|
|
385
|
+
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
lines.push(`${key}: ${stringifyScalar(value)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
lines.push('---');
|
|
392
|
+
lines.push('');
|
|
393
|
+
return lines.join('\n') + body + (body.endsWith('\n') ? '' : '\n');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function stringifyScalar(v) {
|
|
397
|
+
if (v === null || v === undefined) return 'null';
|
|
398
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
399
|
+
if (typeof v === 'number') return String(v);
|
|
400
|
+
const s = String(v);
|
|
401
|
+
// Quote if contains special chars
|
|
402
|
+
if (/[:#\n\[\]{}"',&*!|>%@`]/.test(s) || s.trim() !== s || s === '') {
|
|
403
|
+
return JSON.stringify(s);
|
|
404
|
+
}
|
|
405
|
+
return s;
|
|
406
|
+
}
|
package/dist/adapter/types.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface ListResult<T> {
|
|
|
34
34
|
}
|
|
35
35
|
export interface DataAdapter {
|
|
36
36
|
/** Backend identifier */
|
|
37
|
-
readonly backend: 'pocketbase' | 'supabase';
|
|
37
|
+
readonly backend: 'pocketbase' | 'supabase' | 'markdown';
|
|
38
38
|
/** Create a record in a collection */
|
|
39
39
|
create<T extends Record<string, unknown>>(collection: string, data: Record<string, unknown>): Promise<T>;
|
|
40
40
|
/** Get a single record by ID */
|
package/dist/config.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Configuration parsing from environment variables.
|
|
3
3
|
*
|
|
4
|
-
* SB_BACKEND: 'pocketbase' | 'supabase' (required)
|
|
4
|
+
* SB_BACKEND: 'pocketbase' | 'supabase' | 'markdown' (required)
|
|
5
5
|
* SB_POCKETBASE_URL: PocketBase server URL (required if backend=pocketbase)
|
|
6
6
|
* SB_POCKETBASE_ADMIN_EMAIL: PocketBase admin email (required if backend=pocketbase)
|
|
7
7
|
* SB_POCKETBASE_ADMIN_PASSWORD: PocketBase admin password (required if backend=pocketbase)
|
|
8
8
|
* SB_SUPABASE_URL: Supabase project URL (required if backend=supabase)
|
|
9
9
|
* SB_SUPABASE_KEY: Supabase service role key (required if backend=supabase)
|
|
10
|
+
* SB_MARKDOWN_ROOT: filesystem path to the memory/ folder (required if backend=markdown)
|
|
10
11
|
* SB_SCHEMA_MAP: JSON string mapping logical names to actual table names (optional)
|
|
11
12
|
* SB_RESEND_API_KEY: Resend API key for email sending (optional)
|
|
12
13
|
*/
|
|
14
|
+
const ALLOWED_BACKENDS = ['pocketbase', 'supabase', 'markdown'];
|
|
13
15
|
export function parseConfig() {
|
|
14
16
|
const backend = requireEnv('SB_BACKEND');
|
|
15
|
-
if (backend
|
|
16
|
-
throw new Error(`SB_BACKEND must be
|
|
17
|
+
if (!ALLOWED_BACKENDS.includes(backend)) {
|
|
18
|
+
throw new Error(`SB_BACKEND must be one of ${ALLOWED_BACKENDS.join('|')}, got '${backend}'`);
|
|
17
19
|
}
|
|
18
20
|
const schemaMap = parseSchemaMap(process.env.SB_SCHEMA_MAP);
|
|
19
21
|
const resendApiKey = process.env.SB_RESEND_API_KEY || undefined;
|
|
@@ -27,6 +29,14 @@ export function parseConfig() {
|
|
|
27
29
|
resendApiKey,
|
|
28
30
|
};
|
|
29
31
|
}
|
|
32
|
+
if (backend === 'markdown') {
|
|
33
|
+
return {
|
|
34
|
+
backend,
|
|
35
|
+
markdownRoot: requireEnv('SB_MARKDOWN_ROOT'),
|
|
36
|
+
schemaMap,
|
|
37
|
+
resendApiKey,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
30
40
|
return {
|
|
31
41
|
backend,
|
|
32
42
|
supabaseUrl: requireEnv('SB_SUPABASE_URL'),
|
package/dist/server.js
CHANGED
|
@@ -29,8 +29,8 @@ const SERVER_INSTRUCTIONS = `You are the user's AI Second Brain data layer. This
|
|
|
29
29
|
- Use setup_status to check database readiness`;
|
|
30
30
|
export function createServer(adapter) {
|
|
31
31
|
const server = new McpServer({
|
|
32
|
-
name: '@
|
|
33
|
-
version: '0.
|
|
32
|
+
name: '@second-brain/data-mcp',
|
|
33
|
+
version: '0.1.0',
|
|
34
34
|
}, {
|
|
35
35
|
instructions: SERVER_INSTRUCTIONS,
|
|
36
36
|
});
|
|
@@ -12,9 +12,9 @@ export function registerLinkCreate(server, adapter) {
|
|
|
12
12
|
'Links express how knowledge items relate: supports, contradicts, derived_from, etc. ' +
|
|
13
13
|
'Deduplicates by (source, target, relation_type).', {
|
|
14
14
|
source_type: z.enum(ENTITY_TYPES).describe('Type of the source entity'),
|
|
15
|
-
source_id: z.string().
|
|
15
|
+
source_id: z.string().uuid().describe('UUID of the source entity'),
|
|
16
16
|
target_type: z.enum(ENTITY_TYPES).describe('Type of the target entity'),
|
|
17
|
-
target_id: z.string().
|
|
17
|
+
target_id: z.string().uuid().describe('UUID of the target entity'),
|
|
18
18
|
relation_type: z.enum(RELATION_TYPES).describe('Type of relationship'),
|
|
19
19
|
confidence: z.number().min(0).max(1).optional().default(0.8).describe('Confidence in this relationship (0-1)'),
|
|
20
20
|
notes: z.string().max(500).optional().describe('Optional context for why this link exists'),
|
|
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { makeToolResponse, handleAdapterError, withGracefulDegradation } from '../shared.js';
|
|
8
8
|
export function registerLinkDelete(server, adapter) {
|
|
9
9
|
server.tool('link_delete', 'Delete a knowledge link by its ID.', {
|
|
10
|
-
link_id: z.string().
|
|
10
|
+
link_id: z.string().uuid().describe('UUID of the link to delete'),
|
|
11
11
|
}, withGracefulDegradation('knowledge_links', adapter, async (params) => {
|
|
12
12
|
try {
|
|
13
13
|
try {
|
|
@@ -9,7 +9,7 @@ const ENTITY_TYPES = ['knowledge', 'decision', 'session', 'blog_post', 'prospect
|
|
|
9
9
|
export function registerLinkRelated(server, adapter) {
|
|
10
10
|
server.tool('link_related', 'Get all links for an entity. Shows outgoing and incoming relationships with resolved titles.', {
|
|
11
11
|
entity_type: z.enum(ENTITY_TYPES).describe('Type of the entity'),
|
|
12
|
-
entity_id: z.string().
|
|
12
|
+
entity_id: z.string().uuid().describe('UUID of the entity'),
|
|
13
13
|
direction: z.enum(['both', 'outgoing', 'incoming']).optional().default('both').describe('Filter direction'),
|
|
14
14
|
relation_type: z.enum(['supports', 'contradicts', 'derived_from', 'example_of', 'supersedes', 'part_of', 'prerequisite'])
|
|
15
15
|
.optional().describe('Filter by relation type'),
|
|
@@ -8,7 +8,7 @@ import { makeToolResponse, handleAdapterError, withGracefulDegradation } from '.
|
|
|
8
8
|
export function registerLinkSuggest(server, adapter) {
|
|
9
9
|
server.tool('link_suggest', 'Find knowledge items similar to a given item and suggest links. ' +
|
|
10
10
|
'Uses text search to find related items. Returns matches with suggested relation types.', {
|
|
11
|
-
item_id: z.string().
|
|
11
|
+
item_id: z.string().uuid().describe('UUID of the knowledge item to find suggestions for'),
|
|
12
12
|
limit: z.number().min(1).max(20).optional().default(5).describe('Max suggestions'),
|
|
13
13
|
}, withGracefulDegradation('knowledge', adapter, async (params) => {
|
|
14
14
|
try {
|
package/dist/tools/register.js
CHANGED
|
@@ -28,9 +28,10 @@ import { registerLinkCreate } from './memory/link-create.js';
|
|
|
28
28
|
import { registerLinkDelete } from './memory/link-delete.js';
|
|
29
29
|
import { registerLinkRelated } from './memory/link-related.js';
|
|
30
30
|
import { registerLinkSuggest } from './memory/link-suggest.js';
|
|
31
|
-
// Setup tools (
|
|
31
|
+
// Setup tools (4)
|
|
32
32
|
import { registerSetupStatus } from './setup/setup-status.js';
|
|
33
33
|
import { registerSetupMigrate } from './setup/setup-migrate.js';
|
|
34
|
+
import { registerSetupBootstrap } from './setup/setup-bootstrap.js';
|
|
34
35
|
import { registerSetupSeed } from './setup/setup-seed.js';
|
|
35
36
|
// Business tools (11)
|
|
36
37
|
import { registerProspectCreate } from './business/prospect-create.js';
|
|
@@ -75,6 +76,7 @@ export function registerAllTools(server, adapter) {
|
|
|
75
76
|
// Setup tools
|
|
76
77
|
registerSetupStatus(server, adapter);
|
|
77
78
|
registerSetupMigrate(server, adapter);
|
|
79
|
+
registerSetupBootstrap(server, adapter);
|
|
78
80
|
registerSetupSeed(server, adapter);
|
|
79
81
|
// Business tools
|
|
80
82
|
registerProspectCreate(server, adapter);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool: setup_bootstrap
|
|
3
|
+
*
|
|
4
|
+
* RC-2 fix (incident 2026-05-11): Generates a single SQL block containing
|
|
5
|
+
* every bundled Supabase migration concatenated in numeric order, followed
|
|
6
|
+
* by NOTIFY pgrst, 'reload schema'. Customer pastes the block into the
|
|
7
|
+
* Supabase SQL Editor and runs it once. Stops short of running the SQL
|
|
8
|
+
* itself because Supabase service-role over PostgREST does not expose
|
|
9
|
+
* arbitrary SQL execution — that needs either a custom exec_sql function
|
|
10
|
+
* (Approach B, opt-in by customer) or the Management API with a personal
|
|
11
|
+
* access token (Approach C, adds auth surface). This is Approach A: lowest
|
|
12
|
+
* friction, no new auth, works on Cloud + self-hosted.
|
|
13
|
+
*
|
|
14
|
+
* After the customer runs the block, setup_migrate will report all
|
|
15
|
+
* collections present.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { makeToolResponse, makeErrorResponse } from '../shared.js';
|
|
21
|
+
const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'migrations', 'supabase');
|
|
22
|
+
export function registerSetupBootstrap(server, adapter) {
|
|
23
|
+
server.tool('setup_bootstrap', 'Generate a paste-ready SQL block (all bundled Supabase migrations + PostgREST reload). Open the returned sql_editor_url, paste sql, run. Idempotent. Supabase backend only — PocketBase customers use setup_migrate.', {}, async () => {
|
|
24
|
+
if (adapter.backend !== 'supabase') {
|
|
25
|
+
return makeErrorResponse('setup_bootstrap is Supabase-only. PocketBase customers use setup_migrate.');
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
|
|
29
|
+
if (files.length === 0) {
|
|
30
|
+
return makeErrorResponse(`No bundled migration files found at ${MIGRATIONS_DIR}. Package install may be corrupted.`);
|
|
31
|
+
}
|
|
32
|
+
const blocks = files.map((f) => `-- ===== ${f} =====\n${readFileSync(join(MIGRATIONS_DIR, f), 'utf-8').trimEnd()}\n`);
|
|
33
|
+
const sql = [
|
|
34
|
+
...blocks,
|
|
35
|
+
'-- ===== Reload PostgREST schema cache =====',
|
|
36
|
+
"-- Without this, PostgREST keeps serving stale cache and knowledge_* calls fail.",
|
|
37
|
+
"NOTIFY pgrst, 'reload schema';",
|
|
38
|
+
'',
|
|
39
|
+
].join('\n');
|
|
40
|
+
// Supabase URL format: https://<ref>.supabase.co. The SQL Editor URL is
|
|
41
|
+
// https://supabase.com/dashboard/project/<ref>/sql/new. We try to extract
|
|
42
|
+
// the ref; if the URL doesn't match the expected format (self-hosted,
|
|
43
|
+
// etc.), we fall back to the generic dashboard.
|
|
44
|
+
const supabaseUrl = adapter.supabaseUrl || '';
|
|
45
|
+
const refMatch = supabaseUrl.match(/https?:\/\/([a-z0-9]+)\.supabase\.co/);
|
|
46
|
+
const sqlEditorUrl = refMatch
|
|
47
|
+
? `https://supabase.com/dashboard/project/${refMatch[1]}/sql/new`
|
|
48
|
+
: 'https://supabase.com/dashboard/project/_/sql/new';
|
|
49
|
+
return makeToolResponse({
|
|
50
|
+
backend: 'supabase',
|
|
51
|
+
migrations_path: MIGRATIONS_DIR,
|
|
52
|
+
files_count: files.length,
|
|
53
|
+
files,
|
|
54
|
+
sql,
|
|
55
|
+
sql_editor_url: sqlEditorUrl,
|
|
56
|
+
instructions: [
|
|
57
|
+
`1. Open ${sqlEditorUrl}`,
|
|
58
|
+
'2. Paste the contents of the `sql` field above into the editor.',
|
|
59
|
+
'3. Run.',
|
|
60
|
+
'4. Verify with setup_migrate (it should report 0 missing collections).',
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
66
|
+
console.error('[setup_bootstrap] Error:', err);
|
|
67
|
+
return makeErrorResponse(`Failed to assemble bootstrap SQL from ${MIGRATIONS_DIR}: ${msg}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=setup-bootstrap.js.map
|