mcp-memory-bucket 0.2.2 → 0.3.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.
@@ -7,7 +7,7 @@
7
7
  :root { color-scheme: light dark; }
8
8
  body { font-family: system-ui, sans-serif; margin: 0; }
9
9
  </style>
10
- <script type="module" crossorigin src="/assets/index-BxAZ48QK.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-DIO48C0V.js"></script>
11
11
  </head>
12
12
  <body>
13
13
  <mem-bucket-app></mem-bucket-app>
@@ -5,6 +5,7 @@ import { writeMarkdownFile } from '../store/markdown-file.js';
5
5
  import { slugify } from '../store/slug.js';
6
6
  import { resolveWithinBase } from '../store/safe-path.js';
7
7
  import { upsertFile, removeFile, scanSingleRoot, unregisterRoot, memorySyncSpec } from '../store/sync.js';
8
+ import { SearchQueryError } from '../store/search.js';
8
9
  import { normalizeKey } from '../types.js';
9
10
  function rowToDoc(row) {
10
11
  return {
@@ -16,6 +17,8 @@ function rowToDoc(row) {
16
17
  tags: JSON.parse(row.tags),
17
18
  status: row.status,
18
19
  related_to: row.related_to,
20
+ deprecated: !!row.deprecated,
21
+ created_at: row.created_at ?? undefined,
19
22
  source_path: row.source_path,
20
23
  root: row.root,
21
24
  body: row.body,
@@ -79,10 +82,58 @@ export class MemoryRepository {
79
82
  : this.db.prepare(`SELECT * FROM memory_docs WHERE key = ?`).all(normalized);
80
83
  return rows.map(rowToDoc);
81
84
  }
85
+ /**
86
+ * Full-text search over memory description/body/tags via FTS5 — `query` is
87
+ * raw FTS5 MATCH syntax (AND/OR/NOT, "phrases", prefix*). Ranked by bm25.
88
+ * Optional metadata filters (doc_type/status/root/tag) apply before limit/offset,
89
+ * so pagination stays correct even when filtering narrows the FTS hit set.
90
+ */
91
+ search(query, opts = {}) {
92
+ const { docType, status, root, tag, limit = 20, offset = 0 } = opts;
93
+ const conditions = [];
94
+ const params = [query];
95
+ if (docType) {
96
+ conditions.push('m.doc_type = ?');
97
+ params.push(docType);
98
+ }
99
+ if (status) {
100
+ conditions.push('m.status = ?');
101
+ params.push(status);
102
+ }
103
+ if (root) {
104
+ conditions.push('m.root = ?');
105
+ params.push(root);
106
+ }
107
+ if (tag) {
108
+ conditions.push('EXISTS (SELECT 1 FROM json_each(m.tags) WHERE value = ?)');
109
+ params.push(tag);
110
+ }
111
+ params.push(limit, offset);
112
+ try {
113
+ const rows = this.db
114
+ .prepare(`SELECT m.id, m.key, m.description, m.doc_type, m.root,
115
+ snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
116
+ -bm25(search_index) AS score
117
+ FROM search_index
118
+ JOIN memory_docs m ON m.id = search_index.ref_id
119
+ WHERE search_index.ref_table = 'memory_docs' AND search_index MATCH ? ${conditions.map((c) => `AND ${c}`).join(' ')}
120
+ ORDER BY bm25(search_index)
121
+ LIMIT ? OFFSET ?`)
122
+ .all(...params);
123
+ return rows;
124
+ }
125
+ catch (err) {
126
+ throw new SearchQueryError(query, err);
127
+ }
128
+ }
82
129
  get(id) {
83
130
  const row = this.db.prepare(`SELECT * FROM memory_docs WHERE id = ?`).get(id);
84
131
  return row ? rowToDoc(row) : null;
85
132
  }
133
+ /** Fetches many memory docs by id in one call — e.g. hydrating full bodies for a batch of search() hits. Missing ids are simply absent from the result, not errors. */
134
+ bulkGet(ids) {
135
+ return ids.map((id) => this.get(id)).filter((doc) => doc !== null);
136
+ }
86
137
  listKeys(keyPrefix) {
87
138
  const rows = this.db
88
139
  .prepare(`SELECT key, COUNT(*) as doc_count FROM memory_docs GROUP BY key ORDER BY key`)
@@ -107,6 +158,8 @@ export class MemoryRepository {
107
158
  tags: input.tags ?? [],
108
159
  status: 'active',
109
160
  related_to: input.related_to ?? null,
161
+ deprecated: false,
162
+ created_at: new Date().toISOString(),
110
163
  source_path: filePath,
111
164
  root: targetRoot.name,
112
165
  };
@@ -114,6 +167,22 @@ export class MemoryRepository {
114
167
  upsertFile(this.db, this.syncSpec, filePath);
115
168
  return { ...fm, body: input.body };
116
169
  }
170
+ /**
171
+ * Creates many memory docs in one call — each entry is the same shape as
172
+ * create()'s input. Returns per-key results (with the id filled in on
173
+ * success) so one bad entry doesn't abort the rest of the batch.
174
+ */
175
+ bulkCreate(entries) {
176
+ return entries.map((entry) => {
177
+ try {
178
+ const doc = this.create(entry);
179
+ return { key: entry.key, ok: true, id: doc.id };
180
+ }
181
+ catch (err) {
182
+ return { key: entry.key, ok: false, error: err.message };
183
+ }
184
+ });
185
+ }
117
186
  update(id, frontmatter, body) {
118
187
  const existing = this.get(id);
119
188
  if (!existing)
@@ -129,6 +198,38 @@ export class MemoryRepository {
129
198
  upsertFile(this.db, this.syncSpec, existing.source_path);
130
199
  return { ...merged, body: newBody };
131
200
  }
201
+ /**
202
+ * Applies the same frontmatter change to many memory docs at once — e.g.
203
+ * add/remove a tag across a batch found via search(), or flip status for a
204
+ * group (e.g. mark a set of docs "shipped"). Tags in `add_tags`/`remove_tags`
205
+ * are merged/subtracted per-doc; `status`/`related_to` overwrite uniformly
206
+ * when provided. Never touches body. Returns per-id results so partial
207
+ * failures (e.g. an unknown id) don't abort the rest of the batch.
208
+ */
209
+ bulkUpdate(ids, changes) {
210
+ return ids.map((id) => {
211
+ try {
212
+ const existing = this.get(id);
213
+ if (!existing)
214
+ throw new Error(`memory doc with id "${id}" not found`);
215
+ let tags = existing.tags;
216
+ if (changes.add_tags?.length)
217
+ tags = Array.from(new Set([...tags, ...changes.add_tags]));
218
+ if (changes.remove_tags?.length)
219
+ tags = tags.filter((t) => !changes.remove_tags.includes(t));
220
+ this.update(id, {
221
+ tags,
222
+ ...(changes.status !== undefined ? { status: changes.status } : {}),
223
+ ...(changes.related_to !== undefined ? { related_to: changes.related_to } : {}),
224
+ ...(changes.deprecated !== undefined ? { deprecated: changes.deprecated } : {}),
225
+ });
226
+ return { id, ok: true };
227
+ }
228
+ catch (err) {
229
+ return { id, ok: false, error: err.message };
230
+ }
231
+ });
232
+ }
132
233
  delete(id) {
133
234
  const existing = this.get(id);
134
235
  if (!existing)
@@ -136,6 +237,22 @@ export class MemoryRepository {
136
237
  fs.unlinkSync(existing.source_path);
137
238
  removeFile(this.db, 'memory_docs', existing.source_path);
138
239
  }
240
+ /**
241
+ * Deletes many memory docs by id in one call — e.g. cleaning up a batch of
242
+ * abandoned docs found via search(). Returns per-id results so one bad id
243
+ * doesn't abort the rest of the batch.
244
+ */
245
+ bulkDelete(ids) {
246
+ return ids.map((id) => {
247
+ try {
248
+ this.delete(id);
249
+ return { id, ok: true };
250
+ }
251
+ catch (err) {
252
+ return { id, ok: false, error: err.message };
253
+ }
254
+ });
255
+ }
139
256
  }
140
257
  function stripSourcePath(fm) {
141
258
  const { source_path: _sp, root: _root, ...rest } = fm;
@@ -14,10 +14,42 @@ export function registerMemoryTools(mcp, repo) {
14
14
  const docs = repo.getByKey(key, doc_type);
15
15
  return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
16
16
  });
17
+ mcp.tool('memory_bulk_get', 'Fetches many memory docs by id in one call, including full markdown bodies — e.g. hydrating a batch of memory_search hits (which return ids, not keys). Missing ids are simply omitted from the result, not errors.', { ids: z.array(z.string()).min(1) }, async ({ ids }) => {
18
+ const docs = repo.bulkGet(ids);
19
+ return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
20
+ });
17
21
  mcp.tool('memory_list', 'Browses available memory keys (optionally filtered by a prefix) without needing to know the exact key upfront. Returns each key with its doc count.', { key_prefix: z.string().optional() }, async ({ key_prefix }) => {
18
22
  const keys = repo.listKeys(key_prefix);
19
23
  return { content: [{ type: 'text', text: JSON.stringify(keys, null, 2) }] };
20
24
  });
25
+ mcp.tool('memory_search', 'Full-text search over memory doc description/body/tags (grep/find-like, ranked by relevance) — unlike memory_get\'s exact-key lookup, this searches by content across all keys. `query` is raw SQLite FTS5 MATCH syntax: bare words, "exact phrases", prefix* wildcards, AND/OR/NOT boolean operators; hyphenated/punctuated terms must be quoted, e.g. "blue-green". Can be combined with doc_type/status/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call memory_get(key) or fetch by id for that.', {
26
+ query: z.string().describe('FTS5 match expression, e.g. `migration AND rollback` or `"blue green"`'),
27
+ doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
28
+ status: z.enum(MEMORY_STATUS).optional(),
29
+ tag: z.string().optional(),
30
+ ...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
31
+ limit: z.number().int().positive().max(100).optional(),
32
+ offset: z.number().int().nonnegative().optional(),
33
+ }, async ({ query, doc_type, status, tag, root, limit, offset }) => {
34
+ try {
35
+ const hits = repo.search(query, { docType: doc_type, status, tag, root, limit, offset });
36
+ return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
37
+ }
38
+ catch (err) {
39
+ return { content: [{ type: 'text', text: err.message }], isError: true };
40
+ }
41
+ });
42
+ mcp.tool('memory_bulk_update', 'Applies the same frontmatter change to many memory docs at once by id — e.g. add/remove a tag across a batch found via memory_search, or mark a group of docs "shipped". add_tags/remove_tags merge or subtract per-doc; status/related_to overwrite uniformly when provided. Body is never touched. Returns per-id success/failure so one bad id doesn\'t abort the batch.', {
43
+ ids: z.array(z.string()).min(1),
44
+ add_tags: z.array(z.string()).optional(),
45
+ remove_tags: z.array(z.string()).optional(),
46
+ status: z.enum(MEMORY_STATUS).optional(),
47
+ related_to: z.string().nullable().optional(),
48
+ deprecated: z.boolean().optional().describe('marks docs as deprecated (or un-deprecates when false) — independent of status'),
49
+ }, async ({ ids, add_tags, remove_tags, status, related_to, deprecated }) => {
50
+ const results = repo.bulkUpdate(ids, { add_tags, remove_tags, status, related_to, deprecated });
51
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
52
+ });
21
53
  mcp.tool('memory_create', `Writes a new memory doc (plan, spec, SQL, testing notes, discovery, etc.) into the memory root under the given key. ${AUTHORING_SKILL_HINT}`, {
22
54
  key: z.string().describe('lookup handle — ticket ID or free-form name; normalized on write'),
23
55
  key_type: z.enum(MEMORY_KEY_TYPES),
@@ -37,6 +69,21 @@ export function registerMemoryTools(mcp, repo) {
37
69
  return { content: [{ type: 'text', text: err.message }], isError: true };
38
70
  }
39
71
  });
72
+ const memoryEntrySchema = z.object({
73
+ key: z.string().describe('lookup handle — ticket ID or free-form name; normalized on write'),
74
+ key_type: z.enum(MEMORY_KEY_TYPES),
75
+ doc_type: z.enum(MEMORY_DOC_TYPES),
76
+ description: z.string().describe('distinguishes this doc from others sharing the same key'),
77
+ body: z.string(),
78
+ tags: z.array(z.string()).optional(),
79
+ related_to: z.string().optional(),
80
+ folder: z.string().optional(),
81
+ ...(multiRoot ? { root: z.string().describe(`which configured memory root to write into: ${rootNames}`) } : {}),
82
+ });
83
+ mcp.tool('memory_bulk_create', `Writes many memory docs in one call — each entry is the same shape as memory_create's args. Returns per-key success/failure (with the new id on success) so one bad entry doesn't abort the rest of the batch. ${AUTHORING_SKILL_HINT}`, { entries: z.array(memoryEntrySchema).min(1) }, async ({ entries }) => {
84
+ const results = repo.bulkCreate(entries);
85
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
86
+ });
40
87
  mcp.tool('memory_update', `Edits an existing memory doc in place — frontmatter fields and/or body. Only provided fields change. ${AUTHORING_SKILL_HINT}`, {
41
88
  id: z.string(),
42
89
  key: z.string().optional(),
@@ -47,6 +94,7 @@ export function registerMemoryTools(mcp, repo) {
47
94
  tags: z.array(z.string()).optional(),
48
95
  status: z.enum(MEMORY_STATUS).optional(),
49
96
  related_to: z.string().optional(),
97
+ deprecated: z.boolean().optional().describe('marks the doc as deprecated (or un-deprecates when false) — independent of status'),
50
98
  }, async ({ id, body, ...frontmatterFields }) => {
51
99
  try {
52
100
  const doc = repo.update(id, frontmatterFields, body);
@@ -65,6 +113,10 @@ export function registerMemoryTools(mcp, repo) {
65
113
  return { content: [{ type: 'text', text: err.message }], isError: true };
66
114
  }
67
115
  });
116
+ mcp.tool('memory_bulk_delete', 'Hard-deletes many memory docs by id in one call — e.g. cleaning up a batch of abandoned docs found via memory_search. No tombstone. Returns per-id success/failure so one bad id doesn\'t abort the rest of the batch.', { ids: z.array(z.string()).min(1) }, async ({ ids }) => {
117
+ const results = repo.bulkDelete(ids);
118
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
119
+ });
68
120
  mcp.tool('memory_save_session', `Saves a summary of the current chat session as a memory doc (doc_type "session-summary"). Pass a summary, not a raw transcript. If key or description are omitted, ask the user for them rather than guessing. ${AUTHORING_SKILL_HINT}`, {
69
121
  summary: z.string().describe('a scannable summary of the session, not a raw transcript'),
70
122
  key: z.string().optional(),
@@ -11,6 +11,7 @@ import { MemoryRepository } from './memory/repository.js';
11
11
  import { registerSkillTools } from './skills/tools.js';
12
12
  import { registerMemoryTools } from './memory/tools.js';
13
13
  import { registerRelocateTool } from './shared/relocate-tool.js';
14
+ import { registerSearchTool } from './shared/search-tool.js';
14
15
  import { buildWebRouter } from './web/routes.js';
15
16
  import { registerUiTool } from './web/ui-tool.js';
16
17
  // server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
@@ -47,12 +48,13 @@ if (config.skillRoots.length === 0 && config.memoryRoots.length === 0) {
47
48
  // this server — surfaced both in serverInfo.description and instructions so
48
49
  // clients that expose either to the model can make that association.
49
50
  const SERVER_DESCRIPTION = 'Also known as "memory bucket", "mem bucket", or "skill bucket" — if the user refers to this server by any of those names, they mean this one.';
50
- const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus a shared relocate tool. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
51
+ const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
51
52
  function buildMcpServer() {
52
53
  const server = new McpServer({ name: 'memory-bucket', version: '0.1.0', description: SERVER_DESCRIPTION }, { capabilities: {}, instructions: SERVER_INSTRUCTIONS });
53
54
  registerSkillTools(server, skillRepo);
54
55
  registerMemoryTools(server, memoryRepo);
55
56
  registerRelocateTool(server, skillRepo, memoryRepo);
57
+ registerSearchTool(server, db);
56
58
  registerUiTool(server, PORT);
57
59
  return server;
58
60
  }
@@ -1,28 +1,29 @@
1
1
  import { z } from 'zod';
2
- import { relocate } from './relocate.js';
2
+ import { relocate, relocateMany } from './relocate.js';
3
3
  const AUTHORING_SKILL_HINT = "Before your first call in a session, run skill_get(\"memory-bucket-authoring\") to learn the exact frontmatter schema and conventions — don't guess the shape.";
4
+ const overridesSchema = z
5
+ .object({
6
+ name: z.string().optional().describe('skill target only: lowercase, hyphenated, <=64 chars — becomes the skill folder name'),
7
+ description: z
8
+ .string()
9
+ .max(1024)
10
+ .optional()
11
+ .describe('required for skill target (cannot be inferred from a filename — must state what the skill does and when to use it); optional for memory target where it distinguishes this doc from siblings under the same key'),
12
+ key: z.string().optional().describe('memory target only'),
13
+ key_type: z.enum(['ticket', 'freeform']).optional().describe('memory target only'),
14
+ doc_type: z.enum(['plan', 'spec', 'sql', 'testing-todo', 'discovery', 'session-summary', 'other']).optional().describe('memory target only'),
15
+ tags: z.array(z.string()).optional(),
16
+ status: z.enum(['stable', 'beta', 'unreviewed', 'active', 'shipped', 'abandoned']).optional(),
17
+ folder: z.string().optional().describe('optional subdirectory under the target root'),
18
+ root: z.string().optional().describe('which configured root to write into; required if multiple roots exist for the target type'),
19
+ })
20
+ .optional();
4
21
  export function registerRelocateTool(mcp, skillRepo, memoryRepo) {
5
22
  mcp.tool('relocate', `Moves an existing local markdown file into the skill or memory source directory, converting it into a properly frontmattered doc. Default is a move (original deleted); pass keep_original to copy instead. On a weak/ambiguous filename match for memory docs, does nothing — no guess, no partial write; ask the user for an explicit key/description and retry with overrides. ${AUTHORING_SKILL_HINT}`, {
6
23
  path: z.string().describe('absolute or relative path to the local file to relocate'),
7
24
  target: z.enum(['skill', 'memory']),
8
25
  keep_original: z.boolean().optional().describe('if true, copies instead of moving (default: move)'),
9
- overrides: z
10
- .object({
11
- name: z.string().optional().describe('skill target only: lowercase, hyphenated, <=64 chars — becomes the skill folder name'),
12
- description: z
13
- .string()
14
- .max(1024)
15
- .optional()
16
- .describe('required for skill target (cannot be inferred from a filename — must state what the skill does and when to use it); optional for memory target where it distinguishes this doc from siblings under the same key'),
17
- key: z.string().optional().describe('memory target only'),
18
- key_type: z.enum(['ticket', 'freeform']).optional().describe('memory target only'),
19
- doc_type: z.enum(['plan', 'spec', 'sql', 'testing-todo', 'discovery', 'session-summary', 'other']).optional().describe('memory target only'),
20
- tags: z.array(z.string()).optional(),
21
- status: z.enum(['stable', 'beta', 'unreviewed', 'active', 'shipped', 'abandoned']).optional(),
22
- folder: z.string().optional().describe('optional subdirectory under the target root'),
23
- root: z.string().optional().describe('which configured root to write into; required if multiple roots exist for the target type'),
24
- })
25
- .optional(),
26
+ overrides: overridesSchema,
26
27
  }, async (opts) => {
27
28
  const result = relocate(opts, skillRepo, memoryRepo);
28
29
  return {
@@ -30,4 +31,17 @@ export function registerRelocateTool(mcp, skillRepo, memoryRepo) {
30
31
  isError: !result.moved,
31
32
  };
32
33
  });
34
+ mcp.tool('relocate_bulk', `Relocates many local files in one call — each entry is the same shape as relocate's args. Returns one result per path (in order); a weak/ambiguous filename match for one file does not block the rest of the batch. ${AUTHORING_SKILL_HINT}`, {
35
+ entries: z
36
+ .array(z.object({
37
+ path: z.string().describe('absolute or relative path to the local file to relocate'),
38
+ target: z.enum(['skill', 'memory']),
39
+ keep_original: z.boolean().optional(),
40
+ overrides: overridesSchema,
41
+ }))
42
+ .min(1),
43
+ }, async ({ entries }) => {
44
+ const results = relocateMany(entries, skillRepo, memoryRepo);
45
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
46
+ });
33
47
  }
@@ -98,6 +98,15 @@ export function relocate(opts, skillRepo, memoryRepo) {
98
98
  fs.unlinkSync(opts.path);
99
99
  return { moved: true, id: doc.id, target: 'memory' };
100
100
  }
101
+ /**
102
+ * Relocates many files in one call — each entry is the same shape as
103
+ * relocate()'s options, minus `path` which is supplied per-entry. Returns one
104
+ * RelocateResult per path (in order) so one bad/ambiguous file doesn't abort
105
+ * the rest of the batch — same "no guess, no partial write" behavior per file.
106
+ */
107
+ export function relocateMany(entries, skillRepo, memoryRepo) {
108
+ return entries.map((entry) => ({ path: entry.path, ...relocate(entry, skillRepo, memoryRepo) }));
109
+ }
101
110
  function slugFromFilename(filePath) {
102
111
  return path
103
112
  .basename(filePath, path.extname(filePath))
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ import { searchCombined, SearchQueryError } from '../store/search.js';
3
+ export function registerSearchTool(mcp, db) {
4
+ mcp.tool('bucket_search', 'Full-text search across BOTH skills and memory docs in one ranked list — use this when you don\'t know (or don\'t care) which bucket something landed in. `query` is raw SQLite FTS5 MATCH syntax: bare words, "exact phrases", prefix* wildcards, AND/OR/NOT boolean operators; hyphenated/punctuated terms must be quoted, e.g. "blue-green". For filtering by doc_type/status/tag, use skill_search or memory_search instead. Returns ranked hits with a highlighted snippet, not full body — call skill_get/memory_get on a hit for that.', {
5
+ query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
6
+ limit: z.number().int().positive().max(100).optional(),
7
+ offset: z.number().int().nonnegative().optional(),
8
+ }, async ({ query, limit, offset }) => {
9
+ try {
10
+ const hits = searchCombined(db, query, limit, offset);
11
+ return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
12
+ }
13
+ catch (err) {
14
+ const message = err instanceof SearchQueryError ? err.message : err.message;
15
+ return { content: [{ type: 'text', text: message }], isError: true };
16
+ }
17
+ });
18
+ }