@adityanair98/api-oracle 0.5.0 → 0.6.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 +78 -26
- package/dist/cli.js +24 -11
- package/dist/dashboard/routes/api.js +1 -1
- package/dist/dashboard/server.d.ts +7 -1
- package/dist/dashboard/server.js +48 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -5
- package/dist/knowledge/db.d.ts +10 -0
- package/dist/knowledge/db.js +38 -2
- package/dist/knowledge/loader.d.ts +56 -0
- package/dist/knowledge/loader.js +116 -0
- package/dist/knowledge/relevance.d.ts +66 -0
- package/dist/knowledge/relevance.js +267 -0
- package/dist/knowledge/scorer.d.ts +41 -15
- package/dist/knowledge/scorer.js +151 -156
- package/dist/knowledge/search.d.ts +37 -12
- package/dist/knowledge/search.js +74 -71
- package/dist/knowledge/synonyms.d.ts +9 -0
- package/dist/knowledge/synonyms.js +23 -2
- package/dist/server.d.ts +3 -1
- package/dist/server.js +8 -2
- package/dist/tools/check-freshness.js +1 -1
- package/dist/tools/find-api.d.ts +18 -1
- package/dist/tools/find-api.js +109 -63
- package/dist/utils/config.d.ts +1 -0
- package/dist/utils/config.js +4 -1
- package/dist/utils/logger.d.ts +8 -1
- package/dist/utils/logger.js +13 -1
- package/package.json +4 -3
- package/dist/dashboard/public/public/app.js +0 -1004
- package/dist/dashboard/public/public/index.html +0 -142
- package/dist/dashboard/public/public/styles.css +0 -1464
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ An MCP server that gives Claude Code the ability to find, evaluate, and recommen
|
|
|
4
4
|
|
|
5
5
|
When a developer asks *"what should I use to send emails?"* — API Oracle returns a structured recommendation with working code, honest gotchas, pricing details, and everything needed to get started immediately.
|
|
6
6
|
|
|
7
|
-
[](https://github.com/adityanair98/api-oracle/actions/workflows/ci.yml)
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -49,35 +49,57 @@ When a developer asks *"what should I use to send emails?"* — API Oracle retur
|
|
|
49
49
|
|
|
50
50
|
## Quick Start
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
API Oracle runs as a local MCP server over stdio. There's no setup step. The 68 bundled entries are loaded into an in-memory database when the server starts, and nothing is written to your working directory.
|
|
53
|
+
|
|
54
|
+
### Claude Code
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
claude mcp add api-oracle -- npx -y @adityanair98/api-oracle serve
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This adds the server to the current project (local scope). To make it available in every project, use `--scope user`:
|
|
53
61
|
|
|
54
62
|
```bash
|
|
55
|
-
npx @adityanair98/api-oracle serve
|
|
63
|
+
claude mcp add --scope user api-oracle -- npx -y @adityanair98/api-oracle serve
|
|
56
64
|
```
|
|
57
65
|
|
|
58
|
-
|
|
66
|
+
To check that it's registered, run `claude mcp list`, or `/mcp` inside Claude Code.
|
|
67
|
+
|
|
68
|
+
On native Windows (not WSL), wrap `npx` with `cmd /c`:
|
|
59
69
|
|
|
60
70
|
```bash
|
|
61
|
-
|
|
62
|
-
api-oracle serve
|
|
71
|
+
claude mcp add api-oracle -- cmd /c npx -y @adityanair98/api-oracle serve
|
|
63
72
|
```
|
|
64
73
|
|
|
65
|
-
###
|
|
74
|
+
### Claude Desktop
|
|
66
75
|
|
|
67
|
-
|
|
76
|
+
Claude Desktop reads its MCP servers from `claude_desktop_config.json`. (Claude Code doesn't use this file.)
|
|
77
|
+
|
|
78
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
79
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
80
|
+
|
|
81
|
+
Add the server under `mcpServers`, then restart Claude Desktop:
|
|
68
82
|
|
|
69
83
|
```json
|
|
70
84
|
{
|
|
71
85
|
"mcpServers": {
|
|
72
86
|
"api-oracle": {
|
|
73
87
|
"command": "npx",
|
|
74
|
-
"args": ["@adityanair98/api-oracle", "serve"]
|
|
88
|
+
"args": ["-y", "@adityanair98/api-oracle", "serve"]
|
|
75
89
|
}
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
```
|
|
79
93
|
|
|
80
|
-
|
|
94
|
+
### Run it directly
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npx -y @adityanair98/api-oracle serve # MCP server on stdio (what the clients above launch)
|
|
98
|
+
npx -y @adityanair98/api-oracle dashboard # web dashboard at http://localhost:3737
|
|
99
|
+
npx -y @adityanair98/api-oracle --help
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or install it globally with `npm install -g @adityanair98/api-oracle`, then run `api-oracle serve`.
|
|
81
103
|
|
|
82
104
|
---
|
|
83
105
|
|
|
@@ -105,18 +127,19 @@ Dashboard features:
|
|
|
105
127
|
### 1. Clone and install
|
|
106
128
|
|
|
107
129
|
```bash
|
|
108
|
-
git clone https://github.com/
|
|
130
|
+
git clone https://github.com/adityanair98/api-oracle.git
|
|
109
131
|
cd api-oracle
|
|
110
132
|
npm install
|
|
111
133
|
```
|
|
112
134
|
|
|
113
|
-
### 2. Build
|
|
135
|
+
### 2. Build
|
|
114
136
|
|
|
115
137
|
```bash
|
|
116
138
|
npm run build
|
|
117
|
-
npm run seed
|
|
118
139
|
```
|
|
119
140
|
|
|
141
|
+
No seeding step is needed: entries are loaded from `src/entries/` at startup.
|
|
142
|
+
|
|
120
143
|
### 3. Run
|
|
121
144
|
|
|
122
145
|
```bash
|
|
@@ -131,10 +154,11 @@ node dist/cli.js dashboard
|
|
|
131
154
|
|
|
132
155
|
```bash
|
|
133
156
|
npm run dev # Run MCP server with tsx (no build needed)
|
|
134
|
-
npm test # Run all tests (
|
|
157
|
+
npm test # Run all tests (228 tests)
|
|
135
158
|
npm run validate # Validate all JSON entries + cross-references
|
|
136
159
|
npm run lint-entries # Content quality linter (0 errors, 15 warnings)
|
|
137
160
|
npm run e2e # End-to-end query scenarios (43 assertions)
|
|
161
|
+
npm run eval # Retrieval accuracy on the dev and held-out query sets
|
|
138
162
|
npm run dashboard # Start dashboard with tsx
|
|
139
163
|
```
|
|
140
164
|
|
|
@@ -144,8 +168,8 @@ npm run dashboard # Start dashboard with tsx
|
|
|
144
168
|
|
|
145
169
|
| Variable | Default | Description |
|
|
146
170
|
|----------|---------|-------------|
|
|
147
|
-
| `DB_PATH` |
|
|
148
|
-
| `PORT` | `3737` | Dashboard server port |
|
|
171
|
+
| `DB_PATH` | _(unset)_: in-memory | Optional SQLite file. If it's unset, the bundled entries are loaded into memory at startup. If it's set, they're upserted into that file and other rows are kept. |
|
|
172
|
+
| `PORT` | `3737` | Dashboard server port (`0` picks any free port; the banner shows the one in use) |
|
|
149
173
|
| `LOG_LEVEL` | `info` | Log level: debug \| info \| warn \| error |
|
|
150
174
|
|
|
151
175
|
---
|
|
@@ -155,7 +179,7 @@ npm run dashboard # Start dashboard with tsx
|
|
|
155
179
|
1. Create `src/entries/<category>/<slug>.json`
|
|
156
180
|
2. Fill in all schema fields (see `docs/adding-apis.md`)
|
|
157
181
|
3. `npm run validate` — verify schema and cross-references
|
|
158
|
-
4.
|
|
182
|
+
4. Restart the server. Entries are loaded at startup.
|
|
159
183
|
5. `npm test` — run tests
|
|
160
184
|
|
|
161
185
|
See [docs/adding-apis.md](docs/adding-apis.md) for the full guide and JSON template.
|
|
@@ -177,10 +201,12 @@ src/
|
|
|
177
201
|
├── knowledge/ # Core logic
|
|
178
202
|
│ ├── schema.ts # Zod schema + TypeScript types
|
|
179
203
|
│ ├── db.ts # SQLite CRUD (better-sqlite3)
|
|
204
|
+
│ ├── loader.ts # Loads + validates bundled entries at startup
|
|
205
|
+
│ ├── relevance.ts # Query relevance: IDF-weighted coverage, phrases
|
|
180
206
|
│ ├── search.ts # Search pipeline orchestration
|
|
181
|
-
│ ├── scorer.ts #
|
|
207
|
+
│ ├── scorer.ts # Relevance-gated ranking (relevance × prior)
|
|
182
208
|
│ ├── synonyms.ts # Query expansion + category detection
|
|
183
|
-
│ └── tfidf.ts # TF-IDF
|
|
209
|
+
│ └── tfidf.ts # TF-IDF index (candidate pre-selection for large KBs)
|
|
184
210
|
├── updater/ # Freshness and quality system
|
|
185
211
|
│ ├── staleness.ts # Staleness level detection
|
|
186
212
|
│ ├── report.ts # Freshness report builder
|
|
@@ -199,16 +225,42 @@ src/
|
|
|
199
225
|
|
|
200
226
|
## How Search Works
|
|
201
227
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
228
|
+
Relevance gates the score. An API's quality, developer experience, pricing and freshness can adjust its rank among relevant APIs, but they can't make an unrelated API show up.
|
|
229
|
+
|
|
230
|
+
1. **Relevance.** This is the IDF-weighted share of your request's words that an entry covers. It checks the entry's name, subcategory, `bestFor` and use cases, with the description counting less. Rare words like "transcription" count more than common ones like "users". Synonyms count partially, and word order counts too, so "speech to text" and "text to speech" give different results.
|
|
231
|
+
2. **Category fit.** The request is mapped to a category (email, payments, …). APIs in unrelated categories are down-weighted.
|
|
232
|
+
3. **Prior.** A weighted mean of quality score, developer experience, pricing fit and recency. It scales a score between 0.75× and 1.0× of its relevance: `score = relevance × (0.75 + 0.25 × prior)`. If you name an API ("firebase", "send emails with resend"), a small keyword term ranks that API above entries that merely mention it.
|
|
233
|
+
4. **Gate.** Candidates below a minimum relevance, below 60% of the best candidate's relevance, or in a clearly unrelated category are dropped.
|
|
234
|
+
5. **Response.** `find_best_api` returns the top result with quick-start code, up to 2 alternatives, and `candidates`: up to 5 relevant APIs with key facts and relevance scores, so the calling model can make the final choice. `totalEvaluated` is the number of entries scored.
|
|
209
235
|
|
|
210
236
|
See [docs/scoring-logic.md](docs/scoring-logic.md) for full details.
|
|
211
237
|
|
|
238
|
+
## Accuracy
|
|
239
|
+
|
|
240
|
+
`npm run eval` measures retrieval on two query sets in `tests/eval/`. **dev** (26 queries) is the tuning set. **holdout** (42 queries) was written before the scorer was changed and is never tuned on.
|
|
241
|
+
|
|
242
|
+
| Scorer | Set | Top-1 | Top-3 | Must-not violations |
|
|
243
|
+
|--------|-----|-------|-------|---------------------|
|
|
244
|
+
| v0.5.0 | dev | 80.8% | 96.2% | 38.1% |
|
|
245
|
+
| v0.5.0 | holdout | **38.1%** | 71.4% | 31.0% |
|
|
246
|
+
| v0.6.0 | dev | 96.2% | 100% | 0.0% |
|
|
247
|
+
| v0.6.0 | holdout | **92.9%** | 95.2% | 2.4% |
|
|
248
|
+
|
|
249
|
+
"Must-not violations" is the share of queries with a must-not list (21 of 26 dev queries, all 42 holdout queries) where a clearly wrong API appeared in the top 3. Earlier versions of this project reported 20/20 queries passing. That was measured on the same queries the weights were tuned on, and the held-out top-1 for that scorer was 38.1%.
|
|
250
|
+
|
|
251
|
+
Caveats: the holdout labels were written from the entries' own descriptions and use cases, which is also what the scorer matches on. Real-world phrasing is likely to score lower. With 42 queries, each one is worth about 2.4 percentage points. Full per-query results are in [`tests/eval/results/`](tests/eval/results/).
|
|
252
|
+
|
|
253
|
+
## Data freshness
|
|
254
|
+
|
|
255
|
+
Every entry has a `lastVerified` date: the last time someone checked its pricing, limits and SDK details against the provider's own docs. Dates are only changed when an entry is actually re-verified. Nothing updates them automatically.
|
|
256
|
+
|
|
257
|
+
- `find_best_api` returns `lastVerified` for every API it lists. For the recommendation it also returns `freshness` (`lastVerified`, `daysSinceVerified`, `level`), plus a `freshnessNote` when the entry isn't fresh.
|
|
258
|
+
- Levels: **fresh** < 90 days, **aging** 90–180, **stale** 180–365, **critical** > 365.
|
|
259
|
+
- Recency is a small part of the ranking prior (full credit within 30 days, falling to zero at 365 days).
|
|
260
|
+
- `check_api_freshness` (MCP tool), `npm run refresh-report` and the dashboard list entries that need re-verification.
|
|
261
|
+
|
|
262
|
+
As of v0.6.0, all 68 entries were last verified on 2026-02-25, so they report as **stale**. Check pricing and limits on the provider's site before relying on them.
|
|
263
|
+
|
|
212
264
|
---
|
|
213
265
|
|
|
214
266
|
## License
|
package/dist/cli.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* api-oracle --version — Print version
|
|
10
10
|
*/
|
|
11
11
|
import { createRequire } from "module";
|
|
12
|
-
import {
|
|
13
|
-
import { config } from "./utils/config.js";
|
|
12
|
+
import { bootstrapKnowledgeBase } from "./knowledge/loader.js";
|
|
14
13
|
const require = createRequire(import.meta.url);
|
|
15
14
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports
|
|
16
15
|
const pkg = require("../package.json");
|
|
16
|
+
/** The one-line Claude Code setup command (package name from package.json) */
|
|
17
|
+
const CLAUDE_CODE_CMD = `claude mcp add api-oracle -- npx -y ${pkg.name} serve`;
|
|
17
18
|
const HELP = `
|
|
18
19
|
API Oracle — MCP server for API discovery and recommendations
|
|
19
20
|
|
|
@@ -29,15 +30,27 @@ const HELP = `
|
|
|
29
30
|
--version Show version
|
|
30
31
|
|
|
31
32
|
Environment variables:
|
|
32
|
-
DB_PATH
|
|
33
|
-
|
|
33
|
+
DB_PATH Optional SQLite file to use. By default the bundled entries
|
|
34
|
+
are loaded into an in-memory database at startup and no
|
|
35
|
+
file is written. If set, bundled entries are upserted into it.
|
|
36
|
+
PORT Dashboard port (default: 3737; 0 picks any free port)
|
|
34
37
|
LOG_LEVEL Log level: debug | info | warn | error (default: info)
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
Add to Claude Code:
|
|
40
|
+
${CLAUDE_CODE_CMD}
|
|
41
|
+
For every project, add --scope user right after "claude mcp add".
|
|
42
|
+
On native Windows (not WSL), put "cmd /c" before npx.
|
|
43
|
+
Check it with: claude mcp list
|
|
44
|
+
|
|
45
|
+
Add to Claude Desktop — edit claude_desktop_config.json
|
|
46
|
+
(macOS: ~/Library/Application Support/Claude/claude_desktop_config.json,
|
|
47
|
+
Windows: %APPDATA%\\Claude\\claude_desktop_config.json):
|
|
37
48
|
{
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"api-oracle": {
|
|
51
|
+
"command": "npx",
|
|
52
|
+
"args": ["-y", "${pkg.name}", "serve"]
|
|
53
|
+
}
|
|
41
54
|
}
|
|
42
55
|
}
|
|
43
56
|
`.trimStart();
|
|
@@ -53,14 +66,14 @@ async function main() {
|
|
|
53
66
|
process.exit(0);
|
|
54
67
|
}
|
|
55
68
|
if (command === "dashboard") {
|
|
56
|
-
|
|
69
|
+
bootstrapKnowledgeBase();
|
|
57
70
|
const { startDashboard } = await import("./dashboard/server.js");
|
|
58
71
|
startDashboard();
|
|
59
72
|
return;
|
|
60
73
|
}
|
|
61
74
|
if (command === "serve" || command === "") {
|
|
62
|
-
// Default: MCP server on stdio
|
|
63
|
-
|
|
75
|
+
// Default: MCP server on stdio. Load the bundled entries before serving.
|
|
76
|
+
bootstrapKnowledgeBase();
|
|
64
77
|
const { startServer } = await import("./server.js");
|
|
65
78
|
await startServer();
|
|
66
79
|
return;
|
|
@@ -216,7 +216,7 @@ apiRouter.post("/entries/:slug/verify", (req, res) => {
|
|
|
216
216
|
slug,
|
|
217
217
|
lastVerified: today,
|
|
218
218
|
staleness,
|
|
219
|
-
note: `To persist: update lastVerified in src/entries/**/${slug}.json and
|
|
219
|
+
note: `To persist: update lastVerified in src/entries/**/${slug}.json and restart the server (entries are loaded at startup)`,
|
|
220
220
|
});
|
|
221
221
|
}
|
|
222
222
|
catch (e) {
|
|
@@ -6,4 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Exports: startDashboard
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
import type { Server } from "http";
|
|
10
|
+
/**
|
|
11
|
+
* Port from PORT (default 3737). 0 asks the OS for a free port; the banner
|
|
12
|
+
* prints the port actually bound. Returns null for an invalid value.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveDashboardPort(raw?: string | undefined): number | null;
|
|
15
|
+
export declare function startDashboard(): Server | null;
|
package/dist/dashboard/server.js
CHANGED
|
@@ -9,14 +9,33 @@
|
|
|
9
9
|
import express from "express";
|
|
10
10
|
import { join, dirname } from "path";
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
12
|
-
import {
|
|
12
|
+
import { getAllEntries } from "../knowledge/db.js";
|
|
13
|
+
import { bootstrapKnowledgeBase } from "../knowledge/loader.js";
|
|
13
14
|
import { config } from "../utils/config.js";
|
|
14
15
|
import { createLogger } from "../utils/logger.js";
|
|
15
16
|
import { apiRouter } from "./routes/api.js";
|
|
16
17
|
const logger = createLogger("dashboard");
|
|
17
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
const
|
|
19
|
+
const DEFAULT_PORT = 3737;
|
|
20
|
+
/**
|
|
21
|
+
* Port from PORT (default 3737). 0 asks the OS for a free port; the banner
|
|
22
|
+
* prints the port actually bound. Returns null for an invalid value.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDashboardPort(raw = process.env.PORT) {
|
|
25
|
+
if (raw === undefined || raw.trim() === "")
|
|
26
|
+
return DEFAULT_PORT;
|
|
27
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
28
|
+
return null;
|
|
29
|
+
const port = Number(raw.trim());
|
|
30
|
+
return port <= 65535 ? port : null;
|
|
31
|
+
}
|
|
19
32
|
export function startDashboard() {
|
|
33
|
+
const port = resolveDashboardPort();
|
|
34
|
+
if (port === null) {
|
|
35
|
+
logger.error(`Invalid PORT "${process.env.PORT ?? ""}": use a number from 0 to 65535.`);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
20
39
|
const app = express();
|
|
21
40
|
// Parse JSON bodies
|
|
22
41
|
app.use(express.json());
|
|
@@ -25,21 +44,40 @@ export function startDashboard() {
|
|
|
25
44
|
app.use(express.static(publicDir));
|
|
26
45
|
// Mount REST API
|
|
27
46
|
app.use("/api", apiRouter);
|
|
28
|
-
// Catch-all: serve index.html for client-side routing
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
// Catch-all: serve index.html for client-side routing.
|
|
48
|
+
// Express 5 (path-to-regexp v8) requires a named wildcard — a bare "*" throws at startup.
|
|
49
|
+
app.get("/{*splat}", (_req, res) => {
|
|
50
|
+
// Resolve relative to `root`: with an absolute path, send() refuses any
|
|
51
|
+
// dot-directory in it (npx installs under ~/.npm/_npx/…) and returns 404.
|
|
52
|
+
res.sendFile("index.html", { root: publicDir });
|
|
31
53
|
});
|
|
32
|
-
|
|
54
|
+
const entries = getAllEntries();
|
|
55
|
+
const categoryCount = new Set(entries.map((e) => e.category)).size;
|
|
56
|
+
// Express 5 passes listen errors (e.g. EADDRINUSE) to this callback instead
|
|
57
|
+
// of throwing. Ignoring them printed the "Local:" banner and then exited 0.
|
|
58
|
+
const server = app.listen(port, (err) => {
|
|
59
|
+
if (err) {
|
|
60
|
+
const code = err.code;
|
|
61
|
+
const hint = code === "EADDRINUSE"
|
|
62
|
+
? ` Port ${port} is already in use. Stop the other process or set PORT, e.g. PORT=3738 api-oracle dashboard.`
|
|
63
|
+
: "";
|
|
64
|
+
logger.error(`Dashboard could not start: ${err.message}.${hint}`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const address = server.address();
|
|
69
|
+
const boundPort = typeof address === "object" && address !== null ? address.port : port;
|
|
33
70
|
process.stdout.write(`\n API Oracle Dashboard\n`);
|
|
34
71
|
process.stdout.write(` ───────────────────────────────\n`);
|
|
35
|
-
process.stdout.write(` Local: http://localhost:${
|
|
36
|
-
process.stdout.write(` Entries:
|
|
72
|
+
process.stdout.write(` Local: http://localhost:${boundPort}\n`);
|
|
73
|
+
process.stdout.write(` Entries: ${entries.length} APIs across ${categoryCount} categories\n\n`);
|
|
37
74
|
});
|
|
75
|
+
return server;
|
|
38
76
|
}
|
|
39
77
|
// When run directly (npm run dashboard)
|
|
40
78
|
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
41
79
|
if (isMain) {
|
|
42
|
-
logger.info("
|
|
43
|
-
|
|
80
|
+
logger.info("Loading knowledge base for dashboard", { dbPath: config.dbPath });
|
|
81
|
+
bootstrapKnowledgeBase();
|
|
44
82
|
startDashboard();
|
|
45
83
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry point —
|
|
2
|
+
* Entry point — loads the bundled knowledge base and starts the MCP server.
|
|
3
3
|
* Run with: npm run dev (via tsx) or node dist/index.js (compiled).
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { bootstrapKnowledgeBase } from "./knowledge/loader.js";
|
|
6
6
|
import { startServer } from "./server.js";
|
|
7
7
|
import { config } from "./utils/config.js";
|
|
8
8
|
import { logger } from "./utils/logger.js";
|
|
@@ -11,9 +11,8 @@ async function main() {
|
|
|
11
11
|
dbPath: config.dbPath,
|
|
12
12
|
nodeEnv: config.nodeEnv,
|
|
13
13
|
});
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
logger.info("Database initialized");
|
|
14
|
+
// Open the database and load the bundled entries (in-memory unless DB_PATH is set)
|
|
15
|
+
bootstrapKnowledgeBase();
|
|
17
16
|
// Start the MCP server on stdio
|
|
18
17
|
await startServer();
|
|
19
18
|
}
|
package/dist/knowledge/db.d.ts
CHANGED
|
@@ -8,12 +8,22 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import Database from "better-sqlite3";
|
|
10
10
|
import { type ApiEntry } from "./schema.js";
|
|
11
|
+
/**
|
|
12
|
+
* Open the database. Defaults to an in-memory database — callers that want
|
|
13
|
+
* data must load it (see bootstrapKnowledgeBase in loader.ts).
|
|
14
|
+
*/
|
|
11
15
|
export declare function initDb(dbPath?: string): void;
|
|
12
16
|
export declare function getDb(): Database.Database;
|
|
13
17
|
export declare function closeDb(): void;
|
|
14
18
|
/** Allows tests to inject their own in-memory database instance */
|
|
15
19
|
export declare function _setDbForTesting(testDb: Database.Database): void;
|
|
16
20
|
export declare function insertEntry(entry: ApiEntry): void;
|
|
21
|
+
/**
|
|
22
|
+
* Insert or replace many entries in one transaction (quiet: one log line).
|
|
23
|
+
* Entries are re-validated; returns the number written.
|
|
24
|
+
*/
|
|
25
|
+
export declare function upsertEntries(entries: ApiEntry[]): number;
|
|
26
|
+
export declare function countEntries(): number;
|
|
17
27
|
export declare function getBySlug(slug: string): ApiEntry | null;
|
|
18
28
|
export declare function getByCategory(category: string): ApiEntry[];
|
|
19
29
|
export declare function searchByText(query: string): ApiEntry[];
|
package/dist/knowledge/db.js
CHANGED
|
@@ -83,14 +83,20 @@ function deserializeRow(row) {
|
|
|
83
83
|
return result.data;
|
|
84
84
|
}
|
|
85
85
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Open the database. Defaults to an in-memory database — callers that want
|
|
88
|
+
* data must load it (see bootstrapKnowledgeBase in loader.ts).
|
|
89
|
+
*/
|
|
90
|
+
export function initDb(dbPath = ":memory:") {
|
|
87
91
|
if (db)
|
|
88
92
|
return; // already initialized
|
|
89
93
|
if (dbPath !== ":memory:") {
|
|
90
94
|
mkdirSync(dirname(dbPath), { recursive: true });
|
|
91
95
|
}
|
|
92
96
|
db = new Database(dbPath);
|
|
93
|
-
|
|
97
|
+
if (dbPath !== ":memory:") {
|
|
98
|
+
db.pragma("journal_mode = WAL");
|
|
99
|
+
}
|
|
94
100
|
db.pragma("foreign_keys = ON");
|
|
95
101
|
db.exec(CREATE_TABLE_SQL);
|
|
96
102
|
db.exec(CREATE_CATEGORY_INDEX_SQL);
|
|
@@ -129,6 +135,36 @@ export function insertEntry(entry) {
|
|
|
129
135
|
stmt.run(row);
|
|
130
136
|
logger.info("Inserted entry", { slug: entry.slug });
|
|
131
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Insert or replace many entries in one transaction (quiet: one log line).
|
|
140
|
+
* Entries are re-validated; returns the number written.
|
|
141
|
+
*/
|
|
142
|
+
export function upsertEntries(entries) {
|
|
143
|
+
const database = getDb();
|
|
144
|
+
const rows = entries.map((entry) => {
|
|
145
|
+
const validation = validateEntry(entry);
|
|
146
|
+
if (!validation.success) {
|
|
147
|
+
throw new Error(`Invalid entry ${entry.slug}: ${validation.errors.join("; ")}`);
|
|
148
|
+
}
|
|
149
|
+
return serializeEntry(validation.data);
|
|
150
|
+
});
|
|
151
|
+
if (rows.length === 0)
|
|
152
|
+
return 0;
|
|
153
|
+
const columns = Object.keys(rows[0]);
|
|
154
|
+
const placeholders = columns.map((c) => `@${c}`).join(", ");
|
|
155
|
+
const stmt = database.prepare(`INSERT OR REPLACE INTO apis (${columns.join(", ")}) VALUES (${placeholders})`);
|
|
156
|
+
const writeAll = database.transaction((batch) => {
|
|
157
|
+
for (const row of batch)
|
|
158
|
+
stmt.run(row);
|
|
159
|
+
});
|
|
160
|
+
writeAll(rows);
|
|
161
|
+
logger.debug("Upserted entries", { count: rows.length });
|
|
162
|
+
return rows.length;
|
|
163
|
+
}
|
|
164
|
+
export function countEntries() {
|
|
165
|
+
const row = getDb().prepare("SELECT COUNT(*) AS n FROM apis").get();
|
|
166
|
+
return row.n;
|
|
167
|
+
}
|
|
132
168
|
export function getBySlug(slug) {
|
|
133
169
|
const database = getDb();
|
|
134
170
|
const row = database
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge-base loader — reads the bundled JSON entries that ship with the
|
|
3
|
+
* package (src/entries/<category>/<slug>.json), validates each one, and loads
|
|
4
|
+
* them into the SQLite database at startup.
|
|
5
|
+
*
|
|
6
|
+
* The entries directory is resolved relative to this compiled module
|
|
7
|
+
* (import.meta.url), never the process cwd, so running the package via npx
|
|
8
|
+
* works from any folder.
|
|
9
|
+
*
|
|
10
|
+
* Exports: resolveEntriesDir, loadEntriesFromDir, bootstrapKnowledgeBase,
|
|
11
|
+
* LoadResult, SkippedEntry, BootstrapResult
|
|
12
|
+
*/
|
|
13
|
+
import { type ApiEntry } from "./schema.js";
|
|
14
|
+
export interface SkippedEntry {
|
|
15
|
+
file: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
export interface LoadResult {
|
|
19
|
+
dir: string;
|
|
20
|
+
entries: ApiEntry[];
|
|
21
|
+
skipped: SkippedEntry[];
|
|
22
|
+
}
|
|
23
|
+
export interface BootstrapResult {
|
|
24
|
+
dbPath: string;
|
|
25
|
+
entriesDir: string;
|
|
26
|
+
loaded: number;
|
|
27
|
+
skipped: SkippedEntry[];
|
|
28
|
+
totalInDb: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Absolute path of the bundled entries directory.
|
|
32
|
+
*
|
|
33
|
+
* This module lives two levels below the package root in both layouts:
|
|
34
|
+
* <pkg>/dist/knowledge/loader.js (compiled, what npm users run)
|
|
35
|
+
* <pkg>/src/knowledge/loader.ts (tsx, local development)
|
|
36
|
+
* and package.json "files" ships the entries at <pkg>/src/entries.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveEntriesDir(): string;
|
|
39
|
+
/**
|
|
40
|
+
* Read and validate every JSON entry under `dir`.
|
|
41
|
+
* Invalid JSON, schema failures and duplicate slugs are skipped (and reported),
|
|
42
|
+
* never thrown — one bad entry must not take the server down.
|
|
43
|
+
*/
|
|
44
|
+
export declare function loadEntriesFromDir(dir: string): LoadResult;
|
|
45
|
+
/**
|
|
46
|
+
* Open the database and load the bundled entries into it.
|
|
47
|
+
*
|
|
48
|
+
* - Default (DB_PATH unset): an in-memory database, rebuilt on every start, so
|
|
49
|
+
* no file is ever written to the user's working directory.
|
|
50
|
+
* - DB_PATH set explicitly: that file is opened and the bundled entries are
|
|
51
|
+
* upserted into it (bundled data wins for matching slugs; other rows are kept).
|
|
52
|
+
*/
|
|
53
|
+
export declare function bootstrapKnowledgeBase(options?: {
|
|
54
|
+
dbPath?: string;
|
|
55
|
+
entriesDir?: string;
|
|
56
|
+
}): BootstrapResult;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge-base loader — reads the bundled JSON entries that ship with the
|
|
3
|
+
* package (src/entries/<category>/<slug>.json), validates each one, and loads
|
|
4
|
+
* them into the SQLite database at startup.
|
|
5
|
+
*
|
|
6
|
+
* The entries directory is resolved relative to this compiled module
|
|
7
|
+
* (import.meta.url), never the process cwd, so running the package via npx
|
|
8
|
+
* works from any folder.
|
|
9
|
+
*
|
|
10
|
+
* Exports: resolveEntriesDir, loadEntriesFromDir, bootstrapKnowledgeBase,
|
|
11
|
+
* LoadResult, SkippedEntry, BootstrapResult
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
14
|
+
import { join, relative } from "path";
|
|
15
|
+
import { fileURLToPath } from "url";
|
|
16
|
+
import { initDb, upsertEntries, countEntries } from "./db.js";
|
|
17
|
+
import { validateEntry } from "./schema.js";
|
|
18
|
+
import { resetTfIdfEngine } from "./tfidf.js";
|
|
19
|
+
import { config } from "../utils/config.js";
|
|
20
|
+
import { createLogger } from "../utils/logger.js";
|
|
21
|
+
const logger = createLogger("loader");
|
|
22
|
+
/**
|
|
23
|
+
* Absolute path of the bundled entries directory.
|
|
24
|
+
*
|
|
25
|
+
* This module lives two levels below the package root in both layouts:
|
|
26
|
+
* <pkg>/dist/knowledge/loader.js (compiled, what npm users run)
|
|
27
|
+
* <pkg>/src/knowledge/loader.ts (tsx, local development)
|
|
28
|
+
* and package.json "files" ships the entries at <pkg>/src/entries.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveEntriesDir() {
|
|
31
|
+
return fileURLToPath(new URL("../../src/entries/", import.meta.url));
|
|
32
|
+
}
|
|
33
|
+
function findJsonFiles(dir) {
|
|
34
|
+
const results = [];
|
|
35
|
+
for (const name of readdirSync(dir).sort()) {
|
|
36
|
+
const full = join(dir, name);
|
|
37
|
+
if (statSync(full).isDirectory()) {
|
|
38
|
+
results.push(...findJsonFiles(full));
|
|
39
|
+
}
|
|
40
|
+
else if (name.endsWith(".json")) {
|
|
41
|
+
results.push(full);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read and validate every JSON entry under `dir`.
|
|
48
|
+
* Invalid JSON, schema failures and duplicate slugs are skipped (and reported),
|
|
49
|
+
* never thrown — one bad entry must not take the server down.
|
|
50
|
+
*/
|
|
51
|
+
export function loadEntriesFromDir(dir) {
|
|
52
|
+
if (!existsSync(dir)) {
|
|
53
|
+
throw new Error(`Entries directory not found: ${dir}`);
|
|
54
|
+
}
|
|
55
|
+
const entries = [];
|
|
56
|
+
const skipped = [];
|
|
57
|
+
const seen = new Map();
|
|
58
|
+
for (const file of findJsonFiles(dir)) {
|
|
59
|
+
const rel = relative(dir, file);
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = JSON.parse(readFileSync(file, "utf-8"));
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
skipped.push({ file: rel, reason: `invalid JSON: ${String(e)}` });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const validation = validateEntry(raw);
|
|
69
|
+
if (!validation.success) {
|
|
70
|
+
skipped.push({ file: rel, reason: `schema: ${validation.errors.slice(0, 3).join("; ")}` });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const entry = validation.data;
|
|
74
|
+
const firstFile = seen.get(entry.slug);
|
|
75
|
+
if (firstFile) {
|
|
76
|
+
skipped.push({ file: rel, reason: `duplicate slug "${entry.slug}" (already loaded from ${firstFile})` });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
seen.set(entry.slug, rel);
|
|
80
|
+
entries.push(entry);
|
|
81
|
+
}
|
|
82
|
+
return { dir, entries, skipped };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Open the database and load the bundled entries into it.
|
|
86
|
+
*
|
|
87
|
+
* - Default (DB_PATH unset): an in-memory database, rebuilt on every start, so
|
|
88
|
+
* no file is ever written to the user's working directory.
|
|
89
|
+
* - DB_PATH set explicitly: that file is opened and the bundled entries are
|
|
90
|
+
* upserted into it (bundled data wins for matching slugs; other rows are kept).
|
|
91
|
+
*/
|
|
92
|
+
export function bootstrapKnowledgeBase(options = {}) {
|
|
93
|
+
const dbPath = options.dbPath ?? config.dbPath;
|
|
94
|
+
const entriesDir = options.entriesDir ?? resolveEntriesDir();
|
|
95
|
+
initDb(dbPath);
|
|
96
|
+
const { entries, skipped } = loadEntriesFromDir(entriesDir);
|
|
97
|
+
for (const s of skipped) {
|
|
98
|
+
logger.warn("Skipped invalid entry", s);
|
|
99
|
+
}
|
|
100
|
+
const loaded = upsertEntries(entries);
|
|
101
|
+
// The TF-IDF index is built lazily from the DB — force a rebuild.
|
|
102
|
+
resetTfIdfEngine();
|
|
103
|
+
const totalInDb = countEntries();
|
|
104
|
+
logger.info("Knowledge base loaded", {
|
|
105
|
+
loaded,
|
|
106
|
+
skipped: skipped.length,
|
|
107
|
+
totalInDb,
|
|
108
|
+
dbPath,
|
|
109
|
+
});
|
|
110
|
+
if (totalInDb === 0) {
|
|
111
|
+
logger.error("Knowledge base is empty — find_best_api will return no results", {
|
|
112
|
+
entriesDir,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return { dbPath, entriesDir, loaded, skipped, totalInDb };
|
|
116
|
+
}
|