jetty-mcp-server 1.0.0 → 1.0.3

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/src/tools.ts CHANGED
@@ -8,6 +8,60 @@ function jsonResult(data: unknown) {
8
8
  };
9
9
  }
10
10
 
11
+ const PROVIDER_KEYS = [
12
+ "OPENAI_API_KEY",
13
+ "ANTHROPIC_API_KEY",
14
+ "GEMINI_API_KEY",
15
+ "REPLICATE_API_TOKEN",
16
+ ];
17
+
18
+ async function detectTrialEligibility(
19
+ client: JettyClient,
20
+ collection: string
21
+ ): Promise<{ useTrialKeys: boolean; trialInfo: Record<string, unknown> }> {
22
+ try {
23
+ const trialStatus = (await client.getTrialStatus(collection)) as Record<
24
+ string,
25
+ unknown
26
+ >;
27
+
28
+ if (!trialStatus.active) {
29
+ return {
30
+ useTrialKeys: false,
31
+ trialInfo: { trial_active: false },
32
+ };
33
+ }
34
+
35
+ const env = (await client.getCollectionEnvironment(collection)) as Record<
36
+ string,
37
+ unknown
38
+ >;
39
+ const envKeys = Object.keys(env);
40
+ const hasProviderKeys = PROVIDER_KEYS.some((k) => envKeys.includes(k));
41
+
42
+ if (hasProviderKeys) {
43
+ return {
44
+ useTrialKeys: false,
45
+ trialInfo: {
46
+ trial_active: true,
47
+ using_trial_keys: false,
48
+ reason: "Collection has its own provider keys",
49
+ },
50
+ };
51
+ }
52
+
53
+ return {
54
+ useTrialKeys: true,
55
+ trialInfo: {
56
+ trial_active: true,
57
+ using_trial_keys: true,
58
+ },
59
+ };
60
+ } catch {
61
+ return { useTrialKeys: false, trialInfo: {} };
62
+ }
63
+ }
64
+
11
65
  export function registerTools(server: McpServer, client: JettyClient) {
12
66
  // ── Collections ──────────────────────────────────────────────
13
67
 
@@ -87,11 +141,31 @@ export function registerTools(server: McpServer, client: JettyClient) {
87
141
  }
88
142
  );
89
143
 
144
+ // ── Trial Keys ────────────────────────────────────────────────
145
+
146
+ server.tool(
147
+ "get-trial-status",
148
+ "Get trial key status for a collection",
149
+ { collection: z.string().describe("Collection name") },
150
+ async ({ collection }) => {
151
+ return jsonResult(await client.getTrialStatus(collection));
152
+ }
153
+ );
154
+
155
+ server.tool(
156
+ "activate-trial",
157
+ "Activate trial keys for a collection",
158
+ { collection: z.string().describe("Collection name") },
159
+ async ({ collection }) => {
160
+ return jsonResult(await client.activateTrial(collection));
161
+ }
162
+ );
163
+
90
164
  // ── Run Workflows ────────────────────────────────────────────
91
165
 
92
166
  server.tool(
93
167
  "run-workflow",
94
- "Run a workflow asynchronously (returns immediately with workflow_id)",
168
+ "Run a workflow asynchronously (returns immediately with workflow_id). Auto-detects trial key eligibility.",
95
169
  {
96
170
  collection: z.string().describe("Collection name"),
97
171
  task: z.string().describe("Task name"),
@@ -101,19 +175,23 @@ export function registerTools(server: McpServer, client: JettyClient) {
101
175
  .describe("Input parameters for the workflow"),
102
176
  },
103
177
  async ({ collection, task, init_params }) => {
104
- return jsonResult(
105
- await client.runWorkflow(
106
- collection,
107
- task,
108
- init_params as Record<string, unknown>
109
- )
178
+ const { useTrialKeys, trialInfo } = await detectTrialEligibility(
179
+ client,
180
+ collection
181
+ );
182
+ const result = await client.runWorkflow(
183
+ collection,
184
+ task,
185
+ init_params as Record<string, unknown>,
186
+ useTrialKeys
110
187
  );
188
+ return jsonResult({ ...trialInfo, result });
111
189
  }
112
190
  );
113
191
 
114
192
  server.tool(
115
193
  "run-workflow-sync",
116
- "Run a workflow synchronously (blocks until completion, may take 30-60s)",
194
+ "Run a workflow synchronously (blocks until completion, may take 30-60s). Auto-detects trial key eligibility.",
117
195
  {
118
196
  collection: z.string().describe("Collection name"),
119
197
  task: z.string().describe("Task name"),
@@ -123,13 +201,17 @@ export function registerTools(server: McpServer, client: JettyClient) {
123
201
  .describe("Input parameters for the workflow"),
124
202
  },
125
203
  async ({ collection, task, init_params }) => {
126
- return jsonResult(
127
- await client.runWorkflowSync(
128
- collection,
129
- task,
130
- init_params as Record<string, unknown>
131
- )
204
+ const { useTrialKeys, trialInfo } = await detectTrialEligibility(
205
+ client,
206
+ collection
132
207
  );
208
+ const result = await client.runWorkflowSync(
209
+ collection,
210
+ task,
211
+ init_params as Record<string, unknown>,
212
+ useTrialKeys
213
+ );
214
+ return jsonResult({ ...trialInfo, result });
133
215
  }
134
216
  );
135
217
 
@@ -219,4 +301,285 @@ export function registerTools(server: McpServer, client: JettyClient) {
219
301
  return jsonResult(await client.getStepTemplate(name));
220
302
  }
221
303
  );
304
+
305
+ // ── Environment Variables ─────────────────────────────────────
306
+
307
+ server.tool(
308
+ "check-secrets",
309
+ "Check which environment variables a collection has configured vs. what a runbook needs",
310
+ {
311
+ collection: z.string().describe("Collection name"),
312
+ required_keys: z
313
+ .array(z.string())
314
+ .describe("Environment variable names the runbook requires"),
315
+ },
316
+ async ({ collection, required_keys }) => {
317
+ const col = (await client.getCollectionEnvironment(
318
+ collection
319
+ )) as Record<string, unknown>;
320
+ const envVars = (col.environment_variables || {}) as Record<
321
+ string,
322
+ unknown
323
+ >;
324
+ const configured = Object.keys(envVars);
325
+ const missing = required_keys.filter((k) => !configured.includes(k));
326
+ return jsonResult({
327
+ configured,
328
+ missing,
329
+ ready: missing.length === 0,
330
+ });
331
+ }
332
+ );
333
+
334
+ server.tool(
335
+ "set-environment-vars",
336
+ "Set environment variables on a collection (merge semantics, pass null to delete a key)",
337
+ {
338
+ collection: z.string().describe("Collection name"),
339
+ variables: z
340
+ .record(z.string().nullable())
341
+ .describe("Key-value pairs to set (null to delete)"),
342
+ },
343
+ async ({ collection, variables }) => {
344
+ return jsonResult(
345
+ await client.setEnvironmentVars(
346
+ collection,
347
+ variables as Record<string, string>
348
+ )
349
+ );
350
+ }
351
+ );
352
+
353
+ // ── Routines (Scheduled Runs) ─────────────────────────────────
354
+
355
+ const cadenceSchema = z
356
+ .object({
357
+ type: z
358
+ .enum(["manual", "hourly", "daily", "weekdays", "weekly"])
359
+ .describe(
360
+ "Cadence type. 'manual' creates a saved invocation preset (no schedule); the others register a Temporal schedule."
361
+ ),
362
+ hour_utc: z
363
+ .number()
364
+ .int()
365
+ .min(0)
366
+ .max(23)
367
+ .optional()
368
+ .describe(
369
+ "Hour of day in UTC (0-23). Required for daily/weekdays/weekly."
370
+ ),
371
+ minute_utc: z
372
+ .number()
373
+ .int()
374
+ .min(0)
375
+ .max(59)
376
+ .optional()
377
+ .describe("Minute of the hour in UTC (0-59). Defaults to 0."),
378
+ day_of_week: z
379
+ .enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
380
+ .optional()
381
+ .describe("Day of week. Required for weekly cadence."),
382
+ })
383
+ .describe(
384
+ "Cadence config. Examples: {type:'hourly', minute_utc:15}, {type:'daily', hour_utc:9}, {type:'weekly', day_of_week:'mon', hour_utc:9}."
385
+ );
386
+
387
+ server.tool(
388
+ "list-routines",
389
+ "List scheduled routines for a collection (optionally scoped to a task)",
390
+ {
391
+ collection: z.string().describe("Collection name"),
392
+ task: z
393
+ .string()
394
+ .optional()
395
+ .describe("Task name (omit to list all routines in the collection)"),
396
+ },
397
+ async ({ collection, task }) => {
398
+ return jsonResult(await client.listRoutines(collection, task));
399
+ }
400
+ );
401
+
402
+ server.tool(
403
+ "get-routine",
404
+ "Get a single routine including resolved next_run_at from Temporal",
405
+ {
406
+ collection: z.string().describe("Collection name"),
407
+ task: z.string().describe("Task name"),
408
+ name: z.string().describe("Routine name (unique per collection+task)"),
409
+ },
410
+ async ({ collection, task, name }) => {
411
+ return jsonResult(await client.getRoutine(collection, task, name));
412
+ }
413
+ );
414
+
415
+ server.tool(
416
+ "create-routine",
417
+ "Create a scheduled routine for an existing task. init_params_overrides keys must be a subset of task.workflow.init_params.",
418
+ {
419
+ collection: z.string().describe("Collection name"),
420
+ task: z.string().describe("Task name"),
421
+ name: z.string().describe("Routine name (slug, unique per task)"),
422
+ cadence: cadenceSchema,
423
+ init_params_overrides: z
424
+ .record(z.unknown())
425
+ .optional()
426
+ .describe(
427
+ "Init param overrides. Keys must be a subset of task.workflow.init_params; unknown keys return 400."
428
+ ),
429
+ secret_params: z
430
+ .record(z.unknown())
431
+ .optional()
432
+ .describe(
433
+ "Secrets injected at fire time (encrypted at rest, never logged)."
434
+ ),
435
+ paused: z
436
+ .boolean()
437
+ .optional()
438
+ .describe("Create the routine in paused state"),
439
+ webhook_url: z
440
+ .string()
441
+ .optional()
442
+ .describe("Optional webhook to notify on each run"),
443
+ webhook_secret: z
444
+ .string()
445
+ .optional()
446
+ .describe("Optional shared secret for webhook signing"),
447
+ },
448
+ async ({
449
+ collection,
450
+ task,
451
+ name,
452
+ cadence,
453
+ init_params_overrides,
454
+ secret_params,
455
+ paused,
456
+ webhook_url,
457
+ webhook_secret,
458
+ }) => {
459
+ const body: Record<string, unknown> = { name, cadence };
460
+ if (init_params_overrides !== undefined)
461
+ body.init_params_overrides = init_params_overrides;
462
+ if (secret_params !== undefined) body.secret_params = secret_params;
463
+ if (paused !== undefined) body.paused = paused;
464
+ if (webhook_url !== undefined) body.webhook_url = webhook_url;
465
+ if (webhook_secret !== undefined) body.webhook_secret = webhook_secret;
466
+ return jsonResult(await client.createRoutine(collection, task, body));
467
+ }
468
+ );
469
+
470
+ server.tool(
471
+ "update-routine",
472
+ "Patch a routine — provide any subset of cadence/overrides/secrets/paused/webhook fields",
473
+ {
474
+ collection: z.string().describe("Collection name"),
475
+ task: z.string().describe("Task name"),
476
+ name: z.string().describe("Routine name"),
477
+ cadence: cadenceSchema.optional(),
478
+ init_params_overrides: z
479
+ .record(z.unknown())
480
+ .optional()
481
+ .describe("Replace init param overrides"),
482
+ secret_params: z
483
+ .record(z.unknown())
484
+ .optional()
485
+ .describe("Replace secret params"),
486
+ paused: z.boolean().optional().describe("Pause/resume the routine"),
487
+ webhook_url: z.string().optional().describe("Update webhook URL"),
488
+ webhook_secret: z
489
+ .string()
490
+ .optional()
491
+ .describe("Update webhook signing secret"),
492
+ },
493
+ async ({
494
+ collection,
495
+ task,
496
+ name,
497
+ cadence,
498
+ init_params_overrides,
499
+ secret_params,
500
+ paused,
501
+ webhook_url,
502
+ webhook_secret,
503
+ }) => {
504
+ const patch: Record<string, unknown> = {};
505
+ if (cadence !== undefined) patch.cadence = cadence;
506
+ if (init_params_overrides !== undefined)
507
+ patch.init_params_overrides = init_params_overrides;
508
+ if (secret_params !== undefined) patch.secret_params = secret_params;
509
+ if (paused !== undefined) patch.paused = paused;
510
+ if (webhook_url !== undefined) patch.webhook_url = webhook_url;
511
+ if (webhook_secret !== undefined) patch.webhook_secret = webhook_secret;
512
+ return jsonResult(
513
+ await client.updateRoutine(collection, task, name, patch)
514
+ );
515
+ }
516
+ );
517
+
518
+ server.tool(
519
+ "delete-routine",
520
+ "Delete a routine and unregister its Temporal schedule",
521
+ {
522
+ collection: z.string().describe("Collection name"),
523
+ task: z.string().describe("Task name"),
524
+ name: z.string().describe("Routine name"),
525
+ },
526
+ async ({ collection, task, name }) => {
527
+ return jsonResult(await client.deleteRoutine(collection, task, name));
528
+ }
529
+ );
530
+
531
+ server.tool(
532
+ "pause-routine",
533
+ "Pause a routine (stops future fires until resumed)",
534
+ {
535
+ collection: z.string().describe("Collection name"),
536
+ task: z.string().describe("Task name"),
537
+ name: z.string().describe("Routine name"),
538
+ },
539
+ async ({ collection, task, name }) => {
540
+ return jsonResult(await client.pauseRoutine(collection, task, name));
541
+ }
542
+ );
543
+
544
+ server.tool(
545
+ "resume-routine",
546
+ "Resume a paused routine",
547
+ {
548
+ collection: z.string().describe("Collection name"),
549
+ task: z.string().describe("Task name"),
550
+ name: z.string().describe("Routine name"),
551
+ },
552
+ async ({ collection, task, name }) => {
553
+ return jsonResult(await client.resumeRoutine(collection, task, name));
554
+ }
555
+ );
556
+
557
+ server.tool(
558
+ "run-routine-now",
559
+ "Fire a routine immediately, bypassing the schedule. Returns workflow_id (parity with run-workflow).",
560
+ {
561
+ collection: z.string().describe("Collection name"),
562
+ task: z.string().describe("Task name"),
563
+ name: z.string().describe("Routine name"),
564
+ },
565
+ async ({ collection, task, name }) => {
566
+ return jsonResult(await client.runRoutineNow(collection, task, name));
567
+ }
568
+ );
569
+
570
+ server.tool(
571
+ "list-routine-runs",
572
+ "List recent trajectories triggered by this routine (filtered by triggered_by_routine_id)",
573
+ {
574
+ collection: z.string().describe("Collection name"),
575
+ task: z.string().describe("Task name"),
576
+ name: z.string().describe("Routine name"),
577
+ limit: z.number().optional().describe("Max results"),
578
+ },
579
+ async ({ collection, task, name, limit }) => {
580
+ return jsonResult(
581
+ await client.listRoutineRuns(collection, task, name, limit)
582
+ );
583
+ }
584
+ );
222
585
  }
@@ -0,0 +1,129 @@
1
+ // Auth/token-resolution tests for JettyClient.
2
+ //
3
+ // Regression for the "MCP can't authenticate" bug: the plugin's .mcp.json used
4
+ // to pass JETTY_API_TOKEN="" (empty), which both supplied no token AND shadowed
5
+ // any inherited one. The server now (a) treats empty/whitespace env as unset and
6
+ // (b) falls back to ~/.config/jetty/token written by `jetty login`.
7
+ //
8
+ // Imports from ../dist (build first; `npm test` runs the pretest build hook).
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { JettyClient } from "../dist/client.js";
16
+
17
+ // Snapshot env we mutate so tests don't leak into each other.
18
+ function withEnv(overrides, fn) {
19
+ const saved = {};
20
+ for (const k of ["JETTY_API_TOKEN", "JETTY_API_URL", "HOME"]) {
21
+ saved[k] = process.env[k];
22
+ if (overrides[k] === undefined) delete process.env[k];
23
+ else process.env[k] = overrides[k];
24
+ }
25
+ try {
26
+ return fn();
27
+ } finally {
28
+ for (const k of Object.keys(saved)) {
29
+ if (saved[k] === undefined) delete process.env[k];
30
+ else process.env[k] = saved[k];
31
+ }
32
+ }
33
+ }
34
+
35
+ // Build a fake HOME containing ~/.config/jetty/token (or not, if token === null).
36
+ function fakeHome(token) {
37
+ const home = mkdtempSync(join(tmpdir(), "jetty-auth-test-"));
38
+ if (token !== null) {
39
+ mkdirSync(join(home, ".config", "jetty"), { recursive: true });
40
+ writeFileSync(join(home, ".config", "jetty", "token"), token);
41
+ }
42
+ return home;
43
+ }
44
+
45
+ // requireToken() is private; exercise it through a no-network request and assert
46
+ // on the thrown message (or the lack of an auth-shaped one).
47
+ async function tokenError(client) {
48
+ const orig = globalThis.fetch;
49
+ globalThis.fetch = async () => {
50
+ throw new Error("__network_reached__");
51
+ };
52
+ try {
53
+ await client.listCollections();
54
+ return null;
55
+ } catch (e) {
56
+ return e.message;
57
+ } finally {
58
+ globalThis.fetch = orig;
59
+ }
60
+ }
61
+
62
+ test("empty JETTY_API_TOKEN falls back to the token file", async () => {
63
+ const home = fakeHome("mlc_filetoken");
64
+ try {
65
+ await withEnv({ JETTY_API_TOKEN: "", HOME: home }, async () => {
66
+ const client = new JettyClient();
67
+ // Token file present → no auth error; the only failure is the mocked network.
68
+ const msg = await tokenError(client);
69
+ assert.equal(msg, "__network_reached__");
70
+ });
71
+ } finally {
72
+ rmSync(home, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ test("whitespace-only JETTY_API_TOKEN does not count as set", async () => {
77
+ const home = fakeHome("mlc_filetoken");
78
+ try {
79
+ await withEnv({ JETTY_API_TOKEN: " ", HOME: home }, async () => {
80
+ const client = new JettyClient();
81
+ assert.equal(await tokenError(client), "__network_reached__");
82
+ });
83
+ } finally {
84
+ rmSync(home, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test("env token takes precedence over the file", async () => {
89
+ const home = fakeHome("mlc_filetoken");
90
+ try {
91
+ await withEnv({ JETTY_API_TOKEN: "mlc_envtoken", HOME: home }, async () => {
92
+ const calls = [];
93
+ const orig = globalThis.fetch;
94
+ globalThis.fetch = async (url, init) => {
95
+ calls.push(init?.headers?.Authorization);
96
+ return {
97
+ ok: true,
98
+ status: 200,
99
+ headers: new Headers({ "content-type": "application/json" }),
100
+ json: async () => ({}),
101
+ text: async () => "{}",
102
+ };
103
+ };
104
+ try {
105
+ const client = new JettyClient();
106
+ await client.listCollections();
107
+ assert.equal(calls[0], "Bearer mlc_envtoken");
108
+ } finally {
109
+ globalThis.fetch = orig;
110
+ }
111
+ });
112
+ } finally {
113
+ rmSync(home, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test("no env and no file yields a helpful error mentioning jetty login", async () => {
118
+ const home = fakeHome(null); // no token file
119
+ try {
120
+ await withEnv({ JETTY_API_TOKEN: undefined, HOME: home }, async () => {
121
+ const client = new JettyClient();
122
+ const msg = await tokenError(client);
123
+ assert.match(msg, /jetty login/);
124
+ assert.notEqual(msg, "__network_reached__"); // failed before any fetch
125
+ });
126
+ } finally {
127
+ rmSync(home, { recursive: true, force: true });
128
+ }
129
+ });