@opsee/mcp-server 0.6.9 → 0.7.1

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.6.9",
3
+ "version": "0.7.1",
4
4
  "description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "@modelcontextprotocol/sdk": "^1.12.1",
22
22
  "cors": "^2.8.6",
23
23
  "express": "^5.2.1",
24
+ "ioredis": "^5.11.1",
24
25
  "open": "^10.1.0",
25
26
  "tsx": "^4.21.0",
26
27
  "zod": "^4.3.6"
@@ -0,0 +1,182 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { Response } from "express";
3
+ import { InMemoryKVStore } from "../kv-store.js";
4
+ import { OpseeClientStore, OpseeOAuthProvider } from "../oauth-provider.js";
5
+ import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
6
+
7
+ const SERVER_URL = "https://mcp.example.com";
8
+ const REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback";
9
+
10
+ function makeClient(): Omit<
11
+ OAuthClientInformationFull,
12
+ "client_id" | "client_id_issued_at"
13
+ > {
14
+ return {
15
+ redirect_uris: [REDIRECT_URI],
16
+ token_endpoint_auth_method: "none",
17
+ grant_types: ["authorization_code"],
18
+ response_types: ["code"],
19
+ } as unknown as Omit<
20
+ OAuthClientInformationFull,
21
+ "client_id" | "client_id_issued_at"
22
+ >;
23
+ }
24
+
25
+ describe("InMemoryKVStore", () => {
26
+ test("stores and retrieves values", async () => {
27
+ const store = new InMemoryKVStore();
28
+ await store.set("k", "v");
29
+ expect(await store.get("k")).toBe("v");
30
+ });
31
+
32
+ test("returns null for missing keys", async () => {
33
+ const store = new InMemoryKVStore();
34
+ expect(await store.get("nope")).toBeNull();
35
+ });
36
+
37
+ test("deletes values", async () => {
38
+ const store = new InMemoryKVStore();
39
+ await store.set("k", "v");
40
+ await store.del("k");
41
+ expect(await store.get("k")).toBeNull();
42
+ });
43
+
44
+ test("expires values after the TTL elapses", async () => {
45
+ vi.useFakeTimers();
46
+ const store = new InMemoryKVStore();
47
+ await store.set("k", "v", 10); // 10 second TTL
48
+ expect(await store.get("k")).toBe("v");
49
+ vi.advanceTimersByTime(11_000);
50
+ expect(await store.get("k")).toBeNull();
51
+ vi.useRealTimers();
52
+ });
53
+ });
54
+
55
+ describe("OpseeClientStore", () => {
56
+ test("registerClient persists redirect_uris and getClient round-trips them", async () => {
57
+ const store = new InMemoryKVStore();
58
+ const clientStore = new OpseeClientStore(store);
59
+
60
+ const registered = await clientStore.registerClient(makeClient());
61
+ expect(registered.client_id).toBeTruthy();
62
+ expect(registered.client_secret).toBeTruthy();
63
+
64
+ const fetched = await clientStore.getClient(registered.client_id);
65
+ expect(fetched).toBeDefined();
66
+ // The actual fix: the registered redirect_uri survives the round-trip, so
67
+ // the SDK's `redirect_uris.includes(redirect_uri)` check passes.
68
+ expect(fetched?.redirect_uris).toContain(REDIRECT_URI);
69
+ });
70
+
71
+ test("getClient returns undefined for an unknown client (no empty-uri fallback)", async () => {
72
+ const store = new InMemoryKVStore();
73
+ const clientStore = new OpseeClientStore(store);
74
+ expect(await clientStore.getClient("never-registered")).toBeUndefined();
75
+ });
76
+ });
77
+
78
+ describe("OpseeOAuthProvider flow", () => {
79
+ let store: InMemoryKVStore;
80
+ let provider: OpseeOAuthProvider;
81
+ let client: OAuthClientInformationFull;
82
+
83
+ beforeEach(async () => {
84
+ store = new InMemoryKVStore();
85
+ provider = new OpseeOAuthProvider(SERVER_URL, store, "https://app.example.com");
86
+ client = await (provider.clientsStore as OpseeClientStore).registerClient(
87
+ makeClient(),
88
+ );
89
+ });
90
+
91
+ // Captures the pendingId the provider embedded in the login redirect URL.
92
+ async function authorizeAndGetPendingId(): Promise<string> {
93
+ let location = "";
94
+ const res = {
95
+ redirect: (url: string) => {
96
+ location = url;
97
+ },
98
+ } as unknown as Response;
99
+
100
+ await provider.authorize(
101
+ client,
102
+ {
103
+ redirectUri: REDIRECT_URI,
104
+ codeChallenge: "challenge-123",
105
+ state: "state-xyz",
106
+ },
107
+ res,
108
+ );
109
+
110
+ expect(location).toContain("https://app.example.com/auth/mcp");
111
+ const callback = new URL(
112
+ decodeURIComponent(location.split("callback=")[1]),
113
+ );
114
+ const pendingId = callback.searchParams.get("pending");
115
+ expect(pendingId).toBeTruthy();
116
+ return pendingId as string;
117
+ }
118
+
119
+ test("authorize → callback → exchange issues the backend token", async () => {
120
+ const pendingId = await authorizeAndGetPendingId();
121
+
122
+ const result = await provider.handleCallback(
123
+ pendingId,
124
+ "backend.jwt.token",
125
+ "user-1",
126
+ "company-1",
127
+ "",
128
+ );
129
+ expect("redirectUri" in result).toBe(true);
130
+ const redirectUri = (result as { redirectUri: string }).redirectUri;
131
+
132
+ const url = new URL(redirectUri);
133
+ expect(url.searchParams.get("state")).toBe("state-xyz");
134
+ const code = url.searchParams.get("code");
135
+ expect(code).toBeTruthy();
136
+
137
+ // PKCE challenge is recoverable for the issued code.
138
+ expect(await provider.challengeForAuthorizationCode(client, code as string)).toBe(
139
+ "challenge-123",
140
+ );
141
+
142
+ const tokens = await provider.exchangeAuthorizationCode(client, code as string);
143
+ expect(tokens.access_token).toBe("backend.jwt.token");
144
+ expect(tokens.token_type).toBe("bearer");
145
+ });
146
+
147
+ test("authorization codes are single-use", async () => {
148
+ const pendingId = await authorizeAndGetPendingId();
149
+ const result = await provider.handleCallback(pendingId, "t", "u", "c", "");
150
+ const code = new URL((result as { redirectUri: string }).redirectUri).searchParams.get(
151
+ "code",
152
+ ) as string;
153
+
154
+ await provider.exchangeAuthorizationCode(client, code);
155
+ await expect(provider.exchangeAuthorizationCode(client, code)).rejects.toThrow(
156
+ /Invalid or expired authorization code/,
157
+ );
158
+ });
159
+
160
+ test("a pendingId cannot be replayed", async () => {
161
+ const pendingId = await authorizeAndGetPendingId();
162
+ await provider.handleCallback(pendingId, "t", "u", "c", "");
163
+ const second = await provider.handleCallback(pendingId, "t", "u", "c", "");
164
+ expect("error" in second).toBe(true);
165
+ });
166
+
167
+ test("revoked tokens fail verification", async () => {
168
+ // Build a JWT-shaped token with a future exp so only revocation can reject it.
169
+ const future = Math.floor(Date.now() / 1000) + 3600;
170
+ const payload = Buffer.from(
171
+ JSON.stringify({ exp: future, userId: "u", companyId: "c" }),
172
+ ).toString("base64url");
173
+ const token = `header.${payload}.sig`;
174
+
175
+ await expect(provider.verifyAccessToken(token)).resolves.toMatchObject({
176
+ clientId: "opsee",
177
+ });
178
+
179
+ await provider.revokeToken(client, { token });
180
+ await expect(provider.verifyAccessToken(token)).rejects.toThrow(/revoked/);
181
+ });
182
+ });
@@ -0,0 +1,103 @@
1
+ import { Redis } from "ioredis";
2
+
3
+ /**
4
+ * Minimal key/value store used to persist OAuth state.
5
+ *
6
+ * The OAuth flow keeps clients, pending authorizations, authorization codes,
7
+ * and revoked tokens here. Backing it with Redis (instead of in-process Maps)
8
+ * lets registration on one request survive a server restart and be visible to
9
+ * other replicas — which is what makes horizontal scaling safe.
10
+ */
11
+ export interface KVStore {
12
+ /** Returns the stored value, or null if missing/expired. */
13
+ get(key: string): Promise<string | null>;
14
+ /** Stores a value, optionally expiring it after `ttlSeconds`. */
15
+ set(key: string, value: string, ttlSeconds?: number): Promise<void>;
16
+ /** Deletes a key. No-op if absent. */
17
+ del(key: string): Promise<void>;
18
+ }
19
+
20
+ /**
21
+ * Process-local fallback used when Redis is not configured (local dev, stdio
22
+ * usage). State is lost on restart and is NOT shared across replicas, so this
23
+ * must not be used in a multi-replica deployment.
24
+ */
25
+ export class InMemoryKVStore implements KVStore {
26
+ private entries = new Map<string, { value: string; expiresAt: number | null }>();
27
+
28
+ async get(key: string): Promise<string | null> {
29
+ const entry = this.entries.get(key);
30
+ if (!entry) return null;
31
+ if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
32
+ this.entries.delete(key);
33
+ return null;
34
+ }
35
+ return entry.value;
36
+ }
37
+
38
+ async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
39
+ const expiresAt =
40
+ ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null;
41
+ this.entries.set(key, { value, expiresAt });
42
+ }
43
+
44
+ async del(key: string): Promise<void> {
45
+ this.entries.delete(key);
46
+ }
47
+ }
48
+
49
+ /** Redis-backed store. Expiry is delegated to Redis via the EX option. */
50
+ export class RedisKVStore implements KVStore {
51
+ constructor(private readonly redis: Redis) {}
52
+
53
+ async get(key: string): Promise<string | null> {
54
+ return this.redis.get(key);
55
+ }
56
+
57
+ async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
58
+ if (ttlSeconds !== undefined) {
59
+ await this.redis.set(key, value, "EX", ttlSeconds);
60
+ } else {
61
+ await this.redis.set(key, value);
62
+ }
63
+ }
64
+
65
+ async del(key: string): Promise<void> {
66
+ await this.redis.del(key);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Builds a KVStore from the environment. Uses Redis when REDIS_HOST is set,
72
+ * mirroring the backend's REDIS_* env conventions; otherwise falls back to an
73
+ * in-process store and warns that scaling beyond one replica is unsafe.
74
+ */
75
+ export function createKVStore(): KVStore {
76
+ const host = process.env.REDIS_HOST;
77
+ if (!host) {
78
+ console.warn(
79
+ "[oauth] REDIS_HOST not set — using in-memory OAuth state. " +
80
+ "OAuth registrations will not survive restarts or scale beyond one replica.",
81
+ );
82
+ return new InMemoryKVStore();
83
+ }
84
+
85
+ const redis = new Redis({
86
+ host,
87
+ port: parseInt(process.env.REDIS_PORT || "6379", 10),
88
+ password: process.env.REDIS_PASSWORD || undefined,
89
+ db: parseInt(process.env.REDIS_DB || "0", 10),
90
+ tls: process.env.REDIS_TLS === "true" ? {} : undefined,
91
+ // Don't queue commands forever if Redis is unreachable; surface errors.
92
+ maxRetriesPerRequest: 3,
93
+ });
94
+
95
+ redis.on("error", (err: Error) => {
96
+ console.error("[oauth] Redis error:", err.message);
97
+ });
98
+ redis.on("connect", () => {
99
+ console.log(`[oauth] Connected to Redis at ${host}`);
100
+ });
101
+
102
+ return new RedisKVStore(redis);
103
+ }
@@ -1,4 +1,4 @@
1
- import { randomBytes, randomUUID, createHash } from "node:crypto";
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
2
  import type { Response } from "express";
3
3
  import type {
4
4
  OAuthServerProvider,
@@ -11,15 +11,15 @@ import type {
11
11
  OAuthTokens,
12
12
  OAuthTokenRevocationRequest,
13
13
  } from "@modelcontextprotocol/sdk/shared/auth.js";
14
+ import type { KVStore } from "./kv-store.js";
14
15
 
15
- // --- In-memory stores ---
16
+ // --- Stored value shapes ---
16
17
 
17
18
  interface PendingAuth {
18
19
  clientId: string;
19
20
  redirectUri: string;
20
21
  state?: string;
21
22
  codeChallenge: string;
22
- createdAt: number;
23
23
  }
24
24
 
25
25
  interface AuthCode {
@@ -30,33 +30,41 @@ interface AuthCode {
30
30
  codeChallenge: string;
31
31
  redirectUri: string;
32
32
  clientId: string;
33
- createdAt: number;
34
33
  }
35
34
 
36
- const TTL_MS = 10 * 60 * 1000; // 10 minutes
35
+ // --- Redis key namespacing + TTLs ---
37
36
 
38
- export class OpseeClientStore implements OAuthRegisteredClientsStore {
39
- private clients = new Map<string, OAuthClientInformationFull>();
37
+ const CLIENT_KEY = (id: string) => `oauth:client:${id}`;
38
+ const PENDING_KEY = (id: string) => `oauth:pending:${id}`;
39
+ const CODE_KEY = (code: string) => `oauth:code:${code}`;
40
+ const REVOKED_KEY = (token: string) => `oauth:revoked:${token}`;
40
41
 
41
- getClient(clientId: string): OAuthClientInformationFull | undefined {
42
- const known = this.clients.get(clientId);
43
- if (known) return known;
42
+ const FLOW_TTL_SECONDS = 10 * 60; // pending auths + auth codes: 10 minutes
43
+ const CLIENT_TTL_SECONDS = 30 * 24 * 60 * 60; // dynamic client registrations: 30 days
44
+ const REVOKED_FALLBACK_TTL_SECONDS = 24 * 60 * 60; // if token expiry is unknown
44
45
 
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;
46
+ export class OpseeClientStore implements OAuthRegisteredClientsStore {
47
+ constructor(private readonly store: KVStore) {}
48
+
49
+ async getClient(
50
+ clientId: string,
51
+ ): Promise<OAuthClientInformationFull | undefined> {
52
+ const raw = await this.store.get(CLIENT_KEY(clientId));
53
+ if (!raw) {
54
+ // Unknown client. With a shared Redis store this means the client was
55
+ // never registered (or its registration expired) — return undefined so
56
+ // the SDK responds with invalid_client and the MCP client re-registers
57
+ // via Dynamic Client Registration. (Previously we returned a synthetic
58
+ // client with an empty redirect_uris list, which made the SDK reject
59
+ // every redirect_uri with "Unregistered redirect_uri".)
60
+ return undefined;
61
+ }
62
+ return JSON.parse(raw) as OAuthClientInformationFull;
55
63
  }
56
64
 
57
- registerClient(
65
+ async registerClient(
58
66
  client: Omit<OAuthClientInformationFull, "client_id" | "client_id_issued_at">,
59
- ): OAuthClientInformationFull {
67
+ ): Promise<OAuthClientInformationFull> {
60
68
  const clientId = randomUUID();
61
69
  const clientSecret = randomBytes(32).toString("hex");
62
70
  const registered: OAuthClientInformationFull = {
@@ -65,22 +73,25 @@ export class OpseeClientStore implements OAuthRegisteredClientsStore {
65
73
  client_secret: clientSecret,
66
74
  client_id_issued_at: Math.floor(Date.now() / 1000),
67
75
  };
68
- this.clients.set(clientId, registered);
76
+ await this.store.set(
77
+ CLIENT_KEY(clientId),
78
+ JSON.stringify(registered),
79
+ CLIENT_TTL_SECONDS,
80
+ );
69
81
  return registered;
70
82
  }
71
83
  }
72
84
 
73
85
  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
-
86
+ private _clientsStore: OpseeClientStore;
87
+ private store: KVStore;
79
88
  private serverUrl: string;
80
89
  private appUrl: string;
81
90
 
82
- constructor(serverUrl: string, appUrl?: string) {
91
+ constructor(serverUrl: string, store: KVStore, appUrl?: string) {
83
92
  this.serverUrl = serverUrl;
93
+ this.store = store;
94
+ this._clientsStore = new OpseeClientStore(store);
84
95
  this.appUrl = appUrl || process.env.OPSEE_APP_URL || "https://opsee.ai";
85
96
  }
86
97
 
@@ -98,13 +109,17 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
98
109
  ): Promise<void> {
99
110
  const pendingId = randomBytes(16).toString("hex");
100
111
 
101
- this.pendingAuths.set(pendingId, {
112
+ const pending: PendingAuth = {
102
113
  clientId: client.client_id,
103
114
  redirectUri: params.redirectUri,
104
115
  state: params.state,
105
116
  codeChallenge: params.codeChallenge,
106
- createdAt: Date.now(),
107
- });
117
+ };
118
+ await this.store.set(
119
+ PENDING_KEY(pendingId),
120
+ JSON.stringify(pending),
121
+ FLOW_TTL_SECONDS,
122
+ );
108
123
 
109
124
  // Build callback URL back to our server
110
125
  const callbackUrl = `${this.serverUrl}/oauth/callback?pending=${pendingId}`;
@@ -120,9 +135,9 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
120
135
  _client: OAuthClientInformationFull,
121
136
  authorizationCode: string,
122
137
  ): Promise<string> {
123
- const code = this.authCodes.get(authorizationCode);
124
- if (!code) throw new Error("Invalid authorization code");
125
- return code.codeChallenge;
138
+ const raw = await this.store.get(CODE_KEY(authorizationCode));
139
+ if (!raw) throw new Error("Invalid authorization code");
140
+ return (JSON.parse(raw) as AuthCode).codeChallenge;
126
141
  }
127
142
 
128
143
  /**
@@ -132,16 +147,13 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
132
147
  _client: OAuthClientInformationFull,
133
148
  authorizationCode: string,
134
149
  ): 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
- }
150
+ const raw = await this.store.get(CODE_KEY(authorizationCode));
151
+ if (!raw) throw new Error("Invalid or expired authorization code");
142
152
 
143
153
  // One-time use
144
- this.authCodes.delete(authorizationCode);
154
+ await this.store.del(CODE_KEY(authorizationCode));
155
+
156
+ const code = JSON.parse(raw) as AuthCode;
145
157
 
146
158
  // Calculate expires_in from the JWT's expiresAt
147
159
  let expiresIn: number | undefined;
@@ -166,7 +178,7 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
166
178
  * We do lightweight validation here; the backend does full validation on API calls.
167
179
  */
168
180
  async verifyAccessToken(token: string): Promise<AuthInfo> {
169
- if (this.revokedTokens.has(token)) {
181
+ if (await this.store.get(REVOKED_KEY(token))) {
170
182
  throw new Error("Token has been revoked");
171
183
  }
172
184
 
@@ -205,7 +217,24 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
205
217
  _client: OAuthClientInformationFull,
206
218
  request: OAuthTokenRevocationRequest,
207
219
  ): Promise<void> {
208
- this.revokedTokens.add(request.token);
220
+ // Keep the revocation marker only as long as the token could still be
221
+ // valid. Derive that from the JWT's exp when available.
222
+ let ttl = REVOKED_FALLBACK_TTL_SECONDS;
223
+ const parts = request.token.split(".");
224
+ if (parts.length === 3) {
225
+ try {
226
+ const payload = JSON.parse(
227
+ Buffer.from(parts[1], "base64url").toString("utf-8"),
228
+ );
229
+ if (payload.exp) {
230
+ const remaining = payload.exp - Math.floor(Date.now() / 1000);
231
+ if (remaining > 0) ttl = remaining;
232
+ }
233
+ } catch {
234
+ // Fall back to default TTL on undecodable tokens.
235
+ }
236
+ }
237
+ await this.store.set(REVOKED_KEY(request.token), "1", ttl);
209
238
  }
210
239
 
211
240
  // --- Custom methods for the callback flow ---
@@ -214,27 +243,24 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
214
243
  * Step 2: Opsee login redirects back to /oauth/callback.
215
244
  * We generate an auth code and redirect to Claude's redirect_uri.
216
245
  */
217
- handleCallback(
246
+ async handleCallback(
218
247
  pendingId: string,
219
248
  token: string,
220
249
  userId: string,
221
250
  companyId: string,
222
251
  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
- }
252
+ ): Promise<{ redirectUri: string } | { error: string }> {
253
+ const raw = await this.store.get(PENDING_KEY(pendingId));
254
+ if (!raw) return { error: "Invalid or expired pending authorization" };
231
255
 
232
256
  // One-time use
233
- this.pendingAuths.delete(pendingId);
257
+ await this.store.del(PENDING_KEY(pendingId));
258
+
259
+ const pending = JSON.parse(raw) as PendingAuth;
234
260
 
235
261
  // Generate authorization code
236
262
  const authCode = randomBytes(32).toString("hex");
237
- this.authCodes.set(authCode, {
263
+ const code: AuthCode = {
238
264
  token,
239
265
  userId,
240
266
  companyId,
@@ -242,8 +268,8 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
242
268
  codeChallenge: pending.codeChallenge,
243
269
  redirectUri: pending.redirectUri,
244
270
  clientId: pending.clientId,
245
- createdAt: Date.now(),
246
- });
271
+ };
272
+ await this.store.set(CODE_KEY(authCode), JSON.stringify(code), FLOW_TTL_SECONDS);
247
273
 
248
274
  // Build redirect URL with code and state
249
275
  const url = new URL(pending.redirectUri);
@@ -252,15 +278,4 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
252
278
 
253
279
  return { redirectUri: url.toString() };
254
280
  }
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
281
  }
package/src/client/api.ts CHANGED
@@ -36,6 +36,10 @@ const authInterceptor: Interceptor = (next) => async (req) => {
36
36
  if (token) {
37
37
  req.header.set("Authorization", `Bearer ${token}`);
38
38
  }
39
+ // Mark this as an automation (MCP) client so the backend stamps live board
40
+ // events with the system actor — an agent acting under a user's token must
41
+ // not be self-skipped on that user's own board. (ADR-0002 §5; Slice 5.)
42
+ req.header.set("X-Opsee-Client", "mcp");
39
43
  return await next(req);
40
44
  };
41
45
 
@@ -6,6 +6,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
6
6
  import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
7
7
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
8
8
  import { OpseeOAuthProvider } from "./auth/oauth-provider.js";
9
+ import { createKVStore } from "./auth/kv-store.js";
9
10
  import { tokenContext } from "./auth/token-context.js";
10
11
  import { createServer } from "./server.js";
11
12
 
@@ -28,12 +29,10 @@ export async function startHttpServer(): Promise<void> {
28
29
  const backendUrl =
29
30
  process.env.OPSEE_API_URL || "https://grpc.api.opsee.ai";
30
31
 
31
- const provider = new OpseeOAuthProvider(serverUrl);
32
+ const store = createKVStore();
33
+ const provider = new OpseeOAuthProvider(serverUrl, store);
32
34
  const issuerUrl = new URL(serverUrl);
33
35
 
34
- // Periodically clean up expired auth entries
35
- setInterval(() => provider.cleanup(), 60_000);
36
-
37
36
  const app = express();
38
37
  // Trust proxy headers (X-Forwarded-For) from nginx ingress
39
38
  app.set("trust proxy", 1);
@@ -57,7 +56,7 @@ export async function startHttpServer(): Promise<void> {
57
56
  );
58
57
 
59
58
  // --- Custom OAuth callback (Opsee login redirects here) ---
60
- app.get("/oauth/callback", (req, res) => {
59
+ app.get("/oauth/callback", async (req, res) => {
61
60
  const { pending, token, userId, companyId, expiresAt } = req.query as Record<string, string>;
62
61
 
63
62
  if (!pending || !token) {
@@ -65,7 +64,7 @@ export async function startHttpServer(): Promise<void> {
65
64
  return;
66
65
  }
67
66
 
68
- const result = provider.handleCallback(
67
+ const result = await provider.handleCallback(
69
68
  pending,
70
69
  token,
71
70
  userId || "",