@loopops/mcp-server 1.0.0 → 2.0.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/dist/api-client.d.ts +24 -10
- package/dist/api-client.js +165 -56
- package/package.json +1 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -1,38 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tRPC client for
|
|
3
|
-
*
|
|
2
|
+
* tRPC client for the hosted Loop Operations API. Authenticated with
|
|
3
|
+
* short-lived Okta access tokens minted from a cached refresh token.
|
|
4
|
+
*
|
|
5
|
+
* The MCP subprocess is spawned by Claude Desktop / Claude Code with
|
|
6
|
+
* these env vars (written to ~/.mcp.json by `@loopops/mcp-cli`):
|
|
7
|
+
* - API_URL
|
|
8
|
+
* - OKTA_ISSUER
|
|
9
|
+
* - OKTA_CLIENT_ID
|
|
10
|
+
* - OKTA_REFRESH_TOKEN (initial; may be rotated on refresh)
|
|
11
|
+
*
|
|
12
|
+
* Token lifecycle:
|
|
13
|
+
* - On startup we have the refresh token from env. No access token yet.
|
|
14
|
+
* - First tRPC call triggers `acquireAccessToken()` which hits Okta's
|
|
15
|
+
* /token endpoint, caches the access_token + expiry in memory.
|
|
16
|
+
* - Subsequent calls reuse the cached access_token until expiry (1h).
|
|
17
|
+
* - On 401, we refresh once and retry (covers token lifetime edge).
|
|
18
|
+
* - If Okta rotates the refresh_token (our policy says "rotate on every
|
|
19
|
+
* use"), we persist the new one back to ~/.mcp.json via the shared
|
|
20
|
+
* updater helper, and also keep the latest rotated token in memory
|
|
21
|
+
* in case Claude Desktop doesn't restart before next use.
|
|
4
22
|
*/
|
|
5
23
|
export declare function isApiMode(): boolean;
|
|
6
24
|
export declare function getApiUrl(): string;
|
|
7
|
-
export declare function getApiKey(): string;
|
|
8
25
|
/** Base class so callers can distinguish API errors from unexpected errors. */
|
|
9
26
|
export declare class ApiError extends Error {
|
|
10
27
|
constructor(message: string);
|
|
11
28
|
}
|
|
12
|
-
/** The request took longer than the configured timeout. */
|
|
13
29
|
export declare class ApiTimeoutError extends ApiError {
|
|
14
30
|
readonly timeoutMs: number;
|
|
15
31
|
constructor(timeoutMs: number);
|
|
16
32
|
}
|
|
17
|
-
/** fetch() itself failed (DNS, TCP reset, etc.) — no HTTP response received. */
|
|
18
33
|
export declare class ApiNetworkError extends ApiError {
|
|
19
34
|
constructor(cause: unknown);
|
|
20
35
|
}
|
|
21
|
-
/** API returned a non-2xx HTTP status. */
|
|
22
36
|
export declare class ApiHttpError extends ApiError {
|
|
23
37
|
readonly status: number;
|
|
24
38
|
readonly body: string;
|
|
25
39
|
constructor(status: number, body: string);
|
|
26
40
|
}
|
|
27
|
-
/** API returned 401/403 — API key missing, expired, or lacks permission. */
|
|
28
41
|
export declare class ApiAuthError extends ApiError {
|
|
29
42
|
readonly status: number;
|
|
30
43
|
readonly body: string;
|
|
31
|
-
/** The tRPC error's `message` field if parseable, else null. */
|
|
32
44
|
readonly serverMessage: string | null;
|
|
33
45
|
constructor(status: number, body: string);
|
|
34
46
|
}
|
|
35
|
-
/**
|
|
47
|
+
/** The refresh token itself was rejected by Okta — user must re-auth. */
|
|
48
|
+
export declare class OktaRefreshError extends ApiError {
|
|
49
|
+
constructor(message: string);
|
|
50
|
+
}
|
|
36
51
|
export declare function trpcQuery<T = unknown>(path: string, input?: Record<string, unknown>): Promise<T>;
|
|
37
|
-
/** Make a tRPC mutation call to the hosted API. Does NOT retry (non-idempotent). */
|
|
38
52
|
export declare function trpcMutation<T = unknown>(path: string, input: Record<string, unknown>): Promise<T>;
|
package/dist/api-client.js
CHANGED
|
@@ -1,23 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tRPC client for
|
|
3
|
-
*
|
|
2
|
+
* tRPC client for the hosted Loop Operations API. Authenticated with
|
|
3
|
+
* short-lived Okta access tokens minted from a cached refresh token.
|
|
4
|
+
*
|
|
5
|
+
* The MCP subprocess is spawned by Claude Desktop / Claude Code with
|
|
6
|
+
* these env vars (written to ~/.mcp.json by `@loopops/mcp-cli`):
|
|
7
|
+
* - API_URL
|
|
8
|
+
* - OKTA_ISSUER
|
|
9
|
+
* - OKTA_CLIENT_ID
|
|
10
|
+
* - OKTA_REFRESH_TOKEN (initial; may be rotated on refresh)
|
|
11
|
+
*
|
|
12
|
+
* Token lifecycle:
|
|
13
|
+
* - On startup we have the refresh token from env. No access token yet.
|
|
14
|
+
* - First tRPC call triggers `acquireAccessToken()` which hits Okta's
|
|
15
|
+
* /token endpoint, caches the access_token + expiry in memory.
|
|
16
|
+
* - Subsequent calls reuse the cached access_token until expiry (1h).
|
|
17
|
+
* - On 401, we refresh once and retry (covers token lifetime edge).
|
|
18
|
+
* - If Okta rotates the refresh_token (our policy says "rotate on every
|
|
19
|
+
* use"), we persist the new one back to ~/.mcp.json via the shared
|
|
20
|
+
* updater helper, and also keep the latest rotated token in memory
|
|
21
|
+
* in case Claude Desktop doesn't restart before next use.
|
|
4
22
|
*/
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
26
|
const apiUrl = process.env.API_URL;
|
|
6
|
-
const
|
|
27
|
+
const oktaIssuer = process.env.OKTA_ISSUER;
|
|
28
|
+
const oktaClientId = process.env.OKTA_CLIENT_ID;
|
|
29
|
+
// Mutable — may be updated in-memory when Okta rotates the refresh token.
|
|
30
|
+
let refreshToken = process.env.OKTA_REFRESH_TOKEN;
|
|
31
|
+
// Access token cache (in-memory, subprocess-lifetime).
|
|
32
|
+
let cachedAccessToken = null;
|
|
33
|
+
let cachedAccessTokenExpiresAt = 0; // epoch ms
|
|
7
34
|
const DEFAULT_TIMEOUT_MS = Number(process.env.API_TIMEOUT_MS || 30_000);
|
|
35
|
+
const SERVER_NAME = "loop-operations";
|
|
36
|
+
// Refresh a little early so requests don't race token expiry at the edge.
|
|
37
|
+
const ACCESS_TOKEN_EARLY_REFRESH_MS = 60 * 1000; // 1 minute
|
|
8
38
|
export function isApiMode() {
|
|
9
|
-
return !!apiUrl;
|
|
39
|
+
return !!apiUrl && !!oktaIssuer && !!oktaClientId && !!refreshToken;
|
|
10
40
|
}
|
|
11
41
|
export function getApiUrl() {
|
|
12
42
|
if (!apiUrl)
|
|
13
43
|
throw new Error("API_URL not set");
|
|
14
44
|
return apiUrl;
|
|
15
45
|
}
|
|
16
|
-
export function getApiKey() {
|
|
17
|
-
if (!apiKey)
|
|
18
|
-
throw new Error("API_KEY not set");
|
|
19
|
-
return apiKey;
|
|
20
|
-
}
|
|
21
46
|
/** Base class so callers can distinguish API errors from unexpected errors. */
|
|
22
47
|
export class ApiError extends Error {
|
|
23
48
|
constructor(message) {
|
|
@@ -25,7 +50,6 @@ export class ApiError extends Error {
|
|
|
25
50
|
this.name = "ApiError";
|
|
26
51
|
}
|
|
27
52
|
}
|
|
28
|
-
/** The request took longer than the configured timeout. */
|
|
29
53
|
export class ApiTimeoutError extends ApiError {
|
|
30
54
|
timeoutMs;
|
|
31
55
|
constructor(timeoutMs) {
|
|
@@ -34,7 +58,6 @@ export class ApiTimeoutError extends ApiError {
|
|
|
34
58
|
this.name = "ApiTimeoutError";
|
|
35
59
|
}
|
|
36
60
|
}
|
|
37
|
-
/** fetch() itself failed (DNS, TCP reset, etc.) — no HTTP response received. */
|
|
38
61
|
export class ApiNetworkError extends ApiError {
|
|
39
62
|
constructor(cause) {
|
|
40
63
|
super(`Loop API network error: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
@@ -42,7 +65,6 @@ export class ApiNetworkError extends ApiError {
|
|
|
42
65
|
this.cause = cause;
|
|
43
66
|
}
|
|
44
67
|
}
|
|
45
|
-
/** API returned a non-2xx HTTP status. */
|
|
46
68
|
export class ApiHttpError extends ApiError {
|
|
47
69
|
status;
|
|
48
70
|
body;
|
|
@@ -53,11 +75,9 @@ export class ApiHttpError extends ApiError {
|
|
|
53
75
|
this.name = "ApiHttpError";
|
|
54
76
|
}
|
|
55
77
|
}
|
|
56
|
-
/** API returned 401/403 — API key missing, expired, or lacks permission. */
|
|
57
78
|
export class ApiAuthError extends ApiError {
|
|
58
79
|
status;
|
|
59
80
|
body;
|
|
60
|
-
/** The tRPC error's `message` field if parseable, else null. */
|
|
61
81
|
serverMessage;
|
|
62
82
|
constructor(status, body) {
|
|
63
83
|
super(`Loop API auth error (${status}): ${body.slice(0, 300)}`);
|
|
@@ -67,14 +87,13 @@ export class ApiAuthError extends ApiError {
|
|
|
67
87
|
this.serverMessage = extractTrpcMessage(body);
|
|
68
88
|
}
|
|
69
89
|
}
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*/
|
|
90
|
+
/** The refresh token itself was rejected by Okta — user must re-auth. */
|
|
91
|
+
export class OktaRefreshError extends ApiError {
|
|
92
|
+
constructor(message) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "OktaRefreshError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
78
97
|
function extractTrpcMessage(body) {
|
|
79
98
|
try {
|
|
80
99
|
const parsed = JSON.parse(body);
|
|
@@ -90,7 +109,6 @@ function extractTrpcMessage(body) {
|
|
|
90
109
|
if (typeof msg === "string" && msg.trim().length > 0)
|
|
91
110
|
return msg;
|
|
92
111
|
}
|
|
93
|
-
// Older/simpler shape: error.message at the top level
|
|
94
112
|
const flat = errorNode.message;
|
|
95
113
|
if (typeof flat === "string" && flat.trim().length > 0)
|
|
96
114
|
return flat;
|
|
@@ -98,7 +116,7 @@ function extractTrpcMessage(body) {
|
|
|
98
116
|
}
|
|
99
117
|
}
|
|
100
118
|
catch {
|
|
101
|
-
//
|
|
119
|
+
// not JSON
|
|
102
120
|
}
|
|
103
121
|
return null;
|
|
104
122
|
}
|
|
@@ -134,25 +152,125 @@ function isRetryable(err) {
|
|
|
134
152
|
return true;
|
|
135
153
|
return false;
|
|
136
154
|
}
|
|
137
|
-
/**
|
|
138
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Persist a rotated refresh token across every config location we know
|
|
157
|
+
* users store MCP settings in:
|
|
158
|
+
* - ~/.mcp.json (Claude Desktop)
|
|
159
|
+
* - ~/.claude/settings.json (Claude Code)
|
|
160
|
+
*
|
|
161
|
+
* We update whichever files have the loop-operations stanza. Missing
|
|
162
|
+
* files are skipped silently. This mirrors @loopops/mcp-cli's
|
|
163
|
+
* `updateRefreshToken` helper — keep the two in sync.
|
|
164
|
+
*
|
|
165
|
+
* Best-effort: if all writes fail, we still keep the new token
|
|
166
|
+
* in-memory so the current subprocess keeps working. The next cold
|
|
167
|
+
* spawn is the one that breaks.
|
|
168
|
+
*/
|
|
169
|
+
function persistRotatedRefreshToken(newToken) {
|
|
170
|
+
const paths = [
|
|
171
|
+
join(homedir(), ".mcp.json"),
|
|
172
|
+
join(homedir(), ".claude", "settings.json"),
|
|
173
|
+
];
|
|
174
|
+
for (const path of paths) {
|
|
175
|
+
try {
|
|
176
|
+
if (!existsSync(path))
|
|
177
|
+
continue;
|
|
178
|
+
const raw = readFileSync(path, "utf-8");
|
|
179
|
+
const data = JSON.parse(raw);
|
|
180
|
+
const stanza = data.mcpServers?.[SERVER_NAME];
|
|
181
|
+
if (!stanza?.env)
|
|
182
|
+
continue;
|
|
183
|
+
stanza.env.OKTA_REFRESH_TOKEN = newToken;
|
|
184
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
185
|
+
try {
|
|
186
|
+
chmodSync(path, 0o600);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// ok on platforms where this is a no-op
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
// Log to stderr (visible in Claude Desktop/Code logs) but don't
|
|
194
|
+
// fail the request.
|
|
195
|
+
console.error(`[MCP] Could not persist rotated refresh token to ${path}:`, err instanceof Error ? err.message : String(err));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Mint a fresh access token via Okta's /token endpoint. Updates the
|
|
201
|
+
* in-memory cache AND (if Okta rotated the refresh token) persists the
|
|
202
|
+
* new refresh token to ~/.mcp.json.
|
|
203
|
+
*/
|
|
204
|
+
async function refreshAccessTokenOnce() {
|
|
205
|
+
if (!oktaIssuer || !oktaClientId || !refreshToken) {
|
|
206
|
+
throw new OktaRefreshError("Missing OKTA_ISSUER / OKTA_CLIENT_ID / OKTA_REFRESH_TOKEN. Re-run `npx @loopops/mcp-cli login`.");
|
|
207
|
+
}
|
|
208
|
+
const response = await doFetch(`${oktaIssuer}/v1/token`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
211
|
+
body: new URLSearchParams({
|
|
212
|
+
grant_type: "refresh_token",
|
|
213
|
+
client_id: oktaClientId,
|
|
214
|
+
refresh_token: refreshToken,
|
|
215
|
+
}),
|
|
216
|
+
}, DEFAULT_TIMEOUT_MS);
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
const body = await response.text().catch(() => "<unreadable>");
|
|
219
|
+
throw new OktaRefreshError(`Okta refresh failed (HTTP ${response.status}). Your refresh token may be revoked or idle-expired. Re-run \`npx @loopops/mcp-cli login\`. Detail: ${body.slice(0, 300)}`);
|
|
220
|
+
}
|
|
221
|
+
const tokens = (await response.json());
|
|
222
|
+
cachedAccessToken = tokens.access_token;
|
|
223
|
+
cachedAccessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;
|
|
224
|
+
// Handle rotation (our Okta policy rotates on every use).
|
|
225
|
+
if (tokens.refresh_token && tokens.refresh_token !== refreshToken) {
|
|
226
|
+
refreshToken = tokens.refresh_token;
|
|
227
|
+
persistRotatedRefreshToken(tokens.refresh_token);
|
|
228
|
+
}
|
|
229
|
+
return cachedAccessToken;
|
|
230
|
+
}
|
|
231
|
+
async function acquireAccessToken(forceRefresh = false) {
|
|
232
|
+
if (!forceRefresh &&
|
|
233
|
+
cachedAccessToken &&
|
|
234
|
+
Date.now() + ACCESS_TOKEN_EARLY_REFRESH_MS < cachedAccessTokenExpiresAt) {
|
|
235
|
+
return cachedAccessToken;
|
|
236
|
+
}
|
|
237
|
+
return await refreshAccessTokenOnce();
|
|
238
|
+
}
|
|
239
|
+
/** Shared tRPC call plumbing — one place handles auth headers + retry. */
|
|
240
|
+
async function callTrpc(args) {
|
|
139
241
|
const url = new URL(getApiUrl());
|
|
140
|
-
url.pathname = url.pathname.replace(/\/$/, "") + "/" + path;
|
|
141
|
-
if (input !== undefined) {
|
|
142
|
-
url.searchParams.set("input", JSON.stringify({ json: input }));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
headers: {
|
|
148
|
-
Authorization: `Bearer ${getApiKey()}`,
|
|
149
|
-
"Content-Type": "application/json",
|
|
150
|
-
},
|
|
151
|
-
};
|
|
242
|
+
url.pathname = url.pathname.replace(/\/$/, "") + "/" + args.path;
|
|
243
|
+
if (args.method === "GET" && args.input !== undefined) {
|
|
244
|
+
url.searchParams.set("input", JSON.stringify({ json: args.input }));
|
|
245
|
+
}
|
|
246
|
+
// Up to 2 tries when allowed for transient errors, plus 1 extra try
|
|
247
|
+
// dedicated to re-auth after 401.
|
|
248
|
+
const maxNetAttempts = args.retryOnNetwork ? 2 : 1;
|
|
152
249
|
let lastErr;
|
|
153
|
-
|
|
250
|
+
let reauthed = false;
|
|
251
|
+
// Outer retry loop: network/5xx (GET only) + one re-auth retry.
|
|
252
|
+
for (let attempt = 0; attempt < maxNetAttempts + 1; attempt++) {
|
|
154
253
|
try {
|
|
254
|
+
const accessToken = await acquireAccessToken();
|
|
255
|
+
const init = {
|
|
256
|
+
method: args.method,
|
|
257
|
+
redirect: "follow",
|
|
258
|
+
headers: {
|
|
259
|
+
Authorization: `Bearer ${accessToken}`,
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
if (args.method === "POST") {
|
|
264
|
+
init.body = JSON.stringify({ json: args.input ?? {} });
|
|
265
|
+
}
|
|
155
266
|
const response = await doFetch(url.toString(), init, DEFAULT_TIMEOUT_MS);
|
|
267
|
+
if (response.status === 401 && !reauthed) {
|
|
268
|
+
// Access token may have expired between cache check + request.
|
|
269
|
+
// Force-refresh once and retry.
|
|
270
|
+
reauthed = true;
|
|
271
|
+
await acquireAccessToken(/* forceRefresh */ true);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
156
274
|
if (!response.ok)
|
|
157
275
|
await readAndThrow(response);
|
|
158
276
|
const data = (await response.json());
|
|
@@ -160,29 +278,20 @@ export async function trpcQuery(path, input) {
|
|
|
160
278
|
}
|
|
161
279
|
catch (err) {
|
|
162
280
|
lastErr = err;
|
|
281
|
+
if (err instanceof OktaRefreshError)
|
|
282
|
+
throw err;
|
|
163
283
|
if (!isRetryable(err))
|
|
164
284
|
throw err;
|
|
165
|
-
if (attempt
|
|
285
|
+
if (attempt < maxNetAttempts)
|
|
166
286
|
continue;
|
|
287
|
+
throw err;
|
|
167
288
|
}
|
|
168
289
|
}
|
|
169
290
|
throw lastErr;
|
|
170
291
|
}
|
|
171
|
-
|
|
292
|
+
export async function trpcQuery(path, input) {
|
|
293
|
+
return callTrpc({ method: "GET", path, input, retryOnNetwork: true });
|
|
294
|
+
}
|
|
172
295
|
export async function trpcMutation(path, input) {
|
|
173
|
-
|
|
174
|
-
url.pathname = url.pathname.replace(/\/$/, "") + "/" + path;
|
|
175
|
-
const response = await doFetch(url.toString(), {
|
|
176
|
-
method: "POST",
|
|
177
|
-
redirect: "follow",
|
|
178
|
-
headers: {
|
|
179
|
-
Authorization: `Bearer ${getApiKey()}`,
|
|
180
|
-
"Content-Type": "application/json",
|
|
181
|
-
},
|
|
182
|
-
body: JSON.stringify({ json: input }),
|
|
183
|
-
}, DEFAULT_TIMEOUT_MS);
|
|
184
|
-
if (!response.ok)
|
|
185
|
-
await readAndThrow(response);
|
|
186
|
-
const data = (await response.json());
|
|
187
|
-
return data.result?.data?.json;
|
|
296
|
+
return callTrpc({ method: "POST", path, input, retryOnNetwork: false });
|
|
188
297
|
}
|