@rockhopper-co/mcp-server 0.5.0 → 0.7.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +134 -1
  2. package/README.md +34 -10
  3. package/dist/api-client.d.ts +56 -5
  4. package/dist/api-client.d.ts.map +1 -1
  5. package/dist/api-client.js +86 -11
  6. package/dist/api-client.js.map +1 -1
  7. package/dist/auth/device-grant-client.d.ts +80 -0
  8. package/dist/auth/device-grant-client.d.ts.map +1 -0
  9. package/dist/auth/device-grant-client.js +138 -0
  10. package/dist/auth/device-grant-client.js.map +1 -0
  11. package/dist/auth/resolve-auth.d.ts +52 -0
  12. package/dist/auth/resolve-auth.d.ts.map +1 -0
  13. package/dist/auth/resolve-auth.js +100 -0
  14. package/dist/auth/resolve-auth.js.map +1 -0
  15. package/dist/auth/token-store.d.ts +51 -0
  16. package/dist/auth/token-store.d.ts.map +1 -0
  17. package/dist/auth/token-store.js +98 -0
  18. package/dist/auth/token-store.js.map +1 -0
  19. package/dist/cli.js +37 -11
  20. package/dist/cli.js.map +1 -1
  21. package/dist/prompts/index.d.ts.map +1 -1
  22. package/dist/prompts/index.js +20 -9
  23. package/dist/prompts/index.js.map +1 -1
  24. package/dist/resources/changes.d.ts.map +1 -1
  25. package/dist/resources/changes.js +8 -2
  26. package/dist/resources/changes.js.map +1 -1
  27. package/dist/resources/orchestration-guide.md +1 -1
  28. package/dist/server.js +1 -1
  29. package/dist/server.js.map +1 -1
  30. package/dist/tools/search.d.ts.map +1 -1
  31. package/dist/tools/search.js +113 -20
  32. package/dist/tools/search.js.map +1 -1
  33. package/dist/tools/write-files.js +4 -4
  34. package/dist/tools/write-files.js.map +1 -1
  35. package/dist/tools/write-reviews.d.ts.map +1 -1
  36. package/dist/tools/write-reviews.js +3 -1
  37. package/dist/tools/write-reviews.js.map +1 -1
  38. package/dist/types.d.ts +23 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/zod-schemas.d.ts +111 -0
  41. package/dist/zod-schemas.d.ts.map +1 -0
  42. package/dist/zod-schemas.js +62 -0
  43. package/dist/zod-schemas.js.map +1 -0
  44. package/package.json +6 -1
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ENG-1444 / KI-081 — RFC 8628 device-grant client.
3
+ *
4
+ * Talks to the backend's `POST /auth/device/{code,token}` endpoints
5
+ * (introduced in ENG-1384 PR 1, `Rockhopper-Co/backend#473`). Used by
6
+ * the mcp-server CLI when no PAT env var is set and no OAuth bundle
7
+ * is stored in the OS keychain.
8
+ *
9
+ * Surfaces a single entrypoint, `runDeviceGrantFlow`, that:
10
+ *
11
+ * 1. Calls `/auth/device/code` to get a (deviceCode, userCode) pair.
12
+ * 2. Emits the user-facing `userCode` + verification URI to stderr
13
+ * (LLM clients pick this up via stdout's stderr passthrough).
14
+ * 3. Polls `/auth/device/token` at the server-specified interval,
15
+ * respecting RFC 8628 § 3.5 `slow_down` (bumps interval +5s) and
16
+ * `authorization_pending` (continues polling).
17
+ * 4. Resolves with the access-token bundle on success, rejects on
18
+ * `access_denied` / `expired_token` / network failure.
19
+ *
20
+ * No external HTTP dependency — uses globalThis.fetch (Node 18+).
21
+ * `fetchImpl` + `sleep` + `onUserCode` are injectable for tests.
22
+ */
23
+ export class DeviceGrantError extends Error {
24
+ code;
25
+ constructor(code, message) {
26
+ super(message);
27
+ this.code = code;
28
+ this.name = 'DeviceGrantError';
29
+ }
30
+ }
31
+ /* v8 ignore next 2 -- trivial setTimeout wrapper; tests inject a sleep stub via `opts.sleep` */
32
+ const DEFAULT_SLEEP = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
+ const DEFAULT_ON_USER_CODE = (info) => {
34
+ process.stderr.write('\nRockhopper — sign in to authorize this MCP client.\n' +
35
+ `Open: ${info.verificationUriComplete}\n` +
36
+ `(or visit ${info.verificationUri} and enter code: ${info.userCode})\n\n`);
37
+ };
38
+ /**
39
+ * Issue a fresh device code + user code pair.
40
+ */
41
+ export async function requestDeviceCode(opts) {
42
+ const fetchImpl = opts.fetchImpl ?? fetch;
43
+ let res;
44
+ try {
45
+ res = await fetchImpl(`${opts.baseUrl}/auth/device/code`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ clientId: opts.clientId }),
49
+ });
50
+ }
51
+ catch (e) {
52
+ throw new DeviceGrantError('network_error', `Failed to reach ${opts.baseUrl}/auth/device/code: ${e instanceof Error ? e.message : String(e)}`);
53
+ }
54
+ if (!res.ok) {
55
+ throw new DeviceGrantError('unknown', `Device-code request failed with HTTP ${res.status}`);
56
+ }
57
+ return (await res.json());
58
+ }
59
+ /**
60
+ * Internal: one polling round-trip. Returns the token bundle on
61
+ * success, or a sentinel describing why the server isn't ready yet.
62
+ */
63
+ export async function pollOnce(opts, deviceCode) {
64
+ const fetchImpl = opts.fetchImpl ?? fetch;
65
+ let res;
66
+ try {
67
+ res = await fetchImpl(`${opts.baseUrl}/auth/device/token`, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ deviceCode, clientId: opts.clientId }),
71
+ });
72
+ }
73
+ catch (e) {
74
+ throw new DeviceGrantError('network_error', `Failed to reach ${opts.baseUrl}/auth/device/token: ${e instanceof Error ? e.message : String(e)}`);
75
+ }
76
+ if (res.ok) {
77
+ return {
78
+ kind: 'success',
79
+ bundle: (await res.json()),
80
+ };
81
+ }
82
+ // RFC 8628 error mapping. Body shape is `{ error, error_description }`
83
+ // per the backend (Rockhopper's exception filter unwraps it — see
84
+ // memory `project_backend_badrequest_unwraps_payload`).
85
+ let body = {};
86
+ try {
87
+ body = await res.json();
88
+ }
89
+ catch {
90
+ // Non-JSON error body — fall through to 'unknown'.
91
+ }
92
+ switch (body.error) {
93
+ case 'authorization_pending':
94
+ return { kind: 'pending' };
95
+ case 'slow_down':
96
+ return { kind: 'slow_down' };
97
+ case 'access_denied':
98
+ throw new DeviceGrantError('access_denied', body.error_description ?? 'Device code denied.');
99
+ case 'expired_token':
100
+ throw new DeviceGrantError('expired_token', body.error_description ?? 'Device code expired before approval.');
101
+ default:
102
+ throw new DeviceGrantError('unknown', `Unexpected device-grant error (HTTP ${res.status}): ${body.error ?? 'no error code'}`);
103
+ }
104
+ }
105
+ /**
106
+ * Full flow — request code, print to stderr, poll until success or
107
+ * fatal error. Returns the access-token bundle on success.
108
+ *
109
+ * `interval` (seconds, server-supplied) becomes the poll cadence.
110
+ * `slow_down` responses bump the interval by RFC's recommended +5s.
111
+ * Total wall time is bounded by the server-supplied `expiresIn`.
112
+ */
113
+ export async function runDeviceGrantFlow(opts) {
114
+ const sleep = opts.sleep ?? DEFAULT_SLEEP;
115
+ const onUserCode = opts.onUserCode ?? DEFAULT_ON_USER_CODE;
116
+ const code = await requestDeviceCode(opts);
117
+ onUserCode({
118
+ userCode: code.userCode,
119
+ verificationUri: code.verificationUri,
120
+ verificationUriComplete: code.verificationUriComplete,
121
+ });
122
+ let intervalMs = code.interval * 1000;
123
+ const deadline = Date.now() + code.expiresIn * 1000;
124
+ while (Date.now() < deadline) {
125
+ await sleep(intervalMs);
126
+ const result = await pollOnce(opts, code.deviceCode);
127
+ if (result.kind === 'success') {
128
+ return result.bundle;
129
+ }
130
+ if (result.kind === 'slow_down') {
131
+ // RFC 8628 § 3.5 — increase interval by 5s and retry.
132
+ intervalMs += 5_000;
133
+ }
134
+ // 'pending' just continues at the current interval.
135
+ }
136
+ throw new DeviceGrantError('expired_token', `Device code expired (waited ${code.expiresIn}s without approval).`);
137
+ }
138
+ //# sourceMappingURL=device-grant-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-grant-client.js","sourceRoot":"","sources":["../../src/auth/device-grant-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ENG-1444 / KI-081 — auth resolution for the mcp-server CLI.
3
+ *
4
+ * Decides where the bearer token comes from on each launch, in order:
5
+ *
6
+ * 1. `ROCKHOPPER_TOKEN` env var — Personal Access Token. Headless /
7
+ * CI / scripted scenarios. Same path as pre-OAuth releases.
8
+ * 2. Stored OAuth bundle in the OS keychain (token-store). The user
9
+ * previously completed the device-grant flow; if the access
10
+ * token is still valid, use it.
11
+ * 3. Device-grant flow — issues a fresh code, prints the user code
12
+ * + verification URI to stderr, polls for approval, persists the
13
+ * resulting bundle to the keychain for next time.
14
+ *
15
+ * Returns `{ accessToken, source }` where `source` is one of
16
+ * `'pat' | 'stored-oauth' | 'device-grant'` for logging / debugging.
17
+ *
18
+ * Heavy dependencies (token store, device-grant flow, env reads) are
19
+ * injectable for tests.
20
+ */
21
+ import { runDeviceGrantFlow } from './device-grant-client.js';
22
+ import { isExpired as defaultIsExpired, type OAuthTokenBundle } from './token-store.js';
23
+ export type AuthSource = 'pat' | 'stored-oauth' | 'device-grant';
24
+ export interface ResolvedAuth {
25
+ accessToken: string;
26
+ source: AuthSource;
27
+ }
28
+ export interface ResolveAuthOptions {
29
+ baseUrl: string;
30
+ /** Defaults to `'mcp-stdio'`. */
31
+ clientId?: string;
32
+ /** Typically `process.env.ROCKHOPPER_TOKEN`. */
33
+ patFromEnv?: string;
34
+ /** Override for tests. */
35
+ tokenStoreGet?: () => Promise<OAuthTokenBundle | null>;
36
+ /** Override for tests. */
37
+ tokenStoreSet?: (bundle: OAuthTokenBundle) => Promise<void>;
38
+ /** Override for tests. */
39
+ tokenStoreClear?: () => Promise<void>;
40
+ /** Override for tests. */
41
+ isExpiredFn?: typeof defaultIsExpired;
42
+ /** Override for tests. Defaults to `runDeviceGrantFlow`. */
43
+ deviceGrantFlow?: typeof runDeviceGrantFlow;
44
+ /** Optional stderr logger; defaults to `process.stderr.write` line-suffixed. */
45
+ log?: (msg: string) => void;
46
+ }
47
+ export declare class AuthResolutionError extends Error {
48
+ readonly code: 'pat_malformed' | 'device_grant_failed' | 'token_store_failure';
49
+ constructor(code: 'pat_malformed' | 'device_grant_failed' | 'token_store_failure', message: string);
50
+ }
51
+ export declare function resolveAuth(opts: ResolveAuthOptions): Promise<ResolvedAuth>;
52
+ //# sourceMappingURL=resolve-auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-auth.d.ts","sourceRoot":"","sources":["../../src/auth/resolve-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAEL,kBAAkB,EAEnB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAGL,SAAS,IAAI,gBAAgB,EAE7B,KAAK,gBAAgB,EACtB,MAAM,kBAAkB,CAAC;AAI1B,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,cAAc,GAAG,cAAc,CAAC;AAEjE,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,UAAU,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0BAA0B;IAC1B,aAAa,CAAC,EAAE,MAAM,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACvD,0BAA0B;IAC1B,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,0BAA0B;IAC1B,eAAe,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,0BAA0B;IAC1B,WAAW,CAAC,EAAE,OAAO,gBAAgB,CAAC;IACtC,4DAA4D;IAC5D,eAAe,CAAC,EAAE,OAAO,kBAAkB,CAAC;IAC5C,gFAAgF;IAChF,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,qBAAa,mBAAoB,SAAQ,KAAK;aAE1B,IAAI,EAChB,eAAe,GACf,qBAAqB,GACrB,qBAAqB;gBAHT,IAAI,EAChB,eAAe,GACf,qBAAqB,GACrB,qBAAqB,EACzB,OAAO,EAAE,MAAM;CAKlB;AAKD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,YAAY,CAAC,CAgFvB"}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ENG-1444 / KI-081 — auth resolution for the mcp-server CLI.
3
+ *
4
+ * Decides where the bearer token comes from on each launch, in order:
5
+ *
6
+ * 1. `ROCKHOPPER_TOKEN` env var — Personal Access Token. Headless /
7
+ * CI / scripted scenarios. Same path as pre-OAuth releases.
8
+ * 2. Stored OAuth bundle in the OS keychain (token-store). The user
9
+ * previously completed the device-grant flow; if the access
10
+ * token is still valid, use it.
11
+ * 3. Device-grant flow — issues a fresh code, prints the user code
12
+ * + verification URI to stderr, polls for approval, persists the
13
+ * resulting bundle to the keychain for next time.
14
+ *
15
+ * Returns `{ accessToken, source }` where `source` is one of
16
+ * `'pat' | 'stored-oauth' | 'device-grant'` for logging / debugging.
17
+ *
18
+ * Heavy dependencies (token store, device-grant flow, env reads) are
19
+ * injectable for tests.
20
+ */
21
+ import { DeviceGrantError, runDeviceGrantFlow, } from './device-grant-client.js';
22
+ import { clearTokens as defaultClearTokens, getTokens as defaultGetTokens, isExpired as defaultIsExpired, setTokens as defaultSetTokens, } from './token-store.js';
23
+ const PAT_PREFIX = 'rh_pat_';
24
+ export class AuthResolutionError extends Error {
25
+ code;
26
+ constructor(code, message) {
27
+ super(message);
28
+ this.code = code;
29
+ this.name = 'AuthResolutionError';
30
+ }
31
+ }
32
+ /* v8 ignore next -- trivial stderr wrapper; tests inject a log stub via `opts.log` */
33
+ const DEFAULT_LOG = (msg) => process.stderr.write(`${msg}\n`);
34
+ export async function resolveAuth(opts) {
35
+ const clientId = opts.clientId ?? 'mcp-stdio';
36
+ const log = opts.log ?? DEFAULT_LOG;
37
+ const tokenStoreGet = opts.tokenStoreGet ?? defaultGetTokens;
38
+ const tokenStoreSet = opts.tokenStoreSet ?? defaultSetTokens;
39
+ const tokenStoreClear = opts.tokenStoreClear ?? defaultClearTokens;
40
+ const isExpiredFn = opts.isExpiredFn ?? defaultIsExpired;
41
+ const deviceGrantFlow = opts.deviceGrantFlow ?? runDeviceGrantFlow;
42
+ // 1. PAT env var — takes precedence; matches pre-OAuth behavior.
43
+ if (opts.patFromEnv) {
44
+ if (!opts.patFromEnv.startsWith(PAT_PREFIX)) {
45
+ throw new AuthResolutionError('pat_malformed', `ROCKHOPPER_TOKEN does not look like a valid Personal Access Token. Tokens start with "${PAT_PREFIX}".`);
46
+ }
47
+ return { accessToken: opts.patFromEnv, source: 'pat' };
48
+ }
49
+ // 2. Stored OAuth bundle.
50
+ let stored = null;
51
+ try {
52
+ stored = await tokenStoreGet();
53
+ }
54
+ catch (e) {
55
+ // Keychain backend missing or locked. Don't abort yet — fall through
56
+ // to device-grant. If THAT fails too, surface a combined error.
57
+ log(`Could not read OS keychain (${e instanceof Error ? e.message : String(e)}). Falling back to device-grant flow.`);
58
+ }
59
+ if (stored && !isExpiredFn(stored)) {
60
+ return { accessToken: stored.accessToken, source: 'stored-oauth' };
61
+ }
62
+ if (stored) {
63
+ // Expired bundle — clear it and proceed.
64
+ try {
65
+ await tokenStoreClear();
66
+ }
67
+ catch {
68
+ // Non-fatal — overwrite on the upcoming setTokens.
69
+ }
70
+ }
71
+ // 3. Device-grant flow.
72
+ let bundle;
73
+ try {
74
+ bundle = await deviceGrantFlow({
75
+ baseUrl: opts.baseUrl,
76
+ clientId,
77
+ });
78
+ }
79
+ catch (e) {
80
+ if (e instanceof DeviceGrantError) {
81
+ throw new AuthResolutionError('device_grant_failed', `Device-grant flow failed (${e.code}): ${e.message}`);
82
+ }
83
+ throw new AuthResolutionError('device_grant_failed', `Device-grant flow failed: ${e instanceof Error ? e.message : String(e)}`);
84
+ }
85
+ // Persist for next launch. Storage failure is non-fatal — the
86
+ // current token still works, the user just re-runs the flow next
87
+ // time.
88
+ try {
89
+ await tokenStoreSet({
90
+ accessToken: bundle.accessToken,
91
+ refreshToken: bundle.refreshToken,
92
+ expiresAt: Date.now() + bundle.expiresIn * 1000,
93
+ });
94
+ }
95
+ catch (e) {
96
+ log(`Warning: could not persist OAuth token to OS keychain (${e instanceof Error ? e.message : String(e)}). You may need to re-authenticate on the next launch.`);
97
+ }
98
+ return { accessToken: bundle.accessToken, source: 'device-grant' };
99
+ }
100
+ //# sourceMappingURL=resolve-auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-auth.js","sourceRoot":"","sources":["../../src/auth/resolve-auth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ENG-1444 / KI-081 — RFC 8628 device-grant token storage.
3
+ *
4
+ * Wraps `keytar` to persist OAuth tokens in the OS-native keychain
5
+ * (Keychain on macOS, Credential Manager on Windows, libsecret on
6
+ * Linux). The mcp-server is spawned by AI clients (Cursor, Claude
7
+ * Desktop) and survives across client launches, so tokens must live
8
+ * outside the process.
9
+ *
10
+ * One bundle per machine — there's no multi-user concept inside a
11
+ * single mcp-server install. If the user re-runs the device-grant
12
+ * flow, the prior bundle is overwritten.
13
+ *
14
+ * Linux without libsecret installed will throw on first keytar call
15
+ * (the native binding loads but the backend lookup fails). The CLI
16
+ * surfaces a clear remediation message ("apt-get install
17
+ * libsecret-tools" etc.) — we do NOT silently fall back to plaintext
18
+ * file storage. Encrypted file fallback may land in a follow-up.
19
+ */
20
+ export interface OAuthTokenBundle {
21
+ accessToken: string;
22
+ /** Optional — backend does not currently issue refresh tokens (ENG-1446). */
23
+ refreshToken?: string;
24
+ /** Epoch milliseconds. Null means "no expiry set" — treat as expired. */
25
+ expiresAt: number | null;
26
+ }
27
+ /**
28
+ * Read the persisted OAuth bundle. Returns null if no bundle is stored
29
+ * OR if the stored value is malformed (corrupted entry — treat as
30
+ * "no tokens" and let the CLI initiate a fresh device flow).
31
+ */
32
+ export declare function getTokens(): Promise<OAuthTokenBundle | null>;
33
+ /**
34
+ * Persist the OAuth bundle, overwriting any prior bundle.
35
+ */
36
+ export declare function setTokens(bundle: OAuthTokenBundle): Promise<void>;
37
+ /**
38
+ * Delete any persisted bundle. Safe to call when nothing is stored
39
+ * (keytar returns false; we ignore the return).
40
+ */
41
+ export declare function clearTokens(): Promise<void>;
42
+ /**
43
+ * `true` if `bundle.expiresAt` is in the past (or null). Pure helper —
44
+ * the device-grant client wires this into its refresh-on-expiry logic.
45
+ *
46
+ * A small safety margin (60s by default) treats tokens about to expire
47
+ * as already expired, so the client refreshes BEFORE the API rejects
48
+ * the next call.
49
+ */
50
+ export declare function isExpired(bundle: OAuthTokenBundle, marginMs?: number, now?: number): boolean;
51
+ //# sourceMappingURL=token-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-store.d.ts","sourceRoot":"","sources":["../../src/auth/token-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAgDH,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAmBlE;AAED;;GAEG;AACH,wBAAsB,SAAS,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAOvE;AAED;;;GAGG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAGjD;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,gBAAgB,EACxB,QAAQ,SAAS,EACjB,GAAG,SAAa,GACf,OAAO,CAGT"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * ENG-1444 / KI-081 — RFC 8628 device-grant token storage.
3
+ *
4
+ * Wraps `keytar` to persist OAuth tokens in the OS-native keychain
5
+ * (Keychain on macOS, Credential Manager on Windows, libsecret on
6
+ * Linux). The mcp-server is spawned by AI clients (Cursor, Claude
7
+ * Desktop) and survives across client launches, so tokens must live
8
+ * outside the process.
9
+ *
10
+ * One bundle per machine — there's no multi-user concept inside a
11
+ * single mcp-server install. If the user re-runs the device-grant
12
+ * flow, the prior bundle is overwritten.
13
+ *
14
+ * Linux without libsecret installed will throw on first keytar call
15
+ * (the native binding loads but the backend lookup fails). The CLI
16
+ * surfaces a clear remediation message ("apt-get install
17
+ * libsecret-tools" etc.) — we do NOT silently fall back to plaintext
18
+ * file storage. Encrypted file fallback may land in a follow-up.
19
+ */
20
+ let _keytar = null;
21
+ let _keytarErr = null;
22
+ async function loadKeytar() {
23
+ if (_keytar)
24
+ return _keytar;
25
+ /* v8 ignore next */
26
+ if (_keytarErr)
27
+ throw _keytarErr;
28
+ try {
29
+ _keytar = await import('keytar');
30
+ return _keytar;
31
+ }
32
+ catch (e) {
33
+ /* v8 ignore next 9 -- runtime dlopen failure (Linux without libsecret); the catch can't be reached via vi.mock since the mock factory always resolves successfully. Behavior is covered by the CLI's end-to-end behavior on platforms where the import genuinely fails. */
34
+ const reason = e instanceof Error ? e.message : String(e);
35
+ _keytarErr = new Error(`OS keychain unavailable (${reason}). ` +
36
+ 'On Linux, install libsecret (e.g. `apt-get install libsecret-1-dev` on Debian/Ubuntu, ' +
37
+ '`dnf install libsecret` on Fedora). Or set ROCKHOPPER_TOKEN to a Personal Access Token ' +
38
+ 'to use PAT auth instead of OAuth.');
39
+ throw _keytarErr;
40
+ }
41
+ }
42
+ const KEYTAR_SERVICE = 'rockhopper-mcp';
43
+ const KEYTAR_ACCOUNT = 'oauth-tokens';
44
+ /**
45
+ * Read the persisted OAuth bundle. Returns null if no bundle is stored
46
+ * OR if the stored value is malformed (corrupted entry — treat as
47
+ * "no tokens" and let the CLI initiate a fresh device flow).
48
+ */
49
+ export async function getTokens() {
50
+ const keytar = await loadKeytar();
51
+ const raw = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
52
+ if (!raw)
53
+ return null;
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ if (typeof parsed?.accessToken !== 'string')
57
+ return null;
58
+ return {
59
+ accessToken: parsed.accessToken,
60
+ refreshToken: typeof parsed.refreshToken === 'string'
61
+ ? parsed.refreshToken
62
+ : undefined,
63
+ expiresAt: typeof parsed.expiresAt === 'number' ? parsed.expiresAt : null,
64
+ };
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * Persist the OAuth bundle, overwriting any prior bundle.
72
+ */
73
+ export async function setTokens(bundle) {
74
+ const keytar = await loadKeytar();
75
+ await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, JSON.stringify(bundle));
76
+ }
77
+ /**
78
+ * Delete any persisted bundle. Safe to call when nothing is stored
79
+ * (keytar returns false; we ignore the return).
80
+ */
81
+ export async function clearTokens() {
82
+ const keytar = await loadKeytar();
83
+ await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
84
+ }
85
+ /**
86
+ * `true` if `bundle.expiresAt` is in the past (or null). Pure helper —
87
+ * the device-grant client wires this into its refresh-on-expiry logic.
88
+ *
89
+ * A small safety margin (60s by default) treats tokens about to expire
90
+ * as already expired, so the client refreshes BEFORE the API rejects
91
+ * the next call.
92
+ */
93
+ export function isExpired(bundle, marginMs = 60_000, now = Date.now()) {
94
+ if (bundle.expiresAt === null)
95
+ return true;
96
+ return bundle.expiresAt - marginMs <= now;
97
+ }
98
+ //# sourceMappingURL=token-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-store.js","sourceRoot":"","sources":["../../src/auth/token-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAsBH,IAAI,OAAO,GAAwB,IAAI,CAAC;AACxC,IAAI,UAAU,GAAiB,IAAI,CAAC;AAEpC,KAAK,UAAU,UAAU;IACvB,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC;IAC5B,oBAAoB;IACpB,IAAI,UAAU;QAAE,MAAM,UAAU,CAAC;IACjC,IAAI,CAAC;QACH,OAAO,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;QACjC,OAAO,OAAO,CAAC;IACjB,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,2QAA2Q;QAC3Q,MAAM,MAAM,GAAG,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC1D,UAAU,GAAG,IAAI,KAAK,CACpB,4BAA4B,MAAM,KAAK;YACrC,wFAAwF;YACxF,yFAAyF;YACzF,mCAAmC,CACtC,CAAC;QACF,MAAM,UAAU,CAAC;IACnB,CAAC;AACH,CAAC;AAED,MAAM,cAAc,GAAG,gBAAgB,CAAC;AACxC,MAAM,cAAc,GAAG,cAAc,CAAC;AAUtC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS;IAC7B,MAAM,MAAM,GAAG,MAAM,UAAU,EAAE,CAAC;IAClC,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;IACrE,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,OAAO,MAAM,EAAE,WAAW,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QACzD,OAAO;YACL,WAAW,EAAE,MAAM,CAAC,WAAW;YAC/B,YAAY,EACV,OAAO,MAAM,CAAC,YAAY,KAAK,QAAQ;gBACrC,CAAC,CAAC,MAAM,CAAC,YAAY;gBACrB,CAAC,CAAC,SAAS;YACf,SAAS,EACP,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI;SACjE,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,MAAwB;IACtD,MAAM,MAAM,GAAG,MAAM,UAAU,EAAE,CAAC;IAClC,MAAM,MAAM,CAAC,WAAW,CACtB,cAAc,EACd,cAAc,EACd,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CACvB,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW;IAC/B,MAAM,MAAM,GAAG,MAAM,UAAU,EAAE,CAAC;IAClC,MAAM,MAAM,CAAC,cAAc,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,SAAS,CACvB,MAAwB,EACxB,QAAQ,GAAG,MAAM,EACjB,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IAEhB,IAAI,MAAM,CAAC,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC3C,OAAO,MAAM,CAAC,SAAS,GAAG,QAAQ,IAAI,GAAG,CAAC;AAC5C,CAAC"}
package/dist/cli.js CHANGED
@@ -1,22 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { ApiClient } from './api-client.js';
4
+ import { AuthResolutionError, resolveAuth, } from './auth/resolve-auth.js';
4
5
  import { createServer } from './server.js';
5
6
  const ROCKHOPPER_API_URL = process.env.ROCKHOPPER_API_URL || 'https://api.rockhopper.co';
6
- const ROCKHOPPER_TOKEN = process.env.ROCKHOPPER_TOKEN;
7
- if (!ROCKHOPPER_TOKEN) {
8
- console.error('Error: ROCKHOPPER_TOKEN environment variable is required.\n' +
9
- 'Create a Personal Access Token in Rockhopper Settings and set it as ROCKHOPPER_TOKEN.');
10
- process.exit(1);
7
+ // ENG-1444: auth resolution order is
8
+ // 1. ROCKHOPPER_TOKEN env var (Personal Access Token — headless / CI)
9
+ // 2. Stored OAuth bundle in the OS keychain (prior device-grant flow)
10
+ // 3. Device-grant flow (prints code to stderr, polls for approval)
11
+ let resolved;
12
+ try {
13
+ resolved = await resolveAuth({
14
+ baseUrl: ROCKHOPPER_API_URL,
15
+ patFromEnv: process.env.ROCKHOPPER_TOKEN,
16
+ });
11
17
  }
12
- if (!ROCKHOPPER_TOKEN.startsWith('rh_pat_')) {
13
- console.error('Error: ROCKHOPPER_TOKEN does not look like a valid Personal Access Token.\n' +
14
- 'Tokens start with "rh_pat_". Check that the full token was copied correctly.');
18
+ catch (err) {
19
+ if (err instanceof AuthResolutionError) {
20
+ if (err.code === 'pat_malformed') {
21
+ console.error(`Error: ${err.message}`);
22
+ console.error('Tokens start with "rh_pat_". Check that the full token was copied correctly.');
23
+ }
24
+ else if (err.code === 'device_grant_failed') {
25
+ console.error(`Error: Could not complete sign-in.\n${err.message}`);
26
+ console.error('You can also set ROCKHOPPER_TOKEN to a Personal Access Token (Settings → Personal Access Tokens) and re-launch.');
27
+ }
28
+ else {
29
+ console.error(`Error: ${err.message}`);
30
+ }
31
+ }
32
+ else {
33
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
34
+ }
15
35
  process.exit(1);
16
36
  }
17
37
  const apiClient = new ApiClient({
18
38
  baseUrl: ROCKHOPPER_API_URL,
19
- token: ROCKHOPPER_TOKEN,
39
+ token: resolved.accessToken,
20
40
  });
21
41
  try {
22
42
  await apiClient.getMe();
@@ -24,8 +44,14 @@ try {
24
44
  catch (err) {
25
45
  const msg = err instanceof Error ? err.message : String(err);
26
46
  if (msg.includes('401') || msg.includes('403')) {
27
- console.error('Error: ROCKHOPPER_TOKEN is invalid or expired.\n' +
28
- 'Create a new Personal Access Token in Rockhopper Settings and set it as ROCKHOPPER_TOKEN.');
47
+ if (resolved.source === 'pat') {
48
+ console.error('Error: ROCKHOPPER_TOKEN is invalid or expired.\n' +
49
+ 'Create a new Personal Access Token in Rockhopper Settings and set it as ROCKHOPPER_TOKEN.');
50
+ }
51
+ else {
52
+ console.error(`Error: Stored OAuth token is invalid (source: ${resolved.source}).\n` +
53
+ 'Re-launch the MCP server — it will run the device-grant flow again.');
54
+ }
29
55
  }
30
56
  else {
31
57
  console.error(`Error: Could not reach Rockhopper API at ${ROCKHOPPER_API_URL}.\n` +
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,kBAAkB,GACtB,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,2BAA2B,CAAC;AAChE,MAAM,gBAAgB,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC;AAEtD,IAAI,CAAC,gBAAgB,EAAE,CAAC;IACtB,OAAO,CAAC,KAAK,CACX,6DAA6D;QAC3D,uFAAuF,CAC1F,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,IAAI,CAAC,gBAAgB,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;IAC5C,OAAO,CAAC,KAAK,CACX,6EAA6E;QAC3E,8EAA8E,CACjF,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,SAAS,GAAG,IAAI,SAAS,CAAC;IAC9B,OAAO,EAAE,kBAAkB;IAC3B,KAAK,EAAE,gBAAgB;CACxB,CAAC,CAAC;AAEH,IAAI,CAAC;IACH,MAAM,SAAS,CAAC,KAAK,EAAE,CAAC;AAC1B,CAAC;AAAC,OAAO,GAAG,EAAE,CAAC;IACb,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,IAAI,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC/C,OAAO,CAAC,KAAK,CACX,kDAAkD;YAChD,2FAA2F,CAC9F,CAAC;IACJ,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,KAAK,CACX,4CAA4C,kBAAkB,KAAK;YACjE,YAAY,GAAG,EAAE,CACpB,CAAC;IACJ,CAAC;IACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,MAAM,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;AAEvC,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;AAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC"}
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/prompts/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,SAAS,GAAG,IAAI,CA4MvE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/prompts/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD,wBAAgB,eAAe,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,SAAS,GAAG,IAAI,CAuNvE"}
@@ -7,17 +7,20 @@ export function registerPrompts(server, api) {
7
7
  fileMsId: z.string().describe('Platform ID of the enrolled file'),
8
8
  },
9
9
  }, async ({ fileMsId }) => {
10
- const [file, versions, changes] = await Promise.all([
10
+ const [file, versions, changesPage] = await Promise.all([
11
11
  api.getEnrolledFile(fileMsId),
12
12
  api.getFileVersions(fileMsId),
13
- api.getUnattributedChanges(fileMsId),
13
+ // KI-097: switched to cursor-paginated route. Top-of-prompt summary
14
+ // uses `totalCount` for the full file count; the per-cell preview
15
+ // uses up to 20 rows from this first page (sufficient for a recap).
16
+ api.getUnattributedChangesPaginated(fileMsId),
14
17
  ]);
15
18
  const recentVersions = versions.slice(0, 5);
16
19
  const versionSummary = recentVersions
17
20
  .map((v) => `- v${v.majorVersion}.${v.minorVersion}.${v.patchVersion}: ${v.description || 'No description'} (${v.createdAt})`)
18
21
  .join('\n');
19
- const changeSummary = changes.length
20
- ? changes
22
+ const changeSummary = changesPage.changes.length
23
+ ? changesPage.changes
21
24
  .slice(0, 20)
22
25
  .map((c) => `- ${c.sheetName}!${c.cellAddress}: ${JSON.stringify(c.oldValue)} → ${JSON.stringify(c.newValue)}`)
23
26
  .join('\n')
@@ -30,7 +33,7 @@ export function registerPrompts(server, api) {
30
33
  type: 'text',
31
34
  text: `Summarize the recent activity on the file "${file.name}".\n\n` +
32
35
  `## Recent Versions (last ${recentVersions.length} of ${versions.length})\n${versionSummary}\n\n` +
33
- `## Unattributed Changes (${changes.length} total)\n${changeSummary}\n\n` +
36
+ `## Unattributed Changes (${changesPage.totalCount} total)\n${changeSummary}\n\n` +
34
37
  `Provide a concise summary of what has changed recently, who made changes, and any notable patterns.`,
35
38
  },
36
39
  },
@@ -115,11 +118,14 @@ export function registerPrompts(server, api) {
115
118
  fileMsId: z.string().describe('Platform ID of the enrolled file'),
116
119
  },
117
120
  }, async ({ fileMsId }) => {
118
- const [file, versions, comments, changes] = await Promise.all([
121
+ const [file, versions, comments, changesPage] = await Promise.all([
119
122
  api.getEnrolledFile(fileMsId),
120
123
  api.getFileVersions(fileMsId),
121
124
  api.getFileComments(fileMsId),
122
- api.getUnattributedChanges(fileMsId),
125
+ // KI-097: switched to cursor-paginated route. The file-overview
126
+ // prompt only displays a total count, so we use `totalCount` from
127
+ // the first page — no need to fetch every row.
128
+ api.getUnattributedChangesPaginated(fileMsId),
123
129
  ]);
124
130
  const latestVersion = versions[0];
125
131
  let reviews = [];
@@ -127,7 +133,12 @@ export function registerPrompts(server, api) {
127
133
  reviews = await api.getReviewsForVersion(latestVersion.internalId);
128
134
  }
129
135
  const unresolvedComments = comments.filter((c) => !c.resolved);
130
- const pendingReviews = reviews.filter((r) => r.status !== 'approved' && r.status !== 'rejected');
136
+ // Backend's ReviewRequestStatus enum is uppercase (PENDING/APPROVED/CANCELLED).
137
+ // Prior code compared against lowercase 'approved' and a nonexistent 'rejected',
138
+ // so APPROVED + CANCELLED reviews were silently classified as pending (sibling
139
+ // of KI-099). Match positive intent — "pending = PENDING" — with defensive
140
+ // .toUpperCase() so the check survives future backend casing flips.
141
+ const pendingReviews = reviews.filter((r) => r.status?.toUpperCase() === 'PENDING');
131
142
  return {
132
143
  messages: [
133
144
  {
@@ -145,7 +156,7 @@ export function registerPrompts(server, api) {
145
156
  : 'No versions yet.\n') +
146
157
  `\n## Comments: ${comments.length} total, ${unresolvedComments.length} unresolved\n` +
147
158
  `## Reviews: ${reviews.length} total, ${pendingReviews.length} pending\n` +
148
- `## Unattributed Changes: ${changes.length}\n\n` +
159
+ `## Unattributed Changes: ${changesPage.totalCount}\n\n` +
149
160
  `Provide a status report highlighting anything that needs attention.`,
150
161
  },
151
162
  },