@opsee/mcp-server 0.9.59 → 0.10.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.
Files changed (38) hide show
  1. package/gen/api/v1/billing_pb.d.ts +0 -40
  2. package/gen/api/v1/billing_pb.js +22 -36
  3. package/gen/api/v1/developer_metrics_pb.d.ts +9 -3
  4. package/gen/api/v1/developer_metrics_pb.js +2 -1
  5. package/gen/api/v1/models_pb.d.ts +116 -5
  6. package/gen/api/v1/models_pb.js +33 -26
  7. package/gen/api/v1/project_pb.d.ts +18 -13
  8. package/gen/api/v1/project_pb.js +1 -1
  9. package/gen/api/v1/vcs_connection_pb.d.ts +34 -3
  10. package/gen/api/v1/vcs_connection_pb.js +28 -20
  11. package/gen/api/v1/vcs_integration_pb.d.ts +11 -1
  12. package/gen/api/v1/vcs_integration_pb.js +2 -1
  13. package/gen/fk/foreign_key_pb.d.ts +22 -0
  14. package/gen/fk/foreign_key_pb.js +24 -0
  15. package/package.json +11 -11
  16. package/src/auth/kv-store.ts +12 -33
  17. package/src/auth/manager.ts +1 -6
  18. package/src/auth/oauth-provider.ts +32 -104
  19. package/src/auth/renew.ts +5 -15
  20. package/src/client/api.ts +3 -5
  21. package/src/server-http.ts +14 -30
  22. package/src/tools/acceptance-criteria.ts +4 -14
  23. package/src/tools/descriptions.ts +2 -7
  24. package/src/tools/doc-links.ts +4 -9
  25. package/src/tools/doc-spaces.ts +1 -1
  26. package/src/tools/docs.ts +8 -19
  27. package/src/tools/notifications.ts +2 -6
  28. package/src/tools/tasks-bulk.ts +3 -8
  29. package/src/tools/tasks-read-write.ts +1 -8
  30. package/src/utils/format/blocknote.ts +8 -23
  31. package/src/utils/tool-schema.ts +7 -29
  32. package/README.md +0 -304
  33. package/gen/api/v1/deployment_pb.d.ts +0 -277
  34. package/gen/api/v1/deployment_pb.js +0 -89
  35. package/gen/api/v1/environment_pb.d.ts +0 -277
  36. package/gen/api/v1/environment_pb.js +0 -89
  37. package/gen/api/v1/test_run_pb.d.ts +0 -277
  38. package/gen/api/v1/test_run_pb.js +0 -89
@@ -18,8 +18,6 @@ import {
18
18
  import type { KVStore } from "./kv-store.js";
19
19
  import { renewAccessToken, type TokenRenewer } from "./renew.js";
20
20
 
21
- // --- Stored value shapes ---
22
-
23
21
  interface PendingAuth {
24
22
  clientId: string;
25
23
  redirectUri: string;
@@ -37,17 +35,15 @@ interface AuthCode {
37
35
  clientId: string;
38
36
  }
39
37
 
40
- /** Server-side record behind an opaque refresh token. */
38
+ /** RefreshRecord is the server-side record behind an opaque refresh token. */
41
39
  interface RefreshRecord {
42
- /** The last access token issued to this session. Used to mint the next one. */
40
+ /** Last access token issued to this session; used to mint the next one. */
43
41
  accessToken: string;
44
42
  userId: string;
45
43
  companyId: string;
46
44
  clientId: string;
47
45
  }
48
46
 
49
- // --- Redis key namespacing + TTLs ---
50
-
51
47
  const CLIENT_KEY = (id: string) => `oauth:client:${id}`;
52
48
  const PENDING_KEY = (id: string) => `oauth:pending:${id}`;
53
49
  const CODE_KEY = (code: string) => `oauth:code:${code}`;
@@ -56,16 +52,11 @@ const REFRESH_KEY = (token: string) => `oauth:refresh:${token}`;
56
52
 
57
53
  const FLOW_TTL_SECONDS = 10 * 60; // pending auths + auth codes: 10 minutes
58
54
  const REVOKED_FALLBACK_TTL_SECONDS = 24 * 60 * 60; // if token expiry is unknown
59
- // Refresh tokens outlive the access token they renew, so a client that has been
60
- // idle for longer than one access-token lifetime can still come back without
61
- // sending the user through an interactive login.
55
+ // Refresh tokens outlive the access token they renew, so an idle client returns without a login.
62
56
  const REFRESH_TTL_SECONDS = 180 * 24 * 60 * 60; // 180 days
63
57
 
64
- /**
65
- * Converts an RFC3339 expiry into the `expires_in` seconds an OAuth token
66
- * response carries. Returns undefined when the expiry is absent or unparseable
67
- * so the field is simply omitted rather than reported as an immediate expiry.
68
- */
58
+ /** expiresInFrom converts an RFC3339 expiry to `expires_in` seconds, or undefined so the field is
59
+ * omitted rather than read as an immediate expiry. */
69
60
  function expiresInFrom(expiresAt: string): number | undefined {
70
61
  if (!expiresAt) return undefined;
71
62
  const expMs = new Date(expiresAt).getTime();
@@ -81,12 +72,8 @@ export class OpseeClientStore implements OAuthRegisteredClientsStore {
81
72
  ): Promise<OAuthClientInformationFull | undefined> {
82
73
  const raw = await this.store.get(CLIENT_KEY(clientId));
83
74
  if (!raw) {
84
- // Unknown client. With a shared Redis store this means the client was
85
- // never registered (or its registration expired) — return undefined so
86
- // the SDK responds with invalid_client and the MCP client re-registers
87
- // via Dynamic Client Registration. (Previously we returned a synthetic
88
- // client with an empty redirect_uris list, which made the SDK reject
89
- // every redirect_uri with "Unregistered redirect_uri".)
75
+ // undefined makes the SDK answer invalid_client so the client re-registers via DCR. A synthetic
76
+ // client with empty redirect_uris instead fails every redirect with "Unregistered redirect_uri".
90
77
  return undefined;
91
78
  }
92
79
  return JSON.parse(raw) as OAuthClientInformationFull;
@@ -102,19 +89,14 @@ export class OpseeClientStore implements OAuthRegisteredClientsStore {
102
89
  client_id_issued_at: Math.floor(Date.now() / 1000),
103
90
  };
104
91
 
105
- // Only confidential clients get a secret. A public client registers with
106
- // token_endpoint_auth_method "none" and authenticates via PKCE alone. If we
107
- // minted a secret for it anyway, the SDK's token endpoint would then *require*
108
- // that secret (see authenticateClient) and reject the PKCE-only token request
109
- // with invalid_client — breaking every public client (e.g. Claude).
92
+ // Only confidential clients get a secret: minting one for a PKCE-only client makes the SDK's token
93
+ // endpoint require it and reject the request with invalid_client.
110
94
  if (client.token_endpoint_auth_method !== "none") {
111
95
  registered.client_secret = randomBytes(32).toString("hex");
112
96
  }
113
97
 
114
- // Persist without a TTL. Clients cache their Dynamic Client Registration
115
- // indefinitely and the DCR response advertises no expiry, so a server-side
116
- // TTL guarantees that any client idle past the TTL comes back with a
117
- // client_id we no longer recognise and gets invalid_client at /authorize.
98
+ // No TTL: DCR advertises no expiry and clients cache it forever, so an expiring record would
99
+ // return as an unrecognised client_id at /authorize.
118
100
  await this.store.set(CLIENT_KEY(clientId), JSON.stringify(registered));
119
101
  return registered;
120
102
  }
@@ -144,9 +126,7 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
144
126
  return this._clientsStore;
145
127
  }
146
128
 
147
- /**
148
- * Step 1: Claude calls /authorize. We redirect to Opsee's login UI.
149
- */
129
+ /** authorize: step 1, redirect the client to Opsee's login UI. */
150
130
  async authorize(
151
131
  client: OAuthClientInformationFull,
152
132
  params: AuthorizationParams,
@@ -166,16 +146,13 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
166
146
  FLOW_TTL_SECONDS,
167
147
  );
168
148
 
169
- // Build callback URL back to our server
170
149
  const callbackUrl = `${this.serverUrl}/oauth/callback?pending=${pendingId}`;
171
150
  const loginUrl = `${this.appUrl}/auth/mcp?callback=${encodeURIComponent(callbackUrl)}`;
172
151
 
173
152
  res.redirect(loginUrl);
174
153
  }
175
154
 
176
- /**
177
- * Called by SDK to get the code_challenge for PKCE validation.
178
- */
155
+ /** challengeForAuthorizationCode returns the stored code_challenge for PKCE validation. */
179
156
  async challengeForAuthorizationCode(
180
157
  _client: OAuthClientInformationFull,
181
158
  authorizationCode: string,
@@ -185,9 +162,7 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
185
162
  return (JSON.parse(raw) as AuthCode).codeChallenge;
186
163
  }
187
164
 
188
- /**
189
- * Step 3: Claude exchanges auth code for access token.
190
- */
165
+ /** exchangeAuthorizationCode: step 3, trade the auth code for an access token. */
191
166
  async exchangeAuthorizationCode(
192
167
  _client: OAuthClientInformationFull,
193
168
  authorizationCode: string,
@@ -195,8 +170,7 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
195
170
  const raw = await this.store.get(CODE_KEY(authorizationCode));
196
171
  if (!raw) throw new Error("Invalid or expired authorization code");
197
172
 
198
- // One-time use
199
- await this.store.del(CODE_KEY(authorizationCode));
173
+ await this.store.del(CODE_KEY(authorizationCode)); // one-time use
200
174
 
201
175
  const code = JSON.parse(raw) as AuthCode;
202
176
 
@@ -204,9 +178,7 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
204
178
  access_token: code.token,
205
179
  token_type: "bearer",
206
180
  expires_in: expiresInFrom(code.expiresAt),
207
- // Without a refresh token the client has exactly one credential: any
208
- // failure to present it — expiry, revocation, a transient 5xx — can only
209
- // be recovered by sending the user through an interactive login again.
181
+ // Without a refresh token, any credential failure can only be recovered by interactive login.
210
182
  refresh_token: await this.issueRefreshToken({
211
183
  accessToken: code.token,
212
184
  userId: code.userId,
@@ -216,11 +188,7 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
216
188
  };
217
189
  }
218
190
 
219
- /**
220
- * Mints an opaque refresh token for a session and persists the record it
221
- * stands for. Refresh tokens are rotated on every use, so each call replaces
222
- * the previous handle.
223
- */
191
+ /** issueRefreshToken mints an opaque refresh token; tokens rotate on every use. */
224
192
  private async issueRefreshToken(record: RefreshRecord): Promise<string> {
225
193
  const refreshToken = randomBytes(32).toString("hex");
226
194
  await this.store.set(
@@ -231,14 +199,8 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
231
199
  return refreshToken;
232
200
  }
233
201
 
234
- /**
235
- * Trades a refresh token for a freshly minted access token.
236
- *
237
- * The stored access token is used to authenticate the renewal against the
238
- * backend, so the session rolls forward for as long as the client keeps
239
- * refreshing — the user is only sent back through the login UI once the
240
- * stored token has itself lapsed.
241
- */
202
+ /** exchangeRefreshToken trades a refresh token for a new access token, authenticating the renewal
203
+ * with the stored token so the session rolls forward until that token itself lapses. */
242
204
  async exchangeRefreshToken(
243
205
  client: OAuthClientInformationFull,
244
206
  refreshToken: string,
@@ -248,23 +210,19 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
248
210
 
249
211
  const record = JSON.parse(raw) as RefreshRecord;
250
212
 
251
- // A refresh token belongs to the client it was issued to. Without this a
252
- // leaked handle could be redeemed by any other registered client.
213
+ // Bind to the issuing client, or a leaked handle is redeemable by any registered client.
253
214
  if (record.clientId !== client.client_id) {
254
215
  throw new InvalidGrantError("Refresh token was issued to another client");
255
216
  }
256
217
 
257
- // Rotate: the presented handle is spent whether or not renewal succeeds, so
258
- // a captured refresh token cannot be replayed.
218
+ // Rotate: the handle is spent even if renewal fails, so it cannot be replayed.
259
219
  await this.store.del(REFRESH_KEY(refreshToken));
260
220
 
261
221
  let renewed;
262
222
  try {
263
223
  renewed = await this.renew(record.accessToken);
264
224
  } catch (err) {
265
- // The stored token is no longer good enough to mint another (expired,
266
- // user deactivated, backend rejecting it). Surface invalid_grant so the
267
- // client falls back to a full authorization instead of retrying forever.
225
+ // invalid_grant makes the client fall back to full authorization instead of retrying forever.
268
226
  throw new InvalidGrantError(
269
227
  `Could not renew the session: ${err instanceof Error ? err.message : String(err)}`,
270
228
  );
@@ -283,23 +241,16 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
283
241
  };
284
242
  }
285
243
 
286
- /**
287
- * Validate the access token (JWT from Opsee backend).
288
- * We do lightweight validation here; the backend does full validation on API calls.
289
- */
244
+ /** verifyAccessToken does lightweight JWT validation; the backend fully validates on each API call. */
290
245
  async verifyAccessToken(token: string): Promise<AuthInfo> {
291
246
  if (await this.isRevoked(token)) {
292
247
  throw new InvalidTokenError("Token has been revoked");
293
248
  }
294
249
 
295
- // Decode JWT payload without crypto verification
296
250
  const parts = token.split(".");
297
251
  if (parts.length !== 3) throw new InvalidTokenError("Invalid token format");
298
252
 
299
- // Scope the try to ONLY the decode/parse: a malformed payload becomes
300
- // "Invalid token". The validation checks below run outside the catch so
301
- // their errors propagate verbatim — the caller no longer depends on
302
- // string-matching error messages that a refactor or locale could change.
253
+ // Try scoped to the parse only, so validation errors below propagate verbatim.
303
254
  let payload: { exp?: unknown; userId?: unknown; companyId?: unknown; role?: unknown };
304
255
  try {
305
256
  payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
@@ -307,8 +258,7 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
307
258
  throw new InvalidTokenError("Invalid token");
308
259
  }
309
260
 
310
- // Require an expiry claim and reject expired tokens. A token that omits
311
- // exp must not be treated as valid indefinitely.
261
+ // A token omitting exp must not be valid indefinitely.
312
262
  if (typeof payload.exp !== "number") {
313
263
  throw new InvalidTokenError("Token missing expiry");
314
264
  }
@@ -329,19 +279,8 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
329
279
  };
330
280
  }
331
281
 
332
- /**
333
- * Checks the revocation denylist, treating an unreachable store as "not
334
- * revoked".
335
- *
336
- * This lookup runs on every authenticated request. Letting a store error
337
- * propagate would turn a momentary Redis hiccup — a failover, a reconnect, a
338
- * command timeout — into a failed request for every live session at once,
339
- * and with no way for a client to tell that apart from a real credential
340
- * problem, the user ends up re-authenticating by hand. Revocation is a
341
- * best-effort denylist on top of a token that is already time-bounded and
342
- * re-validated by the backend on every API call, so a read failure degrades
343
- * to the pre-revocation behaviour rather than locking everyone out.
344
- */
282
+ /** isRevoked checks the denylist, treating an unreachable store as "not revoked": this runs on every
283
+ * request, and the token is already time-bounded and re-validated by the backend. */
345
284
  private async isRevoked(token: string): Promise<boolean> {
346
285
  try {
347
286
  return (await this.store.get(REVOKED_KEY(token))) !== null;
@@ -358,13 +297,10 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
358
297
  _client: OAuthClientInformationFull,
359
298
  request: OAuthTokenRevocationRequest,
360
299
  ): Promise<void> {
361
- // A refresh handle is opaque, not a JWT — drop the record outright so it
362
- // cannot be redeemed, rather than filing it on the JWT denylist where
363
- // verifyAccessToken would never look it up.
300
+ // A refresh handle is opaque, not a JWT, so drop the record: the JWT denylist is never consulted for it.
364
301
  await this.store.del(REFRESH_KEY(request.token));
365
302
 
366
- // Keep the revocation marker only as long as the token could still be
367
- // valid. Derive that from the JWT's exp when available.
303
+ // Keep the marker only while the token could still be valid, per the JWT's exp.
368
304
  let ttl = REVOKED_FALLBACK_TTL_SECONDS;
369
305
  const parts = request.token.split(".");
370
306
  if (parts.length === 3) {
@@ -377,18 +313,13 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
377
313
  if (remaining > 0) ttl = remaining;
378
314
  }
379
315
  } catch {
380
- // Fall back to default TTL on undecodable tokens.
316
+ // Undecodable token: keep the default TTL.
381
317
  }
382
318
  }
383
319
  await this.store.set(REVOKED_KEY(request.token), "1", ttl);
384
320
  }
385
321
 
386
- // --- Custom methods for the callback flow ---
387
-
388
- /**
389
- * Step 2: Opsee login redirects back to /oauth/callback.
390
- * We generate an auth code and redirect to Claude's redirect_uri.
391
- */
322
+ /** handleCallback: step 2, mint an auth code and redirect back to the client's redirect_uri. */
392
323
  async handleCallback(
393
324
  pendingId: string,
394
325
  token: string,
@@ -399,12 +330,10 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
399
330
  const raw = await this.store.get(PENDING_KEY(pendingId));
400
331
  if (!raw) return { error: "Invalid or expired pending authorization" };
401
332
 
402
- // One-time use
403
- await this.store.del(PENDING_KEY(pendingId));
333
+ await this.store.del(PENDING_KEY(pendingId)); // one-time use
404
334
 
405
335
  const pending = JSON.parse(raw) as PendingAuth;
406
336
 
407
- // Generate authorization code
408
337
  const authCode = randomBytes(32).toString("hex");
409
338
  const code: AuthCode = {
410
339
  token,
@@ -417,7 +346,6 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
417
346
  };
418
347
  await this.store.set(CODE_KEY(authCode), JSON.stringify(code), FLOW_TTL_SECONDS);
419
348
 
420
- // Build redirect URL with code and state
421
349
  const url = new URL(pending.redirectUri);
422
350
  url.searchParams.set("code", authCode);
423
351
  if (pending.state) url.searchParams.set("state", pending.state);
package/src/auth/renew.ts CHANGED
@@ -2,9 +2,7 @@ import { createClient } from "@connectrpc/connect";
2
2
  import { createConnectTransport } from "@connectrpc/connect-node";
3
3
  import { AccountService } from "../../gen/api/v1/account_pb.js";
4
4
 
5
- /**
6
- * A freshly minted MCP access token plus the identity it belongs to.
7
- */
5
+ /** A freshly minted MCP access token and the identity it belongs to. */
8
6
  export interface RenewedToken {
9
7
  token: string;
10
8
  userId: string;
@@ -13,15 +11,9 @@ export interface RenewedToken {
13
11
  expiresAt: string;
14
12
  }
15
13
 
16
- /**
17
- * Mints a new MCP access token on behalf of the holder of `currentToken`.
18
- *
19
- * `GenerateMCPToken` authenticates the caller from the bearer token itself, so
20
- * a still-valid token is all that is needed to roll a new one — no user
21
- * interaction and no signing key on this side. That is what lets a refresh
22
- * token renew a session: we keep the last good access token server-side and
23
- * trade it for a fresh one, rather than handing the same token back forever.
24
- */
14
+ /** Mints a new access token for the holder of `currentToken`. GenerateMCPToken authenticates from the
15
+ * bearer itself, so a still-valid token rolls a new one with no user interaction and no local signing
16
+ * key, which is what lets a refresh token renew the session. */
25
17
  export type TokenRenewer = (currentToken: string) => Promise<RenewedToken>;
26
18
 
27
19
  function backendUrl(): string {
@@ -29,9 +21,7 @@ function backendUrl(): string {
29
21
  }
30
22
 
31
23
  export const renewAccessToken: TokenRenewer = async (currentToken) => {
32
- // Built per call rather than cached: the bearer differs for every renewal,
33
- // and renewals are rare enough that connection reuse is not worth the risk
34
- // of leaking one user's token onto another user's request.
24
+ // Per call, not cached: the bearer differs each time, and reuse risks leaking one user's token.
35
25
  const transport = createConnectTransport({
36
26
  baseUrl: backendUrl(),
37
27
  httpVersion: "1.1",
package/src/client/api.ts CHANGED
@@ -35,15 +35,13 @@ import { TaskDocPageService } from "../../gen/api/v1/task_doc_page_pb.js";
35
35
  import { MilestoneDocPageService } from "../../gen/api/v1/milestone_doc_page_pb.js";
36
36
 
37
37
  const authInterceptor: Interceptor = (next) => async (req) => {
38
- // Remote mode: token from AsyncLocalStorage (per-request)
39
- // Local mode: token from credentials file
38
+ // Remote mode reads the per-request token from AsyncLocalStorage; local mode from the credentials file.
40
39
  const token = getCurrentToken() || authManager.getToken();
41
40
  if (token) {
42
41
  req.header.set("Authorization", `Bearer ${token}`);
43
42
  }
44
- // Mark this as an automation (MCP) client so the backend stamps live board
45
- // events with the system actor — an agent acting under a user's token must
46
- // not be self-skipped on that user's own board. (ADR-0002 §5; Slice 5.)
43
+ // Marks an automation client so board events carry the system actor: an agent using a user's token
44
+ // must not be self-skipped on that user's board (ADR-0002 §5).
47
45
  req.header.set("X-Opsee-Client", "mcp");
48
46
  return await next(req);
49
47
  };
@@ -21,31 +21,24 @@ 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.
24
+ // Express turns a repeated query param into string[], which a blanket cast would smuggle through as a
25
+ // string. Accept a single string only; arrays and missing values become undefined.
28
26
  function singleQueryParam(value: unknown): string | undefined {
29
27
  return typeof value === "string" ? value : undefined;
30
28
  }
31
29
 
32
30
  let processHandlersRegistered = false;
33
31
 
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).
32
+ // Registered once: doing this inside startHttpServer would stack duplicate listeners across calls.
37
33
  function registerProcessGuards(): void {
38
34
  if (processHandlersRegistered) return;
39
35
  processHandlersRegistered = true;
40
36
 
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.
37
+ // Not fatal: request handlers catch their own errors, so this is only a diagnostic backstop.
43
38
  process.on("unhandledRejection", (reason) => {
44
39
  console.error("Unhandled promise rejection:", reason);
45
40
  });
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.
41
+ // Exit non-zero so the supervisor restarts a clean process instead of serving from a corrupt one.
49
42
  process.on("uncaughtException", (err) => {
50
43
  console.error("Uncaught exception:", err);
51
44
  process.exit(1);
@@ -67,17 +60,15 @@ export async function startHttpServer(): Promise<void> {
67
60
  const issuerUrl = new URL(serverUrl);
68
61
 
69
62
  const app = express();
70
- // Trust proxy headers (X-Forwarded-For) from nginx ingress
63
+ // Trust X-Forwarded-For from the nginx ingress.
71
64
  app.set("trust proxy", 1);
72
65
  app.use(cors());
73
66
  app.use(express.json());
74
67
 
75
- // --- Health check ---
76
68
  app.get("/health", (_req, res) => {
77
69
  res.json({ status: "ok" });
78
70
  });
79
71
 
80
- // --- OAuth 2.0 endpoints (authorize, token, register, metadata) ---
81
72
  app.use(
82
73
  mcpAuthRouter({
83
74
  provider,
@@ -88,7 +79,7 @@ export async function startHttpServer(): Promise<void> {
88
79
  }),
89
80
  );
90
81
 
91
- // --- Custom OAuth callback (Opsee login redirects here) ---
82
+ // OAuth callback: the Opsee login redirects here.
92
83
  app.get("/oauth/callback", async (req, res) => {
93
84
  try {
94
85
  const pending = singleQueryParam(req.query.pending);
@@ -115,11 +106,9 @@ export async function startHttpServer(): Promise<void> {
115
106
  return;
116
107
  }
117
108
 
118
- // Redirect to Claude's redirect_uri with the auth code
119
109
  res.redirect(result.redirectUri);
120
110
  } catch (err) {
121
- // Without this, a throw here (e.g. Redis down in handleCallback) leaves
122
- // the request hanging until the client times out.
111
+ // Without this, a throw (e.g. Redis down) hangs the request until the client times out.
123
112
  console.error("OAuth callback failed:", err);
124
113
  if (!res.headersSent) {
125
114
  res.status(500).send("Internal server error during OAuth callback");
@@ -127,22 +116,19 @@ export async function startHttpServer(): Promise<void> {
127
116
  }
128
117
  });
129
118
 
130
- // --- Bearer auth middleware for MCP endpoints ---
131
119
  const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(new URL(serverUrl));
132
120
  const authMiddleware = requireBearerAuth({
133
121
  verifier: provider,
134
122
  resourceMetadataUrl,
135
123
  });
136
124
 
137
- // --- MCP Streamable HTTP transport (stateless mode) ---
138
- // Each request creates a fresh transport+server - no session persistence needed.
139
- // This works reliably behind proxies/load balancers and with Claude Code's HTTP transport.
125
+ // Stateless Streamable HTTP: a fresh transport+server per request, which survives proxies and
126
+ // load balancers without session persistence.
140
127
 
141
128
  app.all("/mcp", authMiddleware, async (req, res) => {
142
- // Extract the verified JWT from the auth middleware
143
129
  const accessToken = req.auth?.token;
144
130
 
145
- // Wrap the MCP handling in the token context so API calls use this user's JWT
131
+ // Run inside the token context so API calls carry this user's JWT.
146
132
  await tokenContext.run({ token: accessToken || "" }, async () => {
147
133
  const transport = new StreamableHTTPServerTransport({
148
134
  sessionIdGenerator: undefined, // stateless - no session IDs
@@ -162,17 +148,15 @@ export async function startHttpServer(): Promise<void> {
162
148
  });
163
149
  }
164
150
  } finally {
165
- // Always tear down the per-request transport+server, even on error,
166
- // so they are not leaked on every failed request.
151
+ // Tear down even on error, or every failed request leaks a transport+server.
167
152
  await transport.close().catch(() => {});
168
153
  await mcpServer.close().catch(() => {});
169
154
  }
170
155
  });
171
156
  });
172
157
 
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.
158
+ // Error handler; needs 4 args for Express to recognise it. Turns handler throws into a 500 rather
159
+ // than a hung connection.
176
160
  app.use(
177
161
  (
178
162
  err: unknown,
@@ -3,20 +3,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import type { ApiClients } from "../client/api.js";
4
4
  import { formatError } from "../utils/format.js";
5
5
 
6
- // Structured Acceptance Criterion entity — SECONDARY path.
7
- //
8
- // The PRIMARY way to record AC on an Opsee task is markdown inside the
9
- // task description: a `## Acceptance criteria` heading followed by
10
- // `- [ ]` / `- [x]` checkbox lines. BlockNote renders those as
11
- // clickable checkbox blocks in the task detail UI. /tpm and any other
12
- // AI agent should write AC via opsee_update_task's description field
13
- // using that convention.
14
- //
15
- // These four tools operate on a SEPARATE DB entity (acceptance_criteria
16
- // table) intended for automation paths that need machine-trackable AC
17
- // outside the description — PR-to-criterion mapping, AI workflow
18
- // outputs, reporting. They do NOT surface in the current task detail
19
- // UI; do not use them as the default AC write path.
6
+ // Secondary AC path. The primary one is markdown in the task description (a `## Acceptance criteria`
7
+ // heading with `- [ ]` lines), which BlockNote renders as checkboxes; agents should write AC there via
8
+ // opsee_update_task. These tools write the separate acceptance_criteria table for automation that needs
9
+ // machine-trackable AC (PR mapping, workflow output, reporting) and do not surface in the task UI.
20
10
 
21
11
  function formatCriterion(c: {
22
12
  id: number;
@@ -1,10 +1,5 @@
1
- /**
2
- * Description strings shared by more than one tool or schema field.
3
- *
4
- * These are LLM-facing contract text: an agent picks a tool and fills its
5
- * arguments from nothing but this wording. Copies that drift teach two
6
- * different contracts for the same field, so anything repeated lives here.
7
- */
1
+ /** Description strings shared by more than one tool or schema field. This is LLM-facing contract text,
2
+ * and drifting copies teach two contracts for one field, so anything repeated lives here. */
8
3
 
9
4
  /** Task/description authoring rules. Shared by create and update. */
10
5
  export const TASK_DESCRIPTION_GUIDANCE =
@@ -6,9 +6,7 @@ import { overridePagination } from "../client/api.js";
6
6
  import { formatDocPage, formatDocPageList, formatError } from "../utils/format.js";
7
7
  import type { DocPage } from "../../gen/api/v1/models_pb.js";
8
8
 
9
- // TaskDocPage and MilestoneDocPage rows both embed the linked DocPage; pull
10
- // those out so list views can reuse formatDocPageList from format/docs.ts
11
- // instead of a parallel formatter.
9
+ // Both row types embed the linked DocPage; unwrap so list views reuse formatDocPageList.
12
10
  function toDocPages<T extends { docPage?: DocPage }>(rows: T[]): DocPage[] {
13
11
  return rows.map((r) => r.docPage).filter((p): p is DocPage => !!p);
14
12
  }
@@ -17,9 +15,8 @@ type LinkTarget =
17
15
  | { kind: "task" | "milestone"; id: number }
18
16
  | { error: { content: [{ type: "text"; text: string }]; isError: true } };
19
17
 
20
- // OPS-203: attach/detach used to be four tools (task and milestone variants of
21
- // each). They are now one pair with the target passed as a parameter, so the
22
- // exactly-one-of rule has to be enforced here rather than by the tool split.
18
+ // The task/milestone attach/detach variants became one pair with a target parameter, so the
19
+ // exactly-one-of rule is enforced here rather than by the tool split.
23
20
  function resolveTarget(taskId?: number, milestoneId?: number): LinkTarget {
24
21
  if ((taskId === undefined) === (milestoneId === undefined)) {
25
22
  return {
@@ -124,9 +121,7 @@ export function registerDocLinkTools(
124
121
  await clients.milestoneDocPages.deleteMilestoneDocPage({ id: rowId });
125
122
  }
126
123
  } catch (deleteError) {
127
- // A concurrent detach can remove the row between the list above and
128
- // this delete. The caller's intent (link gone) is already
129
- // satisfied, so report the same outcome instead of a raw NotFound.
124
+ // A concurrent detach already satisfied the caller's intent, so report success, not NotFound.
130
125
  if (deleteError instanceof ConnectError && deleteError.code === Code.NotFound) return notAttached;
131
126
  throw deleteError;
132
127
  }
@@ -127,7 +127,7 @@ export function registerDocSpaceWriteTools(
127
127
 
128
128
  server.tool(
129
129
  "opsee_delete_doc_space",
130
- "Delete a doc space. Doc pages are not linked to doc spaces in the data model — DocPage.doc_space_id is a legacy proto field the backend no longer populates, since pages are attached directly to the project. Deleting a doc space only removes this grouping row; it has no effect on any doc pages.",
130
+ "Delete a doc space. Doc pages are not linked to doc spaces in the data model — pages are attached directly to the project. Deleting a doc space only removes this grouping row; it has no effect on any doc pages.",
131
131
  { spaceId: z.number().int().min(1).describe("The doc space ID to delete. Requires the Admin or Owner role.") },
132
132
  { readOnlyHint: false, destructiveHint: true },
133
133
  async ({ spaceId }) => {