@opsee/mcp-server 0.1.6 → 0.1.8
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 +207 -0
- package/bin/opsee-mcp.js +9 -3
- package/package.json +9 -3
- package/src/__tests__/tools.test.ts +465 -0
- package/src/auth/oauth-provider.ts +254 -0
- package/src/auth/token-context.ts +11 -0
- package/src/client/api.ts +48 -24
- package/src/index-http.ts +6 -0
- package/src/server-http.ts +120 -0
- package/src/server.ts +11 -9
- package/src/tools/cycles.ts +3 -0
- package/src/tools/docs.ts +4 -0
- package/src/tools/projects.ts +2 -0
- package/src/tools/repositories.ts +1 -0
- package/src/tools/task-metadata.ts +4 -0
- package/src/tools/tasks.ts +4 -0
- package/src/tools/user.ts +1 -0
- package/src/utils/format.ts +8 -8
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { createServer } from "../server.js";
|
|
5
|
+
import type { ApiClients } from "../client/api.js";
|
|
6
|
+
|
|
7
|
+
// --- Mock data ---
|
|
8
|
+
|
|
9
|
+
const mockProject = {
|
|
10
|
+
id: 1,
|
|
11
|
+
name: "Test Project",
|
|
12
|
+
shortName: "TP",
|
|
13
|
+
slug: "test-project",
|
|
14
|
+
description: "A test project",
|
|
15
|
+
isActive: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const mockTask = {
|
|
19
|
+
id: 10,
|
|
20
|
+
identifier: "TP-1",
|
|
21
|
+
title: "Fix the bug",
|
|
22
|
+
description: "Something is broken",
|
|
23
|
+
displayOrder: 0,
|
|
24
|
+
storyPoints: 3,
|
|
25
|
+
aiModeEnabled: false,
|
|
26
|
+
boardColumn: { id: 1, name: "To Do" },
|
|
27
|
+
taskPriority: { id: 2, name: "High" },
|
|
28
|
+
taskType: { id: 1, name: "Bug" },
|
|
29
|
+
assignedUser: { id: 5, fullName: "Alice" },
|
|
30
|
+
cycle: { id: 1, name: "Sprint 1" },
|
|
31
|
+
reporterUser: { id: 5 },
|
|
32
|
+
board: { id: 1 },
|
|
33
|
+
project: { id: 1 },
|
|
34
|
+
taskRepositories: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const mockUser = {
|
|
38
|
+
id: 5,
|
|
39
|
+
fullName: "Alice Test",
|
|
40
|
+
email: "alice@test.com",
|
|
41
|
+
companyId: 1,
|
|
42
|
+
company: { name: "TestCo" },
|
|
43
|
+
flattenedRoles: ["User"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const mockBoard = { id: 1, name: "Main Board", isActive: true };
|
|
47
|
+
const mockBoardColumn = {
|
|
48
|
+
id: 1,
|
|
49
|
+
name: "To Do",
|
|
50
|
+
displayOrder: 0,
|
|
51
|
+
lifecycleState: "active",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const mockCycle = {
|
|
55
|
+
id: 1,
|
|
56
|
+
name: "Sprint 1",
|
|
57
|
+
description: "First sprint",
|
|
58
|
+
goal: "Ship v1",
|
|
59
|
+
startDate: "2026-03-01",
|
|
60
|
+
endDate: "2026-03-15",
|
|
61
|
+
isActive: true,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const mockDocSpace = {
|
|
65
|
+
id: 1,
|
|
66
|
+
title: "Engineering Docs",
|
|
67
|
+
description: "Technical documentation",
|
|
68
|
+
slug: "engineering-docs",
|
|
69
|
+
isDefault: true,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const mockDocPage = {
|
|
73
|
+
id: 1,
|
|
74
|
+
title: "Getting Started",
|
|
75
|
+
slug: "getting-started",
|
|
76
|
+
currentContent: "Welcome to the docs",
|
|
77
|
+
currentVersionNumber: 1,
|
|
78
|
+
createdByUser: { fullName: "Alice" },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const mockTaskType = { id: 1, name: "Bug" };
|
|
82
|
+
const mockTaskPriority = { id: 2, name: "High", level: 2 };
|
|
83
|
+
const mockVcsIntegration = {
|
|
84
|
+
id: 1,
|
|
85
|
+
provider: 2,
|
|
86
|
+
repoOwner: "acme",
|
|
87
|
+
repoName: "backend",
|
|
88
|
+
defaultBranch: "main",
|
|
89
|
+
isActive: true,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// --- Mock client factory ---
|
|
93
|
+
|
|
94
|
+
function createMockClients(): ApiClients {
|
|
95
|
+
return {
|
|
96
|
+
projects: {
|
|
97
|
+
getProjects: async () => ({ projects: [mockProject] }),
|
|
98
|
+
getProject: async () => ({ project: mockProject }),
|
|
99
|
+
},
|
|
100
|
+
tasks: {
|
|
101
|
+
getTasks: async () => ({ tasks: [mockTask] }),
|
|
102
|
+
getTask: async () => ({ task: mockTask }),
|
|
103
|
+
addTask: async () => ({ task: mockTask }),
|
|
104
|
+
editTask: async () => ({ task: { ...mockTask, title: "Updated bug" } }),
|
|
105
|
+
},
|
|
106
|
+
boards: {
|
|
107
|
+
getBoards: async () => ({ boards: [mockBoard] }),
|
|
108
|
+
},
|
|
109
|
+
boardColumns: {
|
|
110
|
+
getBoardColumns: async () => ({ boardColumns: [mockBoardColumn] }),
|
|
111
|
+
},
|
|
112
|
+
cycles: {
|
|
113
|
+
getCycles: async () => ({ cycles: [mockCycle] }),
|
|
114
|
+
getCycle: async () => ({ cycle: mockCycle }),
|
|
115
|
+
addCycle: async () => ({ cycle: mockCycle }),
|
|
116
|
+
},
|
|
117
|
+
docSpaces: {
|
|
118
|
+
getDocSpaces: async () => ({ docSpaces: [mockDocSpace] }),
|
|
119
|
+
},
|
|
120
|
+
docPages: {
|
|
121
|
+
getDocPages: async () => ({ docPages: [mockDocPage] }),
|
|
122
|
+
getDocPage: async () => ({ docPage: mockDocPage }),
|
|
123
|
+
addDocPage: async () => ({ docPage: mockDocPage }),
|
|
124
|
+
},
|
|
125
|
+
taskTypes: {
|
|
126
|
+
getTaskTypes: async () => ({ taskTypes: [mockTaskType] }),
|
|
127
|
+
},
|
|
128
|
+
taskPriorities: {
|
|
129
|
+
getTaskPriorities: async () => ({
|
|
130
|
+
taskPriorities: [mockTaskPriority],
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
users: {
|
|
134
|
+
getMe: async () => ({ user: mockUser }),
|
|
135
|
+
},
|
|
136
|
+
vcsIntegrations: {
|
|
137
|
+
getVCSIntegrations: async () => ({
|
|
138
|
+
vcsIntegrations: [mockVcsIntegration],
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
} as unknown as ApiClients;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- Test suite ---
|
|
145
|
+
|
|
146
|
+
describe("MCP Tools", () => {
|
|
147
|
+
let client: Client;
|
|
148
|
+
let cleanup: () => Promise<void>;
|
|
149
|
+
|
|
150
|
+
beforeAll(async () => {
|
|
151
|
+
const [clientTransport, serverTransport] =
|
|
152
|
+
InMemoryTransport.createLinkedPair();
|
|
153
|
+
const server = createServer(() => createMockClients());
|
|
154
|
+
await server.connect(serverTransport);
|
|
155
|
+
|
|
156
|
+
client = new Client({ name: "test-client", version: "1.0.0" });
|
|
157
|
+
await client.connect(clientTransport);
|
|
158
|
+
|
|
159
|
+
cleanup = async () => {
|
|
160
|
+
await client.close();
|
|
161
|
+
await server.close();
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterAll(async () => {
|
|
166
|
+
await cleanup();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// --- Project tools ---
|
|
170
|
+
|
|
171
|
+
test("opsee_list_projects returns projects", async () => {
|
|
172
|
+
const result = await client.callTool({
|
|
173
|
+
name: "opsee_list_projects",
|
|
174
|
+
arguments: {},
|
|
175
|
+
});
|
|
176
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
177
|
+
.text;
|
|
178
|
+
expect(text).toContain("Test Project");
|
|
179
|
+
expect(text).toContain("TP");
|
|
180
|
+
expect(text).toContain("1 project(s)");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("opsee_get_project returns project details", async () => {
|
|
184
|
+
const result = await client.callTool({
|
|
185
|
+
name: "opsee_get_project",
|
|
186
|
+
arguments: { projectId: 1 },
|
|
187
|
+
});
|
|
188
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
189
|
+
.text;
|
|
190
|
+
expect(text).toContain("Test Project");
|
|
191
|
+
expect(text).toContain("test-project");
|
|
192
|
+
expect(text).toContain("ID: 1");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// --- Task tools ---
|
|
196
|
+
|
|
197
|
+
test("opsee_list_tasks returns tasks for a project", async () => {
|
|
198
|
+
const result = await client.callTool({
|
|
199
|
+
name: "opsee_list_tasks",
|
|
200
|
+
arguments: { projectId: 1 },
|
|
201
|
+
});
|
|
202
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
203
|
+
.text;
|
|
204
|
+
expect(text).toContain("TP-1");
|
|
205
|
+
expect(text).toContain("Fix the bug");
|
|
206
|
+
expect(text).toContain("1 task(s)");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("opsee_get_task returns task details", async () => {
|
|
210
|
+
const result = await client.callTool({
|
|
211
|
+
name: "opsee_get_task",
|
|
212
|
+
arguments: { taskId: 10 },
|
|
213
|
+
});
|
|
214
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
215
|
+
.text;
|
|
216
|
+
expect(text).toContain("TP-1");
|
|
217
|
+
expect(text).toContain("Fix the bug");
|
|
218
|
+
expect(text).toContain("High");
|
|
219
|
+
expect(text).toContain("Bug");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("opsee_create_task creates a task with defaults", async () => {
|
|
223
|
+
const result = await client.callTool({
|
|
224
|
+
name: "opsee_create_task",
|
|
225
|
+
arguments: { projectId: 1, title: "New task" },
|
|
226
|
+
});
|
|
227
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
228
|
+
.text;
|
|
229
|
+
expect(text).toContain("Task created");
|
|
230
|
+
expect(text).toContain("TP-1");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("opsee_update_task does read-modify-write", async () => {
|
|
234
|
+
const result = await client.callTool({
|
|
235
|
+
name: "opsee_update_task",
|
|
236
|
+
arguments: { taskId: 10, title: "Updated title" },
|
|
237
|
+
});
|
|
238
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
239
|
+
.text;
|
|
240
|
+
expect(text).toContain("Task updated");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// --- User tools ---
|
|
244
|
+
|
|
245
|
+
test("opsee_get_me returns current user", async () => {
|
|
246
|
+
const result = await client.callTool({
|
|
247
|
+
name: "opsee_get_me",
|
|
248
|
+
arguments: {},
|
|
249
|
+
});
|
|
250
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
251
|
+
.text;
|
|
252
|
+
expect(text).toContain("Alice Test");
|
|
253
|
+
expect(text).toContain("alice@test.com");
|
|
254
|
+
expect(text).toContain("TestCo");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// --- Board/metadata tools ---
|
|
258
|
+
|
|
259
|
+
test("opsee_list_boards returns boards", async () => {
|
|
260
|
+
const result = await client.callTool({
|
|
261
|
+
name: "opsee_list_boards",
|
|
262
|
+
arguments: { projectId: 1 },
|
|
263
|
+
});
|
|
264
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
265
|
+
.text;
|
|
266
|
+
expect(text).toContain("Main Board");
|
|
267
|
+
expect(text).toContain("ID: 1");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("opsee_list_board_columns returns columns", async () => {
|
|
271
|
+
const result = await client.callTool({
|
|
272
|
+
name: "opsee_list_board_columns",
|
|
273
|
+
arguments: { boardId: 1 },
|
|
274
|
+
});
|
|
275
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
276
|
+
.text;
|
|
277
|
+
expect(text).toContain("To Do");
|
|
278
|
+
expect(text).toContain("ID: 1");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("opsee_list_task_types returns types", async () => {
|
|
282
|
+
const result = await client.callTool({
|
|
283
|
+
name: "opsee_list_task_types",
|
|
284
|
+
arguments: { projectId: 1 },
|
|
285
|
+
});
|
|
286
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
287
|
+
.text;
|
|
288
|
+
expect(text).toContain("Bug");
|
|
289
|
+
expect(text).toContain("ID: 1");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("opsee_list_task_priorities returns priorities", async () => {
|
|
293
|
+
const result = await client.callTool({
|
|
294
|
+
name: "opsee_list_task_priorities",
|
|
295
|
+
arguments: { projectId: 1 },
|
|
296
|
+
});
|
|
297
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
298
|
+
.text;
|
|
299
|
+
expect(text).toContain("High");
|
|
300
|
+
expect(text).toContain("level: 2");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// --- Cycle tools ---
|
|
304
|
+
|
|
305
|
+
test("opsee_list_cycles returns cycles", async () => {
|
|
306
|
+
const result = await client.callTool({
|
|
307
|
+
name: "opsee_list_cycles",
|
|
308
|
+
arguments: { projectId: 1 },
|
|
309
|
+
});
|
|
310
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
311
|
+
.text;
|
|
312
|
+
expect(text).toContain("Sprint 1");
|
|
313
|
+
expect(text).toContain("1 cycle(s)");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("opsee_get_cycle returns cycle details", async () => {
|
|
317
|
+
const result = await client.callTool({
|
|
318
|
+
name: "opsee_get_cycle",
|
|
319
|
+
arguments: { cycleId: 1 },
|
|
320
|
+
});
|
|
321
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
322
|
+
.text;
|
|
323
|
+
expect(text).toContain("Sprint 1");
|
|
324
|
+
expect(text).toContain("Ship v1");
|
|
325
|
+
expect(text).toContain("ID: 1");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("opsee_create_cycle creates a cycle", async () => {
|
|
329
|
+
const result = await client.callTool({
|
|
330
|
+
name: "opsee_create_cycle",
|
|
331
|
+
arguments: {
|
|
332
|
+
projectId: 1,
|
|
333
|
+
name: "Sprint 2",
|
|
334
|
+
startDate: "2026-03-25",
|
|
335
|
+
endDate: "2026-04-08",
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
339
|
+
.text;
|
|
340
|
+
expect(text).toContain("Cycle created");
|
|
341
|
+
expect(text).toContain("Sprint 1");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// --- Doc tools ---
|
|
345
|
+
|
|
346
|
+
test("opsee_list_doc_spaces returns spaces", async () => {
|
|
347
|
+
const result = await client.callTool({
|
|
348
|
+
name: "opsee_list_doc_spaces",
|
|
349
|
+
arguments: { projectId: 1 },
|
|
350
|
+
});
|
|
351
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
352
|
+
.text;
|
|
353
|
+
expect(text).toContain("Engineering Docs");
|
|
354
|
+
expect(text).toContain("1 doc space(s)");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("opsee_list_doc_pages returns pages", async () => {
|
|
358
|
+
const result = await client.callTool({
|
|
359
|
+
name: "opsee_list_doc_pages",
|
|
360
|
+
arguments: { docSpaceId: 1 },
|
|
361
|
+
});
|
|
362
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
363
|
+
.text;
|
|
364
|
+
expect(text).toContain("Getting Started");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("opsee_get_doc_page returns page content", async () => {
|
|
368
|
+
const result = await client.callTool({
|
|
369
|
+
name: "opsee_get_doc_page",
|
|
370
|
+
arguments: { pageId: 1 },
|
|
371
|
+
});
|
|
372
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
373
|
+
.text;
|
|
374
|
+
expect(text).toContain("Getting Started");
|
|
375
|
+
expect(text).toContain("Welcome to the docs");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("opsee_create_doc_page creates a page", async () => {
|
|
379
|
+
const result = await client.callTool({
|
|
380
|
+
name: "opsee_create_doc_page",
|
|
381
|
+
arguments: {
|
|
382
|
+
projectId: 1,
|
|
383
|
+
title: "New Page",
|
|
384
|
+
content: "Hello world",
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
388
|
+
.text;
|
|
389
|
+
expect(text).toContain("Doc page created");
|
|
390
|
+
expect(text).toContain("Getting Started");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// --- Repository tools ---
|
|
394
|
+
|
|
395
|
+
test("opsee_list_repositories returns connected repos", async () => {
|
|
396
|
+
const result = await client.callTool({
|
|
397
|
+
name: "opsee_list_repositories",
|
|
398
|
+
arguments: { projectId: 1 },
|
|
399
|
+
});
|
|
400
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
401
|
+
.text;
|
|
402
|
+
expect(text).toContain("acme/backend");
|
|
403
|
+
expect(text).toContain("GitLab");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// --- Error handling ---
|
|
407
|
+
|
|
408
|
+
test("tool returns isError on API failure", async () => {
|
|
409
|
+
const errorClients = createMockClients();
|
|
410
|
+
(errorClients.projects as any).getProjects = async () => {
|
|
411
|
+
throw new Error("connection refused ECONNREFUSED");
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
415
|
+
const errorServer = createServer(() => errorClients);
|
|
416
|
+
await errorServer.connect(st);
|
|
417
|
+
|
|
418
|
+
const errorClient = new Client({
|
|
419
|
+
name: "error-test",
|
|
420
|
+
version: "1.0.0",
|
|
421
|
+
});
|
|
422
|
+
await errorClient.connect(ct);
|
|
423
|
+
|
|
424
|
+
const result = await errorClient.callTool({
|
|
425
|
+
name: "opsee_list_projects",
|
|
426
|
+
arguments: {},
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
expect(result.isError).toBe(true);
|
|
430
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
431
|
+
.text;
|
|
432
|
+
expect(text).toContain("ECONNREFUSED");
|
|
433
|
+
|
|
434
|
+
await errorClient.close();
|
|
435
|
+
await errorServer.close();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("auth error returns login instruction", async () => {
|
|
439
|
+
const authClients = createMockClients();
|
|
440
|
+
(authClients.users as any).getMe = async () => {
|
|
441
|
+
throw new Error("Unauthenticated");
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
445
|
+
const authServer = createServer(() => authClients);
|
|
446
|
+
await authServer.connect(st);
|
|
447
|
+
|
|
448
|
+
const authClient = new Client({ name: "auth-test", version: "1.0.0" });
|
|
449
|
+
await authClient.connect(ct);
|
|
450
|
+
|
|
451
|
+
const result = await authClient.callTool({
|
|
452
|
+
name: "opsee_get_me",
|
|
453
|
+
arguments: {},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(result.isError).toBe(true);
|
|
457
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0]
|
|
458
|
+
.text;
|
|
459
|
+
expect(text).toContain("Not authenticated");
|
|
460
|
+
expect(text).toContain("login");
|
|
461
|
+
|
|
462
|
+
await authClient.close();
|
|
463
|
+
await authServer.close();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import type { Response } from "express";
|
|
3
|
+
import type {
|
|
4
|
+
OAuthServerProvider,
|
|
5
|
+
AuthorizationParams,
|
|
6
|
+
} from "@modelcontextprotocol/sdk/server/auth/provider.js";
|
|
7
|
+
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
|
|
8
|
+
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
|
9
|
+
import type {
|
|
10
|
+
OAuthClientInformationFull,
|
|
11
|
+
OAuthTokens,
|
|
12
|
+
OAuthTokenRevocationRequest,
|
|
13
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
14
|
+
|
|
15
|
+
// --- In-memory stores ---
|
|
16
|
+
|
|
17
|
+
interface PendingAuth {
|
|
18
|
+
clientId: string;
|
|
19
|
+
redirectUri: string;
|
|
20
|
+
state?: string;
|
|
21
|
+
codeChallenge: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface AuthCode {
|
|
26
|
+
token: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
companyId: string;
|
|
29
|
+
expiresAt: string;
|
|
30
|
+
codeChallenge: string;
|
|
31
|
+
redirectUri: string;
|
|
32
|
+
clientId: string;
|
|
33
|
+
createdAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
37
|
+
|
|
38
|
+
export class OpseeClientStore implements OAuthRegisteredClientsStore {
|
|
39
|
+
private clients = new Map<string, OAuthClientInformationFull>();
|
|
40
|
+
|
|
41
|
+
getClient(clientId: string): OAuthClientInformationFull | undefined {
|
|
42
|
+
return this.clients.get(clientId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
registerClient(
|
|
46
|
+
client: Omit<OAuthClientInformationFull, "client_id" | "client_id_issued_at">,
|
|
47
|
+
): OAuthClientInformationFull {
|
|
48
|
+
const clientId = randomUUID();
|
|
49
|
+
const clientSecret = randomBytes(32).toString("hex");
|
|
50
|
+
const registered: OAuthClientInformationFull = {
|
|
51
|
+
...client,
|
|
52
|
+
client_id: clientId,
|
|
53
|
+
client_secret: clientSecret,
|
|
54
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
55
|
+
};
|
|
56
|
+
this.clients.set(clientId, registered);
|
|
57
|
+
return registered;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
62
|
+
private _clientsStore = new OpseeClientStore();
|
|
63
|
+
private pendingAuths = new Map<string, PendingAuth>();
|
|
64
|
+
private authCodes = new Map<string, AuthCode>();
|
|
65
|
+
private revokedTokens = new Set<string>();
|
|
66
|
+
|
|
67
|
+
private serverUrl: string;
|
|
68
|
+
private appUrl: string;
|
|
69
|
+
|
|
70
|
+
constructor(serverUrl: string, appUrl?: string) {
|
|
71
|
+
this.serverUrl = serverUrl;
|
|
72
|
+
this.appUrl = appUrl || process.env.OPSEE_APP_URL || "https://opsee.ai";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get clientsStore(): OAuthRegisteredClientsStore {
|
|
76
|
+
return this._clientsStore;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Step 1: Claude calls /authorize. We redirect to Opsee's login UI.
|
|
81
|
+
*/
|
|
82
|
+
async authorize(
|
|
83
|
+
client: OAuthClientInformationFull,
|
|
84
|
+
params: AuthorizationParams,
|
|
85
|
+
res: Response,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const pendingId = randomBytes(16).toString("hex");
|
|
88
|
+
|
|
89
|
+
this.pendingAuths.set(pendingId, {
|
|
90
|
+
clientId: client.client_id,
|
|
91
|
+
redirectUri: params.redirectUri,
|
|
92
|
+
state: params.state,
|
|
93
|
+
codeChallenge: params.codeChallenge,
|
|
94
|
+
createdAt: Date.now(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Build callback URL back to our server
|
|
98
|
+
const callbackUrl = `${this.serverUrl}/oauth/callback?pending=${pendingId}`;
|
|
99
|
+
const loginUrl = `${this.appUrl}/auth/mcp?callback=${encodeURIComponent(callbackUrl)}`;
|
|
100
|
+
|
|
101
|
+
res.redirect(loginUrl);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Called by SDK to get the code_challenge for PKCE validation.
|
|
106
|
+
*/
|
|
107
|
+
async challengeForAuthorizationCode(
|
|
108
|
+
_client: OAuthClientInformationFull,
|
|
109
|
+
authorizationCode: string,
|
|
110
|
+
): Promise<string> {
|
|
111
|
+
const code = this.authCodes.get(authorizationCode);
|
|
112
|
+
if (!code) throw new Error("Invalid authorization code");
|
|
113
|
+
return code.codeChallenge;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Step 3: Claude exchanges auth code for access token.
|
|
118
|
+
*/
|
|
119
|
+
async exchangeAuthorizationCode(
|
|
120
|
+
_client: OAuthClientInformationFull,
|
|
121
|
+
authorizationCode: string,
|
|
122
|
+
): Promise<OAuthTokens> {
|
|
123
|
+
const code = this.authCodes.get(authorizationCode);
|
|
124
|
+
if (!code) throw new Error("Invalid or expired authorization code");
|
|
125
|
+
|
|
126
|
+
if (Date.now() - code.createdAt > TTL_MS) {
|
|
127
|
+
this.authCodes.delete(authorizationCode);
|
|
128
|
+
throw new Error("Authorization code expired");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// One-time use
|
|
132
|
+
this.authCodes.delete(authorizationCode);
|
|
133
|
+
|
|
134
|
+
// Calculate expires_in from the JWT's expiresAt
|
|
135
|
+
let expiresIn: number | undefined;
|
|
136
|
+
if (code.expiresAt) {
|
|
137
|
+
const expMs = new Date(code.expiresAt).getTime() - Date.now();
|
|
138
|
+
expiresIn = Math.max(0, Math.floor(expMs / 1000));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
access_token: code.token,
|
|
143
|
+
token_type: "bearer",
|
|
144
|
+
expires_in: expiresIn,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
|
149
|
+
throw new Error("Refresh tokens are not supported. Re-authenticate to get a new token.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate the access token (JWT from Opsee backend).
|
|
154
|
+
* We do lightweight validation here; the backend does full validation on API calls.
|
|
155
|
+
*/
|
|
156
|
+
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
|
157
|
+
if (this.revokedTokens.has(token)) {
|
|
158
|
+
throw new Error("Token has been revoked");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Decode JWT payload without crypto verification
|
|
162
|
+
const parts = token.split(".");
|
|
163
|
+
if (parts.length !== 3) throw new Error("Invalid token format");
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const payload = JSON.parse(
|
|
167
|
+
Buffer.from(parts[1], "base64url").toString("utf-8"),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Check expiry
|
|
171
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
172
|
+
throw new Error("Token expired");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
token,
|
|
177
|
+
clientId: "opsee",
|
|
178
|
+
scopes: [],
|
|
179
|
+
expiresAt: payload.exp,
|
|
180
|
+
extra: {
|
|
181
|
+
userId: payload.userId,
|
|
182
|
+
companyId: payload.companyId,
|
|
183
|
+
role: payload.role,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (err instanceof Error && err.message === "Token expired") throw err;
|
|
188
|
+
throw new Error("Invalid token");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async revokeToken(
|
|
193
|
+
_client: OAuthClientInformationFull,
|
|
194
|
+
request: OAuthTokenRevocationRequest,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
this.revokedTokens.add(request.token);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- Custom methods for the callback flow ---
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Step 2: Opsee login redirects back to /oauth/callback.
|
|
203
|
+
* We generate an auth code and redirect to Claude's redirect_uri.
|
|
204
|
+
*/
|
|
205
|
+
handleCallback(
|
|
206
|
+
pendingId: string,
|
|
207
|
+
token: string,
|
|
208
|
+
userId: string,
|
|
209
|
+
companyId: string,
|
|
210
|
+
expiresAt: string,
|
|
211
|
+
): { redirectUri: string } | { error: string } {
|
|
212
|
+
const pending = this.pendingAuths.get(pendingId);
|
|
213
|
+
if (!pending) return { error: "Invalid or expired pending authorization" };
|
|
214
|
+
|
|
215
|
+
if (Date.now() - pending.createdAt > TTL_MS) {
|
|
216
|
+
this.pendingAuths.delete(pendingId);
|
|
217
|
+
return { error: "Pending authorization expired" };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// One-time use
|
|
221
|
+
this.pendingAuths.delete(pendingId);
|
|
222
|
+
|
|
223
|
+
// Generate authorization code
|
|
224
|
+
const authCode = randomBytes(32).toString("hex");
|
|
225
|
+
this.authCodes.set(authCode, {
|
|
226
|
+
token,
|
|
227
|
+
userId,
|
|
228
|
+
companyId,
|
|
229
|
+
expiresAt,
|
|
230
|
+
codeChallenge: pending.codeChallenge,
|
|
231
|
+
redirectUri: pending.redirectUri,
|
|
232
|
+
clientId: pending.clientId,
|
|
233
|
+
createdAt: Date.now(),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Build redirect URL with code and state
|
|
237
|
+
const url = new URL(pending.redirectUri);
|
|
238
|
+
url.searchParams.set("code", authCode);
|
|
239
|
+
if (pending.state) url.searchParams.set("state", pending.state);
|
|
240
|
+
|
|
241
|
+
return { redirectUri: url.toString() };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Periodic cleanup of expired entries */
|
|
245
|
+
cleanup(): void {
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
for (const [id, entry] of this.pendingAuths) {
|
|
248
|
+
if (now - entry.createdAt > TTL_MS) this.pendingAuths.delete(id);
|
|
249
|
+
}
|
|
250
|
+
for (const [code, entry] of this.authCodes) {
|
|
251
|
+
if (now - entry.createdAt > TTL_MS) this.authCodes.delete(code);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|