@opsee/mcp-server 0.9.14 → 0.9.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsee/mcp-server",
3
- "version": "0.9.14",
3
+ "version": "0.9.17",
4
4
  "description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,53 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { InMemoryKVStore } from "../auth/kv-store.js";
3
+
4
+ describe("InMemoryKVStore", () => {
5
+ test("stores and retrieves values", async () => {
6
+ const kv = new InMemoryKVStore();
7
+ await kv.set("a", "1");
8
+ expect(await kv.get("a")).toBe("1");
9
+ expect(await kv.get("missing")).toBeNull();
10
+ });
11
+
12
+ test("expires entries after their TTL", async () => {
13
+ const kv = new InMemoryKVStore();
14
+ await kv.set("a", "1", -1); // already expired
15
+ expect(await kv.get("a")).toBeNull();
16
+ });
17
+
18
+ test("bounds the store to maxEntries, evicting oldest first", async () => {
19
+ const kv = new InMemoryKVStore(3);
20
+ await kv.set("a", "1");
21
+ await kv.set("b", "2");
22
+ await kv.set("c", "3");
23
+ await kv.set("d", "4"); // exceeds cap -> "a" (oldest) evicted
24
+
25
+ expect(await kv.get("a")).toBeNull();
26
+ expect(await kv.get("b")).toBe("2");
27
+ expect(await kv.get("c")).toBe("3");
28
+ expect(await kv.get("d")).toBe("4");
29
+ });
30
+
31
+ test("re-setting a key refreshes its position so it is not evicted first", async () => {
32
+ const kv = new InMemoryKVStore(3);
33
+ await kv.set("a", "1");
34
+ await kv.set("b", "2");
35
+ await kv.set("a", "1-updated"); // refresh a -> b is now oldest
36
+ await kv.set("c", "3");
37
+ await kv.set("d", "4"); // evicts oldest live entry (b)
38
+
39
+ expect(await kv.get("b")).toBeNull();
40
+ expect(await kv.get("a")).toBe("1-updated");
41
+ });
42
+
43
+ test("evicts expired entries before live ones when over cap", async () => {
44
+ const kv = new InMemoryKVStore(2);
45
+ await kv.set("stale", "x", -1); // expired
46
+ await kv.set("a", "1");
47
+ await kv.set("b", "2"); // over cap -> expired "stale" dropped, a/b kept
48
+
49
+ expect(await kv.get("stale")).toBeNull();
50
+ expect(await kv.get("a")).toBe("1");
51
+ expect(await kv.get("b")).toBe("2");
52
+ });
53
+ });
@@ -3,6 +3,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
4
  import { createServer } from "../server.js";
5
5
  import type { ApiClients } from "../client/api.js";
6
+ import { Code, ConnectError } from "@connectrpc/connect";
6
7
 
7
8
  // --- Mock data ---
8
9
 
@@ -288,6 +289,19 @@ describe("MCP Tools", () => {
288
289
  expect(text).toContain("Bug");
289
290
  });
290
291
 
292
+ test("opsee_get_task rejects a non-positive / non-integer taskId", async () => {
293
+ // The ID-validation sweep: an id must be a positive integer. Zero,
294
+ // negatives, and fractionals fail schema validation (isError) before the
295
+ // handler runs. A valid positive integer (taskId: 10) succeeds above.
296
+ for (const taskId of [0, 1.5, -3]) {
297
+ const result = await client.callTool({
298
+ name: "opsee_get_task",
299
+ arguments: { taskId },
300
+ });
301
+ expect(result.isError, `taskId=${taskId} should be rejected`).toBe(true);
302
+ }
303
+ });
304
+
291
305
  test("opsee_create_task creates a task with defaults", async () => {
292
306
  const result = await client.callTool({
293
307
  name: "opsee_create_task",
@@ -869,8 +883,10 @@ describe("MCP Tools", () => {
869
883
 
870
884
  test("auth error returns login instruction", async () => {
871
885
  const authClients = createMockClients();
886
+ // The connect client surfaces backend auth failures as a ConnectError with
887
+ // Code.Unauthenticated (not a plain Error), which is what formatError keys on.
872
888
  (authClients.users as any).getMe = async () => {
873
- throw new Error("Unauthenticated");
889
+ throw new ConnectError("unauthenticated", Code.Unauthenticated);
874
890
  };
875
891
 
876
892
  const [ct, st] = InMemoryTransport.createLinkedPair();
@@ -0,0 +1,67 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Credentials } from "../credentials.js";
3
+
4
+ // Control what the manager reads from disk.
5
+ const { mockRead } = vi.hoisted(() => ({ mockRead: vi.fn() }));
6
+ vi.mock("../credentials.js", () => ({
7
+ readCredentials: mockRead,
8
+ clearCredentials: vi.fn(),
9
+ }));
10
+
11
+ import { AuthManager } from "../manager.js";
12
+
13
+ const CANONICAL = "https://grpc.api.opsee.ai";
14
+ const LEGACY_STALE = "https://api.opsee.dev";
15
+
16
+ function creds(serverUrl: string): Credentials {
17
+ return {
18
+ token: "jwt.token.here",
19
+ serverUrl,
20
+ userId: "u",
21
+ companyId: "c",
22
+ expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
23
+ };
24
+ }
25
+
26
+ // Build a manager whose credentials are populated from `stored` (null = none).
27
+ function managerWith(stored: Credentials | null): AuthManager {
28
+ mockRead.mockReturnValue(stored);
29
+ const m = new AuthManager();
30
+ m.getToken(); // populates the private credentials field, as in real startup
31
+ return m;
32
+ }
33
+
34
+ describe("AuthManager.getServerUrl", () => {
35
+ const OLD_ENV = process.env.OPSEE_API_URL;
36
+ beforeEach(() => {
37
+ mockRead.mockReset();
38
+ delete process.env.OPSEE_API_URL;
39
+ });
40
+ afterEach(() => {
41
+ if (OLD_ENV === undefined) delete process.env.OPSEE_API_URL;
42
+ else process.env.OPSEE_API_URL = OLD_ENV;
43
+ });
44
+
45
+ test("returns the canonical default when nothing is stored", () => {
46
+ expect(managerWith(null).getServerUrl()).toBe(CANONICAL);
47
+ });
48
+
49
+ test("returns a stored, non-stale serverUrl unchanged", () => {
50
+ expect(managerWith(creds("https://custom.example.com")).getServerUrl()).toBe(
51
+ "https://custom.example.com",
52
+ );
53
+ });
54
+
55
+ test("treats the legacy stale host as unset and falls back to the canonical default", () => {
56
+ // The whole point of the migration: existing users with the dead host in
57
+ // ~/.opsee/credentials.json must NOT be pinned to it, without re-login.
58
+ expect(managerWith(creds(LEGACY_STALE)).getServerUrl()).toBe(CANONICAL);
59
+ });
60
+
61
+ test("OPSEE_API_URL env var overrides both stored and default", () => {
62
+ process.env.OPSEE_API_URL = "https://env.example.com";
63
+ expect(managerWith(creds("https://custom.example.com")).getServerUrl()).toBe(
64
+ "https://env.example.com",
65
+ );
66
+ });
67
+ });
@@ -224,4 +224,49 @@ describe("OpseeOAuthProvider flow", () => {
224
224
  await provider.revokeToken(client, { token });
225
225
  await expect(provider.verifyAccessToken(token)).rejects.toThrow(/revoked/);
226
226
  });
227
+
228
+ // --- verifyAccessToken validation edge cases (require exp) ---
229
+
230
+ // Build a JWT-shaped token (header.payload.sig) from a payload object.
231
+ const tokenWith = (payload: Record<string, unknown>) =>
232
+ `header.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.sig`;
233
+
234
+ test("accepts a token with a future exp and exposes its claims", async () => {
235
+ const future = Math.floor(Date.now() / 1000) + 3600;
236
+ const token = tokenWith({ exp: future, userId: "u1", companyId: "c1", role: "admin" });
237
+ await expect(provider.verifyAccessToken(token)).resolves.toMatchObject({
238
+ clientId: "opsee",
239
+ expiresAt: future,
240
+ extra: { userId: "u1", companyId: "c1", role: "admin" },
241
+ });
242
+ });
243
+
244
+ test("rejects a token with no exp claim (not valid indefinitely)", async () => {
245
+ const token = tokenWith({ userId: "u", companyId: "c" });
246
+ await expect(provider.verifyAccessToken(token)).rejects.toThrow(/missing expiry/i);
247
+ });
248
+
249
+ test("rejects a token whose exp is not a number", async () => {
250
+ const token = tokenWith({ exp: "later", userId: "u" });
251
+ await expect(provider.verifyAccessToken(token)).rejects.toThrow(/missing expiry/i);
252
+ });
253
+
254
+ test("rejects an expired token", async () => {
255
+ const past = Math.floor(Date.now() / 1000) - 10;
256
+ const token = tokenWith({ exp: past, userId: "u" });
257
+ await expect(provider.verifyAccessToken(token)).rejects.toThrow(/expired/i);
258
+ });
259
+
260
+ test("rejects a token with a non-JSON payload as Invalid token", async () => {
261
+ const badBody = Buffer.from("this is not json").toString("base64url");
262
+ await expect(provider.verifyAccessToken(`header.${badBody}.sig`)).rejects.toThrow(
263
+ /Invalid token/,
264
+ );
265
+ });
266
+
267
+ test("rejects a token that is not three parts", async () => {
268
+ await expect(provider.verifyAccessToken("only.two")).rejects.toThrow(
269
+ /Invalid token format/,
270
+ );
271
+ });
227
272
  });
@@ -24,6 +24,11 @@ export interface KVStore {
24
24
  */
25
25
  export class InMemoryKVStore implements KVStore {
26
26
  private entries = new Map<string, { value: string; expiresAt: number | null }>();
27
+ private readonly maxEntries: number;
28
+
29
+ constructor(maxEntries = 10_000) {
30
+ this.maxEntries = maxEntries;
31
+ }
27
32
 
28
33
  async get(key: string): Promise<string | null> {
29
34
  const entry = this.entries.get(key);
@@ -38,12 +43,40 @@ export class InMemoryKVStore implements KVStore {
38
43
  async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
39
44
  const expiresAt =
40
45
  ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null;
46
+ // Re-insert so the most recently set key sorts last in iteration order and
47
+ // is evicted last.
48
+ this.entries.delete(key);
41
49
  this.entries.set(key, { value, expiresAt });
50
+ this.evictIfNeeded();
42
51
  }
43
52
 
44
53
  async del(key: string): Promise<void> {
45
54
  this.entries.delete(key);
46
55
  }
56
+
57
+ /**
58
+ * Bound memory. Dynamic client registrations are stored without a TTL (by
59
+ * design — clients cache their DCR indefinitely), so without a cap this
60
+ * fallback store grows forever in a long-running remote process that has no
61
+ * Redis. Evict expired entries first, then the oldest live entries.
62
+ */
63
+ private evictIfNeeded(): void {
64
+ if (this.entries.size <= this.maxEntries) return;
65
+
66
+ const now = Date.now();
67
+ for (const [k, e] of this.entries) {
68
+ if (e.expiresAt !== null && now > e.expiresAt) {
69
+ this.entries.delete(k);
70
+ if (this.entries.size <= this.maxEntries) return;
71
+ }
72
+ }
73
+
74
+ while (this.entries.size > this.maxEntries) {
75
+ const oldest = this.entries.keys().next().value as string | undefined;
76
+ if (oldest === undefined) break;
77
+ this.entries.delete(oldest);
78
+ }
79
+ }
47
80
  }
48
81
 
49
82
  /** Redis-backed store. Expiry is delegated to Redis via the EX option. */
package/src/auth/login.ts CHANGED
@@ -43,7 +43,7 @@ export async function startLoginFlow(): Promise<void> {
43
43
  if (token) {
44
44
  const credentials: Credentials = {
45
45
  token,
46
- serverUrl: process.env.OPSEE_API_URL || "https://api.opsee.dev",
46
+ serverUrl: process.env.OPSEE_API_URL || "https://grpc.api.opsee.ai",
47
47
  userId,
48
48
  companyId,
49
49
  expiresAt,
@@ -1,5 +1,11 @@
1
1
  import { readCredentials, clearCredentials, type Credentials } from "./credentials.js";
2
2
 
3
+ const DEFAULT_SERVER_URL = "https://grpc.api.opsee.ai";
4
+ // A stale default that older login builds wrote to ~/.opsee/credentials.json.
5
+ // It points at a dead host, so treat a stored value of it as "unset" and fall
6
+ // back to the canonical default instead of forcing users to re-login.
7
+ const LEGACY_STALE_SERVER_URL = "https://api.opsee.dev";
8
+
3
9
  export class AuthManager {
4
10
  private credentials: Credentials | null = null;
5
11
 
@@ -15,10 +21,11 @@ export class AuthManager {
15
21
  }
16
22
 
17
23
  getServerUrl(): string {
24
+ const stored = this.credentials?.serverUrl;
18
25
  return (
19
26
  process.env.OPSEE_API_URL ||
20
- this.credentials?.serverUrl ||
21
- "https://grpc.api.opsee.ai"
27
+ (stored && stored !== LEGACY_STALE_SERVER_URL ? stored : undefined) ||
28
+ DEFAULT_SERVER_URL
22
29
  );
23
30
  }
24
31
 
@@ -193,31 +193,37 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
193
193
  const parts = token.split(".");
194
194
  if (parts.length !== 3) throw new Error("Invalid token format");
195
195
 
196
+ // Scope the try to ONLY the decode/parse: a malformed payload becomes
197
+ // "Invalid token". The validation checks below run outside the catch so
198
+ // their errors propagate verbatim — the caller no longer depends on
199
+ // string-matching error messages that a refactor or locale could change.
200
+ let payload: { exp?: unknown; userId?: unknown; companyId?: unknown; role?: unknown };
196
201
  try {
197
- const payload = JSON.parse(
198
- Buffer.from(parts[1], "base64url").toString("utf-8"),
199
- );
200
-
201
- // Check expiry
202
- if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
203
- throw new Error("Token expired");
204
- }
205
-
206
- return {
207
- token,
208
- clientId: "opsee",
209
- scopes: [],
210
- expiresAt: payload.exp,
211
- extra: {
212
- userId: payload.userId,
213
- companyId: payload.companyId,
214
- role: payload.role,
215
- },
216
- };
217
- } catch (err) {
218
- if (err instanceof Error && err.message === "Token expired") throw err;
202
+ payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
203
+ } catch {
219
204
  throw new Error("Invalid token");
220
205
  }
206
+
207
+ // Require an expiry claim and reject expired tokens. A token that omits
208
+ // exp must not be treated as valid indefinitely.
209
+ if (typeof payload.exp !== "number") {
210
+ throw new Error("Token missing expiry");
211
+ }
212
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
213
+ throw new Error("Token expired");
214
+ }
215
+
216
+ return {
217
+ token,
218
+ clientId: "opsee",
219
+ scopes: [],
220
+ expiresAt: payload.exp,
221
+ extra: {
222
+ userId: payload.userId,
223
+ companyId: payload.companyId,
224
+ role: payload.role,
225
+ },
226
+ };
221
227
  }
222
228
 
223
229
  async revokeToken(
@@ -21,7 +21,40 @@ const SUCCESS_HTML = `<!DOCTYPE html>
21
21
  </body>
22
22
  </html>`;
23
23
 
24
+ // Express parses a repeated query param (?token=a&token=b) into a string[], so
25
+ // a blanket `req.query as Record<string,string>` cast lies to the type system
26
+ // and lets an array slip through as if it were a string. Accept only a single
27
+ // string value; anything else (array, missing) becomes undefined.
28
+ function singleQueryParam(value: unknown): string | undefined {
29
+ return typeof value === "string" ? value : undefined;
30
+ }
31
+
32
+ let processHandlersRegistered = false;
33
+
34
+ // Register last-resort process guards exactly once. Registering inside
35
+ // startHttpServer directly would accumulate duplicate listeners if it were
36
+ // ever called more than once (tests, restart loops).
37
+ function registerProcessGuards(): void {
38
+ if (processHandlersRegistered) return;
39
+ processHandlersRegistered = true;
40
+
41
+ // An unhandled rejection is logged but not fatal: the per-request handlers
42
+ // catch their own errors, so this is a diagnostic backstop, not a crash path.
43
+ process.on("unhandledRejection", (reason) => {
44
+ console.error("Unhandled promise rejection:", reason);
45
+ });
46
+ // An uncaught exception leaves the process in an undefined state. Log and
47
+ // exit non-zero so the supervisor (k8s/systemd) restarts a clean process,
48
+ // rather than continuing to serve from a corrupt one.
49
+ process.on("uncaughtException", (err) => {
50
+ console.error("Uncaught exception:", err);
51
+ process.exit(1);
52
+ });
53
+ }
54
+
24
55
  export async function startHttpServer(): Promise<void> {
56
+ registerProcessGuards();
57
+
25
58
  const port = parseInt(process.env.MCP_PORT || "3100", 10);
26
59
  const host = process.env.MCP_HOST || "0.0.0.0";
27
60
  const serverUrl =
@@ -57,28 +90,41 @@ export async function startHttpServer(): Promise<void> {
57
90
 
58
91
  // --- Custom OAuth callback (Opsee login redirects here) ---
59
92
  app.get("/oauth/callback", async (req, res) => {
60
- const { pending, token, userId, companyId, expiresAt } = req.query as Record<string, string>;
61
-
62
- if (!pending || !token) {
63
- res.status(400).send("Missing pending or token parameter");
64
- return;
65
- }
66
-
67
- const result = await provider.handleCallback(
68
- pending,
69
- token,
70
- userId || "",
71
- companyId || "",
72
- expiresAt || "",
73
- );
74
-
75
- if ("error" in result) {
76
- res.status(400).send(result.error);
77
- return;
93
+ try {
94
+ const pending = singleQueryParam(req.query.pending);
95
+ const token = singleQueryParam(req.query.token);
96
+ const userId = singleQueryParam(req.query.userId) ?? "";
97
+ const companyId = singleQueryParam(req.query.companyId) ?? "";
98
+ const expiresAt = singleQueryParam(req.query.expiresAt) ?? "";
99
+
100
+ if (!pending || !token) {
101
+ res.status(400).send("Missing or invalid pending or token parameter");
102
+ return;
103
+ }
104
+
105
+ const result = await provider.handleCallback(
106
+ pending,
107
+ token,
108
+ userId,
109
+ companyId,
110
+ expiresAt,
111
+ );
112
+
113
+ if ("error" in result) {
114
+ res.status(400).send(result.error);
115
+ return;
116
+ }
117
+
118
+ // Redirect to Claude's redirect_uri with the auth code
119
+ res.redirect(result.redirectUri);
120
+ } catch (err) {
121
+ // Without this, a throw here (e.g. Redis down in handleCallback) leaves
122
+ // the request hanging until the client times out.
123
+ console.error("OAuth callback failed:", err);
124
+ if (!res.headersSent) {
125
+ res.status(500).send("Internal server error during OAuth callback");
126
+ }
78
127
  }
79
-
80
- // Redirect to Claude's redirect_uri with the auth code
81
- res.redirect(result.redirectUri);
82
128
  });
83
129
 
84
130
  // --- Bearer auth middleware for MCP endpoints ---
@@ -103,13 +149,44 @@ export async function startHttpServer(): Promise<void> {
103
149
  });
104
150
 
105
151
  const mcpServer = createServer();
106
- await mcpServer.connect(transport);
107
- await transport.handleRequest(req, res, req.body);
108
- await transport.close();
109
- await mcpServer.close();
152
+ try {
153
+ await mcpServer.connect(transport);
154
+ await transport.handleRequest(req, res, req.body);
155
+ } catch (err) {
156
+ console.error("MCP request handling failed:", err);
157
+ if (!res.headersSent) {
158
+ res.status(500).json({
159
+ jsonrpc: "2.0",
160
+ error: { code: -32603, message: "Internal server error" },
161
+ id: null,
162
+ });
163
+ }
164
+ } finally {
165
+ // Always tear down the per-request transport+server, even on error,
166
+ // so they are not leaked on every failed request.
167
+ await transport.close().catch(() => {});
168
+ await mcpServer.close().catch(() => {});
169
+ }
110
170
  });
111
171
  });
112
172
 
173
+ // Centralized error handler (must have 4 args to be recognized by Express).
174
+ // Catches synchronous throws from handlers so the client gets a 500 instead
175
+ // of a hung connection.
176
+ app.use(
177
+ (
178
+ err: unknown,
179
+ _req: express.Request,
180
+ res: express.Response,
181
+ _next: express.NextFunction,
182
+ ) => {
183
+ console.error("Unhandled request error:", err);
184
+ if (!res.headersSent) {
185
+ res.status(500).json({ error: "Internal server error" });
186
+ }
187
+ },
188
+ );
189
+
113
190
  app.listen(port, host, () => {
114
191
  console.log(`Opsee MCP server (remote) listening on ${host}:${port}`);
115
192
  console.log(` MCP endpoint: ${serverUrl}/mcp`);
@@ -35,7 +35,7 @@ export function registerAcceptanceCriterionTools(
35
35
  server.tool(
36
36
  "opsee_list_task_acceptance_criteria",
37
37
  "Lists STRUCTURED acceptance criteria from a separate DB entity, NOT the checklist items inside the task description. Most tasks have their AC inline in the description as `- [ ]` markdown — to read those, fetch the task description via opsee_get_task and parse the checklist lines yourself. This tool only returns rows from the `acceptance_criteria` table, which is the automation/power-user path, not the main UI. Returns items ordered by display_order then id with text + done flag + display_order.",
38
- { taskId: z.number().describe("The task ID") },
38
+ { taskId: z.number().int().min(1).describe("The task ID") },
39
39
  { readOnlyHint: true, destructiveHint: false },
40
40
  async ({ taskId }) => {
41
41
  try {
@@ -57,7 +57,7 @@ export function registerAcceptanceCriterionTools(
57
57
  "opsee_add_acceptance_criterion",
58
58
  "POWER-USER PATH — writes a STRUCTURED criterion to the `acceptance_criteria` DB table, which does NOT surface in the task detail UI. For typical AC, use opsee_update_task and write `- [ ]` checkbox lines under a `## Acceptance criteria` heading in the task description instead — those render as clickable checkboxes via BlockNote. Use this tool only when you need machine-trackable AC for automation (e.g. mapping criteria to PRs, AI workflow outputs, reporting). display_order defaults to 0; pass an explicit value to control ordering.",
59
59
  {
60
- taskId: z.number().describe("The task ID"),
60
+ taskId: z.number().int().min(1).describe("The task ID"),
61
61
  text: z.string().min(1).max(2000).describe("Criterion text (what passing this AC looks like)"),
62
62
  displayOrder: z.number().int().optional().describe("Display order (default: 0)"),
63
63
  },
@@ -84,7 +84,7 @@ export function registerAcceptanceCriterionTools(
84
84
  "opsee_update_acceptance_criterion",
85
85
  "POWER-USER PATH — patches a STRUCTURED criterion in the `acceptance_criteria` DB table. Does NOT touch description-embedded checkboxes; those live in the task description and are edited via opsee_update_task's description field. Use this only for the automation path that created the criterion via opsee_add_acceptance_criterion. Patch semantics: any subset of text / done / displayOrder; fields not supplied are left alone.",
86
86
  {
87
- criterionId: z.number().describe("The criterion ID"),
87
+ criterionId: z.number().int().min(1).describe("The criterion ID"),
88
88
  text: z.string().max(2000).optional().describe("New text"),
89
89
  done: z.boolean().optional().describe("Mark complete or incomplete"),
90
90
  displayOrder: z.number().int().optional().describe("New display order"),
@@ -112,7 +112,7 @@ export function registerAcceptanceCriterionTools(
112
112
  server.tool(
113
113
  "opsee_delete_acceptance_criterion",
114
114
  "POWER-USER PATH — deletes a STRUCTURED criterion row from the `acceptance_criteria` DB table by ID. Does NOT remove `- [ ]` lines from a task description; for those, edit the description via opsee_update_task. Soft delete; this is permanent at the application layer.",
115
- { criterionId: z.number().describe("The criterion ID") },
115
+ { criterionId: z.number().int().min(1).describe("The criterion ID") },
116
116
  { readOnlyHint: false, destructiveHint: true },
117
117
  async ({ criterionId }) => {
118
118
  try {
@@ -17,10 +17,10 @@ export function registerCommentTools(
17
17
  "opsee_list_comments",
18
18
  "List comments on a task or an Initiative, oldest first. Includes both regular and internal comments visible to the caller. Provide exactly one of taskId or initiativeId.",
19
19
  {
20
- taskId: z.number().optional().describe("The task ID (provide exactly one of taskId or initiativeId)"),
21
- initiativeId: z.number().optional().describe("The initiative ID (provide exactly one of taskId or initiativeId)"),
22
- page: z.number().optional().describe("Page number (default: 1)"),
23
- pageSize: z.number().optional().describe("Items per page (default: 50)"),
20
+ taskId: z.number().int().min(1).optional().describe("The task ID (provide exactly one of taskId or initiativeId)"),
21
+ initiativeId: z.number().int().min(1).optional().describe("The initiative ID (provide exactly one of taskId or initiativeId)"),
22
+ page: z.number().int().min(1).optional().describe("Page number (default: 1)"),
23
+ pageSize: z.number().int().min(1).optional().describe("Items per page (default: 50)"),
24
24
  },
25
25
  { readOnlyHint: true, destructiveHint: false },
26
26
  async ({ taskId, initiativeId, page, pageSize }) => {
@@ -42,8 +42,8 @@ export function registerCommentTools(
42
42
  "opsee_add_comment",
43
43
  "Add a comment to a task or an Initiative. Posts as the authenticated user. Provide exactly one of taskId or initiativeId. Set isInternal=true to mark the comment as internal (visibility depends on backend RBAC).",
44
44
  {
45
- taskId: z.number().optional().describe("The task ID to comment on (provide exactly one of taskId or initiativeId)"),
46
- initiativeId: z.number().optional().describe("The initiative ID to comment on (provide exactly one of taskId or initiativeId)"),
45
+ taskId: z.number().int().min(1).optional().describe("The task ID to comment on (provide exactly one of taskId or initiativeId)"),
46
+ initiativeId: z.number().int().min(1).optional().describe("The initiative ID to comment on (provide exactly one of taskId or initiativeId)"),
47
47
  content: z.string().min(1).describe("Comment body"),
48
48
  isInternal: z.boolean().optional().describe("Mark as internal comment (default: false)"),
49
49
  },
@@ -86,7 +86,7 @@ export function registerCommentTools(
86
86
  "opsee_edit_comment",
87
87
  "Edit an existing comment's content and/or internal flag. Posts as the original author. Use opsee_list_comments first to confirm the comment ID.",
88
88
  {
89
- commentId: z.number().describe("The comment ID to edit"),
89
+ commentId: z.number().int().min(1).describe("The comment ID to edit"),
90
90
  content: z.string().min(1).describe("New comment body"),
91
91
  isInternal: z.boolean().optional().describe("Update the internal flag (defaults to the comment's existing value if omitted)"),
92
92
  },
@@ -124,7 +124,7 @@ export function registerCommentTools(
124
124
  server.tool(
125
125
  "opsee_delete_comment",
126
126
  "Delete a comment by ID. This is permanent.",
127
- { commentId: z.number().describe("The comment ID") },
127
+ { commentId: z.number().int().min(1).describe("The comment ID") },
128
128
  { readOnlyHint: false, destructiveHint: true },
129
129
  async ({ commentId }) => {
130
130
  try {