@opsee/mcp-server 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -10
- package/bin/opsee-mcp.js +9 -3
- package/package.json +5 -1
- package/src/auth/oauth-provider.ts +266 -0
- package/src/auth/token-context.ts +11 -0
- package/src/client/api.ts +4 -1
- package/src/index-http.ts +6 -0
- package/src/server-http.ts +120 -0
- package/src/tools/cycles.ts +3 -0
- package/src/tools/docs.ts +4 -0
- package/src/tools/projects.ts +2 -0
- package/src/tools/repositories.ts +1 -0
- package/src/tools/task-metadata.ts +4 -0
- package/src/tools/tasks.ts +4 -0
- package/src/tools/user.ts +1 -0
package/README.md
CHANGED
|
@@ -55,9 +55,73 @@ Ask Claude things like:
|
|
|
55
55
|
| `opsee_create_doc_page` | Create a new doc page |
|
|
56
56
|
| `opsee_list_repositories` | List connected repositories |
|
|
57
57
|
|
|
58
|
-
##
|
|
58
|
+
## Remote MCP Server (Recommended)
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
The remote server runs in the cloud with OAuth 2.0 authentication — no local setup or tokens needed.
|
|
61
|
+
|
|
62
|
+
### Environments
|
|
63
|
+
|
|
64
|
+
| Environment | URL | Opsee Instance |
|
|
65
|
+
|-------------|-----|----------------|
|
|
66
|
+
| **Production** | `https://mcp.api.opsee.ai/mcp` | `https://opsee.ai` |
|
|
67
|
+
| **Development** | `https://opsee-development.mcp.cls.codilas.link/mcp` | `https://opsee-development.cdn.codilas.link` |
|
|
68
|
+
|
|
69
|
+
### Claude Code
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Production
|
|
73
|
+
claude mcp add opsee --transport http https://mcp.api.opsee.ai/mcp
|
|
74
|
+
|
|
75
|
+
# Development
|
|
76
|
+
claude mcp add opsee-dev --transport http https://opsee-development.mcp.cls.codilas.link/mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Restart Claude Code. On first use, your browser opens for Opsee login. After authenticating, all tools are available immediately.
|
|
80
|
+
|
|
81
|
+
You can have both environments connected at the same time — use tools prefixed with the server name (e.g. `opsee` for production, `opsee-dev` for development).
|
|
82
|
+
|
|
83
|
+
### Cursor / Other MCP Clients
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"mcpServers": {
|
|
88
|
+
"opsee": {
|
|
89
|
+
"type": "http",
|
|
90
|
+
"url": "https://mcp.api.opsee.ai/mcp"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Replace the URL with the development endpoint if targeting that environment.
|
|
97
|
+
|
|
98
|
+
Your MCP client handles the OAuth flow automatically — just sign in when prompted.
|
|
99
|
+
|
|
100
|
+
### How it works
|
|
101
|
+
|
|
102
|
+
1. Client connects to the MCP endpoint (e.g. `https://mcp.api.opsee.ai/mcp`)
|
|
103
|
+
2. Server returns 401 with OAuth discovery metadata
|
|
104
|
+
3. Client registers itself and opens the Opsee login page in your browser
|
|
105
|
+
4. After login, the client exchanges the auth code for an access token
|
|
106
|
+
5. All subsequent tool calls use the token automatically
|
|
107
|
+
|
|
108
|
+
## Local MCP Server (Alternative)
|
|
109
|
+
|
|
110
|
+
Run the MCP server locally via npx. Requires a one-time login to get a token.
|
|
111
|
+
|
|
112
|
+
### 1. Authenticate
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npx @opsee/mcp-server login
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 2. Add to Claude Code
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
claude mcp add opsee -- npx @opsee/mcp-server serve
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Cursor / Other MCP Clients (Local)
|
|
61
125
|
|
|
62
126
|
```json
|
|
63
127
|
{
|
|
@@ -70,7 +134,7 @@ Add this to your MCP client config:
|
|
|
70
134
|
}
|
|
71
135
|
```
|
|
72
136
|
|
|
73
|
-
|
|
137
|
+
### CI / Headless Environments
|
|
74
138
|
|
|
75
139
|
Skip the browser login by setting a token directly:
|
|
76
140
|
|
|
@@ -88,21 +152,56 @@ Skip the browser login by setting a token directly:
|
|
|
88
152
|
}
|
|
89
153
|
```
|
|
90
154
|
|
|
91
|
-
##
|
|
155
|
+
## Self-Hosting the Remote Server
|
|
156
|
+
|
|
157
|
+
Deploy the MCP server in your own infrastructure using Docker.
|
|
158
|
+
|
|
159
|
+
### Docker
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
docker build -f mcp/Dockerfile . -t opsee-mcp-server
|
|
163
|
+
|
|
164
|
+
docker run -p 3100:3100 \
|
|
165
|
+
-e MCP_SERVER_URL=https://mcp.yourdomain.com \
|
|
166
|
+
-e OPSEE_API_URL=http://your-backend:8080 \
|
|
167
|
+
-e OPSEE_APP_URL=https://your-frontend.com \
|
|
168
|
+
opsee-mcp-server
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Kubernetes
|
|
172
|
+
|
|
173
|
+
The server includes Kustomize configs following the same pattern as the backend:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
# Development
|
|
177
|
+
kubectl apply -k mcp/k8/environments/development/ -n opsee-development
|
|
178
|
+
|
|
179
|
+
# Production
|
|
180
|
+
kubectl apply -k mcp/k8/environments/production/ -n opsee-production
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Environment Variables
|
|
92
184
|
|
|
93
185
|
| Variable | Description | Default |
|
|
94
186
|
|----------|-------------|---------|
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
187
|
+
| `MCP_SERVER_URL` | Public URL of this MCP server (for OAuth redirects) | `http://localhost:3100` |
|
|
188
|
+
| `MCP_HOST` | Bind host | `0.0.0.0` |
|
|
189
|
+
| `MCP_PORT` | Bind port | `3100` |
|
|
190
|
+
| `OPSEE_API_URL` | Backend Connect RPC URL | `https://grpc.api.opsee.ai` |
|
|
191
|
+
| `OPSEE_APP_URL` | Frontend URL (OAuth login page) | `https://opsee.ai` |
|
|
192
|
+
| `OPSEE_API_TOKEN` | Direct JWT token for local mode | — |
|
|
98
193
|
| `OPSEE_CREDENTIALS_PATH` | Override credential file path | `~/.opsee/credentials.json` |
|
|
99
194
|
|
|
100
195
|
## Local Development
|
|
101
196
|
|
|
102
197
|
```bash
|
|
103
|
-
#
|
|
104
|
-
OPSEE_APP_URL=http://localhost:5173 OPSEE_API_URL=http://localhost:9990 npx @opsee/mcp-server login
|
|
198
|
+
# Start backend + frontend locally, then:
|
|
105
199
|
|
|
106
|
-
#
|
|
200
|
+
# Local mode (stdio):
|
|
201
|
+
OPSEE_APP_URL=http://localhost:5173 OPSEE_API_URL=http://localhost:9990 npx @opsee/mcp-server login
|
|
107
202
|
claude mcp add opsee -e OPSEE_API_URL=http://localhost:9990 -- npx @opsee/mcp-server serve
|
|
203
|
+
|
|
204
|
+
# Remote mode (HTTP):
|
|
205
|
+
MCP_SERVER_URL=http://localhost:3100 OPSEE_API_URL=http://localhost:9990 OPSEE_APP_URL=http://localhost:5173 npx @opsee/mcp-server serve-remote
|
|
206
|
+
claude mcp add opsee-local --transport http http://localhost:3100/mcp
|
|
108
207
|
```
|
package/bin/opsee-mcp.js
CHANGED
|
@@ -13,9 +13,15 @@ const tsxPkg = dirname(require.resolve("tsx/package.json"));
|
|
|
13
13
|
const tsx = resolve(tsxPkg, "dist", "cli.mjs");
|
|
14
14
|
|
|
15
15
|
const command = process.argv[2];
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
let script;
|
|
17
|
+
if (command === "login") {
|
|
18
|
+
script = resolve(__dirname, "..", "src", "auth", "login-cli.ts");
|
|
19
|
+
} else if (command === "serve-remote") {
|
|
20
|
+
script = resolve(__dirname, "..", "src", "index-http.ts");
|
|
21
|
+
} else {
|
|
22
|
+
// Default: stdio mode (local)
|
|
23
|
+
script = resolve(__dirname, "..", "src", "index.ts");
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
const child = spawn(process.execPath, [tsx, script], { stdio: "inherit", env: process.env });
|
|
21
27
|
child.on("exit", (code) => process.exit(code || 0));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opsee/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,11 +19,15 @@
|
|
|
19
19
|
"@connectrpc/connect": "^2.0.2",
|
|
20
20
|
"@connectrpc/connect-node": "^2.0.2",
|
|
21
21
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
22
|
+
"cors": "^2.8.6",
|
|
23
|
+
"express": "^5.2.1",
|
|
22
24
|
"open": "^10.1.0",
|
|
23
25
|
"tsx": "^4.21.0",
|
|
24
26
|
"zod": "^4.3.6"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
29
|
+
"@types/cors": "^2.8.19",
|
|
30
|
+
"@types/express": "^5.0.6",
|
|
27
31
|
"@types/node": "^22.13.0",
|
|
28
32
|
"typescript": "^5.7.0",
|
|
29
33
|
"vitest": "^4.1.0"
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import type { Response } from "express";
|
|
3
|
+
import type {
|
|
4
|
+
OAuthServerProvider,
|
|
5
|
+
AuthorizationParams,
|
|
6
|
+
} from "@modelcontextprotocol/sdk/server/auth/provider.js";
|
|
7
|
+
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
|
|
8
|
+
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
|
9
|
+
import type {
|
|
10
|
+
OAuthClientInformationFull,
|
|
11
|
+
OAuthTokens,
|
|
12
|
+
OAuthTokenRevocationRequest,
|
|
13
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
14
|
+
|
|
15
|
+
// --- In-memory stores ---
|
|
16
|
+
|
|
17
|
+
interface PendingAuth {
|
|
18
|
+
clientId: string;
|
|
19
|
+
redirectUri: string;
|
|
20
|
+
state?: string;
|
|
21
|
+
codeChallenge: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface AuthCode {
|
|
26
|
+
token: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
companyId: string;
|
|
29
|
+
expiresAt: string;
|
|
30
|
+
codeChallenge: string;
|
|
31
|
+
redirectUri: string;
|
|
32
|
+
clientId: string;
|
|
33
|
+
createdAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
37
|
+
|
|
38
|
+
export class OpseeClientStore implements OAuthRegisteredClientsStore {
|
|
39
|
+
private clients = new Map<string, OAuthClientInformationFull>();
|
|
40
|
+
|
|
41
|
+
getClient(clientId: string): OAuthClientInformationFull | undefined {
|
|
42
|
+
const known = this.clients.get(clientId);
|
|
43
|
+
if (known) return known;
|
|
44
|
+
|
|
45
|
+
// Accept any client ID — required for stateless multi-replica deployments
|
|
46
|
+
// where registration may have happened on a different pod.
|
|
47
|
+
// The OAuth flow itself (PKCE + auth code) provides the security.
|
|
48
|
+
return {
|
|
49
|
+
client_id: clientId,
|
|
50
|
+
redirect_uris: [],
|
|
51
|
+
token_endpoint_auth_method: "none",
|
|
52
|
+
grant_types: ["authorization_code"],
|
|
53
|
+
response_types: ["code"],
|
|
54
|
+
} as unknown as OAuthClientInformationFull;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
registerClient(
|
|
58
|
+
client: Omit<OAuthClientInformationFull, "client_id" | "client_id_issued_at">,
|
|
59
|
+
): OAuthClientInformationFull {
|
|
60
|
+
const clientId = randomUUID();
|
|
61
|
+
const clientSecret = randomBytes(32).toString("hex");
|
|
62
|
+
const registered: OAuthClientInformationFull = {
|
|
63
|
+
...client,
|
|
64
|
+
client_id: clientId,
|
|
65
|
+
client_secret: clientSecret,
|
|
66
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
67
|
+
};
|
|
68
|
+
this.clients.set(clientId, registered);
|
|
69
|
+
return registered;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
74
|
+
private _clientsStore = new OpseeClientStore();
|
|
75
|
+
private pendingAuths = new Map<string, PendingAuth>();
|
|
76
|
+
private authCodes = new Map<string, AuthCode>();
|
|
77
|
+
private revokedTokens = new Set<string>();
|
|
78
|
+
|
|
79
|
+
private serverUrl: string;
|
|
80
|
+
private appUrl: string;
|
|
81
|
+
|
|
82
|
+
constructor(serverUrl: string, appUrl?: string) {
|
|
83
|
+
this.serverUrl = serverUrl;
|
|
84
|
+
this.appUrl = appUrl || process.env.OPSEE_APP_URL || "https://opsee.ai";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get clientsStore(): OAuthRegisteredClientsStore {
|
|
88
|
+
return this._clientsStore;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Step 1: Claude calls /authorize. We redirect to Opsee's login UI.
|
|
93
|
+
*/
|
|
94
|
+
async authorize(
|
|
95
|
+
client: OAuthClientInformationFull,
|
|
96
|
+
params: AuthorizationParams,
|
|
97
|
+
res: Response,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const pendingId = randomBytes(16).toString("hex");
|
|
100
|
+
|
|
101
|
+
this.pendingAuths.set(pendingId, {
|
|
102
|
+
clientId: client.client_id,
|
|
103
|
+
redirectUri: params.redirectUri,
|
|
104
|
+
state: params.state,
|
|
105
|
+
codeChallenge: params.codeChallenge,
|
|
106
|
+
createdAt: Date.now(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Build callback URL back to our server
|
|
110
|
+
const callbackUrl = `${this.serverUrl}/oauth/callback?pending=${pendingId}`;
|
|
111
|
+
const loginUrl = `${this.appUrl}/auth/mcp?callback=${encodeURIComponent(callbackUrl)}`;
|
|
112
|
+
|
|
113
|
+
res.redirect(loginUrl);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Called by SDK to get the code_challenge for PKCE validation.
|
|
118
|
+
*/
|
|
119
|
+
async challengeForAuthorizationCode(
|
|
120
|
+
_client: OAuthClientInformationFull,
|
|
121
|
+
authorizationCode: string,
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
const code = this.authCodes.get(authorizationCode);
|
|
124
|
+
if (!code) throw new Error("Invalid authorization code");
|
|
125
|
+
return code.codeChallenge;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Step 3: Claude exchanges auth code for access token.
|
|
130
|
+
*/
|
|
131
|
+
async exchangeAuthorizationCode(
|
|
132
|
+
_client: OAuthClientInformationFull,
|
|
133
|
+
authorizationCode: string,
|
|
134
|
+
): Promise<OAuthTokens> {
|
|
135
|
+
const code = this.authCodes.get(authorizationCode);
|
|
136
|
+
if (!code) throw new Error("Invalid or expired authorization code");
|
|
137
|
+
|
|
138
|
+
if (Date.now() - code.createdAt > TTL_MS) {
|
|
139
|
+
this.authCodes.delete(authorizationCode);
|
|
140
|
+
throw new Error("Authorization code expired");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// One-time use
|
|
144
|
+
this.authCodes.delete(authorizationCode);
|
|
145
|
+
|
|
146
|
+
// Calculate expires_in from the JWT's expiresAt
|
|
147
|
+
let expiresIn: number | undefined;
|
|
148
|
+
if (code.expiresAt) {
|
|
149
|
+
const expMs = new Date(code.expiresAt).getTime() - Date.now();
|
|
150
|
+
expiresIn = Math.max(0, Math.floor(expMs / 1000));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
access_token: code.token,
|
|
155
|
+
token_type: "bearer",
|
|
156
|
+
expires_in: expiresIn,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
|
161
|
+
throw new Error("Refresh tokens are not supported. Re-authenticate to get a new token.");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Validate the access token (JWT from Opsee backend).
|
|
166
|
+
* We do lightweight validation here; the backend does full validation on API calls.
|
|
167
|
+
*/
|
|
168
|
+
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
|
169
|
+
if (this.revokedTokens.has(token)) {
|
|
170
|
+
throw new Error("Token has been revoked");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Decode JWT payload without crypto verification
|
|
174
|
+
const parts = token.split(".");
|
|
175
|
+
if (parts.length !== 3) throw new Error("Invalid token format");
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const payload = JSON.parse(
|
|
179
|
+
Buffer.from(parts[1], "base64url").toString("utf-8"),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Check expiry
|
|
183
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
184
|
+
throw new Error("Token expired");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
token,
|
|
189
|
+
clientId: "opsee",
|
|
190
|
+
scopes: [],
|
|
191
|
+
expiresAt: payload.exp,
|
|
192
|
+
extra: {
|
|
193
|
+
userId: payload.userId,
|
|
194
|
+
companyId: payload.companyId,
|
|
195
|
+
role: payload.role,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err instanceof Error && err.message === "Token expired") throw err;
|
|
200
|
+
throw new Error("Invalid token");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async revokeToken(
|
|
205
|
+
_client: OAuthClientInformationFull,
|
|
206
|
+
request: OAuthTokenRevocationRequest,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
this.revokedTokens.add(request.token);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Custom methods for the callback flow ---
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Step 2: Opsee login redirects back to /oauth/callback.
|
|
215
|
+
* We generate an auth code and redirect to Claude's redirect_uri.
|
|
216
|
+
*/
|
|
217
|
+
handleCallback(
|
|
218
|
+
pendingId: string,
|
|
219
|
+
token: string,
|
|
220
|
+
userId: string,
|
|
221
|
+
companyId: string,
|
|
222
|
+
expiresAt: string,
|
|
223
|
+
): { redirectUri: string } | { error: string } {
|
|
224
|
+
const pending = this.pendingAuths.get(pendingId);
|
|
225
|
+
if (!pending) return { error: "Invalid or expired pending authorization" };
|
|
226
|
+
|
|
227
|
+
if (Date.now() - pending.createdAt > TTL_MS) {
|
|
228
|
+
this.pendingAuths.delete(pendingId);
|
|
229
|
+
return { error: "Pending authorization expired" };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// One-time use
|
|
233
|
+
this.pendingAuths.delete(pendingId);
|
|
234
|
+
|
|
235
|
+
// Generate authorization code
|
|
236
|
+
const authCode = randomBytes(32).toString("hex");
|
|
237
|
+
this.authCodes.set(authCode, {
|
|
238
|
+
token,
|
|
239
|
+
userId,
|
|
240
|
+
companyId,
|
|
241
|
+
expiresAt,
|
|
242
|
+
codeChallenge: pending.codeChallenge,
|
|
243
|
+
redirectUri: pending.redirectUri,
|
|
244
|
+
clientId: pending.clientId,
|
|
245
|
+
createdAt: Date.now(),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Build redirect URL with code and state
|
|
249
|
+
const url = new URL(pending.redirectUri);
|
|
250
|
+
url.searchParams.set("code", authCode);
|
|
251
|
+
if (pending.state) url.searchParams.set("state", pending.state);
|
|
252
|
+
|
|
253
|
+
return { redirectUri: url.toString() };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Periodic cleanup of expired entries */
|
|
257
|
+
cleanup(): void {
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
for (const [id, entry] of this.pendingAuths) {
|
|
260
|
+
if (now - entry.createdAt > TTL_MS) this.pendingAuths.delete(id);
|
|
261
|
+
}
|
|
262
|
+
for (const [code, entry] of this.authCodes) {
|
|
263
|
+
if (now - entry.createdAt > TTL_MS) this.authCodes.delete(code);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
interface TokenContext {
|
|
4
|
+
token: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const tokenContext = new AsyncLocalStorage<TokenContext>();
|
|
8
|
+
|
|
9
|
+
export function getCurrentToken(): string | null {
|
|
10
|
+
return tokenContext.getStore()?.token ?? null;
|
|
11
|
+
}
|
package/src/client/api.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createConnectTransport } from "@connectrpc/connect-node";
|
|
|
3
3
|
import { create } from "@bufbuild/protobuf";
|
|
4
4
|
import type { GenService } from "@bufbuild/protobuf/codegenv1";
|
|
5
5
|
import { authManager } from "../auth/manager.js";
|
|
6
|
+
import { getCurrentToken } from "../auth/token-context.js";
|
|
6
7
|
import {
|
|
7
8
|
PaginationSchema,
|
|
8
9
|
type Pagination,
|
|
@@ -21,7 +22,9 @@ import { UserService } from "../../gen/api/v1/user_pb.js";
|
|
|
21
22
|
import { VCSIntegrationService } from "../../gen/api/v1/vcs_integration_pb.js";
|
|
22
23
|
|
|
23
24
|
const authInterceptor: Interceptor = (next) => async (req) => {
|
|
24
|
-
|
|
25
|
+
// Remote mode: token from AsyncLocalStorage (per-request)
|
|
26
|
+
// Local mode: token from credentials file
|
|
27
|
+
const token = getCurrentToken() || authManager.getToken();
|
|
25
28
|
if (token) {
|
|
26
29
|
req.header.set("Authorization", `Bearer ${token}`);
|
|
27
30
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
|
+
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
7
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
8
|
+
import { OpseeOAuthProvider } from "./auth/oauth-provider.js";
|
|
9
|
+
import { tokenContext } from "./auth/token-context.js";
|
|
10
|
+
import { createServer } from "./server.js";
|
|
11
|
+
|
|
12
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
13
|
+
<html>
|
|
14
|
+
<head><title>Opsee MCP - Connected</title></head>
|
|
15
|
+
<body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f8f9fa;">
|
|
16
|
+
<div style="text-align: center; padding: 2rem;">
|
|
17
|
+
<h1 style="color: #10b981;">Connected!</h1>
|
|
18
|
+
<p>Your Opsee account is now linked. You can close this window.</p>
|
|
19
|
+
</div>
|
|
20
|
+
</body>
|
|
21
|
+
</html>`;
|
|
22
|
+
|
|
23
|
+
export async function startHttpServer(): Promise<void> {
|
|
24
|
+
const port = parseInt(process.env.MCP_PORT || "3100", 10);
|
|
25
|
+
const host = process.env.MCP_HOST || "0.0.0.0";
|
|
26
|
+
const serverUrl =
|
|
27
|
+
process.env.MCP_SERVER_URL || `http://localhost:${port}`;
|
|
28
|
+
const backendUrl =
|
|
29
|
+
process.env.OPSEE_API_URL || "https://grpc.api.opsee.ai";
|
|
30
|
+
|
|
31
|
+
const provider = new OpseeOAuthProvider(serverUrl);
|
|
32
|
+
const issuerUrl = new URL(serverUrl);
|
|
33
|
+
|
|
34
|
+
// Periodically clean up expired auth entries
|
|
35
|
+
setInterval(() => provider.cleanup(), 60_000);
|
|
36
|
+
|
|
37
|
+
const app = express();
|
|
38
|
+
// Trust proxy headers (X-Forwarded-For) from nginx ingress
|
|
39
|
+
app.set("trust proxy", 1);
|
|
40
|
+
app.use(cors());
|
|
41
|
+
app.use(express.json());
|
|
42
|
+
|
|
43
|
+
// --- Health check ---
|
|
44
|
+
app.get("/health", (_req, res) => {
|
|
45
|
+
res.json({ status: "ok" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// --- OAuth 2.0 endpoints (authorize, token, register, metadata) ---
|
|
49
|
+
app.use(
|
|
50
|
+
mcpAuthRouter({
|
|
51
|
+
provider,
|
|
52
|
+
issuerUrl,
|
|
53
|
+
serviceDocumentationUrl: new URL(
|
|
54
|
+
"https://github.com/ArtisanCloud/opsee",
|
|
55
|
+
),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// --- Custom OAuth callback (Opsee login redirects here) ---
|
|
60
|
+
app.get("/oauth/callback", (req, res) => {
|
|
61
|
+
const { pending, token, userId, companyId, expiresAt } = req.query as Record<string, string>;
|
|
62
|
+
|
|
63
|
+
if (!pending || !token) {
|
|
64
|
+
res.status(400).send("Missing pending or token parameter");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = provider.handleCallback(
|
|
69
|
+
pending,
|
|
70
|
+
token,
|
|
71
|
+
userId || "",
|
|
72
|
+
companyId || "",
|
|
73
|
+
expiresAt || "",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if ("error" in result) {
|
|
77
|
+
res.status(400).send(result.error);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Redirect to Claude's redirect_uri with the auth code
|
|
82
|
+
res.redirect(result.redirectUri);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// --- Bearer auth middleware for MCP endpoints ---
|
|
86
|
+
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(new URL(serverUrl));
|
|
87
|
+
const authMiddleware = requireBearerAuth({
|
|
88
|
+
verifier: provider,
|
|
89
|
+
resourceMetadataUrl,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// --- MCP Streamable HTTP transport (stateless mode) ---
|
|
93
|
+
// Each request creates a fresh transport+server — no session persistence needed.
|
|
94
|
+
// This works reliably behind proxies/load balancers and with Claude Code's HTTP transport.
|
|
95
|
+
|
|
96
|
+
app.all("/mcp", authMiddleware, async (req, res) => {
|
|
97
|
+
// Extract the verified JWT from the auth middleware
|
|
98
|
+
const accessToken = req.auth?.token;
|
|
99
|
+
|
|
100
|
+
// Wrap the MCP handling in the token context so API calls use this user's JWT
|
|
101
|
+
await tokenContext.run({ token: accessToken || "" }, async () => {
|
|
102
|
+
const transport = new StreamableHTTPServerTransport({
|
|
103
|
+
sessionIdGenerator: undefined, // stateless — no session IDs
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const mcpServer = createServer();
|
|
107
|
+
await mcpServer.connect(transport);
|
|
108
|
+
await transport.handleRequest(req, res, req.body);
|
|
109
|
+
await transport.close();
|
|
110
|
+
await mcpServer.close();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
app.listen(port, host, () => {
|
|
115
|
+
console.log(`Opsee MCP server (remote) listening on ${host}:${port}`);
|
|
116
|
+
console.log(` MCP endpoint: ${serverUrl}/mcp`);
|
|
117
|
+
console.log(` OAuth authorize: ${serverUrl}/authorize`);
|
|
118
|
+
console.log(` Backend: ${backendUrl}`);
|
|
119
|
+
});
|
|
120
|
+
}
|
package/src/tools/cycles.ts
CHANGED
|
@@ -12,6 +12,7 @@ export function registerCycleTools(
|
|
|
12
12
|
"opsee_list_cycles",
|
|
13
13
|
"List cycles/sprints in an Opsee project.",
|
|
14
14
|
{ projectId: z.number().describe("The project ID") },
|
|
15
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
15
16
|
async ({ projectId }) => {
|
|
16
17
|
try {
|
|
17
18
|
const clients = getClients();
|
|
@@ -27,6 +28,7 @@ export function registerCycleTools(
|
|
|
27
28
|
"opsee_get_cycle",
|
|
28
29
|
"Get details of a specific cycle/sprint by ID.",
|
|
29
30
|
{ cycleId: z.number().describe("The cycle ID") },
|
|
31
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
30
32
|
async ({ cycleId }) => {
|
|
31
33
|
try {
|
|
32
34
|
const clients = getClients();
|
|
@@ -49,6 +51,7 @@ export function registerCycleTools(
|
|
|
49
51
|
endDate: z.string().describe("End date (ISO 8601, e.g. 2026-04-08)"),
|
|
50
52
|
description: z.string().optional().describe("Cycle description"),
|
|
51
53
|
},
|
|
54
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
52
55
|
async ({ projectId, name, startDate, endDate, description }) => {
|
|
53
56
|
try {
|
|
54
57
|
const clients = getClients();
|
package/src/tools/docs.ts
CHANGED
|
@@ -29,6 +29,7 @@ export function registerDocTools(
|
|
|
29
29
|
"opsee_list_doc_spaces",
|
|
30
30
|
"List documentation spaces in an Opsee project.",
|
|
31
31
|
{ projectId: z.number().describe("The project ID") },
|
|
32
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
32
33
|
async ({ projectId }) => {
|
|
33
34
|
try {
|
|
34
35
|
const clients = getClients();
|
|
@@ -55,6 +56,7 @@ export function registerDocTools(
|
|
|
55
56
|
"opsee_list_doc_pages",
|
|
56
57
|
"List documentation pages in a doc space.",
|
|
57
58
|
{ docSpaceId: z.number().describe("The doc space ID") },
|
|
59
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
58
60
|
async ({ docSpaceId }) => {
|
|
59
61
|
try {
|
|
60
62
|
const clients = getClients();
|
|
@@ -81,6 +83,7 @@ export function registerDocTools(
|
|
|
81
83
|
"opsee_get_doc_page",
|
|
82
84
|
"Read a documentation page's content by ID.",
|
|
83
85
|
{ pageId: z.number().describe("The doc page ID") },
|
|
86
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
84
87
|
async ({ pageId }) => {
|
|
85
88
|
try {
|
|
86
89
|
const clients = getClients();
|
|
@@ -103,6 +106,7 @@ export function registerDocTools(
|
|
|
103
106
|
content: z.string().describe("Page content (text or JSON)"),
|
|
104
107
|
parentPageId: z.number().optional().describe("Parent page ID for nested pages"),
|
|
105
108
|
},
|
|
109
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
106
110
|
async ({ projectId, title, content, parentPageId }) => {
|
|
107
111
|
try {
|
|
108
112
|
const clients = getClients();
|
package/src/tools/projects.ts
CHANGED
|
@@ -11,6 +11,7 @@ export function registerProjectTools(
|
|
|
11
11
|
"opsee_list_projects",
|
|
12
12
|
"List all Opsee projects the authenticated user has access to.",
|
|
13
13
|
{},
|
|
14
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
14
15
|
async () => {
|
|
15
16
|
try {
|
|
16
17
|
const clients = getClients();
|
|
@@ -26,6 +27,7 @@ export function registerProjectTools(
|
|
|
26
27
|
"opsee_get_project",
|
|
27
28
|
"Get details of a specific Opsee project by ID.",
|
|
28
29
|
{ projectId: z.number().describe("The project ID") },
|
|
30
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
29
31
|
async ({ projectId }) => {
|
|
30
32
|
try {
|
|
31
33
|
const clients = getClients();
|
|
@@ -11,6 +11,7 @@ export function registerRepositoryTools(
|
|
|
11
11
|
"opsee_list_repositories",
|
|
12
12
|
"List connected VCS repositories (GitHub/GitLab) for an Opsee project.",
|
|
13
13
|
{ projectId: z.number().describe("The project ID") },
|
|
14
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
14
15
|
async ({ projectId }) => {
|
|
15
16
|
try {
|
|
16
17
|
const clients = getClients();
|
|
@@ -11,6 +11,7 @@ export function registerTaskMetadataTools(
|
|
|
11
11
|
"opsee_list_task_types",
|
|
12
12
|
"Get available task types (Bug, Feature, etc.) for an Opsee project. Use these IDs when creating or updating tasks.",
|
|
13
13
|
{ projectId: z.number().describe("The project ID") },
|
|
14
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
14
15
|
async ({ projectId }) => {
|
|
15
16
|
try {
|
|
16
17
|
const clients = getClients();
|
|
@@ -32,6 +33,7 @@ export function registerTaskMetadataTools(
|
|
|
32
33
|
"opsee_list_task_priorities",
|
|
33
34
|
"Get priority levels (Critical, High, Medium, Low, etc.) for an Opsee project. Use these IDs when creating or updating tasks.",
|
|
34
35
|
{ projectId: z.number().describe("The project ID") },
|
|
36
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
35
37
|
async ({ projectId }) => {
|
|
36
38
|
try {
|
|
37
39
|
const clients = getClients();
|
|
@@ -53,6 +55,7 @@ export function registerTaskMetadataTools(
|
|
|
53
55
|
"opsee_list_boards",
|
|
54
56
|
"List Kanban boards for an Opsee project. Use the board ID to list board columns.",
|
|
55
57
|
{ projectId: z.number().describe("The project ID") },
|
|
58
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
56
59
|
async ({ projectId }) => {
|
|
57
60
|
try {
|
|
58
61
|
const clients = getClients();
|
|
@@ -74,6 +77,7 @@ export function registerTaskMetadataTools(
|
|
|
74
77
|
"opsee_list_board_columns",
|
|
75
78
|
"Get board columns/statuses (To Do, In Progress, Done, etc.) for a board. Use these IDs when creating or updating tasks.",
|
|
76
79
|
{ boardId: z.number().describe("The board ID") },
|
|
80
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
77
81
|
async ({ boardId }) => {
|
|
78
82
|
try {
|
|
79
83
|
const clients = getClients();
|
package/src/tools/tasks.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function registerTaskTools(
|
|
|
25
25
|
page: z.number().optional().describe("Page number (default: 1)"),
|
|
26
26
|
pageSize: z.number().optional().describe("Items per page (default: 50)"),
|
|
27
27
|
},
|
|
28
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
28
29
|
async ({ projectId, columnId, assigneeId, cycleId, page, pageSize }) => {
|
|
29
30
|
try {
|
|
30
31
|
const clients = getClients();
|
|
@@ -57,6 +58,7 @@ export function registerTaskTools(
|
|
|
57
58
|
"opsee_get_task",
|
|
58
59
|
"Get full details of a specific task by ID.",
|
|
59
60
|
{ taskId: z.number().describe("The task ID") },
|
|
61
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
60
62
|
async ({ taskId }) => {
|
|
61
63
|
try {
|
|
62
64
|
const clients = getClients();
|
|
@@ -82,6 +84,7 @@ export function registerTaskTools(
|
|
|
82
84
|
assigneeId: z.number().optional().describe("Assigned user ID"),
|
|
83
85
|
cycleId: z.number().optional().describe("Cycle/sprint ID"),
|
|
84
86
|
},
|
|
87
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
85
88
|
async ({ projectId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId }) => {
|
|
86
89
|
try {
|
|
87
90
|
const clients = getClients();
|
|
@@ -178,6 +181,7 @@ export function registerTaskTools(
|
|
|
178
181
|
assigneeId: z.number().optional().describe("New assigned user ID"),
|
|
179
182
|
cycleId: z.number().optional().describe("New cycle/sprint ID"),
|
|
180
183
|
},
|
|
184
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
181
185
|
async ({ taskId, title, description, taskTypeId, priorityId, boardColumnId, assigneeId, cycleId }) => {
|
|
182
186
|
try {
|
|
183
187
|
const clients = getClients();
|
package/src/tools/user.ts
CHANGED