@bod.ee/db 0.7.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/.claude/settings.local.json +23 -0
- package/.claude/skills/config-file.md +54 -0
- package/.claude/skills/deploying-bod-db.md +29 -0
- package/.claude/skills/developing-bod-db.md +127 -0
- package/.claude/skills/using-bod-db.md +403 -0
- package/CLAUDE.md +110 -0
- package/README.md +252 -0
- package/admin/rules.ts +12 -0
- package/admin/server.ts +523 -0
- package/admin/ui.html +2281 -0
- package/cli.ts +177 -0
- package/client.ts +2 -0
- package/config.ts +20 -0
- package/deploy/.env.example +1 -0
- package/deploy/base.yaml +18 -0
- package/deploy/boddb-logs.yaml +10 -0
- package/deploy/boddb.yaml +10 -0
- package/deploy/demo.html +196 -0
- package/deploy/deploy.ts +32 -0
- package/deploy/prod-logs.config.ts +15 -0
- package/deploy/prod.config.ts +15 -0
- package/index.ts +20 -0
- package/mcp.ts +78 -0
- package/package.json +29 -0
- package/react.ts +1 -0
- package/src/client/BodClient.ts +515 -0
- package/src/react/hooks.ts +121 -0
- package/src/server/BodDB.ts +319 -0
- package/src/server/ExpressionRules.ts +250 -0
- package/src/server/FTSEngine.ts +76 -0
- package/src/server/FileAdapter.ts +116 -0
- package/src/server/MCPAdapter.ts +409 -0
- package/src/server/MQEngine.ts +286 -0
- package/src/server/QueryEngine.ts +45 -0
- package/src/server/RulesEngine.ts +108 -0
- package/src/server/StorageEngine.ts +464 -0
- package/src/server/StreamEngine.ts +320 -0
- package/src/server/SubscriptionEngine.ts +120 -0
- package/src/server/Transport.ts +479 -0
- package/src/server/VectorEngine.ts +115 -0
- package/src/shared/errors.ts +15 -0
- package/src/shared/pathUtils.ts +94 -0
- package/src/shared/protocol.ts +59 -0
- package/src/shared/transforms.ts +99 -0
- package/tests/batch.test.ts +60 -0
- package/tests/bench.ts +205 -0
- package/tests/e2e.test.ts +284 -0
- package/tests/expression-rules.test.ts +114 -0
- package/tests/file-adapter.test.ts +57 -0
- package/tests/fts.test.ts +58 -0
- package/tests/mq-flow.test.ts +204 -0
- package/tests/mq.test.ts +326 -0
- package/tests/push.test.ts +55 -0
- package/tests/query.test.ts +60 -0
- package/tests/rules.test.ts +78 -0
- package/tests/sse.test.ts +78 -0
- package/tests/storage.test.ts +199 -0
- package/tests/stream.test.ts +385 -0
- package/tests/stress.test.ts +202 -0
- package/tests/subscriptions.test.ts +86 -0
- package/tests/transforms.test.ts +92 -0
- package/tests/transport.test.ts +209 -0
- package/tests/ttl.test.ts +70 -0
- package/tests/vector.test.ts +69 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from 'fs';
|
|
2
|
+
import { readdir, stat, readFile, writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { join, relative, extname, dirname } from 'path';
|
|
4
|
+
import type { BodDB } from './BodDB.ts';
|
|
5
|
+
|
|
6
|
+
export class FileAdapterOptions {
|
|
7
|
+
/** Root directory to watch */
|
|
8
|
+
root: string = '.';
|
|
9
|
+
/** Base path in DB (e.g. 'files') */
|
|
10
|
+
basePath: string = 'files';
|
|
11
|
+
/** Watch for changes */
|
|
12
|
+
watch: boolean = true;
|
|
13
|
+
/** Index file content as string */
|
|
14
|
+
indexContent: boolean = false;
|
|
15
|
+
/** Store file metadata */
|
|
16
|
+
metadata: boolean = true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MIME_MAP: Record<string, string> = {
|
|
20
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
|
|
21
|
+
'.js': 'application/javascript', '.ts': 'application/typescript',
|
|
22
|
+
'.html': 'text/html', '.css': 'text/css', '.csv': 'text/csv',
|
|
23
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif',
|
|
24
|
+
'.svg': 'image/svg+xml', '.pdf': 'application/pdf',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class FileAdapter {
|
|
28
|
+
readonly options: FileAdapterOptions;
|
|
29
|
+
private db: BodDB;
|
|
30
|
+
private watcher: FSWatcher | null = null;
|
|
31
|
+
|
|
32
|
+
constructor(db: BodDB, options?: Partial<FileAdapterOptions>) {
|
|
33
|
+
this.options = { ...new FileAdapterOptions(), ...options };
|
|
34
|
+
this.db = db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async start(): Promise<void> {
|
|
38
|
+
await this.scan();
|
|
39
|
+
if (this.options.watch) {
|
|
40
|
+
this.watcher = watch(this.options.root, { recursive: true }, (_event, filename) => {
|
|
41
|
+
if (filename) this.syncFile(filename).catch(() => {});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
stop(): void {
|
|
47
|
+
this.watcher?.close();
|
|
48
|
+
this.watcher = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async scan(): Promise<void> {
|
|
52
|
+
const files = await this.readDirRecursive(this.options.root);
|
|
53
|
+
for (const filePath of files) {
|
|
54
|
+
const relPath = relative(this.options.root, filePath);
|
|
55
|
+
await this.syncFile(relPath);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async syncFile(relPath: string): Promise<void> {
|
|
60
|
+
const fullPath = join(this.options.root, relPath);
|
|
61
|
+
const dbPath = `${this.options.basePath}/${relPath.replace(/\\/g, '/')}`;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const s = await stat(fullPath);
|
|
65
|
+
if (!s.isFile()) return;
|
|
66
|
+
|
|
67
|
+
const meta: Record<string, unknown> = {};
|
|
68
|
+
if (this.options.metadata) {
|
|
69
|
+
meta.size = s.size;
|
|
70
|
+
meta.mtime = s.mtimeMs;
|
|
71
|
+
meta.mime = MIME_MAP[extname(fullPath).toLowerCase()] ?? 'application/octet-stream';
|
|
72
|
+
}
|
|
73
|
+
if (this.options.indexContent) {
|
|
74
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
75
|
+
meta._content = content;
|
|
76
|
+
}
|
|
77
|
+
this.db.set(dbPath, meta);
|
|
78
|
+
} catch {
|
|
79
|
+
// File was deleted
|
|
80
|
+
this.db.delete(dbPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read file content through the adapter */
|
|
85
|
+
async readContent(relPath: string): Promise<string | null> {
|
|
86
|
+
const fullPath = join(this.options.root, relPath);
|
|
87
|
+
try {
|
|
88
|
+
return await readFile(fullPath, 'utf-8');
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Write file content through the adapter (write-through) */
|
|
95
|
+
async writeContent(relPath: string, content: string): Promise<void> {
|
|
96
|
+
const fullPath = join(this.options.root, relPath);
|
|
97
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
98
|
+
await writeFile(fullPath, content, 'utf-8');
|
|
99
|
+
// Sync will happen via watcher, or manually:
|
|
100
|
+
await this.syncFile(relPath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async readDirRecursive(dir: string): Promise<string[]> {
|
|
104
|
+
const results: string[] = [];
|
|
105
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const fullPath = join(dir, entry.name);
|
|
108
|
+
if (entry.isDirectory()) {
|
|
109
|
+
results.push(...await this.readDirRecursive(fullPath));
|
|
110
|
+
} else {
|
|
111
|
+
results.push(fullPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { BodClient } from '../client/BodClient.ts';
|
|
2
|
+
|
|
3
|
+
export class MCPAdapterOptions {
|
|
4
|
+
serverName = 'bod-db';
|
|
5
|
+
serverVersion = '0.7.0';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface MCPTool {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
inputSchema: object;
|
|
12
|
+
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface JsonRpcRequest {
|
|
16
|
+
jsonrpc: string;
|
|
17
|
+
id?: string | number | null;
|
|
18
|
+
method: string;
|
|
19
|
+
params?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface JsonRpcResponse {
|
|
23
|
+
jsonrpc: '2.0';
|
|
24
|
+
id?: string | number | null;
|
|
25
|
+
result?: unknown;
|
|
26
|
+
error?: { code: number; message: string; data?: unknown };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class MCPAdapter {
|
|
30
|
+
readonly options: MCPAdapterOptions;
|
|
31
|
+
private tools: MCPTool[] = [];
|
|
32
|
+
|
|
33
|
+
constructor(private client: BodClient, options?: Partial<MCPAdapterOptions>) {
|
|
34
|
+
this.options = { ...new MCPAdapterOptions(), ...options };
|
|
35
|
+
this.registerTools();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Normalize path: strip leading/trailing slashes, "/" becomes "" (root) */
|
|
39
|
+
private normPath(p: unknown): string {
|
|
40
|
+
if (typeof p !== 'string') return '';
|
|
41
|
+
return p.replace(/^\/+|\/+$/g, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private registerTools(): void {
|
|
45
|
+
const t = this.tools;
|
|
46
|
+
const c = this.client;
|
|
47
|
+
|
|
48
|
+
// --- Core CRUD ---
|
|
49
|
+
t.push({
|
|
50
|
+
name: 'db_get',
|
|
51
|
+
description: 'Get value at path. Use shallow:true to list immediate children only.',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
path: { type: 'string', description: 'Database path' },
|
|
56
|
+
shallow: { type: 'boolean', description: 'Return shallow children list' },
|
|
57
|
+
},
|
|
58
|
+
required: ['path'],
|
|
59
|
+
},
|
|
60
|
+
handler: async (a) => { const p = this.normPath(a.path); return a.shallow ? c.getShallow(p) : c.get(p); },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
t.push({
|
|
64
|
+
name: 'db_set',
|
|
65
|
+
description: 'Set value at path. Optionally set TTL in seconds.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
path: { type: 'string' },
|
|
70
|
+
value: { description: 'Any JSON value' },
|
|
71
|
+
ttl: { type: 'number', description: 'Time-to-live in seconds' },
|
|
72
|
+
},
|
|
73
|
+
required: ['path', 'value'],
|
|
74
|
+
},
|
|
75
|
+
handler: async (a) => { await c.set(this.normPath(a.path), a.value, a.ttl ? { ttl: a.ttl as number } : undefined); return 'ok'; },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
t.push({
|
|
79
|
+
name: 'db_update',
|
|
80
|
+
description: 'Multi-path update. Keys are paths, values are data to set.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: { updates: { type: 'object', description: 'Record<path, value>' } },
|
|
84
|
+
required: ['updates'],
|
|
85
|
+
},
|
|
86
|
+
handler: async (a) => {
|
|
87
|
+
const raw = a.updates as Record<string, unknown>;
|
|
88
|
+
const normalized: Record<string, unknown> = {};
|
|
89
|
+
for (const [k, v] of Object.entries(raw)) normalized[this.normPath(k)] = v;
|
|
90
|
+
await c.update(normalized);
|
|
91
|
+
return 'ok';
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
t.push({
|
|
96
|
+
name: 'db_delete',
|
|
97
|
+
description: 'Delete value at path (and all children).',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: { path: { type: 'string' } },
|
|
101
|
+
required: ['path'],
|
|
102
|
+
},
|
|
103
|
+
handler: async (a) => { await c.delete(this.normPath(a.path)); return 'ok'; },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
t.push({
|
|
107
|
+
name: 'db_push',
|
|
108
|
+
description: 'Push value with auto-generated time-sortable key. Returns the generated key.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
path: { type: 'string' },
|
|
113
|
+
value: { description: 'Any JSON value' },
|
|
114
|
+
idempotencyKey: { type: 'string' },
|
|
115
|
+
},
|
|
116
|
+
required: ['path', 'value'],
|
|
117
|
+
},
|
|
118
|
+
handler: async (a) => c.push(this.normPath(a.path), a.value, a.idempotencyKey ? { idempotencyKey: a.idempotencyKey as string } : undefined),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
t.push({
|
|
122
|
+
name: 'db_query',
|
|
123
|
+
description: 'Query children at path with optional filters, ordering, limit, offset.',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
path: { type: 'string' },
|
|
128
|
+
where: { type: 'array', items: { type: 'object', properties: { field: { type: 'string' }, op: { type: 'string' }, value: {} }, required: ['field', 'op', 'value'] } },
|
|
129
|
+
order: { type: 'object', properties: { field: { type: 'string' }, dir: { type: 'string', enum: ['asc', 'desc'] } }, required: ['field'] },
|
|
130
|
+
limit: { type: 'number' },
|
|
131
|
+
offset: { type: 'number' },
|
|
132
|
+
},
|
|
133
|
+
required: ['path'],
|
|
134
|
+
},
|
|
135
|
+
handler: async (a) => {
|
|
136
|
+
let q = c.query(this.normPath(a.path));
|
|
137
|
+
if (Array.isArray(a.where)) for (const f of a.where as any[]) q = q.where(f.field, f.op, f.value);
|
|
138
|
+
if (a.order) q = q.order((a.order as any).field, (a.order as any).dir);
|
|
139
|
+
if (a.limit != null) q = q.limit(a.limit as number);
|
|
140
|
+
if (a.offset != null) q = q.offset(a.offset as number);
|
|
141
|
+
return q.get();
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// --- FTS ---
|
|
146
|
+
t.push({
|
|
147
|
+
name: 'db_search',
|
|
148
|
+
description: 'Full-text search. Returns [{path, data, rank}]. Requires FTS configured on server.',
|
|
149
|
+
inputSchema: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: { text: { type: 'string' }, path: { type: 'string' }, limit: { type: 'number' } },
|
|
152
|
+
required: ['text'],
|
|
153
|
+
},
|
|
154
|
+
handler: async (a) => c.search({ text: a.text as string, path: a.path ? this.normPath(a.path) : undefined, limit: a.limit as number }),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
t.push({
|
|
158
|
+
name: 'db_index',
|
|
159
|
+
description: 'Index a path for full-text search. Pass content string or array of field names. Requires FTS configured on server.',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
path: { type: 'string' },
|
|
164
|
+
content: { type: 'string', description: 'Text content to index' },
|
|
165
|
+
fields: { type: 'array', items: { type: 'string' }, description: 'Field names to extract and index from the stored object' },
|
|
166
|
+
},
|
|
167
|
+
required: ['path'],
|
|
168
|
+
},
|
|
169
|
+
handler: async (a) => {
|
|
170
|
+
const p = this.normPath(a.path);
|
|
171
|
+
if (a.content) await c.index(p, a.content as string);
|
|
172
|
+
else if (a.fields) await c.index(p, a.fields as string[]);
|
|
173
|
+
else throw new Error('Provide content (string) or fields (string[])');
|
|
174
|
+
return 'ok';
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// --- Vectors ---
|
|
179
|
+
t.push({
|
|
180
|
+
name: 'db_vector_search',
|
|
181
|
+
description: 'Vector similarity search. Returns [{path, data, score}]. Requires vectors configured on server.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
query: { type: 'array', items: { type: 'number' }, description: 'Query embedding' },
|
|
186
|
+
path: { type: 'string' }, limit: { type: 'number' }, threshold: { type: 'number' },
|
|
187
|
+
},
|
|
188
|
+
required: ['query'],
|
|
189
|
+
},
|
|
190
|
+
handler: async (a) => c.vectorSearch({ query: a.query as number[], path: a.path ? this.normPath(a.path) : undefined, limit: a.limit as number, threshold: a.threshold as number }),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
t.push({
|
|
194
|
+
name: 'db_vector_store',
|
|
195
|
+
description: 'Store a vector embedding at path. Requires vectors configured on server.',
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: 'object',
|
|
198
|
+
properties: { path: { type: 'string' }, embedding: { type: 'array', items: { type: 'number' } } },
|
|
199
|
+
required: ['path', 'embedding'],
|
|
200
|
+
},
|
|
201
|
+
handler: async (a) => { await c.vectorStore(this.normPath(a.path), a.embedding as number[]); return 'ok'; },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// --- Streams ---
|
|
205
|
+
t.push({
|
|
206
|
+
name: 'stream_read',
|
|
207
|
+
description: 'Read unacknowledged events from a stream topic for a consumer group.',
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: { topic: { type: 'string' }, groupId: { type: 'string' }, limit: { type: 'number' } },
|
|
211
|
+
required: ['topic', 'groupId'],
|
|
212
|
+
},
|
|
213
|
+
handler: async (a) => c._streamRead(this.normPath(a.topic), a.groupId as string, a.limit as number | undefined),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
t.push({
|
|
217
|
+
name: 'stream_ack',
|
|
218
|
+
description: 'Acknowledge a stream event (advance consumer group offset).',
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: { topic: { type: 'string' }, groupId: { type: 'string' }, key: { type: 'string' } },
|
|
222
|
+
required: ['topic', 'groupId', 'key'],
|
|
223
|
+
},
|
|
224
|
+
handler: async (a) => { await c._streamAck(this.normPath(a.topic), a.groupId as string, a.key as string); return 'ok'; },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
t.push({
|
|
228
|
+
name: 'stream_materialize',
|
|
229
|
+
description: 'Materialize a stream topic: merge snapshot + live events into a single view.',
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: { topic: { type: 'string' }, keepKey: { type: 'string' } },
|
|
233
|
+
required: ['topic'],
|
|
234
|
+
},
|
|
235
|
+
handler: async (a) => c.streamMaterialize(this.normPath(a.topic), a.keepKey ? { keepKey: a.keepKey as string } : undefined),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
t.push({
|
|
239
|
+
name: 'stream_compact',
|
|
240
|
+
description: 'Compact a stream topic: fold events into snapshot, delete originals.',
|
|
241
|
+
inputSchema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: { topic: { type: 'string' }, maxAge: { type: 'number' }, maxCount: { type: 'number' }, keepKey: { type: 'string' } },
|
|
244
|
+
required: ['topic'],
|
|
245
|
+
},
|
|
246
|
+
handler: async (a) => c.streamCompact(this.normPath(a.topic), { maxAge: a.maxAge as number, maxCount: a.maxCount as number, keepKey: a.keepKey as string }),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// --- MQ ---
|
|
250
|
+
t.push({
|
|
251
|
+
name: 'mq_push',
|
|
252
|
+
description: 'Push a message onto a queue. Returns the generated key.',
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
properties: { queue: { type: 'string' }, value: { description: 'Any JSON value' } },
|
|
256
|
+
required: ['queue', 'value'],
|
|
257
|
+
},
|
|
258
|
+
handler: async (a) => c._mqSend('mq-push', { path: this.normPath(a.queue), value: a.value }),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
t.push({
|
|
262
|
+
name: 'mq_fetch',
|
|
263
|
+
description: 'Fetch and claim messages from a queue (visibility timeout applies).',
|
|
264
|
+
inputSchema: {
|
|
265
|
+
type: 'object',
|
|
266
|
+
properties: { queue: { type: 'string' }, count: { type: 'number' } },
|
|
267
|
+
required: ['queue'],
|
|
268
|
+
},
|
|
269
|
+
handler: async (a) => c._mqSend('mq-fetch', { path: this.normPath(a.queue), count: a.count }),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
t.push({
|
|
273
|
+
name: 'mq_ack',
|
|
274
|
+
description: 'Acknowledge (delete) a processed message.',
|
|
275
|
+
inputSchema: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
properties: { queue: { type: 'string' }, key: { type: 'string' } },
|
|
278
|
+
required: ['queue', 'key'],
|
|
279
|
+
},
|
|
280
|
+
handler: async (a) => { await c._mqSend('mq-ack', { path: this.normPath(a.queue), key: a.key }); return 'ok'; },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
t.push({
|
|
284
|
+
name: 'mq_nack',
|
|
285
|
+
description: 'Return a message to the queue (release visibility lock).',
|
|
286
|
+
inputSchema: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: { queue: { type: 'string' }, key: { type: 'string' } },
|
|
289
|
+
required: ['queue', 'key'],
|
|
290
|
+
},
|
|
291
|
+
handler: async (a) => { await c._mqSend('mq-nack', { path: this.normPath(a.queue), key: a.key }); return 'ok'; },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
t.push({
|
|
295
|
+
name: 'mq_peek',
|
|
296
|
+
description: 'Peek at pending messages without claiming them.',
|
|
297
|
+
inputSchema: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
properties: { queue: { type: 'string' }, count: { type: 'number' } },
|
|
300
|
+
required: ['queue'],
|
|
301
|
+
},
|
|
302
|
+
handler: async (a) => c._mqSend('mq-peek', { path: this.normPath(a.queue), count: a.count }),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
t.push({
|
|
306
|
+
name: 'mq_dlq',
|
|
307
|
+
description: 'List dead-letter messages for a queue.',
|
|
308
|
+
inputSchema: {
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties: { queue: { type: 'string' } },
|
|
311
|
+
required: ['queue'],
|
|
312
|
+
},
|
|
313
|
+
handler: async (a) => c._mqSend('mq-dlq', { path: this.normPath(a.queue) }),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
t.push({
|
|
317
|
+
name: 'mq_purge',
|
|
318
|
+
description: 'Purge pending messages from a queue. Use all:true to also purge inflight + DLQ.',
|
|
319
|
+
inputSchema: {
|
|
320
|
+
type: 'object',
|
|
321
|
+
properties: { queue: { type: 'string' }, all: { type: 'boolean' } },
|
|
322
|
+
required: ['queue'],
|
|
323
|
+
},
|
|
324
|
+
handler: async (a) => c._mqSend('mq-purge', { path: this.normPath(a.queue), all: a.all }),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- JSON-RPC dispatch ---
|
|
329
|
+
|
|
330
|
+
async handle(req: JsonRpcRequest): Promise<JsonRpcResponse | null> {
|
|
331
|
+
const { method, id, params } = req;
|
|
332
|
+
|
|
333
|
+
if (method === 'notifications/initialized') return null;
|
|
334
|
+
|
|
335
|
+
if (method === 'initialize') {
|
|
336
|
+
return this.ok(id, {
|
|
337
|
+
protocolVersion: '2024-11-05',
|
|
338
|
+
capabilities: { tools: {} },
|
|
339
|
+
serverInfo: { name: this.options.serverName, version: this.options.serverVersion },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (method === 'tools/list') {
|
|
344
|
+
return this.ok(id, {
|
|
345
|
+
tools: this.tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (method === 'tools/call') {
|
|
350
|
+
const name = (params as any)?.name as string;
|
|
351
|
+
const args = ((params as any)?.arguments ?? {}) as Record<string, unknown>;
|
|
352
|
+
const tool = this.tools.find(t => t.name === name);
|
|
353
|
+
if (!tool) return this.ok(id, { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] });
|
|
354
|
+
try {
|
|
355
|
+
const result = await tool.handler(args);
|
|
356
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
357
|
+
return this.ok(id, { content: [{ type: 'text', text }] });
|
|
358
|
+
} catch (e: any) {
|
|
359
|
+
return this.ok(id, { isError: true, content: [{ type: 'text', text: e.message ?? String(e) }] });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown method: ${method}` } };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private ok(id: string | number | null | undefined, result: unknown): JsonRpcResponse {
|
|
367
|
+
return { jsonrpc: '2.0', id: id ?? null, result };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- Stdio transport ---
|
|
371
|
+
|
|
372
|
+
serveStdio(): void {
|
|
373
|
+
let buffer = '';
|
|
374
|
+
process.stdin.setEncoding('utf-8');
|
|
375
|
+
process.stdin.on('data', async (chunk: string) => {
|
|
376
|
+
buffer += chunk;
|
|
377
|
+
const lines = buffer.split('\n');
|
|
378
|
+
buffer = lines.pop()!;
|
|
379
|
+
for (const line of lines) {
|
|
380
|
+
const trimmed = line.trim();
|
|
381
|
+
if (!trimmed) continue;
|
|
382
|
+
try {
|
|
383
|
+
const req = JSON.parse(trimmed) as JsonRpcRequest;
|
|
384
|
+
const res = await this.handle(req);
|
|
385
|
+
if (res) process.stdout.write(JSON.stringify(res) + '\n');
|
|
386
|
+
} catch {
|
|
387
|
+
const err: JsonRpcResponse = { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
|
|
388
|
+
process.stdout.write(JSON.stringify(err) + '\n');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// --- HTTP transport ---
|
|
395
|
+
|
|
396
|
+
httpHandler(): (req: Request) => Promise<Response> {
|
|
397
|
+
return async (req: Request): Promise<Response> => {
|
|
398
|
+
if (req.method !== 'POST') return new Response('POST only', { status: 405 });
|
|
399
|
+
try {
|
|
400
|
+
const body = await req.json() as JsonRpcRequest;
|
|
401
|
+
const res = await this.handle(body);
|
|
402
|
+
if (!res) return new Response(null, { status: 204 });
|
|
403
|
+
return Response.json(res);
|
|
404
|
+
} catch {
|
|
405
|
+
return Response.json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, { status: 400 });
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|