ubersearch 1.6.2 → 1.7.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 +4 -2
- package/package.json +5 -5
- package/src/bootstrap/container.ts +1 -15
- package/src/cli.ts +2 -1
- package/src/config/load.ts +2 -20
- package/src/core/credits/CreditManager.ts +18 -0
- package/src/core/docker/dockerComposeHelper.ts +7 -9
- package/src/core/docker/dockerLifecycleManager.ts +40 -32
- package/src/core/paths.ts +49 -8
- package/src/core/strategy/AllProvidersStrategy.ts +10 -16
- package/src/core/strategy/FirstSuccessStrategy.ts +5 -8
- package/src/mcp-server.ts +19 -18
- package/src/providers/searchxng.ts +1 -1
package/README.md
CHANGED
|
@@ -69,8 +69,10 @@ ubersearch "query" [options]
|
|
|
69
69
|
Options:
|
|
70
70
|
--json Output results as JSON
|
|
71
71
|
--engines engine1,engine2 Use specific engines
|
|
72
|
-
--strategy all|first-success Search strategy (default:
|
|
72
|
+
--strategy all|first-success Search strategy (default: first-success)
|
|
73
73
|
--limit number Max results per engine
|
|
74
|
+
--categories categories Categories filter for SearXNG (e.g., "it,science")
|
|
75
|
+
--config path Config file path
|
|
74
76
|
--include-raw Include raw provider responses
|
|
75
77
|
--help, -h Show help
|
|
76
78
|
health Run provider health checks (starts Docker-backed ones if needed)
|
|
@@ -286,7 +288,7 @@ tavily (10 results)
|
|
|
286
288
|
|
|
287
289
|
## Search Strategies
|
|
288
290
|
|
|
289
|
-
### All
|
|
291
|
+
### All
|
|
290
292
|
|
|
291
293
|
Queries all configured/enabled providers and combines results.
|
|
292
294
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ubersearch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"module": "src/index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"prepare": "git config core.hooksPath scripts/hooks",
|
|
44
|
-
"lint": "bunx biome check
|
|
45
|
-
"lint:fix": "bunx biome check --write
|
|
46
|
-
"format": "bunx biome format --write
|
|
44
|
+
"lint": "bunx biome check src/ test/ scripts/ docs/ *.json *.md",
|
|
45
|
+
"lint:fix": "bunx biome check --write src/ test/ scripts/ docs/ *.json *.md",
|
|
46
|
+
"format": "bunx biome format --write src/ test/ scripts/ docs/ *.json *.md",
|
|
47
47
|
"test": "SKIP_DOCKER_TESTS=true bun test --preload ./test/setup.ts test/",
|
|
48
48
|
"test:unit": "bun test --preload ./test/setup.ts test/unit/",
|
|
49
49
|
"test:integration": "bun test --preload ./test/setup.ts test/integration/",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"mcp": "bun run src/cli.ts mcp",
|
|
56
56
|
"mcp:test": "bun run scripts/test-mcp.ts",
|
|
57
57
|
"build": "bun run scripts/build.ts",
|
|
58
|
-
"build:binary": "bun build --compile --minify src/cli.ts --outfile dist/ubersearch",
|
|
58
|
+
"build:binary": "bun build --compile --minify src/cli.ts --outfile dist/ubersearch && bun run scripts/copy-searxng-assets.ts",
|
|
59
59
|
"semantic-release": "semantic-release",
|
|
60
60
|
"semantic-release:dry-run": "semantic-release --dry-run",
|
|
61
61
|
"release": "bun run build && bun run semantic-release"
|
|
@@ -11,7 +11,7 @@ import { CreditManager } from "../core/credits";
|
|
|
11
11
|
import { FileCreditStateProvider } from "../core/credits/FileCreditStateProvider";
|
|
12
12
|
import { createLogger } from "../core/logger";
|
|
13
13
|
import { UberSearchOrchestrator } from "../core/orchestrator";
|
|
14
|
-
import type {
|
|
14
|
+
import type { SearchProvider } from "../core/provider";
|
|
15
15
|
import { ProviderRegistry } from "../core/provider";
|
|
16
16
|
import { ProviderFactory } from "../core/provider/ProviderFactory";
|
|
17
17
|
import { ServiceKeys } from "../core/serviceKeys";
|
|
@@ -156,17 +156,3 @@ export async function bootstrapContainer(
|
|
|
156
156
|
function createProvider(engineConfig: EngineConfig): SearchProvider {
|
|
157
157
|
return ProviderFactory.createProvider(engineConfig, container);
|
|
158
158
|
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Helper function to check if a provider implements ILifecycleProvider
|
|
162
|
-
*/
|
|
163
|
-
export function isLifecycleProvider(provider: unknown): provider is ILifecycleProvider {
|
|
164
|
-
return (
|
|
165
|
-
provider != null &&
|
|
166
|
-
typeof provider === "object" &&
|
|
167
|
-
"init" in provider &&
|
|
168
|
-
typeof provider.init === "function" &&
|
|
169
|
-
"healthcheck" in provider &&
|
|
170
|
-
typeof provider.healthcheck === "function"
|
|
171
|
-
);
|
|
172
|
-
}
|
package/src/cli.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* Unified search interface for multiple search providers
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { bootstrapContainer
|
|
8
|
+
import { bootstrapContainer } from "./bootstrap/container";
|
|
9
9
|
import type { ProviderRegistry } from "./core/provider";
|
|
10
10
|
import { ServiceKeys } from "./core/serviceKeys";
|
|
11
11
|
import { serve as serveMcp } from "./mcp-server";
|
|
12
|
+
import { isLifecycleProvider } from "./plugin/types.js";
|
|
12
13
|
import type { UberSearchOutput } from "./tool/interface";
|
|
13
14
|
import { getCreditStatus, uberSearch } from "./tool/uberSearchTool";
|
|
14
15
|
|
package/src/config/load.ts
CHANGED
|
@@ -14,28 +14,11 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { dirname, isAbsolute, join } from "node:path";
|
|
17
|
-
import {
|
|
17
|
+
import { getBundledSearxngComposePath } from "../core/paths";
|
|
18
18
|
import { PluginRegistry, registerBuiltInPlugins } from "../plugin";
|
|
19
19
|
import type { ConfigFactory, ExtendedSearchConfig } from "./defineConfig";
|
|
20
20
|
import { formatValidationErrors, validateConfigSafe } from "./validation";
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
* Get package root directory
|
|
24
|
-
* Handles both development (src/) and bundled (dist/) environments
|
|
25
|
-
*/
|
|
26
|
-
function getPackageRoot(): string {
|
|
27
|
-
const currentFile = fileURLToPath(import.meta.url);
|
|
28
|
-
const currentDir = dirname(currentFile);
|
|
29
|
-
|
|
30
|
-
// Check if we're in dist/ or src/
|
|
31
|
-
if (currentDir.includes("/dist")) {
|
|
32
|
-
// Bundled: dist/cli.js -> go up 1 level
|
|
33
|
-
return dirname(currentDir);
|
|
34
|
-
}
|
|
35
|
-
// Development: src/config/load.ts -> go up 2 levels
|
|
36
|
-
return dirname(dirname(currentDir));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
22
|
/** Supported config file extensions */
|
|
40
23
|
const CONFIG_EXTENSIONS = [".ts", ".json"] as const;
|
|
41
24
|
|
|
@@ -210,8 +193,7 @@ function getDefaultConfig(): ExtendedSearchConfig {
|
|
|
210
193
|
const defaultEngineOrder: string[] = [];
|
|
211
194
|
|
|
212
195
|
// Always add SearXNG first - it's free and unlimited (requires Docker)
|
|
213
|
-
const
|
|
214
|
-
const composeFile = join(packageRoot, "providers", "searxng", "docker-compose.yml");
|
|
196
|
+
const composeFile = getBundledSearxngComposePath();
|
|
215
197
|
|
|
216
198
|
defaultEngineOrder.push("searchxng");
|
|
217
199
|
engines.push({
|
|
@@ -100,6 +100,24 @@ export class CreditManager {
|
|
|
100
100
|
return result;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Refund credits for a failed search
|
|
105
|
+
* @note This does NOT persist the state - call saveState() separately
|
|
106
|
+
*/
|
|
107
|
+
refund(engineId: EngineId): void {
|
|
108
|
+
const config = this.engines.get(engineId);
|
|
109
|
+
if (!config) {
|
|
110
|
+
throw new Error(`Unknown engine: ${engineId}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const record = this.state[engineId];
|
|
114
|
+
if (!record) {
|
|
115
|
+
throw new Error(`No credit record for engine: ${engineId}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
record.used = Math.max(0, record.used - config.creditCostPerSearch);
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
/**
|
|
104
122
|
* Check if engine has sufficient credits
|
|
105
123
|
*/
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
* Manages Docker Compose services for local providers
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
11
|
import { getSearxngPaths } from "../paths";
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Get or generate a persistent SearXNG secret key
|
|
@@ -30,9 +30,7 @@ function getSearxngSecret(configDir: string): string {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// Generate a new secret using crypto
|
|
33
|
-
const secret = [...Array(64)]
|
|
34
|
-
.map(() => Math.floor(Math.random() * 16).toString(16))
|
|
35
|
-
.join("");
|
|
33
|
+
const secret = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join("");
|
|
36
34
|
try {
|
|
37
35
|
writeFileSync(secretFile, secret, { mode: 0o600 });
|
|
38
36
|
} catch {
|
|
@@ -59,15 +57,15 @@ export class DockerComposeHelper {
|
|
|
59
57
|
args: string[],
|
|
60
58
|
options: DockerComposeOptions = {},
|
|
61
59
|
): Promise<string> {
|
|
62
|
-
const cmd = `docker compose -f "${this.composeFile}" ${args.join(" ")}`;
|
|
63
60
|
const cwd = options.cwd || this.getComposeDir();
|
|
64
61
|
const timeout = options.timeout || 30000;
|
|
62
|
+
const cmdArgs = ["compose", "-f", this.composeFile, ...args];
|
|
65
63
|
|
|
66
64
|
try {
|
|
67
65
|
// Get SearXNG paths and ensure directories exist
|
|
68
66
|
const { configDir, dataDir } = getSearxngPaths();
|
|
69
67
|
|
|
70
|
-
const { stdout, stderr } = await
|
|
68
|
+
const { stdout, stderr } = await execFileAsync("docker", cmdArgs, {
|
|
71
69
|
cwd,
|
|
72
70
|
timeout,
|
|
73
71
|
env: {
|
|
@@ -90,7 +88,7 @@ export class DockerComposeHelper {
|
|
|
90
88
|
|
|
91
89
|
const errorDetails = [
|
|
92
90
|
`Docker Compose command ${timedOut ? "timed out" : "failed"}: ${errorMessage}`,
|
|
93
|
-
`Command: ${
|
|
91
|
+
`Command: docker compose -f ${this.composeFile} ${args.join(" ")}`,
|
|
94
92
|
];
|
|
95
93
|
if (err.stdout) {
|
|
96
94
|
errorDetails.push(`Output: ${err.stdout}`);
|
|
@@ -192,7 +190,7 @@ export class DockerComposeHelper {
|
|
|
192
190
|
*/
|
|
193
191
|
static async isDockerAvailable(): Promise<boolean> {
|
|
194
192
|
try {
|
|
195
|
-
await
|
|
193
|
+
await execFileAsync("docker", ["version"], { timeout: 5000 });
|
|
196
194
|
return true;
|
|
197
195
|
} catch {
|
|
198
196
|
return false;
|
|
@@ -63,21 +63,20 @@ export class DockerLifecycleManager {
|
|
|
63
63
|
* @throws Error if container startup fails
|
|
64
64
|
*/
|
|
65
65
|
async init(): Promise<void> {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// Atomic check-and-set pattern to prevent race conditions
|
|
67
|
+
if (!this.initPromise) {
|
|
68
|
+
if (!this.config.autoStart || !this.dockerHelper) {
|
|
69
|
+
this.initialized = true;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
this.initPromise = this.performInit().catch((error) => {
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
75
|
+
log.debug("Initialization failed:", message);
|
|
76
|
+
this.initialized = false;
|
|
77
|
+
throw error;
|
|
78
|
+
});
|
|
73
79
|
}
|
|
74
|
-
|
|
75
|
-
this.initPromise = this.performInit().catch((error) => {
|
|
76
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
-
log.debug("Initialization failed:", message);
|
|
78
|
-
this.initialized = false;
|
|
79
|
-
throw error;
|
|
80
|
-
});
|
|
81
80
|
return this.initPromise;
|
|
82
81
|
}
|
|
83
82
|
|
|
@@ -162,20 +161,20 @@ export class DockerLifecycleManager {
|
|
|
162
161
|
// Try health endpoint first - works for any running instance
|
|
163
162
|
// (even if started manually, via different docker-compose, or k8s, etc.)
|
|
164
163
|
if (this.config.healthEndpoint) {
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
165
166
|
try {
|
|
166
|
-
const controller = new AbortController();
|
|
167
|
-
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
168
|
-
|
|
169
167
|
const response = await fetch(this.config.healthEndpoint, {
|
|
170
168
|
signal: controller.signal,
|
|
171
169
|
});
|
|
172
170
|
|
|
173
|
-
clearTimeout(timeoutId);
|
|
174
171
|
if (response.ok) {
|
|
175
172
|
return true;
|
|
176
173
|
}
|
|
177
174
|
} catch {
|
|
178
175
|
// Health endpoint failed, fall through to Docker check
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timeoutId);
|
|
179
178
|
}
|
|
180
179
|
}
|
|
181
180
|
|
|
@@ -269,6 +268,23 @@ export class DockerLifecycleManager {
|
|
|
269
268
|
const errors: string[] = [];
|
|
270
269
|
const warnings: string[] = [];
|
|
271
270
|
|
|
271
|
+
// Validate config fields first (these don't require Docker)
|
|
272
|
+
|
|
273
|
+
// Check health endpoint if configured
|
|
274
|
+
if (this.config.healthEndpoint) {
|
|
275
|
+
try {
|
|
276
|
+
// Try to parse the URL
|
|
277
|
+
new URL(this.config.healthEndpoint);
|
|
278
|
+
} catch {
|
|
279
|
+
warnings.push(`Health endpoint URL is invalid: ${this.config.healthEndpoint}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Check container name if specified
|
|
284
|
+
if (this.config.containerName && !/^[a-zA-Z0-9_-]+$/.test(this.config.containerName)) {
|
|
285
|
+
warnings.push(`Container name contains invalid characters: ${this.config.containerName}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
272
288
|
// Check Docker is available
|
|
273
289
|
const dockerAvailable = await DockerComposeHelper.isDockerAvailable();
|
|
274
290
|
if (!dockerAvailable) {
|
|
@@ -297,21 +313,6 @@ export class DockerLifecycleManager {
|
|
|
297
313
|
}
|
|
298
314
|
}
|
|
299
315
|
|
|
300
|
-
// Check health endpoint if configured
|
|
301
|
-
if (this.config.healthEndpoint) {
|
|
302
|
-
try {
|
|
303
|
-
// Try to parse the URL
|
|
304
|
-
new URL(this.config.healthEndpoint);
|
|
305
|
-
} catch {
|
|
306
|
-
warnings.push(`Health endpoint URL is invalid: ${this.config.healthEndpoint}`);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Check container name if specified
|
|
311
|
-
if (this.config.containerName && !/^[a-zA-Z0-9_-]+$/.test(this.config.containerName)) {
|
|
312
|
-
warnings.push(`Container name contains invalid characters: ${this.config.containerName}`);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
316
|
return {
|
|
316
317
|
valid: errors.length === 0,
|
|
317
318
|
errors,
|
|
@@ -333,6 +334,13 @@ export class DockerLifecycleManager {
|
|
|
333
334
|
return this.initialized;
|
|
334
335
|
}
|
|
335
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Check if initialization is currently in progress
|
|
339
|
+
*/
|
|
340
|
+
isInitializing(): boolean {
|
|
341
|
+
return this.initPromise !== null;
|
|
342
|
+
}
|
|
343
|
+
|
|
336
344
|
/**
|
|
337
345
|
* Check if container is running
|
|
338
346
|
*
|
package/src/core/paths.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { dirname, join } from "node:path";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
|
|
13
13
|
const APP_NAME = "ubersearch";
|
|
@@ -108,17 +108,58 @@ export function getPackageRoot(): string {
|
|
|
108
108
|
return dirname(dirname(currentDir));
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Get candidate runtime roots where bundled SearXNG assets may live.
|
|
113
|
+
*
|
|
114
|
+
* This covers:
|
|
115
|
+
* - source checkouts (`<root>/providers/searxng`)
|
|
116
|
+
* - built JS output (`<root>/dist/providers/searxng`)
|
|
117
|
+
* - compiled binaries with copied assets next to the executable
|
|
118
|
+
*/
|
|
119
|
+
function getRuntimeRoots(): string[] {
|
|
120
|
+
const roots = new Set<string>();
|
|
121
|
+
|
|
122
|
+
roots.add(resolve(getPackageRoot()));
|
|
123
|
+
roots.add(resolve(dirname(process.execPath)));
|
|
124
|
+
roots.add(resolve(process.cwd()));
|
|
125
|
+
|
|
126
|
+
return [...roots];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getBundledSearxngAssetCandidates(relativePath: string): string[] {
|
|
130
|
+
const candidates = new Set<string>();
|
|
131
|
+
|
|
132
|
+
for (const root of getRuntimeRoots()) {
|
|
133
|
+
candidates.add(join(root, "providers", "searxng", relativePath));
|
|
134
|
+
candidates.add(join(root, "dist", "providers", "searxng", relativePath));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...candidates];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolveBundledSearxngAsset(relativePath: string): string {
|
|
141
|
+
const candidates = getBundledSearxngAssetCandidates(relativePath);
|
|
142
|
+
const existing = candidates.find((candidate) => existsSync(candidate));
|
|
143
|
+
|
|
144
|
+
if (existing) {
|
|
145
|
+
return existing;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return candidates[0] ?? join(getPackageRoot(), "providers", "searxng", relativePath);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get the bundled SearXNG docker-compose file path.
|
|
153
|
+
*/
|
|
154
|
+
export function getBundledSearxngComposePath(): string {
|
|
155
|
+
return resolveBundledSearxngAsset("docker-compose.yml");
|
|
156
|
+
}
|
|
157
|
+
|
|
111
158
|
/**
|
|
112
159
|
* Get the bundled default settings.yml path
|
|
113
160
|
*/
|
|
114
161
|
export function getDefaultSettingsPath(): string {
|
|
115
|
-
|
|
116
|
-
// In bundled mode, look in dist/providers/searxng/
|
|
117
|
-
// In dev mode, look in providers/searxng/
|
|
118
|
-
if (packageRoot.endsWith("/dist")) {
|
|
119
|
-
return join(packageRoot, "providers", "searxng", "config", "settings.yml");
|
|
120
|
-
}
|
|
121
|
-
return join(packageRoot, "providers", "searxng", "config", "settings.yml");
|
|
162
|
+
return resolveBundledSearxngAsset(join("config", "settings.yml"));
|
|
122
163
|
}
|
|
123
164
|
|
|
124
165
|
/**
|
|
@@ -81,8 +81,8 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
81
81
|
continue;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
//
|
|
85
|
-
if (!context.creditManager.
|
|
84
|
+
// Charge credits before search (atomic check+deduct to avoid race conditions)
|
|
85
|
+
if (!context.creditManager.charge(engineId)) {
|
|
86
86
|
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
87
87
|
continue;
|
|
88
88
|
}
|
|
@@ -98,13 +98,6 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
98
98
|
}),
|
|
99
99
|
);
|
|
100
100
|
|
|
101
|
-
// Deduct credits
|
|
102
|
-
if (!context.creditManager.charge(engineId)) {
|
|
103
|
-
// This shouldn't happen because we checked above, but handle gracefully
|
|
104
|
-
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
101
|
// Record success
|
|
109
102
|
attempts.push({ engineId, success: true });
|
|
110
103
|
|
|
@@ -115,6 +108,9 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
115
108
|
results.push(...response.items);
|
|
116
109
|
}
|
|
117
110
|
} catch (error) {
|
|
111
|
+
// Refund credits for failed search
|
|
112
|
+
context.creditManager.refund(engineId);
|
|
113
|
+
|
|
118
114
|
// Record failure
|
|
119
115
|
if (error instanceof SearchError) {
|
|
120
116
|
attempts.push({ engineId, success: false, reason: error.reason });
|
|
@@ -163,7 +159,8 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
163
159
|
continue;
|
|
164
160
|
}
|
|
165
161
|
|
|
166
|
-
|
|
162
|
+
// Charge credits before search (atomic check+deduct to avoid race conditions)
|
|
163
|
+
if (!context.creditManager.charge(engineId)) {
|
|
167
164
|
ineligibleAttempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
168
165
|
continue;
|
|
169
166
|
}
|
|
@@ -215,6 +212,9 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
215
212
|
const { response, error } = settledResult.value;
|
|
216
213
|
|
|
217
214
|
if (error) {
|
|
215
|
+
// Refund credits for failed search
|
|
216
|
+
context.creditManager.refund(engineId);
|
|
217
|
+
|
|
218
218
|
// Search threw an error
|
|
219
219
|
if (error instanceof SearchError) {
|
|
220
220
|
attempts.push({ engineId, success: false, reason: error.reason });
|
|
@@ -226,12 +226,6 @@ export class AllProvidersStrategy implements ISearchStrategy {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
if (response) {
|
|
229
|
-
// Deduct credits
|
|
230
|
-
if (!context.creditManager.charge(engineId)) {
|
|
231
|
-
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
229
|
// Record success
|
|
236
230
|
attempts.push({ engineId, success: true });
|
|
237
231
|
|
|
@@ -53,8 +53,8 @@ export class FirstSuccessStrategy implements ISearchStrategy {
|
|
|
53
53
|
continue;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
//
|
|
57
|
-
if (!context.creditManager.
|
|
56
|
+
// Charge credits before search (atomic check+deduct to avoid race conditions)
|
|
57
|
+
if (!context.creditManager.charge(engineId)) {
|
|
58
58
|
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
59
59
|
continue;
|
|
60
60
|
}
|
|
@@ -70,18 +70,15 @@ export class FirstSuccessStrategy implements ISearchStrategy {
|
|
|
70
70
|
}),
|
|
71
71
|
);
|
|
72
72
|
|
|
73
|
-
// Deduct credits
|
|
74
|
-
if (!context.creditManager.charge(engineId)) {
|
|
75
|
-
attempts.push({ engineId, success: false, reason: "out_of_credit" });
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
73
|
// Record success
|
|
80
74
|
attempts.push({ engineId, success: true });
|
|
81
75
|
|
|
82
76
|
// Return immediately with results from this provider
|
|
83
77
|
return { results: response.items, attempts };
|
|
84
78
|
} catch (error) {
|
|
79
|
+
// Refund credits for failed search
|
|
80
|
+
context.creditManager.refund(engineId);
|
|
81
|
+
|
|
85
82
|
// Record failure and continue to next provider
|
|
86
83
|
if (error instanceof SearchError) {
|
|
87
84
|
attempts.push({ engineId, success: false, reason: error.reason });
|
package/src/mcp-server.ts
CHANGED
|
@@ -7,32 +7,27 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { bootstrapContainer, getCreditStatus, uberSearch } from "./app/index";
|
|
10
|
-
import { isLifecycleProvider } from "./bootstrap/container";
|
|
11
10
|
import type { ProviderRegistry } from "./core/provider";
|
|
12
11
|
import { ServiceKeys } from "./core/serviceKeys";
|
|
12
|
+
import { isLifecycleProvider } from "./plugin/types.js";
|
|
13
13
|
|
|
14
14
|
interface MCPRequest {
|
|
15
15
|
jsonrpc: string;
|
|
16
16
|
id?: number | string;
|
|
17
17
|
method: string;
|
|
18
|
-
params?:
|
|
18
|
+
params?: Record<string, unknown>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
interface MCPTool {
|
|
22
22
|
name: string;
|
|
23
23
|
description: string;
|
|
24
|
-
inputSchema:
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface MCPToolCallParams {
|
|
28
|
-
name: string;
|
|
29
|
-
arguments: Record<string, any>;
|
|
24
|
+
inputSchema: Record<string, unknown>;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
interface MCPResponse {
|
|
33
28
|
jsonrpc: string;
|
|
34
29
|
id?: number | string;
|
|
35
|
-
result?:
|
|
30
|
+
result?: unknown;
|
|
36
31
|
error?: {
|
|
37
32
|
code?: number;
|
|
38
33
|
message: string;
|
|
@@ -168,23 +163,29 @@ Example: "it,science" for tech and academic results`,
|
|
|
168
163
|
}
|
|
169
164
|
|
|
170
165
|
if (request.method === "tools/call") {
|
|
171
|
-
const
|
|
166
|
+
const params = request.params ?? {};
|
|
167
|
+
const name = String(params.name ?? "");
|
|
168
|
+
const args =
|
|
169
|
+
typeof params.arguments === "object" && params.arguments !== null
|
|
170
|
+
? (params.arguments as Record<string, string>)
|
|
171
|
+
: ({} as Record<string, string>);
|
|
172
172
|
|
|
173
173
|
try {
|
|
174
|
-
let result:
|
|
174
|
+
let result: unknown;
|
|
175
175
|
if (name === "uber_search") {
|
|
176
|
-
const engines = args.engines
|
|
177
|
-
? args.engines.split(",").map((e: string) => e.trim())
|
|
178
|
-
: undefined;
|
|
176
|
+
const engines = args.engines ? args.engines.split(",").map((e) => e.trim()) : undefined;
|
|
179
177
|
const categories = args.categories
|
|
180
|
-
? args.categories.split(",").map((c
|
|
178
|
+
? args.categories.split(",").map((c) => c.trim())
|
|
181
179
|
: undefined;
|
|
182
180
|
result = await withTimeout(
|
|
183
181
|
uberSearch({
|
|
184
|
-
query: args.query,
|
|
185
|
-
limit: args.limit,
|
|
182
|
+
query: args.query ?? "",
|
|
183
|
+
limit: args.limit ? Number(args.limit) : undefined,
|
|
186
184
|
engines,
|
|
187
|
-
strategy:
|
|
185
|
+
strategy:
|
|
186
|
+
args.strategy === "all" || args.strategy === "first-success"
|
|
187
|
+
? args.strategy
|
|
188
|
+
: undefined,
|
|
188
189
|
categories,
|
|
189
190
|
}),
|
|
190
191
|
60000,
|
|
@@ -79,7 +79,7 @@ export class SearchxngProvider
|
|
|
79
79
|
let isHealthy = await this.healthcheck();
|
|
80
80
|
|
|
81
81
|
if (!isHealthy) {
|
|
82
|
-
const isInitializing =
|
|
82
|
+
const isInitializing = this.lifecycleManager.isInitializing();
|
|
83
83
|
if (!isInitializing) {
|
|
84
84
|
try {
|
|
85
85
|
log.debug("Container not healthy, attempting auto-start...");
|