@evantahler/mcpx 0.15.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.
Files changed (106) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/.claude/skills/mcpx.md +165 -0
  3. package/.claude/worktrees/elastic-jennings/.claude/settings.local.json +18 -0
  4. package/.claude/worktrees/elastic-jennings/.claude/skills/mcpcli.md +93 -0
  5. package/.claude/worktrees/elastic-jennings/.github/workflows/auto-release.yml +117 -0
  6. package/.claude/worktrees/elastic-jennings/.github/workflows/ci.yml +18 -0
  7. package/.claude/worktrees/elastic-jennings/.prettierignore +4 -0
  8. package/.claude/worktrees/elastic-jennings/.prettierrc +7 -0
  9. package/.claude/worktrees/elastic-jennings/CLAUDE.md +19 -0
  10. package/.claude/worktrees/elastic-jennings/LICENSE +21 -0
  11. package/.claude/worktrees/elastic-jennings/README.md +487 -0
  12. package/.claude/worktrees/elastic-jennings/bun.lock +381 -0
  13. package/.claude/worktrees/elastic-jennings/install.sh +55 -0
  14. package/.claude/worktrees/elastic-jennings/package.json +56 -0
  15. package/.claude/worktrees/elastic-jennings/src/cli.ts +39 -0
  16. package/.claude/worktrees/elastic-jennings/src/client/http.ts +100 -0
  17. package/.claude/worktrees/elastic-jennings/src/client/manager.ts +266 -0
  18. package/.claude/worktrees/elastic-jennings/src/client/oauth.ts +299 -0
  19. package/.claude/worktrees/elastic-jennings/src/client/stdio.ts +12 -0
  20. package/.claude/worktrees/elastic-jennings/src/commands/add.ts +155 -0
  21. package/.claude/worktrees/elastic-jennings/src/commands/auth.ts +114 -0
  22. package/.claude/worktrees/elastic-jennings/src/commands/exec.ts +91 -0
  23. package/.claude/worktrees/elastic-jennings/src/commands/index.ts +62 -0
  24. package/.claude/worktrees/elastic-jennings/src/commands/info.ts +38 -0
  25. package/.claude/worktrees/elastic-jennings/src/commands/list.ts +30 -0
  26. package/.claude/worktrees/elastic-jennings/src/commands/remove.ts +67 -0
  27. package/.claude/worktrees/elastic-jennings/src/commands/search.ts +45 -0
  28. package/.claude/worktrees/elastic-jennings/src/commands/skill.ts +70 -0
  29. package/.claude/worktrees/elastic-jennings/src/config/env.ts +41 -0
  30. package/.claude/worktrees/elastic-jennings/src/config/loader.ts +156 -0
  31. package/.claude/worktrees/elastic-jennings/src/config/schemas.ts +137 -0
  32. package/.claude/worktrees/elastic-jennings/src/context.ts +53 -0
  33. package/.claude/worktrees/elastic-jennings/src/output/formatter.ts +316 -0
  34. package/.claude/worktrees/elastic-jennings/src/output/logger.ts +114 -0
  35. package/.claude/worktrees/elastic-jennings/src/search/index.ts +69 -0
  36. package/.claude/worktrees/elastic-jennings/src/search/indexer.ts +92 -0
  37. package/.claude/worktrees/elastic-jennings/src/search/keyword.ts +86 -0
  38. package/.claude/worktrees/elastic-jennings/src/search/semantic.ts +75 -0
  39. package/.claude/worktrees/elastic-jennings/src/search/staleness.ts +8 -0
  40. package/.claude/worktrees/elastic-jennings/src/validation/schema.ts +77 -0
  41. package/.claude/worktrees/elastic-jennings/test/cli.test.ts +51 -0
  42. package/.claude/worktrees/elastic-jennings/test/client/manager.test.ts +249 -0
  43. package/.claude/worktrees/elastic-jennings/test/client/oauth.test.ts +328 -0
  44. package/.claude/worktrees/elastic-jennings/test/commands/add-remove.test.ts +253 -0
  45. package/.claude/worktrees/elastic-jennings/test/commands/exec.test.ts +105 -0
  46. package/.claude/worktrees/elastic-jennings/test/commands/info.test.ts +48 -0
  47. package/.claude/worktrees/elastic-jennings/test/commands/list.test.ts +39 -0
  48. package/.claude/worktrees/elastic-jennings/test/commands/skill.test.ts +98 -0
  49. package/.claude/worktrees/elastic-jennings/test/config/env.test.ts +61 -0
  50. package/.claude/worktrees/elastic-jennings/test/config/loader.test.ts +139 -0
  51. package/.claude/worktrees/elastic-jennings/test/fixtures/.keep +0 -0
  52. package/.claude/worktrees/elastic-jennings/test/fixtures/auth.json +10 -0
  53. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/.keep +0 -0
  54. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/servers.json +8 -0
  55. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-server.ts +113 -0
  56. package/.claude/worktrees/elastic-jennings/test/fixtures/search.json +15 -0
  57. package/.claude/worktrees/elastic-jennings/test/fixtures/servers.json +18 -0
  58. package/.claude/worktrees/elastic-jennings/test/integration/stdio-server.test.ts +149 -0
  59. package/.claude/worktrees/elastic-jennings/test/output/formatter.test.ts +54 -0
  60. package/.claude/worktrees/elastic-jennings/test/output/logger.test.ts +89 -0
  61. package/.claude/worktrees/elastic-jennings/test/search/indexer.test.ts +32 -0
  62. package/.claude/worktrees/elastic-jennings/test/search/keyword.test.ts +80 -0
  63. package/.claude/worktrees/elastic-jennings/test/search/semantic.test.ts +32 -0
  64. package/.claude/worktrees/elastic-jennings/test/validation/schema.test.ts +113 -0
  65. package/.claude/worktrees/elastic-jennings/tsconfig.json +29 -0
  66. package/.cursor/rules/mcpx.mdc +165 -0
  67. package/LICENSE +21 -0
  68. package/README.md +627 -0
  69. package/package.json +58 -0
  70. package/src/cli.ts +72 -0
  71. package/src/client/browser.ts +24 -0
  72. package/src/client/debug-fetch.ts +81 -0
  73. package/src/client/elicitation.ts +368 -0
  74. package/src/client/http.ts +25 -0
  75. package/src/client/manager.ts +566 -0
  76. package/src/client/oauth.ts +314 -0
  77. package/src/client/sse.ts +17 -0
  78. package/src/client/stdio.ts +12 -0
  79. package/src/client/trace.ts +184 -0
  80. package/src/commands/add.ts +179 -0
  81. package/src/commands/auth.ts +114 -0
  82. package/src/commands/exec.ts +156 -0
  83. package/src/commands/index.ts +62 -0
  84. package/src/commands/info.ts +63 -0
  85. package/src/commands/list.ts +64 -0
  86. package/src/commands/ping.ts +69 -0
  87. package/src/commands/prompt.ts +60 -0
  88. package/src/commands/remove.ts +67 -0
  89. package/src/commands/resource.ts +46 -0
  90. package/src/commands/search.ts +49 -0
  91. package/src/commands/servers.ts +66 -0
  92. package/src/commands/skill.ts +112 -0
  93. package/src/commands/task.ts +82 -0
  94. package/src/config/env.ts +41 -0
  95. package/src/config/loader.ts +156 -0
  96. package/src/config/schemas.ts +152 -0
  97. package/src/context.ts +62 -0
  98. package/src/lib/input.ts +36 -0
  99. package/src/output/formatter.ts +884 -0
  100. package/src/output/logger.ts +173 -0
  101. package/src/search/index.ts +69 -0
  102. package/src/search/indexer.ts +92 -0
  103. package/src/search/keyword.ts +86 -0
  104. package/src/search/semantic.ts +75 -0
  105. package/src/search/staleness.ts +8 -0
  106. package/src/validation/schema.ts +103 -0
@@ -0,0 +1,173 @@
1
+ import { createSpinner } from "nanospinner";
2
+ import { dim, yellow, red } from "ansis";
3
+ import type { FormatOptions } from "./formatter.ts";
4
+
5
+ /** MCP log levels ordered by severity (RFC 5424) */
6
+ const LOG_LEVELS = [
7
+ "debug",
8
+ "info",
9
+ "notice",
10
+ "warning",
11
+ "error",
12
+ "critical",
13
+ "alert",
14
+ "emergency",
15
+ ] as const;
16
+
17
+ type LogLevel = (typeof LOG_LEVELS)[number];
18
+
19
+ function logLevelIndex(level: string): number {
20
+ const idx = LOG_LEVELS.indexOf(level as LogLevel);
21
+ return idx === -1 ? 0 : idx;
22
+ }
23
+
24
+ function colorForLevel(level: string): (s: string) => string {
25
+ switch (level) {
26
+ case "debug":
27
+ return dim;
28
+ case "warning":
29
+ return yellow;
30
+ case "error":
31
+ case "critical":
32
+ case "alert":
33
+ case "emergency":
34
+ return red;
35
+ default:
36
+ return (s: string) => s;
37
+ }
38
+ }
39
+
40
+ export interface Spinner {
41
+ update(text: string): void;
42
+ success(text?: string): void;
43
+ error(text?: string): void;
44
+ stop(): void;
45
+ }
46
+
47
+ class Logger {
48
+ private static instance: Logger;
49
+ private activeSpinner: ReturnType<typeof createSpinner> | null = null;
50
+ private formatOptions: FormatOptions = {};
51
+
52
+ private constructor() {}
53
+
54
+ static getInstance(): Logger {
55
+ if (!Logger.instance) {
56
+ Logger.instance = new Logger();
57
+ }
58
+ return Logger.instance;
59
+ }
60
+
61
+ /** Set format options (called once during context setup) */
62
+ configure(options: FormatOptions): void {
63
+ this.formatOptions = options;
64
+ }
65
+
66
+ /** Whether interactive output is suppressed (JSON mode or non-TTY stderr) */
67
+ private isSilent(): boolean {
68
+ return !!this.formatOptions.json || !(process.stderr.isTTY ?? false);
69
+ }
70
+
71
+ /** Write a line to stderr, pausing any active spinner around the write */
72
+ private writeStderr(msg: string): void {
73
+ if (this.activeSpinner) {
74
+ this.activeSpinner.clear();
75
+ process.stderr.write(msg + "\n");
76
+ this.activeSpinner.render();
77
+ } else {
78
+ process.stderr.write(msg + "\n");
79
+ }
80
+ }
81
+
82
+ /** Info-level message (dim text on stderr). Suppressed in JSON/non-TTY mode. */
83
+ info(msg: string): void {
84
+ if (this.isSilent()) return;
85
+ this.writeStderr(dim(msg));
86
+ }
87
+
88
+ /** Warning message (yellow text on stderr). Suppressed in JSON/non-TTY mode. */
89
+ warn(msg: string): void {
90
+ if (this.isSilent()) return;
91
+ this.writeStderr(yellow(msg));
92
+ }
93
+
94
+ /** Error message (red text on stderr). Always writes. */
95
+ error(msg: string): void {
96
+ this.writeStderr(red(msg));
97
+ }
98
+
99
+ /** Debug/verbose message (dim text on stderr). Only when verbose is enabled. */
100
+ debug(msg: string): void {
101
+ if (!this.formatOptions.verbose || this.isSilent()) return;
102
+ this.writeStderr(dim(msg));
103
+ }
104
+
105
+ /** Write a raw string to stderr. Spinner-aware but no formatting or newline added. */
106
+ writeRaw(msg: string): void {
107
+ if (this.activeSpinner) {
108
+ this.activeSpinner.clear();
109
+ process.stderr.write(msg);
110
+ this.activeSpinner.render();
111
+ } else {
112
+ process.stderr.write(msg);
113
+ }
114
+ }
115
+
116
+ /** Display a structured server log message. Suppressed if below configured log level. */
117
+ logServerMessage(
118
+ serverName: string,
119
+ params: { level: string; logger?: string; data: unknown },
120
+ ): void {
121
+ const minLevel = this.formatOptions.logLevel ?? "warning";
122
+ if (logLevelIndex(params.level) < logLevelIndex(minLevel)) return;
123
+
124
+ if (this.formatOptions.json) {
125
+ // JSON mode: structured object to stderr
126
+ const obj = { server: serverName, ...params };
127
+ process.stderr.write(JSON.stringify(obj) + "\n");
128
+ return;
129
+ }
130
+
131
+ if (!(process.stderr.isTTY ?? false)) return;
132
+
133
+ const prefix = params.logger ? `[${serverName}/${params.logger}]` : `[${serverName}]`;
134
+ const dataStr = typeof params.data === "string" ? params.data : JSON.stringify(params.data);
135
+ const line = `${prefix} ${params.level}: ${dataStr}`;
136
+ const color = colorForLevel(params.level);
137
+ this.writeStderr(color(line));
138
+ }
139
+
140
+ /** Start a spinner. Returns the Spinner interface. */
141
+ startSpinner(text: string, options?: FormatOptions): Spinner {
142
+ const opts = options ?? this.formatOptions;
143
+
144
+ // No spinner in JSON/piped/verbose mode — verbose writeRaw output conflicts with spinner rendering
145
+ if (opts.json || opts.verbose || !(process.stderr.isTTY ?? false)) {
146
+ return { update() {}, success() {}, error() {}, stop() {} };
147
+ }
148
+
149
+ const spinner = createSpinner(text, { stream: process.stderr }).start();
150
+ this.activeSpinner = spinner;
151
+
152
+ return {
153
+ update: (text: string) => {
154
+ spinner.update({ text });
155
+ },
156
+ success: (text?: string) => {
157
+ spinner.success({ text });
158
+ this.activeSpinner = null;
159
+ },
160
+ error: (text?: string) => {
161
+ spinner.error({ text });
162
+ this.activeSpinner = null;
163
+ },
164
+ stop: () => {
165
+ spinner.stop();
166
+ this.activeSpinner = null;
167
+ },
168
+ };
169
+ }
170
+ }
171
+
172
+ /** The singleton logger instance */
173
+ export const logger = Logger.getInstance();
@@ -0,0 +1,69 @@
1
+ import type { SearchIndex } from "../config/schemas.ts";
2
+ import { keywordSearch } from "./keyword.ts";
3
+ import { semanticSearch } from "./semantic.ts";
4
+
5
+ export interface SearchResult {
6
+ server: string;
7
+ tool: string;
8
+ description: string;
9
+ score: number;
10
+ matchType: "keyword" | "semantic" | "both";
11
+ }
12
+
13
+ export interface SearchOptions {
14
+ keywordOnly?: boolean;
15
+ semanticOnly?: boolean;
16
+ topK?: number;
17
+ }
18
+
19
+ /** Search tools using keyword and/or semantic matching */
20
+ export async function search(
21
+ query: string,
22
+ index: SearchIndex,
23
+ options: SearchOptions = {},
24
+ ): Promise<SearchResult[]> {
25
+ const topK = options.topK ?? 10;
26
+ const results = new Map<string, SearchResult>();
27
+
28
+ const runKeyword = !options.semanticOnly;
29
+ const runSemantic = !options.keywordOnly;
30
+
31
+ // Keyword search
32
+ if (runKeyword) {
33
+ const matches = keywordSearch(query, index.tools);
34
+ for (const m of matches) {
35
+ const key = `${m.server}/${m.tool}`;
36
+ results.set(key, {
37
+ server: m.server,
38
+ tool: m.tool,
39
+ description: m.description,
40
+ score: m.score,
41
+ matchType: "keyword",
42
+ });
43
+ }
44
+ }
45
+
46
+ // Semantic search
47
+ if (runSemantic && index.tools.some((t) => t.embedding.length > 0)) {
48
+ const matches = await semanticSearch(query, index.tools, topK);
49
+ for (const m of matches) {
50
+ const key = `${m.server}/${m.tool}`;
51
+ const existing = results.get(key);
52
+ if (existing) {
53
+ // Combine scores: keyword 0.4 + semantic 0.6
54
+ existing.score = existing.score * 0.4 + m.score * 0.6;
55
+ existing.matchType = "both";
56
+ } else {
57
+ results.set(key, {
58
+ server: m.server,
59
+ tool: m.tool,
60
+ description: m.description,
61
+ score: m.score,
62
+ matchType: "semantic",
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ return [...results.values()].sort((a, b) => b.score - a.score).slice(0, topK);
69
+ }
@@ -0,0 +1,92 @@
1
+ import type { ServerManager, ToolWithServer } from "../client/manager.ts";
2
+ import type { SearchIndex, IndexedTool } from "../config/schemas.ts";
3
+ import { generateEmbedding } from "./semantic.ts";
4
+ import { logger } from "../output/logger.ts";
5
+
6
+ /** Extract keywords from a tool name by splitting on separators and camelCase */
7
+ export function extractKeywords(name: string): string[] {
8
+ // Split on underscores, hyphens, dots
9
+ const parts = name.split(/[_\-.]+/);
10
+
11
+ // Also split camelCase
12
+ const words: string[] = [];
13
+ for (const part of parts) {
14
+ words.push(...part.replace(/([a-z])([A-Z])/g, "$1 $2").split(/\s+/));
15
+ }
16
+
17
+ return words.map((w) => w.toLowerCase()).filter((w) => w.length > 1);
18
+ }
19
+
20
+ /** Generate scenario phrases from tool name and description */
21
+ export function generateScenarios(name: string, description: string): string[] {
22
+ const scenarios: string[] = [];
23
+
24
+ // Use description as-is if short enough
25
+ if (description && description.length < 200) {
26
+ scenarios.push(description);
27
+ }
28
+
29
+ // Extract action + noun from tool name (e.g., "SendMessage" → "send a message")
30
+ const keywords = extractKeywords(name);
31
+ if (keywords.length >= 2) {
32
+ scenarios.push(keywords.join(" "));
33
+ }
34
+
35
+ return scenarios;
36
+ }
37
+
38
+ /** Build an IndexedTool from a tool with server info */
39
+ async function indexTool(t: ToolWithServer): Promise<IndexedTool> {
40
+ const description = t.tool.description ?? "";
41
+ const keywords = extractKeywords(t.tool.name);
42
+ const scenarios = generateScenarios(t.tool.name, description);
43
+
44
+ // Build text for embedding: combine name, description, and scenarios
45
+ const embeddingText = [t.tool.name, description, ...scenarios].filter(Boolean).join(" ");
46
+ const embedding = await generateEmbedding(embeddingText);
47
+
48
+ return {
49
+ server: t.server,
50
+ tool: t.tool.name,
51
+ description,
52
+ input_schema: t.tool.inputSchema,
53
+ scenarios,
54
+ keywords,
55
+ embedding,
56
+ };
57
+ }
58
+
59
+ export interface IndexProgress {
60
+ total: number;
61
+ current: number;
62
+ tool: string;
63
+ }
64
+
65
+ /** Build a search index from all configured servers */
66
+ export async function buildSearchIndex(
67
+ manager: ServerManager,
68
+ onProgress?: (progress: IndexProgress) => void,
69
+ ): Promise<SearchIndex> {
70
+ const { tools, errors } = await manager.getAllTools();
71
+
72
+ if (errors.length > 0) {
73
+ for (const err of errors) {
74
+ logger.warn(`${err.server}: ${err.message}`);
75
+ }
76
+ }
77
+
78
+ const indexed: IndexedTool[] = [];
79
+
80
+ for (let i = 0; i < tools.length; i++) {
81
+ const t = tools[i]!;
82
+ onProgress?.({ total: tools.length, current: i + 1, tool: `${t.server}/${t.tool.name}` });
83
+ indexed.push(await indexTool(t));
84
+ }
85
+
86
+ return {
87
+ version: 1,
88
+ indexed_at: new Date().toISOString(),
89
+ embedding_model: "Xenova/all-MiniLM-L6-v2",
90
+ tools: indexed,
91
+ };
92
+ }
@@ -0,0 +1,86 @@
1
+ import picomatch from "picomatch";
2
+ import type { IndexedTool } from "../config/schemas.ts";
3
+
4
+ export interface KeywordMatch {
5
+ server: string;
6
+ tool: string;
7
+ description: string;
8
+ score: number;
9
+ matchedField: string;
10
+ }
11
+
12
+ interface FieldWeight {
13
+ field: string;
14
+ weight: number;
15
+ values: (t: IndexedTool) => string[];
16
+ }
17
+
18
+ const FIELDS: FieldWeight[] = [
19
+ { field: "name", weight: 1.0, values: (t) => [t.tool] },
20
+ { field: "keyword", weight: 0.8, values: (t) => t.keywords },
21
+ { field: "scenario", weight: 0.6, values: (t) => t.scenarios },
22
+ { field: "description", weight: 0.4, values: (t) => [t.description] },
23
+ ];
24
+
25
+ /** Check if query looks like a glob pattern */
26
+ function isGlob(query: string): boolean {
27
+ return /[*?[\]{}]/.test(query);
28
+ }
29
+
30
+ /** Search indexed tools by keyword/glob matching */
31
+ export function keywordSearch(query: string, tools: IndexedTool[]): KeywordMatch[] {
32
+ const queryLower = query.toLowerCase();
33
+ const tokens = queryLower.split(/\s+/).filter(Boolean);
34
+
35
+ // If any token is a glob, use picomatch for name matching
36
+ const globTokens = tokens.filter(isGlob);
37
+ const textTokens = tokens.filter((t) => !isGlob(t));
38
+ const globMatcher = globTokens.length > 0 ? picomatch(globTokens, { nocase: true }) : null;
39
+
40
+ const results: KeywordMatch[] = [];
41
+
42
+ for (const tool of tools) {
43
+ let bestScore = 0;
44
+ let bestField = "";
45
+
46
+ // Glob matching against tool name
47
+ if (globMatcher && globMatcher(tool.tool)) {
48
+ bestScore = 1.0;
49
+ bestField = "name";
50
+ }
51
+
52
+ // Text token matching against all fields
53
+ if (textTokens.length > 0) {
54
+ for (const { field, weight, values } of FIELDS) {
55
+ const fieldValues = values(tool).map((v) => v.toLowerCase());
56
+ let matchCount = 0;
57
+
58
+ for (const token of textTokens) {
59
+ if (fieldValues.some((v) => v.includes(token))) {
60
+ matchCount++;
61
+ }
62
+ }
63
+
64
+ if (matchCount > 0) {
65
+ const score = (matchCount / textTokens.length) * weight;
66
+ if (score > bestScore) {
67
+ bestScore = score;
68
+ bestField = field;
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ if (bestScore > 0) {
75
+ results.push({
76
+ server: tool.server,
77
+ tool: tool.tool,
78
+ description: tool.description,
79
+ score: bestScore,
80
+ matchedField: bestField,
81
+ });
82
+ }
83
+ }
84
+
85
+ return results.sort((a, b) => b.score - a.score);
86
+ }
@@ -0,0 +1,75 @@
1
+ import type { IndexedTool } from "../config/schemas.ts";
2
+
3
+ export interface SemanticMatch {
4
+ server: string;
5
+ tool: string;
6
+ description: string;
7
+ score: number;
8
+ }
9
+
10
+ // Lazy-loaded pipeline singleton
11
+ let pipelineInstance: ((text: string) => Promise<Float32Array>) | null = null;
12
+
13
+ /** Get or create the embedding pipeline */
14
+ async function getEmbedder(): Promise<(text: string) => Promise<Float32Array>> {
15
+ if (pipelineInstance) return pipelineInstance;
16
+
17
+ const { pipeline } = await import("@huggingface/transformers");
18
+ const extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
19
+ dtype: "fp32",
20
+ });
21
+
22
+ pipelineInstance = async (text: string): Promise<Float32Array> => {
23
+ const output = await extractor(text, { pooling: "mean", normalize: true });
24
+ // output.data is a Float32Array of the pooled embedding
25
+ return output.data as Float32Array;
26
+ };
27
+
28
+ return pipelineInstance;
29
+ }
30
+
31
+ /** Generate an embedding vector for text */
32
+ export async function generateEmbedding(text: string): Promise<number[]> {
33
+ const embed = await getEmbedder();
34
+ const vec = await embed(text);
35
+ return Array.from(vec);
36
+ }
37
+
38
+ /** Cosine similarity between two vectors */
39
+ export function cosineSimilarity(a: number[], b: number[]): number {
40
+ if (a.length !== b.length || a.length === 0) return 0;
41
+
42
+ let dot = 0;
43
+ let magA = 0;
44
+ let magB = 0;
45
+ for (let i = 0; i < a.length; i++) {
46
+ dot += a[i]! * b[i]!;
47
+ magA += a[i]! * a[i]!;
48
+ magB += b[i]! * b[i]!;
49
+ }
50
+
51
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
52
+ return denom === 0 ? 0 : dot / denom;
53
+ }
54
+
55
+ /** Search indexed tools by semantic similarity */
56
+ export async function semanticSearch(
57
+ query: string,
58
+ tools: IndexedTool[],
59
+ topK = 10,
60
+ ): Promise<SemanticMatch[]> {
61
+ // Only search tools that have embeddings
62
+ const withEmbeddings = tools.filter((t) => t.embedding.length > 0);
63
+ if (withEmbeddings.length === 0) return [];
64
+
65
+ const queryEmbedding = await generateEmbedding(query);
66
+
67
+ const scored = withEmbeddings.map((tool) => ({
68
+ server: tool.server,
69
+ tool: tool.tool,
70
+ description: tool.description,
71
+ score: cosineSimilarity(queryEmbedding, tool.embedding),
72
+ }));
73
+
74
+ return scored.sort((a, b) => b.score - a.score).slice(0, topK);
75
+ }
@@ -0,0 +1,8 @@
1
+ import type { SearchIndex, ServersFile } from "../config/schemas.ts";
2
+
3
+ /** Return server names that appear in the index but not in the current config */
4
+ export function getStaleServers(index: SearchIndex, servers: ServersFile): string[] {
5
+ const configured = new Set(Object.keys(servers.mcpServers));
6
+ const indexed = new Set(index.tools.map((t) => t.server));
7
+ return [...indexed].filter((s) => !configured.has(s));
8
+ }
@@ -0,0 +1,103 @@
1
+ import Ajv, { type ErrorObject } from "ajv";
2
+ import type { Tool } from "../config/schemas.ts";
3
+
4
+ const ajv = new Ajv({ allErrors: true, strict: false });
5
+
6
+ // Cache compiled validators by a key of "server/tool"
7
+ const validatorCache = new Map<string, ReturnType<typeof ajv.compile>>();
8
+
9
+ export interface ValidationError {
10
+ path: string;
11
+ message: string;
12
+ }
13
+
14
+ export interface ValidationResult {
15
+ valid: boolean;
16
+ errors: ValidationError[];
17
+ }
18
+
19
+ /** Validate tool arguments against the tool's inputSchema */
20
+ export function validateToolInput(
21
+ serverName: string,
22
+ tool: Tool,
23
+ input: Record<string, unknown>,
24
+ ): ValidationResult {
25
+ const schema = tool.inputSchema;
26
+ if (!schema || Object.keys(schema).length === 0) {
27
+ return { valid: true, errors: [] };
28
+ }
29
+
30
+ const cacheKey = `${serverName}/${tool.name}`;
31
+ let validate = validatorCache.get(cacheKey);
32
+
33
+ if (!validate) {
34
+ try {
35
+ validate = ajv.compile(schema);
36
+ validatorCache.set(cacheKey, validate);
37
+ } catch {
38
+ // If schema can't be compiled, skip validation
39
+ return { valid: true, errors: [] };
40
+ }
41
+ }
42
+
43
+ const valid = validate(input);
44
+ if (valid) {
45
+ return { valid: true, errors: [] };
46
+ }
47
+
48
+ const errors = (validate.errors ?? []).map(formatAjvError);
49
+ return { valid: false, errors };
50
+ }
51
+
52
+ /** Validate user-collected form data against an elicitation requestedSchema */
53
+ export function validateElicitationResponse(
54
+ schema: Record<string, unknown>,
55
+ input: Record<string, unknown>,
56
+ ): ValidationResult {
57
+ const cacheKey = `__elicitation__${JSON.stringify(schema)}`;
58
+ let validate = validatorCache.get(cacheKey);
59
+
60
+ if (!validate) {
61
+ try {
62
+ validate = ajv.compile(schema);
63
+ validatorCache.set(cacheKey, validate);
64
+ } catch {
65
+ return { valid: true, errors: [] };
66
+ }
67
+ }
68
+
69
+ const valid = validate(input);
70
+ if (valid) {
71
+ return { valid: true, errors: [] };
72
+ }
73
+
74
+ const errors = (validate.errors ?? []).map(formatAjvError);
75
+ return { valid: false, errors };
76
+ }
77
+
78
+ function formatAjvError(err: ErrorObject): ValidationError {
79
+ const path = err.instancePath
80
+ ? err.instancePath.replace(/^\//, "").replace(/\//g, ".")
81
+ : "(root)";
82
+
83
+ switch (err.keyword) {
84
+ case "required": {
85
+ const field = (err.params as { missingProperty: string }).missingProperty;
86
+ return { path: field, message: `missing required field "${field}"` };
87
+ }
88
+ case "type": {
89
+ const expected = (err.params as { type: string }).type;
90
+ return { path, message: `must be ${expected}` };
91
+ }
92
+ case "enum": {
93
+ const allowed = (err.params as { allowedValues: unknown[] }).allowedValues;
94
+ return { path, message: `must be one of: ${allowed.join(", ")}` };
95
+ }
96
+ case "additionalProperties": {
97
+ const extra = (err.params as { additionalProperty: string }).additionalProperty;
98
+ return { path: extra, message: `unknown property "${extra}"` };
99
+ }
100
+ default:
101
+ return { path, message: err.message ?? "validation failed" };
102
+ }
103
+ }