riskmodels 2.0.2 → 3.0.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.
- package/README.md +2 -3
- package/dist/commands/config.js +1 -19
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/install.js +38 -11
- package/dist/commands/status.js +0 -1
- package/dist/index.js +0 -0
- package/dist/lib/api-client.js +27 -32
- package/dist/lib/cli-human-diagnostics.d.ts +0 -1
- package/dist/lib/cli-human-diagnostics.js +0 -1
- package/dist/lib/config.d.ts +0 -3
- package/dist/lib/config.js +1 -1
- package/dist/lib/credentials.d.ts +12 -10
- package/dist/lib/credentials.js +2 -23
- package/dist/lib/mcp-config-writer.d.ts +12 -0
- package/dist/lib/mcp-config-writer.js +39 -2
- package/dist/lib/mcp-install-plan.d.ts +30 -0
- package/dist/lib/mcp-install-plan.js +76 -10
- package/package.json +2 -2
- package/dist/lib/oauth.d.ts +0 -2
- package/dist/lib/oauth.js +0 -47
package/README.md
CHANGED
|
@@ -27,9 +27,8 @@ node dist/index.js install --dry-run
|
|
|
27
27
|
## Authentication
|
|
28
28
|
|
|
29
29
|
- **API key (recommended):** `riskmodels config init` (billed mode) or `riskmodels config set apiKey <rm_agent_...>`.
|
|
30
|
-
- **OAuth client credentials:** `riskmodels config set clientId …` and `config set clientSecret …`, or set `RISKMODELS_CLIENT_ID` / `RISKMODELS_CLIENT_SECRET` (optional `RISKMODELS_OAUTH_SCOPE`; default matches the Python SDK).
|
|
31
30
|
- **Environment:** `RISKMODELS_API_KEY` works without a config file for REST commands.
|
|
32
|
-
- **Direct (Supabase) mode** is for `query` + `schema` only. REST analytics need an API key
|
|
31
|
+
- **Direct (Supabase) mode** is for `query` + `schema` only. REST analytics need an API key (config or env).
|
|
33
32
|
|
|
34
33
|
Base URL: stored as `apiBaseUrl` (default `https://riskmodels.app`). The CLI calls paths under `…/api/...` (same as `OPENAPI_SPEC.yaml`).
|
|
35
34
|
|
|
@@ -51,7 +50,7 @@ Config file: `~/.config/riskmodels/config.json`
|
|
|
51
50
|
|
|
52
51
|
| Command | Description |
|
|
53
52
|
|--------|-------------|
|
|
54
|
-
| `riskmodels config init \| set \| list` | API key,
|
|
53
|
+
| `riskmodels config init \| set \| list` | API key, `apiBaseUrl`, or Supabase (direct) |
|
|
55
54
|
| `riskmodels install` | Detect Claude, Cursor, Codex, and VS Code MCP targets, store the API key in shared config, merge MCP configs with backups, and run a connection test. |
|
|
56
55
|
| `riskmodels status` | Show shared config and MCP client detection status. |
|
|
57
56
|
| `riskmodels doctor` | Local install-readiness diagnostics: Node, npx, API credentials, and client detection. |
|
package/dist/commands/config.js
CHANGED
|
@@ -76,15 +76,12 @@ export function configCommand() {
|
|
|
76
76
|
cmd
|
|
77
77
|
.command("set")
|
|
78
78
|
.description("Set a config value")
|
|
79
|
-
.argument("<key>", "apiKey | apiBaseUrl |
|
|
79
|
+
.argument("<key>", "apiKey | apiBaseUrl | supabaseUrl | serviceRoleKey")
|
|
80
80
|
.argument("<value>", "value to store")
|
|
81
81
|
.action(async (key, value) => {
|
|
82
82
|
const allowed = new Set([
|
|
83
83
|
"apiKey",
|
|
84
84
|
"apiBaseUrl",
|
|
85
|
-
"clientId",
|
|
86
|
-
"clientSecret",
|
|
87
|
-
"oauthScope",
|
|
88
85
|
"supabaseUrl",
|
|
89
86
|
"serviceRoleKey",
|
|
90
87
|
]);
|
|
@@ -101,18 +98,6 @@ export function configCommand() {
|
|
|
101
98
|
else if (key === "apiBaseUrl") {
|
|
102
99
|
cfg.apiBaseUrl = value.replace(/\/$/, "");
|
|
103
100
|
}
|
|
104
|
-
else if (key === "clientId") {
|
|
105
|
-
cfg.mode = "billed";
|
|
106
|
-
cfg.clientId = value;
|
|
107
|
-
}
|
|
108
|
-
else if (key === "clientSecret") {
|
|
109
|
-
cfg.mode = "billed";
|
|
110
|
-
cfg.clientSecret = value;
|
|
111
|
-
}
|
|
112
|
-
else if (key === "oauthScope") {
|
|
113
|
-
cfg.mode = "billed";
|
|
114
|
-
cfg.oauthScope = value;
|
|
115
|
-
}
|
|
116
101
|
else if (key === "supabaseUrl") {
|
|
117
102
|
cfg.mode = "direct";
|
|
118
103
|
cfg.supabaseUrl = value.replace(/\/$/, "");
|
|
@@ -140,9 +125,6 @@ export function configCommand() {
|
|
|
140
125
|
mode: cfg.mode,
|
|
141
126
|
apiBaseUrl: cfg.apiBaseUrl ?? DEFAULT_API_BASE,
|
|
142
127
|
apiKey: maskSecret(cfg.apiKey),
|
|
143
|
-
clientId: maskSecret(cfg.clientId),
|
|
144
|
-
clientSecret: maskSecret(cfg.clientSecret),
|
|
145
|
-
oauthScope: cfg.oauthScope ?? "(not set)",
|
|
146
128
|
supabaseUrl: cfg.supabaseUrl ?? "(not set)",
|
|
147
129
|
serviceRoleKey: maskSecret(cfg.serviceRoleKey),
|
|
148
130
|
};
|
package/dist/commands/doctor.js
CHANGED
|
@@ -31,7 +31,7 @@ export function doctorCommand() {
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
id: "api_credentials",
|
|
34
|
-
ok: !!cfg?.apiKey || !!process.env.RISKMODELS_API_KEY
|
|
34
|
+
ok: !!cfg?.apiKey || !!process.env.RISKMODELS_API_KEY,
|
|
35
35
|
detail: cfg?.apiKey
|
|
36
36
|
? `Config API key ${maskSecret(cfg.apiKey)} in ${abbreviatePath(configPath())}`
|
|
37
37
|
: process.env.RISKMODELS_API_KEY
|
package/dist/commands/install.js
CHANGED
|
@@ -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("--
|
|
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
|
-
|
|
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) =>
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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/commands/status.js
CHANGED
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/lib/api-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAuthorizationHeader
|
|
1
|
+
import { getAuthorizationHeader } from "./credentials.js";
|
|
2
2
|
/** Thrown on non-2xx API responses so callers can inspect `status` (e.g. retries on 503). */
|
|
3
3
|
export class ApiHttpError extends Error {
|
|
4
4
|
status;
|
|
@@ -22,38 +22,33 @@ function errorMessageFromBody(body) {
|
|
|
22
22
|
}
|
|
23
23
|
export async function apiFetchJson(auth, method, path, options) {
|
|
24
24
|
const url = buildUrl(auth.apiRoot, path, options?.query);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
let body;
|
|
44
|
-
try {
|
|
45
|
-
body = text ? JSON.parse(text) : null;
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
body = { raw: text };
|
|
49
|
-
}
|
|
50
|
-
if (!res.ok) {
|
|
51
|
-
const msg = errorMessageFromBody(body) || `HTTP ${res.status}`;
|
|
52
|
-
throw new ApiHttpError(res.status, msg);
|
|
53
|
-
}
|
|
54
|
-
return { body, costUsd, status: res.status, headers: res.headers };
|
|
25
|
+
// Single attempt: the retry existed only to refresh an expiring OAuth access
|
|
26
|
+
// token, and auth is now a static API key with nothing to refresh.
|
|
27
|
+
const headers = await getAuthorizationHeader(auth);
|
|
28
|
+
const init = {
|
|
29
|
+
method,
|
|
30
|
+
headers: {
|
|
31
|
+
...headers,
|
|
32
|
+
Accept: "application/json",
|
|
33
|
+
...(options?.jsonBody !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
34
|
+
},
|
|
35
|
+
body: options?.jsonBody !== undefined ? JSON.stringify(options.jsonBody) : undefined,
|
|
36
|
+
};
|
|
37
|
+
const res = await fetch(url, init);
|
|
38
|
+
const costUsd = res.headers.get("x-api-cost-usd") ?? undefined;
|
|
39
|
+
const text = await res.text();
|
|
40
|
+
let body;
|
|
41
|
+
try {
|
|
42
|
+
body = text ? JSON.parse(text) : null;
|
|
55
43
|
}
|
|
56
|
-
|
|
44
|
+
catch {
|
|
45
|
+
body = { raw: text };
|
|
46
|
+
}
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const msg = errorMessageFromBody(body) || `HTTP ${res.status}`;
|
|
49
|
+
throw new ApiHttpError(res.status, msg);
|
|
50
|
+
}
|
|
51
|
+
return { body, costUsd, status: res.status, headers: res.headers };
|
|
57
52
|
}
|
|
58
53
|
export async function apiFetchOptionalAuth(apiRoot, method, path, options) {
|
|
59
54
|
const url = buildUrl(apiRoot, path, options?.query);
|
|
@@ -38,7 +38,6 @@ export function printStatusHuman(payload) {
|
|
|
38
38
|
logLine(` ${BULLET} ${payload.configFound ? chalk.green("file present") : chalk.dim("file missing (run install or config init)")}`);
|
|
39
39
|
logLine(` ${BULLET} ${chalk.bold("apiBaseUrl")} ${chalk.dim(payload.apiBaseUrl)}`);
|
|
40
40
|
logLine(` ${BULLET} ${chalk.bold("API key")} ${payload.apiKey}`);
|
|
41
|
-
logLine(` ${BULLET} ${chalk.bold("OAuth")} ${payload.oauthConfigured ? chalk.green("configured") : chalk.dim("not configured")}`);
|
|
42
41
|
logLine("");
|
|
43
42
|
logLine(chalk.bold("MCP-related clients:"));
|
|
44
43
|
for (const d of payload.mcpClients) {
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -5,9 +5,6 @@ export interface RiskmodelsConfig {
|
|
|
5
5
|
/** Base URL without trailing slash, e.g. https://riskmodels.app */
|
|
6
6
|
apiBaseUrl?: string;
|
|
7
7
|
/** OAuth client credentials (billed mode); scope defaults match the Python SDK. */
|
|
8
|
-
clientId?: string;
|
|
9
|
-
clientSecret?: string;
|
|
10
|
-
oauthScope?: string;
|
|
11
8
|
supabaseUrl?: string;
|
|
12
9
|
serviceRoleKey?: string;
|
|
13
10
|
}
|
package/dist/lib/config.js
CHANGED
|
@@ -42,7 +42,7 @@ export function maskSecret(value, visible = 6) {
|
|
|
42
42
|
export function isBilledReady(cfg) {
|
|
43
43
|
return (!!cfg &&
|
|
44
44
|
cfg.mode === "billed" &&
|
|
45
|
-
|
|
45
|
+
!!cfg.apiKey?.trim());
|
|
46
46
|
}
|
|
47
47
|
export function isDirectReady(cfg) {
|
|
48
48
|
return (!!cfg &&
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import type { RiskmodelsConfig } from "./config.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Auth is a static Bearer API key. There is no token exchange.
|
|
4
|
+
*
|
|
5
|
+
* Removed in 3.0.0: the OAuth client-credentials path. It POSTed a
|
|
6
|
+
* `client_credentials` grant to `{apiRoot}/auth/token`, an endpoint that was
|
|
7
|
+
* documented but never implemented — it returns 404, so any invocation
|
|
8
|
+
* configured that way failed on its first request. The API's only OAuth flow is
|
|
9
|
+
* authorization-code + PKCE for MCP clients, which issues an `rm_user_*` key
|
|
10
|
+
* that is used here as a plain API key.
|
|
11
|
+
*/
|
|
4
12
|
export type ResolvedApiAuth = {
|
|
5
13
|
apiRoot: string;
|
|
6
|
-
/** Static Bearer (
|
|
7
|
-
apiKey
|
|
8
|
-
oauth?: {
|
|
9
|
-
clientId: string;
|
|
10
|
-
clientSecret: string;
|
|
11
|
-
scope: string;
|
|
12
|
-
};
|
|
14
|
+
/** Static Bearer (`rm_agent_*` or `rm_user_*`). */
|
|
15
|
+
apiKey: string;
|
|
13
16
|
};
|
|
14
17
|
export declare function resolveApiAuth(cfg: RiskmodelsConfig | null): ResolvedApiAuth | null;
|
|
15
18
|
/** True when user has API key or OAuth credentials (config and/or env). */
|
|
@@ -21,6 +24,5 @@ export declare function assertRestApiAuth(cfg: RiskmodelsConfig | null, chalkYel
|
|
|
21
24
|
/** Returns auth or sets process.exitCode and prints via assertRestApiAuth. */
|
|
22
25
|
export declare function requireResolvedAuth(cfg: RiskmodelsConfig | null, chalkYellow: (s: string) => string): ResolvedApiAuth | null;
|
|
23
26
|
export declare function getAuthorizationHeader(auth: ResolvedApiAuth): Promise<Record<string, string>>;
|
|
24
|
-
export declare function invalidateAuthForRetry(auth: ResolvedApiAuth): void;
|
|
25
27
|
/** Public origin for docs (no `/api` suffix). */
|
|
26
28
|
export declare function displayApiOrigin(cfg: RiskmodelsConfig | null): string;
|
package/dist/lib/credentials.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { apiRootFromUserBase } from "./api-url.js";
|
|
2
2
|
import { DEFAULT_API_BASE } from "./config.js";
|
|
3
|
-
import { fetchOAuthAccessToken, invalidateOAuthToken } from "./oauth.js";
|
|
4
|
-
/** Matches sdk/riskmodels/client.py DEFAULT_SCOPE. */
|
|
5
|
-
export const DEFAULT_OAUTH_SCOPE = "ticker-returns risk-decomposition batch-analysis factor-correlation macro-factor-series rankings";
|
|
6
3
|
function trimEnv(key) {
|
|
7
4
|
const v = process.env[key];
|
|
8
5
|
return v?.trim() || undefined;
|
|
@@ -13,12 +10,6 @@ export function resolveApiAuth(cfg) {
|
|
|
13
10
|
if (apiKey) {
|
|
14
11
|
return { apiRoot, apiKey };
|
|
15
12
|
}
|
|
16
|
-
const clientId = cfg?.clientId?.trim() || trimEnv("RISKMODELS_CLIENT_ID");
|
|
17
|
-
const clientSecret = cfg?.clientSecret?.trim() || trimEnv("RISKMODELS_CLIENT_SECRET");
|
|
18
|
-
const scope = cfg?.oauthScope?.trim() || trimEnv("RISKMODELS_OAUTH_SCOPE") || DEFAULT_OAUTH_SCOPE;
|
|
19
|
-
if (clientId && clientSecret) {
|
|
20
|
-
return { apiRoot, oauth: { clientId, clientSecret, scope } };
|
|
21
|
-
}
|
|
22
13
|
return null;
|
|
23
14
|
}
|
|
24
15
|
/** True when user has API key or OAuth credentials (config and/or env). */
|
|
@@ -33,7 +24,7 @@ export function assertRestApiAuth(cfg, chalkYellow) {
|
|
|
33
24
|
return;
|
|
34
25
|
if (cfg?.mode === "direct") {
|
|
35
26
|
console.error(chalkYellow("REST API commands need an API key or OAuth client credentials. " +
|
|
36
|
-
"Set RISKMODELS_API_KEY
|
|
27
|
+
"Set RISKMODELS_API_KEY — get a key at https://riskmodels.app/get-key — " +
|
|
37
28
|
"or run `riskmodels config init` in billed mode."));
|
|
38
29
|
process.exitCode = 1;
|
|
39
30
|
return;
|
|
@@ -50,19 +41,7 @@ export function requireResolvedAuth(cfg, chalkYellow) {
|
|
|
50
41
|
return null;
|
|
51
42
|
}
|
|
52
43
|
export async function getAuthorizationHeader(auth) {
|
|
53
|
-
|
|
54
|
-
return { Authorization: `Bearer ${auth.apiKey}` };
|
|
55
|
-
}
|
|
56
|
-
if (auth.oauth) {
|
|
57
|
-
const token = await fetchOAuthAccessToken(auth.apiRoot, auth.oauth.clientId, auth.oauth.clientSecret, auth.oauth.scope);
|
|
58
|
-
return { Authorization: `Bearer ${token}` };
|
|
59
|
-
}
|
|
60
|
-
throw new Error("No API credentials");
|
|
61
|
-
}
|
|
62
|
-
export function invalidateAuthForRetry(auth) {
|
|
63
|
-
if (auth.oauth) {
|
|
64
|
-
invalidateOAuthToken(auth.apiRoot, auth.oauth.clientId, auth.oauth.scope);
|
|
65
|
-
}
|
|
44
|
+
return { Authorization: `Bearer ${auth.apiKey}` };
|
|
66
45
|
}
|
|
67
46
|
/** Public origin for docs (no `/api` suffix). */
|
|
68
47
|
export function displayApiOrigin(cfg) {
|
|
@@ -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 {
|
|
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 =
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "RiskModels CLI — REST API, SQL query, schema, billing, and agent manifests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"README.md"
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
|
-
"build": "tsc",
|
|
14
|
+
"build": "rm -rf dist && tsc",
|
|
15
15
|
"prepublishOnly": "npm run build",
|
|
16
16
|
"install:global": "npm run build && npm link"
|
|
17
17
|
},
|
package/dist/lib/oauth.d.ts
DELETED
package/dist/lib/oauth.js
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
const skewSeconds = 60;
|
|
2
|
-
const cache = new Map();
|
|
3
|
-
function cacheKey(apiRoot, clientId, scope) {
|
|
4
|
-
return `${apiRoot}\0${clientId}\0${scope}`;
|
|
5
|
-
}
|
|
6
|
-
export function invalidateOAuthToken(apiRoot, clientId, scope) {
|
|
7
|
-
cache.delete(cacheKey(apiRoot, clientId, scope));
|
|
8
|
-
}
|
|
9
|
-
export async function fetchOAuthAccessToken(apiRoot, clientId, clientSecret, scope) {
|
|
10
|
-
const key = cacheKey(apiRoot, clientId, scope);
|
|
11
|
-
const now = performance.now();
|
|
12
|
-
const hit = cache.get(key);
|
|
13
|
-
if (hit && now < hit.expiresAtMono - skewSeconds * 1000) {
|
|
14
|
-
return hit.token;
|
|
15
|
-
}
|
|
16
|
-
const url = `${apiRoot.replace(/\/$/, "")}/auth/token`;
|
|
17
|
-
const res = await fetch(url, {
|
|
18
|
-
method: "POST",
|
|
19
|
-
headers: { "Content-Type": "application/json" },
|
|
20
|
-
body: JSON.stringify({
|
|
21
|
-
grant_type: "client_credentials",
|
|
22
|
-
client_id: clientId,
|
|
23
|
-
client_secret: clientSecret,
|
|
24
|
-
scope,
|
|
25
|
-
}),
|
|
26
|
-
});
|
|
27
|
-
const text = await res.text();
|
|
28
|
-
let data;
|
|
29
|
-
try {
|
|
30
|
-
data = JSON.parse(text);
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
throw new Error(`OAuth token: HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
34
|
-
}
|
|
35
|
-
if (!res.ok) {
|
|
36
|
-
throw new Error(data?.error ?? `OAuth token: HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
37
|
-
}
|
|
38
|
-
if (!data.access_token) {
|
|
39
|
-
throw new Error("OAuth token response missing access_token");
|
|
40
|
-
}
|
|
41
|
-
const expiresIn = Math.max(30, Number(data.expires_in ?? 900));
|
|
42
|
-
cache.set(key, {
|
|
43
|
-
token: data.access_token,
|
|
44
|
-
expiresAtMono: now + expiresIn * 1000,
|
|
45
|
-
});
|
|
46
|
-
return data.access_token;
|
|
47
|
-
}
|