@opsee/mcp-server 0.9.60 → 0.10.2
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/gen/api/v1/billing_pb.d.ts +0 -40
- package/gen/api/v1/billing_pb.js +22 -36
- package/gen/api/v1/developer_metrics_pb.d.ts +9 -3
- package/gen/api/v1/developer_metrics_pb.js +2 -1
- package/gen/api/v1/models_pb.d.ts +116 -5
- package/gen/api/v1/models_pb.js +33 -26
- package/gen/api/v1/project_pb.d.ts +18 -13
- package/gen/api/v1/project_pb.js +1 -1
- package/gen/api/v1/task_subscription_pb.d.ts +146 -0
- package/gen/api/v1/task_subscription_pb.js +66 -0
- package/gen/api/v1/vcs_connection_pb.d.ts +34 -3
- package/gen/api/v1/vcs_connection_pb.js +28 -20
- package/gen/api/v1/vcs_integration_pb.d.ts +11 -1
- package/gen/api/v1/vcs_integration_pb.js +2 -1
- package/gen/fk/foreign_key_pb.d.ts +22 -0
- package/gen/fk/foreign_key_pb.js +24 -0
- package/package.json +11 -11
- package/src/auth/kv-store.ts +12 -33
- package/src/auth/manager.ts +1 -6
- package/src/auth/oauth-provider.ts +32 -104
- package/src/auth/renew.ts +5 -15
- package/src/client/api.ts +3 -5
- package/src/server-http.ts +14 -30
- package/src/tools/acceptance-criteria.ts +4 -14
- package/src/tools/descriptions.ts +2 -7
- package/src/tools/doc-links.ts +4 -9
- package/src/tools/doc-spaces.ts +1 -1
- package/src/tools/docs.ts +8 -19
- package/src/tools/notifications.ts +2 -6
- package/src/tools/tasks-bulk.ts +3 -8
- package/src/tools/tasks-read-write.ts +1 -8
- package/src/utils/format/blocknote.ts +8 -23
- package/src/utils/tool-schema.ts +7 -29
- package/README.md +0 -304
- package/gen/api/v1/deployment_pb.d.ts +0 -277
- package/gen/api/v1/deployment_pb.js +0 -89
- package/gen/api/v1/environment_pb.d.ts +0 -277
- package/gen/api/v1/environment_pb.js +0 -89
- package/gen/api/v1/test_run_pb.d.ts +0 -277
- 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
|
-
/**
|
|
38
|
+
/** RefreshRecord is the server-side record behind an opaque refresh token. */
|
|
41
39
|
interface RefreshRecord {
|
|
42
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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
|
-
//
|
|
85
|
-
//
|
|
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
|
|
106
|
-
//
|
|
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
|
-
//
|
|
115
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
45
|
-
//
|
|
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
|
};
|
package/src/server-http.ts
CHANGED
|
@@ -21,31 +21,24 @@ const SUCCESS_HTML = `<!DOCTYPE html>
|
|
|
21
21
|
</body>
|
|
22
22
|
</html>`;
|
|
23
23
|
|
|
24
|
-
// Express
|
|
25
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
138
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
174
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
*
|
|
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 =
|
package/src/tools/doc-links.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
21
|
-
//
|
|
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
|
|
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
|
}
|
package/src/tools/doc-spaces.ts
CHANGED
|
@@ -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 —
|
|
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 }) => {
|