riskmodels 2.0.2 → 2.1.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.
@@ -3,11 +3,11 @@ import inquirer from "inquirer";
3
3
  import chalk from "chalk";
4
4
  import { configPath, DEFAULT_API_BASE, loadConfig, sharedRiskmodelsConfigExists, } from "../lib/config.js";
5
5
  import { detectClients, selectedClients } from "../lib/mcp-config-paths.js";
6
- import { buildInstallPlans, firstPrompt } from "../lib/mcp-install-plan.js";
6
+ import { buildInstallPlans, firstPrompt, looksLikeRiskmodelsKey, } from "../lib/mcp-install-plan.js";
7
7
  import { printResults } from "../lib/display.js";
8
8
  import { API_KEY_EMAIL_HINT, printInstallDryRunHuman, printInstallMissingKeyHuman, printInstallSuccessHuman, } from "../lib/mcp-cli-human-output.js";
9
9
  import { redactSecret } from "../lib/redact.js";
10
- import { installMcpConfig, writeSharedApiKey } from "../lib/mcp-config-writer.js";
10
+ import { installClaudeCodeRemote, installMcpConfig, writeSharedApiKey, } from "../lib/mcp-config-writer.js";
11
11
  import { apiRootFromUserBase } from "../lib/api-url.js";
12
12
  import { apiFetchOptionalAuth } from "../lib/api-client.js";
13
13
  import { warnIfGlobalJsonBeforeSubcommand } from "../lib/global-json-hint.js";
@@ -64,7 +64,9 @@ export function installCommand() {
64
64
  .option("--api-key <key>", "RiskModels API key for one-shot setup")
65
65
  .option("--dry-run", "Show planned config changes without writing")
66
66
  .option("--yes", "Non-interactive mode")
67
- .option("--embed-key", "Explicitly embed the API key in MCP config env (not recommended)")
67
+ .option("--remote", "Use the hosted endpoint via mcp-remote (default)")
68
+ .option("--local", "Use the local stdio server (npx @riskmodels/mcp) instead of the hosted endpoint")
69
+ .option("--embed-key", "Local stdio only: embed the API key in MCP config env (not recommended)")
68
70
  .option("--json", "Structured JSON instead of formatted text (matches historical output shape)")
69
71
  .action(async (opts, cmd) => {
70
72
  const json = opts.json || cmd.optsWithGlobals().json || false;
@@ -78,19 +80,36 @@ export function installCommand() {
78
80
  return;
79
81
  }
80
82
  const dryRun = opts.dryRun ?? false;
83
+ const transport = opts.local ? "local" : "remote";
81
84
  const { apiKey, source } = await resolveApiKey(opts);
85
+ if (apiKey && !looksLikeRiskmodelsKey(apiKey)) {
86
+ const msg = 'That does not look like a RiskModels API key (expected an "rm_…" value). ' +
87
+ "Get one at https://riskmodels.app/get-key";
88
+ if (json) {
89
+ warnIfGlobalJsonBeforeSubcommand("install");
90
+ printResults({ error: msg, apiKey: { source } }, json);
91
+ }
92
+ else {
93
+ console.error(chalk.red(msg));
94
+ }
95
+ process.exitCode = 1;
96
+ return;
97
+ }
82
98
  const detections = await detectClients(clients);
83
- const plans = buildInstallPlans(detections, { apiKey, embedKey: opts.embedKey });
99
+ const plans = buildInstallPlans(detections, { apiKey, embedKey: opts.embedKey, transport });
84
100
  const existingConfig = await loadConfig();
85
101
  const output = {
86
102
  dryRun,
103
+ transport,
87
104
  apiKey: {
88
105
  found: !!apiKey,
89
106
  source,
90
107
  value: redactSecret(apiKey),
91
108
  storage: configPath(),
92
109
  willStoreInSharedConfig: !!apiKey && source !== configPath() && !dryRun,
93
- embeddedInMcpConfig: !!opts.embedKey,
110
+ // Remote always carries the key in the Authorization header; local only
111
+ // embeds it when --embed-key is passed.
112
+ embeddedInMcpConfig: transport === "remote" ? !!apiKey : !!opts.embedKey,
94
113
  },
95
114
  clients: plans,
96
115
  firstPrompt: firstPrompt(),
@@ -128,11 +147,19 @@ export function installCommand() {
128
147
  }
129
148
  const sharedBefore = await sharedRiskmodelsConfigExists();
130
149
  const sharedConfigWrite = await writeSharedApiKey(apiKey, existingConfig?.apiBaseUrl ?? DEFAULT_API_BASE);
131
- const writes = await Promise.all(detections.map((detection) => installMcpConfig(detection, {
132
- apiKey,
133
- embedKey: opts.embedKey,
134
- apiBaseUrl: existingConfig?.apiBaseUrl,
135
- })));
150
+ const writes = await Promise.all(detections.map((detection) => {
151
+ // Claude Code reads its own config, not claude_desktop_config.json —
152
+ // register via its CLI over HTTP transport instead of writing Desktop JSON.
153
+ if (transport === "remote" && detection.client === "claude" && detection.commandAvailable) {
154
+ return Promise.resolve(installClaudeCodeRemote(detection, { apiKey }));
155
+ }
156
+ return installMcpConfig(detection, {
157
+ apiKey,
158
+ embedKey: opts.embedKey,
159
+ apiBaseUrl: existingConfig?.apiBaseUrl,
160
+ transport,
161
+ });
162
+ }));
136
163
  const test = await connectionTest(existingConfig?.apiBaseUrl);
137
164
  const result = {
138
165
  ...output,
@@ -152,7 +179,7 @@ export function installCommand() {
152
179
  connectionTest: test,
153
180
  firstPrompt: firstPrompt(),
154
181
  hadErrors: writes.some((w) => w.action === "error") || !test.ok,
155
- showClaudeCodeMcpTip: detections.some((d) => d.client === "claude" && d.commandAvailable),
182
+ showClaudeCodeMcpTip: transport === "local" && detections.some((d) => d.client === "claude" && d.commandAvailable),
156
183
  });
157
184
  }
158
185
  if (writes.some((write) => write.action === "error") || !test.ok) {
package/dist/index.js CHANGED
File without changes
@@ -1,4 +1,5 @@
1
1
  import type { ClientDetection } from "./mcp-config-paths.js";
2
+ import { type McpTransport } from "./mcp-install-plan.js";
2
3
  export interface ConfigWriteResult {
3
4
  client: string;
4
5
  label: string;
@@ -11,6 +12,7 @@ export interface SafeWriteOptions {
11
12
  apiKey?: string;
12
13
  embedKey?: boolean;
13
14
  apiBaseUrl?: string;
15
+ transport?: McpTransport;
14
16
  now?: Date;
15
17
  }
16
18
  export interface SharedConfigWriteResult {
@@ -23,6 +25,16 @@ export declare function mergeCodexTomlConfig(existingText: string, mcpServer: un
23
25
  export declare function validateRiskmodelsToml(text: string): void;
24
26
  export declare function writeSharedApiKey(apiKey: string, apiBaseUrl?: string, now?: Date): Promise<SharedConfigWriteResult>;
25
27
  export declare function installMcpConfig(detection: ClientDetection, opts?: SafeWriteOptions): Promise<ConfigWriteResult>;
28
+ /**
29
+ * Register the hosted endpoint with Claude Code via its own CLI
30
+ * (`claude mcp add --transport http …`) instead of writing Desktop JSON —
31
+ * Claude Code reads its own config, not claude_desktop_config.json. Idempotent:
32
+ * removes any existing `riskmodels` entry first so re-running doesn't error on
33
+ * "already exists". Used for the remote transport when the `claude` CLI is present.
34
+ */
35
+ export declare function installClaudeCodeRemote(detection: ClientDetection, opts?: {
36
+ apiKey?: string;
37
+ }): ConfigWriteResult;
26
38
  export declare function removeJsonMcpConfig(existingText: string): {
27
39
  text: string;
28
40
  removed: boolean;
@@ -1,7 +1,8 @@
1
1
  import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
2
+ import { spawnSync } from "node:child_process";
2
3
  import path from "node:path";
3
4
  import { configPath, DEFAULT_API_BASE, loadConfig } from "./config.js";
4
- import { defaultMcpServerConfig } from "./mcp-install-plan.js";
5
+ import { claudeCodeRemoteAddArgs, mcpServerConfigFor, } from "./mcp-install-plan.js";
5
6
  const RISKMODELS_SERVER_NAME = "riskmodels";
6
7
  const TOML_MANAGED_HEADER = "# RiskModels MCP (managed by riskmodels CLI)";
7
8
  function timestamp(date = new Date()) {
@@ -165,7 +166,7 @@ export async function installMcpConfig(detection, opts = {}) {
165
166
  }
166
167
  try {
167
168
  const { exists, text } = await readIfExists(detection.configPath);
168
- const mcpServer = defaultMcpServerConfig(opts.apiKey, !!opts.embedKey);
169
+ const mcpServer = mcpServerConfigFor(opts.transport ?? "remote", opts.apiKey, !!opts.embedKey);
169
170
  const nextText = detection.client === "codex"
170
171
  ? mergeCodexTomlConfig(text, mcpServer)
171
172
  : mergeJsonMcpConfig(text, mcpServer);
@@ -189,6 +190,42 @@ export async function installMcpConfig(detection, opts = {}) {
189
190
  };
190
191
  }
191
192
  }
193
+ /**
194
+ * Register the hosted endpoint with Claude Code via its own CLI
195
+ * (`claude mcp add --transport http …`) instead of writing Desktop JSON —
196
+ * Claude Code reads its own config, not claude_desktop_config.json. Idempotent:
197
+ * removes any existing `riskmodels` entry first so re-running doesn't error on
198
+ * "already exists". Used for the remote transport when the `claude` CLI is present.
199
+ */
200
+ export function installClaudeCodeRemote(detection, opts = {}) {
201
+ const base = {
202
+ client: detection.client,
203
+ label: detection.label,
204
+ configPath: detection.configPath,
205
+ };
206
+ // Best-effort removal of a prior entry — ignore failure (none registered yet).
207
+ spawnSync("claude", ["mcp", "remove", "--scope", "user", "riskmodels"], { stdio: "ignore" });
208
+ const res = spawnSync("claude", claudeCodeRemoteAddArgs(opts.apiKey), {
209
+ stdio: "pipe",
210
+ encoding: "utf8",
211
+ });
212
+ if (res.error) {
213
+ return { ...base, action: "error", message: `Failed to run \`claude\`: ${res.error.message}` };
214
+ }
215
+ if (res.status === 0) {
216
+ return {
217
+ ...base,
218
+ action: "written",
219
+ message: "Registered with Claude Code via `claude mcp add --transport http` (user scope).",
220
+ };
221
+ }
222
+ const detail = (res.stderr || res.stdout || "").trim();
223
+ return {
224
+ ...base,
225
+ action: "error",
226
+ message: detail || `\`claude mcp add\` exited with status ${res.status ?? "unknown"}.`,
227
+ };
228
+ }
192
229
  export function removeJsonMcpConfig(existingText) {
193
230
  const parsed = existingText.trim() ? JSON.parse(existingText) : {};
194
231
  assertPlainObject(parsed, "MCP config");
@@ -8,9 +8,39 @@ export interface InstallPlan {
8
8
  notes: string[];
9
9
  mcpServer: unknown;
10
10
  }
11
+ /** Hosted MCP endpoint (Streamable HTTP). Key-first auth, no npm publish needed. */
12
+ export declare const REMOTE_MCP_URL = "https://riskmodels.app/api/mcp/sse";
13
+ export type McpTransport = "remote" | "local";
14
+ /**
15
+ * Local stdio path: runs the published `@riskmodels/mcp` server via npx. The key
16
+ * is read from the shared config by default; `--embed-key` writes it into env.
17
+ */
11
18
  export declare function defaultMcpServerConfig(apiKey?: string, embedKey?: boolean): unknown;
19
+ /**
20
+ * Remote path (default): bridges a stdio client to the hosted endpoint via
21
+ * `mcp-remote`. The key rides in an `Authorization: Bearer` header — kept out of
22
+ * the URL so it never lands in server access logs or referrers. Works today with
23
+ * no npm publish dependency.
24
+ */
25
+ export declare function remoteMcpServerConfig(apiKey?: string): unknown;
26
+ /**
27
+ * Native `claude mcp add` args for Claude Code — registers the hosted endpoint
28
+ * over HTTP transport at user scope (no mcp-remote shim needed; Claude Code
29
+ * speaks Streamable HTTP directly).
30
+ */
31
+ export declare function claudeCodeRemoteAddArgs(apiKey?: string): string[];
32
+ export declare function mcpServerConfigFor(transport: McpTransport, apiKey?: string, embedKey?: boolean): unknown;
33
+ /** RiskModels keys are `rm_agent_live_…` / `rm_…`. Cheap client-side sanity check. */
34
+ export declare function looksLikeRiskmodelsKey(key: string): boolean;
35
+ /**
36
+ * Mask `Authorization: Bearer <token>` strings anywhere in a config tree.
37
+ * `redactJson` only masks values whose *key name* matches (e.g. `env.RISKMODELS_API_KEY`);
38
+ * the remote header lives inside an `args` string, so it needs this pass too.
39
+ */
40
+ export declare function redactBearerStrings(value: unknown): unknown;
12
41
  export declare function buildInstallPlans(detections: ClientDetection[], opts: {
13
42
  apiKey?: string;
14
43
  embedKey?: boolean;
44
+ transport: McpTransport;
15
45
  }): InstallPlan[];
16
46
  export declare function firstPrompt(): string;
@@ -1,4 +1,10 @@
1
- import { redactJson } from "./redact.js";
1
+ import { redactJson, redactSecret } from "./redact.js";
2
+ /** Hosted MCP endpoint (Streamable HTTP). Key-first auth, no npm publish needed. */
3
+ export const REMOTE_MCP_URL = "https://riskmodels.app/api/mcp/sse";
4
+ /**
5
+ * Local stdio path: runs the published `@riskmodels/mcp` server via npx. The key
6
+ * is read from the shared config by default; `--embed-key` writes it into env.
7
+ */
2
8
  export function defaultMcpServerConfig(apiKey, embedKey = false) {
3
9
  return {
4
10
  command: "npx",
@@ -12,16 +18,76 @@ export function defaultMcpServerConfig(apiKey, embedKey = false) {
12
18
  : {}),
13
19
  };
14
20
  }
21
+ /**
22
+ * Remote path (default): bridges a stdio client to the hosted endpoint via
23
+ * `mcp-remote`. The key rides in an `Authorization: Bearer` header — kept out of
24
+ * the URL so it never lands in server access logs or referrers. Works today with
25
+ * no npm publish dependency.
26
+ */
27
+ export function remoteMcpServerConfig(apiKey) {
28
+ const args = ["-y", "mcp-remote", REMOTE_MCP_URL];
29
+ if (apiKey) {
30
+ args.push("--header", `Authorization: Bearer ${apiKey}`);
31
+ }
32
+ return { command: "npx", args };
33
+ }
34
+ /**
35
+ * Native `claude mcp add` args for Claude Code — registers the hosted endpoint
36
+ * over HTTP transport at user scope (no mcp-remote shim needed; Claude Code
37
+ * speaks Streamable HTTP directly).
38
+ */
39
+ export function claudeCodeRemoteAddArgs(apiKey) {
40
+ const args = ["mcp", "add", "--scope", "user", "--transport", "http", "riskmodels", REMOTE_MCP_URL];
41
+ if (apiKey) {
42
+ args.push("--header", `Authorization: Bearer ${apiKey}`);
43
+ }
44
+ return args;
45
+ }
46
+ export function mcpServerConfigFor(transport, apiKey, embedKey = false) {
47
+ return transport === "remote"
48
+ ? remoteMcpServerConfig(apiKey)
49
+ : defaultMcpServerConfig(apiKey, embedKey);
50
+ }
51
+ /** RiskModels keys are `rm_agent_live_…` / `rm_…`. Cheap client-side sanity check. */
52
+ export function looksLikeRiskmodelsKey(key) {
53
+ return /^rm_[A-Za-z0-9_]+$/.test(key.trim());
54
+ }
55
+ /**
56
+ * Mask `Authorization: Bearer <token>` strings anywhere in a config tree.
57
+ * `redactJson` only masks values whose *key name* matches (e.g. `env.RISKMODELS_API_KEY`);
58
+ * the remote header lives inside an `args` string, so it needs this pass too.
59
+ */
60
+ export function redactBearerStrings(value) {
61
+ if (Array.isArray(value))
62
+ return value.map(redactBearerStrings);
63
+ if (typeof value === "string") {
64
+ const m = value.match(/^(Authorization:\s*Bearer\s+)(.+)$/i);
65
+ return m ? `${m[1]}${redactSecret(m[2])}` : value;
66
+ }
67
+ if (value && typeof value === "object") {
68
+ const out = {};
69
+ for (const [k, v] of Object.entries(value))
70
+ out[k] = redactBearerStrings(v);
71
+ return out;
72
+ }
73
+ return value;
74
+ }
15
75
  export function buildInstallPlans(detections, opts) {
16
- return detections.map((detection) => ({
17
- client: detection.client,
18
- label: detection.label,
19
- status: detection.status,
20
- mode: detection.mode,
21
- configPath: detection.configPath,
22
- notes: detection.notes,
23
- mcpServer: redactJson(defaultMcpServerConfig(opts.apiKey, opts.embedKey)),
24
- }));
76
+ return detections.map((detection) => {
77
+ const claudeCodeNative = opts.transport === "remote" && detection.client === "claude" && detection.commandAvailable;
78
+ const mcpServer = redactBearerStrings(redactJson(mcpServerConfigFor(opts.transport, opts.apiKey, opts.embedKey)));
79
+ return {
80
+ client: detection.client,
81
+ label: detection.label,
82
+ status: detection.status,
83
+ mode: claudeCodeNative ? "command" : detection.mode,
84
+ configPath: detection.configPath,
85
+ notes: claudeCodeNative
86
+ ? [...detection.notes, "Remote: will register via `claude mcp add --transport http` (user scope)."]
87
+ : detection.notes,
88
+ mcpServer,
89
+ };
90
+ });
25
91
  }
26
92
  export function firstPrompt() {
27
93
  return "Compare AAPL and NVDA using RiskModels. What am I really betting on?";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "riskmodels",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "RiskModels CLI — REST API, SQL query, schema, billing, and agent manifests",
5
5
  "type": "module",
6
6
  "bin": {