@opsee/mcp-server 0.9.60 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/gen/api/v1/billing_pb.d.ts +0 -40
  2. package/gen/api/v1/billing_pb.js +22 -36
  3. package/gen/api/v1/developer_metrics_pb.d.ts +9 -3
  4. package/gen/api/v1/developer_metrics_pb.js +2 -1
  5. package/gen/api/v1/models_pb.d.ts +116 -5
  6. package/gen/api/v1/models_pb.js +33 -26
  7. package/gen/api/v1/project_pb.d.ts +18 -13
  8. package/gen/api/v1/project_pb.js +1 -1
  9. package/gen/api/v1/task_subscription_pb.d.ts +146 -0
  10. package/gen/api/v1/task_subscription_pb.js +66 -0
  11. package/gen/api/v1/vcs_connection_pb.d.ts +34 -3
  12. package/gen/api/v1/vcs_connection_pb.js +28 -20
  13. package/gen/api/v1/vcs_integration_pb.d.ts +11 -1
  14. package/gen/api/v1/vcs_integration_pb.js +2 -1
  15. package/gen/fk/foreign_key_pb.d.ts +22 -0
  16. package/gen/fk/foreign_key_pb.js +24 -0
  17. package/package.json +11 -11
  18. package/src/auth/kv-store.ts +12 -33
  19. package/src/auth/manager.ts +1 -6
  20. package/src/auth/oauth-provider.ts +32 -104
  21. package/src/auth/renew.ts +5 -15
  22. package/src/client/api.ts +3 -5
  23. package/src/server-http.ts +14 -30
  24. package/src/tools/acceptance-criteria.ts +4 -14
  25. package/src/tools/descriptions.ts +2 -7
  26. package/src/tools/doc-links.ts +4 -9
  27. package/src/tools/doc-spaces.ts +1 -1
  28. package/src/tools/docs.ts +8 -19
  29. package/src/tools/notifications.ts +2 -6
  30. package/src/tools/tasks-bulk.ts +3 -8
  31. package/src/tools/tasks-read-write.ts +1 -8
  32. package/src/utils/format/blocknote.ts +8 -23
  33. package/src/utils/tool-schema.ts +7 -29
  34. package/README.md +0 -304
  35. package/gen/api/v1/deployment_pb.d.ts +0 -277
  36. package/gen/api/v1/deployment_pb.js +0 -89
  37. package/gen/api/v1/environment_pb.d.ts +0 -277
  38. package/gen/api/v1/environment_pb.js +0 -89
  39. package/gen/api/v1/test_run_pb.d.ts +0 -277
  40. package/gen/api/v1/test_run_pb.js +0 -89
package/src/tools/docs.ts CHANGED
@@ -39,11 +39,8 @@ export function registerDocTools(
39
39
  async ({ projectId }) => {
40
40
  try {
41
41
  const clients = getClients();
42
- // project_id goes on the request itself, as with doc pages: the backend
43
- // scopes the list with GetDocSpacesRequest.project_id, and a generic
44
- // ProjectId filter cannot stand in for it -- scopes.ParseFilterOptions
45
- // drops that key as security-sensitive, which returned every doc space
46
- // in the company.
42
+ // project_id must ride on the request: ParseFilterOptions drops it from generic filters as
43
+ // security-sensitive, which returned every doc space in the company.
47
44
  const res = await clients.docSpaces.getDocSpaces({
48
45
  projectId,
49
46
  pagination: overridePagination(),
@@ -65,11 +62,8 @@ export function registerDocTools(
65
62
  async ({ projectId }) => {
66
63
  try {
67
64
  const clients = getClients();
68
- // Doc pages hang off the project, not off a doc space: model.DocPage has
69
- // no doc_space_id column (only the legacy proto field), and the backend
70
- // scopes the list with the request's own project_id, which the validator
71
- // requires to be >= 1. A generic ProjectId filter cannot substitute for
72
- // it -- scopes.ParseFilterOptions drops that key as security-sensitive.
65
+ // Doc pages hang off the project, and the request's own project_id (validated >= 1) is what
66
+ // scopes the list; a generic filter is dropped as security-sensitive.
73
67
  const res = await clients.docPages.getDocPages({
74
68
  projectId,
75
69
  pagination: overridePagination(),
@@ -121,7 +115,6 @@ export function registerDocTools(
121
115
  try {
122
116
  const clients = getClients();
123
117
 
124
- // Resolve current user for createdByUserId
125
118
  let userId = Number(authManager.getUserId()) || 0;
126
119
  if (!userId) {
127
120
  const meRes = await clients.users.getMe({});
@@ -235,10 +228,8 @@ export function registerDocTools(
235
228
  isRoot: existing.isRoot,
236
229
  isArchived: existing.isArchived,
237
230
  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.
231
+ // Left unset: EditDocPageRequest carries project_id but the DocPage message has no such
232
+ // field, so the existing value cannot be read back.
242
233
  parentPageId: existing.parentPageId,
243
234
  createdByUserId: existing.createdByUserId,
244
235
  updatedByUserId: userId,
@@ -260,10 +251,8 @@ export function registerDocTools(
260
251
  createdByUserId: userId,
261
252
  });
262
253
  } 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.
254
+ // The edit already committed, so report rather than raise, naming the version so the gap
255
+ // against version history can be reconciled by hand.
267
256
  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
257
  }
269
258
  }
@@ -9,12 +9,8 @@ import {
9
9
  FilterSchema,
10
10
  } from "../../gen/api/v1/filter_pb.js";
11
11
 
12
- // Notifications are user-scoped server-side (the GetNotifications RPC reads
13
- // the user_id off the auth context), so there's no per-call user parameter.
14
- // notification_type values are free-form strings produced by the backend
15
- // (e.g. "task_assigned", "comment_added", "task_blocked_unblocked",
16
- // "mention", "ai_workflow_status_change"). Treat them as opaque tags;
17
- // callers can filter via the unreadOnly param or post-process by type.
12
+ // Notifications are user-scoped server-side (GetNotifications reads user_id off the auth context), so
13
+ // there is no user parameter. notification_type values are backend-defined opaque tags.
18
14
 
19
15
  function formatNotification(n: {
20
16
  id: number;
@@ -4,14 +4,9 @@ import type { ApiClients } from "../client/api.js";
4
4
  import { formatError } from "../utils/format.js";
5
5
  import { BULK_PROJECT_ID } from "./descriptions.js";
6
6
 
7
- // Multi-task edits. These go through the backend BulkEditTasks RPC, which
8
- // applies every change in one transaction and silently skips task ids the
9
- // caller cannot see - hence the "n of m" wording in the response.
10
- //
11
- // OPS-203: the per-field convenience wrappers (bulk_assign, bulk_set_priority,
12
- // bulk_move_to_column, bulk_move_to_cycle) were removed. Each was a strict
13
- // subset of opsee_bulk_update_tasks with a single field set, and four extra
14
- // tool definitions cost more context on every request than they saved.
7
+ // Multi-task edits via BulkEditTasks, which applies everything in one transaction and silently skips
8
+ // invisible task ids, hence the "n of m" response wording. The per-field wrappers were removed:
9
+ // each was opsee_bulk_update_tasks with one field set, and cost context on every request.
15
10
  export function registerTaskBulkTools(
16
11
  server: McpServer,
17
12
  getClients: () => ApiClients,
@@ -204,18 +204,15 @@ export function registerTaskReadWriteTools(
204
204
  try {
205
205
  const clients = getClients();
206
206
 
207
- // Auto-resolve defaults
208
207
  let resolvedBoardId = 0;
209
208
  let resolvedColumnId = boardColumnId || 0;
210
209
  let resolvedTaskTypeId = taskTypeId || 0;
211
210
  let resolvedPriorityId = priorityId || 0;
212
211
 
213
- // Get default board
214
212
  const boardsRes = await clients.boards.getBoards({ projectId });
215
213
  if (boardsRes.boards?.length) {
216
214
  resolvedBoardId = boardsRes.boards[0].id;
217
215
 
218
- // Get first column if not specified
219
216
  if (!boardColumnId) {
220
217
  const colsRes = await clients.boardColumns.getBoardColumns({
221
218
  boardId: resolvedBoardId,
@@ -226,7 +223,6 @@ export function registerTaskReadWriteTools(
226
223
  }
227
224
  }
228
225
 
229
- // Get first task type if not specified
230
226
  if (!taskTypeId) {
231
227
  const typesRes = await clients.taskTypes.getTaskTypes({ projectId });
232
228
  if (typesRes.taskTypes?.length) {
@@ -234,7 +230,6 @@ export function registerTaskReadWriteTools(
234
230
  }
235
231
  }
236
232
 
237
- // Get first priority if not specified
238
233
  if (!priorityId) {
239
234
  const priRes = await clients.taskPriorities.getTaskPriorities({ projectId });
240
235
  if (priRes.taskPriorities?.length) {
@@ -242,7 +237,6 @@ export function registerTaskReadWriteTools(
242
237
  }
243
238
  }
244
239
 
245
- // Get reporter (current user)
246
240
  let reporterUserId = 0;
247
241
  const userId = authManager.getUserId();
248
242
  if (userId) {
@@ -312,13 +306,12 @@ export function registerTaskReadWriteTools(
312
306
  try {
313
307
  const clients = getClients();
314
308
 
315
- // Read current task
316
309
  const getRes = await clients.tasks.getTask({ id: taskId });
317
310
  const task = getRes.task;
318
311
  if (!task)
319
312
  return { content: [{ type: "text", text: "Task not found. Use opsee_list_tasks to see available tasks." }] };
320
313
 
321
- // Merge changes into full EditTaskRequest
314
+ // EditTaskRequest is a full replacement, so merge onto the current task.
322
315
  const res = await clients.tasks.editTask({
323
316
  id: task.id,
324
317
  identifier: task.identifier,
@@ -1,11 +1,5 @@
1
- // ---------------------------------------------------------------------------
2
- // BlockNote JSON → Markdown
3
- // ---------------------------------------------------------------------------
4
-
5
- // `strike` is the key the BlockNote default schema uses, and it is what both Go
6
- // writers persist (backend/service/task.go parseInlineMarkdown and
7
- // orchestrator/internal/util/blocknote.go). `strikethrough` is accepted too so a
8
- // payload using the long spelling is not silently dropped.
1
+ // `strike` is the BlockNote default schema's key and what both Go writers persist;
2
+ // `strikethrough` is accepted too so the long spelling is not silently dropped.
9
3
  type BNInlineStyle = {
10
4
  bold?: boolean;
11
5
  italic?: boolean;
@@ -31,14 +25,12 @@ function renderInline(content: BNInlineContent[]): string {
31
25
  const inner = renderInline(node.content ?? []);
32
26
  return `[${inner}](${node.href})`;
33
27
  }
34
- // text node
35
28
  const t = node as BNTextContent;
36
29
  let s = t.text ?? "";
37
30
  if (t.styles?.code) s = `\`${s}\``;
38
31
  if (t.styles?.bold) s = `**${s}**`;
39
32
  if (t.styles?.italic) s = `*${s}*`;
40
- // Struck-through text usually means "decided against this". Dropping the
41
- // markers would present an abandoned plan item as a live one.
33
+ // Keep the markers: struck text means "decided against", not a live plan item.
42
34
  if (t.styles?.strike || t.styles?.strikethrough) s = `~~${s}~~`;
43
35
  if (t.styles?.underline) s = `<u>${s}</u>`;
44
36
  return s;
@@ -70,8 +62,7 @@ function renderBlock(block: BNBlock, indent: string): string[] {
70
62
  case "codeBlock": {
71
63
  const lang = (block.props?.language as string) || "";
72
64
  lines.push(`${indent}\`\`\`${lang}`);
73
- // A code block's text is one node containing newlines. Indent every line,
74
- // not just the first, or the rest escapes the list item it is nested in.
65
+ // A code block is one node with newlines; indent every line or it escapes the enclosing list item.
75
66
  for (const body of inline.split("\n")) lines.push(`${indent}${body}`);
76
67
  lines.push(`${indent}\`\`\``);
77
68
  break;
@@ -94,12 +85,9 @@ function renderBlock(block: BNBlock, indent: string): string[] {
94
85
  return lines;
95
86
  }
96
87
 
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.
88
+ // Mirrors the product-side detectors (useDocTreeMutations.js, mentions.go IsBlockNoteJSON) so MCP and
89
+ // the web UI label identical bytes alike. BlockNote persists only arrays, so an object or scalar is
90
+ // content, not a document body.
103
91
  export function isBlockNoteJSON(content: string): boolean {
104
92
  try {
105
93
  return Array.isArray(JSON.parse(content));
@@ -108,9 +96,7 @@ export function isBlockNoteJSON(content: string): boolean {
108
96
  }
109
97
  }
110
98
 
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.
99
+ // DocPage.jsx only JSON-parses when contentType is "json", so mislabelling markdown renders an empty page.
114
100
  export function detectDocContentType(content: string): "text" | "json" {
115
101
  return isBlockNoteJSON(content) ? "json" : "text";
116
102
  }
@@ -126,7 +112,6 @@ export function blockNoteToMarkdown(content: string): string {
126
112
  if (typeof block !== "object" || block === null || !block.type) return content;
127
113
  const blockLines = renderBlock(block, "");
128
114
  mdLines.push(...blockLines);
129
- // Separate top-level blocks with a blank line (except last)
130
115
  if (i < parsed.length - 1) mdLines.push("");
131
116
  }
132
117
  return mdLines.join("\n");
@@ -1,24 +1,10 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
3
 
4
- // Every tool definition is re-sent on every AI request, so the tools/list
5
- // payload is a permanent tax on the context window. Three constructs the SDK
6
- // emits carry no information for a caller:
7
- //
8
- // "$schema": "http://json-schema.org/draft-07/schema#" (once per tool)
9
- // "maximum": 9007199254740991 (per unbounded int)
10
- // "minimum": -9007199254740991 (per unbounded int)
11
- // "execution": {"taskSupport": "forbidden"} (once per tool)
12
- //
13
- // The bounds are Number.MAX/MIN_SAFE_INTEGER, which `z.number().int()` records
14
- // on any int field that doesn't declare its own; no caller is going to pass an
15
- // id near 2^53, and the server-side Zod schema still rejects one if they do.
16
- // Stripping them leaves the advertised schema a valid, strictly weaker JSON
17
- // Schema — an explicit bound like `.min(1)` is left alone.
18
- //
19
- // `execution.taskSupport` is optional in the protocol and clients only act on
20
- // "required"/"optional" (see the SDK client's callTool path) — an absent field
21
- // and an explicit "forbidden" are the same thing to them.
4
+ // tools/list is re-sent on every AI request, so these no-information SDK constructs are stripped:
5
+ // "$schema", the MAX/MIN_SAFE_INTEGER bounds z.number().int() adds to undeclared int fields, and
6
+ // "execution": {"taskSupport": "forbidden"}. Explicit bounds like .min(1) are left alone, the server-side
7
+ // Zod schema still enforces the removed ones, and clients treat absent taskSupport as "forbidden".
22
8
  function stripSafeIntBounds(node: unknown): void {
23
9
  if (Array.isArray(node)) {
24
10
  for (const child of node) stripSafeIntBounds(child);
@@ -54,17 +40,9 @@ function compactTool(tool: ToolDefinition): void {
54
40
  }
55
41
  }
56
42
 
57
- /**
58
- * Wrap the SDK's tools/list handler so every advertised tool definition is
59
- * compacted on the way out. The SDK builds the JSON Schema internally (tools
60
- * are registered as Zod shapes and converted inside its list handler), so
61
- * post-processing the response is the only place this can happen.
62
- *
63
- * Degrades to a no-op if the SDK ever stops storing handlers where we look for
64
- * them — the definitions go back to being verbose, nothing breaks. The test in
65
- * tool-schema.test.ts asserts the compaction is live so that degradation is
66
- * caught in CI rather than in production token bills.
67
- */
43
+ /** Wraps the SDK's tools/list handler to compact each definition on the way out; the SDK converts Zod
44
+ * to JSON Schema internally, so the response is the only interception point. Degrades to a no-op if the
45
+ * SDK moves its handlers, which tool-schema.test.ts asserts against so CI catches it, not token bills. */
68
46
  export function compactToolDefinitions(server: McpServer): void {
69
47
  const handlers = (
70
48
  server.server as unknown as {
package/README.md DELETED
@@ -1,304 +0,0 @@
1
- # Opsee MCP Server
2
-
3
- Manage your Opsee projects, tasks, docs, and cycles directly from AI-powered coding environments like Claude Code and Cursor.
4
-
5
- ## Quick Start
6
-
7
- ### 1. Authenticate
8
-
9
- ```bash
10
- npx @opsee/mcp-server login
11
- ```
12
-
13
- This opens your browser to sign in to Opsee. Your credentials are saved to `~/.opsee/credentials.json`.
14
-
15
- ### 2. Add to Claude Code
16
-
17
- ```bash
18
- claude mcp add opsee -- npx @opsee/mcp-server serve
19
- ```
20
-
21
- ### 3. Restart Claude Code
22
-
23
- Exit and reopen Claude Code. Run `/mcp` to verify `opsee` shows as connected.
24
-
25
- ### 4. Start using it
26
-
27
- Ask Claude things like:
28
- - "List my Opsee projects"
29
- - "Show tasks in project Codlas"
30
- - "Create a task called 'Fix login bug' in project 1"
31
- - "What cycles are active in project 1?"
32
- - "Show me the docs in project 1"
33
-
34
- ## Available Tools (47)
35
-
36
- | Tool | Description |
37
- |------|-------------|
38
- | `opsee_get_me` | Get your profile info |
39
- | `opsee_list_users` | List company users (resolve assignee IDs) |
40
- | `opsee_get_user` | Get user details |
41
- | `opsee_list_projects` | List all projects |
42
- | `opsee_get_project` | Get project details |
43
- | `opsee_list_tasks` | List tasks with filters |
44
- | `opsee_get_task` | Get task details; `includeContext: true` adds labels, comments, dependencies, parent, subtasks in the same call |
45
- | `opsee_create_task` | Create a new task (accepts inline labelIds[] to skip follow-up attach calls) |
46
- | `opsee_update_task` | Update task fields (accepts labelIds[] with replace-set semantics) |
47
- | `opsee_bulk_update_tasks` | Update many tasks in one transaction — assignee, priority, column, cycle, storyPoints, estimatedHours, parentTaskId, labelIds |
48
- | `opsee_replace_task_labels` | Replace a single task's full set of labels |
49
- | `opsee_delete_task` | Delete a task by ID |
50
- | `opsee_bulk_delete_tasks` | Delete many tasks in one call |
51
- | `opsee_list_comments` | List comments on a task |
52
- | `opsee_add_comment` | Add a comment to a task |
53
- | `opsee_edit_comment` | Edit an existing comment |
54
- | `opsee_delete_comment` | Delete a comment |
55
- | `opsee_list_labels` | List labels in a project |
56
- | `opsee_get_label` | Get label details |
57
- | `opsee_create_label` | Create a label |
58
- | `opsee_update_label` | Update a label |
59
- | `opsee_delete_label` | Delete a label |
60
- | `opsee_attach_label_to_task` | Attach a label to a task |
61
- | `opsee_detach_label_from_task` | Detach a label from a task (pass TaskLabel join-row id) |
62
- | `opsee_list_task_labels` | List labels attached to a task |
63
- | `opsee_create_task_dependency` | Add a typed task-to-task relation (BLOCKS / BLOCKED_BY / DUPLICATES / RELATES_TO) |
64
- | `opsee_list_task_dependencies` | List a task's dependencies (both directions) |
65
- | `opsee_delete_task_dependency` | Delete a task dependency by ID |
66
- | `opsee_list_task_types` | Get task types (Bug, Feature, etc.) |
67
- | `opsee_list_task_priorities` | Get priority levels |
68
- | `opsee_list_boards` | List Kanban boards |
69
- | `opsee_list_board_columns` | Get board columns/statuses |
70
- | `opsee_list_cycles` | List cycles/sprints |
71
- | `opsee_get_cycle` | Get cycle details |
72
- | `opsee_create_cycle` | Create a new cycle |
73
- | `opsee_update_cycle` | Update a cycle's fields |
74
- | `opsee_delete_cycle` | Delete a cycle |
75
- | `opsee_list_doc_spaces` | List doc spaces in a project |
76
- | `opsee_get_doc_space` | Get doc space details |
77
- | `opsee_create_doc_space` | Create a doc space |
78
- | `opsee_update_doc_space` | Update a doc space |
79
- | `opsee_delete_doc_space` | Delete a doc space |
80
- | `opsee_list_doc_pages` | List pages in a doc space |
81
- | `opsee_get_doc_page` | Read a doc page |
82
- | `opsee_create_doc_page` | Create a new doc page |
83
- | `opsee_update_doc_page` | Update a doc page (read-merge-write, with version snapshotting) |
84
- | `opsee_delete_doc_page` | Delete a doc page |
85
- | `opsee_set_doc_page_archived` | Archive or unarchive a doc page |
86
- | `opsee_list_doc_templates` | List doc templates in a project |
87
- | `opsee_create_doc_template` | Create a doc template |
88
- | `opsee_update_doc_template` | Update a doc template |
89
- | `opsee_delete_doc_template` | Delete a doc template |
90
- | `opsee_list_task_doc_pages` | List doc pages attached to a task |
91
- | `opsee_list_milestone_doc_pages` | List doc pages attached to a milestone |
92
- | `opsee_attach_doc_page` | Attach a doc page to a task or a milestone |
93
- | `opsee_detach_doc_page` | Detach a doc page from a task or a milestone |
94
- | `opsee_list_repositories` | List connected repositories |
95
-
96
- ## Task description format
97
-
98
- The `description` field on tasks accepts and returns user-supplied text, but the Opsee backend may normalize the stored representation. The exact round-trip behavior depends on the backend version and is not yet fully documented here.
99
-
100
- ### Acceptance criteria → description checklist
101
-
102
- Write acceptance criteria **inline in the task description** using a `## Acceptance criteria` heading followed by markdown checkbox lines. The frontend (BlockNote) renders those as clickable checkboxes; users can toggle them in the UI.
103
-
104
- ```
105
- ## Acceptance criteria
106
-
107
- - [ ] WaiveLateFee RPC compiles and builds
108
- - [ ] Admin can waive full or partial late fee with reason
109
- - [ ] Apply-as-credit reduces next payment amount
110
- - [x] Audit log entry with before/after snapshots
111
- - [ ] Modal UI matches mockup
112
- - [ ] Non-admin users cannot access waive action
113
- ```
114
-
115
- This is the path `/tpm` and any other AI agent should use when creating or updating tasks via `opsee_create_task` / `opsee_update_task`. The heading casing doesn't matter to the parser (`## Acceptance Criteria` works too).
116
-
117
- **Do not use the structured `opsee_add_acceptance_criterion` tool for typical AC.** That tool writes to a separate `acceptance_criteria` DB table that does NOT surface in the task detail UI; it's reserved for automation paths that need machine-trackable criteria outside the description (PR-to-criterion mapping, AI workflow outputs, reporting).
118
-
119
- ### What's confirmed
120
-
121
- - The MCP wrapper passes `description` to the backend unmodified. Whatever transformation happens is server-side.
122
- - Read tools (`opsee_get_task`, `opsee_list_tasks`) return whatever the backend has stored, byte-for-byte.
123
-
124
- ### What needs empirical verification
125
-
126
- The following test plan, run against a live Opsee instance, captures round-trip behavior for the three common input forms. **Results have not yet been recorded here — run the test and fill in the observed strings below.**
127
-
128
- ```text
129
- # Plain text with separator and trailer
130
- opsee_create_task(projectId=<P>, title="rt plain",
131
- description="line one\nline two\n---\nLabels: x")
132
- opsee_get_task(taskId=<id>) → record exact returned description
133
-
134
- # Markdown with headers, list, fenced code
135
- opsee_create_task(projectId=<P>, title="rt md",
136
- description="# H1\n- bullet\n\n```ts\ncode\n```")
137
- opsee_get_task(taskId=<id>) → record exact returned description
138
-
139
- # BlockNote JSON
140
- opsee_create_task(projectId=<P>, title="rt blocknote",
141
- description='[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]')
142
- opsee_get_task(taskId=<id>) → record exact returned description
143
- ```
144
-
145
- ### Observed round-trip outcomes
146
-
147
- | Input form | Stored form (from `opsee_get_task`) | Notes |
148
- |---|---|---|
149
- | Plain text | _Pending empirical verification_ | |
150
- | Markdown | _Pending empirical verification_ | |
151
- | BlockNote JSON | _Pending empirical verification_ | |
152
-
153
- > If the round trip is lossy (e.g. plain text becomes BlockNote JSON on read), callers relying on byte-identical round-trip should treat `description` as backend-defined and avoid parsing trailers; use first-class fields (`opsee_attach_label_to_task`, `opsee_add_comment`, etc.) for structured metadata instead.
154
-
155
- ## Remote MCP Server (Recommended)
156
-
157
- The remote server runs in the cloud with OAuth 2.0 authentication - no local setup or tokens needed.
158
-
159
- ### Environments
160
-
161
- | Environment | URL | Opsee Instance |
162
- |-------------|-----|----------------|
163
- | **Production** | `https://mcp.api.opsee.ai/mcp` | `https://opsee.ai` |
164
- | **Development** | `https://opsee-development.mcp.cls.codilas.link/mcp` | `https://opsee-development.cdn.codilas.link` |
165
-
166
- ### Claude Code
167
-
168
- ```bash
169
- # Production
170
- claude mcp add opsee --transport http https://mcp.api.opsee.ai/mcp
171
-
172
- # Development
173
- claude mcp add opsee-dev --transport http https://opsee-development.mcp.cls.codilas.link/mcp
174
- ```
175
-
176
- Restart Claude Code. On first use, your browser opens for Opsee login. After authenticating, all tools are available immediately.
177
-
178
- You can have both environments connected at the same time - use tools prefixed with the server name (e.g. `opsee` for production, `opsee-dev` for development).
179
-
180
- ### Cursor / Other MCP Clients
181
-
182
- ```json
183
- {
184
- "mcpServers": {
185
- "opsee": {
186
- "type": "http",
187
- "url": "https://mcp.api.opsee.ai/mcp"
188
- }
189
- }
190
- }
191
- ```
192
-
193
- Replace the URL with the development endpoint if targeting that environment.
194
-
195
- Your MCP client handles the OAuth flow automatically - just sign in when prompted.
196
-
197
- ### How it works
198
-
199
- 1. Client connects to the MCP endpoint (e.g. `https://mcp.api.opsee.ai/mcp`)
200
- 2. Server returns 401 with OAuth discovery metadata
201
- 3. Client registers itself and opens the Opsee login page in your browser
202
- 4. After login, the client exchanges the auth code for an access token
203
- 5. All subsequent tool calls use the token automatically
204
-
205
- ## Local MCP Server (Alternative)
206
-
207
- Run the MCP server locally via npx. Requires a one-time login to get a token.
208
-
209
- ### 1. Authenticate
210
-
211
- ```bash
212
- npx @opsee/mcp-server login
213
- ```
214
-
215
- ### 2. Add to Claude Code
216
-
217
- ```bash
218
- claude mcp add opsee -- npx @opsee/mcp-server serve
219
- ```
220
-
221
- ### Cursor / Other MCP Clients (Local)
222
-
223
- ```json
224
- {
225
- "mcpServers": {
226
- "opsee": {
227
- "command": "npx",
228
- "args": ["@opsee/mcp-server", "serve"]
229
- }
230
- }
231
- }
232
- ```
233
-
234
- ### CI / Headless Environments
235
-
236
- Skip the browser login by setting a token directly:
237
-
238
- ```json
239
- {
240
- "mcpServers": {
241
- "opsee": {
242
- "command": "npx",
243
- "args": ["@opsee/mcp-server", "serve"],
244
- "env": {
245
- "OPSEE_API_TOKEN": "your-jwt-token"
246
- }
247
- }
248
- }
249
- }
250
- ```
251
-
252
- ## Self-Hosting the Remote Server
253
-
254
- Deploy the MCP server in your own infrastructure using Docker.
255
-
256
- ### Docker
257
-
258
- ```bash
259
- docker build -f mcp/Dockerfile . -t opsee-mcp-server
260
-
261
- docker run -p 3100:3100 \
262
- -e MCP_SERVER_URL=https://mcp.yourdomain.com \
263
- -e OPSEE_API_URL=http://your-backend:8080 \
264
- -e OPSEE_APP_URL=https://your-frontend.com \
265
- opsee-mcp-server
266
- ```
267
-
268
- ### Kubernetes
269
-
270
- The server includes Kustomize configs following the same pattern as the backend:
271
-
272
- ```bash
273
- # Development
274
- kubectl apply -k mcp/k8/environments/development/ -n opsee-development
275
-
276
- # Production
277
- kubectl apply -k mcp/k8/environments/production/ -n opsee-production
278
- ```
279
-
280
- ### Environment Variables
281
-
282
- | Variable | Description | Default |
283
- |----------|-------------|---------|
284
- | `MCP_SERVER_URL` | Public URL of this MCP server (for OAuth redirects) | `http://localhost:3100` |
285
- | `MCP_HOST` | Bind host | `0.0.0.0` |
286
- | `MCP_PORT` | Bind port | `3100` |
287
- | `OPSEE_API_URL` | Backend Connect RPC URL | `https://grpc.api.opsee.ai` |
288
- | `OPSEE_APP_URL` | Frontend URL (OAuth login page) | `https://opsee.ai` |
289
- | `OPSEE_API_TOKEN` | Direct JWT token for local mode | - |
290
- | `OPSEE_CREDENTIALS_PATH` | Override credential file path | `~/.opsee/credentials.json` |
291
-
292
- ## Local Development
293
-
294
- ```bash
295
- # Start backend + frontend locally, then:
296
-
297
- # Local mode (stdio):
298
- OPSEE_APP_URL=http://localhost:5173 OPSEE_API_URL=http://localhost:9990 npx @opsee/mcp-server login
299
- claude mcp add opsee -e OPSEE_API_URL=http://localhost:9990 -- npx @opsee/mcp-server serve
300
-
301
- # Remote mode (HTTP):
302
- MCP_SERVER_URL=http://localhost:3100 OPSEE_API_URL=http://localhost:9990 OPSEE_APP_URL=http://localhost:5173 npx @opsee/mcp-server serve-remote
303
- claude mcp add opsee-local --transport http http://localhost:3100/mcp
304
- ```