loopctl-mcp-server 2.1.0 → 2.22.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 (3) hide show
  1. package/README.md +49 -12
  2. package/index.js +1262 -67
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -139,9 +139,12 @@ function toContent(result) {
139
139
  /**
140
140
  * Compact variant for list endpoints — strips acceptance_criteria and
141
141
  * description (use get_story for full details). Keeps all other fields.
142
- * Enforces a max page size to prevent MCP response token overflow.
142
+ * A modest DEFAULT page size keeps unprompted responses small, but an explicit
143
+ * `limit` is honored up to the server's max (500) so story enumeration paginates
144
+ * honestly instead of silently capping at 20 and skipping rows on offset advance.
143
145
  */
144
- const MAX_PAGE_SIZE = 20;
146
+ const DEFAULT_STORY_PAGE_SIZE = 20;
147
+ const SERVER_MAX_STORY_PAGE_SIZE = 500;
145
148
 
146
149
  function toContentCompact(result) {
147
150
  if (result && result.error === true) return toContent(result);
@@ -367,7 +370,10 @@ async function listStories({ project_id, agent_status, verified_status, epic_id,
367
370
  if (agent_status) params.set("agent_status", agent_status);
368
371
  if (verified_status) params.set("verified_status", verified_status);
369
372
  if (epic_id) params.set("epic_id", epic_id);
370
- params.set("limit", String(Math.min(limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE)));
373
+ params.set(
374
+ "limit",
375
+ String(Math.min(limit ?? DEFAULT_STORY_PAGE_SIZE, SERVER_MAX_STORY_PAGE_SIZE)),
376
+ );
371
377
  if (offset != null) params.set("offset", String(offset));
372
378
  if (include_token_totals) params.set("include_token_totals", "true");
373
379
 
@@ -377,7 +383,10 @@ async function listStories({ project_id, agent_status, verified_status, epic_id,
377
383
 
378
384
  async function listReadyStories({ project_id, limit }) {
379
385
  const params = new URLSearchParams({ project_id });
380
- params.set("limit", String(Math.min(limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE)));
386
+ params.set(
387
+ "limit",
388
+ String(Math.min(limit ?? DEFAULT_STORY_PAGE_SIZE, SERVER_MAX_STORY_PAGE_SIZE)),
389
+ );
381
390
 
382
391
  const result = await apiCall("GET", `/api/v1/stories/ready?${params}`);
383
392
  return toContentCompact(result);
@@ -579,31 +588,214 @@ async function setTokenBudget({ scope_type, scope_id, budget_millicents, alert_t
579
588
 
580
589
  // --- Knowledge Wiki Tools (agent key) ---
581
590
 
582
- async function knowledgeIndex({ project_id, story_id }) {
591
+ async function knowledgeIndex({ project_id, story_id, category, tags, match, offset, limit, fields }) {
592
+ if (project_id && !UUID_RE.test(project_id)) {
593
+ return {
594
+ content: [{ type: "text", text: "Error: project_id must be a canonical UUID (8-4-4-4-12 hex)." }],
595
+ isError: true,
596
+ };
597
+ }
583
598
  const basePath = project_id
584
599
  ? `/api/v1/projects/${project_id}/knowledge/index`
585
600
  : "/api/v1/knowledge/index";
586
601
  const params = new URLSearchParams();
587
602
  if (story_id) params.set("story_id", story_id);
603
+ if (category) params.set("category", category);
604
+ if (tags) params.set("tags", tags);
605
+ if (match) params.set("match", match);
606
+ if (offset != null) params.set("offset", String(offset));
607
+ if (limit != null) params.set("limit", String(limit));
608
+ if (fields) params.set("fields", Array.isArray(fields) ? fields.join(",") : fields);
588
609
  const qs = params.toString();
589
610
  const path = qs ? `${basePath}?${qs}` : basePath;
590
611
  const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
591
612
  return toContent(result);
592
613
  }
593
614
 
594
- async function knowledgeSearch({ q, project_id, story_id, category, tags, mode, limit }) {
595
- const params = new URLSearchParams({ q });
615
+ async function knowledgeStats({ project_id }) {
616
+ if (project_id && !UUID_RE.test(project_id)) {
617
+ return {
618
+ content: [{ type: "text", text: "Error: project_id must be a canonical UUID (8-4-4-4-12 hex)." }],
619
+ isError: true,
620
+ };
621
+ }
622
+ const path = project_id
623
+ ? `/api/v1/projects/${project_id}/knowledge/stats`
624
+ : "/api/v1/knowledge/stats";
625
+ const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
626
+ return toContent(result);
627
+ }
628
+
629
+ async function knowledgeGraph({ article_id, depth, project_id }) {
630
+ const params = new URLSearchParams();
631
+ params.set("article_id", article_id);
632
+ if (depth != null) params.set("depth", String(depth));
633
+ if (project_id) params.set("project_id", project_id);
634
+ const result = await apiCall(
635
+ "GET",
636
+ `/api/v1/knowledge/graph?${params}`,
637
+ null,
638
+ process.env.LOOPCTL_AGENT_KEY,
639
+ );
640
+ return toContent(result);
641
+ }
642
+
643
+ async function knowledgeSuggestLinks({ article_id, limit, threshold }) {
644
+ const params = new URLSearchParams();
645
+ if (limit != null) params.set("limit", String(limit));
646
+ if (threshold != null) params.set("threshold", String(threshold));
647
+ const qs = params.toString();
648
+ const base = `/api/v1/knowledge/articles/${article_id}/suggested_links`;
649
+ const result = await apiCall(
650
+ "GET",
651
+ qs ? `${base}?${qs}` : base,
652
+ null,
653
+ process.env.LOOPCTL_AGENT_KEY,
654
+ );
655
+ return toContent(result);
656
+ }
657
+
658
+ async function knowledgeDistantPairs({ min_distance, max_distance, bridge_path, limit, offset }) {
659
+ const params = new URLSearchParams();
660
+ if (min_distance != null) params.set("min_distance", String(min_distance));
661
+ if (max_distance != null) params.set("max_distance", String(max_distance));
662
+ if (bridge_path === true) params.set("bridge_path", "true");
663
+ if (limit != null) params.set("limit", String(limit));
664
+ if (offset != null) params.set("offset", String(offset));
665
+ const qs = params.toString();
666
+ const path = qs ? `/api/v1/knowledge/pairs?${qs}` : "/api/v1/knowledge/pairs";
667
+ const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
668
+ return toContent(result);
669
+ }
670
+
671
+ async function knowledgeNovelty({ ideas, texts, prior_tag }) {
672
+ // Accept either shape (#169): `ideas` (strings or objects) or `texts` (strings).
673
+ const body = {};
674
+ if (ideas !== undefined) body.ideas = ideas;
675
+ if (texts !== undefined) body.texts = texts;
676
+ if (prior_tag) body.prior_tag = prior_tag;
677
+ const result = await apiCall("POST", "/api/v1/knowledge/novelty", body, process.env.LOOPCTL_AGENT_KEY);
678
+ return toContent(result);
679
+ }
680
+
681
+ async function knowledgeRandomWalk({ start_id, length }) {
682
+ const params = new URLSearchParams();
683
+ params.set("start_id", start_id);
684
+ if (length != null) params.set("length", String(length));
685
+ const result = await apiCall(
686
+ "GET",
687
+ `/api/v1/knowledge/walk?${params}`,
688
+ null,
689
+ process.env.LOOPCTL_AGENT_KEY,
690
+ );
691
+ return toContent(result);
692
+ }
693
+
694
+ async function knowledgeCount({
695
+ project_id,
696
+ category,
697
+ status,
698
+ tags,
699
+ match,
700
+ source_type,
701
+ source_id,
702
+ idempotency_key,
703
+ }) {
704
+ const params = new URLSearchParams();
705
+ if (project_id) params.set("project_id", project_id);
706
+ if (category) params.set("category", category);
707
+ if (status) params.set("status", status);
708
+ if (tags) params.set("tags", tags);
709
+ if (match) params.set("match", match);
710
+ if (source_type) params.set("source_type", source_type);
711
+ if (source_id) params.set("source_id", source_id);
712
+ if (idempotency_key) params.set("idempotency_key", idempotency_key);
713
+ const qs = params.toString();
714
+ const path = qs ? `/api/v1/knowledge/count?${qs}` : "/api/v1/knowledge/count";
715
+ const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
716
+ return toContent(result);
717
+ }
718
+
719
+ async function knowledgeFacets({
720
+ project_id,
721
+ category,
722
+ status,
723
+ tags,
724
+ match,
725
+ tag_prefix,
726
+ limit,
727
+ }) {
728
+ const params = new URLSearchParams();
729
+ params.set("group_by", "tag");
730
+ if (project_id) params.set("project_id", project_id);
731
+ if (category) params.set("category", category);
732
+ if (status) params.set("status", status);
733
+ if (tags) params.set("tags", tags);
734
+ if (match) params.set("match", match);
735
+ if (tag_prefix) params.set("tag_prefix", tag_prefix);
736
+ if (limit != null) params.set("limit", String(limit));
737
+ const result = await apiCall(
738
+ "GET",
739
+ `/api/v1/knowledge/facets?${params}`,
740
+ null,
741
+ process.env.LOOPCTL_AGENT_KEY,
742
+ );
743
+ return toContent(result);
744
+ }
745
+
746
+ async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, limit, offset }) {
747
+ const params = new URLSearchParams();
748
+ if (q != null && q !== "") params.set("q", q);
596
749
  if (project_id) params.set("project_id", project_id);
597
750
  if (story_id) params.set("story_id", story_id);
598
751
  if (category) params.set("category", category);
599
752
  if (tags) params.set("tags", tags);
753
+ if (match) params.set("match", match);
600
754
  if (mode) params.set("mode", mode);
601
755
  if (limit != null) params.set("limit", String(limit));
756
+ if (offset != null) params.set("offset", String(offset));
602
757
 
603
758
  const result = await apiCall("GET", `/api/v1/knowledge/search?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
604
759
  return toContent(result);
605
760
  }
606
761
 
762
+ async function knowledgeList({
763
+ project_id,
764
+ category,
765
+ status,
766
+ tags,
767
+ match,
768
+ source_type,
769
+ source_id,
770
+ idempotency_key,
771
+ limit,
772
+ offset,
773
+ include_body,
774
+ }) {
775
+ const params = new URLSearchParams();
776
+ if (project_id) params.set("project_id", project_id);
777
+ if (category) params.set("category", category);
778
+ if (status) params.set("status", status);
779
+ if (tags) params.set("tags", tags);
780
+ if (match) params.set("match", match);
781
+ if (source_type) params.set("source_type", source_type);
782
+ if (source_id) params.set("source_id", source_id);
783
+ if (idempotency_key) params.set("idempotency_key", idempotency_key);
784
+ if (limit != null) params.set("limit", String(limit));
785
+ if (offset != null) params.set("offset", String(offset));
786
+ // Body-less summary by default (safe to enumerate large pages); opt into full
787
+ // bodies (byte-budget bounded server-side) with include_body: true.
788
+ if (include_body === true) params.set("include_body", "true");
789
+
790
+ const result = await apiCall(
791
+ "GET",
792
+ `/api/v1/articles?${params}`,
793
+ null,
794
+ process.env.LOOPCTL_AGENT_KEY,
795
+ );
796
+ return toContent(result);
797
+ }
798
+
607
799
  async function knowledgeGet({ article_id, project_id, story_id }) {
608
800
  const params = new URLSearchParams();
609
801
  if (project_id) params.set("project_id", project_id);
@@ -614,24 +806,63 @@ async function knowledgeGet({ article_id, project_id, story_id }) {
614
806
  return toContent(result);
615
807
  }
616
808
 
617
- async function knowledgeContext({ query, project_id, story_id, limit, recency_weight }) {
809
+ async function knowledgeContext({
810
+ query,
811
+ project_id,
812
+ story_id,
813
+ limit,
814
+ recency_weight,
815
+ memory_types,
816
+ agents,
817
+ conversation_id,
818
+ }) {
618
819
  const params = new URLSearchParams({ query });
619
820
  if (project_id) params.set("project_id", project_id);
620
821
  if (story_id) params.set("story_id", story_id);
621
822
  if (limit != null) params.set("limit", String(limit));
622
823
  if (recency_weight != null) params.set("recency_weight", String(recency_weight));
824
+ // Agent-memory scoping (comma-separated lists are OR'd; conversation_id is exact).
825
+ if (memory_types) params.set("memory_types", Array.isArray(memory_types) ? memory_types.join(",") : memory_types);
826
+ if (agents) params.set("agents", Array.isArray(agents) ? agents.join(",") : agents);
827
+ if (conversation_id) params.set("conversation_id", conversation_id);
623
828
 
624
829
  const result = await apiCall("GET", `/api/v1/knowledge/context?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
625
830
  return toContent(result);
626
831
  }
627
832
 
628
- async function knowledgeCreate({ title, body, category, tags, project_id }) {
833
+ async function knowledgeCreate({
834
+ title,
835
+ body,
836
+ category,
837
+ tags,
838
+ project_id,
839
+ draft,
840
+ source_type,
841
+ source_id,
842
+ idempotency_key,
843
+ metadata,
844
+ }) {
629
845
  const payload = { title, body };
630
846
  if (category) payload.category = category;
631
847
  if (tags) payload.tags = tags;
632
848
  if (project_id) payload.project_id = project_id;
849
+ if (source_type) payload.source_type = source_type;
850
+ if (source_id) payload.source_id = source_id;
851
+ if (idempotency_key) payload.idempotency_key = idempotency_key;
852
+ if (metadata) payload.metadata = metadata;
633
853
 
634
- const result = await apiCall("POST", "/api/v1/articles", payload, process.env.LOOPCTL_AGENT_KEY);
854
+ // Articles publish on create by default for every role (including agent), so a
855
+ // plain create routes through the agent key and is immediately visible. Pass
856
+ // draft:true to stage it for later review instead — publish it afterwards with
857
+ // knowledge_publish.
858
+ if (draft) payload.draft = true;
859
+
860
+ const result = await apiCall(
861
+ "POST",
862
+ "/api/v1/articles",
863
+ payload,
864
+ process.env.LOOPCTL_AGENT_KEY,
865
+ );
635
866
  return toContent(result);
636
867
  }
637
868
 
@@ -649,15 +880,141 @@ async function knowledgeBulkPublish({ article_ids }) {
649
880
  { article_ids },
650
881
  process.env.LOOPCTL_USER_KEY
651
882
  );
883
+
884
+ // Partial success returns 200 even when nothing published. Surface a warning
885
+ // so the agent doesn't treat not_found/errored ids as success.
886
+ const counts = result?.meta?.counts;
887
+ if (counts && (counts.not_found > 0 || counts.errored > 0)) {
888
+ const warning =
889
+ `WARNING: bulk-publish was partial — published ${counts.published}, ` +
890
+ `skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
891
+ `(of ${counts.requested} requested). Inspect meta.results for per-id outcomes; ` +
892
+ `not_found/errored ids were NOT published.`;
893
+ return {
894
+ content: [{ type: "text", text: warning }, ...toContent(result).content],
895
+ };
896
+ }
897
+
898
+ return toContent(result);
899
+ }
900
+
901
+ async function knowledgeBulkUnpublish({ article_ids }) {
902
+ const result = await apiCall(
903
+ "POST",
904
+ "/api/v1/knowledge/bulk-unpublish",
905
+ { article_ids },
906
+ process.env.LOOPCTL_USER_KEY
907
+ );
908
+
909
+ // Partial success returns 200 even when nothing unpublished. Surface a warning
910
+ // so the agent doesn't treat not_found/errored ids as success.
911
+ const counts = result?.meta?.counts;
912
+ if (counts && (counts.not_found > 0 || counts.errored > 0)) {
913
+ const warning =
914
+ `WARNING: bulk-unpublish was partial — unpublished ${counts.unpublished}, ` +
915
+ `skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
916
+ `(of ${counts.requested} requested). Inspect meta.results for per-id outcomes; ` +
917
+ `not_found/errored ids were NOT unpublished.`;
918
+ return {
919
+ content: [{ type: "text", text: warning }, ...toContent(result).content],
920
+ };
921
+ }
922
+
923
+ return toContent(result);
924
+ }
925
+
926
+ async function knowledgeUnpublish({ article_id }) {
927
+ const result = await apiCall(
928
+ "POST",
929
+ `/api/v1/articles/${article_id}/unpublish`,
930
+ null,
931
+ process.env.LOOPCTL_USER_KEY
932
+ );
933
+ return toContent(result);
934
+ }
935
+
936
+ async function knowledgeArchive({ article_id }) {
937
+ const result = await apiCall(
938
+ "POST",
939
+ `/api/v1/articles/${article_id}/archive`,
940
+ null,
941
+ process.env.LOOPCTL_USER_KEY
942
+ );
943
+ return toContent(result);
944
+ }
945
+
946
+ async function knowledgeDelete({ article_id }) {
947
+ const result = await apiCall(
948
+ "DELETE",
949
+ `/api/v1/articles/${article_id}`,
950
+ null,
951
+ process.env.LOOPCTL_USER_KEY
952
+ );
953
+ return toContent(result);
954
+ }
955
+
956
+ async function knowledgeBulkDelete({
957
+ article_ids,
958
+ source_type,
959
+ source_id,
960
+ tag,
961
+ confirm,
962
+ dry_run,
963
+ hard,
964
+ token,
965
+ confirm_hash,
966
+ }) {
967
+ const payload = {};
968
+ if (article_ids) payload.article_ids = article_ids;
969
+ if (source_type) payload.source_type = source_type;
970
+ if (source_id) payload.source_id = source_id;
971
+ if (tag) payload.tag = tag;
972
+ if (confirm) payload.confirm = confirm;
973
+ // US-27.12: dry-run preview + irreversible hard delete via a single-use frozen
974
+ // token. dry_run=true mutates nothing (returns meta.would_affect; for the hard
975
+ // path a single-use meta.token); hard=true + token performs the FK-correct
976
+ // IRREVERSIBLE delete over the frozen id-set. Oversized selectors echo
977
+ // meta.confirm_hash for re-confirm-on-drift instead of a token.
978
+ if (dry_run) payload.dry_run = true;
979
+ if (hard) payload.hard = true;
980
+ if (token) payload.token = token;
981
+ if (confirm_hash) payload.confirm_hash = confirm_hash;
982
+
983
+ const result = await apiCall(
984
+ "POST",
985
+ "/api/v1/knowledge/bulk-delete",
986
+ payload,
987
+ process.env.LOOPCTL_USER_KEY,
988
+ );
989
+
990
+ // Partial success returns 200 even when some/none archived — surface a warning
991
+ // so the agent doesn't treat not_found/errored, or a nothing-archived run, as
992
+ // success.
993
+ const counts = result?.meta?.counts;
994
+ if (
995
+ counts &&
996
+ (counts.not_found > 0 ||
997
+ counts.errored > 0 ||
998
+ (counts.archived === 0 && counts.requested > 0))
999
+ ) {
1000
+ const warning =
1001
+ `WARNING: bulk-delete was partial — archived ${counts.archived}, ` +
1002
+ `skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
1003
+ `(of ${counts.requested}). Inspect meta.results; not_found/errored ids were NOT archived.`;
1004
+ return {
1005
+ content: [{ type: "text", text: warning }, ...toContent(result).content],
1006
+ };
1007
+ }
1008
+
652
1009
  return toContent(result);
653
1010
  }
654
1011
 
655
1012
  async function knowledgeDrafts({ limit, offset, project_id }) {
656
1013
  const params = new URLSearchParams();
657
- params.set(
658
- "limit",
659
- String(Math.min(limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE))
660
- );
1014
+ // Pass `limit` through verbatim (like knowledge_list/index/search) so the
1015
+ // server honors it up to its max page size and returns 400 above it — rather
1016
+ // than silently clamping client-side, which would truncate draft enumeration.
1017
+ if (limit != null) params.set("limit", String(limit));
661
1018
  if (offset != null) params.set("offset", String(offset));
662
1019
  if (project_id) params.set("project_id", project_id);
663
1020
  const path = `/api/v1/knowledge/drafts?${params.toString()}`;
@@ -679,24 +1036,28 @@ async function knowledgeLint({ project_id, stale_days, min_coverage, max_per_cat
679
1036
  return toContent(result);
680
1037
  }
681
1038
 
682
- async function knowledgeIngest({ url, content, source_type, project_id }) {
1039
+ async function knowledgeIngest({ url, content, source_type, project_id, publish }) {
683
1040
  const body = { source_type };
684
1041
  if (url) body.url = url;
685
1042
  if (content) body.content = content;
686
1043
  if (project_id) body.project_id = project_id;
1044
+ if (publish) body.publish = true;
687
1045
  const result = await apiCall("POST", "/api/v1/knowledge/ingest", body, process.env.LOOPCTL_ORCH_KEY);
688
1046
  return toContent(result);
689
1047
  }
690
1048
 
691
- async function knowledgeIngestBatch({ items, project_id }) {
692
- // If a batch-level project_id is supplied, apply it as a default to every
693
- // item that doesn't already set its own.
1049
+ async function knowledgeIngestBatch({ items, project_id, publish }) {
1050
+ // Apply batch-level project_id / publish as defaults to any item that doesn't
1051
+ // set its own.
694
1052
  const resolvedItems = Array.isArray(items)
695
- ? items.map((item) =>
696
- project_id && item && item.project_id == null
697
- ? { ...item, project_id }
698
- : item
699
- )
1053
+ ? items.map((item) => {
1054
+ if (!item) return item;
1055
+ let resolved = item;
1056
+ if (project_id && resolved.project_id == null)
1057
+ resolved = { ...resolved, project_id };
1058
+ if (publish && resolved.publish == null) resolved = { ...resolved, publish: true };
1059
+ return resolved;
1060
+ })
700
1061
  : items;
701
1062
 
702
1063
  const result = await apiCall(
@@ -822,12 +1183,18 @@ async function knowledgeExport({ project_id }) {
822
1183
  ? `/api/v1/projects/${project_id}/knowledge/export`
823
1184
  : "/api/v1/knowledge/export";
824
1185
  const baseUrl = getBaseUrl();
825
- const downloadCmd = `curl -H "Authorization: Bearer $LOOPCTL_ORCH_KEY" "${baseUrl}${basePath}" -o knowledge-export.zip`;
1186
+ // The export endpoint is role:user use LOOPCTL_USER_KEY. (LOOPCTL_ORCH_KEY is
1187
+ // orchestrator role, which is BELOW user in the hierarchy and would 403.) The
1188
+ // endpoint streams a gzipped tar (`application/gzip`), so save it as `.tar.gz`.
1189
+ const downloadCmd = `curl -H "Authorization: Bearer $LOOPCTL_USER_KEY" "${baseUrl}${basePath}" -o knowledge-export.tar.gz`;
826
1190
  return {
827
1191
  content: [{
828
1192
  type: "text",
829
1193
  text: JSON.stringify({
830
- message: "Knowledge export produces a ZIP file. Use the curl command below to download it directly.",
1194
+ message:
1195
+ "Knowledge export streams a bounded-memory gzipped tar (.tar.gz). Use the curl " +
1196
+ "command below to download it directly. Requires a user-role key " +
1197
+ "(LOOPCTL_USER_KEY); extract with `tar -xzf knowledge-export.tar.gz`.",
831
1198
  command: downloadCmd,
832
1199
  endpoint: `${baseUrl}${basePath}`,
833
1200
  }, null, 2),
@@ -835,6 +1202,136 @@ async function knowledgeExport({ project_id }) {
835
1202
  };
836
1203
  }
837
1204
 
1205
+ // --- OKF (Open Knowledge Format) interchange ---
1206
+
1207
+ // Guard a user-supplied absolute filesystem path against pseudo-filesystems.
1208
+ function assertSafeAbsolutePath(nodePath, p, label) {
1209
+ if (!nodePath.isAbsolute(p)) {
1210
+ return `${label} must be an absolute path (got '${p}').`;
1211
+ }
1212
+ const blocked = ["/proc", "/dev", "/sys"];
1213
+ if (blocked.some((b) => p === b || p.startsWith(b + "/"))) {
1214
+ return `${label} refused: '${p}' targets a pseudo-filesystem path.`;
1215
+ }
1216
+ return null;
1217
+ }
1218
+
1219
+ async function knowledgeOkfExport({ project_id, out_dir }) {
1220
+ const basePath = project_id
1221
+ ? `/api/v1/projects/${project_id}/knowledge/okf/export`
1222
+ : "/api/v1/knowledge/okf/export";
1223
+
1224
+ const result = await apiCall(
1225
+ "GET",
1226
+ `${basePath}?format=json`,
1227
+ null,
1228
+ process.env.LOOPCTL_USER_KEY,
1229
+ );
1230
+
1231
+ if (result.error) return toContent(result);
1232
+
1233
+ const bundle = result.data || result;
1234
+ const files = bundle.files || {};
1235
+ const meta = bundle.meta || {};
1236
+
1237
+ if (!out_dir) {
1238
+ return toContent({ meta, file_count: Object.keys(files).length, files });
1239
+ }
1240
+
1241
+ const nodePath = await import("node:path");
1242
+ const pathErr = assertSafeAbsolutePath(nodePath, out_dir, "out_dir");
1243
+ if (pathErr) {
1244
+ return { content: [{ type: "text", text: `Error: ${pathErr}` }], isError: true };
1245
+ }
1246
+ // Normalize so `..`/trailing-slash can't defeat the per-file fence below.
1247
+ const root = nodePath.resolve(out_dir);
1248
+
1249
+ const fs = await import("node:fs/promises");
1250
+ let written = 0;
1251
+ for (const [rel, content] of Object.entries(files)) {
1252
+ // Keep writes inside root even if a server-supplied path is adversarial.
1253
+ const dest = nodePath.resolve(root, rel);
1254
+ if (dest !== root && !dest.startsWith(root + nodePath.sep)) continue;
1255
+ await fs.mkdir(nodePath.dirname(dest), { recursive: true });
1256
+ await fs.writeFile(dest, content, "utf8");
1257
+ written += 1;
1258
+ }
1259
+
1260
+ return toContent({ meta, out_dir: root, written });
1261
+ }
1262
+
1263
+ async function knowledgeOkfImport({ bundle_dir, project_id, merge, dry_run }) {
1264
+ const nodePath = await import("node:path");
1265
+ const pathErr = assertSafeAbsolutePath(nodePath, bundle_dir, "bundle_dir");
1266
+ if (pathErr) {
1267
+ return { content: [{ type: "text", text: `Error: ${pathErr}` }], isError: true };
1268
+ }
1269
+
1270
+ const fs = await import("node:fs/promises");
1271
+
1272
+ let stat;
1273
+ try {
1274
+ stat = await fs.stat(bundle_dir);
1275
+ } catch {
1276
+ return { content: [{ type: "text", text: `Error: bundle_dir '${bundle_dir}' not found.` }], isError: true };
1277
+ }
1278
+ if (!stat.isDirectory()) {
1279
+ return { content: [{ type: "text", text: `Error: bundle_dir '${bundle_dir}' is not a directory.` }], isError: true };
1280
+ }
1281
+
1282
+ // Kept in step with the server-side caps in okf_controller.ex.
1283
+ const MAX_FILES = 10_000;
1284
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
1285
+ const files = {};
1286
+ let totalBytes = 0;
1287
+
1288
+ async function walk(dir) {
1289
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1290
+ for (const entry of entries) {
1291
+ const abs = nodePath.join(dir, entry.name);
1292
+ // Never traverse or read through symlinks (escape out of bundle_dir).
1293
+ if (entry.isSymbolicLink()) {
1294
+ continue;
1295
+ } else if (entry.isDirectory()) {
1296
+ await walk(abs);
1297
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
1298
+ if (Object.keys(files).length >= MAX_FILES) {
1299
+ throw new Error(`bundle exceeds ${MAX_FILES} files`);
1300
+ }
1301
+ const content = await fs.readFile(abs, "utf8");
1302
+ totalBytes += Buffer.byteLength(content, "utf8");
1303
+ if (totalBytes > MAX_TOTAL_BYTES) {
1304
+ throw new Error("bundle exceeds 25 MiB total");
1305
+ }
1306
+ files[nodePath.relative(bundle_dir, abs).split(nodePath.sep).join("/")] = content;
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ try {
1312
+ await walk(bundle_dir);
1313
+ } catch (e) {
1314
+ return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
1315
+ }
1316
+
1317
+ if (Object.keys(files).length === 0) {
1318
+ return { content: [{ type: "text", text: `Error: no .md files found under '${bundle_dir}'.` }], isError: true };
1319
+ }
1320
+
1321
+ const payload = { files };
1322
+ if (project_id) payload.project_id = project_id;
1323
+ if (merge != null) payload.merge = merge;
1324
+ if (dry_run != null) payload.dry_run = dry_run;
1325
+
1326
+ const result = await apiCall(
1327
+ "POST",
1328
+ "/api/v1/knowledge/okf/import",
1329
+ payload,
1330
+ process.env.LOOPCTL_USER_KEY,
1331
+ );
1332
+ return toContent(result);
1333
+ }
1334
+
838
1335
  // --- Discovery Tools ---
839
1336
 
840
1337
  async function listRoutes() {
@@ -1068,8 +1565,8 @@ const TOOLS = [
1068
1565
  description:
1069
1566
  "List stories for a project, optionally filtered by agent_status, verified_status, or epic_id. " +
1070
1567
  "Returns compact results (no acceptance_criteria/description) — use get_story for full details. " +
1071
- "Max 20 per page. Use offset to paginate (response includes total_count). " +
1072
- "Filter by epic_id or agent_status to reduce result size.",
1568
+ "Defaults to 20 per page; pass `limit` up to 500 to page larger. Use offset to paginate " +
1569
+ "(response includes total_count). Filter by epic_id or agent_status to reduce result size.",
1073
1570
  inputSchema: {
1074
1571
  type: "object",
1075
1572
  properties: {
@@ -1092,7 +1589,7 @@ const TOOLS = [
1092
1589
  },
1093
1590
  limit: {
1094
1591
  type: "integer",
1095
- description: "Maximum number of stories to return.",
1592
+ description: "Maximum number of stories to return (default 20, max 500).",
1096
1593
  },
1097
1594
  offset: {
1098
1595
  type: "integer",
@@ -1111,7 +1608,7 @@ const TOOLS = [
1111
1608
  description:
1112
1609
  "List stories that are ready to be worked on (contracted, dependencies met). " +
1113
1610
  "Returns compact results — use get_story for full details. " +
1114
- "Max 20 per page. Response includes total_count for pagination.",
1611
+ "Defaults to 20 per page; pass `limit` up to 500 to page larger. Response includes total_count.",
1115
1612
  inputSchema: {
1116
1613
  type: "object",
1117
1614
  properties: {
@@ -1121,7 +1618,7 @@ const TOOLS = [
1121
1618
  },
1122
1619
  limit: {
1123
1620
  type: "integer",
1124
- description: "Maximum number of stories to return.",
1621
+ description: "Maximum number of stories to return (default 20, max 500).",
1125
1622
  },
1126
1623
  },
1127
1624
  required: ["project_id"],
@@ -1530,7 +2027,13 @@ const TOOLS = [
1530
2027
  {
1531
2028
  name: "knowledge_index",
1532
2029
  description:
1533
- "Load the knowledge wiki catalog at session start. Returns article metadata grouped by category. " +
2030
+ "Browse/paginate the knowledge wiki catalog. Returns LIGHTWEIGHT article metadata grouped by " +
2031
+ "category — by default only id, title, category per article (NOT full metadata). " +
2032
+ "Honors category/tags filters and offset/limit pagination with deterministic ordering, so " +
2033
+ "every article is reachable (meta.categories reports per-category counts over the whole " +
2034
+ "filtered set; meta.has_more/meta.truncated signal more pages). Use `fields` to control the " +
2035
+ "projection (request tags/status/updated_at explicitly; id and category are always included) " +
2036
+ "to keep the payload small on large catalogs. " +
1534
2037
  "Pass story_id when working on a loopctl story so reads attribute correctly.",
1535
2038
  inputSchema: {
1536
2039
  type: "object",
@@ -1545,51 +2048,395 @@ const TOOLS = [
1545
2048
  format: "uuid",
1546
2049
  description: "Optional: loopctl story UUID for attribution tracking.",
1547
2050
  },
2051
+ category: {
2052
+ type: "string",
2053
+ enum: ["pattern", "convention", "decision", "finding", "reference"],
2054
+ description: "Optional: filter to a single category. Rejected (400) if unknown.",
2055
+ },
2056
+ tags: {
2057
+ type: "string",
2058
+ description: "Optional: comma-separated tags; matches articles carrying ANY of them.",
2059
+ },
2060
+ match: {
2061
+ type: "string",
2062
+ enum: ["any", "all"],
2063
+ description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
2064
+ },
2065
+ offset: {
2066
+ type: "integer",
2067
+ description: "Optional: rows to skip for pagination (default 0).",
2068
+ },
2069
+ limit: {
2070
+ type: "integer",
2071
+ description: "Optional: max articles per page (default 1000, max 1000).",
2072
+ },
2073
+ fields: {
2074
+ type: "array",
2075
+ items: {
2076
+ type: "string",
2077
+ enum: ["id", "title", "category", "tags", "status", "updated_at"],
2078
+ },
2079
+ description:
2080
+ "Optional: projection of article fields to return. Default: id, title, category. `id` is always included.",
2081
+ },
1548
2082
  },
1549
2083
  required: [],
1550
2084
  },
1551
2085
  },
1552
2086
  {
1553
- name: "knowledge_search",
2087
+ name: "knowledge_list",
1554
2088
  description:
1555
- "Search the knowledge wiki by topic. Returns snippets. " +
1556
- "Pass story_id when working on a loopctl story so reads attribute correctly.",
2089
+ "List articles (id, title, category, status, tags, source_type, source_id, " +
2090
+ "idempotency_key, timestamps), filtered and paginated. **Body-less summary by default** " +
2091
+ "— the right tool to enumerate, dedup, or repair at scale (safe to page up to limit=1000). " +
2092
+ "Pass `include_body: true` to also return the full `body`, in which case the server bounds " +
2093
+ "the page by a ~5 MB serialized-body budget and returns meta.next_offset/has_more/" +
2094
+ "byte_truncated for continuation (for a single full body use knowledge_get; for the relevant " +
2095
+ "bodies use knowledge_context; for a bulk content dump use knowledge_export). Unlike " +
2096
+ "knowledge_search (ranked, PUBLISHED-only, lags writes while embeddings index) and " +
2097
+ "knowledge_index (id/title/category only), this is the LAG-FREE, ALL-STATUS read of the DB " +
2098
+ "of record (draft, published, archived, superseded visible). Use for idempotency/existence " +
2099
+ "checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read " +
2100
+ "`meta.total_count` (exact) to answer \"does an article for X already exist?\" reliably " +
2101
+ "right after a write. Paginate via offset/limit.",
1557
2102
  inputSchema: {
1558
2103
  type: "object",
1559
2104
  properties: {
1560
- q: {
1561
- type: "string",
1562
- description: "Search query string.",
1563
- },
1564
2105
  project_id: {
1565
2106
  type: "string",
1566
2107
  format: "uuid",
1567
- description: "Optional: scope search to a specific project UUID.",
2108
+ description: "Optional: scope to a project UUID.",
1568
2109
  },
1569
- story_id: {
2110
+ category: {
1570
2111
  type: "string",
1571
- format: "uuid",
1572
- description: "Optional: loopctl story UUID for attribution tracking.",
2112
+ description: "Optional: filter by category.",
1573
2113
  },
1574
- category: {
2114
+ status: {
1575
2115
  type: "string",
1576
- description: "Optional: filter results by category.",
2116
+ description: "Optional: filter by status (draft, published, archived, superseded).",
1577
2117
  },
1578
2118
  tags: {
1579
2119
  type: "string",
1580
- description: "Optional: comma-separated tags to filter by.",
2120
+ description: "Optional: comma-separated tags; matches articles carrying ANY of them.",
1581
2121
  },
1582
- mode: {
2122
+ match: {
1583
2123
  type: "string",
1584
- enum: ["keyword", "semantic", "combined"],
1585
- description: "Optional: search mode (keyword, semantic, or combined).",
2124
+ enum: ["any", "all"],
2125
+ description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
1586
2126
  },
1587
- limit: {
1588
- type: "integer",
1589
- description: "Optional: maximum number of results to return.",
2127
+ source_type: {
2128
+ type: "string",
2129
+ description: "Optional: filter by source_type.",
2130
+ },
2131
+ source_id: {
2132
+ type: "string",
2133
+ description: "Optional: filter by source_id (a malformed id matches nothing).",
2134
+ },
2135
+ idempotency_key: {
2136
+ type: "string",
2137
+ description:
2138
+ "Optional: filter by exact idempotency_key — the lag-free existence check for a " +
2139
+ "prior capture.",
2140
+ },
2141
+ offset: {
2142
+ type: "integer",
2143
+ description: "Optional: rows to skip for pagination (default 0).",
2144
+ },
2145
+ limit: {
2146
+ type: "integer",
2147
+ description:
2148
+ "Optional: max articles per page (default 20, max 1000). A limit above " +
2149
+ "the max is rejected with 400 — not silently clamped — so paging by offset " +
2150
+ "over meta.total_count enumerates the complete set without skipping rows.",
2151
+ },
2152
+ include_body: {
2153
+ type: "boolean",
2154
+ description:
2155
+ "Optional (default false): when true, include the full article `body`. The page is " +
2156
+ "then bounded by a ~5 MB serialized-body budget (it may return fewer than `limit` " +
2157
+ "rows); continue via meta.next_offset while meta.has_more is true. Leave false to " +
2158
+ "enumerate metadata cheaply at scale.",
2159
+ },
2160
+ },
2161
+ required: [],
2162
+ },
2163
+ },
2164
+ {
2165
+ name: "knowledge_stats",
2166
+ description:
2167
+ "Get aggregate article counts for the wiki without pulling any article metadata. " +
2168
+ "Returns { total, by_category, by_status } via cheap COUNT(*) GROUP BY. This is the " +
2169
+ "right tool to answer \"how many articles are in this project?\" — knowledge_index " +
2170
+ "pages article metadata and knowledge_search's total_count is query-dependent. Counts " +
2171
+ "span all statuses (draft/published/archived/superseded); see by_status for the split. " +
2172
+ "Note: `total` is NOT the same as knowledge_index's meta.total_count (which counts only " +
2173
+ "published) — they differ whenever drafts/archived exist.",
2174
+ inputSchema: {
2175
+ type: "object",
2176
+ properties: {
2177
+ project_id: {
2178
+ type: "string",
2179
+ format: "uuid",
2180
+ description:
2181
+ "Optional: scope counts to a project (counts both tenant-wide and project-specific articles).",
2182
+ },
2183
+ },
2184
+ required: [],
2185
+ },
2186
+ },
2187
+ {
2188
+ name: "knowledge_count",
2189
+ description:
2190
+ "Count articles matching filters WITHOUT returning any rows. Accepts the same filters " +
2191
+ "as knowledge_list (category, status, tags, match, source_type, source_id, " +
2192
+ "idempotency_key, project_id). With tags + match:'all' it counts articles carrying ALL " +
2193
+ "the tags; add status:'published' to answer \"how many PUBLISHED articles tagged both X " +
2194
+ "and Y\". Removes the need to paginate rows just to count. Returns { count }.",
2195
+ inputSchema: {
2196
+ type: "object",
2197
+ properties: {
2198
+ project_id: { type: "string", format: "uuid", description: "Optional: scope to a project UUID." },
2199
+ category: { type: "string", description: "Optional: filter by category." },
2200
+ status: { type: "string", description: "Optional: filter by status." },
2201
+ tags: { type: "string", description: "Optional: comma-separated tags." },
2202
+ match: {
2203
+ type: "string",
2204
+ enum: ["any", "all"],
2205
+ description: "Tag match mode: 'any' (default, OR) or 'all' (AND — carries every tag).",
2206
+ },
2207
+ source_type: { type: "string", description: "Optional: filter by source_type." },
2208
+ source_id: { type: "string", description: "Optional: filter by source_id." },
2209
+ idempotency_key: { type: "string", description: "Optional: filter by idempotency_key." },
2210
+ },
2211
+ required: [],
2212
+ },
2213
+ },
2214
+ {
2215
+ name: "knowledge_facets",
2216
+ description:
2217
+ "Count articles grouped by each distinct tag, over the filtered set, WITHOUT returning " +
2218
+ "rows. Returns { data: { tag: count }, meta: { distinct_count, truncated } }. " +
2219
+ "meta.distinct_count is the TRUE number of distinct tags (independent of limit); " +
2220
+ "meta.truncated flags when limit capped the rows. Each `count` is the number of distinct " +
2221
+ "articles carrying that tag. Pass tag_prefix to restrict to a tag family (e.g. 'book-') " +
2222
+ "so you get the DISTINCT count of that family (how many distinct books) plus per-member " +
2223
+ "totals — without dragging tens of thousands of rows through context. Honors the same " +
2224
+ "filters as knowledge_count (status, tags, match). Cost: unnests tags over the whole " +
2225
+ "filtered set; on large tenants narrow with tag_prefix/category/status/project_id.",
2226
+ inputSchema: {
2227
+ type: "object",
2228
+ properties: {
2229
+ project_id: { type: "string", format: "uuid", description: "Optional: scope to a project UUID." },
2230
+ category: { type: "string", description: "Optional: filter by category." },
2231
+ status: { type: "string", description: "Optional: filter by status." },
2232
+ tags: { type: "string", description: "Optional: comma-separated tags." },
2233
+ match: {
2234
+ type: "string",
2235
+ enum: ["any", "all"],
2236
+ description: "Tag match mode: 'any' (default, OR) or 'all' (AND).",
2237
+ },
2238
+ tag_prefix: {
2239
+ type: "string",
2240
+ description: "Optional: only tags starting with this literal prefix (e.g. 'book-').",
2241
+ },
2242
+ limit: { type: "integer", description: "Optional: max distinct tags in the result (default all, max 1000; values above 1000 are rejected with 400)." },
2243
+ },
2244
+ required: [],
2245
+ },
2246
+ },
2247
+ {
2248
+ name: "knowledge_graph",
2249
+ description:
2250
+ "Traverse the published article-link graph outward from an article, up to `depth` hops " +
2251
+ "(1–3, default 1). BIDIRECTIONAL (follows links regardless of source/target direction) and " +
2252
+ "cycle-safe (no node twice). Returns { nodes: [{id,title,category,depth}], edges: " +
2253
+ "[{source_article_id,target_article_id,relationship_type}], truncated, node_count }. Bounded " +
2254
+ "to 100 nodes / 500 edges (truncated:true when hit). Use to explore how a piece of knowledge " +
2255
+ "connects (relates_to/derived_from/contradicts/supersedes) beyond the 1-hop links in " +
2256
+ "knowledge_context.",
2257
+ inputSchema: {
2258
+ type: "object",
2259
+ properties: {
2260
+ article_id: {
2261
+ type: "string",
2262
+ format: "uuid",
2263
+ description: "Starting article UUID (required).",
2264
+ },
2265
+ depth: {
2266
+ type: "integer",
2267
+ minimum: 1,
2268
+ maximum: 3,
2269
+ description: "Hops to traverse (1–3, default 1). Out of range → 400.",
2270
+ },
2271
+ project_id: { type: "string", format: "uuid", description: "Optional: project UUID." },
2272
+ },
2273
+ required: ["article_id"],
2274
+ },
2275
+ },
2276
+ {
2277
+ name: "knowledge_suggest_links",
2278
+ description:
2279
+ "Suggest ranked typed-link CANDIDATES for an article by embedding similarity — " +
2280
+ "READ-ONLY, creates nothing. Excludes the article itself and any already-linked " +
2281
+ "article (either direction, any relationship type); only embedded published articles. " +
2282
+ "Returns { data: [{id, title, category, similarity_score}] } highest-similarity first. " +
2283
+ "Review them and create the one you want as a TYPED link (relates_to/derived_from/" +
2284
+ "contradicts/supersedes) — unlike the auto-linker which only makes ambient relates_to. " +
2285
+ "Optional: threshold (cosine floor 0–1, default 0.5), limit (default 5).",
2286
+ inputSchema: {
2287
+ type: "object",
2288
+ properties: {
2289
+ article_id: {
2290
+ type: "string",
2291
+ format: "uuid",
2292
+ description: "The article to suggest links for (required).",
2293
+ },
2294
+ limit: { type: "integer", description: "Max candidates (default 5)." },
2295
+ threshold: {
2296
+ type: "number",
2297
+ minimum: 0,
2298
+ maximum: 1,
2299
+ description: "Cosine similarity floor (default 0.5).",
1590
2300
  },
1591
2301
  },
1592
- required: ["q"],
2302
+ required: ["article_id"],
2303
+ },
2304
+ },
2305
+ {
2306
+ name: "knowledge_distant_pairs",
2307
+ description:
2308
+ "Find distant-but-bridgeable article pairs in the optimal-novelty embedding band " +
2309
+ "(cosine distance min..max, default 0.3–0.7) — the creative sweet spot (neither banal " +
2310
+ "nor nonsense). Returns { data: [{a, b, distance}], meta:{count} }. With bridge_path:true, " +
2311
+ "only pairs also connected in the link graph (≤2 hops) are returned. Samples up to 1000 " +
2312
+ "embedded published articles; paginate via limit/offset. For computational-creativity " +
2313
+ "ideation (remote-associates generator).",
2314
+ inputSchema: {
2315
+ type: "object",
2316
+ properties: {
2317
+ min_distance: { type: "number", description: "Lower cosine-distance bound (default 0.3)." },
2318
+ max_distance: { type: "number", description: "Upper cosine-distance bound (default 0.7)." },
2319
+ bridge_path: { type: "boolean", description: "Require a ≤2-hop graph path (default false)." },
2320
+ limit: { type: "integer", description: "Max pairs (default 20, max 100)." },
2321
+ offset: { type: "integer", description: "Pairs to skip." },
2322
+ },
2323
+ required: [],
2324
+ },
2325
+ },
2326
+ {
2327
+ name: "knowledge_novelty",
2328
+ description:
2329
+ "Score the NOVELTY of ideas: each idea's text is embedded and compared to the nearest " +
2330
+ "prior proposal, returning novelty_score = cosine distance (0 = identical to existing " +
2331
+ "work, higher = more novel, up to 2.0; null when the idea text is blank, no priors " +
2332
+ "exist, or embedding fails). Priors default to published articles tagged 'proposal' " +
2333
+ "(override with prior_tag). Provide the ideas as EITHER `texts` (a list of strings) " +
2334
+ "OR `ideas` (a list of strings or objects {text|title/spark/thesis,...}); all forms " +
2335
+ "are accepted. " +
2336
+ "Returns { data: [{...idea, novelty_score}], meta: { prior_count } }. Use to rerank " +
2337
+ "generated ideas by novelty × value and avoid repeating prior work.",
2338
+ inputSchema: {
2339
+ type: "object",
2340
+ properties: {
2341
+ ideas: {
2342
+ type: "array",
2343
+ description:
2344
+ "Ideas to score (≤50). Each is a string, or an object whose embed text is " +
2345
+ "`text` (else title/spark/thesis are joined).",
2346
+ items: { type: ["string", "object"] },
2347
+ },
2348
+ texts: {
2349
+ type: "array",
2350
+ description: "Alternative to `ideas`: a list of idea strings (the #152 AC shape).",
2351
+ items: { type: "string" },
2352
+ },
2353
+ prior_tag: {
2354
+ type: "string",
2355
+ description: "Tag identifying prior proposals to compare against (default 'proposal').",
2356
+ },
2357
+ },
2358
+ required: [],
2359
+ },
2360
+ },
2361
+ {
2362
+ name: "knowledge_random_walk",
2363
+ description:
2364
+ "Random walk through the link graph from a starting article (up to `length` published " +
2365
+ "nodes, no cycles, stops at a dead end). Surfaces unexpected connections for creative " +
2366
+ "incubation. Returns { data: [{id,title,category}], meta:{count} } in walk order.",
2367
+ inputSchema: {
2368
+ type: "object",
2369
+ properties: {
2370
+ start_id: { type: "string", format: "uuid", description: "Starting article UUID (required)." },
2371
+ length: { type: "integer", description: "Walk steps (default 4, max 25)." },
2372
+ },
2373
+ required: ["start_id"],
2374
+ },
2375
+ },
2376
+ {
2377
+ name: "knowledge_search",
2378
+ description:
2379
+ "Search the knowledge wiki by topic. Returns snippets. Ranked, and returns PUBLISHED " +
2380
+ "articles only; it LAGS writes by minutes while embeddings index. Do NOT use it for " +
2381
+ "existence/idempotency/dedup checks ('already captured?') — a freshly-written article will " +
2382
+ "false-negative. Use knowledge_list (lag-free, all-status, exact meta.total_count) for that. " +
2383
+ "q is optional when tags and/or category are supplied: in that list mode it returns the " +
2384
+ "COMPLETE filtered set (no relevance ranking) paginated via offset/limit over " +
2385
+ "meta.total_count, so you can enumerate every article carrying a tag/category. " +
2386
+ "IMPORTANT: meta.total_count is mode-dependent — read meta.total_count_scope to know what " +
2387
+ "it counts: keyword_matches (stop-word-filtered tsquery matches; 'the' matches ~nothing), " +
2388
+ "ranked_corpus (semantic ranks all EMBEDDED published articles — that embedded set's size, " +
2389
+ "not a match count, and <= the published count), merged_candidates (combined: deduped UNION of " +
2390
+ "a keyword and a semantic sub-search, each capped at 100, so up to ~200), or filtered_set " +
2391
+ "(list mode: the full set). Do NOT use a relevance-mode total_count to size the wiki — use " +
2392
+ "list mode or knowledge_stats. " +
2393
+ "Pass story_id when working on a loopctl story so reads attribute correctly.",
2394
+ inputSchema: {
2395
+ type: "object",
2396
+ properties: {
2397
+ q: {
2398
+ type: "string",
2399
+ description:
2400
+ "Search query string. Optional when tags/category are supplied (enumeration mode).",
2401
+ },
2402
+ project_id: {
2403
+ type: "string",
2404
+ format: "uuid",
2405
+ description: "Optional: scope search to a specific project UUID.",
2406
+ },
2407
+ story_id: {
2408
+ type: "string",
2409
+ format: "uuid",
2410
+ description: "Optional: loopctl story UUID for attribution tracking.",
2411
+ },
2412
+ category: {
2413
+ type: "string",
2414
+ description: "Optional: filter results by category.",
2415
+ },
2416
+ tags: {
2417
+ type: "string",
2418
+ description: "Optional: comma-separated tags to filter by.",
2419
+ },
2420
+ match: {
2421
+ type: "string",
2422
+ enum: ["any", "all"],
2423
+ description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
2424
+ },
2425
+ mode: {
2426
+ type: "string",
2427
+ enum: ["keyword", "semantic", "combined"],
2428
+ description: "Optional: search mode (keyword, semantic, or combined).",
2429
+ },
2430
+ limit: {
2431
+ type: "integer",
2432
+ description: "Optional: maximum number of results to return.",
2433
+ },
2434
+ offset: {
2435
+ type: "integer",
2436
+ description: "Optional: results to skip for pagination (default 0).",
2437
+ },
2438
+ },
2439
+ required: [],
1593
2440
  },
1594
2441
  },
1595
2442
  {
@@ -1623,7 +2470,11 @@ const TOOLS = [
1623
2470
  name: "knowledge_context",
1624
2471
  description:
1625
2472
  "Get ranked full articles for a task query. Returns best knowledge with linked references. " +
1626
- "Pass story_id when working on a loopctl story so reads attribute correctly.",
2473
+ "Pass story_id when working on a loopctl story so reads attribute correctly. For agent " +
2474
+ "memory, scope to a memory_type/agent/conversation via the memory_types/agents/" +
2475
+ "conversation_id filters (articles whose metadata carries those keys). NOTE (#163): for " +
2476
+ "an agent key, another agent's private/owner memories are never returned (results AND " +
2477
+ "linked refs) regardless of the agents= filter — visibility is enforced, not advisory.",
1627
2478
  inputSchema: {
1628
2479
  type: "object",
1629
2480
  properties: {
@@ -1651,6 +2502,20 @@ const TOOLS = [
1651
2502
  minimum: 0,
1652
2503
  maximum: 1,
1653
2504
  },
2505
+ memory_types: {
2506
+ type: "string",
2507
+ description:
2508
+ "Optional agent-memory scope: comma-separated memory_types (OR) — " +
2509
+ "observation|finding|summary|decision|question|task.",
2510
+ },
2511
+ agents: {
2512
+ type: "string",
2513
+ description: "Optional agent-memory scope: comma-separated agent_ids (OR).",
2514
+ },
2515
+ conversation_id: {
2516
+ type: "string",
2517
+ description: "Optional agent-memory scope: exact conversation_id.",
2518
+ },
1654
2519
  },
1655
2520
  required: ["query"],
1656
2521
  },
@@ -1659,7 +2524,15 @@ const TOOLS = [
1659
2524
  name: "knowledge_create",
1660
2525
  description:
1661
2526
  "Create a new knowledge article. Use to file findings, document patterns, or record decisions " +
1662
- "discovered during implementation.",
2527
+ "discovered during implementation. Articles are PUBLISHED IMMEDIATELY by default and are visible " +
2528
+ "to agents (search/index/context) right away — no separate publish step is needed. Pass " +
2529
+ "draft: true to stage the article for later review instead; the response `note` says which " +
2530
+ "outcome occurred, and a draft can be published afterwards with knowledge_publish. " +
2531
+ "Concurrency-safe: if a create races/retries against an " +
2532
+ "existing article with the same title AND an identical body (ignoring surrounding whitespace), the " +
2533
+ "server returns that existing article idempotently (HTTP 200) instead of a 422. A same-title create " +
2534
+ "with a DIFFERENT body returns 409 title_conflict — do not retry; choose a different title or PATCH " +
2535
+ "the existing article.",
1663
2536
  inputSchema: {
1664
2537
  type: "object",
1665
2538
  properties: {
@@ -1684,6 +2557,47 @@ const TOOLS = [
1684
2557
  type: "string",
1685
2558
  description: "Optional: associate the article with a project UUID.",
1686
2559
  },
2560
+ draft: {
2561
+ type: "boolean",
2562
+ description:
2563
+ "Optional: stage as a draft instead of publishing on create (default false → " +
2564
+ "published immediately). Publish later with knowledge_publish.",
2565
+ },
2566
+ idempotency_key: {
2567
+ type: "string",
2568
+ description:
2569
+ "Optional: stable per-article key for idempotent capture (max 255). Re-creating " +
2570
+ "with the same key is a no-op that returns a reference to the existing article " +
2571
+ "(deduplicated; id only, not its body) instead of a partial duplicate. Use a " +
2572
+ "HIGH-ENTROPY value (e.g. a content hash) — it is a per-tenant lookup key, not a " +
2573
+ "secret, so a guessable key lets another agent in your tenant probe which keys " +
2574
+ "exist. Distinct from source_type/source_id (which mark a shared source).",
2575
+ },
2576
+ metadata: {
2577
+ type: "object",
2578
+ description:
2579
+ "Optional: extensible JSONB. Set the agent-memory keys to file this article as a " +
2580
+ "scoped agent memory: `memory_type` (observation/finding/summary/decision/question/" +
2581
+ "task) and `visibility` (shared | private | owner). TRUST MODEL (#163): for an " +
2582
+ "agent key, `metadata.agent_id` is stamped server-side from your verified key " +
2583
+ "identity — do NOT set it (any value you pass is overridden); `private`/`owner` " +
2584
+ "memories are then readable only by you (other agents get 404/exclusion across all " +
2585
+ "knowledge reads), while `shared` (the default) is visible tenant-wide. An agent " +
2586
+ "key with no agent identity gets 403 agent_identity_required when writing memory " +
2587
+ "metadata. Higher roles may attribute on behalf of others.",
2588
+ },
2589
+ source_type: {
2590
+ type: "string",
2591
+ description:
2592
+ "Optional: advisory provenance for the originating source (e.g. 'web_article', " +
2593
+ "'newsletter'). Shared across articles from the same source; not an idempotency key.",
2594
+ },
2595
+ source_id: {
2596
+ type: "string",
2597
+ description:
2598
+ "Optional: UUID of the originating source entity (shared across articles from " +
2599
+ "that source). Pair with source_type.",
2600
+ },
1687
2601
  },
1688
2602
  required: ["title", "body"],
1689
2603
  },
@@ -1708,38 +2622,193 @@ const TOOLS = [
1708
2622
  {
1709
2623
  name: "knowledge_bulk_publish",
1710
2624
  description:
1711
- "Atomically publish up to 100 draft articles in a single call. " +
1712
- "REQUIRES LOOPCTL_USER_KEY to be set in the MCP server env (user role " +
1713
- "orchestrator role is NOT sufficient for this destructive operation). " +
1714
- "All articles must be drafts belonging to the tenant; if any fail validation, " +
1715
- "the entire operation rolls back.",
2625
+ "Publish draft articles, partial-success style. REQUIRES LOOPCTL_USER_KEY " +
2626
+ "(user role orchestrator is NOT sufficient). Every valid draft is published; " +
2627
+ "each other id gets a per-id outcome instead of failing the whole call: " +
2628
+ "published, skipped (already published idempotent or archived/superseded), " +
2629
+ "not_found, or errored. Duplicate ids are de-duplicated and there is NO 100-id " +
2630
+ "cap (larger requests are auto-chunked server-side). The response's meta.count " +
2631
+ "is the number actually published; meta.counts has the full breakdown and " +
2632
+ "meta.results is the per-id list in request order. Safe to retry — already " +
2633
+ "published ids are skipped, not errored.",
1716
2634
  inputSchema: {
1717
2635
  type: "object",
1718
2636
  properties: {
1719
2637
  article_ids: {
1720
2638
  type: "array",
1721
2639
  items: { type: "string" },
1722
- description: "List of draft article UUIDs to publish (max 100).",
1723
- maxItems: 100,
2640
+ description:
2641
+ "Article UUIDs to publish. Any length (auto-chunked); duplicates ignored.",
1724
2642
  },
1725
2643
  },
1726
2644
  required: ["article_ids"],
1727
2645
  },
1728
2646
  },
2647
+ {
2648
+ name: "knowledge_bulk_unpublish",
2649
+ description:
2650
+ "Unpublish (published → draft) articles in bulk, partial-success style — the mirror " +
2651
+ "of knowledge_bulk_publish, for cleanup passes. REQUIRES LOOPCTL_USER_KEY (user role). " +
2652
+ "Every currently-published id is reverted to draft; each other id gets a per-id outcome: " +
2653
+ "unpublished, skipped (already draft — idempotent — or archived/superseded), not_found, " +
2654
+ "or errored. Duplicate ids de-duplicated; no 100-id cap (auto-chunked server-side, " +
2655
+ "bounded to 5000/call). meta.count = number actually unpublished; meta.counts has the " +
2656
+ "full breakdown; meta.results is the per-id list in request order. Safe to retry — " +
2657
+ "already-draft ids are skipped, not errored. Articles are NOT deleted (re-publish with " +
2658
+ "knowledge_bulk_publish); to archive/soft-delete use knowledge_bulk_delete.",
2659
+ inputSchema: {
2660
+ type: "object",
2661
+ properties: {
2662
+ article_ids: {
2663
+ type: "array",
2664
+ items: { type: "string" },
2665
+ description:
2666
+ "Article UUIDs to unpublish. Any length (auto-chunked); duplicates ignored.",
2667
+ },
2668
+ },
2669
+ required: ["article_ids"],
2670
+ },
2671
+ },
2672
+ {
2673
+ name: "knowledge_unpublish",
2674
+ description:
2675
+ "Revert a published article back to draft state. The article stops being visible " +
2676
+ "in agent search/context but is not deleted — re-publish with knowledge_publish. " +
2677
+ "REQUIRES LOOPCTL_USER_KEY (user role — orchestrator role is NOT sufficient for " +
2678
+ "this destructive operation).",
2679
+ inputSchema: {
2680
+ type: "object",
2681
+ properties: {
2682
+ article_id: {
2683
+ type: "string",
2684
+ description: "The UUID of the published article to unpublish.",
2685
+ },
2686
+ },
2687
+ required: ["article_id"],
2688
+ },
2689
+ },
2690
+ {
2691
+ name: "knowledge_archive",
2692
+ description:
2693
+ "Archive an article (soft delete). The article is hidden from search, context, " +
2694
+ "and the index but the row is retained for audit/history. Works for drafts and " +
2695
+ "published articles. REQUIRES LOOPCTL_USER_KEY (user role — orchestrator role is " +
2696
+ "NOT sufficient for this destructive operation).",
2697
+ inputSchema: {
2698
+ type: "object",
2699
+ properties: {
2700
+ article_id: {
2701
+ type: "string",
2702
+ description: "The UUID of the article to archive.",
2703
+ },
2704
+ },
2705
+ required: ["article_id"],
2706
+ },
2707
+ },
2708
+ {
2709
+ name: "knowledge_delete",
2710
+ description:
2711
+ "Delete an article. Under the hood this performs the same soft-delete (archive) " +
2712
+ "as knowledge_archive — use whichever name is clearer at the call site. The row " +
2713
+ "is retained for audit; there is no hard delete. REQUIRES LOOPCTL_USER_KEY (user " +
2714
+ "role — orchestrator role is NOT sufficient for this destructive operation).",
2715
+ inputSchema: {
2716
+ type: "object",
2717
+ properties: {
2718
+ article_id: {
2719
+ type: "string",
2720
+ description: "The UUID of the article to delete.",
2721
+ },
2722
+ },
2723
+ required: ["article_id"],
2724
+ },
2725
+ },
2726
+ {
2727
+ name: "knowledge_bulk_delete",
2728
+ description:
2729
+ "Bulk archive (default, reversible) or IRREVERSIBLE hard-delete of articles by selector. " +
2730
+ "REQUIRES LOOPCTL_USER_KEY (user role — orchestrator is NOT sufficient). Provide EXACTLY ONE " +
2731
+ "selector: article_ids (explicit list), source_type + source_id (every active article from " +
2732
+ "that source), or tag + confirm:true (every active article carrying the tag — high blast " +
2733
+ "radius, so confirm:true is required). " +
2734
+ "DEFAULT (soft archive): rows move to archived, never dropped; set-based + idempotent; " +
2735
+ "meta.count = archived, meta.counts/meta.results give the breakdown. " +
2736
+ "DRY-RUN: dry_run:true mutates NOTHING and returns meta.would_affect (and, for hard, a " +
2737
+ "single-use meta.token / for oversized selectors a meta.confirm_hash). " +
2738
+ "HARD DELETE (irreversible): first dry_run with hard:true to get a token, then call again " +
2739
+ "with hard:true + that token to FK-correctly delete the FROZEN id-set (links removed first, " +
2740
+ "access events cascade). The token is single-use and TTL-bounded. Bounded to 5000 per call.",
2741
+ inputSchema: {
2742
+ type: "object",
2743
+ properties: {
2744
+ article_ids: {
2745
+ type: "array",
2746
+ items: { type: "string" },
2747
+ description: "Explicit article UUIDs (selector 1).",
2748
+ },
2749
+ source_type: {
2750
+ type: "string",
2751
+ description: "With source_id: every active article from this source (selector 2).",
2752
+ },
2753
+ source_id: {
2754
+ type: "string",
2755
+ description: "With source_type: the source entity UUID (selector 2).",
2756
+ },
2757
+ tag: {
2758
+ type: "string",
2759
+ description:
2760
+ "Every active article carrying this tag (selector 3). Requires confirm:true.",
2761
+ },
2762
+ confirm: {
2763
+ type: "boolean",
2764
+ description: "Required (true) when selecting by tag — guards the high blast radius.",
2765
+ },
2766
+ dry_run: {
2767
+ type: "boolean",
2768
+ description:
2769
+ "Preview only — mutate nothing. Returns meta.would_affect; with hard:true also a " +
2770
+ "single-use meta.token (or meta.confirm_hash for oversized selectors).",
2771
+ },
2772
+ hard: {
2773
+ type: "boolean",
2774
+ description:
2775
+ "IRREVERSIBLE hard delete (vs default reversible archive). Run dry_run first to get a " +
2776
+ "token, then pass hard:true + token.",
2777
+ },
2778
+ token: {
2779
+ type: "string",
2780
+ description:
2781
+ "The single-use frozen-set token from a `dry_run:true, hard:true` preview. Required " +
2782
+ "for the hard delete.",
2783
+ },
2784
+ confirm_hash: {
2785
+ type: "string",
2786
+ description:
2787
+ "For an oversized hard-delete selector (no token): the meta.confirm_hash from the " +
2788
+ "dry-run, echoed back to re-confirm the id-set hasn't drifted.",
2789
+ },
2790
+ },
2791
+ required: [],
2792
+ },
2793
+ },
1729
2794
  {
1730
2795
  name: "knowledge_drafts",
1731
2796
  description:
1732
2797
  "List draft (unpublished) knowledge articles. Requires orchestrator role. " +
1733
- "Returns paginated drafts with total_count in meta. Max 20 per page.",
2798
+ "Returns paginated drafts with total_count in meta. Paginate via offset/limit " +
2799
+ "(limit honored up to 1000; a limit above the max is rejected with 400, not " +
2800
+ "silently clamped).",
1734
2801
  inputSchema: {
1735
2802
  type: "object",
1736
2803
  properties: {
1737
2804
  limit: {
1738
2805
  type: "integer",
1739
- description: "Max drafts per page. Default 20, hard max 20.",
2806
+ description:
2807
+ "Max drafts per page (default 20, max 1000). A limit above the max is " +
2808
+ "rejected with 400 — not silently clamped — so offset pagination stays complete.",
1740
2809
  default: 20,
1741
2810
  minimum: 1,
1742
- maximum: 20,
2811
+ maximum: 1000,
1743
2812
  },
1744
2813
  offset: {
1745
2814
  type: "integer",
@@ -1794,8 +2863,9 @@ const TOOLS = [
1794
2863
  {
1795
2864
  name: "knowledge_export",
1796
2865
  description:
1797
- "Export all knowledge articles as a ZIP archive. Because ZIP binary cannot be returned as MCP content, " +
1798
- "this tool returns a curl command you can run directly to download the archive.",
2866
+ "Export all knowledge articles as an OKF v0.1 bundle gzipped tar archive, unbounded, bounded-memory streaming, " +
2867
+ "fail-closed (no partial bundles). Because binary cannot be returned as MCP content, this tool returns a curl command. " +
2868
+ "Optional: ?format=json for buffered in-memory JSON (capped at export_max_buffered_export_articles, 413 over cap).",
1799
2869
  inputSchema: {
1800
2870
  type: "object",
1801
2871
  properties: {
@@ -1803,10 +2873,71 @@ const TOOLS = [
1803
2873
  type: "string",
1804
2874
  description: "Optional: scope export to a specific project UUID.",
1805
2875
  },
2876
+ format: {
2877
+ type: "string",
2878
+ enum: ["tar.gz", "json"],
2879
+ description: "Optional: tar.gz (default, unbounded streaming) or json (buffered, capped).",
2880
+ },
2881
+ },
2882
+ required: [],
2883
+ },
2884
+ },
2885
+ {
2886
+ name: "knowledge_okf_export",
2887
+ description:
2888
+ "Export the knowledge wiki as a portable OKF (Open Knowledge Format) v0.1 bundle — a tree of " +
2889
+ "markdown files with YAML frontmatter. Requires LOOPCTL_USER_KEY. If out_dir is given, the bundle " +
2890
+ "is written there (one .md file per concept, plus index.md/log.md) and a summary is returned; " +
2891
+ "otherwise the bundle is returned inline as {files, meta}.",
2892
+ inputSchema: {
2893
+ type: "object",
2894
+ properties: {
2895
+ project_id: {
2896
+ type: "string",
2897
+ format: "uuid",
2898
+ description: "Optional: scope the export to a project (includes tenant-wide articles too).",
2899
+ },
2900
+ out_dir: {
2901
+ type: "string",
2902
+ description:
2903
+ "Optional: absolute path of a directory to write the bundle into. When omitted, the " +
2904
+ "bundle is returned inline.",
2905
+ },
1806
2906
  },
1807
2907
  required: [],
1808
2908
  },
1809
2909
  },
2910
+ {
2911
+ name: "knowledge_okf_import",
2912
+ description:
2913
+ "Import an OKF (Open Knowledge Format) v0.1 bundle from a local directory into the wiki. " +
2914
+ "Requires LOOPCTL_USER_KEY. Reserved files (index.md/log.md) are skipped; each concept is created, " +
2915
+ "or (with merge=true, the default) updated in place when it matches an existing article. Unknown " +
2916
+ "frontmatter types/keys are tolerated and preserved. Returns a per-file import report.",
2917
+ inputSchema: {
2918
+ type: "object",
2919
+ properties: {
2920
+ bundle_dir: {
2921
+ type: "string",
2922
+ description: "Absolute path of the OKF bundle directory to read .md files from.",
2923
+ },
2924
+ project_id: {
2925
+ type: "string",
2926
+ format: "uuid",
2927
+ description: "Optional: assign imported articles to a project.",
2928
+ },
2929
+ merge: {
2930
+ type: "boolean",
2931
+ description: "Update existing articles instead of skipping them (default true).",
2932
+ },
2933
+ dry_run: {
2934
+ type: "boolean",
2935
+ description: "Validate and plan only; write nothing (default false).",
2936
+ },
2937
+ },
2938
+ required: ["bundle_dir"],
2939
+ },
2940
+ },
1810
2941
 
1811
2942
  // Knowledge Ingestion Tools
1812
2943
  {
@@ -1814,7 +2945,9 @@ const TOOLS = [
1814
2945
  description:
1815
2946
  "Submit a URL or raw content for knowledge extraction. " +
1816
2947
  "Enqueues an Oban job that fetches the content (if URL), extracts knowledge articles via LLM, " +
1817
- "and inserts them as draft articles. Requires orchestrator role.",
2948
+ "and inserts them. Extracted articles are DRAFTS by default (lower-trust LLM output, staged " +
2949
+ "for review) — unlike knowledge_create which publishes by default. Pass publish:true to " +
2950
+ "publish them on extraction. Requires orchestrator role.",
1818
2951
  inputSchema: {
1819
2952
  type: "object",
1820
2953
  properties: {
@@ -1834,6 +2967,11 @@ const TOOLS = [
1834
2967
  type: "string",
1835
2968
  description: "Optional: scope extracted articles to a specific project UUID.",
1836
2969
  },
2970
+ publish: {
2971
+ type: "boolean",
2972
+ description:
2973
+ "Optional: publish extracted articles immediately instead of staging them as drafts (default false).",
2974
+ },
1837
2975
  },
1838
2976
  required: ["source_type"],
1839
2977
  },
@@ -1872,6 +3010,11 @@ const TOOLS = [
1872
3010
  type: "string",
1873
3011
  description: "Optional: scope the item to a specific project UUID.",
1874
3012
  },
3013
+ publish: {
3014
+ type: "boolean",
3015
+ description:
3016
+ "Optional: publish this item's extracted articles immediately (default false → draft).",
3017
+ },
1875
3018
  metadata: {
1876
3019
  type: "object",
1877
3020
  description: "Optional metadata map.",
@@ -1884,6 +3027,10 @@ const TOOLS = [
1884
3027
  type: "string",
1885
3028
  description: "Optional batch-level default project UUID applied to items that don't specify their own.",
1886
3029
  },
3030
+ publish: {
3031
+ type: "boolean",
3032
+ description: "Optional batch-level default publish flag applied to items that don't specify their own.",
3033
+ },
1887
3034
  },
1888
3035
  required: ["items"],
1889
3036
  },
@@ -2214,9 +3361,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2214
3361
  case "knowledge_index":
2215
3362
  return await knowledgeIndex(args);
2216
3363
 
3364
+ case "knowledge_stats":
3365
+ return await knowledgeStats(args);
3366
+
3367
+ case "knowledge_count":
3368
+ return await knowledgeCount(args);
3369
+
3370
+ case "knowledge_facets":
3371
+ return await knowledgeFacets(args);
3372
+
3373
+ case "knowledge_graph":
3374
+ return await knowledgeGraph(args);
3375
+
3376
+ case "knowledge_suggest_links":
3377
+ return await knowledgeSuggestLinks(args);
3378
+
3379
+ case "knowledge_distant_pairs":
3380
+ return await knowledgeDistantPairs(args);
3381
+
3382
+ case "knowledge_novelty":
3383
+ return await knowledgeNovelty(args);
3384
+
3385
+ case "knowledge_random_walk":
3386
+ return await knowledgeRandomWalk(args);
3387
+
2217
3388
  case "knowledge_search":
2218
3389
  return await knowledgeSearch(args);
2219
3390
 
3391
+ case "knowledge_list":
3392
+ return await knowledgeList(args);
3393
+
2220
3394
  case "knowledge_get":
2221
3395
  return await knowledgeGet(args);
2222
3396
 
@@ -2233,6 +3407,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2233
3407
  case "knowledge_bulk_publish":
2234
3408
  return await knowledgeBulkPublish(args);
2235
3409
 
3410
+ case "knowledge_bulk_unpublish":
3411
+ return await knowledgeBulkUnpublish(args);
3412
+
3413
+ case "knowledge_unpublish":
3414
+ return await knowledgeUnpublish(args);
3415
+
3416
+ case "knowledge_archive":
3417
+ return await knowledgeArchive(args);
3418
+
3419
+ case "knowledge_delete":
3420
+ return await knowledgeDelete(args);
3421
+
3422
+ case "knowledge_bulk_delete":
3423
+ return await knowledgeBulkDelete(args);
3424
+
2236
3425
  case "knowledge_drafts":
2237
3426
  return await knowledgeDrafts(args);
2238
3427
 
@@ -2242,6 +3431,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2242
3431
  case "knowledge_export":
2243
3432
  return await knowledgeExport(args);
2244
3433
 
3434
+ case "knowledge_okf_export":
3435
+ return await knowledgeOkfExport(args);
3436
+
3437
+ case "knowledge_okf_import":
3438
+ return await knowledgeOkfImport(args);
3439
+
2245
3440
  // Knowledge Ingestion Tools
2246
3441
  case "knowledge_ingest":
2247
3442
  return await knowledgeIngest(args);