@opsee/mcp-server 0.9.34 → 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 +1 -1
- package/gen/api/v1/doc_space_pb.d.ts +11 -0
- package/gen/api/v1/doc_space_pb.js +1 -1
- package/package.json +2 -1
- package/src/tools/docs.ts +28 -27
- package/src/tools/initiatives.ts +2 -1
- package/src/tools/milestones.ts +5 -3
- package/src/utils/format/blocknote.ts +21 -0
- package/src/utils/format/index.ts +1 -1
- package/src/__tests__/blocknote-fixtures.test.ts +0 -58
- package/src/__tests__/blocknote-markdown.test.ts +0 -92
- package/src/__tests__/comments.test.ts +0 -193
- package/src/__tests__/initiatives.test.ts +0 -188
- package/src/__tests__/kv-store.test.ts +0 -53
- package/src/__tests__/task-dependencies.test.ts +0 -173
- package/src/__tests__/tools.test.ts +0 -913
- package/src/__tests__/work-logs.test.ts +0 -140
- package/src/auth/__tests__/manager.test.ts +0 -67
- package/src/auth/__tests__/oauth-provider.test.ts +0 -272
- package/src/utils/__tests__/format.test.ts +0 -53
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/
|
|
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.
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
58
|
-
{
|
|
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 ({
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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:
|
|
127
|
+
contentType: contentType ?? detectDocContentType(content),
|
|
127
128
|
currentVersionNumber: 1,
|
|
128
129
|
isRoot: !parentPageId,
|
|
129
130
|
isArchived: false,
|
package/src/tools/initiatives.ts
CHANGED
|
@@ -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.
|
|
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"),
|
package/src/tools/milestones.ts
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
formatError,
|
|
12
12
|
} from "../utils/format.js";
|
|
13
13
|
|
|
14
|
-
|
|
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 '
|
|
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 ?? "
|
|
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
|
-
});
|