aether-code 0.11.1 → 0.12.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 +17 -2
- package/bin/aether-code.js +99 -3
- package/package.json +2 -2
- package/src/mcp-registry.js +266 -0
package/README.md
CHANGED
|
@@ -78,12 +78,27 @@ aether balance # check credits
|
|
|
78
78
|
Configure servers with one command — no JSON editing:
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
|
|
81
|
+
# Browse the curated registry
|
|
82
|
+
aether mcp search browser
|
|
83
|
+
aether mcp search # everything available
|
|
84
|
+
|
|
85
|
+
# One-command install for well-known servers (interactive prompts for any required values)
|
|
86
|
+
aether mcp install filesystem # prompts for the path
|
|
87
|
+
aether mcp install playwright # no inputs needed — just installs
|
|
88
|
+
aether mcp install github # prompts for your GitHub token
|
|
89
|
+
aether mcp install postgres # prompts for connection string
|
|
90
|
+
|
|
91
|
+
# Manual install for anything else (e.g. local Python servers like IDA Pro)
|
|
82
92
|
aether mcp add ida --env IDA_PATH=/opt/ida -- python -m ida_pro_mcp
|
|
83
|
-
aether mcp add
|
|
93
|
+
aether mcp add roblox -- npx -y robloxstudio-mcp
|
|
94
|
+
|
|
95
|
+
# Inspect + manage
|
|
84
96
|
aether mcp list
|
|
97
|
+
aether mcp remove ida
|
|
85
98
|
```
|
|
86
99
|
|
|
100
|
+
Built-in registry covers the official MCP servers (filesystem, github, gitlab, postgres, sqlite, puppeteer, playwright, slack, google-drive, fetch, memory, everart). For Python-based or community servers, use `aether mcp add` with the full command.
|
|
101
|
+
|
|
87
102
|
On the next `aether` launch you'll see:
|
|
88
103
|
|
|
89
104
|
```
|
package/bin/aether-code.js
CHANGED
|
@@ -16,9 +16,17 @@ import { fetchBalance, AetherError } from "../src/api.js";
|
|
|
16
16
|
import { writeConfigFile, getConfig, CONFIG_PATH } from "../src/config.js";
|
|
17
17
|
import { loadMcpConfig, MCPManager } from "../src/mcp.js";
|
|
18
18
|
import { addServer, removeServer, listServers } from "../src/mcp-cli.js";
|
|
19
|
+
import {
|
|
20
|
+
MCP_REGISTRY,
|
|
21
|
+
findRegistryEntry,
|
|
22
|
+
resolveEntry,
|
|
23
|
+
searchRegistry,
|
|
24
|
+
suggestSimilar,
|
|
25
|
+
} from "../src/mcp-registry.js";
|
|
26
|
+
import readline from "node:readline";
|
|
19
27
|
import { c, errorLine, divider } from "../src/render.js";
|
|
20
28
|
|
|
21
|
-
const VERSION = "0.
|
|
29
|
+
const VERSION = "0.12.0";
|
|
22
30
|
|
|
23
31
|
/**
|
|
24
32
|
* Try to start MCP servers from ~/.aether/mcp.json. Returns a started
|
|
@@ -65,7 +73,7 @@ ${c.bold("SUBCOMMANDS")}
|
|
|
65
73
|
${c.cyan("logout")} Clear saved API key
|
|
66
74
|
${c.cyan("balance")} Show plan + credit balance
|
|
67
75
|
${c.cyan("config")} show|set|set-base|path Manage config file
|
|
68
|
-
${c.cyan("mcp")} list|add|remove
|
|
76
|
+
${c.cyan("mcp")} list|search|install|add|remove Manage MCP server connections
|
|
69
77
|
|
|
70
78
|
${c.bold("EXAMPLES")}
|
|
71
79
|
aether # interactive REPL
|
|
@@ -348,12 +356,100 @@ async function handleMcp(rest) {
|
|
|
348
356
|
return;
|
|
349
357
|
}
|
|
350
358
|
|
|
359
|
+
if (sub === "search" || sub === "find") {
|
|
360
|
+
const query = rest.slice(1).join(" ").trim();
|
|
361
|
+
const results = searchRegistry(query);
|
|
362
|
+
if (results.length === 0) {
|
|
363
|
+
console.log(c.gray(`No MCP servers in the registry match "${query}".`));
|
|
364
|
+
console.log(c.gray("Browse the full list: ") + c.cyan("aether mcp search"));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
console.log(
|
|
368
|
+
c.bold(query ? `MCP servers matching "${query}":` : `Available MCP servers (${results.length}):`),
|
|
369
|
+
);
|
|
370
|
+
for (const e of results) {
|
|
371
|
+
const sourceTag = e.source === "official"
|
|
372
|
+
? c.gray("(official)")
|
|
373
|
+
: c.yellow("(community)");
|
|
374
|
+
console.log(` ${c.cyan(e.id.padEnd(16))} ${sourceTag} ${e.description}`);
|
|
375
|
+
}
|
|
376
|
+
console.log("");
|
|
377
|
+
console.log(c.gray("Install one with: ") + c.cyan("aether mcp install <name>"));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (sub === "install" || sub === "get") {
|
|
382
|
+
const name = rest[1];
|
|
383
|
+
if (!name) {
|
|
384
|
+
die(
|
|
385
|
+
"aether mcp install: missing <name>.\n" +
|
|
386
|
+
"Try `aether mcp search` to see what's available.",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const entry = findRegistryEntry(name);
|
|
390
|
+
if (!entry) {
|
|
391
|
+
const suggestions = suggestSimilar(name);
|
|
392
|
+
let msg = `aether mcp install: unknown server "${name}".`;
|
|
393
|
+
if (suggestions.length > 0) {
|
|
394
|
+
msg += `\nDid you mean: ${suggestions.map((s) => c.cyan(s)).join(", ")}?`;
|
|
395
|
+
} else {
|
|
396
|
+
msg += `\nTry \`aether mcp search\` to browse the registry.`;
|
|
397
|
+
}
|
|
398
|
+
die(msg);
|
|
399
|
+
}
|
|
400
|
+
// Prompt for any required values (placeholders in args + env)
|
|
401
|
+
const allRequired = [...(entry.requires ?? []), ...(entry.requiresEnv ?? [])];
|
|
402
|
+
const values = {};
|
|
403
|
+
if (allRequired.length > 0) {
|
|
404
|
+
console.log(c.gray(`Installing ${c.cyan(entry.id)} — needs ${allRequired.length} input${allRequired.length === 1 ? "" : "s"}:`));
|
|
405
|
+
for (const key of allRequired) {
|
|
406
|
+
const promptText = entry.prompts?.[key] ?? key;
|
|
407
|
+
// eslint-disable-next-line no-await-in-loop -- sequential prompts are intentional
|
|
408
|
+
values[key] = await promptUser(` ${promptText}: `);
|
|
409
|
+
if (!values[key]) die(`Cancelled — "${key}" is required.`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
let resolved;
|
|
413
|
+
try {
|
|
414
|
+
resolved = resolveEntry(entry, values);
|
|
415
|
+
} catch (e) {
|
|
416
|
+
die(e.message);
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const added = addServer({
|
|
420
|
+
name: entry.id,
|
|
421
|
+
command: resolved.command,
|
|
422
|
+
args: resolved.args,
|
|
423
|
+
env: resolved.env,
|
|
424
|
+
});
|
|
425
|
+
console.log(`${c.green("✓")} Installed MCP server "${c.cyan(entry.id)}".`);
|
|
426
|
+
console.log(c.gray(` ${added.command}${added.args ? " " + added.args.join(" ") : ""}`));
|
|
427
|
+
console.log(c.gray("Restart aether (or run `aether`) to attach it."));
|
|
428
|
+
} catch (e) {
|
|
429
|
+
die(e.message || String(e));
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
351
434
|
die(
|
|
352
435
|
`aether mcp: unknown subcommand "${sub}".\n` +
|
|
353
|
-
"Try one of: list, add, remove.",
|
|
436
|
+
"Try one of: list, add, install, search, remove.",
|
|
354
437
|
);
|
|
355
438
|
}
|
|
356
439
|
|
|
440
|
+
function promptUser(question) {
|
|
441
|
+
if (!process.stdin.isTTY) {
|
|
442
|
+
return Promise.reject(new Error("Interactive prompt unavailable (non-TTY)"));
|
|
443
|
+
}
|
|
444
|
+
return new Promise((resolve) => {
|
|
445
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
446
|
+
rl.question(question, (answer) => {
|
|
447
|
+
rl.close();
|
|
448
|
+
resolve(answer.trim());
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
357
453
|
main().catch((err) => {
|
|
358
454
|
console.error(errorLine(err.message || String(err)));
|
|
359
455
|
if (process.env.DEBUG) console.error(err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aether-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Uncensored AI coding agent for your terminal — Claude Code alternative with MCP support. Reads code, writes files, runs commands. Drives IDA Pro, Roblox Studio, Wireshark, Blender, and any MCP server. No refusal layer.",
|
|
5
5
|
"homepage": "https://trynoguard.com",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"node": ">=18"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/skills.js",
|
|
26
|
+
"lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/mcp-registry.js src/skills.js",
|
|
27
27
|
"test": "node --test \"test/**/*.test.js\""
|
|
28
28
|
},
|
|
29
29
|
"keywords": [
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Curated registry of well-known MCP servers users can install with
|
|
2
|
+
// `aether mcp install <name>` instead of typing the full npx command.
|
|
3
|
+
//
|
|
4
|
+
// Conservative inclusion criteria:
|
|
5
|
+
// 1. Published to npm under a known scope (@modelcontextprotocol/*,
|
|
6
|
+
// @playwright/*, etc.) OR widely-cited community packages
|
|
7
|
+
// 2. stdio transport (which is all MCPManager supports today)
|
|
8
|
+
// 3. Installable via npx -y with no separate global install step
|
|
9
|
+
//
|
|
10
|
+
// We DON'T include servers that need a local binary (IDA Pro, Ghidra,
|
|
11
|
+
// Wireshark) because the install path is environment-specific — those
|
|
12
|
+
// users need to read each project's README anyway, so `mcp install` won't
|
|
13
|
+
// save them work. The README shows their `mcp add` commands instead.
|
|
14
|
+
//
|
|
15
|
+
// Adding a new entry: confirm the package exists on npm, confirm the
|
|
16
|
+
// stdio entrypoint runs cleanly with `npx -y <pkg>`, then drop in here.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Each entry has:
|
|
20
|
+
* id — kebab-case name used as the MCP server name in config
|
|
21
|
+
* (must satisfy /^[a-z0-9_-]{1,40}$/i per mcp.js validator)
|
|
22
|
+
* command — base spawn command (typically "npx")
|
|
23
|
+
* args — array of args; entries wrapped in `{placeholder}` get
|
|
24
|
+
* substituted from user-provided inputs
|
|
25
|
+
* requires — array of placeholder names the user must supply
|
|
26
|
+
* prompts — { placeholder: "interactive prompt text shown to user" }
|
|
27
|
+
* description — one-line summary shown by `mcp list`/`mcp search`
|
|
28
|
+
* tags — keywords for `mcp search` matching
|
|
29
|
+
* source — "official" (anthropic / first-party orgs), "community"
|
|
30
|
+
*/
|
|
31
|
+
export const MCP_REGISTRY = [
|
|
32
|
+
{
|
|
33
|
+
id: "filesystem",
|
|
34
|
+
command: "npx",
|
|
35
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "{path}"],
|
|
36
|
+
requires: ["path"],
|
|
37
|
+
prompts: { path: "Allowed filesystem path (the server will be sandboxed here)" },
|
|
38
|
+
description: "Read/write/list files under a whitelisted directory",
|
|
39
|
+
tags: ["files", "fs", "disk", "filesystem", "local"],
|
|
40
|
+
source: "official",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "github",
|
|
44
|
+
command: "npx",
|
|
45
|
+
args: ["-y", "@modelcontextprotocol/server-github"],
|
|
46
|
+
requires: [],
|
|
47
|
+
env: { GITHUB_PERSONAL_ACCESS_TOKEN: "{token}" },
|
|
48
|
+
requiresEnv: ["token"],
|
|
49
|
+
prompts: { token: "GitHub personal access token (https://github.com/settings/tokens — repo scope)" },
|
|
50
|
+
description: "Browse + edit GitHub repos, issues, PRs",
|
|
51
|
+
tags: ["github", "git", "repo", "issues", "pull request", "pr"],
|
|
52
|
+
source: "official",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "gitlab",
|
|
56
|
+
command: "npx",
|
|
57
|
+
args: ["-y", "@modelcontextprotocol/server-gitlab"],
|
|
58
|
+
requires: [],
|
|
59
|
+
env: { GITLAB_PERSONAL_ACCESS_TOKEN: "{token}", GITLAB_API_URL: "{url}" },
|
|
60
|
+
requiresEnv: ["token", "url"],
|
|
61
|
+
prompts: {
|
|
62
|
+
token: "GitLab personal access token (api scope)",
|
|
63
|
+
url: "GitLab API URL (default: https://gitlab.com/api/v4)",
|
|
64
|
+
},
|
|
65
|
+
description: "Browse + edit GitLab projects, issues, MRs",
|
|
66
|
+
tags: ["gitlab", "git", "repo", "issues", "merge request", "mr"],
|
|
67
|
+
source: "official",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "postgres",
|
|
71
|
+
command: "npx",
|
|
72
|
+
args: ["-y", "@modelcontextprotocol/server-postgres", "{connection}"],
|
|
73
|
+
requires: ["connection"],
|
|
74
|
+
prompts: { connection: "Postgres connection string (postgresql://user:pass@host:port/db)" },
|
|
75
|
+
description: "Query Postgres databases (read-only by default)",
|
|
76
|
+
tags: ["postgres", "postgresql", "database", "db", "sql"],
|
|
77
|
+
source: "official",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "sqlite",
|
|
81
|
+
command: "npx",
|
|
82
|
+
args: ["-y", "@modelcontextprotocol/server-sqlite", "{db_path}"],
|
|
83
|
+
requires: ["db_path"],
|
|
84
|
+
prompts: { db_path: "Path to the SQLite database file" },
|
|
85
|
+
description: "Query SQLite databases",
|
|
86
|
+
tags: ["sqlite", "database", "db", "sql"],
|
|
87
|
+
source: "official",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "puppeteer",
|
|
91
|
+
command: "npx",
|
|
92
|
+
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
|
|
93
|
+
requires: [],
|
|
94
|
+
description: "Drive a headless browser via Puppeteer (navigate, screenshot, fill forms, scrape)",
|
|
95
|
+
tags: ["browser", "headless", "scrape", "puppeteer", "automation", "chrome"],
|
|
96
|
+
source: "official",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "playwright",
|
|
100
|
+
command: "npx",
|
|
101
|
+
args: ["-y", "@playwright/mcp"],
|
|
102
|
+
requires: [],
|
|
103
|
+
description: "Drive Chrome/Firefox/WebKit via Playwright (more capable than puppeteer)",
|
|
104
|
+
tags: ["browser", "playwright", "scrape", "automation", "chromium", "firefox", "webkit"],
|
|
105
|
+
source: "official",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "slack",
|
|
109
|
+
command: "npx",
|
|
110
|
+
args: ["-y", "@modelcontextprotocol/server-slack"],
|
|
111
|
+
requires: [],
|
|
112
|
+
env: { SLACK_BOT_TOKEN: "{bot_token}", SLACK_TEAM_ID: "{team_id}" },
|
|
113
|
+
requiresEnv: ["bot_token", "team_id"],
|
|
114
|
+
prompts: {
|
|
115
|
+
bot_token: "Slack bot token (xoxb-…)",
|
|
116
|
+
team_id: "Slack team/workspace ID (T…)",
|
|
117
|
+
},
|
|
118
|
+
description: "Read + post in Slack channels, list users, fetch threads",
|
|
119
|
+
tags: ["slack", "chat", "messaging", "workspace"],
|
|
120
|
+
source: "official",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "google-drive",
|
|
124
|
+
command: "npx",
|
|
125
|
+
args: ["-y", "@modelcontextprotocol/server-gdrive"],
|
|
126
|
+
requires: [],
|
|
127
|
+
description: "List + read Google Drive files (requires OAuth setup, see docs)",
|
|
128
|
+
tags: ["google", "drive", "gdrive", "files", "cloud"],
|
|
129
|
+
source: "official",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "memory",
|
|
133
|
+
command: "npx",
|
|
134
|
+
args: ["-y", "@modelcontextprotocol/server-memory"],
|
|
135
|
+
requires: [],
|
|
136
|
+
description: "Persistent knowledge-graph memory across agent sessions",
|
|
137
|
+
tags: ["memory", "kg", "knowledge graph", "persistence", "notes"],
|
|
138
|
+
source: "official",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "fetch",
|
|
142
|
+
command: "npx",
|
|
143
|
+
args: ["-y", "@modelcontextprotocol/server-fetch"],
|
|
144
|
+
requires: [],
|
|
145
|
+
description: "Fetch web pages + convert to markdown (alternative to built-in web_fetch)",
|
|
146
|
+
tags: ["http", "fetch", "web", "url", "markdown"],
|
|
147
|
+
source: "official",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "everart",
|
|
151
|
+
command: "npx",
|
|
152
|
+
args: ["-y", "@modelcontextprotocol/server-everart"],
|
|
153
|
+
requires: [],
|
|
154
|
+
env: { EVERART_API_KEY: "{key}" },
|
|
155
|
+
requiresEnv: ["key"],
|
|
156
|
+
prompts: { key: "EverArt API key (https://everart.ai)" },
|
|
157
|
+
description: "Generate images via EverArt",
|
|
158
|
+
tags: ["image", "generate", "art", "everart"],
|
|
159
|
+
source: "official",
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
const PLACEHOLDER_RE = /\{([a-zA-Z0-9_]+)\}/g;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Look up a registry entry by id. Returns null for unknown names.
|
|
167
|
+
*/
|
|
168
|
+
export function findRegistryEntry(id) {
|
|
169
|
+
return MCP_REGISTRY.find((e) => e.id === id) ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Apply user-provided placeholder values to an entry's args + env.
|
|
174
|
+
* Returns { command, args, env } ready to pass to addServer(), or
|
|
175
|
+
* throws if a required placeholder wasn't supplied.
|
|
176
|
+
*
|
|
177
|
+
* resolveEntry(filesystemEntry, { path: "/tmp" })
|
|
178
|
+
* → { command: "npx", args: ["-y", "...", "/tmp"], env: {} }
|
|
179
|
+
*/
|
|
180
|
+
export function resolveEntry(entry, values) {
|
|
181
|
+
if (!entry) throw new Error("resolveEntry: entry is required");
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
const substitute = (s) =>
|
|
184
|
+
s.replace(PLACEHOLDER_RE, (_, key) => {
|
|
185
|
+
seen.add(key);
|
|
186
|
+
const v = values?.[key];
|
|
187
|
+
if (v === undefined || v === null || v === "") {
|
|
188
|
+
throw new Error(`Missing required value for {${key}}`);
|
|
189
|
+
}
|
|
190
|
+
return String(v);
|
|
191
|
+
});
|
|
192
|
+
const args = (entry.args ?? []).map(substitute);
|
|
193
|
+
const env = {};
|
|
194
|
+
for (const [k, raw] of Object.entries(entry.env ?? {})) {
|
|
195
|
+
env[k] = substitute(raw);
|
|
196
|
+
}
|
|
197
|
+
// Sanity-check that we got every required placeholder.
|
|
198
|
+
for (const k of entry.requires ?? []) {
|
|
199
|
+
if (!seen.has(k)) {
|
|
200
|
+
// Required but never referenced in args — flag in case the registry
|
|
201
|
+
// entry is malformed. Defense in depth.
|
|
202
|
+
throw new Error(`Registry entry "${entry.id}": "${k}" listed in requires but never used`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const k of entry.requiresEnv ?? []) {
|
|
206
|
+
if (!seen.has(k)) {
|
|
207
|
+
throw new Error(`Registry entry "${entry.id}": env placeholder "${k}" listed but never used`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { command: entry.command, args, env };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Search registry by free-text query. Matches id, description, and tags.
|
|
215
|
+
* Case-insensitive substring match. Returns entries ordered by relevance
|
|
216
|
+
* (id matches first, then tag matches, then description matches).
|
|
217
|
+
*/
|
|
218
|
+
export function searchRegistry(query) {
|
|
219
|
+
const q = (query || "").toLowerCase().trim();
|
|
220
|
+
if (!q) return [...MCP_REGISTRY];
|
|
221
|
+
const idHits = [];
|
|
222
|
+
const tagHits = [];
|
|
223
|
+
const descHits = [];
|
|
224
|
+
for (const e of MCP_REGISTRY) {
|
|
225
|
+
if (e.id.includes(q)) {
|
|
226
|
+
idHits.push(e);
|
|
227
|
+
} else if ((e.tags ?? []).some((t) => t.toLowerCase().includes(q))) {
|
|
228
|
+
tagHits.push(e);
|
|
229
|
+
} else if ((e.description ?? "").toLowerCase().includes(q)) {
|
|
230
|
+
descHits.push(e);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...idHits, ...tagHits, ...descHits];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* For "unknown id" errors: return the closest registry ids to a typo,
|
|
238
|
+
* up to 3 suggestions. Simple edit-distance-style proximity using the
|
|
239
|
+
* length-diff + character-overlap heuristic — good enough to suggest
|
|
240
|
+
* "playwright" when the user types "playright".
|
|
241
|
+
*/
|
|
242
|
+
export function suggestSimilar(id) {
|
|
243
|
+
const target = id.toLowerCase();
|
|
244
|
+
const scored = MCP_REGISTRY.map((e) => ({
|
|
245
|
+
id: e.id,
|
|
246
|
+
score: similarity(target, e.id.toLowerCase()),
|
|
247
|
+
}));
|
|
248
|
+
scored.sort((a, b) => b.score - a.score);
|
|
249
|
+
return scored.filter((s) => s.score > 0.4).slice(0, 3).map((s) => s.id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function similarity(a, b) {
|
|
253
|
+
if (a === b) return 1;
|
|
254
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
255
|
+
// Crude bigram-overlap. Good enough to catch one-character typos.
|
|
256
|
+
const bigrams = (s) => {
|
|
257
|
+
const out = new Set();
|
|
258
|
+
for (let i = 0; i < s.length - 1; i++) out.add(s.slice(i, i + 2));
|
|
259
|
+
return out;
|
|
260
|
+
};
|
|
261
|
+
const A = bigrams(a);
|
|
262
|
+
const B = bigrams(b);
|
|
263
|
+
let shared = 0;
|
|
264
|
+
for (const x of A) if (B.has(x)) shared++;
|
|
265
|
+
return (2 * shared) / (A.size + B.size);
|
|
266
|
+
}
|