@ezmodo/mcp-server 0.13.5 → 0.14.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/README.md +112 -42
- package/handlers/auth.js +160 -0
- package/handlers/index.js +2 -0
- package/http.js +6 -1
- package/index.js +21 -52
- package/lib/auth-guidance.js +175 -0
- package/lib/cli-credential.js +3 -12
- package/lib/create-server.js +97 -4
- package/lib/credentials.js +106 -0
- package/lib/git-helpers.js +115 -52
- package/lib/http-client.js +25 -6
- package/lib/instructions.generated.js +14 -0
- package/lib/instructions.js +37 -0
- package/lib/oauth-config.js +98 -0
- package/lib/oauth.js +353 -0
- package/lib/remote-tools.js +6 -0
- package/lib/token-store.js +136 -0
- package/lib/user-paths.js +41 -0
- package/lib/version.js +1 -1
- package/package.json +9 -6
- package/prompts/commands.generated.js +52 -0
- package/prompts/index.js +62 -29
- package/scripts/build-instructions.mjs +76 -0
- package/scripts/build-prompts.mjs +144 -0
- package/tools/auth.js +34 -0
- package/tools/index.js +4 -0
- package/prompts/ai-workflow-automation.js +0 -96
- package/prompts/zephly-usage-guide.js +0 -119
package/lib/http-client.js
CHANGED
|
@@ -10,11 +10,13 @@ import { CONFIG } from '../config/index.js';
|
|
|
10
10
|
import { MCP_VERSION } from './version.js';
|
|
11
11
|
import { getLogger } from './logger.js';
|
|
12
12
|
import { getApiUrl } from './env.js';
|
|
13
|
-
// Not getApiKey() directly
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
13
|
+
// Not getApiKey() directly. Two reasons, both about the credential belonging to
|
|
14
|
+
// the CALL rather than the process: over HTTP one process serves many callers,
|
|
15
|
+
// each with their own key (#2599); and over stdio the credential may be an
|
|
16
|
+
// OAuth token this server obtained for itself, which can need refreshing before
|
|
17
|
+
// use (#2631). lib/credentials.js owns the precedence between them.
|
|
18
|
+
import { resolveCredential } from './credentials.js';
|
|
19
|
+
import { NOT_AUTHENTICATED } from './auth-guidance.js';
|
|
18
20
|
|
|
19
21
|
// API base URL is resolved per-request (see callZephlyAPI): getApiUrl() honors
|
|
20
22
|
// the EZMODO_API_URL / ZEPHLY_API_URL override; CONFIG.apiUrl is the build-time
|
|
@@ -82,8 +84,19 @@ export async function callZephlyAPI(endpoint, data) {
|
|
|
82
84
|
let url = `${apiUrl}/${route}`;
|
|
83
85
|
let body = null;
|
|
84
86
|
|
|
87
|
+
// Resolved per call, and awaited: an expired OAuth token refreshes here.
|
|
88
|
+
const credential = await resolveCredential();
|
|
89
|
+
if (!credential) {
|
|
90
|
+
// Coded, not just worded: lib/create-server.js turns this into sign-in
|
|
91
|
+
// guidance for the agent, and matching on a message would break the first
|
|
92
|
+
// time someone reworded it.
|
|
93
|
+
const error = new Error('Not authenticated with EzModo.');
|
|
94
|
+
error.code = NOT_AUTHENTICATED;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
|
|
85
98
|
const headers = {
|
|
86
|
-
'Authorization': `Bearer ${
|
|
99
|
+
'Authorization': `Bearer ${credential.token}`,
|
|
87
100
|
'Content-Type': 'application/json',
|
|
88
101
|
'User-Agent': `ezmodo-mcp-server/${MCP_VERSION}`,
|
|
89
102
|
'X-MCP-API-Version': 'v1',
|
|
@@ -150,6 +163,12 @@ export async function callZephlyAPI(endpoint, data) {
|
|
|
150
163
|
// status and retryable let callers back off on a 503 instead of giving up
|
|
151
164
|
// the way a 404 tells them to.
|
|
152
165
|
thrown.status = response.status;
|
|
166
|
+
// A machine-readable code, when the API sent one. Carried so a caller can
|
|
167
|
+
// branch on the KIND of failure rather than on its prose — the no-organization
|
|
168
|
+
// 403 (#2639) is answered with specific guidance, and every other 403 is not.
|
|
169
|
+
if (typeof error.code === 'string' && error.code) {
|
|
170
|
+
thrown.code = error.code;
|
|
171
|
+
}
|
|
153
172
|
if (typeof error.retryable === 'boolean') {
|
|
154
173
|
thrown.retryable = error.retryable;
|
|
155
174
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — DO NOT EDIT.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from plugins/ezmodo/skills/work-tracking/SKILL.md by
|
|
5
|
+
* scripts/build-instructions.mjs. Edit the SKILL, then run:
|
|
6
|
+
*
|
|
7
|
+
* npm run generate:instructions --workspace=@ezmodo/mcp-server
|
|
8
|
+
*
|
|
9
|
+
* __tests__/instructions.test.js fails if this drifts from the skill.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const WORK_TRACKING_CORE = 'Work in this repository is tracked in EzModo, and the tools for it are on this\nMCP server. The contract, in short:\n\n1. **Create the task before you edit, not after.** A task written afterwards is\n a changelog; a task written first is what the next session reads to find out\n what you were doing and why.\n2. **Start the session by calling `get_current_project_context()`.** Cache the\n `projectId`. It also returns the components, tags and `terminology` you need.\n No `.ezmodo/config.json` means this repo is not tracked — say so rather than\n guessing at a project.\n3. **Call `get_context` with a keyword query before creating anything.** Use\n what comes back to write a task that names real files, endpoints and\n patterns. A vague task is not worth the call that made it.\n4. **Name every component the work touches** via `componentIds`. A task spanning\n web and api belongs to both. The list REPLACES the previous set on update.\n5. **Set `taskType`** — `feature` | `bug` | `testing` | `chore`. Not cosmetic:\n `bug` feeds open-bug counts, milestone freezes gate on it, and estimation\n weights past tasks of the same kind.\n6. **Toggle steps as you finish them**, not in a batch at the end\n (`manage_task action:"update" toggleSteps:[...]`).\n7. **Capture knowledge the moment it happens**, not in a summary at the end:\n `addKnowledge` with `fact` for a root cause, `decision` for a choice —\n including what you rejected and why — `reference` for a key file or pattern,\n `context` for progress. Be specific: file paths, function names, exact\n errors. "Fixed a bug in the parser" helps nobody.\n8. **Already three edits in with no task?** Call `report_untracked_work` the\n moment you notice, rather than continuing untracked.\n9. **Resuming?** `get_task` first, and read ALL of its knowledge items. That is\n where the previous session\'s reasoning went — do not re-derive it.\n10. **Finish at `in_review`** with `completionNotes`, and do NOT call\n `action:"complete"`. A human completes a task after verifying it.\n11. **Report what actually happened.** A task moved to `in_review` claiming work\n that was not done is worse than no task, because the next session trusts it.\n\nRespect the project\'s `terminology`: a project can rename epics, tasks and\ncomponents, and a marketing project calls an epic a "Campaign". Write anything a\nhuman reads in those words; keep API field names (`epicId`, `taskId`) as they\nare.';
|
|
13
|
+
|
|
14
|
+
export const WORK_TRACKING_LOCAL = 'Running against a local checkout, two more:\n\n12. **Link every commit**: `manage_task action:"link_commit"` with the full\n 40-character `sha` from `git rev-parse HEAD`. A short SHA is rejected, and\n padding one is not a fix. Linking is also what derives component links from\n the commit\'s files — do not link those by hand.\n13. **Pass `changedFiles`** when creating or updating a task, so the work\n resolves to the components that own those paths.';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP `instructions` string — what the server tells every client about how
|
|
3
|
+
* to work with it (#2633).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS MATTERS OUT OF PROPORTION TO ITS SIZE. The tools were always the
|
|
6
|
+
* easy half. What makes an agent actually TRACK work — create a task before you
|
|
7
|
+
* edit, tick steps off, capture knowledge, link the commit, finish at
|
|
8
|
+
* in_review — shipped only as Claude Code skills in plugins/ezmodo/skills/.
|
|
9
|
+
* Cursor, Codex, Zed and every other stdio MCP client got the tools and none of
|
|
10
|
+
* the discipline. The initialize result carries an instructions string that
|
|
11
|
+
* clients inject into system context, so this reaches all of them with no
|
|
12
|
+
* plugin, no per-editor packaging, and no install step.
|
|
13
|
+
*
|
|
14
|
+
* WHY IT IS SHORT. A skill is loaded on demand and can afford several hundred
|
|
15
|
+
* lines. Instructions are paid for on every session, so this is the irreducible
|
|
16
|
+
* core and nothing else. The full contract stays in the skill, and the two are
|
|
17
|
+
* the same words — see scripts/build-instructions.mjs for how, and why they are
|
|
18
|
+
* not two hand-maintained copies.
|
|
19
|
+
*
|
|
20
|
+
* WHY IT VARIES BY SURFACE. The remote connector serves no local-machine tools,
|
|
21
|
+
* so telling an agent there to link commits and pass changed files is advice it
|
|
22
|
+
* cannot follow — worse than silence, because an agent that tries will report a
|
|
23
|
+
* failure the user cannot act on. The local block is appended only for stdio,
|
|
24
|
+
* on the same `surface` parameter that filters the tool list.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { WORK_TRACKING_CORE, WORK_TRACKING_LOCAL } from './instructions.generated.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {'local'|'remote'} [surface]
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function getInstructions(surface = 'local') {
|
|
34
|
+
return surface === 'remote'
|
|
35
|
+
? WORK_TRACKING_CORE
|
|
36
|
+
: `${WORK_TRACKING_CORE}\n\n${WORK_TRACKING_LOCAL}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keycloak OIDC configuration for the local (stdio) MCP server.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE SERVER LOGS IN AT ALL. Installing used to be two steps, and the
|
|
5
|
+
* second one is the cumbersome one: install the server, then leave the agent,
|
|
6
|
+
* open the web UI, mint an API key, paste it into a shell profile, restart.
|
|
7
|
+
* That step is identical for Cursor, Codex, Zed or anything else speaking
|
|
8
|
+
* stdio MCP, and it is what makes people reach for the CLI. The server signs
|
|
9
|
+
* itself in instead, so installing IS the whole install (E-252 #2631).
|
|
10
|
+
*
|
|
11
|
+
* WHY `ezmodo-mcp` AND NOT `ezmodo-cli`. Both are public PKCE clients in the
|
|
12
|
+
* `ezmodo` realm and either would authenticate, since the API skips the
|
|
13
|
+
* audience check (KEYCLOAK_CLIENT_ID is deliberately empty in
|
|
14
|
+
* infra/cloud-run-api-ezmodo.tf). Three things decide it:
|
|
15
|
+
*
|
|
16
|
+
* 1. Redirect URIs. `ezmodo-cli` registers ONE fixed loopback,
|
|
17
|
+
* http://localhost:19838/callback. A fixed port fails when it is already
|
|
18
|
+
* taken — including by an `ezmodo auth login` running in another terminal,
|
|
19
|
+
* which is exactly when a user is most likely to be signing in.
|
|
20
|
+
* `ezmodo-mcp` registers http://localhost/* and http://127.0.0.1/*, which
|
|
21
|
+
* matches ANY ephemeral port: Keycloak's RedirectUtils, on a failed match
|
|
22
|
+
* for an http URI whose host is a loopback interface, rebuilds it with
|
|
23
|
+
* port 80 and re-matches.
|
|
24
|
+
* 2. Consent. `ezmodo-mcp` sets consent_required. The whole argument for
|
|
25
|
+
* preferring OAuth over a pasted key is that the user SEES what they are
|
|
26
|
+
* granting; a client that skips the consent screen throws that away.
|
|
27
|
+
* 3. It is what this is. `ezmodo-mcp` is the MCP client, and the scopes a
|
|
28
|
+
* human reads on the consent screen are attached to it.
|
|
29
|
+
*
|
|
30
|
+
* The client id is baked in, not pasted. That is why Keycloak's refusal of
|
|
31
|
+
* anonymous Dynamic Client Registration — the thing that makes a claude.ai
|
|
32
|
+
* connector user paste `ezmodo-mcp` by hand — does not touch a local install
|
|
33
|
+
* at all.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { CONFIG } from '../config/index.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Keycloak per environment. Mirrors cli/src/lib/config.ts KEYCLOAK_CONFIG; the
|
|
40
|
+
* realm is `ezmodo` everywhere, including local, so a client that works on a
|
|
41
|
+
* laptop works in production.
|
|
42
|
+
*/
|
|
43
|
+
const KEYCLOAK = {
|
|
44
|
+
production: { url: 'https://auth.ezmodo.com', realm: 'ezmodo' },
|
|
45
|
+
staging: { url: 'https://auth.staging.ezmodo.com', realm: 'ezmodo' },
|
|
46
|
+
development: { url: 'http://localhost:7373', realm: 'ezmodo' },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The public PKCE client this server authenticates as. */
|
|
50
|
+
export const OAUTH_CLIENT_ID = 'ezmodo-mcp';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What we ask consent for, and deliberately NOT everything on offer.
|
|
54
|
+
*
|
|
55
|
+
* `ezmodo:delete` is a registered optional scope and is left out by default.
|
|
56
|
+
* Keycloak's consent screen is accept-or-decline over the whole requested set,
|
|
57
|
+
* so asking for it would make "permanently delete your tasks, documents, goals
|
|
58
|
+
* and projects" a condition of installing an MCP server — which is not a
|
|
59
|
+
* decision to bundle into a setup step someone is clicking through. The MCP
|
|
60
|
+
* tool surface is overwhelmingly create/update anyway; almost nothing here
|
|
61
|
+
* deletes.
|
|
62
|
+
*
|
|
63
|
+
* `offline_access` is what makes the refresh token outlive the short access
|
|
64
|
+
* token, i.e. what stops this asking for a browser every few minutes.
|
|
65
|
+
*
|
|
66
|
+
* Override with EZMODO_OAUTH_SCOPES (space-separated) to widen or narrow it —
|
|
67
|
+
* that is the escape hatch for someone who genuinely wants delete, without
|
|
68
|
+
* making it everyone's default.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_SCOPES = 'openid email profile offline_access ezmodo:read ezmodo:write';
|
|
71
|
+
|
|
72
|
+
/** The scopes to request, honouring the override. */
|
|
73
|
+
export function getScopes() {
|
|
74
|
+
const override = process.env.EZMODO_OAUTH_SCOPES;
|
|
75
|
+
return override && override.trim() ? override.trim() : DEFAULT_SCOPES;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Keycloak endpoints for the environment this build targets.
|
|
80
|
+
*
|
|
81
|
+
* Falls back to production for an unrecognised environment for the same reason
|
|
82
|
+
* config/index.js defaults BUILD_ENV to production: the only caller that
|
|
83
|
+
* arrives here with nothing set is an installed copy on a real user's machine,
|
|
84
|
+
* and pointing that at localhost fails with an error saying nothing about why.
|
|
85
|
+
*/
|
|
86
|
+
export function getKeycloakEndpoints() {
|
|
87
|
+
const env = KEYCLOAK[CONFIG.environment] ? CONFIG.environment : 'production';
|
|
88
|
+
const { url, realm } = KEYCLOAK[env];
|
|
89
|
+
const base = `${url}/realms/${realm}/protocol/openid-connect`;
|
|
90
|
+
return {
|
|
91
|
+
environment: env,
|
|
92
|
+
issuer: `${url}/realms/${realm}`,
|
|
93
|
+
authorization: `${base}/auth`,
|
|
94
|
+
token: `${base}/token`,
|
|
95
|
+
logout: `${base}/logout`,
|
|
96
|
+
clientId: OAUTH_CLIENT_ID,
|
|
97
|
+
};
|
|
98
|
+
}
|
package/lib/oauth.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization Code + PKCE sign-in, run by the MCP server for itself (#2631).
|
|
3
|
+
*
|
|
4
|
+
* Ported from cli/src/lib/oauth-login.ts, oauth-callback-server.ts and
|
|
5
|
+
* token-refresh.ts rather than imported from them. This package is published to
|
|
6
|
+
* npm and launched with `npx @ezmodo/mcp-server`; it must not require the CLI
|
|
7
|
+
* to be installed. lib/cli-credential.js re-implements the CLI's credential
|
|
8
|
+
* read for the same reason, and records the same trade-off: when the CLI's
|
|
9
|
+
* formats move, a copy goes stale and stops working, which is the safe
|
|
10
|
+
* direction to fail in.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS DIFFERENT FROM THE CLI'S COPY, and why:
|
|
13
|
+
*
|
|
14
|
+
* - An EPHEMERAL loopback port, not the CLI's fixed 19838. The `ezmodo-mcp`
|
|
15
|
+
* client registers http://localhost/* and http://127.0.0.1/*, so any port
|
|
16
|
+
* matches; a fixed port would collide with a concurrent `ezmodo auth
|
|
17
|
+
* login`, which is precisely when someone is likely to be signing in.
|
|
18
|
+
* - NOTHING is written to stdout. Over stdio, stdout IS the MCP protocol
|
|
19
|
+
* channel — a stray console.log is a protocol violation that corrupts the
|
|
20
|
+
* session. The CLI's copy prints progress freely because it owns its
|
|
21
|
+
* terminal. Here, diagnostics go to the logger (stderr) and anything the
|
|
22
|
+
* user must read is returned to the caller to surface (#2632).
|
|
23
|
+
* - Refresh is single-flight. Tool calls run concurrently, so several can
|
|
24
|
+
* find the same expired token at once; without this they would each burn a
|
|
25
|
+
* refresh token and all but one would fail, because Keycloak rotates it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createHash, randomBytes } from 'crypto';
|
|
29
|
+
import { createServer } from 'http';
|
|
30
|
+
import fetch from 'node-fetch';
|
|
31
|
+
import { getKeycloakEndpoints, getScopes } from './oauth-config.js';
|
|
32
|
+
import { clearTokens, isExpired, readTokens, writeTokens } from './token-store.js';
|
|
33
|
+
import { getLogger } from './logger.js';
|
|
34
|
+
|
|
35
|
+
/** How long to wait for the user to finish in the browser before giving up. */
|
|
36
|
+
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
// ============================================================
|
|
39
|
+
// PKCE primitives (RFC 7636)
|
|
40
|
+
// ============================================================
|
|
41
|
+
|
|
42
|
+
function generateState() {
|
|
43
|
+
return randomBytes(32).toString('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function generateCodeVerifier() {
|
|
47
|
+
return randomBytes(32)
|
|
48
|
+
.toString('base64url')
|
|
49
|
+
.replace(/[^a-zA-Z0-9\-._~]/g, '')
|
|
50
|
+
.substring(0, 128);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function generateCodeChallenge(verifier) {
|
|
54
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the `sub`, `email` and `exp` out of a JWT WITHOUT verifying it.
|
|
59
|
+
*
|
|
60
|
+
* Safe here, and only here: this token came from a TLS connection to the token
|
|
61
|
+
* endpoint moments ago, and nothing security-relevant is decided from these
|
|
62
|
+
* claims — they are stored so a human can be told which account is signed in.
|
|
63
|
+
* The API verifies the signature on every request, which is where that check
|
|
64
|
+
* belongs.
|
|
65
|
+
*/
|
|
66
|
+
function decodeJwtPayload(token) {
|
|
67
|
+
try {
|
|
68
|
+
const parts = token.split('.');
|
|
69
|
+
if (parts.length !== 3) return {};
|
|
70
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
|
71
|
+
} catch {
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================
|
|
77
|
+
// Loopback callback capture
|
|
78
|
+
// ============================================================
|
|
79
|
+
|
|
80
|
+
const DONE_PAGE = (heading, detail) => `<!doctype html>
|
|
81
|
+
<html lang="en"><head><meta charset="utf-8"><title>EzModo</title>
|
|
82
|
+
<style>
|
|
83
|
+
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#0f1115;color:#e8eaed;
|
|
84
|
+
display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
85
|
+
main{text-align:center;max-width:26rem;padding:2rem}
|
|
86
|
+
h1{font-size:1.25rem;font-weight:600;margin:0 0 .5rem}
|
|
87
|
+
p{color:#9aa0a6;line-height:1.5;margin:0}
|
|
88
|
+
</style></head>
|
|
89
|
+
<body><main><h1>${heading}</h1><p>${detail}</p></main></body></html>`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Listen on an ephemeral loopback port for the authorization redirect.
|
|
93
|
+
*
|
|
94
|
+
* Resolves with the code once one arrives. The redirect URI is returned before
|
|
95
|
+
* the code is, because the caller needs the port to build the authorize URL —
|
|
96
|
+
* hence the two-stage shape rather than a single promise.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} expectedState
|
|
99
|
+
* @returns {Promise<{ redirectUri: string, code: Promise<string>, close: () => void }>}
|
|
100
|
+
*/
|
|
101
|
+
function startCallbackServer(expectedState) {
|
|
102
|
+
return new Promise((resolveReady, rejectReady) => {
|
|
103
|
+
let settle;
|
|
104
|
+
const code = new Promise((resolve, reject) => {
|
|
105
|
+
settle = { resolve, reject };
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let finished = false;
|
|
109
|
+
const server = createServer((req, res) => {
|
|
110
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
111
|
+
if (url.pathname === '/favicon.ico') {
|
|
112
|
+
res.writeHead(204).end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const fail = (message) => {
|
|
117
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
118
|
+
res.end(DONE_PAGE('Sign-in failed', message));
|
|
119
|
+
if (!finished) {
|
|
120
|
+
finished = true;
|
|
121
|
+
settle.reject(new Error(message));
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const error = url.searchParams.get('error');
|
|
126
|
+
if (error) {
|
|
127
|
+
// Keycloak reports a refused consent here rather than by not
|
|
128
|
+
// redirecting, so this is the ordinary "user clicked Cancel" path.
|
|
129
|
+
const description = url.searchParams.get('error_description') || error;
|
|
130
|
+
fail(description);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const received = url.searchParams.get('code');
|
|
135
|
+
const state = url.searchParams.get('state');
|
|
136
|
+
if (!received) {
|
|
137
|
+
fail('No authorization code in the callback.');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (state !== expectedState) {
|
|
141
|
+
// The CSRF check. A mismatch means this redirect was not the one we
|
|
142
|
+
// started, so the code must not be exchanged.
|
|
143
|
+
fail('State mismatch — this sign-in did not come from this request.');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
148
|
+
res.end(DONE_PAGE('You are signed in', 'You can close this tab and return to your editor.'));
|
|
149
|
+
if (!finished) {
|
|
150
|
+
finished = true;
|
|
151
|
+
settle.resolve(received);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
server.on('error', (error) => {
|
|
156
|
+
if (!finished) {
|
|
157
|
+
finished = true;
|
|
158
|
+
settle.reject(error);
|
|
159
|
+
}
|
|
160
|
+
rejectReady(error);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
if (!finished) {
|
|
165
|
+
finished = true;
|
|
166
|
+
settle.reject(new Error('Timed out waiting for the browser sign-in to complete.'));
|
|
167
|
+
}
|
|
168
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
169
|
+
// Do not hold the process open purely to wait for a browser.
|
|
170
|
+
timer.unref?.();
|
|
171
|
+
|
|
172
|
+
const close = () => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
server.close();
|
|
175
|
+
};
|
|
176
|
+
code.then(close, close);
|
|
177
|
+
|
|
178
|
+
// Port 0 = let the OS pick. 127.0.0.1 rather than a wildcard bind: this
|
|
179
|
+
// socket briefly accepts an authorization code, and it has no business
|
|
180
|
+
// being reachable from the network.
|
|
181
|
+
server.listen(0, '127.0.0.1', () => {
|
|
182
|
+
const { port } = server.address();
|
|
183
|
+
resolveReady({ redirectUri: `http://127.0.0.1:${port}/callback`, code, close });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================
|
|
189
|
+
// Token endpoint
|
|
190
|
+
// ============================================================
|
|
191
|
+
|
|
192
|
+
async function postToken(body) {
|
|
193
|
+
const endpoints = getKeycloakEndpoints();
|
|
194
|
+
const response = await fetch(endpoints.token, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
197
|
+
body: new URLSearchParams(body).toString(),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const text = await response.text();
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
const error = new Error(`Token request failed (${response.status}): ${text}`);
|
|
203
|
+
error.status = response.status;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
return JSON.parse(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Turn a Keycloak token response into what the store holds. */
|
|
210
|
+
function toStoredTokens(response) {
|
|
211
|
+
const claims = decodeJwtPayload(response.access_token);
|
|
212
|
+
return {
|
|
213
|
+
accessToken: response.access_token,
|
|
214
|
+
refreshToken: response.refresh_token,
|
|
215
|
+
expiresAt: new Date(Date.now() + (response.expires_in ?? 60) * 1000).toISOString(),
|
|
216
|
+
userId: claims.sub,
|
|
217
|
+
email: claims.email || claims.preferred_username,
|
|
218
|
+
scope: response.scope,
|
|
219
|
+
issuer: getKeycloakEndpoints().issuer,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================
|
|
224
|
+
// Sign-in
|
|
225
|
+
// ============================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build the authorize URL and start listening for its redirect.
|
|
229
|
+
*
|
|
230
|
+
* Split from the wait deliberately: the caller needs the URL to hand to the
|
|
231
|
+
* user (in a tool result, or a browser it opened) BEFORE anyone can complete
|
|
232
|
+
* the flow. Returning both at once would mean the URL only became available
|
|
233
|
+
* after it was already too late to show it.
|
|
234
|
+
*
|
|
235
|
+
* @returns {Promise<{ authUrl: string, complete: () => Promise<StoredTokens>, cancel: () => void }>}
|
|
236
|
+
*/
|
|
237
|
+
export async function beginLogin() {
|
|
238
|
+
const endpoints = getKeycloakEndpoints();
|
|
239
|
+
const state = generateState();
|
|
240
|
+
const verifier = generateCodeVerifier();
|
|
241
|
+
const challenge = generateCodeChallenge(verifier);
|
|
242
|
+
|
|
243
|
+
const { redirectUri, code, close } = await startCallbackServer(state);
|
|
244
|
+
|
|
245
|
+
const authUrl = new URL(endpoints.authorization);
|
|
246
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
247
|
+
authUrl.searchParams.set('client_id', endpoints.clientId);
|
|
248
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
249
|
+
authUrl.searchParams.set('scope', getScopes());
|
|
250
|
+
authUrl.searchParams.set('state', state);
|
|
251
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
252
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
253
|
+
|
|
254
|
+
getLogger().debug('OAuth sign-in started', { redirectUri, environment: endpoints.environment });
|
|
255
|
+
|
|
256
|
+
const complete = async () => {
|
|
257
|
+
const authorizationCode = await code;
|
|
258
|
+
const response = await postToken({
|
|
259
|
+
grant_type: 'authorization_code',
|
|
260
|
+
client_id: endpoints.clientId,
|
|
261
|
+
code: authorizationCode,
|
|
262
|
+
redirect_uri: redirectUri,
|
|
263
|
+
code_verifier: verifier,
|
|
264
|
+
});
|
|
265
|
+
const tokens = toStoredTokens(response);
|
|
266
|
+
writeTokens(tokens);
|
|
267
|
+
getLogger().info('OAuth sign-in complete', { email: tokens.email });
|
|
268
|
+
return tokens;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return { authUrl: authUrl.toString(), complete, cancel: close };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================
|
|
275
|
+
// Refresh
|
|
276
|
+
// ============================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* In-flight refresh, shared by every caller that arrives while it runs.
|
|
280
|
+
*
|
|
281
|
+
* Keycloak ROTATES refresh tokens: the old one dies the moment the new one is
|
|
282
|
+
* issued. Two concurrent refreshes therefore do not merely waste a round trip,
|
|
283
|
+
* they race to invalidate each other, and the loser signs the user out. One
|
|
284
|
+
* promise, awaited by all.
|
|
285
|
+
*/
|
|
286
|
+
let refreshInFlight = null;
|
|
287
|
+
|
|
288
|
+
async function refreshTokens(tokens) {
|
|
289
|
+
const endpoints = getKeycloakEndpoints();
|
|
290
|
+
try {
|
|
291
|
+
const response = await postToken({
|
|
292
|
+
grant_type: 'refresh_token',
|
|
293
|
+
client_id: endpoints.clientId,
|
|
294
|
+
refresh_token: tokens.refreshToken,
|
|
295
|
+
});
|
|
296
|
+
const refreshed = toStoredTokens(response);
|
|
297
|
+
// Keycloak may omit a new refresh token; keep the old one when it does,
|
|
298
|
+
// or the next refresh has nothing to present.
|
|
299
|
+
if (!refreshed.refreshToken) refreshed.refreshToken = tokens.refreshToken;
|
|
300
|
+
writeTokens(refreshed);
|
|
301
|
+
return refreshed;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
// 400 from the token endpoint on a refresh means the grant is dead —
|
|
304
|
+
// expired, revoked, or already rotated. Keeping it would retry forever
|
|
305
|
+
// against something that can never succeed, so drop it and let the caller
|
|
306
|
+
// ask for a fresh sign-in.
|
|
307
|
+
if (error.status === 400) {
|
|
308
|
+
getLogger().info('Refresh token no longer valid; signing out', { error: error.message });
|
|
309
|
+
clearTokens();
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
// Anything else (network, 5xx) is plausibly transient. Leave the stored
|
|
313
|
+
// tokens alone so a later call can try again.
|
|
314
|
+
getLogger().warn('Token refresh failed', { error: error.message });
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A usable access token, refreshing if needed, or null when a sign-in is due.
|
|
321
|
+
*
|
|
322
|
+
* Null is the ONLY failure mode: every caller is on the path of a tool call,
|
|
323
|
+
* and "you need to sign in" is a message the agent can act on, whereas a thrown
|
|
324
|
+
* error is one it cannot.
|
|
325
|
+
*
|
|
326
|
+
* @returns {Promise<string|null>}
|
|
327
|
+
*/
|
|
328
|
+
export async function getAccessToken() {
|
|
329
|
+
const tokens = readTokens();
|
|
330
|
+
if (!tokens) return null;
|
|
331
|
+
if (!isExpired(tokens)) return tokens.accessToken;
|
|
332
|
+
if (!tokens.refreshToken) return null;
|
|
333
|
+
|
|
334
|
+
if (!refreshInFlight) {
|
|
335
|
+
refreshInFlight = refreshTokens(tokens).finally(() => {
|
|
336
|
+
refreshInFlight = null;
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const refreshed = await refreshInFlight;
|
|
340
|
+
return refreshed?.accessToken ?? null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Who is signed in, for diagnostics. Null when nobody is. */
|
|
344
|
+
export function getSignedInIdentity() {
|
|
345
|
+
const tokens = readTokens();
|
|
346
|
+
if (!tokens) return null;
|
|
347
|
+
return { email: tokens.email, userId: tokens.userId, scope: tokens.scope };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Forget the stored tokens. */
|
|
351
|
+
export function signOut() {
|
|
352
|
+
return clearTokens();
|
|
353
|
+
}
|
package/lib/remote-tools.js
CHANGED
|
@@ -41,6 +41,12 @@
|
|
|
41
41
|
|
|
42
42
|
/** Tools that operate on the local machine and are never served remotely. */
|
|
43
43
|
export const LOCAL_ONLY_TOOLS = Object.freeze([
|
|
44
|
+
// Not a filesystem tool, but local for the same reason: over the connector
|
|
45
|
+
// Claude completes its own OAuth before any tool call, so a second sign-in
|
|
46
|
+
// offered there would be inert and confusing (#2632). It also opens a
|
|
47
|
+
// browser and binds a loopback port, neither of which means anything in a
|
|
48
|
+
// container.
|
|
49
|
+
'authenticate',
|
|
44
50
|
'detect_git_repository',
|
|
45
51
|
'get_current_project_context',
|
|
46
52
|
'initialize_project_context',
|