@loopops/mcp-server 1.0.0 → 2.0.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.
@@ -1,38 +1,52 @@
1
1
  /**
2
- * tRPC client for connecting to the hosted Loop Operations API.
3
- * Used when the MCP server runs in API mode (API_URL + API_KEY set).
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
- /** Make a tRPC query call to the hosted API. Retries once on timeout/network/5xx. */
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>;
@@ -1,23 +1,48 @@
1
1
  /**
2
- * tRPC client for connecting to the hosted Loop Operations API.
3
- * Used when the MCP server runs in API mode (API_URL + API_KEY set).
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 apiKey = process.env.API_KEY;
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
- * tRPC returns errors wrapped in an envelope. Extract the `message`
72
- * field so MCP tools can surface the server's actionable text verbatim
73
- * instead of our generic fallback.
74
- *
75
- * Shape (tRPC fetch adapter, HTTP error): [{ error: { json: { message } } }]
76
- * or { error: { json: { message } } }
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
- // Body wasn't JSON; fall through.
119
+ // not JSON
102
120
  }
103
121
  return null;
104
122
  }
@@ -134,25 +152,111 @@ function isRetryable(err) {
134
152
  return true;
135
153
  return false;
136
154
  }
137
- /** Make a tRPC query call to the hosted API. Retries once on timeout/network/5xx. */
138
- export async function trpcQuery(path, input) {
155
+ /**
156
+ * Persist a rotated refresh token back to ~/.mcp.json. Best-effort — if
157
+ * the write fails, we still keep the new token in-memory so the current
158
+ * subprocess keeps working. Claude Desktop's next spawn will need the
159
+ * disk value though.
160
+ */
161
+ function persistRotatedRefreshToken(newToken) {
162
+ try {
163
+ const path = join(homedir(), ".mcp.json");
164
+ if (!existsSync(path))
165
+ return;
166
+ const raw = readFileSync(path, "utf-8");
167
+ const data = JSON.parse(raw);
168
+ const stanza = data.mcpServers?.[SERVER_NAME];
169
+ if (!stanza?.env)
170
+ return;
171
+ stanza.env.OKTA_REFRESH_TOKEN = newToken;
172
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
173
+ try {
174
+ chmodSync(path, 0o600);
175
+ }
176
+ catch {
177
+ // ok on platforms where this is a no-op
178
+ }
179
+ }
180
+ catch (err) {
181
+ // Log to stderr (visible in Claude Desktop logs) but don't fail the request.
182
+ console.error("[MCP] Could not persist rotated refresh token to ~/.mcp.json:", err instanceof Error ? err.message : String(err));
183
+ }
184
+ }
185
+ /**
186
+ * Mint a fresh access token via Okta's /token endpoint. Updates the
187
+ * in-memory cache AND (if Okta rotated the refresh token) persists the
188
+ * new refresh token to ~/.mcp.json.
189
+ */
190
+ async function refreshAccessTokenOnce() {
191
+ if (!oktaIssuer || !oktaClientId || !refreshToken) {
192
+ throw new OktaRefreshError("Missing OKTA_ISSUER / OKTA_CLIENT_ID / OKTA_REFRESH_TOKEN. Re-run `npx @loopops/mcp-cli login`.");
193
+ }
194
+ const response = await doFetch(`${oktaIssuer}/v1/token`, {
195
+ method: "POST",
196
+ headers: { "content-type": "application/x-www-form-urlencoded" },
197
+ body: new URLSearchParams({
198
+ grant_type: "refresh_token",
199
+ client_id: oktaClientId,
200
+ refresh_token: refreshToken,
201
+ }),
202
+ }, DEFAULT_TIMEOUT_MS);
203
+ if (!response.ok) {
204
+ const body = await response.text().catch(() => "<unreadable>");
205
+ 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)}`);
206
+ }
207
+ const tokens = (await response.json());
208
+ cachedAccessToken = tokens.access_token;
209
+ cachedAccessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;
210
+ // Handle rotation (our Okta policy rotates on every use).
211
+ if (tokens.refresh_token && tokens.refresh_token !== refreshToken) {
212
+ refreshToken = tokens.refresh_token;
213
+ persistRotatedRefreshToken(tokens.refresh_token);
214
+ }
215
+ return cachedAccessToken;
216
+ }
217
+ async function acquireAccessToken(forceRefresh = false) {
218
+ if (!forceRefresh &&
219
+ cachedAccessToken &&
220
+ Date.now() + ACCESS_TOKEN_EARLY_REFRESH_MS < cachedAccessTokenExpiresAt) {
221
+ return cachedAccessToken;
222
+ }
223
+ return await refreshAccessTokenOnce();
224
+ }
225
+ /** Shared tRPC call plumbing — one place handles auth headers + retry. */
226
+ async function callTrpc(args) {
139
227
  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
- const init = {
145
- method: "GET",
146
- redirect: "follow",
147
- headers: {
148
- Authorization: `Bearer ${getApiKey()}`,
149
- "Content-Type": "application/json",
150
- },
151
- };
228
+ url.pathname = url.pathname.replace(/\/$/, "") + "/" + args.path;
229
+ if (args.method === "GET" && args.input !== undefined) {
230
+ url.searchParams.set("input", JSON.stringify({ json: args.input }));
231
+ }
232
+ // Up to 2 tries when allowed for transient errors, plus 1 extra try
233
+ // dedicated to re-auth after 401.
234
+ const maxNetAttempts = args.retryOnNetwork ? 2 : 1;
152
235
  let lastErr;
153
- for (let attempt = 0; attempt < 2; attempt++) {
236
+ let reauthed = false;
237
+ // Outer retry loop: network/5xx (GET only) + one re-auth retry.
238
+ for (let attempt = 0; attempt < maxNetAttempts + 1; attempt++) {
154
239
  try {
240
+ const accessToken = await acquireAccessToken();
241
+ const init = {
242
+ method: args.method,
243
+ redirect: "follow",
244
+ headers: {
245
+ Authorization: `Bearer ${accessToken}`,
246
+ "Content-Type": "application/json",
247
+ },
248
+ };
249
+ if (args.method === "POST") {
250
+ init.body = JSON.stringify({ json: args.input ?? {} });
251
+ }
155
252
  const response = await doFetch(url.toString(), init, DEFAULT_TIMEOUT_MS);
253
+ if (response.status === 401 && !reauthed) {
254
+ // Access token may have expired between cache check + request.
255
+ // Force-refresh once and retry.
256
+ reauthed = true;
257
+ await acquireAccessToken(/* forceRefresh */ true);
258
+ continue;
259
+ }
156
260
  if (!response.ok)
157
261
  await readAndThrow(response);
158
262
  const data = (await response.json());
@@ -160,29 +264,20 @@ export async function trpcQuery(path, input) {
160
264
  }
161
265
  catch (err) {
162
266
  lastErr = err;
267
+ if (err instanceof OktaRefreshError)
268
+ throw err;
163
269
  if (!isRetryable(err))
164
270
  throw err;
165
- if (attempt === 0)
271
+ if (attempt < maxNetAttempts)
166
272
  continue;
273
+ throw err;
167
274
  }
168
275
  }
169
276
  throw lastErr;
170
277
  }
171
- /** Make a tRPC mutation call to the hosted API. Does NOT retry (non-idempotent). */
278
+ export async function trpcQuery(path, input) {
279
+ return callTrpc({ method: "GET", path, input, retryOnNetwork: true });
280
+ }
172
281
  export async function trpcMutation(path, input) {
173
- const url = new URL(getApiUrl());
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;
282
+ return callTrpc({ method: "POST", path, input, retryOnNetwork: false });
188
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopops/mcp-server",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Loop Operations MCP Server — AI skills for RevOps",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",