levelbox-mcp 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Koh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # levelbox-mcp
2
+
3
+ Open-source MCP bridge for **levelbox.ai** — connect your AI assistant (Claude Desktop, Claude Code, etc.) to the remote levelbox wheel screener over Model Context Protocol.
4
+
5
+ This is a **client/bridge** that lets AI agents access the levelbox screener tools, not a server.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g levelbox-mcp
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### 1. Authenticate
16
+
17
+ ```bash
18
+ levelbox-mcp login
19
+ ```
20
+
21
+ This opens a browser to sign in with your **levelbox.ai** account (Google OAuth or email/password). Credentials are stored locally and auto-refresh.
22
+
23
+ **Headless fallback** (e.g., on a remote machine without a browser):
24
+
25
+ ```bash
26
+ levelbox-mcp login --token <access_token> --refresh <refresh_token>
27
+ ```
28
+
29
+ ### 2. Configure Your AI Assistant
30
+
31
+ Add this block to your MCP configuration:
32
+
33
+ **Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "levelbox": {
39
+ "command": "npx",
40
+ "args": ["-y", "levelbox-mcp", "connect"]
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ **Claude Code** (`.mcp.json` in your project, or globally via `claude mcp add`):
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "levelbox": {
52
+ "command": "npx",
53
+ "args": ["-y", "levelbox-mcp", "connect"]
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Restart your assistant to load the levelbox tools.
60
+
61
+ ### 3. Use the Tools
62
+
63
+ Your assistant now has access to:
64
+
65
+ - **list_themes** — Browse available screener themes (filter types, criteria)
66
+ - **list_candidates** — View screened candidates for a theme
67
+ - **top_candidates** — Get the top N candidates by rank
68
+ - **get_candidate** — Fetch details for a specific symbol
69
+ - **pick_symbol** — Add a symbol to your watchlist
70
+ - **unpick_symbol** — Remove a symbol from your watchlist
71
+ - **list_picks** — View your current picks/watchlist
72
+
73
+ **Analytical use only.** These tools provide market data, valuations, and signals to inform your analysis — not investment directives. Use them to research, backtest, and understand opportunities, then make your own decisions.
74
+
75
+ ## Commands
76
+
77
+ - **`levelbox-mcp connect`** (default) — Start the MCP bridge (stdio). This is what the config block runs.
78
+ - **`levelbox-mcp login`** — Authenticate with levelbox.ai (opens browser or accepts `--token`/`--refresh`).
79
+ - **`levelbox-mcp logout`** — Clear stored credentials.
80
+ - **`levelbox-mcp status`** — Show login status and configured base URL.
81
+
82
+ ## Configuration
83
+
84
+ Set via environment variables or command-line flags. Flags override env vars.
85
+
86
+ | Env Var | Flag | Default |
87
+ | ---------------------------- | --------------------- | ------------------------------ |
88
+ | `LEVELBOX_MCP_URL` | `--base-url` | `https://api.levelbox.ai/mcp` |
89
+ | `LEVELBOX_SUPABASE_URL` | `--supabase-url` | (from env, required for login) |
90
+ | `LEVELBOX_SUPABASE_ANON_KEY` | `--supabase-anon-key` | (from env, required for login) |
91
+
92
+ Example:
93
+
94
+ ```bash
95
+ LEVELBOX_MCP_URL=http://localhost:8000/mcp levelbox-mcp connect
96
+ # or
97
+ levelbox-mcp --base-url http://localhost:8000/mcp connect
98
+ ```
99
+
100
+ ## Setup Note: Browser Login & OAuth
101
+
102
+ For **Google OAuth** and other OAuth redirects to work, the **levelbox.ai Supabase project's Auth settings must allow localhost redirects**:
103
+
104
+ 1. Go to your Supabase project → **Authentication** → **URL Configuration**
105
+ 2. Add `http://localhost:*` and/or `http://127.0.0.1:*` to **Redirect URLs**
106
+
107
+ **Email/password login works without this.**
108
+
109
+ If you see an OAuth redirect error, check that these URLs are in your allowlist.
110
+
111
+ ## Troubleshooting
112
+
113
+ ### 401 "Not Logged In"
114
+
115
+ The token has expired or refresh failed:
116
+
117
+ ```bash
118
+ levelbox-mcp login
119
+ ```
120
+
121
+ Re-authenticate to refresh your local credentials.
122
+
123
+ ### Connection refused
124
+
125
+ Check that the base URL is correct:
126
+
127
+ ```bash
128
+ levelbox-mcp status
129
+ # Shows current base-url; update via LEVELBOX_MCP_URL or --base-url
130
+ ```
131
+
132
+ ### MCP tool not showing in your assistant
133
+
134
+ Ensure the config block is in the right file:
135
+
136
+ - **Claude Desktop:** `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows)
137
+ - **Claude Code:** `.mcp.json` in your project root or globally (~/.claude/.mcp.json)
138
+
139
+ Restart your assistant after updating the config.
140
+
141
+ ## License
142
+
143
+ MIT — see LICENSE.
144
+
145
+ ## Repository
146
+
147
+ https://github.com/danielkoh/levelbox-mcp
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/cli.js");
@@ -0,0 +1,61 @@
1
+ import {
2
+ getValidAccessToken
3
+ } from "./chunk-K4KHIFRJ.js";
4
+
5
+ // src/connect.ts
6
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
8
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
+ async function buildRemoteClient(cfg, token) {
12
+ const client = new Client({ name: "levelbox-mcp", version: "0.1.0" });
13
+ const transport = new StreamableHTTPClientTransport(new URL(cfg.baseUrl), {
14
+ requestInit: { headers: { Authorization: `Bearer ${token}` } }
15
+ });
16
+ await client.connect(transport);
17
+ return client;
18
+ }
19
+ function isUnauthorized(e) {
20
+ const s = String(e?.message ?? e);
21
+ return e?.status === 401 || /401|unauthor/i.test(s);
22
+ }
23
+ function makeHandlers(getRemote) {
24
+ let remotePromise = getRemote();
25
+ async function withRetry(fn) {
26
+ try {
27
+ return await fn(await remotePromise);
28
+ } catch (e) {
29
+ if (!isUnauthorized(e)) throw e;
30
+ remotePromise = getRemote(true);
31
+ return fn(await remotePromise);
32
+ }
33
+ }
34
+ return {
35
+ listTools: async () => withRetry((remote) => remote.listTools()),
36
+ callTool: async (params) => withRetry((remote) => remote.callTool(params))
37
+ };
38
+ }
39
+ async function runBridge(cfg) {
40
+ let remote = null;
41
+ const getRemote = async (forceRefresh = false) => {
42
+ if (remote && !forceRefresh) return remote;
43
+ const token = await getValidAccessToken(cfg);
44
+ remote = await buildRemoteClient(cfg, token);
45
+ return remote;
46
+ };
47
+ const h = makeHandlers(getRemote);
48
+ const server = new Server(
49
+ { name: "levelbox-mcp", version: "0.1.0" },
50
+ { capabilities: { tools: {} } }
51
+ );
52
+ server.setRequestHandler(ListToolsRequestSchema, async () => h.listTools());
53
+ server.setRequestHandler(CallToolRequestSchema, async (req) => h.callTool(req.params));
54
+ await server.connect(new StdioServerTransport());
55
+ }
56
+
57
+ export {
58
+ buildRemoteClient,
59
+ makeHandlers,
60
+ runBridge
61
+ };
@@ -0,0 +1,32 @@
1
+ // src/auth/store.ts
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
5
+ function credsPath() {
6
+ return join(homedir(), ".levelbox", "credentials.json");
7
+ }
8
+ function readCreds() {
9
+ try {
10
+ return JSON.parse(readFileSync(credsPath(), "utf8"));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+ function writeCreds(c) {
16
+ const dir = join(homedir(), ".levelbox");
17
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 448 });
18
+ writeFileSync(credsPath(), JSON.stringify(c, null, 2), { mode: 384 });
19
+ }
20
+ function clearCreds() {
21
+ try {
22
+ rmSync(credsPath());
23
+ } catch {
24
+ }
25
+ }
26
+
27
+ export {
28
+ credsPath,
29
+ readCreds,
30
+ writeCreds,
31
+ clearCreds
32
+ };
@@ -0,0 +1,22 @@
1
+ // src/config.ts
2
+ var DEFAULTS = {
3
+ baseUrl: "https://api.levelbox.ai/mcp",
4
+ supabaseUrl: "https://gmrkfqscgbxapaljfqve.supabase.co",
5
+ supabaseAnonKey: "sb_publishable__GLbOg-CpEqLuxtmlPab9g_wnE0IH_0"
6
+ };
7
+ function resolveConfig(flags = {}) {
8
+ const pick = (flag, env, dflt) => flag ?? env ?? dflt;
9
+ return {
10
+ baseUrl: pick(flags.baseUrl, process.env.LEVELBOX_MCP_URL, DEFAULTS.baseUrl),
11
+ supabaseUrl: pick(flags.supabaseUrl, process.env.LEVELBOX_SUPABASE_URL, DEFAULTS.supabaseUrl),
12
+ supabaseAnonKey: pick(
13
+ flags.supabaseAnonKey,
14
+ process.env.LEVELBOX_SUPABASE_ANON_KEY,
15
+ DEFAULTS.supabaseAnonKey
16
+ )
17
+ };
18
+ }
19
+
20
+ export {
21
+ resolveConfig
22
+ };
@@ -0,0 +1,29 @@
1
+ import {
2
+ readCreds,
3
+ writeCreds
4
+ } from "./chunk-7A2TMCJM.js";
5
+
6
+ // src/auth/refresh.ts
7
+ async function refreshTokens(cfg, refreshToken) {
8
+ const res = await fetch(`${cfg.supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
9
+ method: "POST",
10
+ headers: { apikey: cfg.supabaseAnonKey, "Content-Type": "application/json" },
11
+ body: JSON.stringify({ refresh_token: refreshToken })
12
+ });
13
+ if (!res.ok) throw new Error(`token refresh failed (${res.status}) \u2014 run: levelbox-mcp login`);
14
+ const j = await res.json();
15
+ return { accessToken: j.access_token, refreshToken: j.refresh_token, expiresAt: j.expires_at };
16
+ }
17
+ async function getValidAccessToken(cfg, now = Math.floor(Date.now() / 1e3)) {
18
+ const c = readCreds();
19
+ if (!c) throw new Error("not logged in \u2014 run: levelbox-mcp login");
20
+ if (c.expiresAt - now > 60) return c.accessToken;
21
+ const t = await refreshTokens(cfg, c.refreshToken);
22
+ writeCreds({ ...c, ...t });
23
+ return t.accessToken;
24
+ }
25
+
26
+ export {
27
+ refreshTokens,
28
+ getValidAccessToken
29
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { Command } from 'commander';
2
+
3
+ interface CliDeps {
4
+ connect: () => Promise<void>;
5
+ loginToken: (token: string, refresh: string) => Promise<void>;
6
+ loginBrowser: () => Promise<void>;
7
+ logout: () => Promise<void>;
8
+ status: () => Promise<void>;
9
+ }
10
+ interface GlobalOpts {
11
+ baseUrl?: string;
12
+ supabaseUrl?: string;
13
+ supabaseAnonKey?: string;
14
+ }
15
+ declare function buildProgram(deps: CliDeps): Command;
16
+ declare function main(): Promise<void>;
17
+
18
+ export { type CliDeps, type GlobalOpts, buildProgram, main };
package/dist/cli.js ADDED
@@ -0,0 +1,85 @@
1
+ // src/cli.ts
2
+ import { Command } from "commander";
3
+ import { pathToFileURL } from "url";
4
+ function buildProgram(deps) {
5
+ const program = new Command();
6
+ program.name("levelbox-mcp").description("MCP bridge for levelbox.ai \u2014 run as default to start the bridge").option("--base-url <url>", "levelbox API base URL").option("--supabase-url <url>", "Supabase project URL").option("--supabase-anon-key <key>", "Supabase anon key").exitOverride();
7
+ program.command("connect", { isDefault: true }).description("Start the MCP stdio bridge (default when no subcommand is given)").action(async () => {
8
+ await deps.connect();
9
+ });
10
+ program.command("login").description("Authenticate with levelbox.ai").option("--token <access_token>", "Access token (paste mode)").option("--refresh <refresh_token>", "Refresh token (required with --token)").action(async (opts) => {
11
+ if (opts.token && opts.refresh) {
12
+ await deps.loginToken(opts.token, opts.refresh);
13
+ } else {
14
+ await deps.loginBrowser();
15
+ }
16
+ });
17
+ program.command("logout").description("Remove stored credentials").action(async () => {
18
+ await deps.logout();
19
+ });
20
+ program.command("status").description("Show current auth status and configured base URL").action(async () => {
21
+ await deps.status();
22
+ });
23
+ return program;
24
+ }
25
+ async function main() {
26
+ const { resolveConfig } = await import("./config-2FXMX7WT.js");
27
+ const { runBridge } = await import("./connect-4G7SN4WT.js");
28
+ const { loginWithToken, loginWithBrowser } = await import("./login-3K4VUHVG.js");
29
+ const { clearCreds, readCreds } = await import("./store-O2JHTLQU.js");
30
+ let cfg = resolveConfig();
31
+ const deps = {
32
+ connect: async () => {
33
+ await runBridge(cfg);
34
+ },
35
+ loginToken: async (token, refresh) => {
36
+ await loginWithToken(cfg, token, refresh);
37
+ process.stderr.write("levelbox-mcp: logged in (token)\n");
38
+ },
39
+ loginBrowser: async () => {
40
+ await loginWithBrowser(cfg);
41
+ process.stderr.write("levelbox-mcp: logged in (browser)\n");
42
+ },
43
+ logout: async () => {
44
+ clearCreds();
45
+ process.stderr.write("levelbox-mcp: credentials removed\n");
46
+ },
47
+ status: async () => {
48
+ const creds = readCreds();
49
+ const now = Math.floor(Date.now() / 1e3);
50
+ if (!creds) {
51
+ process.stderr.write(`levelbox-mcp: not logged in
52
+ base-url: ${cfg.baseUrl}
53
+ `);
54
+ } else {
55
+ const valid = creds.expiresAt > now;
56
+ process.stderr.write(
57
+ `levelbox-mcp: logged in
58
+ account: ${creds.supabaseUrl}
59
+ token valid: ${valid}
60
+ base-url: ${cfg.baseUrl}
61
+ `
62
+ );
63
+ }
64
+ }
65
+ };
66
+ const program = buildProgram(deps);
67
+ program.hook("preAction", () => {
68
+ const opts = program.opts();
69
+ cfg = resolveConfig(opts);
70
+ });
71
+ await program.parseAsync(process.argv);
72
+ }
73
+ var argv1 = process.argv[1] ?? "";
74
+ var isEntry = argv1.endsWith("/levelbox-mcp") || argv1.endsWith("/levelbox-mcp.mjs") || pathToFileURL(argv1).href === import.meta.url;
75
+ if (isEntry) {
76
+ main().catch((err) => {
77
+ process.stderr.write(`levelbox-mcp: ${String(err)}
78
+ `);
79
+ process.exit(1);
80
+ });
81
+ }
82
+ export {
83
+ buildProgram,
84
+ main
85
+ };
@@ -0,0 +1,6 @@
1
+ import {
2
+ resolveConfig
3
+ } from "./chunk-FYOY32WT.js";
4
+ export {
5
+ resolveConfig
6
+ };
@@ -0,0 +1,12 @@
1
+ import {
2
+ buildRemoteClient,
3
+ makeHandlers,
4
+ runBridge
5
+ } from "./chunk-2CRXJH3H.js";
6
+ import "./chunk-K4KHIFRJ.js";
7
+ import "./chunk-7A2TMCJM.js";
8
+ export {
9
+ buildRemoteClient,
10
+ makeHandlers,
11
+ runBridge
12
+ };
@@ -0,0 +1,14 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+
3
+ interface LevelboxConfig {
4
+ baseUrl: string;
5
+ supabaseUrl: string;
6
+ supabaseAnonKey: string;
7
+ }
8
+ declare function resolveConfig(flags?: Partial<LevelboxConfig>): LevelboxConfig;
9
+
10
+ declare function createLevelboxClient(flags?: Partial<LevelboxConfig> & {
11
+ token?: string;
12
+ }): Promise<Client>;
13
+
14
+ export { type LevelboxConfig, createLevelboxClient, resolveConfig };
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ import {
2
+ resolveConfig
3
+ } from "./chunk-FYOY32WT.js";
4
+ import {
5
+ buildRemoteClient
6
+ } from "./chunk-2CRXJH3H.js";
7
+ import {
8
+ getValidAccessToken
9
+ } from "./chunk-K4KHIFRJ.js";
10
+ import "./chunk-7A2TMCJM.js";
11
+
12
+ // src/lib.ts
13
+ async function createLevelboxClient(flags = {}) {
14
+ const { token, ...configFlags } = flags;
15
+ const cfg = resolveConfig(configFlags);
16
+ const accessToken = token ?? await getValidAccessToken(cfg);
17
+ return buildRemoteClient(cfg, accessToken);
18
+ }
19
+ export {
20
+ createLevelboxClient,
21
+ resolveConfig
22
+ };
@@ -0,0 +1,235 @@
1
+ import {
2
+ refreshTokens
3
+ } from "./chunk-K4KHIFRJ.js";
4
+ import {
5
+ writeCreds
6
+ } from "./chunk-7A2TMCJM.js";
7
+
8
+ // src/auth/login.ts
9
+ import { createServer } from "http";
10
+ function requireSupabaseConfig(cfg) {
11
+ if (!cfg.supabaseUrl || !cfg.supabaseAnonKey) {
12
+ throw new Error(
13
+ "supabase not configured \u2014 set LEVELBOX_SUPABASE_URL and LEVELBOX_SUPABASE_ANON_KEY (or pass --supabase-url / --supabase-anon-key)"
14
+ );
15
+ }
16
+ }
17
+ function handleCallbackBody(cfg, body) {
18
+ if (typeof body.access_token !== "string" || body.access_token === "" || typeof body.refresh_token !== "string" || body.refresh_token === "" || typeof body.expires_at !== "number") {
19
+ throw new Error("invalid session from login page");
20
+ }
21
+ return {
22
+ accessToken: body.access_token,
23
+ refreshToken: body.refresh_token,
24
+ expiresAt: body.expires_at,
25
+ supabaseUrl: cfg.supabaseUrl
26
+ };
27
+ }
28
+ function buildLoginPage(supabaseUrl, supabaseAnonKey) {
29
+ return `<!DOCTYPE html>
30
+ <html lang="en">
31
+ <head>
32
+ <meta charset="UTF-8" />
33
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
34
+ <title>levelbox.ai \u2014 Sign in</title>
35
+ <style>
36
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
37
+ body {
38
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
39
+ background: #0f1117;
40
+ color: #e1e4e8;
41
+ min-height: 100vh;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ }
46
+ .card {
47
+ background: #161b22;
48
+ border: 1px solid #30363d;
49
+ border-radius: 12px;
50
+ padding: 40px;
51
+ width: 100%;
52
+ max-width: 380px;
53
+ }
54
+ h1 { font-size: 1.4rem; font-weight: 600; margin-bottom: 8px; }
55
+ p.tagline { color: #8b949e; font-size: 0.9rem; margin-bottom: 28px; }
56
+ button, input[type="email"], input[type="password"] {
57
+ width: 100%;
58
+ padding: 10px 14px;
59
+ border-radius: 6px;
60
+ font-size: 0.95rem;
61
+ border: 1px solid #30363d;
62
+ outline: none;
63
+ }
64
+ input[type="email"], input[type="password"] {
65
+ background: #0d1117;
66
+ color: #e1e4e8;
67
+ margin-bottom: 10px;
68
+ }
69
+ button {
70
+ cursor: pointer;
71
+ font-weight: 600;
72
+ border: none;
73
+ margin-bottom: 10px;
74
+ }
75
+ #btn-google { background: #238636; color: #fff; }
76
+ #btn-google:hover { background: #2ea043; }
77
+ #btn-email { background: #21262d; color: #e1e4e8; border: 1px solid #30363d; }
78
+ #btn-email:hover { background: #30363d; }
79
+ .divider { text-align: center; color: #8b949e; font-size: 0.8rem; margin: 14px 0; }
80
+ #status { margin-top: 16px; font-size: 0.9rem; color: #8b949e; text-align: center; }
81
+ #status.error { color: #f85149; }
82
+ #status.success { color: #3fb950; }
83
+ </style>
84
+ </head>
85
+ <body>
86
+ <div class="card">
87
+ <h1>levelbox.ai</h1>
88
+ <p class="tagline">Get paid to wait for your price</p>
89
+
90
+ <button id="btn-google">Continue with Google</button>
91
+
92
+ <div class="divider">or</div>
93
+
94
+ <input type="email" id="email" placeholder="Email address" autocomplete="email" />
95
+ <input type="password" id="password" placeholder="Password" autocomplete="current-password" />
96
+ <button id="btn-email">Sign in with Email</button>
97
+
98
+ <div id="status"></div>
99
+ </div>
100
+
101
+ <script type="module">
102
+ // Config injected by the CLI server
103
+ window.__LVB = { url: ${JSON.stringify(supabaseUrl)}, anon: ${JSON.stringify(supabaseAnonKey)} };
104
+
105
+ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
106
+
107
+ const supabase = createClient(window.__LVB.url, window.__LVB.anon);
108
+
109
+ const status = document.getElementById("status");
110
+
111
+ function setStatus(msg, kind) {
112
+ status.textContent = msg;
113
+ status.className = kind || "";
114
+ }
115
+
116
+ // Listen for successful sign-in and POST the session to the local callback
117
+ supabase.auth.onAuthStateChange(async (event, session) => {
118
+ if (event === "SIGNED_IN" && session) {
119
+ setStatus("Sending credentials to CLI\u2026");
120
+ try {
121
+ const res = await fetch("/callback", {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify({
125
+ access_token: session.access_token,
126
+ refresh_token: session.refresh_token,
127
+ expires_at: session.expires_at,
128
+ }),
129
+ });
130
+ if (res.ok) {
131
+ setStatus("You can close this tab.", "success");
132
+ } else {
133
+ setStatus("CLI callback failed (" + res.status + "). Please retry.", "error");
134
+ }
135
+ } catch (err) {
136
+ setStatus("Could not reach CLI: " + String(err), "error");
137
+ }
138
+ }
139
+ });
140
+
141
+ document.getElementById("btn-google").addEventListener("click", async () => {
142
+ setStatus("Redirecting to Google\u2026");
143
+ const { error } = await supabase.auth.signInWithOAuth({
144
+ provider: "google",
145
+ options: { redirectTo: location.origin },
146
+ });
147
+ if (error) setStatus(error.message, "error");
148
+ });
149
+
150
+ document.getElementById("btn-email").addEventListener("click", async () => {
151
+ const email = document.getElementById("email").value.trim();
152
+ const password = document.getElementById("password").value;
153
+ if (!email || !password) { setStatus("Enter email and password.", "error"); return; }
154
+ setStatus("Signing in\u2026");
155
+ const { error } = await supabase.auth.signInWithPassword({ email, password });
156
+ if (error) setStatus(error.message, "error");
157
+ });
158
+ </script>
159
+ </body>
160
+ </html>`;
161
+ }
162
+ async function loginWithBrowser(cfg) {
163
+ requireSupabaseConfig(cfg);
164
+ const { default: open } = await import("open");
165
+ const html = buildLoginPage(cfg.supabaseUrl, cfg.supabaseAnonKey);
166
+ return new Promise((resolve, reject) => {
167
+ const TIMEOUT_MS = 12e4;
168
+ const server = createServer((req, res) => {
169
+ if (req.method === "GET" && req.url === "/") {
170
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
171
+ res.end(html);
172
+ return;
173
+ }
174
+ if (req.method === "POST" && req.url === "/callback") {
175
+ const chunks = [];
176
+ req.on("data", (chunk) => chunks.push(chunk));
177
+ req.on("end", () => {
178
+ clearTimeout(timer);
179
+ try {
180
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
181
+ writeCreds(handleCallbackBody(cfg, body));
182
+ res.writeHead(200, { "Content-Type": "application/json" });
183
+ res.end(JSON.stringify({ ok: true }));
184
+ server.close();
185
+ resolve();
186
+ } catch (err) {
187
+ res.writeHead(400, { "Content-Type": "application/json" });
188
+ res.end(JSON.stringify({ error: String(err) }));
189
+ server.close();
190
+ reject(err instanceof Error ? err : new Error(String(err)));
191
+ }
192
+ });
193
+ return;
194
+ }
195
+ res.writeHead(404);
196
+ res.end();
197
+ });
198
+ server.listen(0, "127.0.0.1", () => {
199
+ const addr = server.address();
200
+ if (!addr || typeof addr === "string") {
201
+ reject(new Error("Failed to get server address"));
202
+ return;
203
+ }
204
+ const url = `http://127.0.0.1:${addr.port}`;
205
+ open(url).catch(() => {
206
+ });
207
+ process.stderr.write(`
208
+ Open this URL in your browser to sign in:
209
+ ${url}
210
+
211
+ `);
212
+ });
213
+ const timer = setTimeout(() => {
214
+ server.close();
215
+ reject(new Error("Browser login timed out after 120 seconds"));
216
+ }, TIMEOUT_MS);
217
+ timer.unref();
218
+ });
219
+ }
220
+ async function loginWithToken(cfg, _accessToken, refreshToken) {
221
+ requireSupabaseConfig(cfg);
222
+ const t = await refreshTokens(cfg, refreshToken);
223
+ writeCreds({
224
+ accessToken: t.accessToken,
225
+ refreshToken: t.refreshToken,
226
+ expiresAt: t.expiresAt,
227
+ supabaseUrl: cfg.supabaseUrl
228
+ });
229
+ }
230
+ export {
231
+ handleCallbackBody,
232
+ loginWithBrowser,
233
+ loginWithToken,
234
+ requireSupabaseConfig
235
+ };
@@ -0,0 +1,12 @@
1
+ import {
2
+ clearCreds,
3
+ credsPath,
4
+ readCreds,
5
+ writeCreds
6
+ } from "./chunk-7A2TMCJM.js";
7
+ export {
8
+ clearCreds,
9
+ credsPath,
10
+ readCreds,
11
+ writeCreds
12
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "levelbox-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Open-source MCP bridge for levelbox.ai — connect your AI assistant to the levelbox wheel screener.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "levelbox-mcp": "bin/levelbox-mcp.mjs"
9
+ },
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "files": [
13
+ "dist",
14
+ "bin",
15
+ "skill",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "test": "vitest run",
25
+ "lint": "eslint . && prettier --check .",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1",
30
+ "@supabase/supabase-js": "^2",
31
+ "commander": "^12",
32
+ "open": "^10"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20",
36
+ "eslint": "^9",
37
+ "prettier": "^3",
38
+ "tsup": "^8",
39
+ "typescript": "^5",
40
+ "typescript-eslint": "^8",
41
+ "vitest": "^2"
42
+ }
43
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: levelbox-mcp
3
+ description: Open-source MCP client bridge to levelbox.ai wheel screener — install, login, add config, access screening tools.
4
+ ---
5
+
6
+ # levelbox-mcp
7
+
8
+ ## What it is
9
+
10
+ A client/bridge that connects your AI assistant to **levelbox.ai**, an options wheel screener. It exposes screener tools over Model Context Protocol so Claude (Desktop, Code, or other AI apps) can query candidates, themes, and manage your picks.
11
+
12
+ ## Install & Setup
13
+
14
+ ### Step 1: Install the CLI
15
+
16
+ ```bash
17
+ npm install -g levelbox-mcp
18
+ ```
19
+
20
+ ### Step 2: Authenticate
21
+
22
+ ```bash
23
+ levelbox-mcp login
24
+ ```
25
+
26
+ Opens a browser to sign in with your levelbox.ai account (Google OAuth or email/password). Credentials auto-refresh locally.
27
+
28
+ ### Step 3: Add the MCP Config Block
29
+
30
+ Add to your Claude Desktop / Claude Code MCP configuration:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "levelbox": {
36
+ "command": "npx",
37
+ "args": ["-y", "levelbox-mcp", "connect"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ - **Claude Desktop:** `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows)
44
+ - **Claude Code:** `.mcp.json` in project root or `~/.claude/.mcp.json`
45
+
46
+ Restart your assistant.
47
+
48
+ ### Step 4: Use the Tools
49
+
50
+ Your assistant now has access to the levelbox screener:
51
+
52
+ - **list_themes** — Browse screening themes
53
+ - **list_candidates** — Get candidates for a theme
54
+ - **top_candidates** — Top N candidates by rank
55
+ - **get_candidate** — Details for a symbol
56
+ - **pick_symbol** — Add to watchlist
57
+ - **unpick_symbol** — Remove from watchlist
58
+ - **list_picks** — View your picks
59
+
60
+ ## Important
61
+
62
+ **Analytical use only.** These tools provide market data, analysis, and signals — not investment advice. Use them to research and understand opportunities. You decide what to do.
63
+
64
+ ## Troubleshooting
65
+
66
+ - **401 "Not logged in"?** Run `levelbox-mcp login` to refresh credentials.
67
+ - **Browser login fails with OAuth error?** Check that your Supabase project's **Auth > URL Configuration** allows `http://localhost:*` (email/password works without it).
68
+ - **Not seeing tools?** Restart your AI assistant and verify the config block is in the right file.
69
+
70
+ ## Env Vars
71
+
72
+ - `LEVELBOX_MCP_URL` (default: `https://api.levelbox.ai/mcp`)
73
+ - `LEVELBOX_SUPABASE_URL` (required for login)
74
+ - `LEVELBOX_SUPABASE_ANON_KEY` (required for login)
75
+
76
+ Or use flags: `--base-url`, `--supabase-url`, `--supabase-anon-key`.
77
+
78
+ ---
79
+
80
+ **Repo:** https://github.com/danielkoh/levelbox-mcp