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 +21 -0
- package/README.md +147 -0
- package/bin/levelbox-mcp.mjs +2 -0
- package/dist/chunk-2CRXJH3H.js +61 -0
- package/dist/chunk-7A2TMCJM.js +32 -0
- package/dist/chunk-FYOY32WT.js +22 -0
- package/dist/chunk-K4KHIFRJ.js +29 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +85 -0
- package/dist/config-2FXMX7WT.js +6 -0
- package/dist/connect-4G7SN4WT.js +12 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +22 -0
- package/dist/login-3K4VUHVG.js +235 -0
- package/dist/store-O2JHTLQU.js +12 -0
- package/package.json +43 -0
- package/skill/SKILL.md +80 -0
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,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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|
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
|