@opsee/mcp-server 0.9.36 → 0.9.37

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
@@ -78,9 +78,26 @@ Ask Claude things like:
78
78
  | `opsee_update_cycle` | Update a cycle's fields |
79
79
  | `opsee_delete_cycle` | Delete a cycle |
80
80
  | `opsee_list_doc_spaces` | List doc spaces in a project |
81
+ | `opsee_get_doc_space` | Get doc space details |
82
+ | `opsee_create_doc_space` | Create a doc space |
83
+ | `opsee_update_doc_space` | Update a doc space |
84
+ | `opsee_delete_doc_space` | Delete a doc space |
81
85
  | `opsee_list_doc_pages` | List pages in a doc space |
82
86
  | `opsee_get_doc_page` | Read a doc page |
83
87
  | `opsee_create_doc_page` | Create a new doc page |
88
+ | `opsee_update_doc_page` | Update a doc page (read-merge-write, with version snapshotting) |
89
+ | `opsee_delete_doc_page` | Delete a doc page |
90
+ | `opsee_set_doc_page_archived` | Archive or unarchive a doc page |
91
+ | `opsee_list_doc_templates` | List doc templates in a project |
92
+ | `opsee_create_doc_template` | Create a doc template |
93
+ | `opsee_update_doc_template` | Update a doc template |
94
+ | `opsee_delete_doc_template` | Delete a doc template |
95
+ | `opsee_list_task_doc_pages` | List doc pages attached to a task |
96
+ | `opsee_attach_doc_page_to_task` | Attach a doc page to a task |
97
+ | `opsee_detach_doc_page_from_task` | Detach a doc page from a task |
98
+ | `opsee_list_milestone_doc_pages` | List doc pages attached to a milestone |
99
+ | `opsee_attach_doc_page_to_milestone` | Attach a doc page to a milestone |
100
+ | `opsee_detach_doc_page_from_milestone` | Detach a doc page from a milestone |
84
101
  | `opsee_list_repositories` | List connected repositories |
85
102
 
86
103
  ## Task description format
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsee/mcp-server",
3
- "version": "0.9.36",
3
+ "version": "0.9.37",
4
4
  "description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client/api.ts CHANGED
@@ -16,6 +16,7 @@ import { BoardColumnService } from "../../gen/api/v1/board_column_pb.js";
16
16
  import { CycleService } from "../../gen/api/v1/cycle_pb.js";
17
17
  import { DocSpaceService } from "../../gen/api/v1/doc_space_pb.js";
18
18
  import { DocPageService } from "../../gen/api/v1/doc_page_pb.js";
19
+ import { DocPageVersionService } from "../../gen/api/v1/doc_page_version_pb.js";
19
20
  import { TaskTypeService } from "../../gen/api/v1/task_type_pb.js";
20
21
  import { TaskPriorityService } from "../../gen/api/v1/task_priority_pb.js";
21
22
  import { UserService } from "../../gen/api/v1/user_pb.js";
@@ -29,6 +30,9 @@ import { TaskDependencyService } from "../../gen/api/v1/task_dependency_pb.js";
29
30
  import { NotificationService } from "../../gen/api/v1/notification_pb.js";
30
31
  import { AcceptanceCriterionService } from "../../gen/api/v1/acceptance_criterion_pb.js";
31
32
  import { InitiativeService } from "../../gen/api/v1/initiative_pb.js";
33
+ import { DocTemplateService } from "../../gen/api/v1/doc_template_pb.js";
34
+ import { TaskDocPageService } from "../../gen/api/v1/task_doc_page_pb.js";
35
+ import { MilestoneDocPageService } from "../../gen/api/v1/milestone_doc_page_pb.js";
32
36
 
33
37
  const authInterceptor: Interceptor = (next) => async (req) => {
34
38
  // Remote mode: token from AsyncLocalStorage (per-request)
@@ -102,6 +106,7 @@ export type ApiClients = {
102
106
  cycles: Client<typeof CycleService>;
103
107
  docSpaces: Client<typeof DocSpaceService>;
104
108
  docPages: Client<typeof DocPageService>;
109
+ docPageVersions: Client<typeof DocPageVersionService>;
105
110
  taskTypes: Client<typeof TaskTypeService>;
106
111
  taskPriorities: Client<typeof TaskPriorityService>;
107
112
  users: Client<typeof UserService>;
@@ -115,6 +120,9 @@ export type ApiClients = {
115
120
  notifications: Client<typeof NotificationService>;
116
121
  acceptanceCriteria: Client<typeof AcceptanceCriterionService>;
117
122
  initiatives: Client<typeof InitiativeService>;
123
+ docTemplates: Client<typeof DocTemplateService>;
124
+ taskDocPages: Client<typeof TaskDocPageService>;
125
+ milestoneDocPages: Client<typeof MilestoneDocPageService>;
118
126
  };
119
127
 
120
128
  export function getClients(): ApiClients {
@@ -127,6 +135,7 @@ export function getClients(): ApiClients {
127
135
  cycles: makeClient(CycleService),
128
136
  docSpaces: makeClient(DocSpaceService),
129
137
  docPages: makeClient(DocPageService),
138
+ docPageVersions: makeClient(DocPageVersionService),
130
139
  taskTypes: makeClient(TaskTypeService),
131
140
  taskPriorities: makeClient(TaskPriorityService),
132
141
  users: makeClient(UserService),
@@ -140,6 +149,9 @@ export function getClients(): ApiClients {
140
149
  notifications: makeClient(NotificationService),
141
150
  acceptanceCriteria: makeClient(AcceptanceCriterionService),
142
151
  initiatives: makeClient(InitiativeService),
152
+ docTemplates: makeClient(DocTemplateService),
153
+ taskDocPages: makeClient(TaskDocPageService),
154
+ milestoneDocPages: makeClient(MilestoneDocPageService),
143
155
  };
144
156
  }
145
157
  return cachedClients;
package/src/server.ts CHANGED
@@ -15,6 +15,9 @@ import { registerWorkLogTools } from "./tools/work-logs.js";
15
15
  import { registerNotificationTools } from "./tools/notifications.js";
16
16
  import { registerAcceptanceCriterionTools } from "./tools/acceptance-criteria.js";
17
17
  import { registerInitiativeTools } from "./tools/initiatives.js";
18
+ import { registerDocSpaceWriteTools } from "./tools/doc-spaces.js";
19
+ import { registerDocTemplateTools } from "./tools/doc-templates.js";
20
+ import { registerDocLinkTools } from "./tools/doc-links.js";
18
21
 
19
22
  export function createServer(clientFactory?: () => ApiClients): McpServer {
20
23
  const factory = clientFactory ?? getClients;
@@ -39,6 +42,9 @@ export function createServer(clientFactory?: () => ApiClients): McpServer {
39
42
  registerNotificationTools(server, factory);
40
43
  registerAcceptanceCriterionTools(server, factory);
41
44
  registerInitiativeTools(server, factory);
45
+ registerDocSpaceWriteTools(server, factory);
46
+ registerDocTemplateTools(server, factory);
47
+ registerDocLinkTools(server, factory);
42
48
 
43
49
  return server;
44
50
  }
@@ -0,0 +1,170 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { Code, ConnectError } from "@connectrpc/connect";
4
+ import type { ApiClients } from "../client/api.js";
5
+ import { overridePagination } from "../client/api.js";
6
+ import { formatDocPage, formatDocPageList, formatError } from "../utils/format.js";
7
+ import type { DocPage } from "../../gen/api/v1/models_pb.js";
8
+
9
+ // TaskDocPage and MilestoneDocPage rows both embed the linked DocPage; pull
10
+ // those out so list views can reuse formatDocPageList from format/docs.ts
11
+ // instead of a parallel formatter.
12
+ function toDocPages<T extends { docPage?: DocPage }>(rows: T[]): DocPage[] {
13
+ return rows.map((r) => r.docPage).filter((p): p is DocPage => !!p);
14
+ }
15
+
16
+ export function registerDocLinkTools(
17
+ server: McpServer,
18
+ getClients: () => ApiClients,
19
+ ): void {
20
+ server.tool(
21
+ "opsee_list_task_doc_pages",
22
+ "List doc pages attached to a specific task.",
23
+ { taskId: z.number().int().min(1).describe("The task ID") },
24
+ { readOnlyHint: true, destructiveHint: false },
25
+ async ({ taskId }) => {
26
+ try {
27
+ const clients = getClients();
28
+ const res = await clients.taskDocPages.getTaskDocPages({
29
+ taskId,
30
+ pagination: overridePagination(),
31
+ });
32
+ return { content: [{ type: "text", text: formatDocPageList(toDocPages(res.taskDocPages)) }] };
33
+ } catch (error) {
34
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
35
+ }
36
+ },
37
+ );
38
+
39
+ server.tool(
40
+ "opsee_attach_doc_page_to_task",
41
+ "Attach an existing doc page to a task.",
42
+ {
43
+ taskId: z.number().int().min(1).describe("The task ID"),
44
+ docPageId: z.number().int().min(1).describe("The doc page ID to attach"),
45
+ },
46
+ { readOnlyHint: false, destructiveHint: false },
47
+ async ({ taskId, docPageId }) => {
48
+ try {
49
+ const clients = getClients();
50
+ const res = await clients.taskDocPages.addTaskDocPage({ taskId, docPageId });
51
+ if (!res.taskDocPage?.docPage)
52
+ return { content: [{ type: "text", text: "Doc page attached." }] };
53
+ return { content: [{ type: "text", text: `Doc page attached:\n${formatDocPage(res.taskDocPage.docPage)}` }] };
54
+ } catch (error) {
55
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
56
+ }
57
+ },
58
+ );
59
+
60
+ server.tool(
61
+ "opsee_detach_doc_page_from_task",
62
+ "Detach a doc page from a task. The DeleteTaskDocPage RPC takes the join-row id, which callers never have — this tool resolves it by listing the task's doc pages and matching on docPageId.",
63
+ {
64
+ taskId: z.number().int().min(1).describe("The task ID"),
65
+ docPageId: z.number().int().min(1).describe("The doc page ID to detach"),
66
+ },
67
+ { readOnlyHint: false, destructiveHint: false },
68
+ async ({ taskId, docPageId }) => {
69
+ try {
70
+ const clients = getClients();
71
+ const listRes = await clients.taskDocPages.getTaskDocPages({
72
+ taskId,
73
+ pagination: overridePagination(),
74
+ });
75
+ const row = listRes.taskDocPages.find((r) => r.docPageId === docPageId);
76
+ if (!row)
77
+ return { content: [{ type: "text", text: `Doc page ${docPageId} is not attached to task ${taskId}.` }] };
78
+
79
+ try {
80
+ await clients.taskDocPages.deleteTaskDocPage({ id: row.id });
81
+ } catch (deleteError) {
82
+ // A concurrent detach can remove the row between the list above and
83
+ // this delete. The caller's intent (link gone) is already
84
+ // satisfied, so report the same outcome instead of a raw NotFound.
85
+ if (deleteError instanceof ConnectError && deleteError.code === Code.NotFound)
86
+ return { content: [{ type: "text", text: `Doc page ${docPageId} is not attached to task ${taskId}.` }] };
87
+ throw deleteError;
88
+ }
89
+ return { content: [{ type: "text", text: "Doc page detached." }] };
90
+ } catch (error) {
91
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
92
+ }
93
+ },
94
+ );
95
+
96
+ server.tool(
97
+ "opsee_list_milestone_doc_pages",
98
+ "List doc pages attached to a specific milestone.",
99
+ { milestoneId: z.number().int().min(1).describe("The milestone ID") },
100
+ { readOnlyHint: true, destructiveHint: false },
101
+ async ({ milestoneId }) => {
102
+ try {
103
+ const clients = getClients();
104
+ const res = await clients.milestoneDocPages.getMilestoneDocPages({
105
+ milestoneId,
106
+ pagination: overridePagination(),
107
+ });
108
+ return { content: [{ type: "text", text: formatDocPageList(toDocPages(res.milestoneDocPages)) }] };
109
+ } catch (error) {
110
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
111
+ }
112
+ },
113
+ );
114
+
115
+ server.tool(
116
+ "opsee_attach_doc_page_to_milestone",
117
+ "Attach an existing doc page to a milestone.",
118
+ {
119
+ milestoneId: z.number().int().min(1).describe("The milestone ID"),
120
+ docPageId: z.number().int().min(1).describe("The doc page ID to attach"),
121
+ },
122
+ { readOnlyHint: false, destructiveHint: false },
123
+ async ({ milestoneId, docPageId }) => {
124
+ try {
125
+ const clients = getClients();
126
+ const res = await clients.milestoneDocPages.addMilestoneDocPage({ milestoneId, docPageId });
127
+ if (!res.milestoneDocPage?.docPage)
128
+ return { content: [{ type: "text", text: "Doc page attached." }] };
129
+ return { content: [{ type: "text", text: `Doc page attached:\n${formatDocPage(res.milestoneDocPage.docPage)}` }] };
130
+ } catch (error) {
131
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
132
+ }
133
+ },
134
+ );
135
+
136
+ server.tool(
137
+ "opsee_detach_doc_page_from_milestone",
138
+ "Detach a doc page from a milestone. The DeleteMilestoneDocPage RPC takes the join-row id, which callers never have — this tool resolves it by listing the milestone's doc pages and matching on docPageId.",
139
+ {
140
+ milestoneId: z.number().int().min(1).describe("The milestone ID"),
141
+ docPageId: z.number().int().min(1).describe("The doc page ID to detach"),
142
+ },
143
+ { readOnlyHint: false, destructiveHint: false },
144
+ async ({ milestoneId, docPageId }) => {
145
+ try {
146
+ const clients = getClients();
147
+ const listRes = await clients.milestoneDocPages.getMilestoneDocPages({
148
+ milestoneId,
149
+ pagination: overridePagination(),
150
+ });
151
+ const row = listRes.milestoneDocPages.find((r) => r.docPageId === docPageId);
152
+ if (!row)
153
+ return { content: [{ type: "text", text: `Doc page ${docPageId} is not attached to milestone ${milestoneId}.` }] };
154
+
155
+ try {
156
+ await clients.milestoneDocPages.deleteMilestoneDocPage({ id: row.id });
157
+ } catch (deleteError) {
158
+ // Same TOCTOU as the task variant above: a concurrent detach can
159
+ // remove the row between the list and this delete.
160
+ if (deleteError instanceof ConnectError && deleteError.code === Code.NotFound)
161
+ return { content: [{ type: "text", text: `Doc page ${docPageId} is not attached to milestone ${milestoneId}.` }] };
162
+ throw deleteError;
163
+ }
164
+ return { content: [{ type: "text", text: "Doc page detached." }] };
165
+ } catch (error) {
166
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
167
+ }
168
+ },
169
+ );
170
+ }
@@ -0,0 +1,143 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import type { ApiClients } from "../client/api.js";
4
+ import { authManager } from "../auth/manager.js";
5
+ import { formatDocSpace, formatError } from "../utils/format.js";
6
+ import { toKebabCase } from "../utils/slug.js";
7
+
8
+ async function resolveUserId(clients: ApiClients): Promise<number> {
9
+ let userId = Number(authManager.getUserId()) || 0;
10
+ if (!userId) {
11
+ const meRes = await clients.users.getMe({});
12
+ if (meRes.user) userId = meRes.user.id;
13
+ }
14
+ return userId;
15
+ }
16
+
17
+ export function registerDocSpaceWriteTools(
18
+ server: McpServer,
19
+ getClients: () => ApiClients,
20
+ ): void {
21
+ server.tool(
22
+ "opsee_get_doc_space",
23
+ "Get full details of a specific doc space by ID.",
24
+ { spaceId: z.number().int().min(1).describe("The doc space ID") },
25
+ { readOnlyHint: true, destructiveHint: false },
26
+ async ({ spaceId }) => {
27
+ try {
28
+ const clients = getClients();
29
+ const res = await clients.docSpaces.getDocSpace({ id: spaceId });
30
+ if (!res.docSpace)
31
+ return { content: [{ type: "text", text: "Doc space not found. Use opsee_list_doc_spaces to see available doc spaces." }] };
32
+ return { content: [{ type: "text", text: formatDocSpace(res.docSpace) }] };
33
+ } catch (error) {
34
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
35
+ }
36
+ },
37
+ );
38
+
39
+ server.tool(
40
+ "opsee_create_doc_space",
41
+ "Create a new documentation space in an Opsee project. Requires the Admin or Owner role; the User role has read-only access to doc spaces.",
42
+ {
43
+ projectId: z.number().int().min(1).describe("The project ID"),
44
+ title: z.string().min(1).describe("Doc space title"),
45
+ description: z.string().optional().describe("Doc space description"),
46
+ isDefault: z.boolean().optional().describe("Whether this is the project's default doc space (default: false)"),
47
+ },
48
+ { readOnlyHint: false, destructiveHint: false },
49
+ async ({ projectId, title, description, isDefault }) => {
50
+ try {
51
+ const clients = getClients();
52
+ const userId = await resolveUserId(clients);
53
+ if (!userId)
54
+ return {
55
+ content: [{ type: "text", text: "Could not resolve the current user (no cached session and getMe returned none). The doc space was not created, since the backend would otherwise persist it as owned by user 0." }],
56
+ isError: true,
57
+ };
58
+
59
+ const res = await clients.docSpaces.addDocSpace({
60
+ title,
61
+ slug: toKebabCase(title),
62
+ description,
63
+ isDefault: isDefault ?? false,
64
+ isArchived: false,
65
+ projectId,
66
+ createdByUserId: userId,
67
+ updatedByUserId: userId,
68
+ });
69
+
70
+ if (!res.docSpace)
71
+ return { content: [{ type: "text", text: "Failed to create doc space. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
72
+
73
+ return { content: [{ type: "text", text: `Doc space created:\n${formatDocSpace(res.docSpace)}` }] };
74
+ } catch (error) {
75
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
76
+ }
77
+ },
78
+ );
79
+
80
+ server.tool(
81
+ "opsee_update_doc_space",
82
+ "Update a doc space's fields (patch semantics - only provided fields are changed). EditDocSpaceRequest has no field mask, so this fetches the current doc space first and resends every field. The slug is regenerated only when title changes.",
83
+ {
84
+ spaceId: z.number().int().min(1).describe("The doc space ID to update. Requires the Admin or Owner role."),
85
+ title: z.string().min(1).optional().describe("New title"),
86
+ description: z.string().optional().describe("New description"),
87
+ isDefault: z.boolean().optional().describe("Whether this is the project's default doc space"),
88
+ isArchived: z.boolean().optional().describe("Whether the doc space is archived"),
89
+ },
90
+ { readOnlyHint: false, destructiveHint: false },
91
+ async ({ spaceId, title, description, isDefault, isArchived }) => {
92
+ try {
93
+ const clients = getClients();
94
+ const getRes = await clients.docSpaces.getDocSpace({ id: spaceId });
95
+ const space = getRes.docSpace;
96
+ if (!space)
97
+ return { content: [{ type: "text", text: "Doc space not found. Use opsee_list_doc_spaces to see available doc spaces." }] };
98
+
99
+ const userId = await resolveUserId(clients);
100
+ if (!userId)
101
+ return {
102
+ content: [{ type: "text", text: "Could not resolve the current user (no cached session and getMe returned none). The edit was not applied, since the backend would otherwise silently misattribute it to whoever last edited the doc space." }],
103
+ isError: true,
104
+ };
105
+
106
+ const res = await clients.docSpaces.editDocSpace({
107
+ id: space.id,
108
+ title: title ?? space.title,
109
+ slug: title ? toKebabCase(title) : space.slug,
110
+ description: description ?? space.description,
111
+ isDefault: isDefault ?? space.isDefault,
112
+ isArchived: isArchived ?? space.isArchived,
113
+ projectId: space.projectId,
114
+ createdByUserId: space.createdByUserId,
115
+ updatedByUserId: userId,
116
+ });
117
+
118
+ if (!res.docSpace)
119
+ return { content: [{ type: "text", text: "Failed to update doc space. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
120
+
121
+ return { content: [{ type: "text", text: `Doc space updated:\n${formatDocSpace(res.docSpace)}` }] };
122
+ } catch (error) {
123
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
124
+ }
125
+ },
126
+ );
127
+
128
+ server.tool(
129
+ "opsee_delete_doc_space",
130
+ "Delete a doc space. Doc pages are not linked to doc spaces in the data model — DocPage.doc_space_id is a legacy proto field the backend no longer populates, since pages are attached directly to the project. Deleting a doc space only removes this grouping row; it has no effect on any doc pages.",
131
+ { spaceId: z.number().int().min(1).describe("The doc space ID to delete. Requires the Admin or Owner role.") },
132
+ { readOnlyHint: false, destructiveHint: true },
133
+ async ({ spaceId }) => {
134
+ try {
135
+ const clients = getClients();
136
+ await clients.docSpaces.deleteDocSpace({ id: spaceId });
137
+ return { content: [{ type: "text", text: "Doc space deleted. Doc pages in the project are unaffected." }] };
138
+ } catch (error) {
139
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
140
+ }
141
+ },
142
+ );
143
+ }
@@ -0,0 +1,107 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import type { ApiClients } from "../client/api.js";
4
+ import { formatDocTemplate, formatDocTemplateList, formatError } from "../utils/format.js";
5
+
6
+ export function registerDocTemplateTools(
7
+ server: McpServer,
8
+ getClients: () => ApiClients,
9
+ ): void {
10
+ server.tool(
11
+ "opsee_list_doc_templates",
12
+ "List documentation templates in an Opsee project.",
13
+ { projectId: z.number().int().min(1).describe("The project ID") },
14
+ { readOnlyHint: true, destructiveHint: false },
15
+ async ({ projectId }) => {
16
+ try {
17
+ const clients = getClients();
18
+ const res = await clients.docTemplates.listDocTemplates({ projectId });
19
+ return { content: [{ type: "text", text: formatDocTemplateList(res.docTemplates) }] };
20
+ } catch (error) {
21
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
22
+ }
23
+ },
24
+ );
25
+
26
+ server.tool(
27
+ "opsee_create_doc_template",
28
+ "Create a new documentation template in an Opsee project.",
29
+ {
30
+ projectId: z.number().int().min(1).describe("The project ID"),
31
+ name: z.string().min(1).max(200).describe("Template name"),
32
+ title: z.string().max(200).optional().describe("Template title"),
33
+ content: z.string().describe("Template content"),
34
+ },
35
+ { readOnlyHint: false, destructiveHint: false },
36
+ async ({ projectId, name, title, content }) => {
37
+ try {
38
+ const clients = getClients();
39
+ const res = await clients.docTemplates.createDocTemplate({
40
+ projectId,
41
+ name,
42
+ title: title ?? "",
43
+ content,
44
+ });
45
+
46
+ if (!res.docTemplate)
47
+ return { content: [{ type: "text", text: "Failed to create doc template. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
48
+
49
+ return { content: [{ type: "text", text: `Doc template created:\n${formatDocTemplate(res.docTemplate)}` }] };
50
+ } catch (error) {
51
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
52
+ }
53
+ },
54
+ );
55
+
56
+ server.tool(
57
+ "opsee_update_doc_template",
58
+ "Update a doc template's fields (patch semantics - only provided fields are changed). There is no GetDocTemplate RPC, so projectId is required: this tool looks up the current template via opsee_list_doc_templates for that project, merges in your changes, and resends the full update (UpdateDocTemplateRequest has no field mask).",
59
+ {
60
+ projectId: z.number().int().min(1).describe("The project ID the template belongs to — required because there is no GetDocTemplate RPC; the current template is found via a project list"),
61
+ templateId: z.number().int().min(1).describe("The doc template ID to update"),
62
+ name: z.string().min(1).max(200).optional().describe("New name"),
63
+ title: z.string().max(200).optional().describe("New title"),
64
+ content: z.string().optional().describe("New content"),
65
+ },
66
+ { readOnlyHint: false, destructiveHint: false },
67
+ async ({ projectId, templateId, name, title, content }) => {
68
+ try {
69
+ const clients = getClients();
70
+ const listRes = await clients.docTemplates.listDocTemplates({ projectId });
71
+ const template = listRes.docTemplates.find((t) => t.id === templateId);
72
+ if (!template)
73
+ return { content: [{ type: "text", text: `Doc template ${templateId} not found in project ${projectId}. Use opsee_list_doc_templates to see available templates.` }] };
74
+
75
+ const res = await clients.docTemplates.updateDocTemplate({
76
+ id: template.id,
77
+ name: name ?? template.name,
78
+ title: title ?? template.title,
79
+ content: content ?? template.content,
80
+ });
81
+
82
+ if (!res.docTemplate)
83
+ return { content: [{ type: "text", text: "Failed to update doc template. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
84
+
85
+ return { content: [{ type: "text", text: `Doc template updated:\n${formatDocTemplate(res.docTemplate)}` }] };
86
+ } catch (error) {
87
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
88
+ }
89
+ },
90
+ );
91
+
92
+ server.tool(
93
+ "opsee_delete_doc_template",
94
+ "Delete a doc template. Soft delete (the row is marked deleted, not erased), so it is recoverable by an operator but not through this API. Requires the Admin or Owner role; the User role can only list templates.",
95
+ { templateId: z.number().int().min(1).describe("The doc template ID to delete") },
96
+ { readOnlyHint: false, destructiveHint: true },
97
+ async ({ templateId }) => {
98
+ try {
99
+ const clients = getClients();
100
+ await clients.docTemplates.deleteDocTemplate({ id: templateId });
101
+ return { content: [{ type: "text", text: "Doc template deleted." }] };
102
+ } catch (error) {
103
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
104
+ }
105
+ },
106
+ );
107
+ }
package/src/tools/docs.ts CHANGED
@@ -18,6 +18,15 @@ function toKebabCase(str: string): string {
18
18
  .replace(/^-|-$/g, "");
19
19
  }
20
20
 
21
+ async function resolveCurrentUserId(clients: ApiClients): Promise<number> {
22
+ let userId = Number(authManager.getUserId()) || 0;
23
+ if (!userId) {
24
+ const meRes = await clients.users.getMe({});
25
+ if (meRes.user) userId = meRes.user.id;
26
+ }
27
+ return userId;
28
+ }
29
+
21
30
  export function registerDocTools(
22
31
  server: McpServer,
23
32
  getClients: () => ApiClients,
@@ -150,4 +159,170 @@ export function registerDocTools(
150
159
  }
151
160
  },
152
161
  );
162
+
163
+ server.tool(
164
+ "opsee_update_doc_page",
165
+ "Update a documentation page's title, content, or contentType. Performs a read-merge-write: any field you don't pass is preserved from the existing page (EditDocPageRequest has no field mask, so an incomplete request would blank the rest). No optimistic lock -- a concurrent edit from the web UI can be silently lost. Cannot clear content to an empty string: the backend's struct-based update silently skips empty-string fields, so that call is rejected up front instead of reporting a false success. Does not take a display-order argument -- ordering is owned by opsee_move_doc_page (MoveDocPage), a separate endpoint. By default, changing the title or content also snapshots the prior version into page history -- pass snapshotVersion: false to skip that.",
166
+ {
167
+ pageId: z.number().int().min(1).describe("The doc page ID"),
168
+ title: z.string().optional().describe("New title. When provided, the slug is regenerated from it."),
169
+ content: z.string().optional().describe("New page content: markdown/plain text, or a BlockNote JSON array"),
170
+ contentType: z
171
+ .enum(["text", "json"])
172
+ .optional()
173
+ .describe(
174
+ "How the reader should interpret content. Only used when content is also provided; leave unset to detect it from the content.",
175
+ ),
176
+ changeSummary: z
177
+ .string()
178
+ .optional()
179
+ .describe("Short note describing the change, stored on the version snapshot"),
180
+ snapshotVersion: z
181
+ .boolean()
182
+ .optional()
183
+ .default(true)
184
+ .describe(
185
+ "Record this edit in page version history when the title or content changed. Defaults to true.",
186
+ ),
187
+ },
188
+ { readOnlyHint: false, destructiveHint: false },
189
+ async ({ pageId, title, content, contentType, changeSummary, snapshotVersion }) => {
190
+ try {
191
+ const clients = getClients();
192
+
193
+ const existingRes = await clients.docPages.getDocPage({ id: pageId });
194
+ const existing = existingRes.docPage;
195
+ if (!existing)
196
+ return { content: [{ type: "text", text: "Doc page not found." }], isError: true };
197
+
198
+ const userId = await resolveCurrentUserId(clients);
199
+ if (!userId)
200
+ return {
201
+ content: [{ type: "text", text: "Could not resolve the current user (no cached session and getMe returned none). The edit was not applied, since the backend would otherwise silently misattribute it to whoever last edited the page." }],
202
+ isError: true,
203
+ };
204
+
205
+ const finalTitle = title ?? existing.title;
206
+ const finalSlug = title !== undefined ? toKebabCase(title) : existing.slug;
207
+ if (title !== undefined && finalSlug === "")
208
+ return {
209
+ content: [{ type: "text", text: `Cannot update: title "${title}" has no alphanumeric characters usable in a slug. Choose a title with at least one letter or digit.` }],
210
+ isError: true,
211
+ };
212
+
213
+ const finalContent = content ?? existing.currentContent;
214
+ if (finalContent === "" && existing.currentContent !== "")
215
+ return {
216
+ content: [{ type: "text", text: "Cannot clear page content: the backend's EditDocPage endpoint uses a struct-based update that silently skips empty-string fields, so an empty content value would not actually be written. The page was left unchanged." }],
217
+ isError: true,
218
+ };
219
+
220
+ const finalContentType =
221
+ content !== undefined ? (contentType ?? detectDocContentType(content)) : existing.contentType;
222
+
223
+ const titleChanged = title !== undefined && title !== existing.title;
224
+ const contentChanged = content !== undefined && content !== existing.currentContent;
225
+ const shouldSnapshot = snapshotVersion && (titleChanged || contentChanged);
226
+ const nextVersionNumber = existing.currentVersionNumber + (shouldSnapshot ? 1 : 0);
227
+
228
+ const res = await clients.docPages.editDocPage({
229
+ id: pageId,
230
+ title: finalTitle,
231
+ slug: finalSlug,
232
+ currentContent: finalContent,
233
+ contentType: finalContentType,
234
+ currentVersionNumber: nextVersionNumber,
235
+ isRoot: existing.isRoot,
236
+ isArchived: existing.isArchived,
237
+ displayOrder: existing.displayOrder,
238
+ // EditDocPageRequest carries project_id, but the DocPage message read
239
+ // back from GetDocPage (proto/api/v1/models.proto) has no project_id
240
+ // field -- only the unused legacy doc_space_id -- so there is no way
241
+ // to read the existing value back to resend it here. Left unset.
242
+ parentPageId: existing.parentPageId,
243
+ createdByUserId: existing.createdByUserId,
244
+ updatedByUserId: userId,
245
+ });
246
+
247
+ if (!res.docPage)
248
+ return { content: [{ type: "text", text: "Failed to update doc page. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
249
+
250
+ let warning = "";
251
+ if (shouldSnapshot) {
252
+ try {
253
+ await clients.docPageVersions.addDocPageVersion({
254
+ versionNumber: nextVersionNumber,
255
+ title: finalTitle,
256
+ content: finalContent,
257
+ contentType: finalContentType,
258
+ changeSummary: changeSummary ?? "Edited via opsee_update_doc_page",
259
+ docPageId: pageId,
260
+ createdByUserId: userId,
261
+ });
262
+ } catch (snapshotError) {
263
+ // The page edit already committed, so this is reported rather than
264
+ // raised: the content is saved either way. Name the version number
265
+ // so the gap between the page counter and version history can be
266
+ // reconciled by hand.
267
+ warning = `\n\nWarning: the page was saved, but recording version ${nextVersionNumber} in page history failed: ${formatError(snapshotError)}\nThe page now reports version ${nextVersionNumber} with no matching history entry. Re-running this tool creates version ${nextVersionNumber + 1} rather than backfilling ${nextVersionNumber}.`;
268
+ }
269
+ }
270
+
271
+ return {
272
+ content: [
273
+ {
274
+ type: "text",
275
+ text: `Doc page updated:\n${formatDocPage(res.docPage)}${warning}`,
276
+ },
277
+ ],
278
+ };
279
+ } catch (error) {
280
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
281
+ }
282
+ },
283
+ );
284
+
285
+ server.tool(
286
+ "opsee_delete_doc_page",
287
+ "Delete a documentation page. This is a soft delete with no cascade: child pages are neither deleted nor reparented, so they keep their parent_page_id pointing at the now-deleted page. They still exist and are returned by opsee_list_doc_pages / opsee_get_doc_page, but they disappear from the page tree since their parent no longer resolves there. There is no undo exposed through this API. Requires the Admin or Owner role -- the User role can add/edit/read doc pages but not delete them, and this call fails with a permission error for a User-role caller. To hide a page reversibly instead, use opsee_set_doc_page_archived.",
288
+ { pageId: z.number().int().min(1).describe("The doc page ID") },
289
+ { readOnlyHint: false, destructiveHint: true },
290
+ async ({ pageId }) => {
291
+ try {
292
+ const clients = getClients();
293
+ await clients.docPages.deleteDocPage({ id: pageId });
294
+ return { content: [{ type: "text", text: `Doc page ${pageId} deleted.` }] };
295
+ } catch (error) {
296
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
297
+ }
298
+ },
299
+ );
300
+
301
+ server.tool(
302
+ "opsee_set_doc_page_archived",
303
+ "Archive or unarchive a documentation page. This does not cascade to child pages. Archived pages are excluded from the doc page tree view by default. Reversible: call again with archived: false to restore it.",
304
+ {
305
+ pageId: z.number().int().min(1).describe("The doc page ID"),
306
+ archived: z.boolean().describe("true to archive, false to unarchive"),
307
+ },
308
+ { readOnlyHint: false, destructiveHint: false },
309
+ async ({ pageId, archived }) => {
310
+ try {
311
+ const clients = getClients();
312
+ const res = await clients.docPages.setDocPageArchived({ id: pageId, archived });
313
+ if (!res.docPage)
314
+ return { content: [{ type: "text", text: "Doc page not found." }], isError: true };
315
+ return {
316
+ content: [
317
+ {
318
+ type: "text",
319
+ text: `Doc page ${archived ? "archived" : "unarchived"}:\n${formatDocPage(res.docPage)}`,
320
+ },
321
+ ],
322
+ };
323
+ } catch (error) {
324
+ return { content: [{ type: "text", text: formatError(error) }], isError: true };
325
+ }
326
+ },
327
+ );
153
328
  }
@@ -0,0 +1,23 @@
1
+ import type { DocTemplate } from "../../../gen/api/v1/doc_template_pb.js";
2
+
3
+ export function formatDocTemplate(t: DocTemplate): string {
4
+ const lines = [`${t.name}`];
5
+ if (t.title) lines.push(` Title: ${t.title}`);
6
+ if (t.content) {
7
+ const content =
8
+ t.content.length > 500 ? t.content.substring(0, 500) + "..." : t.content;
9
+ lines.push(` Content: ${content}`);
10
+ }
11
+ lines.push(` Project ID: ${t.projectId}`);
12
+ lines.push(` ID: ${t.id}`);
13
+ return lines.join("\n");
14
+ }
15
+
16
+ export function formatDocTemplateList(templates: DocTemplate[]): string {
17
+ if (templates.length === 0) return "No doc templates found.";
18
+ const header = `Found ${templates.length} doc template(s):\n`;
19
+ return (
20
+ header +
21
+ templates.map((t, i) => `${i + 1}. ${formatDocTemplate(t)}`).join("\n\n")
22
+ );
23
+ }
@@ -16,6 +16,7 @@ export {
16
16
  } from "./milestone.js";
17
17
  export { formatUser, formatUserList } from "./user.js";
18
18
  export { formatLabel, formatLabelList, formatTaskLabelList } from "./label.js";
19
+ export { formatDocTemplate, formatDocTemplateList } from "./doc-templates.js";
19
20
  export { formatComment, formatCommentList } from "./comment.js";
20
21
  export { blockNoteToMarkdown, isBlockNoteJSON, detectDocContentType } from "./blocknote.js";
21
22
  export {
@@ -0,0 +1,10 @@
1
+ // Slugs are derived client-side rather than left to the backend: Add*Request
2
+ // messages for doc spaces and doc pages require a non-empty slug field with
3
+ // no server-side default, so a create call must already carry one derived
4
+ // from the title to succeed in a single round trip.
5
+ export function toKebabCase(str: string): string {
6
+ return str
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, "-")
9
+ .replace(/^-|-$/g, "");
10
+ }