@opsee/mcp-server 0.1.7 → 0.2.0

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 CHANGED
@@ -55,9 +55,73 @@ Ask Claude things like:
55
55
  | `opsee_create_doc_page` | Create a new doc page |
56
56
  | `opsee_list_repositories` | List connected repositories |
57
57
 
58
- ## Cursor / Other MCP Clients
58
+ ## Remote MCP Server (Recommended)
59
59
 
60
- Add this to your MCP client config:
60
+ The remote server runs in the cloud with OAuth 2.0 authentication — no local setup or tokens needed.
61
+
62
+ ### Environments
63
+
64
+ | Environment | URL | Opsee Instance |
65
+ |-------------|-----|----------------|
66
+ | **Production** | `https://mcp.api.opsee.ai/mcp` | `https://opsee.ai` |
67
+ | **Development** | `https://opsee-development.mcp.cls.codilas.link/mcp` | `https://opsee-development.cdn.codilas.link` |
68
+
69
+ ### Claude Code
70
+
71
+ ```bash
72
+ # Production
73
+ claude mcp add opsee --transport http https://mcp.api.opsee.ai/mcp
74
+
75
+ # Development
76
+ claude mcp add opsee-dev --transport http https://opsee-development.mcp.cls.codilas.link/mcp
77
+ ```
78
+
79
+ Restart Claude Code. On first use, your browser opens for Opsee login. After authenticating, all tools are available immediately.
80
+
81
+ You can have both environments connected at the same time — use tools prefixed with the server name (e.g. `opsee` for production, `opsee-dev` for development).
82
+
83
+ ### Cursor / Other MCP Clients
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "opsee": {
89
+ "type": "http",
90
+ "url": "https://mcp.api.opsee.ai/mcp"
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ Replace the URL with the development endpoint if targeting that environment.
97
+
98
+ Your MCP client handles the OAuth flow automatically — just sign in when prompted.
99
+
100
+ ### How it works
101
+
102
+ 1. Client connects to the MCP endpoint (e.g. `https://mcp.api.opsee.ai/mcp`)
103
+ 2. Server returns 401 with OAuth discovery metadata
104
+ 3. Client registers itself and opens the Opsee login page in your browser
105
+ 4. After login, the client exchanges the auth code for an access token
106
+ 5. All subsequent tool calls use the token automatically
107
+
108
+ ## Local MCP Server (Alternative)
109
+
110
+ Run the MCP server locally via npx. Requires a one-time login to get a token.
111
+
112
+ ### 1. Authenticate
113
+
114
+ ```bash
115
+ npx @opsee/mcp-server login
116
+ ```
117
+
118
+ ### 2. Add to Claude Code
119
+
120
+ ```bash
121
+ claude mcp add opsee -- npx @opsee/mcp-server serve
122
+ ```
123
+
124
+ ### Cursor / Other MCP Clients (Local)
61
125
 
62
126
  ```json
63
127
  {
@@ -70,7 +134,7 @@ Add this to your MCP client config:
70
134
  }
71
135
  ```
72
136
 
73
- ## CI / Headless Environments
137
+ ### CI / Headless Environments
74
138
 
75
139
  Skip the browser login by setting a token directly:
76
140
 
@@ -88,21 +152,56 @@ Skip the browser login by setting a token directly:
88
152
  }
89
153
  ```
90
154
 
91
- ## Environment Variables
155
+ ## Self-Hosting the Remote Server
156
+
157
+ Deploy the MCP server in your own infrastructure using Docker.
158
+
159
+ ### Docker
160
+
161
+ ```bash
162
+ docker build -f mcp/Dockerfile . -t opsee-mcp-server
163
+
164
+ docker run -p 3100:3100 \
165
+ -e MCP_SERVER_URL=https://mcp.yourdomain.com \
166
+ -e OPSEE_API_URL=http://your-backend:8080 \
167
+ -e OPSEE_APP_URL=https://your-frontend.com \
168
+ opsee-mcp-server
169
+ ```
170
+
171
+ ### Kubernetes
172
+
173
+ The server includes Kustomize configs following the same pattern as the backend:
174
+
175
+ ```bash
176
+ # Development
177
+ kubectl apply -k mcp/k8/environments/development/ -n opsee-development
178
+
179
+ # Production
180
+ kubectl apply -k mcp/k8/environments/production/ -n opsee-production
181
+ ```
182
+
183
+ ### Environment Variables
92
184
 
93
185
  | Variable | Description | Default |
94
186
  |----------|-------------|---------|
95
- | `OPSEE_API_URL` | Backend gRPC API URL | `https://grpc.api.opsee.ai` |
96
- | `OPSEE_APP_URL` | Frontend URL (for login) | `https://opsee.ai` |
97
- | `OPSEE_API_TOKEN` | Direct JWT token (bypasses login) | |
187
+ | `MCP_SERVER_URL` | Public URL of this MCP server (for OAuth redirects) | `http://localhost:3100` |
188
+ | `MCP_HOST` | Bind host | `0.0.0.0` |
189
+ | `MCP_PORT` | Bind port | `3100` |
190
+ | `OPSEE_API_URL` | Backend Connect RPC URL | `https://grpc.api.opsee.ai` |
191
+ | `OPSEE_APP_URL` | Frontend URL (OAuth login page) | `https://opsee.ai` |
192
+ | `OPSEE_API_TOKEN` | Direct JWT token for local mode | — |
98
193
  | `OPSEE_CREDENTIALS_PATH` | Override credential file path | `~/.opsee/credentials.json` |
99
194
 
100
195
  ## Local Development
101
196
 
102
197
  ```bash
103
- # With local backend + frontend running:
104
- OPSEE_APP_URL=http://localhost:5173 OPSEE_API_URL=http://localhost:9990 npx @opsee/mcp-server login
198
+ # Start backend + frontend locally, then:
105
199
 
106
- # Then add to Claude Code:
200
+ # Local mode (stdio):
201
+ OPSEE_APP_URL=http://localhost:5173 OPSEE_API_URL=http://localhost:9990 npx @opsee/mcp-server login
107
202
  claude mcp add opsee -e OPSEE_API_URL=http://localhost:9990 -- npx @opsee/mcp-server serve
203
+
204
+ # Remote mode (HTTP):
205
+ MCP_SERVER_URL=http://localhost:3100 OPSEE_API_URL=http://localhost:9990 OPSEE_APP_URL=http://localhost:5173 npx @opsee/mcp-server serve-remote
206
+ claude mcp add opsee-local --transport http http://localhost:3100/mcp
108
207
  ```
package/bin/opsee-mcp.js CHANGED
@@ -13,9 +13,15 @@ const tsxPkg = dirname(require.resolve("tsx/package.json"));
13
13
  const tsx = resolve(tsxPkg, "dist", "cli.mjs");
14
14
 
15
15
  const command = process.argv[2];
16
- const script = command === "login"
17
- ? resolve(__dirname, "..", "src", "auth", "login-cli.ts")
18
- : resolve(__dirname, "..", "src", "index.ts");
16
+ let script;
17
+ if (command === "login") {
18
+ script = resolve(__dirname, "..", "src", "auth", "login-cli.ts");
19
+ } else if (command === "serve-remote") {
20
+ script = resolve(__dirname, "..", "src", "index-http.ts");
21
+ } else {
22
+ // Default: stdio mode (local)
23
+ script = resolve(__dirname, "..", "src", "index.ts");
24
+ }
19
25
 
20
26
  const child = spawn(process.execPath, [tsx, script], { stdio: "inherit", env: process.env });
21
27
  child.on("exit", (code) => process.exit(code || 0));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsee/mcp-server",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,11 +19,15 @@
19
19
  "@connectrpc/connect": "^2.0.2",
20
20
  "@connectrpc/connect-node": "^2.0.2",
21
21
  "@modelcontextprotocol/sdk": "^1.12.1",
22
+ "cors": "^2.8.6",
23
+ "express": "^5.2.1",
22
24
  "open": "^10.1.0",
23
25
  "tsx": "^4.21.0",
24
26
  "zod": "^4.3.6"
25
27
  },
26
28
  "devDependencies": {
29
+ "@types/cors": "^2.8.19",
30
+ "@types/express": "^5.0.6",
27
31
  "@types/node": "^22.13.0",
28
32
  "typescript": "^5.7.0",
29
33
  "vitest": "^4.1.0"
@@ -0,0 +1,266 @@
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
+ const known = this.clients.get(clientId);
43
+ if (known) return known;
44
+
45
+ // Accept any client ID — required for stateless multi-replica deployments
46
+ // where registration may have happened on a different pod.
47
+ // The OAuth flow itself (PKCE + auth code) provides the security.
48
+ return {
49
+ client_id: clientId,
50
+ redirect_uris: [],
51
+ token_endpoint_auth_method: "none",
52
+ grant_types: ["authorization_code"],
53
+ response_types: ["code"],
54
+ } as unknown as OAuthClientInformationFull;
55
+ }
56
+
57
+ registerClient(
58
+ client: Omit<OAuthClientInformationFull, "client_id" | "client_id_issued_at">,
59
+ ): OAuthClientInformationFull {
60
+ const clientId = randomUUID();
61
+ const clientSecret = randomBytes(32).toString("hex");
62
+ const registered: OAuthClientInformationFull = {
63
+ ...client,
64
+ client_id: clientId,
65
+ client_secret: clientSecret,
66
+ client_id_issued_at: Math.floor(Date.now() / 1000),
67
+ };
68
+ this.clients.set(clientId, registered);
69
+ return registered;
70
+ }
71
+ }
72
+
73
+ export class OpseeOAuthProvider implements OAuthServerProvider {
74
+ private _clientsStore = new OpseeClientStore();
75
+ private pendingAuths = new Map<string, PendingAuth>();
76
+ private authCodes = new Map<string, AuthCode>();
77
+ private revokedTokens = new Set<string>();
78
+
79
+ private serverUrl: string;
80
+ private appUrl: string;
81
+
82
+ constructor(serverUrl: string, appUrl?: string) {
83
+ this.serverUrl = serverUrl;
84
+ this.appUrl = appUrl || process.env.OPSEE_APP_URL || "https://opsee.ai";
85
+ }
86
+
87
+ get clientsStore(): OAuthRegisteredClientsStore {
88
+ return this._clientsStore;
89
+ }
90
+
91
+ /**
92
+ * Step 1: Claude calls /authorize. We redirect to Opsee's login UI.
93
+ */
94
+ async authorize(
95
+ client: OAuthClientInformationFull,
96
+ params: AuthorizationParams,
97
+ res: Response,
98
+ ): Promise<void> {
99
+ const pendingId = randomBytes(16).toString("hex");
100
+
101
+ this.pendingAuths.set(pendingId, {
102
+ clientId: client.client_id,
103
+ redirectUri: params.redirectUri,
104
+ state: params.state,
105
+ codeChallenge: params.codeChallenge,
106
+ createdAt: Date.now(),
107
+ });
108
+
109
+ // Build callback URL back to our server
110
+ const callbackUrl = `${this.serverUrl}/oauth/callback?pending=${pendingId}`;
111
+ const loginUrl = `${this.appUrl}/auth/mcp?callback=${encodeURIComponent(callbackUrl)}`;
112
+
113
+ res.redirect(loginUrl);
114
+ }
115
+
116
+ /**
117
+ * Called by SDK to get the code_challenge for PKCE validation.
118
+ */
119
+ async challengeForAuthorizationCode(
120
+ _client: OAuthClientInformationFull,
121
+ authorizationCode: string,
122
+ ): Promise<string> {
123
+ const code = this.authCodes.get(authorizationCode);
124
+ if (!code) throw new Error("Invalid authorization code");
125
+ return code.codeChallenge;
126
+ }
127
+
128
+ /**
129
+ * Step 3: Claude exchanges auth code for access token.
130
+ */
131
+ async exchangeAuthorizationCode(
132
+ _client: OAuthClientInformationFull,
133
+ authorizationCode: string,
134
+ ): Promise<OAuthTokens> {
135
+ const code = this.authCodes.get(authorizationCode);
136
+ if (!code) throw new Error("Invalid or expired authorization code");
137
+
138
+ if (Date.now() - code.createdAt > TTL_MS) {
139
+ this.authCodes.delete(authorizationCode);
140
+ throw new Error("Authorization code expired");
141
+ }
142
+
143
+ // One-time use
144
+ this.authCodes.delete(authorizationCode);
145
+
146
+ // Calculate expires_in from the JWT's expiresAt
147
+ let expiresIn: number | undefined;
148
+ if (code.expiresAt) {
149
+ const expMs = new Date(code.expiresAt).getTime() - Date.now();
150
+ expiresIn = Math.max(0, Math.floor(expMs / 1000));
151
+ }
152
+
153
+ return {
154
+ access_token: code.token,
155
+ token_type: "bearer",
156
+ expires_in: expiresIn,
157
+ };
158
+ }
159
+
160
+ async exchangeRefreshToken(): Promise<OAuthTokens> {
161
+ throw new Error("Refresh tokens are not supported. Re-authenticate to get a new token.");
162
+ }
163
+
164
+ /**
165
+ * Validate the access token (JWT from Opsee backend).
166
+ * We do lightweight validation here; the backend does full validation on API calls.
167
+ */
168
+ async verifyAccessToken(token: string): Promise<AuthInfo> {
169
+ if (this.revokedTokens.has(token)) {
170
+ throw new Error("Token has been revoked");
171
+ }
172
+
173
+ // Decode JWT payload without crypto verification
174
+ const parts = token.split(".");
175
+ if (parts.length !== 3) throw new Error("Invalid token format");
176
+
177
+ try {
178
+ const payload = JSON.parse(
179
+ Buffer.from(parts[1], "base64url").toString("utf-8"),
180
+ );
181
+
182
+ // Check expiry
183
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
184
+ throw new Error("Token expired");
185
+ }
186
+
187
+ return {
188
+ token,
189
+ clientId: "opsee",
190
+ scopes: [],
191
+ expiresAt: payload.exp,
192
+ extra: {
193
+ userId: payload.userId,
194
+ companyId: payload.companyId,
195
+ role: payload.role,
196
+ },
197
+ };
198
+ } catch (err) {
199
+ if (err instanceof Error && err.message === "Token expired") throw err;
200
+ throw new Error("Invalid token");
201
+ }
202
+ }
203
+
204
+ async revokeToken(
205
+ _client: OAuthClientInformationFull,
206
+ request: OAuthTokenRevocationRequest,
207
+ ): Promise<void> {
208
+ this.revokedTokens.add(request.token);
209
+ }
210
+
211
+ // --- Custom methods for the callback flow ---
212
+
213
+ /**
214
+ * Step 2: Opsee login redirects back to /oauth/callback.
215
+ * We generate an auth code and redirect to Claude's redirect_uri.
216
+ */
217
+ handleCallback(
218
+ pendingId: string,
219
+ token: string,
220
+ userId: string,
221
+ companyId: string,
222
+ expiresAt: string,
223
+ ): { redirectUri: string } | { error: string } {
224
+ const pending = this.pendingAuths.get(pendingId);
225
+ if (!pending) return { error: "Invalid or expired pending authorization" };
226
+
227
+ if (Date.now() - pending.createdAt > TTL_MS) {
228
+ this.pendingAuths.delete(pendingId);
229
+ return { error: "Pending authorization expired" };
230
+ }
231
+
232
+ // One-time use
233
+ this.pendingAuths.delete(pendingId);
234
+
235
+ // Generate authorization code
236
+ const authCode = randomBytes(32).toString("hex");
237
+ this.authCodes.set(authCode, {
238
+ token,
239
+ userId,
240
+ companyId,
241
+ expiresAt,
242
+ codeChallenge: pending.codeChallenge,
243
+ redirectUri: pending.redirectUri,
244
+ clientId: pending.clientId,
245
+ createdAt: Date.now(),
246
+ });
247
+
248
+ // Build redirect URL with code and state
249
+ const url = new URL(pending.redirectUri);
250
+ url.searchParams.set("code", authCode);
251
+ if (pending.state) url.searchParams.set("state", pending.state);
252
+
253
+ return { redirectUri: url.toString() };
254
+ }
255
+
256
+ /** Periodic cleanup of expired entries */
257
+ cleanup(): void {
258
+ const now = Date.now();
259
+ for (const [id, entry] of this.pendingAuths) {
260
+ if (now - entry.createdAt > TTL_MS) this.pendingAuths.delete(id);
261
+ }
262
+ for (const [code, entry] of this.authCodes) {
263
+ if (now - entry.createdAt > TTL_MS) this.authCodes.delete(code);
264
+ }
265
+ }
266
+ }
@@ -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
  }
@@ -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
+ }
@@ -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();