@iwo-szapar/data-mcp 0.3.2 → 0.4.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 +1 -1
- 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/package.json +1 -1
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
|
@@ -30,7 +30,7 @@ const SERVER_INSTRUCTIONS = `You are the user's AI Second Brain data layer. This
|
|
|
30
30
|
export function createServer(adapter) {
|
|
31
31
|
const server = new McpServer({
|
|
32
32
|
name: '@iwo-szapar/data-mcp',
|
|
33
|
-
version: '0.3.
|
|
33
|
+
version: '0.3.3',
|
|
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().min(1).max(50).describe('ID of the source entity (UUID on Supabase, 15-char native ID on PocketBase)'),
|
|
16
16
|
target_type: z.enum(ENTITY_TYPES).describe('Type of the target entity'),
|
|
17
|
-
target_id: z.string().
|
|
17
|
+
target_id: z.string().min(1).max(50).describe('ID of the target entity (UUID on Supabase, 15-char native ID on PocketBase)'),
|
|
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().min(1).max(50).describe('ID 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().min(1).max(50).describe('ID of the entity (UUID on Supabase, 15-char native ID on PocketBase)'),
|
|
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().min(1).max(50).describe('ID 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iwo-szapar/data-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Unified data MCP server for Second Brain — PocketBase and Supabase adapters. 40 tools: knowledge, sessions, goals, tasks, contacts, CRM, blog, email, content calendar.",
|
|
5
5
|
"author": "Iwo Szapar <iwo.szapar@gmail.com> (https://iwoszapar.com)",
|
|
6
6
|
"homepage": "https://iwoszapar.com/second-brain-ai",
|