@opsee/mcp-server 0.9.56 → 0.9.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsee/mcp-server",
3
- "version": "0.9.56",
3
+ "version": "0.9.58",
4
4
  "description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,12 @@ import type {
11
11
  OAuthTokens,
12
12
  OAuthTokenRevocationRequest,
13
13
  } from "@modelcontextprotocol/sdk/shared/auth.js";
14
+ import {
15
+ InvalidGrantError,
16
+ InvalidTokenError,
17
+ } from "@modelcontextprotocol/sdk/server/auth/errors.js";
14
18
  import type { KVStore } from "./kv-store.js";
19
+ import { renewAccessToken, type TokenRenewer } from "./renew.js";
15
20
 
16
21
  // --- Stored value shapes ---
17
22
 
@@ -32,15 +37,41 @@ interface AuthCode {
32
37
  clientId: string;
33
38
  }
34
39
 
40
+ /** Server-side record behind an opaque refresh token. */
41
+ interface RefreshRecord {
42
+ /** The last access token issued to this session. Used to mint the next one. */
43
+ accessToken: string;
44
+ userId: string;
45
+ companyId: string;
46
+ clientId: string;
47
+ }
48
+
35
49
  // --- Redis key namespacing + TTLs ---
36
50
 
37
51
  const CLIENT_KEY = (id: string) => `oauth:client:${id}`;
38
52
  const PENDING_KEY = (id: string) => `oauth:pending:${id}`;
39
53
  const CODE_KEY = (code: string) => `oauth:code:${code}`;
40
54
  const REVOKED_KEY = (token: string) => `oauth:revoked:${token}`;
55
+ const REFRESH_KEY = (token: string) => `oauth:refresh:${token}`;
41
56
 
42
57
  const FLOW_TTL_SECONDS = 10 * 60; // pending auths + auth codes: 10 minutes
43
58
  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.
62
+ const REFRESH_TTL_SECONDS = 180 * 24 * 60 * 60; // 180 days
63
+
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
+ */
69
+ function expiresInFrom(expiresAt: string): number | undefined {
70
+ if (!expiresAt) return undefined;
71
+ const expMs = new Date(expiresAt).getTime();
72
+ if (Number.isNaN(expMs)) return undefined;
73
+ return Math.max(0, Math.floor((expMs - Date.now()) / 1000));
74
+ }
44
75
 
45
76
  export class OpseeClientStore implements OAuthRegisteredClientsStore {
46
77
  constructor(private readonly store: KVStore) {}
@@ -94,12 +125,19 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
94
125
  private store: KVStore;
95
126
  private serverUrl: string;
96
127
  private appUrl: string;
97
-
98
- constructor(serverUrl: string, store: KVStore, appUrl?: string) {
128
+ private renew: TokenRenewer;
129
+
130
+ constructor(
131
+ serverUrl: string,
132
+ store: KVStore,
133
+ appUrl?: string,
134
+ renew: TokenRenewer = renewAccessToken,
135
+ ) {
99
136
  this.serverUrl = serverUrl;
100
137
  this.store = store;
101
138
  this._clientsStore = new OpseeClientStore(store);
102
139
  this.appUrl = appUrl || process.env.OPSEE_APP_URL || "https://opsee.ai";
140
+ this.renew = renew;
103
141
  }
104
142
 
105
143
  get clientsStore(): OAuthRegisteredClientsStore {
@@ -162,22 +200,87 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
162
200
 
163
201
  const code = JSON.parse(raw) as AuthCode;
164
202
 
165
- // Calculate expires_in from the JWT's expiresAt
166
- let expiresIn: number | undefined;
167
- if (code.expiresAt) {
168
- const expMs = new Date(code.expiresAt).getTime() - Date.now();
169
- expiresIn = Math.max(0, Math.floor(expMs / 1000));
170
- }
171
-
172
203
  return {
173
204
  access_token: code.token,
174
205
  token_type: "bearer",
175
- expires_in: expiresIn,
206
+ 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.
210
+ refresh_token: await this.issueRefreshToken({
211
+ accessToken: code.token,
212
+ userId: code.userId,
213
+ companyId: code.companyId,
214
+ clientId: code.clientId,
215
+ }),
176
216
  };
177
217
  }
178
218
 
179
- async exchangeRefreshToken(): Promise<OAuthTokens> {
180
- throw new Error("Refresh tokens are not supported. Re-authenticate to get a new token.");
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
+ */
224
+ private async issueRefreshToken(record: RefreshRecord): Promise<string> {
225
+ const refreshToken = randomBytes(32).toString("hex");
226
+ await this.store.set(
227
+ REFRESH_KEY(refreshToken),
228
+ JSON.stringify(record),
229
+ REFRESH_TTL_SECONDS,
230
+ );
231
+ return refreshToken;
232
+ }
233
+
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
+ */
242
+ async exchangeRefreshToken(
243
+ client: OAuthClientInformationFull,
244
+ refreshToken: string,
245
+ ): Promise<OAuthTokens> {
246
+ const raw = await this.store.get(REFRESH_KEY(refreshToken));
247
+ if (!raw) throw new InvalidGrantError("Invalid or expired refresh token");
248
+
249
+ const record = JSON.parse(raw) as RefreshRecord;
250
+
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.
253
+ if (record.clientId !== client.client_id) {
254
+ throw new InvalidGrantError("Refresh token was issued to another client");
255
+ }
256
+
257
+ // Rotate: the presented handle is spent whether or not renewal succeeds, so
258
+ // a captured refresh token cannot be replayed.
259
+ await this.store.del(REFRESH_KEY(refreshToken));
260
+
261
+ let renewed;
262
+ try {
263
+ renewed = await this.renew(record.accessToken);
264
+ } 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.
268
+ throw new InvalidGrantError(
269
+ `Could not renew the session: ${err instanceof Error ? err.message : String(err)}`,
270
+ );
271
+ }
272
+
273
+ return {
274
+ access_token: renewed.token,
275
+ token_type: "bearer",
276
+ expires_in: expiresInFrom(renewed.expiresAt),
277
+ refresh_token: await this.issueRefreshToken({
278
+ accessToken: renewed.token,
279
+ userId: renewed.userId || record.userId,
280
+ companyId: renewed.companyId || record.companyId,
281
+ clientId: record.clientId,
282
+ }),
283
+ };
181
284
  }
182
285
 
183
286
  /**
@@ -185,13 +288,13 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
185
288
  * We do lightweight validation here; the backend does full validation on API calls.
186
289
  */
187
290
  async verifyAccessToken(token: string): Promise<AuthInfo> {
188
- if (await this.store.get(REVOKED_KEY(token))) {
189
- throw new Error("Token has been revoked");
291
+ if (await this.isRevoked(token)) {
292
+ throw new InvalidTokenError("Token has been revoked");
190
293
  }
191
294
 
192
295
  // Decode JWT payload without crypto verification
193
296
  const parts = token.split(".");
194
- if (parts.length !== 3) throw new Error("Invalid token format");
297
+ if (parts.length !== 3) throw new InvalidTokenError("Invalid token format");
195
298
 
196
299
  // Scope the try to ONLY the decode/parse: a malformed payload becomes
197
300
  // "Invalid token". The validation checks below run outside the catch so
@@ -201,16 +304,16 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
201
304
  try {
202
305
  payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
203
306
  } catch {
204
- throw new Error("Invalid token");
307
+ throw new InvalidTokenError("Invalid token");
205
308
  }
206
309
 
207
310
  // Require an expiry claim and reject expired tokens. A token that omits
208
311
  // exp must not be treated as valid indefinitely.
209
312
  if (typeof payload.exp !== "number") {
210
- throw new Error("Token missing expiry");
313
+ throw new InvalidTokenError("Token missing expiry");
211
314
  }
212
315
  if (payload.exp < Math.floor(Date.now() / 1000)) {
213
- throw new Error("Token expired");
316
+ throw new InvalidTokenError("Token expired");
214
317
  }
215
318
 
216
319
  return {
@@ -226,10 +329,40 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
226
329
  };
227
330
  }
228
331
 
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
+ */
345
+ private async isRevoked(token: string): Promise<boolean> {
346
+ try {
347
+ return (await this.store.get(REVOKED_KEY(token))) !== null;
348
+ } catch (err) {
349
+ console.error(
350
+ "[oauth] revocation check unavailable, allowing token:",
351
+ err instanceof Error ? err.message : err,
352
+ );
353
+ return false;
354
+ }
355
+ }
356
+
229
357
  async revokeToken(
230
358
  _client: OAuthClientInformationFull,
231
359
  request: OAuthTokenRevocationRequest,
232
360
  ): 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.
364
+ await this.store.del(REFRESH_KEY(request.token));
365
+
233
366
  // Keep the revocation marker only as long as the token could still be
234
367
  // valid. Derive that from the JWT's exp when available.
235
368
  let ttl = REVOKED_FALLBACK_TTL_SECONDS;
@@ -0,0 +1,56 @@
1
+ import { createClient } from "@connectrpc/connect";
2
+ import { createConnectTransport } from "@connectrpc/connect-node";
3
+ import { AccountService } from "../../gen/api/v1/account_pb.js";
4
+
5
+ /**
6
+ * A freshly minted MCP access token plus the identity it belongs to.
7
+ */
8
+ export interface RenewedToken {
9
+ token: string;
10
+ userId: string;
11
+ companyId: string;
12
+ /** RFC3339 expiry, as returned by the backend. Empty when unknown. */
13
+ expiresAt: string;
14
+ }
15
+
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
+ */
25
+ export type TokenRenewer = (currentToken: string) => Promise<RenewedToken>;
26
+
27
+ function backendUrl(): string {
28
+ return process.env.OPSEE_API_URL || "https://grpc.api.opsee.ai";
29
+ }
30
+
31
+ 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.
35
+ const transport = createConnectTransport({
36
+ baseUrl: backendUrl(),
37
+ httpVersion: "1.1",
38
+ interceptors: [
39
+ (next) => async (req) => {
40
+ req.header.set("Authorization", `Bearer ${currentToken}`);
41
+ req.header.set("X-Opsee-Client", "mcp");
42
+ return await next(req);
43
+ },
44
+ ],
45
+ });
46
+
47
+ const account = createClient(AccountService, transport);
48
+ const res = await account.generateMCPToken({});
49
+
50
+ return {
51
+ token: res.token,
52
+ userId: res.userId,
53
+ companyId: res.companyId,
54
+ expiresAt: res.expiresAt,
55
+ };
56
+ };