@clien-ai/mcp 0.1.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 +221 -0
- package/dist/auth/oauth.js +468 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/pkce.js +31 -0
- package/dist/auth/pkce.js.map +1 -0
- package/dist/auth/storage.js +371 -0
- package/dist/auth/storage.js.map +1 -0
- package/dist/cli.js +280 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/authorize.js +57 -0
- package/dist/commands/authorize.js.map +1 -0
- package/dist/commands/install.js +144 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/logout.js +35 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/status.js +70 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/config.js +48 -0
- package/dist/config.js.map +1 -0
- package/dist/server.js +189 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/research.js +453 -0
- package/dist/tools/research.js.map +1 -0
- package/dist/types/report.js +60 -0
- package/dist/types/report.js.map +1 -0
- package/dist/util/atomic-write.js +43 -0
- package/dist/util/atomic-write.js.map +1 -0
- package/dist/util/exit-codes.js +97 -0
- package/dist/util/exit-codes.js.map +1 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# @clien-ai/mcp
|
|
2
|
+
|
|
3
|
+
Run a full Clien.ai product-research pass from Claude Code. One tool —
|
|
4
|
+
`clien_research` — that takes an idea, runs a 15-25 minute deep research pass
|
|
5
|
+
on our sandbox, and hands you back a markdown report plus structured
|
|
6
|
+
`report_data` (hypotheses, competitors, key findings).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx @clien-ai/mcp install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Restart Claude Code, then ask:
|
|
17
|
+
|
|
18
|
+
> run a deep research pass on my product idea
|
|
19
|
+
|
|
20
|
+
The first call opens your browser for a one-time OAuth login. The refresh
|
|
21
|
+
token is stored in your OS keyring (or an encrypted file if the keyring is
|
|
22
|
+
unavailable). Subsequent calls reuse it silently.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Manual install
|
|
27
|
+
|
|
28
|
+
If you prefer to edit Claude Code's config yourself, add this to
|
|
29
|
+
`~/.claude.json`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"clien": {
|
|
35
|
+
"command": "npx",
|
|
36
|
+
"args": ["-y", "@clien-ai/mcp"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Restart Claude Code. First call triggers OAuth.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## What the tool returns
|
|
47
|
+
|
|
48
|
+
```jsonc
|
|
49
|
+
{
|
|
50
|
+
"content": [{ "type": "text", "text": "<full markdown report>" }],
|
|
51
|
+
"_meta": {
|
|
52
|
+
"job_id": "...",
|
|
53
|
+
"status": "complete",
|
|
54
|
+
"report_data": {
|
|
55
|
+
"hypotheses": [...],
|
|
56
|
+
"competitors": [...],
|
|
57
|
+
"key_findings": [...],
|
|
58
|
+
// ...the full structured report
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The markdown is what a human reads. The structured `report_data` is what you
|
|
65
|
+
pass back to Claude Code when you want it to reason about the research
|
|
66
|
+
further — "find the strongest competitor", "draft a positioning doc against
|
|
67
|
+
the top 3 threats", etc.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Inputs
|
|
72
|
+
|
|
73
|
+
| Field | Type | Required | Default | Notes |
|
|
74
|
+
|----------------|--------------------------|----------|---------|--------------------------------------------------------------------------------------------------|
|
|
75
|
+
| `idea` | string (1-10,000 chars) | yes | | The idea, hypothesis, or question |
|
|
76
|
+
| `depth` | `"quick"` \| `"deep"` | no | `deep` | `quick` caps cost; `deep` is the full 15-25 min pass |
|
|
77
|
+
| `project_id` | UUID | no | | Existing Clien.ai project to attach the run to (returned in `_meta.project_id` from prior runs) |
|
|
78
|
+
| `project_name` | string (1-100 chars) | no | | Name for a new project. Supply when `project_id` is not set so the report is discoverable in the web UI. Ignored when `project_id` is also provided. |
|
|
79
|
+
|
|
80
|
+
## Outputs
|
|
81
|
+
|
|
82
|
+
The tool returns markdown content plus `_meta`:
|
|
83
|
+
|
|
84
|
+
| `_meta` field | Type | Notes |
|
|
85
|
+
|--------------------|-----------------|-------------------------------------------------------------------------------------|
|
|
86
|
+
| `job_id` | UUID | The validation job's ID |
|
|
87
|
+
| `status` | string | Terminal status: `complete`, `failed`, or `cancelled` |
|
|
88
|
+
| `project_id` | UUID \| `null` | The project this run is attached to. Pass back as `project_id` on follow-up runs. |
|
|
89
|
+
| `report_data` | object \| `null`| Structured report (hypotheses, competitors, key findings) |
|
|
90
|
+
| `total_cost_cents` | number | Optional. Total cost of the run, in cents. |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Progress
|
|
95
|
+
|
|
96
|
+
The MCP emits `notifications/progress` events every ~5 seconds during the
|
|
97
|
+
run. Claude Code keeps the tool call alive as long as progress flows
|
|
98
|
+
(verified in our Unit 0 spike with a 25-minute run).
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Troubleshooting
|
|
103
|
+
|
|
104
|
+
| Symptom | Likely cause | Fix |
|
|
105
|
+
|------------------------------------------------------------|----------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
|
|
106
|
+
| "Session expired during research" | Access + refresh both expired | Run `clien_research` again — triggers re-consent in browser |
|
|
107
|
+
| "Insufficient credits ..." | Credit balance at 0 | Buy a pack at <https://clien.ai/app/billing> |
|
|
108
|
+
| "All OAuth redirect ports [7873, 7874, 7875] are in use" | Another app is on all three loopback ports | Free one port and retry |
|
|
109
|
+
| "keytar not available" note in logs | libsecret missing (WSL, headless Linux) | Falls back to AES-256-GCM encrypted file at `~/.clien-ai/tokens.json.enc` — OK but weaker than the OS keyring |
|
|
110
|
+
| Browser never opens | Headless environment / container | See the **Devcontainer / remote SSH** section below |
|
|
111
|
+
| `tokens.json.enc` corrupt after a copy to a different host | Machine-id changed; encrypted file can't decrypt | Run `clien_research` — triggers new OAuth, overwrites the file |
|
|
112
|
+
|
|
113
|
+
### Reset authentication
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
# Drops the stored refresh token. Next tool call opens OAuth again.
|
|
117
|
+
npx @clien-ai/mcp logout
|
|
118
|
+
|
|
119
|
+
# --json for agent-pipeable output
|
|
120
|
+
npx @clien-ai/mcp logout --json
|
|
121
|
+
# {"ok":true,"hadTokenIn":"file"}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Check current state
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Snapshot of credentials state without triggering OAuth.
|
|
128
|
+
npx @clien-ai/mcp status
|
|
129
|
+
|
|
130
|
+
# @clien-ai/mcp v0.1.0
|
|
131
|
+
#
|
|
132
|
+
# Backend: file
|
|
133
|
+
# Has token: yes
|
|
134
|
+
# Last refreshed: 2026-04-27T14:32:18.421Z (3h ago)
|
|
135
|
+
|
|
136
|
+
# --json for agents
|
|
137
|
+
npx @clien-ai/mcp status --json
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Headless / CI authentication
|
|
141
|
+
|
|
142
|
+
For non-interactive environments (devcontainers, CI runners, remote SSH), the
|
|
143
|
+
loopback OAuth flow can't open a browser. Two ways to authenticate without a
|
|
144
|
+
TTY:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# 1) Pre-seed via env var (skips OAuth entirely on first call):
|
|
148
|
+
export CLIEN_MCP_REFRESH_TOKEN=<refresh_token_from_a_prior_browser_run>
|
|
149
|
+
npx @clien-ai/mcp
|
|
150
|
+
|
|
151
|
+
# 2) Or persist the seed once with the authorize subcommand:
|
|
152
|
+
npx @clien-ai/mcp authorize --token <refresh_token>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
If neither is set in a non-TTY context (CI / devcontainer / SSH session
|
|
156
|
+
without a browser), the OAuth flow opens a loopback server and waits up to
|
|
157
|
+
5 minutes for the callback. After that timeout it surfaces `oauth_timeout`
|
|
158
|
+
with the authorize URL — re-run after seeding the env var or the
|
|
159
|
+
`authorize` subcommand. (The MCP intentionally does NOT short-circuit on
|
|
160
|
+
non-TTY because Claude Code launches the server over stdio, which is also
|
|
161
|
+
non-TTY but is the primary production path.)
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Devcontainer / remote SSH / Docker
|
|
166
|
+
|
|
167
|
+
**Run the MCP on your local machine, not inside a container or a remote
|
|
168
|
+
shell.** The OAuth handshake opens a browser on `http://localhost:787x/callback`,
|
|
169
|
+
which won't reach a containerized or remote process. Claude Code already
|
|
170
|
+
runs locally; the MCP subprocess it spawns runs locally too by default.
|
|
171
|
+
|
|
172
|
+
If you develop inside a devcontainer and want to use Clien.ai, install and
|
|
173
|
+
configure the MCP in your host's Claude Code, not the one inside the
|
|
174
|
+
container.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Env overrides (for development)
|
|
179
|
+
|
|
180
|
+
| Variable | Purpose |
|
|
181
|
+
|-----------------------------|----------------------------------------------------|
|
|
182
|
+
| `CLIEN_MCP_ENV=test` | Use the test Supabase project + test client id |
|
|
183
|
+
| `CLIEN_MCP_SERVER_URL` | Override the Clien.ai HTTP base URL |
|
|
184
|
+
| `CLIEN_MCP_SUPABASE_URL` | Override the Supabase auth base URL |
|
|
185
|
+
| `CLIEN_MCP_CLIENT_ID` | Override the OAuth client id |
|
|
186
|
+
| `CLIEN_MCP_POLL_INTERVAL_MS`| Change events poll cadence (default 5000 ms) |
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Privacy
|
|
191
|
+
|
|
192
|
+
Credentials never leave your machine. The refresh token is stored either in
|
|
193
|
+
your OS keyring (macOS Keychain, Windows Credential Manager, Linux libsecret)
|
|
194
|
+
or in an AES-256-GCM encrypted file keyed to your host. Clien.ai sees only
|
|
195
|
+
the bearer token on each API call — the same token your browser would send
|
|
196
|
+
from the web app.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## CLI exit codes
|
|
201
|
+
|
|
202
|
+
The CLI uses stable, documented exit codes so scripts and agents can branch on
|
|
203
|
+
failure class without parsing message strings.
|
|
204
|
+
|
|
205
|
+
| Code | Meaning |
|
|
206
|
+
|------|---------|
|
|
207
|
+
| 0 | success |
|
|
208
|
+
| 1 | generic / uncategorized error |
|
|
209
|
+
| 2 | authentication failed (`OAuthError`, refresh-token revoked, no-TTY without env seed) |
|
|
210
|
+
| 3 | filesystem operation failed (EACCES, ENOENT, ENOTDIR, ENOSPC, EROFS, ...) |
|
|
211
|
+
| 4 | network operation failed (AbortError, TimeoutError, fetch failed) |
|
|
212
|
+
| 5 | invalid arguments / unknown flag |
|
|
213
|
+
|
|
214
|
+
Pass `--debug` to any command to surface stack traces alongside the
|
|
215
|
+
human-readable error message.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
UNLICENSED — do not redistribute. Contact Clien.ai for commercial licensing.
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 PKCE client for the Clien.ai Supabase authorization server.
|
|
3
|
+
*
|
|
4
|
+
* Two paths:
|
|
5
|
+
* 1. refreshAccessToken(refresh) - bounce a stored refresh token for a new
|
|
6
|
+
* access token. Supabase rotates refresh tokens; we persist whatever the
|
|
7
|
+
* server returns.
|
|
8
|
+
* 2. runAuthorizationCodeFlow(config, openBrowser) - full PKCE dance:
|
|
9
|
+
* - bind loopback port (7873 then 7874 then 7875)
|
|
10
|
+
* - open the browser to /auth/v1/oauth/authorize with code_challenge + state
|
|
11
|
+
* - wait for the callback, verify state matches
|
|
12
|
+
* - exchange code at /auth/v1/oauth/token for {access, refresh}
|
|
13
|
+
*
|
|
14
|
+
* The `openBrowser` callback is injected so tests can stub it out.
|
|
15
|
+
*/
|
|
16
|
+
import { createServer, } from 'node:http';
|
|
17
|
+
import { URL, URLSearchParams } from 'node:url';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { generatePkcePair, generateState, } from './pkce.js';
|
|
20
|
+
// 30 seconds. Token endpoint should respond within a few hundred ms; longer
|
|
21
|
+
// means hung. Single bound covers both the network-issue case (#173) and the
|
|
22
|
+
// TypeError-preservation case (#165) — AbortError flows through the same
|
|
23
|
+
// catch path as connection-refused / DNS-down.
|
|
24
|
+
const TOKEN_FETCH_TIMEOUT_MS = 30_000;
|
|
25
|
+
// ------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ------------------------------------------------------------------
|
|
28
|
+
// Runtime schema for the token endpoint's success response. Replaces the
|
|
29
|
+
// previous `as TokenResponse` cast so a malformed body fails loudly at the
|
|
30
|
+
// boundary instead of returning `undefined` further down the stack.
|
|
31
|
+
//
|
|
32
|
+
// `TokenResponse` is derived directly from the schema so the runtime
|
|
33
|
+
// validator and the static type cannot drift. Adding a field requires
|
|
34
|
+
// touching one place.
|
|
35
|
+
const TokenResponseSchema = z.object({
|
|
36
|
+
access_token: z.string().min(1),
|
|
37
|
+
refresh_token: z.string().min(1),
|
|
38
|
+
expires_in: z.number().int().nonnegative().optional().default(3600),
|
|
39
|
+
token_type: z.string().optional().default('Bearer'),
|
|
40
|
+
scope: z.string().optional(),
|
|
41
|
+
});
|
|
42
|
+
export class OAuthError extends Error {
|
|
43
|
+
code;
|
|
44
|
+
constructor(code, message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.name = 'OAuthError';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ------------------------------------------------------------------
|
|
51
|
+
// Token endpoint
|
|
52
|
+
// ------------------------------------------------------------------
|
|
53
|
+
function tokenEndpoint(config) {
|
|
54
|
+
return `${config.supabaseUrl}/auth/v1/oauth/token?grant_type=authorization_code`;
|
|
55
|
+
}
|
|
56
|
+
function refreshEndpoint(config) {
|
|
57
|
+
return `${config.supabaseUrl}/auth/v1/oauth/token?grant_type=refresh_token`;
|
|
58
|
+
}
|
|
59
|
+
function authorizeEndpoint(config, redirectUri, challenge, state) {
|
|
60
|
+
const url = new URL(`${config.supabaseUrl}/auth/v1/oauth/authorize`);
|
|
61
|
+
url.searchParams.set('response_type', 'code');
|
|
62
|
+
url.searchParams.set('client_id', config.clientId);
|
|
63
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
64
|
+
url.searchParams.set('code_challenge', challenge);
|
|
65
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
66
|
+
url.searchParams.set('scope', config.scope);
|
|
67
|
+
url.searchParams.set('state', state);
|
|
68
|
+
return url.toString();
|
|
69
|
+
}
|
|
70
|
+
async function postForm(url, body) {
|
|
71
|
+
const params = new URLSearchParams(body);
|
|
72
|
+
// Wrap fetch in try/catch so raw network failures (DNS down, connection
|
|
73
|
+
// refused, TLS error) and AbortError (timeout) become typed OAuthErrors
|
|
74
|
+
// with code `oauth_network_error`. The previous code let TypeError leak
|
|
75
|
+
// out, which bypassed `ensureSession`'s guard and forced re-consent.
|
|
76
|
+
// See issue #165.
|
|
77
|
+
let response;
|
|
78
|
+
try {
|
|
79
|
+
response = await fetch(url, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
83
|
+
Accept: 'application/json',
|
|
84
|
+
},
|
|
85
|
+
body: params.toString(),
|
|
86
|
+
signal: AbortSignal.timeout(TOKEN_FETCH_TIMEOUT_MS),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err instanceof OAuthError)
|
|
91
|
+
throw err;
|
|
92
|
+
const e = err;
|
|
93
|
+
const reason = e.name === 'AbortError' || e.name === 'TimeoutError'
|
|
94
|
+
? `Token request timed out after ${TOKEN_FETCH_TIMEOUT_MS / 1000}s`
|
|
95
|
+
: (e.message ?? 'Token request failed before reaching the server');
|
|
96
|
+
throw new OAuthError('oauth_network_error', reason);
|
|
97
|
+
}
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const text = await response.text().catch(() => '');
|
|
100
|
+
let code = `oauth_${response.status}`;
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(text);
|
|
103
|
+
if (parsed.error)
|
|
104
|
+
code = parsed.error;
|
|
105
|
+
throw new OAuthError(code, parsed.error_description ?? parsed.error ?? text);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (err instanceof OAuthError)
|
|
109
|
+
throw err;
|
|
110
|
+
throw new OAuthError(code, `Token request failed: ${response.status} ${text}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const raw = await response.json().catch(() => null);
|
|
114
|
+
const parsed = TokenResponseSchema.safeParse(raw);
|
|
115
|
+
if (!parsed.success) {
|
|
116
|
+
throw new OAuthError('oauth_invalid_response', `Token response failed schema validation: ${parsed.error.issues
|
|
117
|
+
.map((i) => `${i.path.join('.') || 'root'}: ${i.message}`)
|
|
118
|
+
.join('; ')}`);
|
|
119
|
+
}
|
|
120
|
+
return parsed.data;
|
|
121
|
+
}
|
|
122
|
+
function toTokenSet(raw) {
|
|
123
|
+
const expiresInMs = Math.max(0, (raw.expires_in ?? 3600) * 1000);
|
|
124
|
+
// Apply 60s skew so we refresh a touch early.
|
|
125
|
+
return {
|
|
126
|
+
accessToken: raw.access_token,
|
|
127
|
+
refreshToken: raw.refresh_token,
|
|
128
|
+
expiresAt: Date.now() + Math.max(0, expiresInMs - 60_000),
|
|
129
|
+
tokenType: raw.token_type ?? 'Bearer',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Refresh an access token using a stored refresh token. */
|
|
133
|
+
export async function refreshAccessToken(config, refreshToken) {
|
|
134
|
+
const raw = await postForm(refreshEndpoint(config), {
|
|
135
|
+
grant_type: 'refresh_token',
|
|
136
|
+
refresh_token: refreshToken,
|
|
137
|
+
client_id: config.clientId,
|
|
138
|
+
});
|
|
139
|
+
return toTokenSet(raw);
|
|
140
|
+
}
|
|
141
|
+
async function tryBindPort(port) {
|
|
142
|
+
const server = createServer();
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
server.once('error', (err) => {
|
|
145
|
+
// Surface the actual errno (EADDRINUSE / EACCES / EMFILE / ENOBUFS / ...)
|
|
146
|
+
// so port_unavailable's message tells the user *why* — not all bind
|
|
147
|
+
// failures look the same and "in use" is misleading for EACCES/EMFILE.
|
|
148
|
+
resolve({ server: null, errorCode: err.code ?? 'EUNKNOWN' });
|
|
149
|
+
});
|
|
150
|
+
server.once('listening', () => {
|
|
151
|
+
resolve({ server, errorCode: null });
|
|
152
|
+
});
|
|
153
|
+
// Bind to 127.0.0.1 explicitly — loopback only, never external iface.
|
|
154
|
+
server.listen(port, '127.0.0.1');
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async function bindLoopback(ports) {
|
|
158
|
+
const failures = [];
|
|
159
|
+
for (const p of ports) {
|
|
160
|
+
const attempt = await tryBindPort(p);
|
|
161
|
+
if (attempt.server)
|
|
162
|
+
return { server: attempt.server, port: p };
|
|
163
|
+
failures.push({ port: p, code: attempt.errorCode ?? 'EUNKNOWN' });
|
|
164
|
+
}
|
|
165
|
+
const detail = failures.map((f) => `${f.port}=${f.code}`).join(', ');
|
|
166
|
+
throw new OAuthError('port_unavailable', `All OAuth redirect ports [${ports.join(', ')}] failed to bind on 127.0.0.1 (${detail}). ` +
|
|
167
|
+
'Free one of them and retry, or set CLIEN_MCP_OAUTH_PORT=<port>.');
|
|
168
|
+
}
|
|
169
|
+
/** Await a single /callback request; reject on timeout or bad state. */
|
|
170
|
+
function awaitCallback(server, expectedState, timeoutMs) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
cleanup();
|
|
174
|
+
reject(new OAuthError('oauth_timeout', `Did not receive OAuth callback within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
175
|
+
'Did you complete the browser consent?'));
|
|
176
|
+
}, timeoutMs);
|
|
177
|
+
const onRequest = (req, res) => {
|
|
178
|
+
try {
|
|
179
|
+
const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
180
|
+
if (reqUrl.pathname !== '/callback') {
|
|
181
|
+
res.writeHead(404);
|
|
182
|
+
res.end('Not found');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const err = reqUrl.searchParams.get('error');
|
|
186
|
+
if (err) {
|
|
187
|
+
const desc = reqUrl.searchParams.get('error_description') ?? err;
|
|
188
|
+
respondHtml(res, 400, errorPage(desc));
|
|
189
|
+
cleanup();
|
|
190
|
+
reject(new OAuthError(err, desc));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const code = reqUrl.searchParams.get('code');
|
|
194
|
+
const state = reqUrl.searchParams.get('state');
|
|
195
|
+
if (!code || !state) {
|
|
196
|
+
respondHtml(res, 400, errorPage('Missing code or state in callback.'));
|
|
197
|
+
cleanup();
|
|
198
|
+
reject(new OAuthError('invalid_callback', 'Missing code or state'));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (state !== expectedState) {
|
|
202
|
+
respondHtml(res, 400, errorPage('State mismatch — potential CSRF attempt.'));
|
|
203
|
+
cleanup();
|
|
204
|
+
reject(new OAuthError('state_mismatch', 'OAuth state did not match'));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
respondHtml(res, 200, successPage());
|
|
208
|
+
cleanup();
|
|
209
|
+
resolve({ code, state });
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
cleanup();
|
|
213
|
+
reject(e);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
server.on('request', onRequest);
|
|
217
|
+
function cleanup() {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
server.off('request', onRequest);
|
|
220
|
+
// Close the listener so the next OAuth flow can rebind the same port.
|
|
221
|
+
// Force-kill any lingering keep-alive sockets on Node 18+.
|
|
222
|
+
try {
|
|
223
|
+
server.closeAllConnections?.();
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// ignore
|
|
227
|
+
}
|
|
228
|
+
server.close(() => { });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function respondHtml(res, status, body) {
|
|
233
|
+
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
234
|
+
res.end(body);
|
|
235
|
+
}
|
|
236
|
+
function successPage() {
|
|
237
|
+
return `<!doctype html><html><head><title>Clien.ai — Connected</title>
|
|
238
|
+
<meta charset="utf-8"><style>
|
|
239
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
240
|
+
background: #FDFBF7; color: #1a1a1a; display: flex; min-height: 100vh;
|
|
241
|
+
align-items: center; justify-content: center; margin: 0; }
|
|
242
|
+
.card { max-width: 480px; text-align: center; padding: 2rem; }
|
|
243
|
+
h1 { color: #3B5CCC; margin-bottom: .5rem; }
|
|
244
|
+
p { color: #444; margin: .25rem 0; }
|
|
245
|
+
</style></head><body><div class="card">
|
|
246
|
+
<h1>Clien.ai connected</h1>
|
|
247
|
+
<p>You can return to Claude Code. Close this tab at your leisure.</p>
|
|
248
|
+
</div></body></html>`;
|
|
249
|
+
}
|
|
250
|
+
function errorPage(message) {
|
|
251
|
+
const safe = message.replace(/[<>&]/g, (c) => c === '<' ? '<' : c === '>' ? '>' : '&');
|
|
252
|
+
return `<!doctype html><html><head><title>Clien.ai — Error</title>
|
|
253
|
+
<meta charset="utf-8"><style>
|
|
254
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
255
|
+
background: #FDFBF7; color: #1a1a1a; display: flex; min-height: 100vh;
|
|
256
|
+
align-items: center; justify-content: center; margin: 0; }
|
|
257
|
+
.card { max-width: 480px; text-align: center; padding: 2rem; }
|
|
258
|
+
h1 { color: #E87B6D; margin-bottom: .5rem; }
|
|
259
|
+
p { color: #444; margin: .25rem 0; }
|
|
260
|
+
</style></head><body><div class="card">
|
|
261
|
+
<h1>Connection failed</h1>
|
|
262
|
+
<p>${safe}</p>
|
|
263
|
+
<p>Close this tab and try <code>clien_research</code> again.</p>
|
|
264
|
+
</div></body></html>`;
|
|
265
|
+
}
|
|
266
|
+
async function exchangeCode(config, code, redirectUri, pkce) {
|
|
267
|
+
const raw = await postForm(tokenEndpoint(config), {
|
|
268
|
+
grant_type: 'authorization_code',
|
|
269
|
+
code,
|
|
270
|
+
client_id: config.clientId,
|
|
271
|
+
redirect_uri: redirectUri,
|
|
272
|
+
code_verifier: pkce.verifier,
|
|
273
|
+
});
|
|
274
|
+
return toTokenSet(raw);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Run the full authorization code flow. On success, returns the token set
|
|
278
|
+
* (access + refresh). On failure, throws OAuthError with a stable code.
|
|
279
|
+
*/
|
|
280
|
+
export async function runAuthorizationCodeFlow(config, openBrowser) {
|
|
281
|
+
const pkce = generatePkcePair();
|
|
282
|
+
const state = generateState();
|
|
283
|
+
const { server, port } = await bindLoopback(config.oauthPorts);
|
|
284
|
+
const actualPort = server.address()?.port ?? port;
|
|
285
|
+
// RFC 8252 §7.3: prefer 127.0.0.1 over the symbolic `localhost` so that
|
|
286
|
+
// dual-stack hosts (Linux + Node 17+) don't try to connect to ::1 while
|
|
287
|
+
// we listen on the v4 loopback. See issue #170.
|
|
288
|
+
const redirectUri = `http://127.0.0.1:${actualPort}/callback`;
|
|
289
|
+
const authorizeUrl = authorizeEndpoint(config, redirectUri, pkce.challenge, state);
|
|
290
|
+
// Register the callback listener FIRST so we don't race the browser
|
|
291
|
+
// (important in tests where openBrowser is synchronous + awaited).
|
|
292
|
+
const callbackPromise = awaitCallback(server, state, config.oauthTimeoutMs);
|
|
293
|
+
// Attach a noop catch so Node doesn't warn about "unhandled rejection"
|
|
294
|
+
// if openBrowser is still running when the callback rejects — we still
|
|
295
|
+
// await this promise below.
|
|
296
|
+
callbackPromise.catch(() => { });
|
|
297
|
+
// Open the browser after the listener is wired up.
|
|
298
|
+
try {
|
|
299
|
+
await openBrowser(authorizeUrl);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
// Don't abort — the URL may also be logged; user can click manually.
|
|
303
|
+
// But surface as stderr so operators see the recovery path.
|
|
304
|
+
console.error('[clien-mcp] Failed to auto-open browser, please visit manually:', authorizeUrl, err);
|
|
305
|
+
}
|
|
306
|
+
const callback = await callbackPromise;
|
|
307
|
+
const tokens = await exchangeCode(config, callback.code, redirectUri, pkce);
|
|
308
|
+
return { tokens, redirectUri };
|
|
309
|
+
}
|
|
310
|
+
/** Read the env-var refresh-token seed for headless contexts (issue #169). */
|
|
311
|
+
function readEnvRefreshSeed() {
|
|
312
|
+
const v = process.env.CLIEN_MCP_REFRESH_TOKEN;
|
|
313
|
+
return v && v.length > 0 ? v : null;
|
|
314
|
+
}
|
|
315
|
+
const ALREADY_USED_CODES = new Set([
|
|
316
|
+
'invalid_grant',
|
|
317
|
+
'refresh_token_already_used',
|
|
318
|
+
'refresh_token_not_found',
|
|
319
|
+
]);
|
|
320
|
+
function isAlreadyUsedError(err) {
|
|
321
|
+
return err instanceof OAuthError && ALREADY_USED_CODES.has(err.code);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Ensure a valid session. On success: refresh token persisted via
|
|
325
|
+
* `saveRefreshToken`, access token returned in-memory for the current run.
|
|
326
|
+
*
|
|
327
|
+
* State machine:
|
|
328
|
+
* 1. CLIEN_MCP_REFRESH_TOKEN env seed -> use it; skip disk + browser.
|
|
329
|
+
* 2. Stored refresh -> take cross-process lock -> reload from disk ->
|
|
330
|
+
* refresh. On 'already-used' (peer rotated), reload + retry once.
|
|
331
|
+
* On `oauth_network_error`, bubble WITHOUT clearing storage (#165).
|
|
332
|
+
* On any other failure, clear and fall through.
|
|
333
|
+
* 3. Otherwise -> full browser OAuth.
|
|
334
|
+
*
|
|
335
|
+
* NOTE: A previous version of this code added a `process.stdin.isTTY` check
|
|
336
|
+
* before step 3 to fast-fail with `oauth_no_tty` in headless contexts. That
|
|
337
|
+
* check was removed because Claude Code launches the MCP server over stdio
|
|
338
|
+
* (stdin = pipe, isTTY === undefined), which is the PRIMARY production path.
|
|
339
|
+
* The TTY check broke the first-call user flow for every Claude Code user.
|
|
340
|
+
*
|
|
341
|
+
* For genuinely headless contexts (CI, devcontainers without a browser), the
|
|
342
|
+
* recommended path is to set `CLIEN_MCP_REFRESH_TOKEN` (skips OAuth entirely)
|
|
343
|
+
* or run `clien-mcp authorize --token <r>` from a TTY first to seed the
|
|
344
|
+
* stored token. If neither is set, the OAuth flow's existing 5-min timeout
|
|
345
|
+
* surfaces a clear error message at the end, which is recoverable.
|
|
346
|
+
*/
|
|
347
|
+
export async function ensureSession(opts) {
|
|
348
|
+
// 1. Headless seed via env var — skip disk + browser entirely.
|
|
349
|
+
const envSeed = readEnvRefreshSeed();
|
|
350
|
+
if (envSeed) {
|
|
351
|
+
const tokens = await refreshAccessToken(opts.config, envSeed);
|
|
352
|
+
// Best-effort persist so subsequent calls without the env var still work.
|
|
353
|
+
await Promise.resolve(opts.saveRefreshToken(tokens.refreshToken)).catch(() => { });
|
|
354
|
+
return makeSession(tokens, opts);
|
|
355
|
+
}
|
|
356
|
+
// 2. Stored refresh-token path, coordinated across processes by the lock.
|
|
357
|
+
const stored = await opts.loadRefreshToken();
|
|
358
|
+
let tokens = null;
|
|
359
|
+
if (stored) {
|
|
360
|
+
tokens = await opts.withTokenLock(async () => {
|
|
361
|
+
// Re-read inside the lock — a peer process may have rotated since our
|
|
362
|
+
// outer load (their save races our load).
|
|
363
|
+
const current = (await opts.loadRefreshToken()) ?? stored;
|
|
364
|
+
try {
|
|
365
|
+
const fresh = await refreshAccessToken(opts.config, current);
|
|
366
|
+
await opts.saveRefreshToken(fresh.refreshToken);
|
|
367
|
+
return fresh;
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
// CRITICAL: bubble network errors WITHOUT clearing storage. The
|
|
371
|
+
// previous code unconditionally cleared, defeating the guard. #165.
|
|
372
|
+
if (err instanceof OAuthError && err.code === 'oauth_network_error') {
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
// 'already-used' race: peer rotated between our reads. Reload and
|
|
376
|
+
// retry once; the disk now holds the rotated token.
|
|
377
|
+
if (isAlreadyUsedError(err)) {
|
|
378
|
+
const reloaded = await opts.loadRefreshToken();
|
|
379
|
+
if (reloaded && reloaded !== current) {
|
|
380
|
+
try {
|
|
381
|
+
const fresh = await refreshAccessToken(opts.config, reloaded);
|
|
382
|
+
await opts.saveRefreshToken(fresh.refreshToken);
|
|
383
|
+
return fresh;
|
|
384
|
+
}
|
|
385
|
+
catch (retryErr) {
|
|
386
|
+
if (retryErr instanceof OAuthError && retryErr.code === 'oauth_network_error') {
|
|
387
|
+
throw retryErr;
|
|
388
|
+
}
|
|
389
|
+
// fall through to clearTokens
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Genuine failure — clear and fall through to OAuth/seed path.
|
|
394
|
+
await opts.clearTokens().catch(() => { });
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// 3. Fall through to full browser OAuth. The OAuth flow opens a browser
|
|
400
|
+
// via the `open` package and waits for the loopback callback. In a true
|
|
401
|
+
// headless environment with no browser, the existing 5-min timeout
|
|
402
|
+
// surfaces a clear error — the recommended path for those users is the
|
|
403
|
+
// CLIEN_MCP_REFRESH_TOKEN env seed (handled in step 1) or running
|
|
404
|
+
// `clien-mcp authorize --token <r>` from a TTY first to seed the disk.
|
|
405
|
+
if (!tokens) {
|
|
406
|
+
const result = await runAuthorizationCodeFlow(opts.config, opts.openBrowser);
|
|
407
|
+
tokens = result.tokens;
|
|
408
|
+
await opts.saveRefreshToken(tokens.refreshToken);
|
|
409
|
+
}
|
|
410
|
+
return makeSession(tokens, opts);
|
|
411
|
+
}
|
|
412
|
+
/** Build the externally-visible Session object backed by mutable token state. */
|
|
413
|
+
function makeSession(initial, opts) {
|
|
414
|
+
const state = { current: initial };
|
|
415
|
+
return {
|
|
416
|
+
get accessToken() {
|
|
417
|
+
return state.current.accessToken;
|
|
418
|
+
},
|
|
419
|
+
get refreshToken() {
|
|
420
|
+
return state.current.refreshToken;
|
|
421
|
+
},
|
|
422
|
+
async refresh() {
|
|
423
|
+
const fresh = await opts.withTokenLock(async () => {
|
|
424
|
+
// Re-read disk inside the lock so a peer's recent rotation wins
|
|
425
|
+
// over our (potentially stale) in-memory copy.
|
|
426
|
+
const current = (await opts.loadRefreshToken()) ?? state.current.refreshToken;
|
|
427
|
+
try {
|
|
428
|
+
const refreshed = await refreshAccessToken(opts.config, current);
|
|
429
|
+
await opts.saveRefreshToken(refreshed.refreshToken);
|
|
430
|
+
return refreshed;
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
// Bubble transient errors WITHOUT clearing storage — same guard as
|
|
434
|
+
// ensureSession to avoid wiping a still-valid token on a network blip.
|
|
435
|
+
if (err instanceof OAuthError && err.code === 'oauth_network_error') {
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
if (isAlreadyUsedError(err)) {
|
|
439
|
+
const reloaded = await opts.loadRefreshToken();
|
|
440
|
+
if (reloaded && reloaded !== current) {
|
|
441
|
+
try {
|
|
442
|
+
const refreshed = await refreshAccessToken(opts.config, reloaded);
|
|
443
|
+
await opts.saveRefreshToken(refreshed.refreshToken);
|
|
444
|
+
return refreshed;
|
|
445
|
+
}
|
|
446
|
+
catch (retryErr) {
|
|
447
|
+
if (retryErr instanceof OAuthError && retryErr.code === 'oauth_network_error') {
|
|
448
|
+
throw retryErr;
|
|
449
|
+
}
|
|
450
|
+
// fall through to clearTokens
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Genuinely revoked refresh token — clear stored credentials so
|
|
455
|
+
// the next ensureSession run doesn't reload the same dead token
|
|
456
|
+
// and burn another round-trip. Symmetric with ensureSession's
|
|
457
|
+
// step-2 path. Best-effort: an unwritable HOME shouldn't mask
|
|
458
|
+
// the original refresh failure.
|
|
459
|
+
await opts.clearTokens().catch(() => { });
|
|
460
|
+
throw err;
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
state.current = fresh;
|
|
464
|
+
return fresh.accessToken;
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
//# sourceMappingURL=oauth.js.map
|