@mintmcp/hosted-cli 0.0.18 → 0.0.20

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.
@@ -2,6 +2,9 @@ import { type ChildProcess, execFileSync, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import net from "node:net";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5
8
 
6
9
  export interface BuildOptions {
7
10
  dockerfile?: string;
@@ -66,10 +69,7 @@ const SIZE_REJECT_BYTES = 1024 * 1024 * 1024;
66
69
  const DEFAULT_BUILD_PLATFORM = "linux/amd64";
67
70
  const DEFAULT_PROBE_PATH = "/mcp";
68
71
  const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
69
- const LOCAL_TEST_REQUEST_TIMEOUT_MS = 5_000;
70
72
  const LOCAL_TEST_IMAGE_PREFIX = "mintmcp-local-test";
71
- const LOCAL_TEST_PROBE_ID = "mintmcp-local-test";
72
- const LOCAL_TEST_PROTOCOL_VERSION = "2025-06-18";
73
73
  const LOCAL_TEST_POLL_INTERVAL_MS = 500;
74
74
 
75
75
  export function defaultBuildPlatform(): string {
@@ -273,18 +273,14 @@ export async function smokeTestImage(
273
273
  const { logs, exitInfo } = captureContainerLogs(container);
274
274
 
275
275
  try {
276
- await waitForMcpReady(
277
- {
278
- url,
279
- probeTimeoutMs,
280
- },
276
+ await mcpSmokeTest(
277
+ { url, probeTimeoutMs },
281
278
  {
282
279
  fetch: deps.fetch,
283
280
  sleep: deps.sleep,
284
281
  getExitInfo: () => exitInfo.value,
285
282
  },
286
283
  );
287
- console.log(`Local MCP probe succeeded: ${url}`);
288
284
  return { containerName, imageRef: options.imageRef, url };
289
285
  } catch (error) {
290
286
  const message = formatSmokeTestFailureMessage(error, logs.join(""));
@@ -426,7 +422,7 @@ function captureContainerLogs(container: ChildProcess): {
426
422
  return { logs, exitInfo };
427
423
  }
428
424
 
429
- async function waitForMcpReady(
425
+ async function mcpSmokeTest(
430
426
  params: { url: string; probeTimeoutMs: number },
431
427
  deps: {
432
428
  fetch: typeof fetch;
@@ -437,6 +433,8 @@ async function waitForMcpReady(
437
433
  },
438
434
  ): Promise<void> {
439
435
  const deadline = Date.now() + params.probeTimeoutMs;
436
+ const requiredSessions = 2;
437
+
440
438
  while (Date.now() < deadline) {
441
439
  const exitInfo = deps.getExitInfo();
442
440
  if (exitInfo) {
@@ -446,61 +444,38 @@ async function waitForMcpReady(
446
444
  }
447
445
 
448
446
  try {
449
- const response = await deps.fetch(params.url, {
450
- method: "POST",
451
- headers: {
452
- "content-type": "application/json",
453
- accept: "application/json, text/event-stream",
454
- connection: "close",
455
- },
456
- signal: AbortSignal.timeout(
457
- Math.min(
458
- LOCAL_TEST_REQUEST_TIMEOUT_MS,
459
- Math.max(1, deadline - Date.now()),
460
- ),
461
- ),
462
- body: JSON.stringify({
463
- jsonrpc: "2.0",
464
- id: LOCAL_TEST_PROBE_ID,
465
- method: "initialize",
466
- params: {
467
- protocolVersion: LOCAL_TEST_PROTOCOL_VERSION,
468
- capabilities: {},
469
- clientInfo: {
470
- name: "MintMCP Local Probe",
471
- version: "1.0.0",
472
- },
447
+ for (
448
+ let sessionIndex = 0;
449
+ sessionIndex < requiredSessions;
450
+ sessionIndex++
451
+ ) {
452
+ const client = new Client({
453
+ name: "MintMCP Local Probe",
454
+ version: "1.0.0",
455
+ });
456
+ const transport = new StreamableHTTPClientTransport(
457
+ new URL(params.url),
458
+ {
459
+ fetch: deps.fetch,
473
460
  },
474
- }),
475
- });
476
- if (response.status !== 200 && response.status !== 202) {
477
- throw new Error(`Probe returned HTTP ${response.status}`);
478
- }
461
+ );
479
462
 
480
- let responseBody = "";
481
- if (response.body != undefined) {
482
- const reader = response.body.getReader();
483
463
  try {
484
- while (true) {
485
- const { done, value } = await reader.read();
486
- if (done) {
487
- break;
488
- }
489
- responseBody += Buffer.from(value).toString("utf8");
490
- if (isInitializeSuccessResponse(responseBody)) {
491
- await reader.cancel().catch(() => undefined);
492
- return;
493
- }
494
- }
464
+ await client.connect(transport as Transport);
465
+ console.log(
466
+ `Local MCP initialize succeeded (${sessionIndex + 1}/${requiredSessions}): ${params.url}`,
467
+ );
468
+
469
+ const toolsResult = await client.listTools();
470
+ console.log(
471
+ `Local tools/list succeeded (${sessionIndex + 1}/${requiredSessions}): ${toolsResult.tools.length} tool(s) found`,
472
+ );
495
473
  } finally {
496
- reader.releaseLock();
474
+ await client.close();
497
475
  }
498
476
  }
499
477
 
500
- if (isInitializeSuccessResponse(responseBody)) {
501
- return;
502
- }
503
- throw new Error("Probe returned an unexpected JSON-RPC payload");
478
+ return;
504
479
  } catch {
505
480
  const exitInfo = deps.getExitInfo();
506
481
  if (exitInfo) {
@@ -517,43 +492,6 @@ async function waitForMcpReady(
517
492
  );
518
493
  }
519
494
 
520
- function isInitializeSuccessResponse(bodyText: string): boolean {
521
- const parseCandidate = (candidate: string): boolean => {
522
- try {
523
- const parsed = JSON.parse(candidate) as {
524
- jsonrpc?: unknown;
525
- id?: unknown;
526
- result?: unknown;
527
- error?: unknown;
528
- };
529
- return (
530
- parsed.jsonrpc === "2.0" &&
531
- parsed.id === LOCAL_TEST_PROBE_ID &&
532
- parsed.error == undefined &&
533
- typeof parsed.result === "object" &&
534
- parsed.result != undefined
535
- );
536
- } catch {
537
- return false;
538
- }
539
- };
540
-
541
- const trimmed = bodyText.trim();
542
- if (trimmed.length === 0) {
543
- return false;
544
- }
545
- if (parseCandidate(trimmed)) {
546
- return true;
547
- }
548
-
549
- const lines = trimmed
550
- .split(/\r?\n/)
551
- .map((line) => line.trim())
552
- .filter((line) => line.length > 0)
553
- .map((line) => (line.startsWith("data:") ? line.slice(5).trim() : line));
554
- return lines.some((line) => parseCandidate(line));
555
- }
556
-
557
495
  async function stopContainer(
558
496
  containerName: string,
559
497
  deps: ContainerBuilderDeps,
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import z, { ZodSchema } from "zod";
14
14
  import {
15
15
  type AppRouter,
16
16
  type GatewayId,
17
+ GatewayIdSchema,
17
18
  HostedIdSchema,
18
19
  type HostedTransport,
19
20
  UserConfigUpdateSchema,
@@ -86,6 +87,10 @@ const HOSTED_CONFIG_PATHS: Record<Env, string> = {
86
87
  const HostedConfigBaseSchema = z.object({
87
88
  hostedId: HostedIdSchema,
88
89
 
90
+ // Gateway ID for this hosted server. Newer config files include this;
91
+ // older ones may omit it. Eventually this becomes the primary identifier.
92
+ gatewayId: GatewayIdSchema.optional(),
93
+
89
94
  // Organization that owns this hosted server. Older config files may omit it.
90
95
  organizationId: z.string().optional(),
91
96
  });
@@ -112,11 +117,27 @@ type DeployResult = {
112
117
 
113
118
  type RegistryPushSession =
114
119
  CliClientAppRouterOutputs["mcpHost"]["createRegistryPushSession"];
120
+ type RegistryPushImageRefInput = Pick<
121
+ RegistryPushSession,
122
+ "registryHost" | "repository" | "imageTag"
123
+ >;
115
124
 
116
125
  function storedTransport(config: HostedConfig): Transport | undefined {
117
126
  return "mode" in config ? config.transport : undefined;
118
127
  }
119
128
 
129
+ function localTransportFromHostedTransport(
130
+ transport: HostedTransport,
131
+ ): Transport {
132
+ return transport.type;
133
+ }
134
+
135
+ function rejectBuildAndPushTransportChange(transport: Transport): never {
136
+ throw new Error(
137
+ `--transport ${transport} is only supported when creating a new managed connector with build-and-push. To change transport on an existing connector, use deploy or build-and-deploy.`,
138
+ );
139
+ }
140
+
120
141
  function canUseDirectImageStartup(params: {
121
142
  transport: Transport | undefined;
122
143
  mntDataDir: string | undefined;
@@ -316,6 +337,7 @@ async function createNew(
316
337
  return {
317
338
  config: HostedConfigSchema.parse({
318
339
  hostedId: createdServer.hostedId,
340
+ gatewayId: createdServer.gatewayId,
319
341
  ...(image != undefined
320
342
  ? { mode: "container" as const, image, transport }
321
343
  : {}),
@@ -332,6 +354,7 @@ async function updateExisting(
332
354
  config: HostedConfig,
333
355
  organizationId: string,
334
356
  options: DeployOptions,
357
+ gatewayId?: GatewayId,
335
358
  ): Promise<DeployResult> {
336
359
  if (
337
360
  config.organizationId != undefined &&
@@ -390,12 +413,18 @@ async function updateExisting(
390
413
  : undefined;
391
414
  const updatedServer = await client.mcpHost.partialUpdateServer.mutate({
392
415
  hostedId: config.hostedId,
416
+ ...(gatewayId != undefined ? { gatewayId } : {}),
393
417
  update: buildPartialUpdate(options, upload?.secretDataZipGcsPath, {
394
418
  clearCommand,
395
419
  }),
396
420
  });
397
421
  return {
398
- config: newConfig,
422
+ config: HostedConfigSchema.parse({
423
+ ...newConfig,
424
+ ...(updatedServer.gatewayId != undefined
425
+ ? { gatewayId: updatedServer.gatewayId }
426
+ : {}),
427
+ }),
399
428
  ...(updatedServer.gatewayId != undefined
400
429
  ? { gatewayId: updatedServer.gatewayId }
401
430
  : {}),
@@ -437,7 +466,7 @@ function updateConfigAfterManagedPush(
437
466
  });
438
467
  }
439
468
 
440
- function managedRegistryImageRef(session: RegistryPushSession): string {
469
+ function managedRegistryImageRef(session: RegistryPushImageRefInput): string {
441
470
  return `${session.registryHost}/${session.repository}:${session.imageTag}`;
442
471
  }
443
472
 
@@ -445,6 +474,7 @@ async function buildAndPushManagedImage(params: {
445
474
  client: AuthenticatedClient;
446
475
  organizationId: string;
447
476
  config: HostedConfig;
477
+ gatewayId?: GatewayId;
448
478
  dockerfile: string;
449
479
  context: string;
450
480
  buildArg: string[];
@@ -462,6 +492,7 @@ async function buildAndPushManagedImage(params: {
462
492
 
463
493
  const session = await params.client.mcpHost.createRegistryPushSession.mutate({
464
494
  hostedId: params.config.hostedId,
495
+ ...(params.gatewayId != undefined ? { gatewayId: params.gatewayId } : {}),
465
496
  });
466
497
 
467
498
  const deployImageRef = managedRegistryImageRef(session);
@@ -479,15 +510,22 @@ async function buildAndPushManagedImage(params: {
479
510
 
480
511
  const finalized = await params.client.mcpHost.finalizeRegistryPush.mutate({
481
512
  hostedId: params.config.hostedId,
513
+ ...(params.gatewayId != undefined ? { gatewayId: params.gatewayId } : {}),
482
514
  finalizeToken: session.finalizeToken,
483
515
  });
484
516
 
517
+ const updatedConfig = updateConfigAfterManagedPush(
518
+ params.config,
519
+ params.organizationId,
520
+ deployImageRef,
521
+ );
485
522
  return {
486
- config: updateConfigAfterManagedPush(
487
- params.config,
488
- params.organizationId,
489
- deployImageRef,
490
- ),
523
+ config: HostedConfigSchema.parse({
524
+ ...updatedConfig,
525
+ ...(finalized.gatewayId != undefined
526
+ ? { gatewayId: finalized.gatewayId }
527
+ : {}),
528
+ }),
491
529
  ...(finalized.gatewayId != undefined
492
530
  ? { gatewayId: finalized.gatewayId }
493
531
  : {}),
@@ -567,6 +605,7 @@ async function buildAndPushManagedImageForCreate(params: {
567
605
  return {
568
606
  config: HostedConfigSchema.parse({
569
607
  hostedId: finalized.hostedId,
608
+ gatewayId: finalized.gatewayId,
570
609
  organizationId: params.organizationId,
571
610
  mode: "container" as const,
572
611
  image: deployImageRef,
@@ -582,24 +621,71 @@ async function deployWithOptions(
582
621
  env: Env,
583
622
  base: string,
584
623
  options: DeployOptions,
624
+ gatewayId?: GatewayId,
585
625
  ): Promise<void> {
586
626
  const auth = await getAuth(env);
587
627
 
588
628
  await withAuthRetry(env, async (client) => {
589
629
  const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
630
+ const hasExistingConfig = fs.existsSync(configPath);
590
631
 
591
632
  let result: DeployResult;
592
- if (fs.existsSync(configPath)) {
593
- console.log("Updating existing server...");
633
+ if (hasExistingConfig) {
594
634
  const configData = fs.readFileSync(configPath, "utf8");
635
+ const config = HostedConfigSchema.parse(JSON.parse(configData));
636
+ if (
637
+ gatewayId != undefined &&
638
+ config.gatewayId != undefined &&
639
+ config.gatewayId !== gatewayId
640
+ ) {
641
+ throw new Error(
642
+ `--gateway-id ${gatewayId} does not match the gatewayId in ${configPath} (${config.gatewayId}). Remove ${configPath} or omit --gateway-id.`,
643
+ );
644
+ }
645
+ console.log("Updating existing server...");
595
646
  result = await updateExisting(
596
647
  client,
597
648
  base,
598
- HostedConfigSchema.parse(JSON.parse(configData)),
649
+ config,
599
650
  auth.organizationId,
600
651
  options,
652
+ gatewayId,
601
653
  );
602
654
  logDeployResult(env, "Updated", result);
655
+ } else if (gatewayId != undefined) {
656
+ console.log(`Updating existing server via gateway ${gatewayId}...`);
657
+ const upload =
658
+ options.mntDataDir != undefined
659
+ ? await uploadData(client, path.join(base, options.mntDataDir))
660
+ : undefined;
661
+ const updatedServer = await client.mcpHost.partialUpdateServer.mutate({
662
+ gatewayId,
663
+ update: buildPartialUpdate(options, upload?.secretDataZipGcsPath),
664
+ });
665
+ const resolvedGatewayId = updatedServer.gatewayId ?? gatewayId;
666
+ const finalImage = updatedServer.config.image;
667
+ const finalTransport = localTransportFromHostedTransport(
668
+ updatedServer.config.transport,
669
+ );
670
+ result = {
671
+ config: HostedConfigSchema.parse({
672
+ hostedId: updatedServer.hostedId,
673
+ gatewayId: resolvedGatewayId,
674
+ organizationId: auth.organizationId,
675
+ ...(finalImage != undefined
676
+ ? {
677
+ mode: "container" as const,
678
+ image: finalImage,
679
+ transport: finalTransport,
680
+ }
681
+ : {}),
682
+ ...(options.mntDataDir != undefined
683
+ ? { mntDataDir: options.mntDataDir }
684
+ : {}),
685
+ }),
686
+ gatewayId: resolvedGatewayId,
687
+ };
688
+ logDeployResult(env, "Updated", result);
603
689
  } else {
604
690
  console.log("Creating new server...");
605
691
  result = await createNew(client, base, auth.organizationId, options);
@@ -750,9 +836,14 @@ program
750
836
  "--mnt-data-dir <directory>",
751
837
  `Directory to copy to /mnt on the server. ${makeDefaultMessage("mntDataDir")}`,
752
838
  )
839
+ .option(
840
+ "--gateway-id <id>",
841
+ "Gateway ID of an existing connector to update. Use when no local hosted config exists.",
842
+ argParser(GatewayIdSchema),
843
+ )
753
844
  .action(async (options) => {
754
845
  const { env, base } = program.opts();
755
- await deployWithOptions(env, base, options);
846
+ await deployWithOptions(env, base, options, options.gatewayId);
756
847
  });
757
848
 
758
849
  program
@@ -858,6 +949,11 @@ program
858
949
  "--startup-command <command>",
859
950
  "Startup command to configure when creating a new managed connector",
860
951
  )
952
+ .option(
953
+ "--gateway-id <id>",
954
+ "Gateway ID of an existing connector to update. Use when no local hosted config exists.",
955
+ argParser(GatewayIdSchema),
956
+ )
861
957
  .action(async (options) => {
862
958
  const { env, base } = program.opts();
863
959
 
@@ -876,13 +972,28 @@ program
876
972
 
877
973
  const result = await withAuthRetry(env, async (client) => {
878
974
  if (hasExistingConfig) {
975
+ if (options.transport != undefined) {
976
+ rejectBuildAndPushTransportChange(options.transport);
977
+ }
879
978
  const config = HostedConfigSchema.parse(
880
979
  JSON.parse(fs.readFileSync(configPath, "utf8")),
881
980
  );
981
+ if (
982
+ options.gatewayId != undefined &&
983
+ config.gatewayId != undefined &&
984
+ config.gatewayId !== options.gatewayId
985
+ ) {
986
+ throw new Error(
987
+ `--gateway-id ${options.gatewayId} does not match the gatewayId in ${configPath} (${config.gatewayId}). Remove ${configPath} or omit --gateway-id.`,
988
+ );
989
+ }
882
990
  return await buildAndPushManagedImage({
883
991
  client,
884
992
  organizationId: auth.organizationId,
885
993
  config,
994
+ ...(options.gatewayId != undefined
995
+ ? { gatewayId: options.gatewayId }
996
+ : {}),
886
997
  dockerfile: options.dockerfile,
887
998
  context: options.context,
888
999
  buildArg: options.buildArg,
@@ -891,6 +1002,49 @@ program
891
1002
  });
892
1003
  }
893
1004
 
1005
+ if (options.gatewayId != undefined) {
1006
+ if (options.transport != undefined) {
1007
+ rejectBuildAndPushTransportChange(options.transport);
1008
+ }
1009
+ const gwId = options.gatewayId;
1010
+ const session = await client.mcpHost.createRegistryPushSession.mutate({
1011
+ gatewayId: gwId,
1012
+ });
1013
+ const deployImageRef = managedRegistryImageRef(session);
1014
+ const buildResult = buildImage({
1015
+ dockerfile: options.dockerfile,
1016
+ context: options.context,
1017
+ buildArgs: options.buildArg,
1018
+ ...(options.target != undefined ? { target: options.target } : {}),
1019
+ platform: options.platform,
1020
+ imageRef: deployImageRef,
1021
+ });
1022
+ loginToRegistry(
1023
+ session.registryHost,
1024
+ session.username,
1025
+ session.password,
1026
+ );
1027
+ pushImage(buildResult.imageRef);
1028
+ const finalized = await client.mcpHost.finalizeRegistryPush.mutate({
1029
+ gatewayId: gwId,
1030
+ finalizeToken: session.finalizeToken,
1031
+ });
1032
+ const resolvedGatewayId = finalized.gatewayId ?? gwId;
1033
+ return {
1034
+ config: HostedConfigSchema.parse({
1035
+ hostedId: finalized.hostedId,
1036
+ gatewayId: resolvedGatewayId,
1037
+ organizationId: auth.organizationId,
1038
+ mode: "container" as const,
1039
+ image: deployImageRef,
1040
+ transport: localTransportFromHostedTransport(
1041
+ session.currentConfig.transport,
1042
+ ),
1043
+ }),
1044
+ gatewayId: resolvedGatewayId,
1045
+ };
1046
+ }
1047
+
894
1048
  return await buildAndPushManagedImageForCreate({
895
1049
  client,
896
1050
  organizationId: auth.organizationId,
@@ -954,6 +1108,11 @@ program
954
1108
  "--mnt-data-dir <directory>",
955
1109
  `Directory to copy to /mnt on the server. ${makeDefaultMessage("mntDataDir")}`,
956
1110
  )
1111
+ .option(
1112
+ "--gateway-id <id>",
1113
+ "Gateway ID of an existing connector to update. Use when no local hosted config exists.",
1114
+ argParser(GatewayIdSchema),
1115
+ )
957
1116
  .action(async (options) => {
958
1117
  const { env, base } = program.opts();
959
1118
 
@@ -978,19 +1137,24 @@ program
978
1137
  pushImage(buildResult.imageRef);
979
1138
  assertImageSupportsPlatform(buildResult.imageRef, options.platform);
980
1139
 
981
- await deployWithOptions(env, base, {
982
- image: buildResult.imageRef,
983
- ...(options.name != undefined ? { name: options.name } : {}),
984
- ...(options.transport != undefined
985
- ? { transport: options.transport }
986
- : {}),
987
- ...(options.startupCommand != undefined
988
- ? { startupCommand: options.startupCommand }
989
- : {}),
990
- ...(options.mntDataDir != undefined
991
- ? { mntDataDir: options.mntDataDir }
992
- : {}),
993
- });
1140
+ await deployWithOptions(
1141
+ env,
1142
+ base,
1143
+ {
1144
+ image: buildResult.imageRef,
1145
+ ...(options.name != undefined ? { name: options.name } : {}),
1146
+ ...(options.transport != undefined
1147
+ ? { transport: options.transport }
1148
+ : {}),
1149
+ ...(options.startupCommand != undefined
1150
+ ? { startupCommand: options.startupCommand }
1151
+ : {}),
1152
+ ...(options.mntDataDir != undefined
1153
+ ? { mntDataDir: options.mntDataDir }
1154
+ : {}),
1155
+ },
1156
+ options.gatewayId,
1157
+ );
994
1158
  });
995
1159
 
996
1160
  program
@@ -1031,4 +1195,14 @@ program
1031
1195
  }
1032
1196
  });
1033
1197
 
1034
- program.parse();
1198
+ function cliErrorMessage(error: unknown): string {
1199
+ if (error instanceof Error) {
1200
+ return error.message;
1201
+ }
1202
+ return String(error);
1203
+ }
1204
+
1205
+ void program.parseAsync(process.argv).catch((error: unknown) => {
1206
+ console.error(cliErrorMessage(error));
1207
+ process.exitCode = 1;
1208
+ });