@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 +1 -1
- package/src/__tests__/kv-store.test.ts +53 -0
- package/src/__tests__/tools.test.ts +17 -1
- package/src/auth/__tests__/manager.test.ts +67 -0
- package/src/auth/__tests__/oauth-provider.test.ts +45 -0
- package/src/auth/kv-store.ts +33 -0
- package/src/auth/login.ts +1 -1
- package/src/auth/manager.ts +9 -2
- package/src/auth/oauth-provider.ts +28 -22
- package/src/server-http.ts +102 -25
- package/src/tools/acceptance-criteria.ts +4 -4
- package/src/tools/comments.ts +8 -8
- package/src/tools/cycles.ts +8 -8
- package/src/tools/docs.ts +5 -5
- package/src/tools/initiatives.ts +25 -25
- package/src/tools/labels.ts +12 -12
- package/src/tools/milestones.ts +8 -8
- package/src/tools/notifications.ts +3 -3
- package/src/tools/projects.ts +1 -1
- package/src/tools/repositories.ts +1 -1
- package/src/tools/task-dependencies.ts +7 -7
- package/src/tools/task-metadata.ts +4 -4
- package/src/tools/tasks.ts +45 -45
- package/src/tools/user.ts +3 -3
- package/src/tools/work-logs.ts +5 -5
- package/src/utils/__tests__/format.test.ts +53 -0
- package/src/utils/format.ts +19 -9
package/package.json
CHANGED
|
@@ -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
|
|
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
|
});
|
package/src/auth/kv-store.ts
CHANGED
|
@@ -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.
|
|
46
|
+
serverUrl: process.env.OPSEE_API_URL || "https://grpc.api.opsee.ai",
|
|
47
47
|
userId,
|
|
48
48
|
companyId,
|
|
49
49
|
expiresAt,
|
package/src/auth/manager.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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(
|
package/src/server-http.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
package/src/tools/comments.ts
CHANGED
|
@@ -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 {
|