@rockhopper-co/mcp-server 0.6.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.
- package/CHANGELOG.md +134 -1
- package/README.md +34 -10
- package/dist/api-client.d.ts +56 -5
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +86 -11
- package/dist/api-client.js.map +1 -1
- package/dist/auth/device-grant-client.d.ts +80 -0
- package/dist/auth/device-grant-client.d.ts.map +1 -0
- package/dist/auth/device-grant-client.js +138 -0
- package/dist/auth/device-grant-client.js.map +1 -0
- package/dist/auth/resolve-auth.d.ts +52 -0
- package/dist/auth/resolve-auth.d.ts.map +1 -0
- package/dist/auth/resolve-auth.js +100 -0
- package/dist/auth/resolve-auth.js.map +1 -0
- package/dist/auth/token-store.d.ts +51 -0
- package/dist/auth/token-store.d.ts.map +1 -0
- package/dist/auth/token-store.js +98 -0
- package/dist/auth/token-store.js.map +1 -0
- package/dist/cli.js +37 -11
- package/dist/cli.js.map +1 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +20 -9
- package/dist/prompts/index.js.map +1 -1
- package/dist/resources/changes.d.ts.map +1 -1
- package/dist/resources/changes.js +8 -2
- package/dist/resources/changes.js.map +1 -1
- package/dist/resources/orchestration-guide.md +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +113 -20
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/write-files.js +4 -4
- package/dist/tools/write-files.js.map +1 -1
- package/dist/tools/write-reviews.d.ts.map +1 -1
- package/dist/tools/write-reviews.js +3 -1
- package/dist/tools/write-reviews.js.map +1 -1
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/zod-schemas.d.ts +111 -0
- package/dist/zod-schemas.d.ts.map +1 -0
- package/dist/zod-schemas.js +62 -0
- package/dist/zod-schemas.js.map +1 -0
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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:
|
|
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
|
-
|
|
28
|
-
'
|
|
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;
|
|
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,EACL,mBAAmB,EACnB,WAAW,GACZ,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,kBAAkB,GACtB,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,2BAA2B,CAAC;AAEhE,qCAAqC;AACrC,wEAAwE;AACxE,wEAAwE;AACxE,qEAAqE;AACrE,IAAI,QAAQ,CAAC;AACb,IAAI,CAAC;IACH,QAAQ,GAAG,MAAM,WAAW,CAAC;QAC3B,OAAO,EAAE,kBAAkB;QAC3B,UAAU,EAAE,OAAO,CAAC,GAAG,CAAC,gBAAgB;KACzC,CAAC,CAAC;AACL,CAAC;AAAC,OAAO,GAAG,EAAE,CAAC;IACb,IAAI,GAAG,YAAY,mBAAmB,EAAE,CAAC;QACvC,IAAI,GAAG,CAAC,IAAI,KAAK,eAAe,EAAE,CAAC;YACjC,OAAO,CAAC,KAAK,CAAC,UAAU,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YACvC,OAAO,CAAC,KAAK,CACX,8EAA8E,CAC/E,CAAC;QACJ,CAAC;aAAM,IAAI,GAAG,CAAC,IAAI,KAAK,qBAAqB,EAAE,CAAC;YAC9C,OAAO,CAAC,KAAK,CAAC,uCAAuC,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YACpE,OAAO,CAAC,KAAK,CACX,iHAAiH,CAClH,CAAC;QACJ,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,UAAU,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;QACzC,CAAC;IACH,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,KAAK,CACX,UAAU,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAC7D,CAAC;IACJ,CAAC;IACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,SAAS,GAAG,IAAI,SAAS,CAAC;IAC9B,OAAO,EAAE,kBAAkB;IAC3B,KAAK,EAAE,QAAQ,CAAC,WAAW;CAC5B,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,IAAI,QAAQ,CAAC,MAAM,KAAK,KAAK,EAAE,CAAC;YAC9B,OAAO,CAAC,KAAK,CACX,kDAAkD;gBAChD,2FAA2F,CAC9F,CAAC;QACJ,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CACX,iDAAiD,QAAQ,CAAC,MAAM,MAAM;gBACpE,qEAAqE,CACxE,CAAC;QACJ,CAAC;IACH,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 +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,
|
|
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"}
|
package/dist/prompts/index.js
CHANGED
|
@@ -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,
|
|
10
|
+
const [file, versions, changesPage] = await Promise.all([
|
|
11
11
|
api.getEnrolledFile(fileMsId),
|
|
12
12
|
api.getFileVersions(fileMsId),
|
|
13
|
-
|
|
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 (${
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
159
|
+
`## Unattributed Changes: ${changesPage.totalCount}\n\n` +
|
|
149
160
|
`Provide a status report highlighting anything that needs attention.`,
|
|
150
161
|
},
|
|
151
162
|
},
|