@opsee/mcp-server 0.9.33 → 0.9.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,7 +77,7 @@ Ask Claude things like:
77
77
  | `opsee_create_cycle` | Create a new cycle |
78
78
  | `opsee_update_cycle` | Update a cycle's fields |
79
79
  | `opsee_delete_cycle` | Delete a cycle |
80
- | `opsee_list_doc_spaces` | List doc spaces |
80
+ | `opsee_list_doc_spaces` | List doc spaces in a project |
81
81
  | `opsee_list_doc_pages` | List pages in a doc space |
82
82
  | `opsee_get_doc_page` | Read a doc page |
83
83
  | `opsee_create_doc_page` | Create a new doc page |
@@ -152,6 +152,17 @@ export declare type GetDocSpacesRequest = Message<"api.v1.GetDocSpacesRequest">
152
152
  * @generated from field: api.v1.FilterOptions filter_options = 2;
153
153
  */
154
154
  filterOptions?: FilterOptions;
155
+
156
+ /**
157
+ * Doc spaces are project-scoped. This has to be a first-class request field
158
+ * rather than a FilterOptions entry: scopes.ParseFilterOptions drops a
159
+ * "ProjectId" filter key as security-sensitive, so a filter-based scope
160
+ * silently widens the result to every doc space in the company. Mirrors
161
+ * GetDocPagesRequest.project_id.
162
+ *
163
+ * @generated from field: uint32 project_id = 3;
164
+ */
165
+ projectId: number;
155
166
  };
156
167
 
157
168
  /**
@@ -14,7 +14,7 @@ import { file_api_v1_models } from "./models_pb";
14
14
  * Describes the file api/v1/doc_space.proto.
15
15
  */
16
16
  export const file_api_v1_doc_space = /*@__PURE__*/
17
- fileDesc("ChZhcGkvdjEvZG9jX3NwYWNlLnByb3RvEgZhcGkudjEi4gEKEkFkZERvY1NwYWNlUmVxdWVzdBIWCgV0aXRsZRgBIAEoCUIH+kIEcgIQARIVCgRzbHVnGAIgASgJQgf6QgRyAhABEhgKC2Rlc2NyaXB0aW9uGAMgASgJSACIAQESEgoKaXNfZGVmYXVsdBgEIAEoCBITCgtpc19hcmNoaXZlZBgFIAEoCBISCgpwcm9qZWN0X2lkGAYgASgNEhoKEmNyZWF0ZWRfYnlfdXNlcl9pZBgHIAEoDRIaChJ1cGRhdGVkX2J5X3VzZXJfaWQYCCABKA1CDgoMX2Rlc2NyaXB0aW9uIvgBChNFZGl0RG9jU3BhY2VSZXF1ZXN0EhMKAmlkGAEgASgNQgf6QgQqAigBEhYKBXRpdGxlGAIgASgJQgf6QgRyAhABEhUKBHNsdWcYAyABKAlCB/pCBHICEAESGAoLZGVzY3JpcHRpb24YBCABKAlIAIgBARISCgppc19kZWZhdWx0GAUgASgIEhMKC2lzX2FyY2hpdmVkGAYgASgIEhIKCnByb2plY3RfaWQYByABKA0SGgoSY3JlYXRlZF9ieV91c2VyX2lkGAggASgNEhoKEnVwZGF0ZWRfYnlfdXNlcl9pZBgJIAEoDUIOCgxfZGVzY3JpcHRpb24iIAoSR2V0RG9jU3BhY2VSZXF1ZXN0EgoKAmlkGAEgASgNInYKE0dldERvY1NwYWNlc1JlcXVlc3QSMAoKcGFnaW5hdGlvbhgBIAEoCzISLmFwaS52MS5QYWdpbmF0aW9uQgj6QgWKAQIQARItCg5maWx0ZXJfb3B0aW9ucxgCIAEoCzIVLmFwaS52MS5GaWx0ZXJPcHRpb25zIiMKFURlbGV0ZURvY1NwYWNlUmVxdWVzdBIKCgJpZBgBIAEoDSJkChRHZXREb2NTcGFjZXNSZXNwb25zZRIkCgpkb2Nfc3BhY2VzGAEgAygLMhAuYXBpLnYxLkRvY1NwYWNlEiYKCnBhZ2luYXRpb24YAiABKAsyEi5hcGkudjEuUGFnaW5hdGlvbiI6ChNBZGREb2NTcGFjZVJlc3BvbnNlEiMKCWRvY19zcGFjZRgBIAEoCzIQLmFwaS52MS5Eb2NTcGFjZSI7ChRFZGl0RG9jU3BhY2VSZXNwb25zZRIjCglkb2Nfc3BhY2UYASABKAsyEC5hcGkudjEuRG9jU3BhY2UiOgoTR2V0RG9jU3BhY2VSZXNwb25zZRIjCglkb2Nfc3BhY2UYASABKAsyEC5hcGkudjEuRG9jU3BhY2UyoQQKD0RvY1NwYWNlU2VydmljZRJlCgtBZGREb2NTcGFjZRIaLmFwaS52MS5BZGREb2NTcGFjZVJlcXVlc3QaGy5hcGkudjEuQWRkRG9jU3BhY2VSZXNwb25zZSIdgtPkkwIXOgEqIhIvYXBpL3YxL2RvY19zcGFjZXMSbQoMRWRpdERvY1NwYWNlEhsuYXBpLnYxLkVkaXREb2NTcGFjZVJlcXVlc3QaHC5hcGkudjEuRWRpdERvY1NwYWNlUmVzcG9uc2UiIoLT5JMCHDoBKhoXL2FwaS92MS9kb2Nfc3BhY2VzL3tpZH0SZwoLR2V0RG9jU3BhY2USGi5hcGkudjEuR2V0RG9jU3BhY2VSZXF1ZXN0GhsuYXBpLnYxLkdldERvY1NwYWNlUmVzcG9uc2UiH4LT5JMCGRIXL2FwaS92MS9kb2Nfc3BhY2VzL3tpZH0SZQoMR2V0RG9jU3BhY2VzEhsuYXBpLnYxLkdldERvY1NwYWNlc1JlcXVlc3QaHC5hcGkudjEuR2V0RG9jU3BhY2VzUmVzcG9uc2UiGoLT5JMCFBISL2FwaS92MS9kb2Nfc3BhY2VzEmgKDkRlbGV0ZURvY1NwYWNlEh0uYXBpLnYxLkRlbGV0ZURvY1NwYWNlUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIfgtPkkwIZKhcvYXBpL3YxL2RvY19zcGFjZXMve2lkfUJyCgpjb20uYXBpLnYxQg1Eb2NTcGFjZVByb3RvUAFaHG9wc2VlL2JhY2tlbmQvZ2VuL2FwaS92MTtnZW6iAgNBWFiqAgZBcGkuVjHKAgZBcGlcVjHiAhJBcGlcVjFcR1BCTWV0YWRhdGHqAgdBcGk6OlYxYgZwcm90bzM", [file_google_protobuf_empty, file_google_api_annotations, file_validate_validate, file_api_v1_pagination, file_api_v1_filter, file_api_v1_models]);
17
+ fileDesc("ChZhcGkvdjEvZG9jX3NwYWNlLnByb3RvEgZhcGkudjEi4gEKEkFkZERvY1NwYWNlUmVxdWVzdBIWCgV0aXRsZRgBIAEoCUIH+kIEcgIQARIVCgRzbHVnGAIgASgJQgf6QgRyAhABEhgKC2Rlc2NyaXB0aW9uGAMgASgJSACIAQESEgoKaXNfZGVmYXVsdBgEIAEoCBITCgtpc19hcmNoaXZlZBgFIAEoCBISCgpwcm9qZWN0X2lkGAYgASgNEhoKEmNyZWF0ZWRfYnlfdXNlcl9pZBgHIAEoDRIaChJ1cGRhdGVkX2J5X3VzZXJfaWQYCCABKA1CDgoMX2Rlc2NyaXB0aW9uIvgBChNFZGl0RG9jU3BhY2VSZXF1ZXN0EhMKAmlkGAEgASgNQgf6QgQqAigBEhYKBXRpdGxlGAIgASgJQgf6QgRyAhABEhUKBHNsdWcYAyABKAlCB/pCBHICEAESGAoLZGVzY3JpcHRpb24YBCABKAlIAIgBARISCgppc19kZWZhdWx0GAUgASgIEhMKC2lzX2FyY2hpdmVkGAYgASgIEhIKCnByb2plY3RfaWQYByABKA0SGgoSY3JlYXRlZF9ieV91c2VyX2lkGAggASgNEhoKEnVwZGF0ZWRfYnlfdXNlcl9pZBgJIAEoDUIOCgxfZGVzY3JpcHRpb24iIAoSR2V0RG9jU3BhY2VSZXF1ZXN0EgoKAmlkGAEgASgNIpMBChNHZXREb2NTcGFjZXNSZXF1ZXN0EjAKCnBhZ2luYXRpb24YASABKAsyEi5hcGkudjEuUGFnaW5hdGlvbkII+kIFigECEAESLQoOZmlsdGVyX29wdGlvbnMYAiABKAsyFS5hcGkudjEuRmlsdGVyT3B0aW9ucxIbCgpwcm9qZWN0X2lkGAMgASgNQgf6QgQqAigBIiMKFURlbGV0ZURvY1NwYWNlUmVxdWVzdBIKCgJpZBgBIAEoDSJkChRHZXREb2NTcGFjZXNSZXNwb25zZRIkCgpkb2Nfc3BhY2VzGAEgAygLMhAuYXBpLnYxLkRvY1NwYWNlEiYKCnBhZ2luYXRpb24YAiABKAsyEi5hcGkudjEuUGFnaW5hdGlvbiI6ChNBZGREb2NTcGFjZVJlc3BvbnNlEiMKCWRvY19zcGFjZRgBIAEoCzIQLmFwaS52MS5Eb2NTcGFjZSI7ChRFZGl0RG9jU3BhY2VSZXNwb25zZRIjCglkb2Nfc3BhY2UYASABKAsyEC5hcGkudjEuRG9jU3BhY2UiOgoTR2V0RG9jU3BhY2VSZXNwb25zZRIjCglkb2Nfc3BhY2UYASABKAsyEC5hcGkudjEuRG9jU3BhY2UyoQQKD0RvY1NwYWNlU2VydmljZRJlCgtBZGREb2NTcGFjZRIaLmFwaS52MS5BZGREb2NTcGFjZVJlcXVlc3QaGy5hcGkudjEuQWRkRG9jU3BhY2VSZXNwb25zZSIdgtPkkwIXOgEqIhIvYXBpL3YxL2RvY19zcGFjZXMSbQoMRWRpdERvY1NwYWNlEhsuYXBpLnYxLkVkaXREb2NTcGFjZVJlcXVlc3QaHC5hcGkudjEuRWRpdERvY1NwYWNlUmVzcG9uc2UiIoLT5JMCHDoBKhoXL2FwaS92MS9kb2Nfc3BhY2VzL3tpZH0SZwoLR2V0RG9jU3BhY2USGi5hcGkudjEuR2V0RG9jU3BhY2VSZXF1ZXN0GhsuYXBpLnYxLkdldERvY1NwYWNlUmVzcG9uc2UiH4LT5JMCGRIXL2FwaS92MS9kb2Nfc3BhY2VzL3tpZH0SZQoMR2V0RG9jU3BhY2VzEhsuYXBpLnYxLkdldERvY1NwYWNlc1JlcXVlc3QaHC5hcGkudjEuR2V0RG9jU3BhY2VzUmVzcG9uc2UiGoLT5JMCFBISL2FwaS92MS9kb2Nfc3BhY2VzEmgKDkRlbGV0ZURvY1NwYWNlEh0uYXBpLnYxLkRlbGV0ZURvY1NwYWNlUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIfgtPkkwIZKhcvYXBpL3YxL2RvY19zcGFjZXMve2lkfUJyCgpjb20uYXBpLnYxQg1Eb2NTcGFjZVByb3RvUAFaHG9wc2VlL2JhY2tlbmQvZ2VuL2FwaS92MTtnZW6iAgNBWFiqAgZBcGkuVjHKAgZBcGlcVjHiAhJBcGlcVjFcR1BCTWV0YWRhdGHqAgdBcGk6OlYxYgZwcm90bzM", [file_google_protobuf_empty, file_google_api_annotations, file_validate_validate, file_api_v1_pagination, file_api_v1_filter, file_api_v1_models]);
18
18
 
19
19
  /**
20
20
  * Describes the message api.v1.AddDocSpaceRequest.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsee/mcp-server",
3
- "version": "0.9.33",
3
+ "version": "0.9.35",
4
4
  "description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  },
39
39
  "files": [
40
40
  "src",
41
+ "!**/__tests__",
41
42
  "bin",
42
43
  "gen"
43
44
  ]
package/src/tools/docs.ts CHANGED
@@ -1,18 +1,15 @@
1
1
  import { z } from "zod";
2
- import { create } from "@bufbuild/protobuf";
3
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
3
  import type { ApiClients } from "../client/api.js";
4
+ import { overridePagination } from "../client/api.js";
5
5
  import { authManager } from "../auth/manager.js";
6
6
  import {
7
7
  formatDocSpaceList,
8
8
  formatDocPageList,
9
9
  formatDocPage,
10
10
  formatError,
11
+ detectDocContentType,
11
12
  } from "../utils/format.js";
12
- import {
13
- FilterOptionsSchema,
14
- FilterSchema,
15
- } from "../../gen/api/v1/filter_pb.js";
16
13
 
17
14
  function toKebabCase(str: string): string {
18
15
  return str
@@ -33,15 +30,14 @@ export function registerDocTools(
33
30
  async ({ projectId }) => {
34
31
  try {
35
32
  const clients = getClients();
33
+ // project_id goes on the request itself, as with doc pages: the backend
34
+ // scopes the list with GetDocSpacesRequest.project_id, and a generic
35
+ // ProjectId filter cannot stand in for it -- scopes.ParseFilterOptions
36
+ // drops that key as security-sensitive, which returned every doc space
37
+ // in the company.
36
38
  const res = await clients.docSpaces.getDocSpaces({
37
- filterOptions: create(FilterOptionsSchema, {
38
- filters: [
39
- create(FilterSchema, {
40
- key: "ProjectId",
41
- value: String(projectId),
42
- }),
43
- ],
44
- }),
39
+ projectId,
40
+ pagination: overridePagination(),
45
41
  });
46
42
  return {
47
43
  content: [{ type: "text", text: formatDocSpaceList(res.docSpaces) }],
@@ -54,21 +50,20 @@ export function registerDocTools(
54
50
 
55
51
  server.tool(
56
52
  "opsee_list_doc_pages",
57
- "List documentation pages in a doc space.",
58
- { docSpaceId: z.number().int().min(1).describe("The doc space ID") },
53
+ "List documentation pages in an Opsee project.",
54
+ { projectId: z.number().int().min(1).describe("The project ID") },
59
55
  { readOnlyHint: true, destructiveHint: false },
60
- async ({ docSpaceId }) => {
56
+ async ({ projectId }) => {
61
57
  try {
62
58
  const clients = getClients();
59
+ // Doc pages hang off the project, not off a doc space: model.DocPage has
60
+ // no doc_space_id column (only the legacy proto field), and the backend
61
+ // scopes the list with the request's own project_id, which the validator
62
+ // requires to be >= 1. A generic ProjectId filter cannot substitute for
63
+ // it -- scopes.ParseFilterOptions drops that key as security-sensitive.
63
64
  const res = await clients.docPages.getDocPages({
64
- filterOptions: create(FilterOptionsSchema, {
65
- filters: [
66
- create(FilterSchema, {
67
- key: "DocSpaceId",
68
- value: String(docSpaceId),
69
- }),
70
- ],
71
- }),
65
+ projectId,
66
+ pagination: overridePagination(),
72
67
  });
73
68
  return {
74
69
  content: [{ type: "text", text: formatDocPageList(res.docPages) }],
@@ -103,11 +98,17 @@ export function registerDocTools(
103
98
  {
104
99
  projectId: z.number().int().min(1).describe("The project ID"),
105
100
  title: z.string().describe("Page title"),
106
- content: z.string().describe("Page content (text or JSON)"),
101
+ content: z.string().describe("Page content: markdown/plain text, or a BlockNote JSON array"),
102
+ contentType: z
103
+ .enum(["text", "json"])
104
+ .optional()
105
+ .describe(
106
+ "How the reader should interpret content: 'text' (markdown/plain) or 'json' (BlockNote array). Leave unset to detect it from the content.",
107
+ ),
107
108
  parentPageId: z.number().int().min(1).optional().describe("Parent page ID for nested pages"),
108
109
  },
109
110
  { readOnlyHint: false, destructiveHint: false },
110
- async ({ projectId, title, content, parentPageId }) => {
111
+ async ({ projectId, title, content, contentType, parentPageId }) => {
111
112
  try {
112
113
  const clients = getClients();
113
114
 
@@ -123,7 +124,7 @@ export function registerDocTools(
123
124
  title,
124
125
  slug: toKebabCase(title),
125
126
  currentContent: content,
126
- contentType: "json",
127
+ contentType: contentType ?? detectDocContentType(content),
127
128
  currentVersionNumber: 1,
128
129
  isRoot: !parentPageId,
129
130
  isArchived: false,
@@ -23,6 +23,7 @@ const ANCHOR_TYPES = ["none", "task", "milestone", "cycle"] as const;
23
23
  const MEMORY_KINDS = ["decision", "outcome", "learning", "blocker", "context"] as const;
24
24
  const MEMORY_SOURCE_TYPES = ["none", "task", "pull_request", "doc", "workflow"] as const;
25
25
  const EDGE_TYPES = ["blocks", "blocked_by", "duplicates", "relates_to"] as const;
26
+ const MEMORY_CONTENT_TYPES = ["text", "json"] as const;
26
27
 
27
28
  export function registerInitiativeTools(
28
29
  server: McpServer,
@@ -345,7 +346,7 @@ export function registerInitiativeTools(
345
346
  initiativeId: z.number().int().min(1).describe("The initiative ID"),
346
347
  kind: z.enum(MEMORY_KINDS).describe("Entry kind: decision | outcome | learning | blocker | context"),
347
348
  body: z.string().describe("The memory entry body text"),
348
- contentType: z.string().optional().describe("Content type of body (e.g. 'text'). Defaults to 'text'"),
349
+ contentType: z.enum(MEMORY_CONTENT_TYPES).optional().describe("Content type of body: 'text' (markdown — the default) or 'json' (BlockNote). Defaults to 'text'"),
349
350
  sourceType: z.enum(MEMORY_SOURCE_TYPES).optional().describe("Source type: none | task | pull_request | doc | workflow. Defaults to 'none'"),
350
351
  sourceId: z.number().int().min(1).optional().describe("ID of the source entity (task ID, PR ID, etc.)"),
351
352
  sourceUrl: z.string().optional().describe("URL linking to the source"),
@@ -11,7 +11,9 @@ import {
11
11
  formatError,
12
12
  } from "../utils/format.js";
13
13
 
14
- const MILESTONE_STATUSES = ["not_started", "in_progress", "at_risk", "completed"] as const;
14
+ // Must match the values AddMilestoneRequest/EditMilestoneRequest accept in
15
+ // proto/api/v1/milestone.proto; the backend rejects anything else outright.
16
+ const MILESTONE_STATUSES = ["Planned", "InProgress", "Completed", "Cancelled"] as const;
15
17
 
16
18
  export function registerMilestoneTools(
17
19
  server: McpServer,
@@ -59,7 +61,7 @@ export function registerMilestoneTools(
59
61
  {
60
62
  projectId: z.number().int().min(1).describe("The project ID"),
61
63
  name: z.string().describe("Milestone name"),
62
- status: z.enum(MILESTONE_STATUSES).optional().describe("Milestone status. Defaults to 'not_started'"),
64
+ status: z.enum(MILESTONE_STATUSES).optional().describe("Milestone status. Defaults to 'Planned'"),
63
65
  description: z.string().optional().describe("Milestone description"),
64
66
  startDate: z.string().optional().describe("Start date (ISO 8601, e.g. 2026-04-01)"),
65
67
  endDate: z.string().optional().describe("End date (ISO 8601, e.g. 2026-04-08)"),
@@ -71,7 +73,7 @@ export function registerMilestoneTools(
71
73
  const res = await clients.milestones.addMilestone({
72
74
  projectId,
73
75
  name,
74
- status: status ?? "not_started",
76
+ status: status ?? "Planned",
75
77
  description,
76
78
  startDate: startDate ? timestampFromDate(new Date(startDate)) : undefined,
77
79
  endDate: endDate ? timestampFromDate(new Date(endDate)) : undefined,
@@ -94,6 +94,27 @@ function renderBlock(block: BNBlock, indent: string): string[] {
94
94
  return lines;
95
95
  }
96
96
 
97
+ // Mirrors the two existing product-side detectors so a page written through MCP
98
+ // is labelled exactly as the same bytes would be from the web UI:
99
+ // frontend/src/pages/Docs/hooks/useDocTreeMutations.js (JSON.parse + Array.isArray)
100
+ // and backend/service/mentions.go IsBlockNoteJSON (unmarshal into []interface{}).
101
+ // A JSON array is the only shape BlockNote persists, so an object or a bare
102
+ // scalar is content, not a document body.
103
+ export function isBlockNoteJSON(content: string): boolean {
104
+ try {
105
+ return Array.isArray(JSON.parse(content));
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ // The reader (frontend/src/pages/Docs/DocPage.jsx) only JSON-parses when
112
+ // contentType is "json" and otherwise wraps the raw string in a paragraph, so
113
+ // mislabelling markdown as "json" renders an empty page.
114
+ export function detectDocContentType(content: string): "text" | "json" {
115
+ return isBlockNoteJSON(content) ? "json" : "text";
116
+ }
117
+
97
118
  export function blockNoteToMarkdown(content: string): string {
98
119
  try {
99
120
  const parsed = JSON.parse(content);
@@ -17,7 +17,7 @@ export {
17
17
  export { formatUser, formatUserList } from "./user.js";
18
18
  export { formatLabel, formatLabelList, formatTaskLabelList } from "./label.js";
19
19
  export { formatComment, formatCommentList } from "./comment.js";
20
- export { blockNoteToMarkdown } from "./blocknote.js";
20
+ export { blockNoteToMarkdown, isBlockNoteJSON, detectDocContentType } from "./blocknote.js";
21
21
  export {
22
22
  WORKING_AGREEMENT,
23
23
  formatInitiative,
@@ -1,58 +0,0 @@
1
- import { describe, test, expect } from "vitest";
2
- import { readdirSync, readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { blockNoteToMarkdown } from "../utils/format.js";
6
-
7
- // The BlockNote <-> markdown mapping is implemented three times against no
8
- // shared contract: this reader, orchestrator/internal/util/blocknote.go, and
9
- // the frontend BlockEditor via @blocknote/core. testdata/blocknote is the
10
- // language-neutral fixture set all three should eventually assert against;
11
- // this suite pins the TypeScript side of it.
12
- const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../testdata/blocknote");
13
-
14
- interface Fixture {
15
- name: string;
16
- description: string;
17
- /** Stored value when it is BlockNote JSON. Serialized before rendering. */
18
- blocknote?: unknown[];
19
- /** Stored value when it is not BlockNote JSON, passed through verbatim. */
20
- input?: string;
21
- markdown: string;
22
- }
23
-
24
- const fixtures = readdirSync(FIXTURE_DIR)
25
- .filter((f) => f.endsWith(".json"))
26
- .sort()
27
- .map((f) => JSON.parse(readFileSync(join(FIXTURE_DIR, f), "utf8")) as Fixture);
28
-
29
- describe("blockNoteToMarkdown golden fixtures", () => {
30
- // Naming the cases rather than counting them: a suite whose whole job is to
31
- // catch drift must fail, not vacuously pass, if the fixtures go missing.
32
- test("covers every construct the task-description contract promises", () => {
33
- expect(fixtures.map((f) => f.name).sort()).toEqual([
34
- "block-separation",
35
- "checklist",
36
- "code-fence",
37
- "empty-document",
38
- "heading-level-clamp",
39
- "headings",
40
- "inline-styles",
41
- "links",
42
- "nested-bullets",
43
- "passthrough-malformed-block",
44
- "passthrough-non-array-json",
45
- "passthrough-plain-text",
46
- "task-description",
47
- ]);
48
- });
49
-
50
- for (const fixture of fixtures) {
51
- test(`${fixture.name}: ${fixture.description}`, () => {
52
- const isBlockNote = fixture.blocknote !== undefined;
53
- expect(isBlockNote || typeof fixture.input === "string").toBe(true);
54
- const stored = isBlockNote ? JSON.stringify(fixture.blocknote) : fixture.input!;
55
- expect(blockNoteToMarkdown(stored)).toBe(fixture.markdown);
56
- });
57
- }
58
- });
@@ -1,92 +0,0 @@
1
- import { describe, test, expect } from "vitest";
2
- import { blockNoteToMarkdown } from "../utils/format.js";
3
-
4
- const bn = (blocks: unknown[]) => JSON.stringify(blocks);
5
-
6
- const text = (t: string, styles?: Record<string, boolean>) => ({
7
- type: "text",
8
- text: t,
9
- ...(styles ? { styles } : {}),
10
- });
11
-
12
- describe("blockNoteToMarkdown", () => {
13
- test("renders paragraphs", () => {
14
- expect(blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("hello")] }]))).toBe("hello");
15
- });
16
-
17
- test("renders headings, bullets and checkboxes", () => {
18
- const md = blockNoteToMarkdown(
19
- bn([
20
- { type: "heading", props: { level: 2 }, content: [text("Acceptance criteria")] },
21
- { type: "checkListItem", props: { checked: true }, content: [text("done")] },
22
- { type: "checkListItem", props: { checked: false }, content: [text("todo")] },
23
- ]),
24
- );
25
- expect(md).toContain("## Acceptance criteria");
26
- expect(md).toContain("- [x] done");
27
- expect(md).toContain("- [ ] todo");
28
- });
29
-
30
- test("renders bold, italic and code inline styles", () => {
31
- expect(blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("a", { bold: true })] }]))).toBe("**a**");
32
- expect(blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("a", { italic: true })] }]))).toBe("*a*");
33
- expect(blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("a", { code: true })] }]))).toBe("`a`");
34
- });
35
-
36
- test("renders links", () => {
37
- const md = blockNoteToMarkdown(
38
- bn([{ type: "paragraph", content: [{ type: "link", href: "https://x.test", content: [text("x")] }] }]),
39
- );
40
- expect(md).toBe("[x](https://x.test)");
41
- });
42
-
43
- test("returns the raw string for non-BlockNote input", () => {
44
- expect(blockNoteToMarkdown("just markdown")).toBe("just markdown");
45
- });
46
-
47
- // Both Go writers persist strikethrough under the BlockNote style key
48
- // "strike": backend/service/task.go parseInlineMarkdown and
49
- // orchestrator/internal/util/blocknote.go. This reader is what renders an
50
- // initiative's coreIdea for get-initiative-context, so dropping the markers
51
- // makes an agent read struck-out (abandoned) plan items as live ones.
52
- test("preserves strikethrough written by the Go markdown parser", () => {
53
- const md = blockNoteToMarkdown(
54
- bn([
55
- {
56
- type: "paragraph",
57
- content: [text("Ship "), text("by Friday", { strike: true }), text(" - moved to next cycle")],
58
- },
59
- ]),
60
- );
61
- expect(md).toBe("Ship ~~by Friday~~ - moved to next cycle");
62
- });
63
-
64
- // The local BNInlineStyle type declares `strikethrough`, so cover that
65
- // spelling too - whichever key the pipeline uses, the marker must survive.
66
- test("preserves strikethrough under the declared style key", () => {
67
- const md = blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("gone", { strikethrough: true })] }]));
68
- expect(md).toContain("~~gone~~");
69
- });
70
-
71
- test("preserves underline as declared in BNInlineStyle", () => {
72
- const md = blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("u", { underline: true })] }]));
73
- expect(md).not.toBe("u");
74
- });
75
-
76
- test("nests a code block inside a list item on its own indented lines", () => {
77
- const md = blockNoteToMarkdown(
78
- bn([
79
- {
80
- type: "bulletListItem",
81
- content: [text("run this")],
82
- children: [{ type: "codeBlock", props: { language: "sh" }, content: [text("make test\nmake lint")] }],
83
- },
84
- ]),
85
- );
86
- const lines = md.split("\n");
87
- // Every line of the fenced block body must carry the nesting indent,
88
- // otherwise the second command escapes the list item.
89
- const bodyLines = lines.filter((l) => l.includes("make "));
90
- expect(bodyLines.every((l) => l.startsWith(" "))).toBe(true);
91
- });
92
- });
@@ -1,193 +0,0 @@
1
- import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
2
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
- import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
- import { createServer } from "../server.js";
5
- import type { ApiClients } from "../client/api.js";
6
-
7
- const mockUser = { id: 5, fullName: "Alice Test" };
8
-
9
- const mockComment = {
10
- id: 11,
11
- content: "old text",
12
- isInternal: false,
13
- taskId: 10,
14
- userId: 5,
15
- user: mockUser,
16
- };
17
-
18
- // Records the last request each mock method received so tests can assert pass-through.
19
- let lastAddReq: any = null;
20
- let lastEditReq: any = null;
21
- let lastListReq: any = null;
22
-
23
- function createMockClients(): ApiClients {
24
- lastAddReq = null;
25
- lastEditReq = null;
26
- lastListReq = null;
27
- return {
28
- users: {
29
- getMe: async () => ({ user: mockUser }),
30
- },
31
- comments: {
32
- getComments: async (req: any) => {
33
- lastListReq = req;
34
- return { comments: [mockComment] };
35
- },
36
- getComment: async () => ({ comment: mockComment }),
37
- addComment: async (req: any) => {
38
- lastAddReq = req;
39
- return { comment: { ...mockComment, id: 12, content: req.content, isInternal: req.isInternal, userId: req.userId } };
40
- },
41
- editComment: async (req: any) => {
42
- lastEditReq = req;
43
- return { comment: { ...mockComment, content: req.content, isInternal: req.isInternal } };
44
- },
45
- deleteComment: async () => ({}),
46
- },
47
- } as unknown as ApiClients;
48
- }
49
-
50
- describe("Comment tools", () => {
51
- let client: Client;
52
- let cleanup: () => Promise<void>;
53
-
54
- beforeAll(async () => {
55
- const [ct, st] = InMemoryTransport.createLinkedPair();
56
- const server = createServer(() => createMockClients());
57
- await server.connect(st);
58
- client = new Client({ name: "comments-test", version: "1.0.0" });
59
- await client.connect(ct);
60
- cleanup = async () => {
61
- await client.close();
62
- await server.close();
63
- };
64
- });
65
-
66
- afterAll(async () => {
67
- await cleanup();
68
- });
69
-
70
- beforeEach(() => {
71
- lastAddReq = null;
72
- lastEditReq = null;
73
- lastListReq = null;
74
- });
75
-
76
- test("opsee_list_comments returns the formatted list", async () => {
77
- const result = await client.callTool({
78
- name: "opsee_list_comments",
79
- arguments: { taskId: 10 },
80
- });
81
- const text = (result.content as Array<{ type: string; text: string }>)[0].text;
82
- expect(text).toContain("Found 1 comment(s)");
83
- expect(text).toContain("old text");
84
- expect(text).toContain("Alice Test");
85
- expect(lastListReq.taskId).toBe(10);
86
- });
87
-
88
- test("opsee_list_comments reports none for an empty result", async () => {
89
- const emptyClients = createMockClients();
90
- (emptyClients.comments as any).getComments = async () => ({ comments: [] });
91
-
92
- const [ct, st] = InMemoryTransport.createLinkedPair();
93
- const server = createServer(() => emptyClients);
94
- await server.connect(st);
95
- const c = new Client({ name: "empty-comments-test", version: "1.0.0" });
96
- await c.connect(ct);
97
-
98
- const result = await c.callTool({
99
- name: "opsee_list_comments",
100
- arguments: { initiativeId: 2 },
101
- });
102
- const text = (result.content as Array<{ type: string; text: string }>)[0].text;
103
- expect(text).toContain("No comments found.");
104
-
105
- await c.close();
106
- await server.close();
107
- });
108
-
109
- test("opsee_add_comment resolves the current user via getMe and posts the comment", async () => {
110
- const result = await client.callTool({
111
- name: "opsee_add_comment",
112
- arguments: { taskId: 10, content: "new comment" },
113
- });
114
- const text = (result.content as Array<{ type: string; text: string }>)[0].text;
115
- expect(text).toContain("Comment added");
116
- expect(text).toContain("new comment");
117
- expect(lastAddReq.userId).toBe(5);
118
- expect(lastAddReq.isInternal).toBe(false);
119
- });
120
-
121
- test("opsee_add_comment passes isInternal through", async () => {
122
- await client.callTool({
123
- name: "opsee_add_comment",
124
- arguments: { taskId: 10, content: "internal note", isInternal: true },
125
- });
126
- expect(lastAddReq.isInternal).toBe(true);
127
- });
128
-
129
- test("opsee_add_comment errors when the current user cannot be resolved", async () => {
130
- const noUserClients = createMockClients();
131
- (noUserClients.users as any).getMe = async () => ({});
132
-
133
- const [ct, st] = InMemoryTransport.createLinkedPair();
134
- const server = createServer(() => noUserClients);
135
- await server.connect(st);
136
- const c = new Client({ name: "no-user-test", version: "1.0.0" });
137
- await c.connect(ct);
138
-
139
- const result = await c.callTool({
140
- name: "opsee_add_comment",
141
- arguments: { taskId: 10, content: "orphan comment" },
142
- });
143
- expect(result.isError).toBe(true);
144
- const text = (result.content as Array<{ type: string; text: string }>)[0].text;
145
- expect(text).toContain("Could not resolve current user");
146
-
147
- await c.close();
148
- await server.close();
149
- });
150
-
151
- test("opsee_edit_comment looks up the existing comment before editing", async () => {
152
- const result = await client.callTool({
153
- name: "opsee_edit_comment",
154
- arguments: { commentId: 11, content: "edited text" },
155
- });
156
- const text = (result.content as Array<{ type: string; text: string }>)[0].text;
157
- expect(text).toContain("Comment updated");
158
- expect(text).toContain("edited text");
159
- expect(lastEditReq.id).toBe(11);
160
- expect(lastEditReq.taskId).toBe(10);
161
- expect(lastEditReq.isInternal).toBe(false);
162
- });
163
-
164
- test("opsee_edit_comment reports not found when the comment does not exist", async () => {
165
- const missingClients = createMockClients();
166
- (missingClients.comments as any).getComment = async () => ({});
167
-
168
- const [ct, st] = InMemoryTransport.createLinkedPair();
169
- const server = createServer(() => missingClients);
170
- await server.connect(st);
171
- const c = new Client({ name: "missing-comment-test", version: "1.0.0" });
172
- await c.connect(ct);
173
-
174
- const result = await c.callTool({
175
- name: "opsee_edit_comment",
176
- arguments: { commentId: 999, content: "won't apply" },
177
- });
178
- const text = (result.content as Array<{ type: string; text: string }>)[0].text;
179
- expect(text).toContain("Comment not found.");
180
-
181
- await c.close();
182
- await server.close();
183
- });
184
-
185
- test("opsee_delete_comment succeeds", async () => {
186
- const result = await client.callTool({
187
- name: "opsee_delete_comment",
188
- arguments: { commentId: 11 },
189
- });
190
- const text = (result.content as Array<{ type: string; text: string }>)[0].text;
191
- expect(text).toContain("Comment deleted.");
192
- });
193
- });