Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cli-tunnel-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@mcpjam/sdk": minor
"@mcpjam/cli": minor
---

Add MCPJam tunnels to the platform surface and CLI. The SDK gains `createTunnel`/`closeTunnel` on `PlatformApiClient` plus the `create_tunnel`/`close_tunnel` operations (`@mcpjam/sdk/platform`); the CLI gains `mcpjam tunnel`, which exposes a local MCP server (HTTP URL or stdio command) through a public MCPJam tunnel URL and registers it as a server in your hosted project. The CLI's tunnel command requires this SDK version's platform exports, so the two release together.
4 changes: 3 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
"dependencies": {
"@mcpjam/sdk": "^1.16.0",
"commander": "^12.1.0",
"posthog-node": "^5.24.10"
"posthog-node": "^5.24.10",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/ws": "^8.18.1",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.8.3"
Expand Down
273 changes: 273 additions & 0 deletions cli/src/commands/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import type { Command } from "commander";
import {
closeTunnelOperation,
createTunnelOperation,
type CreateTunnelResult,
Comment on lines +2 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin the SDK version that contains the tunnel exports

This new runtime import depends on createTunnelOperation/closeTunnelOperation, but the CLI package still declares @mcpjam/sdk as ^1.16.0 while the pre-change 1.16.0 SDK did not export these symbols. In a published/non-workspace install, npm can satisfy the range with the already-published SDK and the CLI will fail during module initialization before any command runs. Please bump/pin the SDK dependency to a version that includes these exports, or bundle the dependency.

Useful? React with 👍 / 👎.

} from "@mcpjam/sdk/platform";
import { cliError, usageError, writeResult } from "../lib/output.js";
import { buildPlatformClient, toCliError } from "../lib/platform-client.js";
import { getGlobalOptions, parseServerConfig } from "../lib/server-config.js";
import { startLocalBridge, type TunnelTarget } from "../lib/tunnel/local-bridge.js";
import { RelayConnection } from "../lib/tunnel/relay-client.js";
import { TunnelSession } from "../lib/tunnel/tunnel-session.js";

type TunnelCommandOptions = {
id: string;
project?: string;
apiKey?: string;
apiUrl?: string;
env?: string[];
cwd?: string;
};

export type ParsedTunnelTarget =
| { kind: "http"; url: string }
| { kind: "stdio"; command: string; args: string[] };

/**
* One variadic operand covers both target forms: a single http(s) URL, or a
* stdio command whose argv arrives after the `--` separator (commander
* treats everything past `--` as operands, so no parser-mode changes).
*/
export function parseTunnelTarget(tokens: string[]): ParsedTunnelTarget {
const isUrl = (token: string) => /^https?:\/\//i.test(token);
if (tokens.length === 0) {
throw usageError(
"Specify a target: a local server URL (mcpjam tunnel http://localhost:9090/mcp --id my-server) or a stdio command (mcpjam tunnel --id my-server -- npx -y @modelcontextprotocol/server-everything).",
);
}
if (isUrl(tokens[0])) {
if (tokens.length > 1) {
throw usageError(
"Pass either a URL or a stdio command (after --), not both.",
);
}
try {
new URL(tokens[0]);
} catch {
throw usageError(`Invalid URL: ${tokens[0]}`);
}
return { kind: "http", url: tokens[0] };
}
return { kind: "stdio", command: tokens[0], args: tokens.slice(1) };
}

function overwriteWarning(result: CreateTunnelResult): string | undefined {
const grant = result.grant;
if (!grant.existed) return undefined;
if (grant.previousTransportType === "stdio") {
return `WARNING: server "${grant.name ?? grant.serverId}" already existed as a stdio server — its config was converted to an HTTP server pointing at this tunnel.`;
}
if (grant.previousUrl) {
return `WARNING: server "${grant.name ?? grant.serverId}" already existed — its URL was overwritten (was: ${grant.previousUrl}).`;
}
return undefined;
}

function publicHost(url: string): string {
try {
return new URL(url).host || url;
} catch {
return url;
}
}

export function registerTunnelCommands(program: Command): void {
program
.command("tunnel")
.description(
"Expose a local MCP server through an MCPJam tunnel and register it as a server in your project",
)
.argument(
"[target...]",
"Local http(s) MCP server URL, or a stdio command after `--`",
)
.requiredOption(
"--id <name>",
"Server name to register in the project (an existing server with this name is pointed at the tunnel)",
)
.option(
"--project <id-or-name>",
"Project name or ID (defaults to the most recently updated project)",
)
.option("--api-key <key>", "MCPJam sk_ API key (overrides MCPJAM_API_KEY)")
.option(
"--api-url <url>",
"MCPJam API base URL (defaults to https://app.mcpjam.com/api/v1)",
)
.option(
"-e, --env <env...>",
'Stdio environment assignment in "KEY=VALUE" format. Pass multiple values or repeat the flag.',
)
.option("--cwd <path>", "Working directory for the stdio MCP server process")
.action(
async (target: string[], options: TunnelCommandOptions, command) => {
const globalOptions = getGlobalOptions(command);
const parsedTarget = parseTunnelTarget(target);

if (
parsedTarget.kind === "http" &&
((options.env?.length ?? 0) > 0 || options.cwd)
) {
throw usageError(
"--env and --cwd can only be used with a stdio command target.",
);
}

// Status and progress go to stderr in BOTH formats so `--format
// json` keeps stdout to exactly one machine-readable startup object.
const status = (message: string) => {
process.stderr.write(`${message}\n`);
};

const bridgeTarget: TunnelTarget =
parsedTarget.kind === "http"
? { kind: "http", url: parsedTarget.url }
: {
kind: "stdio",
config: parseServerConfig({
transport: "stdio",
command: parsedTarget.command,
args: parsedTarget.args,
env: options.env,
cwd: options.cwd,
timeout: globalOptions.timeout,
}),
};

let client;
try {
({ client } = buildPlatformClient({
apiKey: options.apiKey,
apiUrl: options.apiUrl,
timeoutMs: globalOptions.timeout,
}));
} catch (error) {
throw toCliError(error);
}

const printStartup = (result: CreateTunnelResult) => {
const warning = overwriteWarning(result);
if (warning) status(warning);
if (globalOptions.format === "human") {
process.stdout.write(
`Tunnel live: ${result.grant.url}\n` +
`Registered server "${result.grant.name ?? options.id}" in project "${result.project.name}" (${result.grant.serverId})\n`,
);
status("Press Ctrl-C to stop the tunnel.");
} else {
writeResult(
{
url: result.grant.url,
serverId: result.grant.serverId,
name: result.grant.name ?? options.id,
slug: result.grant.slug,
project: result.project,
existed: result.grant.existed ?? false,
...(result.grant.previousUrl
? { previousUrl: result.grant.previousUrl }
: {}),
...(result.grant.previousTransportType
? { previousTransportType: result.grant.previousTransportType }
: {}),
...(result.grant.secretVersion !== undefined
? { secretVersion: result.grant.secretVersion }
: {}),
target:
parsedTarget.kind === "http"
? { kind: "http", url: parsedTarget.url }
: {
kind: "stdio",
command: parsedTarget.command,
args: parsedTarget.args,
},
},
globalOptions.format,
);
}
};

const session = new TunnelSession({
createGrant: () =>
createTunnelOperation.execute(
{ project: options.project, name: options.id },
{ client },
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Abort in-flight grant creation on shutdown

When the user hits Ctrl-C while the initial grant creation (or a remint) is still inside createTunnelOperation.execute, session.stop() can settle the command but this callback has no abort signal to cancel the SDK request. The pending fetch/timer keeps Node alive until the platform timeout, and if it eventually succeeds it can still mint a live tunnel grant after the user asked to stop before the later best-effort revoke runs. Please thread an AbortSignal through createGrant the same way closeGrant is wired so startup/remint requests are canceled on shutdown.

Useful? React with 👍 / 👎.

closeGrant: async (result, signal) => {
await closeTunnelOperation.execute(
{
project: result.project.id,
serverId: result.grant.serverId,
},
{ client, signal },
);
},
startBridge: (serverId) =>
startLocalBridge({
serverId,
target: bridgeTarget,
timeoutMs: globalOptions.timeout,
log: status,
}),
connectRelay: ({ grant, localAddr, onPermanentFailure }) =>
new RelayConnection({
serverId: grant.serverId,
slug: grant.slug,
relayWsUrl: grant.relayWsUrl,
connectToken: grant.connectToken,
localAddr,
publicHost: publicHost(grant.url),
logger: { info: status, warn: status },
onPermanentFailure,
}),
log: status,
onGrant: (result, rotated) => {
if (rotated) {
status(`Tunnel secret rotated; new URL: ${result.grant.url}`);
return;
}
printStartup(result);
},
});

if (parsedTarget.kind === "stdio") {
status(
`Starting stdio server: ${parsedTarget.command}${parsedTarget.args.length ? ` ${parsedTarget.args.join(" ")}` : ""}`,
);
}

try {
await session.start();
} catch (error) {
throw toCliError(error);
}

let sigints = 0;
const onSignal = () => {
sigints += 1;
if (sigints === 1) {
status("Shutting down tunnel... (Ctrl-C again to force quit)");
void session.stop();
return;
}
process.exit(130);
};
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
Comment thread
cursor[bot] marked this conversation as resolved.

try {
const result = await session.waitUntilClosed();
if (result.exitCode !== 0) {
throw cliError(
"TUNNEL_CLOSED",
result.reason ?? "Tunnel closed",
result.exitCode,
);
}
} finally {
process.removeListener("SIGINT", onSignal);
process.removeListener("SIGTERM", onSignal);
}
},
);
}
2 changes: 2 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { registerResourcesCommands } from "./commands/resources.js";
import { registerServerCommands } from "./commands/server.js";
import { registerTelemetryCommands } from "./commands/telemetry.js";
import { registerToolsCommands } from "./commands/tools.js";
import { registerTunnelCommands } from "./commands/tunnel.js";
import { registerInspectorCommands } from "./commands/inspector.js";
import {
detectOutputFormatFromArgv,
Expand Down Expand Up @@ -73,6 +74,7 @@ export async function main(
registerProtocolCommands(program);
registerAuthCommands(program);
registerProjectsCommands(program);
registerTunnelCommands(program);
registerInspectorCommands(program);
registerTelemetryCommands(program, dependencies.telemetry);

Expand Down
Loading
Loading