@blenau/mcp 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +284 -111
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -241,6 +241,7 @@ async function apiRequest(method, path, opts = {}) {
241
241
  Authorization: `Bearer ${token}`
242
242
  };
243
243
  if (opts.json !== void 0) headers["Content-Type"] = "application/json";
244
+ if (opts.workspace) headers["X-Blenau-Workspace"] = opts.workspace;
244
245
  let resp;
245
246
  try {
246
247
  resp = await fetch(url, {
@@ -263,6 +264,15 @@ async function apiRequest(method, path, opts = {}) {
263
264
  return { error: "api_error", status_code: resp.status, body: text3 };
264
265
  }
265
266
  }
267
+ const echoed = resp.headers.get("X-Blenau-Workspace");
268
+ if (opts.workspace && echoed && echoed !== opts.workspace) {
269
+ return {
270
+ error: "workspace_mismatch",
271
+ message: `Write targeted ${opts.workspace} but the server resolved ${echoed}. Aborting to avoid a cross-workspace mistake.`,
272
+ targeted: opts.workspace,
273
+ resolved: echoed
274
+ };
275
+ }
266
276
  if (resp.status === 204) return {};
267
277
  const text2 = await resp.text();
268
278
  if (!text2) return {};
@@ -273,12 +283,117 @@ async function apiRequest(method, path, opts = {}) {
273
283
  }
274
284
  }
275
285
 
286
+ // src/workspace.ts
287
+ var _active = null;
288
+ var _anchor = null;
289
+ var _cache = null;
290
+ var _initialized = false;
291
+ function isServiceMode() {
292
+ return detectMode() === "service";
293
+ }
294
+ async function listWorkspaces(refresh = false) {
295
+ if (_cache && !refresh) return _cache;
296
+ const res = await apiRequest("GET", "/workspaces");
297
+ if (!res || res.error || !Array.isArray(res.workspaces)) {
298
+ throw new Error(
299
+ typeof res?.error === "string" ? res.error : "could not list workspaces"
300
+ );
301
+ }
302
+ _cache = res.workspaces;
303
+ return _cache;
304
+ }
305
+ async function resolve(slugOrId) {
306
+ const find = (ws) => ws.find((w) => w.id === slugOrId || w.slug === slugOrId) ?? null;
307
+ let hit = find(await listWorkspaces());
308
+ if (!hit) hit = find(await listWorkspaces(true));
309
+ return hit;
310
+ }
311
+ async function ensureInit() {
312
+ if (_initialized || isServiceMode()) return;
313
+ _initialized = true;
314
+ const pin = process.env.BLENAU_WORKSPACE;
315
+ if (pin) {
316
+ const w = await resolve(pin);
317
+ if (w) {
318
+ _active = w;
319
+ _anchor = w;
320
+ return;
321
+ }
322
+ }
323
+ const all = await listWorkspaces();
324
+ const only = all[0];
325
+ if (all.length === 1 && only) {
326
+ _active = only;
327
+ _anchor = only;
328
+ }
329
+ }
330
+ function activeWorkspace() {
331
+ return _active;
332
+ }
333
+ function pinned() {
334
+ return !!process.env.BLENAU_WORKSPACE;
335
+ }
336
+ async function setActive(slugOrId, confirm = false) {
337
+ await ensureInit();
338
+ const w = await resolve(slugOrId);
339
+ if (!w) {
340
+ return { error: `Unknown workspace "${slugOrId}".`, workspaces: await listWorkspaces() };
341
+ }
342
+ const differsFromAnchor = !_anchor || _anchor.id !== w.id;
343
+ if (differsFromAnchor && !confirm) {
344
+ return {
345
+ status: "confirmation_required",
346
+ from: _anchor?.name ?? null,
347
+ to: w.name,
348
+ message: `Switch the write target to "${w.name}"${_anchor ? ` (currently "${_anchor.name}")` : ""}? Re-call with confirm:true. Reads don't need this \u2014 only writes go to the active workspace.`
349
+ };
350
+ }
351
+ _active = w;
352
+ if (!pinned()) _anchor = w;
353
+ return w;
354
+ }
355
+ async function readTarget(arg) {
356
+ await ensureInit();
357
+ if (arg) {
358
+ const w = await resolve(arg);
359
+ return w?.id ?? arg;
360
+ }
361
+ return _active?.id;
362
+ }
363
+ async function writeTarget(arg, confirm = false) {
364
+ await ensureInit();
365
+ let target = _active;
366
+ if (arg) {
367
+ const w = await resolve(arg);
368
+ if (!w) return { error: `Unknown workspace "${arg}".`, message: "Pick a valid workspace.", workspaces: await listWorkspaces() };
369
+ target = w;
370
+ }
371
+ if (!target) {
372
+ return {
373
+ message: "No active workspace. Choose one with set_active_workspace before writing.",
374
+ workspaces: (await listWorkspaces()).filter((w) => w.can_write)
375
+ };
376
+ }
377
+ const differsFromAnchor = !_anchor || _anchor.id !== target.id;
378
+ if (differsFromAnchor && !confirm) {
379
+ return {
380
+ status: "confirmation_required",
381
+ from: _anchor?.name ?? null,
382
+ to: target.name,
383
+ message: `About to write to "${target.name}"${_anchor ? ` but the active workspace is "${_anchor.name}"` : ""}. Re-call with confirm:true, or switch deliberately with set_active_workspace.`
384
+ };
385
+ }
386
+ return { id: target.id };
387
+ }
388
+
276
389
  // src/server.ts
277
390
  var INSTRUCTIONS = `You are connected to a Blenau knowledge brain \u2014 a structured, versioned, provenance-tracked knowledge base shared across agents and humans.
278
391
 
279
392
  GOLDEN RULE \u2014 search FIRST, write LATER.
280
393
  Before calling ingest_document or crystallize_session, ALWAYS run search_knowledge with the topic(s) of the content you plan to write. The brain already holds docs on many subjects; creating a new file when one exists fragments the knowledge and degrades future searches.
281
394
 
395
+ WORKSPACES \u2014 you may belong to several. Reads accept an optional \`workspace\` (slug or id) to look in a specific one; call list_workspaces() to see them. WRITES go to the ACTIVE workspace. To write in a different one, either switch deliberately with set_active_workspace (a visible checkpoint), or pass \`workspace\` + \`confirm:true\` on the write. Every write response is verified to have landed in the intended workspace \u2014 if you get a workspace_mismatch or confirmation_required, do NOT retry blindly; surface it to the user.
396
+
282
397
  Routing: a workspace can have several connected GitHub repos, each with a path_prefix. Call list_repos() BEFORE writing to a specific repo and prefix your path accordingly. Edits use optimistic locking: fetch get_section right before edit_section and pass expected_version. For images/binaries prefer create_asset_upload (stream bytes via curl) over upload_asset (base64, tiny icons only). Always cite external material via the sources argument.`;
283
398
  var sourceSchema = z.array(
284
399
  z.object({
@@ -288,180 +403,238 @@ var sourceSchema = z.array(
288
403
  retrieved_at: z.string().optional()
289
404
  })
290
405
  ).optional();
406
+ var wsArg = z.string().optional().describe("Workspace slug or id to act in. Reads roam freely; writes require confirm when it differs from the active workspace.");
407
+ var confirmArg = z.boolean().optional().describe("Set true to confirm a write to a workspace other than the active one.");
291
408
  function text(result) {
292
409
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
293
410
  }
411
+ async function rd(method, path, opts = {}, argWorkspace) {
412
+ if (isServiceMode()) return apiRequest(method, path, opts);
413
+ const workspace = await readTarget(argWorkspace);
414
+ return apiRequest(method, path, { ...opts, workspace });
415
+ }
416
+ async function wr(method, path, opts = {}, argWorkspace, confirm) {
417
+ if (isServiceMode()) return apiRequest(method, path, opts);
418
+ const target = await writeTarget(argWorkspace, confirm);
419
+ if (!("id" in target)) return target;
420
+ return apiRequest(method, path, { ...opts, workspace: target.id });
421
+ }
294
422
  function buildServer() {
295
423
  const server = new McpServer(
296
- { name: "Blenau", version: "0.1.0" },
424
+ { name: "Blenau", version: "0.2.0" },
297
425
  { instructions: INSTRUCTIONS }
298
426
  );
299
427
  server.tool(
300
- "list_repos",
301
- "List all GitHub repos connected to this workspace. Returns each repo with id, repo (org/name), label, path_prefix, installation_id, doc_count, created_at. Call this BEFORE writing (ingest_document, crystallize_session, smart_crystallize) when you intend to land content in a specific repo \u2014 your path must start with that repo's path_prefix. Read-only.",
428
+ "list_workspaces",
429
+ "List the workspaces you belong to, with your role and whether you can write in each, and which is currently active. Call this to discover where you can read/write. Read-only.",
302
430
  {},
303
- async () => text(await apiRequest("GET", "/github/repos"))
431
+ async () => {
432
+ if (isServiceMode()) return text(await apiRequest("GET", "/workspaces"));
433
+ const workspaces = await listWorkspaces(true);
434
+ const active = activeWorkspace();
435
+ return text({ active: active?.id ?? null, active_name: active?.name ?? null, workspaces });
436
+ }
437
+ );
438
+ server.tool(
439
+ "set_active_workspace",
440
+ "Set the ACTIVE workspace \u2014 where writes go. Switching to a workspace other than the current one needs confirm:true (the switch is a visible checkpoint). Reads don't need this. In service mode the workspace is pinned by the token.",
441
+ { workspace: z.string(), confirm: z.boolean().optional() },
442
+ async ({ workspace, confirm }) => {
443
+ if (isServiceMode()) return text({ error: "workspace pinned by service token" });
444
+ return text(await setActive(workspace, confirm ?? false));
445
+ }
446
+ );
447
+ server.tool(
448
+ "list_repos",
449
+ "List all GitHub repos connected to a workspace. Returns each repo with id, repo (org/name), label, path_prefix, installation_id, doc_count, created_at. Call this BEFORE writing to land content in a specific repo. Read-only.",
450
+ { workspace: wsArg },
451
+ async ({ workspace }) => text(await rd("GET", "/github/repos", {}, workspace))
304
452
  );
305
453
  server.tool(
306
454
  "search_knowledge",
307
455
  "Search the knowledge base semantically. Returns ranked results with relevance scores.",
308
- { query: z.string(), top_k: z.number().int().default(10) },
309
- async ({ query, top_k }) => text(await apiRequest("POST", "/knowledge/search", { json: { query, top_k } }))
456
+ { query: z.string(), top_k: z.number().int().default(10), workspace: wsArg },
457
+ async ({ query, top_k, workspace }) => text(await rd("POST", "/knowledge/search", { json: { query, top_k } }, workspace))
310
458
  );
311
459
  server.tool(
312
460
  "get_document",
313
461
  "Get a document by its path, including all sections.",
314
- { path: z.string() },
315
- async ({ path }) => text(await apiRequest("GET", `/knowledge/documents/by-path/${path}`))
462
+ { path: z.string(), workspace: wsArg },
463
+ async ({ path, workspace }) => text(await rd("GET", `/knowledge/documents/by-path/${path}`, {}, workspace))
316
464
  );
317
465
  server.tool(
318
466
  "get_document_structure",
319
467
  "Get the outline (headings tree) of a document without full content.",
320
- { path: z.string() },
321
- async ({ path }) => text(await apiRequest("GET", `/knowledge/structure/${path}`))
468
+ { path: z.string(), workspace: wsArg },
469
+ async ({ path, workspace }) => text(await rd("GET", `/knowledge/structure/${path}`, {}, workspace))
322
470
  );
323
471
  server.tool(
324
472
  "get_section",
325
473
  "Get a specific section by path and heading. Returns content + version hash for editing.",
326
- { path: z.string(), heading: z.string() },
327
- async ({ path, heading }) => text(await apiRequest("GET", `/knowledge/section/${path}`, { params: { heading } }))
474
+ { path: z.string(), heading: z.string(), workspace: wsArg },
475
+ async ({ path, heading, workspace }) => text(await rd("GET", `/knowledge/section/${path}`, { params: { heading } }, workspace))
476
+ );
477
+ server.tool(
478
+ "list_documents",
479
+ "List all documents in the knowledge base with their status.",
480
+ { workspace: wsArg },
481
+ async ({ workspace }) => text(await rd("GET", "/knowledge/documents", {}, workspace))
482
+ );
483
+ server.tool(
484
+ "list_assets",
485
+ "List the image references in a doc and whether each resolves in GitHub. Returns { doc_path, doc_id, assets: [...] }.",
486
+ { doc_path: z.string(), workspace: wsArg },
487
+ async ({ doc_path, workspace }) => text(await rd("GET", "/assets/list", { params: { doc_path } }, workspace))
488
+ );
489
+ server.tool(
490
+ "verify_asset",
491
+ "Check that ONE asset reference resolves (exists in GitHub \u2192 will render). relative_path is the path exactly as written in the markdown. Returns { resolves, asset_path, raw_url }.",
492
+ { doc_path: z.string(), relative_path: z.string(), workspace: wsArg },
493
+ async ({ doc_path, relative_path, workspace }) => text(await rd("GET", "/assets/resolve", { params: { doc_path, relative_path } }, workspace))
494
+ );
495
+ server.tool(
496
+ "audit_links",
497
+ "Audit the knowledge base for orphan documents, one-way links, unlinked-but-similar pairs, docs missing citations, and recurring concepts lacking a doc. Read-only.",
498
+ { workspace: wsArg },
499
+ async ({ workspace }) => text(await rd("GET", "/github/audit-links", {}, workspace))
500
+ );
501
+ server.tool(
502
+ "suggest_crosslinks",
503
+ "Suggest markdown cross-links to add in a specific document. Returns the top 5 semantically-similar sections from other documents not currently linked, with ready-to-paste snippets. Read-only.",
504
+ { path: z.string(), workspace: wsArg },
505
+ async ({ path, workspace }) => {
506
+ const doc = await rd("GET", `/knowledge/documents/by-path/${path}`, {}, workspace);
507
+ if (!doc || typeof doc !== "object" || "error" in doc) return text(doc ?? { error: "lookup_failed" });
508
+ const docId = doc.id ?? doc.document_id;
509
+ if (!docId) return text({ error: `Document not found: ${path}` });
510
+ const out = await rd("GET", `/github/suggest-crosslinks/${docId}`, {}, workspace);
511
+ if (out && typeof out === "object" && "suggestions" in out && !("document_path" in out)) {
512
+ out.document_path = path;
513
+ }
514
+ return text(out);
515
+ }
516
+ );
517
+ server.tool(
518
+ "get_audit_log",
519
+ "Get the audit log for a workspace. Shows who did what and when.",
520
+ { limit: z.number().int().default(50), event_type: z.string().optional(), workspace: wsArg },
521
+ async ({ limit, event_type, workspace }) => text(
522
+ await rd(
523
+ "GET",
524
+ "/knowledge/audit-log",
525
+ { params: { limit, ...event_type ? { event_type } : {} } },
526
+ workspace
527
+ )
528
+ )
328
529
  );
329
530
  server.tool(
330
531
  "ingest_document",
331
- "Ingest a document into the knowledge base. Runs full pipeline (parse, chunk, embed, index). Routing: path matches a connected repo by longest path_prefix; call list_repos() first to discover prefixes. auto_link=True rewrites bare textual mentions of known document paths into markdown links. sources: list of {type, ref, title?, retrieved_at?} describing external material; appended as a `## Fuentes` section and stored in provenance.",
532
+ "Ingest a document into the knowledge base (parse, chunk, embed, index). Routing: path matches a connected repo by longest path_prefix; call list_repos() first. auto_link rewrites bare mentions into links. sources: external material appended as a `## Fuentes` section. Writes go to the active workspace.",
332
533
  {
333
534
  path: z.string(),
334
535
  title: z.string(),
335
536
  content: z.string(),
336
537
  source_type: z.string().default("manual"),
337
538
  auto_link: z.boolean().default(false),
338
- sources: sourceSchema
539
+ sources: sourceSchema,
540
+ workspace: wsArg,
541
+ confirm: confirmArg
339
542
  },
340
- async ({ path, title, content, source_type, auto_link, sources }) => text(
341
- await apiRequest("POST", "/knowledge/ingest-enhanced", {
342
- json: { path, title, content, source_type, auto_link, sources: sources ?? null }
343
- })
543
+ async ({ path, title, content, source_type, auto_link, sources, workspace, confirm }) => text(
544
+ await wr(
545
+ "POST",
546
+ "/knowledge/ingest-enhanced",
547
+ { json: { path, title, content, source_type, auto_link, sources: sources ?? null } },
548
+ workspace,
549
+ confirm
550
+ )
344
551
  )
345
552
  );
346
553
  server.tool(
347
554
  "crystallize_session",
348
- "Crystallize knowledge from an agent session into a single structured document. DEPRECATED for multi-topic sessions \u2014 prefer smart_crystallize. output_path is the folder under which the file is created (default docs/crystallized); prefix it with the target repo's path_prefix in multi-repo workspaces. sources: same shape as ingest_document.sources.",
555
+ "Crystallize a session into a single structured document. Prefer smart_crystallize for multi-topic sessions. output_path is the folder (default docs/crystallized). Writes go to the active workspace.",
349
556
  {
350
557
  session_context: z.string(),
351
558
  title: z.string().default("Crystallized Knowledge"),
352
559
  output_path: z.string().default("docs/crystallized"),
353
- sources: sourceSchema
354
- },
355
- async ({ session_context, title, output_path, sources }) => text(
356
- await apiRequest("POST", "/knowledge/crystallize-enhanced", {
357
- json: { session_context, title, output_path, sources: sources ?? null }
358
- })
359
- )
360
- );
361
- server.tool(
362
- "create_asset_upload",
363
- 'PREFERRED way to attach an image / PDF / binary to a doc. Returns a short-lived upload_url; transfer the LOCAL file with one curl command so bytes never pass through you (e.g. `curl -F file=@/path/img.webp "<upload_url>"`; add -F insert_heading / -F insert_position to embed). Asset names are predictable (assets/<filename>); a content hash is appended only on a real collision. Embedding is idempotent. Size limit 1 MB \u2014 compress locally first if larger. Params: doc_path (routes the commit), filename, content_type (optional MIME).',
364
- { doc_path: z.string(), filename: z.string(), content_type: z.string().default("") },
365
- async ({ doc_path, filename, content_type }) => text(
366
- await apiRequest("POST", "/assets/upload-url", {
367
- params: { doc_path, filename, content_type }
368
- })
369
- )
370
- );
371
- server.tool(
372
- "list_assets",
373
- "List the image references in a doc and whether each resolves in GitHub. Returns { doc_path, doc_id, assets: [...] }. resolves:false means the markdown points at a path that isn't committed (the usual broken-image cause).",
374
- { doc_path: z.string() },
375
- async ({ doc_path }) => text(await apiRequest("GET", "/assets/list", { params: { doc_path } }))
376
- );
377
- server.tool(
378
- "verify_asset",
379
- "Check that ONE asset reference resolves (exists in GitHub \u2192 will render). relative_path is the path exactly as written in the markdown (e.g. assets/diagram.webp). Returns { resolves, asset_path, raw_url }.",
380
- { doc_path: z.string(), relative_path: z.string() },
381
- async ({ doc_path, relative_path }) => text(await apiRequest("GET", "/assets/resolve", { params: { doc_path, relative_path } }))
382
- );
383
- server.tool(
384
- "delete_asset",
385
- "Delete an asset FILE from the repo. Does NOT rewrite document references \u2014 if the doc still links it, remove that markdown separately via edit_section. relative_path is the path as written in the markdown. Returns { deleted, asset_path }.",
386
- { doc_path: z.string(), relative_path: z.string() },
387
- async ({ doc_path, relative_path }) => text(await apiRequest("DELETE", "/assets/file", { params: { doc_path, relative_path } }))
388
- );
389
- server.tool(
390
- "upload_asset",
391
- "LEGACY base64 upload \u2014 TINY assets only (<= 64 KB, e.g. a small SVG icon). For photos/screenshots or anything larger, use create_asset_upload instead. Commits the asset under an assets/ subdirectory of the doc's repo and returns a markdown snippet with a RELATIVE path. Params: doc_path, filename, content_base64 (data-URL prefix accepted), alt_text.",
392
- {
393
- doc_path: z.string(),
394
- filename: z.string(),
395
- content_base64: z.string(),
396
- alt_text: z.string().default("")
560
+ sources: sourceSchema,
561
+ workspace: wsArg,
562
+ confirm: confirmArg
397
563
  },
398
- async ({ doc_path, filename, content_base64, alt_text }) => text(
399
- await apiRequest("POST", "/assets/upload", {
400
- json: { doc_path, filename, content_base64, alt_text }
401
- })
564
+ async ({ session_context, title, output_path, sources, workspace, confirm }) => text(
565
+ await wr(
566
+ "POST",
567
+ "/knowledge/crystallize-enhanced",
568
+ { json: { session_context, title, output_path, sources: sources ?? null } },
569
+ workspace,
570
+ confirm
571
+ )
402
572
  )
403
573
  );
404
574
  server.tool(
405
575
  "smart_crystallize",
406
- "Intelligently crystallize a session into the brain. Splits the session into topical blocks, searches existing docs per topic, and routes each block to edit/append/create. Opens ONE PR PER AFFECTED REPO if repos are connected. Prefer this over crystallize_session whenever the session spans multiple subjects. Call list_repos() first to nudge create paths toward the right path_prefix.",
407
- { session_context: z.string(), repo_hint: z.string().optional() },
408
- async ({ session_context, repo_hint }) => text(
409
- await apiRequest("POST", "/knowledge/smart-crystallize", {
410
- json: { session_context, repo_hint: repo_hint ?? null }
411
- })
576
+ "Intelligently crystallize a session: split into topical blocks, search existing docs per topic, route each to edit/append/create. Opens one PR per affected repo. Writes go to the active workspace.",
577
+ { session_context: z.string(), repo_hint: z.string().optional(), workspace: wsArg, confirm: confirmArg },
578
+ async ({ session_context, repo_hint, workspace, confirm }) => text(
579
+ await wr(
580
+ "POST",
581
+ "/knowledge/smart-crystallize",
582
+ { json: { session_context, repo_hint: repo_hint ?? null } },
583
+ workspace,
584
+ confirm
585
+ )
412
586
  )
413
587
  );
414
- server.tool(
415
- "list_documents",
416
- "List all documents in the knowledge base with their status.",
417
- {},
418
- async () => text(await apiRequest("GET", "/knowledge/documents"))
419
- );
420
588
  server.tool(
421
589
  "edit_section",
422
- "Edit a section with optimistic locking. Pass the version from get_section to prevent conflicts.",
590
+ "Edit a section with optimistic locking. Pass the version from get_section to prevent conflicts. Writes go to the active workspace.",
423
591
  {
424
592
  path: z.string(),
425
593
  heading: z.string(),
426
594
  new_content: z.string(),
427
- expected_version: z.string()
595
+ expected_version: z.string(),
596
+ workspace: wsArg,
597
+ confirm: confirmArg
428
598
  },
429
- async ({ path, heading, new_content, expected_version }) => text(
430
- await apiRequest("POST", "/knowledge/edit-section", {
431
- json: { path, heading, new_content, expected_version }
432
- })
599
+ async ({ path, heading, new_content, expected_version, workspace, confirm }) => text(
600
+ await wr(
601
+ "POST",
602
+ "/knowledge/edit-section",
603
+ { json: { path, heading, new_content, expected_version } },
604
+ workspace,
605
+ confirm
606
+ )
433
607
  )
434
608
  );
435
609
  server.tool(
436
- "audit_links",
437
- "Audit the knowledge base for orphan documents, one-way links, pairs of documents with high semantic similarity that are not linked in markdown, documents missing source citations, and recurring concepts that lack their own dedicated doc. Returns orphan_documents, one_way_only, similar_but_unlinked, missing_sources, data_gaps. Read-only.",
438
- {},
439
- async () => text(await apiRequest("GET", "/github/audit-links"))
610
+ "create_asset_upload",
611
+ 'PREFERRED way to attach an image/PDF/binary. Returns a short-lived upload_url; transfer the LOCAL file with one curl command (e.g. `curl -F file=@/path/img.webp "<upload_url>"`). Size limit 1 MB. The workspace is sealed into the upload_url at mint. Writes go to the active workspace.',
612
+ { doc_path: z.string(), filename: z.string(), content_type: z.string().default(""), workspace: wsArg, confirm: confirmArg },
613
+ async ({ doc_path, filename, content_type, workspace, confirm }) => text(
614
+ await wr("POST", "/assets/upload-url", { params: { doc_path, filename, content_type } }, workspace, confirm)
615
+ )
440
616
  );
441
617
  server.tool(
442
- "suggest_crosslinks",
443
- "Suggest markdown cross-links to add in a specific document. Returns the top 5 semantically-similar sections from other documents that aren't currently linked from this doc, with ready-to-paste markdown snippets. Read-only.",
444
- { path: z.string() },
445
- async ({ path }) => {
446
- const doc = await apiRequest("GET", `/knowledge/documents/by-path/${path}`);
447
- if (!doc || typeof doc !== "object" || "error" in doc) return text(doc ?? { error: "lookup_failed" });
448
- const docId = doc.id ?? doc.document_id;
449
- if (!docId) return text({ error: `Document not found: ${path}` });
450
- const out = await apiRequest("GET", `/github/suggest-crosslinks/${docId}`);
451
- if (out && typeof out === "object" && "suggestions" in out && !("document_path" in out)) {
452
- out.document_path = path;
453
- }
454
- return text(out);
455
- }
618
+ "upload_asset",
619
+ "LEGACY base64 upload \u2014 TINY assets only (<= 64 KB). For anything larger use create_asset_upload. Writes go to the active workspace.",
620
+ {
621
+ doc_path: z.string(),
622
+ filename: z.string(),
623
+ content_base64: z.string(),
624
+ alt_text: z.string().default(""),
625
+ workspace: wsArg,
626
+ confirm: confirmArg
627
+ },
628
+ async ({ doc_path, filename, content_base64, alt_text, workspace, confirm }) => text(
629
+ await wr("POST", "/assets/upload", { json: { doc_path, filename, content_base64, alt_text } }, workspace, confirm)
630
+ )
456
631
  );
457
632
  server.tool(
458
- "get_audit_log",
459
- "Get the audit log for this workspace. Shows who did what and when.",
460
- { limit: z.number().int().default(50), event_type: z.string().optional() },
461
- async ({ limit, event_type }) => text(
462
- await apiRequest("GET", "/knowledge/audit-log", {
463
- params: { limit, ...event_type ? { event_type } : {} }
464
- })
633
+ "delete_asset",
634
+ "Delete an asset FILE from the repo. Does NOT rewrite document references. relative_path is the path as written in the markdown. Writes go to the active workspace.",
635
+ { doc_path: z.string(), relative_path: z.string(), workspace: wsArg, confirm: confirmArg },
636
+ async ({ doc_path, relative_path, workspace, confirm }) => text(
637
+ await wr("DELETE", "/assets/file", { params: { doc_path, relative_path } }, workspace, confirm)
465
638
  )
466
639
  );
467
640
  return server;
@@ -473,7 +646,7 @@ async function runStdioServer() {
473
646
  }
474
647
 
475
648
  // src/index.ts
476
- var VERSION = "0.1.0";
649
+ var VERSION = "0.2.0";
477
650
  function rejectInService(action) {
478
651
  if (detectMode() === "service") {
479
652
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blenau/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Blenau MCP server + CLI. Connects agents (Claude Code, Cursor, …) to the Blenau knowledge brain over stdio, with credentials in the OS keychain — never a token in a config file or URL.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "author": "Blenau (Ganemo)",
15
15
  "bin": {
16
- "blenau": "dist/index.js"
16
+ "blenau-mcp": "dist/index.js"
17
17
  },
18
18
  "main": "dist/index.js",
19
19
  "files": [