@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.
@@ -0,0 +1,11 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ interface TokenContext {
4
+ token: string;
5
+ }
6
+
7
+ export const tokenContext = new AsyncLocalStorage<TokenContext>();
8
+
9
+ export function getCurrentToken(): string | null {
10
+ return tokenContext.getStore()?.token ?? null;
11
+ }
package/src/client/api.ts CHANGED
@@ -3,6 +3,7 @@ import { createConnectTransport } from "@connectrpc/connect-node";
3
3
  import { create } from "@bufbuild/protobuf";
4
4
  import type { GenService } from "@bufbuild/protobuf/codegenv1";
5
5
  import { authManager } from "../auth/manager.js";
6
+ import { getCurrentToken } from "../auth/token-context.js";
6
7
  import {
7
8
  PaginationSchema,
8
9
  type Pagination,
@@ -21,7 +22,9 @@ import { UserService } from "../../gen/api/v1/user_pb.js";
21
22
  import { VCSIntegrationService } from "../../gen/api/v1/vcs_integration_pb.js";
22
23
 
23
24
  const authInterceptor: Interceptor = (next) => async (req) => {
24
- const token = authManager.getToken();
25
+ // Remote mode: token from AsyncLocalStorage (per-request)
26
+ // Local mode: token from credentials file
27
+ const token = getCurrentToken() || authManager.getToken();
25
28
  if (token) {
26
29
  req.header.set("Authorization", `Bearer ${token}`);
27
30
  }
@@ -59,33 +62,54 @@ export function defaultPagination(
59
62
  });
60
63
  }
61
64
 
62
- function createTransport() {
63
- return createConnectTransport({
64
- baseUrl: authManager.getServerUrl(),
65
- httpVersion: "1.1",
66
- interceptors: [authInterceptor, paginationInterceptor],
67
- });
65
+ let transport: ReturnType<typeof createConnectTransport> | null = null;
66
+ let cachedClients: ApiClients | null = null;
67
+
68
+ function getTransport() {
69
+ if (!transport) {
70
+ transport = createConnectTransport({
71
+ baseUrl: authManager.getServerUrl(),
72
+ httpVersion: "1.1",
73
+ interceptors: [authInterceptor, paginationInterceptor],
74
+ });
75
+ }
76
+ return transport;
68
77
  }
69
78
 
70
79
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
80
  function makeClient<T extends GenService<any>>(service: T): Client<T> {
72
- return createClient(service, createTransport());
81
+ return createClient(service, getTransport());
73
82
  }
74
83
 
75
- export function getClients() {
76
- return {
77
- projects: makeClient(ProjectService),
78
- tasks: makeClient(TaskService),
79
- boards: makeClient(BoardService),
80
- boardColumns: makeClient(BoardColumnService),
81
- cycles: makeClient(CycleService),
82
- docSpaces: makeClient(DocSpaceService),
83
- docPages: makeClient(DocPageService),
84
- taskTypes: makeClient(TaskTypeService),
85
- taskPriorities: makeClient(TaskPriorityService),
86
- users: makeClient(UserService),
87
- vcsIntegrations: makeClient(VCSIntegrationService),
88
- };
89
- }
84
+ export type ApiClients = {
85
+ projects: Client<typeof ProjectService>;
86
+ tasks: Client<typeof TaskService>;
87
+ boards: Client<typeof BoardService>;
88
+ boardColumns: Client<typeof BoardColumnService>;
89
+ cycles: Client<typeof CycleService>;
90
+ docSpaces: Client<typeof DocSpaceService>;
91
+ docPages: Client<typeof DocPageService>;
92
+ taskTypes: Client<typeof TaskTypeService>;
93
+ taskPriorities: Client<typeof TaskPriorityService>;
94
+ users: Client<typeof UserService>;
95
+ vcsIntegrations: Client<typeof VCSIntegrationService>;
96
+ };
90
97
 
91
- export type ApiClients = ReturnType<typeof getClients>;
98
+ export function getClients(): ApiClients {
99
+ if (!cachedClients) {
100
+ cachedClients = {
101
+ projects: makeClient(ProjectService),
102
+ tasks: makeClient(TaskService),
103
+ boards: makeClient(BoardService),
104
+ boardColumns: makeClient(BoardColumnService),
105
+ cycles: makeClient(CycleService),
106
+ docSpaces: makeClient(DocSpaceService),
107
+ docPages: makeClient(DocPageService),
108
+ taskTypes: makeClient(TaskTypeService),
109
+ taskPriorities: makeClient(TaskPriorityService),
110
+ users: makeClient(UserService),
111
+ vcsIntegrations: makeClient(VCSIntegrationService),
112
+ };
113
+ }
114
+ return cachedClients;
115
+ }
@@ -0,0 +1,6 @@
1
+ import { startHttpServer } from "./server-http.js";
2
+
3
+ startHttpServer().catch((error) => {
4
+ console.error("Fatal error:", error);
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,120 @@
1
+ import express from "express";
2
+ import cors from "cors";
3
+ import { randomUUID } from "node:crypto";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
7
+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
8
+ import { OpseeOAuthProvider } from "./auth/oauth-provider.js";
9
+ import { tokenContext } from "./auth/token-context.js";
10
+ import { createServer } from "./server.js";
11
+
12
+ const SUCCESS_HTML = `<!DOCTYPE html>
13
+ <html>
14
+ <head><title>Opsee MCP - Connected</title></head>
15
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f8f9fa;">
16
+ <div style="text-align: center; padding: 2rem;">
17
+ <h1 style="color: #10b981;">Connected!</h1>
18
+ <p>Your Opsee account is now linked. You can close this window.</p>
19
+ </div>
20
+ </body>
21
+ </html>`;
22
+
23
+ export async function startHttpServer(): Promise<void> {
24
+ const port = parseInt(process.env.MCP_PORT || "3100", 10);
25
+ const host = process.env.MCP_HOST || "0.0.0.0";
26
+ const serverUrl =
27
+ process.env.MCP_SERVER_URL || `http://localhost:${port}`;
28
+ const backendUrl =
29
+ process.env.OPSEE_API_URL || "https://grpc.api.opsee.ai";
30
+
31
+ const provider = new OpseeOAuthProvider(serverUrl);
32
+ const issuerUrl = new URL(serverUrl);
33
+
34
+ // Periodically clean up expired auth entries
35
+ setInterval(() => provider.cleanup(), 60_000);
36
+
37
+ const app = express();
38
+ // Trust proxy headers (X-Forwarded-For) from nginx ingress
39
+ app.set("trust proxy", 1);
40
+ app.use(cors());
41
+ app.use(express.json());
42
+
43
+ // --- Health check ---
44
+ app.get("/health", (_req, res) => {
45
+ res.json({ status: "ok" });
46
+ });
47
+
48
+ // --- OAuth 2.0 endpoints (authorize, token, register, metadata) ---
49
+ app.use(
50
+ mcpAuthRouter({
51
+ provider,
52
+ issuerUrl,
53
+ serviceDocumentationUrl: new URL(
54
+ "https://github.com/ArtisanCloud/opsee",
55
+ ),
56
+ }),
57
+ );
58
+
59
+ // --- Custom OAuth callback (Opsee login redirects here) ---
60
+ app.get("/oauth/callback", (req, res) => {
61
+ const { pending, token, userId, companyId, expiresAt } = req.query as Record<string, string>;
62
+
63
+ if (!pending || !token) {
64
+ res.status(400).send("Missing pending or token parameter");
65
+ return;
66
+ }
67
+
68
+ const result = provider.handleCallback(
69
+ pending,
70
+ token,
71
+ userId || "",
72
+ companyId || "",
73
+ expiresAt || "",
74
+ );
75
+
76
+ if ("error" in result) {
77
+ res.status(400).send(result.error);
78
+ return;
79
+ }
80
+
81
+ // Redirect to Claude's redirect_uri with the auth code
82
+ res.redirect(result.redirectUri);
83
+ });
84
+
85
+ // --- Bearer auth middleware for MCP endpoints ---
86
+ const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(new URL(serverUrl));
87
+ const authMiddleware = requireBearerAuth({
88
+ verifier: provider,
89
+ resourceMetadataUrl,
90
+ });
91
+
92
+ // --- MCP Streamable HTTP transport (stateless mode) ---
93
+ // Each request creates a fresh transport+server — no session persistence needed.
94
+ // This works reliably behind proxies/load balancers and with Claude Code's HTTP transport.
95
+
96
+ app.all("/mcp", authMiddleware, async (req, res) => {
97
+ // Extract the verified JWT from the auth middleware
98
+ const accessToken = req.auth?.token;
99
+
100
+ // Wrap the MCP handling in the token context so API calls use this user's JWT
101
+ await tokenContext.run({ token: accessToken || "" }, async () => {
102
+ const transport = new StreamableHTTPServerTransport({
103
+ sessionIdGenerator: undefined, // stateless — no session IDs
104
+ });
105
+
106
+ const mcpServer = createServer();
107
+ await mcpServer.connect(transport);
108
+ await transport.handleRequest(req, res, req.body);
109
+ await transport.close();
110
+ await mcpServer.close();
111
+ });
112
+ });
113
+
114
+ app.listen(port, host, () => {
115
+ console.log(`Opsee MCP server (remote) listening on ${host}:${port}`);
116
+ console.log(` MCP endpoint: ${serverUrl}/mcp`);
117
+ console.log(` OAuth authorize: ${serverUrl}/authorize`);
118
+ console.log(` Backend: ${backendUrl}`);
119
+ });
120
+ }
package/src/server.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { getClients } from "./client/api.js";
2
+ import { getClients, type ApiClients } from "./client/api.js";
3
3
  import { registerUserTools } from "./tools/user.js";
4
4
  import { registerProjectTools } from "./tools/projects.js";
5
5
  import { registerTaskTools } from "./tools/tasks.js";
@@ -8,19 +8,21 @@ import { registerCycleTools } from "./tools/cycles.js";
8
8
  import { registerDocTools } from "./tools/docs.js";
9
9
  import { registerRepositoryTools } from "./tools/repositories.js";
10
10
 
11
- export function createServer(): McpServer {
11
+ export function createServer(clientFactory?: () => ApiClients): McpServer {
12
+ const factory = clientFactory ?? getClients;
13
+
12
14
  const server = new McpServer({
13
15
  name: "opsee",
14
16
  version: "0.1.0",
15
17
  });
16
18
 
17
- registerUserTools(server, getClients);
18
- registerProjectTools(server, getClients);
19
- registerTaskTools(server, getClients);
20
- registerTaskMetadataTools(server, getClients);
21
- registerCycleTools(server, getClients);
22
- registerDocTools(server, getClients);
23
- registerRepositoryTools(server, getClients);
19
+ registerUserTools(server, factory);
20
+ registerProjectTools(server, factory);
21
+ registerTaskTools(server, factory);
22
+ registerTaskMetadataTools(server, factory);
23
+ registerCycleTools(server, factory);
24
+ registerDocTools(server, factory);
25
+ registerRepositoryTools(server, factory);
24
26
 
25
27
  return server;
26
28
  }
@@ -12,6 +12,7 @@ export function registerCycleTools(
12
12
  "opsee_list_cycles",
13
13
  "List cycles/sprints in an Opsee project.",
14
14
  { projectId: z.number().describe("The project ID") },
15
+ { readOnlyHint: true, destructiveHint: false },
15
16
  async ({ projectId }) => {
16
17
  try {
17
18
  const clients = getClients();
@@ -27,6 +28,7 @@ export function registerCycleTools(
27
28
  "opsee_get_cycle",
28
29
  "Get details of a specific cycle/sprint by ID.",
29
30
  { cycleId: z.number().describe("The cycle ID") },
31
+ { readOnlyHint: true, destructiveHint: false },
30
32
  async ({ cycleId }) => {
31
33
  try {
32
34
  const clients = getClients();
@@ -49,6 +51,7 @@ export function registerCycleTools(
49
51
  endDate: z.string().describe("End date (ISO 8601, e.g. 2026-04-08)"),
50
52
  description: z.string().optional().describe("Cycle description"),
51
53
  },
54
+ { readOnlyHint: false, destructiveHint: false },
52
55
  async ({ projectId, name, startDate, endDate, description }) => {
53
56
  try {
54
57
  const clients = getClients();
package/src/tools/docs.ts CHANGED
@@ -29,6 +29,7 @@ export function registerDocTools(
29
29
  "opsee_list_doc_spaces",
30
30
  "List documentation spaces in an Opsee project.",
31
31
  { projectId: z.number().describe("The project ID") },
32
+ { readOnlyHint: true, destructiveHint: false },
32
33
  async ({ projectId }) => {
33
34
  try {
34
35
  const clients = getClients();
@@ -55,6 +56,7 @@ export function registerDocTools(
55
56
  "opsee_list_doc_pages",
56
57
  "List documentation pages in a doc space.",
57
58
  { docSpaceId: z.number().describe("The doc space ID") },
59
+ { readOnlyHint: true, destructiveHint: false },
58
60
  async ({ docSpaceId }) => {
59
61
  try {
60
62
  const clients = getClients();
@@ -81,6 +83,7 @@ export function registerDocTools(
81
83
  "opsee_get_doc_page",
82
84
  "Read a documentation page's content by ID.",
83
85
  { pageId: z.number().describe("The doc page ID") },
86
+ { readOnlyHint: true, destructiveHint: false },
84
87
  async ({ pageId }) => {
85
88
  try {
86
89
  const clients = getClients();
@@ -103,6 +106,7 @@ export function registerDocTools(
103
106
  content: z.string().describe("Page content (text or JSON)"),
104
107
  parentPageId: z.number().optional().describe("Parent page ID for nested pages"),
105
108
  },
109
+ { readOnlyHint: false, destructiveHint: false },
106
110
  async ({ projectId, title, content, parentPageId }) => {
107
111
  try {
108
112
  const clients = getClients();
@@ -11,6 +11,7 @@ export function registerProjectTools(
11
11
  "opsee_list_projects",
12
12
  "List all Opsee projects the authenticated user has access to.",
13
13
  {},
14
+ { readOnlyHint: true, destructiveHint: false },
14
15
  async () => {
15
16
  try {
16
17
  const clients = getClients();
@@ -26,6 +27,7 @@ export function registerProjectTools(
26
27
  "opsee_get_project",
27
28
  "Get details of a specific Opsee project by ID.",
28
29
  { projectId: z.number().describe("The project ID") },
30
+ { readOnlyHint: true, destructiveHint: false },
29
31
  async ({ projectId }) => {
30
32
  try {
31
33
  const clients = getClients();
@@ -11,6 +11,7 @@ export function registerRepositoryTools(
11
11
  "opsee_list_repositories",
12
12
  "List connected VCS repositories (GitHub/GitLab) for an Opsee project.",
13
13
  { projectId: z.number().describe("The project ID") },
14
+ { readOnlyHint: true, destructiveHint: false },
14
15
  async ({ projectId }) => {
15
16
  try {
16
17
  const clients = getClients();
@@ -11,6 +11,7 @@ export function registerTaskMetadataTools(
11
11
  "opsee_list_task_types",
12
12
  "Get available task types (Bug, Feature, etc.) for an Opsee project. Use these IDs when creating or updating tasks.",
13
13
  { projectId: z.number().describe("The project ID") },
14
+ { readOnlyHint: true, destructiveHint: false },
14
15
  async ({ projectId }) => {
15
16
  try {
16
17
  const clients = getClients();
@@ -32,6 +33,7 @@ export function registerTaskMetadataTools(
32
33
  "opsee_list_task_priorities",
33
34
  "Get priority levels (Critical, High, Medium, Low, etc.) for an Opsee project. Use these IDs when creating or updating tasks.",
34
35
  { projectId: z.number().describe("The project ID") },
36
+ { readOnlyHint: true, destructiveHint: false },
35
37
  async ({ projectId }) => {
36
38
  try {
37
39
  const clients = getClients();
@@ -53,6 +55,7 @@ export function registerTaskMetadataTools(
53
55
  "opsee_list_boards",
54
56
  "List Kanban boards for an Opsee project. Use the board ID to list board columns.",
55
57
  { projectId: z.number().describe("The project ID") },
58
+ { readOnlyHint: true, destructiveHint: false },
56
59
  async ({ projectId }) => {
57
60
  try {
58
61
  const clients = getClients();
@@ -74,6 +77,7 @@ export function registerTaskMetadataTools(
74
77
  "opsee_list_board_columns",
75
78
  "Get board columns/statuses (To Do, In Progress, Done, etc.) for a board. Use these IDs when creating or updating tasks.",
76
79
  { boardId: z.number().describe("The board ID") },
80
+ { readOnlyHint: true, destructiveHint: false },
77
81
  async ({ boardId }) => {
78
82
  try {
79
83
  const clients = getClients();
@@ -25,6 +25,7 @@ export function registerTaskTools(
25
25
  page: z.number().optional().describe("Page number (default: 1)"),
26
26
  pageSize: z.number().optional().describe("Items per page (default: 50)"),
27
27
  },
28
+ { readOnlyHint: true, destructiveHint: false },
28
29
  async ({ projectId, columnId, assigneeId, cycleId, page, pageSize }) => {
29
30
  try {
30
31
  const clients = getClients();
@@ -57,6 +58,7 @@ export function registerTaskTools(
57
58
  "opsee_get_task",
58
59
  "Get full details of a specific task by ID.",
59
60
  { taskId: z.number().describe("The task ID") },
61
+ { readOnlyHint: true, destructiveHint: false },
60
62
  async ({ taskId }) => {
61
63
  try {
62
64
  const clients = getClients();
@@ -82,6 +84,7 @@ export function registerTaskTools(
82
84
  assigneeId: z.number().optional().describe("Assigned user ID"),
83
85
  cycleId: z.number().optional().describe("Cycle/sprint ID"),
84
86
  },
87
+ { readOnlyHint: false, destructiveHint: false },
85
88
  async ({ projectId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId }) => {
86
89
  try {
87
90
  const clients = getClients();
@@ -178,6 +181,7 @@ export function registerTaskTools(
178
181
  assigneeId: z.number().optional().describe("New assigned user ID"),
179
182
  cycleId: z.number().optional().describe("New cycle/sprint ID"),
180
183
  },
184
+ { readOnlyHint: false, destructiveHint: false },
181
185
  async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId }) => {
182
186
  try {
183
187
  const clients = getClients();
package/src/tools/user.ts CHANGED
@@ -10,6 +10,7 @@ export function registerUserTools(
10
10
  "opsee_get_me",
11
11
  "Get the currently authenticated Opsee user's profile (name, email, role, company).",
12
12
  {},
13
+ { readOnlyHint: true, destructiveHint: false },
13
14
  async () => {
14
15
  try {
15
16
  const clients = getClients();
@@ -126,16 +126,16 @@ export function formatDocPageList(pages: DocPage[]): string {
126
126
 
127
127
  export function formatError(error: unknown): string {
128
128
  if (error instanceof Error) {
129
- if (error.message.includes("401") || error.message.includes("Unauthenticated")) {
130
- return "Not authenticated. Run `npx opsee-mcp login` to connect your Opsee account.";
129
+ const msg = error.message;
130
+ const code = (error as any).code;
131
+ if (msg.includes("401") || msg.includes("Unauthenticated") || code === "unauthenticated") {
132
+ return "Not authenticated. Run `npx @opsee/mcp-server login` to connect your Opsee account.";
131
133
  }
132
- if (error.message.includes("not found") || error.message.includes("404")) {
133
- return `Not found. ${error.message}`;
134
+ if (msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
135
+ return `Could not reach Opsee API. Check your connection and OPSEE_API_URL. (${msg})`;
134
136
  }
135
- if (error.message.includes("ECONNREFUSED") || error.message.includes("ENOTFOUND")) {
136
- return "Could not reach Opsee API. Check your connection and OPSEE_API_URL.";
137
- }
138
- return error.message;
137
+ // Show full error details for debugging
138
+ return code ? `[${code}] ${msg}` : msg;
139
139
  }
140
140
  return String(error);
141
141
  }