Skip to content

Commit e69da62

Browse files
chelojimenezclaudecursoragent
authored
feat(cli): mcpjam projects commands for hosted projects (#2592)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8feae6f commit e69da62

8 files changed

Lines changed: 845 additions & 4 deletions

File tree

.changeset/cli-platform-login.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
"@mcpjam/sdk": minor
55
---
66

7-
Add `mcpjam login` / `logout` / `whoami`: OAuth Authorization Code + PKCE login to the MCPJam platform via new hosted bridge routes (`/api/cli/auth/config|start|callback`) on the Inspector server. The bridge signs the CLI's loopback redirect into a short-lived HMAC state and never sees tokens; the CLI exchanges the code directly with AuthKit and stores the session at an XDG-aware path with 0600 permissions, refreshing access tokens near expiry. Cloud credentials resolve as `--api-key` > `MCPJAM_API_KEY` > stored login; explicit legacy `mcpjam_` keys error, ambient ones warn and fall through. The SDK now exports its loopback authorization session and PKCE helpers (`createInteractiveAuthorizationSession`, `openUrlInBrowser`, `generateRandomString`, `generateCodeChallenge`). The bridge requires `CLI_AUTH_STATE_SECRET` and `CLI_AUTH_PUBLIC_ORIGIN`; without them it answers 501.
7+
Add `mcpjam login` / `logout` / `whoami`: OAuth Authorization Code + PKCE login to the MCPJam platform via new hosted bridge routes (`/api/cli/auth/config|start|callback`) on the Inspector server. The bridge signs the CLI's loopback redirect into a short-lived HMAC state and never sees tokens; the CLI exchanges the code directly with AuthKit and stores the session at an XDG-aware path with 0600 permissions, refreshing access tokens near expiry. Platform credentials resolve as `--api-key` > `MCPJAM_API_KEY` > stored login; explicit legacy `mcpjam_` keys error, ambient ones warn and fall through. The SDK now exports its loopback authorization session and PKCE helpers (`createInteractiveAuthorizationSession`, `openUrlInBrowser`, `generateRandomString`, `generateCodeChallenge`). The bridge requires `CLI_AUTH_STATE_SECRET` and `CLI_AUTH_PUBLIC_ORIGIN`; without them it answers 501.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mcpjam/cli": minor
3+
---
4+
5+
Add `mcpjam projects` commands for hosted MCPJam projects: `projects list`, `projects servers --project <id-or-name>`, and `projects status --project <id-or-name>` (per-server hosted health checks via the shared `show_servers` operation). JSON output is the operation payload verbatim; human output renders tables and a status summary. Auth via `--api-key` / `MCPJAM_API_KEY` / `mcpjam login`.

cli/src/commands/projects.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import type { Command } from "commander";
2+
import {
3+
listProjectsOperation,
4+
listProjectServersOperation,
5+
PlatformApiError,
6+
showServersOperation,
7+
} from "@mcpjam/sdk/platform";
8+
import {
9+
formatProjectServersHuman,
10+
formatProjectsHuman,
11+
formatShowServersHuman,
12+
} from "../lib/projects-render.js";
13+
import { writeResult } from "../lib/output.js";
14+
import { buildPlatformClient, toCliError } from "../lib/platform-client.js";
15+
import { getGlobalOptions } from "../lib/server-config.js";
16+
17+
type PlatformOptions = {
18+
apiKey?: string;
19+
apiUrl?: string;
20+
project?: string;
21+
};
22+
23+
function addPlatformOptions(command: Command): Command {
24+
return command
25+
.option("--api-key <key>", "MCPJam sk_ API key (overrides MCPJAM_API_KEY)")
26+
.option(
27+
"--api-url <url>",
28+
"MCPJam API base URL (defaults to https://app.mcpjam.com/api/v1)",
29+
);
30+
}
31+
32+
async function runPlatformCommand<TOutput>(
33+
options: PlatformOptions,
34+
timeoutMs: number,
35+
execute: (context: {
36+
client: ReturnType<typeof buildPlatformClient>["client"];
37+
signal: AbortSignal;
38+
}) => Promise<TOutput>,
39+
): Promise<TOutput> {
40+
const controller = new AbortController();
41+
const timeoutHandle = setTimeout(() => {
42+
controller.abort(
43+
new PlatformApiError(
44+
`Request timed out after ${timeoutMs}ms`,
45+
"TIMEOUT",
46+
{
47+
status: 0,
48+
},
49+
),
50+
);
51+
}, timeoutMs);
52+
timeoutHandle.unref?.();
53+
54+
try {
55+
const { client } = buildPlatformClient({ ...options, timeoutMs });
56+
return await execute({ client, signal: controller.signal });
57+
} catch (error) {
58+
// When OUR deadline fired, surface the armed TIMEOUT error: depending
59+
// on the fetch implementation, the rejection may be a bare AbortError
60+
// that would otherwise map to INTERNAL_ERROR.
61+
if (
62+
controller.signal.aborted &&
63+
controller.signal.reason instanceof PlatformApiError
64+
) {
65+
throw toCliError(controller.signal.reason);
66+
}
67+
throw toCliError(error);
68+
} finally {
69+
clearTimeout(timeoutHandle);
70+
}
71+
}
72+
73+
export function registerProjectsCommands(program: Command): void {
74+
const projects = program
75+
.command("projects")
76+
.description("Operate the MCP servers saved in your hosted MCPJam projects");
77+
78+
addPlatformOptions(
79+
projects.command("list").description("List the projects you can access"),
80+
).action(async (options: PlatformOptions, command) => {
81+
const globalOptions = getGlobalOptions(command);
82+
const result = await runPlatformCommand(
83+
options,
84+
globalOptions.timeout,
85+
({ client, signal }) =>
86+
listProjectsOperation.execute({}, { client, signal }),
87+
);
88+
89+
if (globalOptions.format === "human") {
90+
process.stdout.write(`${formatProjectsHuman(result.items)}\n`);
91+
} else {
92+
// Operation payload verbatim — keeps pagination fields like
93+
// nextCursor, matching the sibling commands and the MCP tool.
94+
writeResult(result, globalOptions.format);
95+
}
96+
});
97+
98+
addPlatformOptions(
99+
projects
100+
.command("servers")
101+
.description("List the servers saved in a project")
102+
.option(
103+
"--project <id-or-name>",
104+
"Project name or ID (defaults to the most recently updated project)",
105+
),
106+
).action(async (options: PlatformOptions, command) => {
107+
const globalOptions = getGlobalOptions(command);
108+
const result = await runPlatformCommand(
109+
options,
110+
globalOptions.timeout,
111+
({ client, signal }) =>
112+
listProjectServersOperation.execute(
113+
{ project: options.project },
114+
{ client, signal },
115+
),
116+
);
117+
118+
if (globalOptions.format === "human") {
119+
process.stdout.write(`${formatProjectServersHuman(result)}\n`);
120+
} else {
121+
writeResult(result, globalOptions.format);
122+
}
123+
});
124+
125+
addPlatformOptions(
126+
projects
127+
.command("status")
128+
.description(
129+
"Health-check every server in a project (hosted doctor per server)",
130+
)
131+
.option(
132+
"--project <id-or-name>",
133+
"Project name or ID (defaults to the most recently updated project)",
134+
),
135+
).action(async (options: PlatformOptions, command) => {
136+
const globalOptions = getGlobalOptions(command);
137+
const payload = await runPlatformCommand(
138+
options,
139+
globalOptions.timeout,
140+
({ client, signal }) =>
141+
showServersOperation.execute(
142+
{ project: options.project },
143+
{ client, signal },
144+
),
145+
);
146+
147+
if (globalOptions.format === "human") {
148+
process.stdout.write(`${formatShowServersHuman(payload)}\n`);
149+
} else {
150+
writeResult(payload, globalOptions.format);
151+
}
152+
// Exit 0 even with unreachable servers: this is a status report, not an
153+
// assertion. CI gating can parse the summary from the JSON payload.
154+
});
155+
}

cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
44
import packageJson from "../package.json" with { type: "json" };
55
import { registerAppsCommands } from "./commands/apps.js";
66
import { registerAuthCommands } from "./commands/auth.js";
7+
import { registerProjectsCommands } from "./commands/projects.js";
78
import { registerProtocolCommands } from "./commands/conformance.js";
89
import { registerOAuthCommands } from "./commands/oauth.js";
910
import { registerPromptCommands } from "./commands/prompts.js";
@@ -71,6 +72,7 @@ export async function main(
7172
registerOAuthCommands(program);
7273
registerProtocolCommands(program);
7374
registerAuthCommands(program);
75+
registerProjectsCommands(program);
7476
registerInspectorCommands(program);
7577
registerTelemetryCommands(program, dependencies.telemetry);
7678

cli/src/lib/platform-auth.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* MCPJam platform credentials for the CLI.
33
*
4-
* Resolution precedence for cloud commands:
4+
* Resolution precedence for platform commands:
55
* 1. `--api-key` flag (explicit `mcpjam_...` legacy keys hard-error)
66
* 2. `MCPJAM_API_KEY` env (`mcpjam_...` warns and falls through — that
77
* env var is shared with SDK eval reporting,
@@ -34,7 +34,7 @@ const TOKEN_REFRESH_SKEW_MS = 60_000;
3434
const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60_000;
3535

3636
const LEGACY_KEY_REMEDY =
37-
"Legacy mcpjam_ API keys are not supported by cloud commands. Create an sk_ key at https://app.mcpjam.com/settings/api-keys or run `mcpjam login`.";
37+
"Legacy mcpjam_ API keys are not supported by platform commands. Create an sk_ key at https://app.mcpjam.com/settings/api-keys or run `mcpjam login`.";
3838

3939
export interface PlatformCredential {
4040
kind: "api-key" | "oauth";
@@ -71,7 +71,7 @@ export function resolvePlatformCredential(
7171
return { kind: "api-key", getAuth: async () => envKey };
7272
}
7373
// A legacy key in the shared env var is a valid eval-reporting setup, so
74-
// it must not break cloud commands for a logged-in user: warn, ignore it.
74+
// it must not break platform commands for a logged-in user: warn, ignore it.
7575
warn(
7676
`Ignoring legacy mcpjam_ key in MCPJAM_API_KEY for this command. ${LEGACY_KEY_REMEDY}`,
7777
);

cli/src/lib/platform-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
export interface PlatformClientOptions {
1515
apiKey?: string;
1616
apiUrl?: string;
17+
timeoutMs?: number;
1718
}
1819

1920
/**
@@ -89,6 +90,9 @@ export function buildPlatformClient(
8990
baseUrl: baseUrl ?? DEFAULT_PLATFORM_API_BASE_URL,
9091
getAuth: credential.getAuth,
9192
...(deps.fetchFn ? { fetch: deps.fetchFn } : {}),
93+
...(options.timeoutMs !== undefined
94+
? { timeoutMs: options.timeoutMs }
95+
: {}),
9296
userAgent: `mcpjam-cli/${packageJson.version}`,
9397
});
9498
return { client, credentialKind: credential.kind };

cli/src/lib/projects-render.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type {
2+
ListProjectServersResult,
3+
PlatformProject,
4+
ShowServersPayload,
5+
} from "@mcpjam/sdk/platform";
6+
7+
function formatTimestamp(value: number | null | undefined): string {
8+
return typeof value === "number" ? new Date(value).toISOString() : "-";
9+
}
10+
11+
function table(rows: string[][]): string[] {
12+
if (rows.length === 0) {
13+
return [];
14+
}
15+
const widths = rows[0].map((_cell, column) =>
16+
Math.max(...rows.map((row) => row[column]?.length ?? 0)),
17+
);
18+
return rows.map((row) =>
19+
row
20+
.map((cell, column) =>
21+
column === row.length - 1 ? cell : cell.padEnd(widths[column]),
22+
)
23+
.join(" ")
24+
.trimEnd(),
25+
);
26+
}
27+
28+
export function formatProjectsHuman(projects: PlatformProject[]): string {
29+
if (projects.length === 0) {
30+
return "No accessible projects.";
31+
}
32+
33+
const lines = table([
34+
["ID", "NAME", "UPDATED"],
35+
...projects.map((project) => [
36+
project.id,
37+
project.name,
38+
formatTimestamp(project.updatedAt),
39+
]),
40+
]);
41+
lines.push("", `${projects.length} project(s).`);
42+
return lines.join("\n");
43+
}
44+
45+
export function formatProjectServersHuman(
46+
result: ListProjectServersResult,
47+
): string {
48+
const lines = [`Project: ${result.project.name} (${result.project.id})`, ""];
49+
50+
if (result.items.length === 0) {
51+
lines.push("No servers in this project.");
52+
} else {
53+
lines.push(
54+
...table([
55+
["ID", "NAME", "TRANSPORT", "URL", "ENABLED"],
56+
...result.items.map((server) => [
57+
server.id,
58+
server.name,
59+
server.transportType,
60+
server.url ?? "-",
61+
server.enabled ? "yes" : "no",
62+
]),
63+
]),
64+
);
65+
}
66+
67+
appendOtherProjects(lines, result.otherProjects);
68+
return lines.join("\n");
69+
}
70+
71+
export function formatShowServersHuman(payload: ShowServersPayload): string {
72+
const lines = [
73+
`Project: ${payload.project.name} (${payload.project.id})`,
74+
"",
75+
];
76+
77+
if (payload.servers.length === 0) {
78+
lines.push("No servers in this project.");
79+
} else {
80+
for (const server of payload.servers) {
81+
const headline = `${statusGlyph(server.status)} ${server.name} [${server.status}]`;
82+
lines.push(server.url ? `${headline} ${server.url}` : headline);
83+
if (server.statusDetail) {
84+
lines.push(` ${server.statusDetail}`);
85+
}
86+
if (server.serverInfo?.name || server.serverInfo?.version) {
87+
lines.push(
88+
` Server: ${server.serverInfo.name ?? "unknown"}${server.serverInfo.version ? ` v${server.serverInfo.version}` : ""}`,
89+
);
90+
}
91+
if (server.primitives) {
92+
lines.push(
93+
` Primitives: tools ${server.primitives.tools.items.length}, resources ${server.primitives.resources.items.length}, prompts ${server.primitives.prompts.items.length}`,
94+
);
95+
}
96+
}
97+
}
98+
99+
const summary = payload.summary;
100+
lines.push(
101+
"",
102+
`Summary: ${summary.reachable} reachable, ${summary.unreachable} unreachable, ${summary.skipped} skipped, ${summary.error} error(s).`,
103+
);
104+
appendOtherProjects(lines, payload.otherProjects);
105+
lines.push(`Generated at ${payload.generatedAt}.`);
106+
return lines.join("\n");
107+
}
108+
109+
function appendOtherProjects(
110+
lines: string[],
111+
otherProjects: Array<{ id: string; name: string }>,
112+
): void {
113+
if (otherProjects.length > 0) {
114+
lines.push(
115+
"",
116+
`Other projects: ${otherProjects
117+
.map((project) => project.name)
118+
.join(", ")} (switch with --project)`,
119+
);
120+
}
121+
}
122+
123+
function statusGlyph(
124+
status: "reachable" | "unreachable" | "skipped" | "error",
125+
): string {
126+
switch (status) {
127+
case "reachable":
128+
return "✓";
129+
case "unreachable":
130+
return "✗";
131+
case "skipped":
132+
return "-";
133+
case "error":
134+
return "!";
135+
}
136+
}

0 commit comments

Comments
 (0)