@oh-my-tool/cli 0.3.1 → 0.3.3

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.
@@ -20,15 +20,26 @@ export function parseManifest(raw: string): ExtensionManifest {
20
20
  }
21
21
 
22
22
  export function validateManifest(manifest: ExtensionManifest): void {
23
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(manifest.id)) {
24
+ throw new ManifestError("manifest 'id' must contain only lowercase letters, numbers, underscores, and hyphens");
25
+ }
23
26
  if (!manifest.name || typeof manifest.name !== "string") {
24
27
  throw new ManifestError("manifest must contain a string 'name'");
25
28
  }
26
29
  if (!manifest.version || typeof manifest.version !== "string") {
27
30
  throw new ManifestError("manifest must contain a string 'version'");
28
31
  }
32
+ if (!FULL_VERSION_RE.test(manifest.version)) {
33
+ throw new ManifestError(`invalid manifest version '${manifest.version}'`);
34
+ }
29
35
  if (!manifest.sdkVersion || typeof manifest.sdkVersion !== "string") {
30
36
  throw new ManifestError("manifest must contain a string 'sdkVersion'");
31
37
  }
38
+ if (manifest.connectionSchema !== undefined && (
39
+ manifest.connectionSchema === null || typeof manifest.connectionSchema !== "object" || Array.isArray(manifest.connectionSchema)
40
+ )) {
41
+ throw new ManifestError("manifest 'connectionSchema' must be an object");
42
+ }
32
43
 
33
44
  const seen = new Set<string>();
34
45
  for (const tool of manifest.tools) {
@@ -47,6 +58,19 @@ export function validateManifest(manifest: ExtensionManifest): void {
47
58
  );
48
59
  }
49
60
  }
61
+
62
+ if (manifest.connectionCheckTool !== undefined) {
63
+ if (typeof manifest.connectionCheckTool !== "string" || manifest.connectionCheckTool.length === 0) {
64
+ throw new ManifestError("manifest 'connectionCheckTool' must be a non-empty string");
65
+ }
66
+ const checkTool = manifest.tools.find((tool) => tool.name === manifest.connectionCheckTool);
67
+ if (!checkTool || !manifest.connectionCheckTool.startsWith(`${manifest.id}.`)) {
68
+ throw new ManifestError("manifest 'connectionCheckTool' must name a declared tool prefixed by the extension id");
69
+ }
70
+ if ((checkTool.risk ?? "read") !== "read") {
71
+ throw new ManifestError("manifest 'connectionCheckTool' must be read-only");
72
+ }
73
+ }
50
74
  }
51
75
 
52
76
  const FULL_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
@@ -112,4 +136,4 @@ export function validateHandlers(
112
136
  );
113
137
  }
114
138
  }
115
- }
139
+ }
@@ -274,17 +274,17 @@ export function createIntegrationManager(options: IntegrationManagerOptions) {
274
274
  const recorded = records[agent.id];
275
275
  if (!recorded) {
276
276
  if (!pathPresent(agent.target)) {
277
- return result(agent, "not-installed", "not installed; run `omt integrate` to install");
277
+ return result(agent, "not-installed", "not installed; run `ohmytool integrate` to install");
278
278
  }
279
279
  return result(agent, "conflict", "target exists but is not managed by OMT");
280
280
  }
281
281
  try {
282
282
  assertSafeManagedState(agent, recorded, options.omtHome, platform);
283
283
  } catch {
284
- return result(agent, "conflict", "recorded state is unsafe; run `omt integrate repair`");
284
+ return result(agent, "conflict", "recorded state is unsafe; run `ohmytool integrate repair`");
285
285
  }
286
286
  if (!pathPresent(agent.target) || !safeRealpath(agent.target)) {
287
- return result(agent, "broken", "link is missing; run `omt integrate repair`");
287
+ return result(agent, "broken", "link is missing; run `ohmytool integrate repair`");
288
288
  }
289
289
  if (!isManagedLink(agent.target, recorded.canonical)) {
290
290
  return result(agent, "conflict", "target was replaced by unmanaged content");
@@ -292,7 +292,7 @@ export function createIntegrationManager(options: IntegrationManagerOptions) {
292
292
  if (recorded.version === options.skillVersion) {
293
293
  return result(agent, "current");
294
294
  }
295
- return result(agent, "update-available", `new version ${options.skillVersion} available; run \`omt integrate\``);
295
+ return result(agent, "update-available", `new version ${options.skillVersion} available; run \`ohmytool integrate\``);
296
296
  });
297
297
  }
298
298
 
@@ -81,6 +81,8 @@ export function assertReadOnly(sql: string): void {
81
81
  }
82
82
 
83
83
  const FORBIDDEN_INPUT = new Set([
84
+ "settings",
85
+ "secrets",
84
86
  "host",
85
87
  "username",
86
88
  "password",
@@ -11,6 +11,22 @@ export interface ToolDescriptor {
11
11
  source: { id: string; kind: string; version?: string };
12
12
  }
13
13
 
14
+ export interface ToolSearchOptions {
15
+ readonly limit?: number;
16
+ readonly provider?: string;
17
+ readonly source?: string;
18
+ readonly risk?: ToolDescriptor["risk"];
19
+ }
20
+
21
+ export interface ProviderStatus {
22
+ readonly id: string;
23
+ readonly kind: string;
24
+ readonly status: "available" | "unavailable";
25
+ readonly code?: string;
26
+ readonly message?: string;
27
+ readonly namespace?: string;
28
+ }
29
+
14
30
  export type ToolSearchResult = Omit<ToolDescriptor, "inputSchema">;
15
31
 
16
32
  export interface ExecutionContext {
@@ -22,6 +38,7 @@ export interface ExecutionContext {
22
38
  export interface ToolProvider {
23
39
  readonly id: string;
24
40
  readonly kind: string;
41
+ readonly namespace?: string;
25
42
  listTools(): Promise<readonly ToolDescriptor[]>;
26
43
  execute(toolId: string, input: unknown, context: ExecutionContext): Promise<ToolResult>;
27
44
  close?(): Promise<void>;
@@ -33,11 +33,13 @@ import {
33
33
  export interface McpOAuthProviderOptions {
34
34
  readonly redirectUrl?: URL;
35
35
  readonly interactive?: boolean;
36
+ readonly forceDynamicRegistration?: boolean;
36
37
  }
37
38
 
38
39
  export interface McpOAuthClientProvider extends OAuthClientProvider {
39
40
  readonly redirectUrl: URL;
40
41
  readonly secretValues: readonly string[];
42
+ readonly clientConfigurationFingerprint: string;
41
43
  authorizationUrl(): URL | undefined;
42
44
  authorizationState(): string | undefined;
43
45
  clearVerifier(): Promise<void>;
@@ -65,6 +67,7 @@ function oauthAuthRequired(serverId: string): RuntimeError {
65
67
 
66
68
  class PersistentMcpOAuthProvider implements McpOAuthClientProvider {
67
69
  readonly clientMetadata: OAuthClientMetadata;
70
+ readonly clientConfigurationFingerprint: string;
68
71
  private pendingAuthorizationUrl: URL | undefined;
69
72
  private pendingState: string | undefined;
70
73
 
@@ -85,6 +88,12 @@ class PersistentMcpOAuthProvider implements McpOAuthClientProvider {
85
88
  client_name: "Oh My Tool",
86
89
  ...(config.auth.scopes.length === 0 ? {} : { scope: config.auth.scopes.join(" ") }),
87
90
  };
91
+ this.clientConfigurationFingerprint = JSON.stringify({
92
+ redirectUrl: redirectUrl.toString(),
93
+ clientId: config.auth.clientId ?? null,
94
+ clientSecretConfigured: config.auth.clientSecretSecret !== undefined,
95
+ metadata: this.clientMetadata,
96
+ });
88
97
  }
89
98
 
90
99
  state(): string {
@@ -172,6 +181,9 @@ export async function createMcpOAuthProvider(
172
181
  const interactive = options.interactive ?? false;
173
182
  const store = createMcpOAuthStore(serverId, secrets);
174
183
  if (!interactive && await store.tokens() === undefined) throw oauthAuthRequired(serverId);
184
+ if (options.forceDynamicRegistration === true && config.auth.clientId === undefined) {
185
+ await store.clear("client");
186
+ }
175
187
  let preRegisteredClient: StoredOAuthClientInformation | undefined;
176
188
  const secretValues: string[] = [];
177
189
  if (config.auth.clientId !== undefined) {
@@ -191,7 +203,7 @@ export async function createMcpOAuthProvider(
191
203
  client_secret: clientSecret,
192
204
  };
193
205
  }
194
- return new PersistentMcpOAuthProvider(
206
+ const provider = new PersistentMcpOAuthProvider(
195
207
  serverId,
196
208
  config,
197
209
  store,
@@ -200,6 +212,14 @@ export async function createMcpOAuthProvider(
200
212
  secretValues,
201
213
  interactive,
202
214
  );
215
+ const previousFingerprint = await store.clientConfiguration();
216
+ if (previousFingerprint !== provider.clientConfigurationFingerprint) {
217
+ if (previousFingerprint !== undefined) {
218
+ await store.clear("client");
219
+ }
220
+ }
221
+ await store.saveClientConfiguration(provider.clientConfigurationFingerprint);
222
+ return provider;
203
223
  }
204
224
 
205
225
  function oauthConfig(serverId: string, config: McpHttpServerConfig): OAuthMcpServerConfig {
@@ -287,6 +307,7 @@ export async function authorizeMcpServer(
287
307
  const activeProvider = await createMcpOAuthProvider(serverId, validated, secrets, {
288
308
  redirectUrl: callback.redirectUrl,
289
309
  interactive: true,
310
+ forceDynamicRegistration: true,
290
311
  });
291
312
  provider = activeProvider;
292
313
  firstClient = createClient({ name: "oh-my-tool", version: VERSION });
@@ -11,6 +11,8 @@ export interface McpOAuthStore {
11
11
  saveTokens(tokens: StoredOAuthTokens): Promise<void>;
12
12
  clientInformation(): Promise<StoredOAuthClientInformation | undefined>;
13
13
  saveClientInformation(info: StoredOAuthClientInformation): Promise<void>;
14
+ clientConfiguration(): Promise<string | undefined>;
15
+ saveClientConfiguration(fingerprint: string): Promise<void>;
14
16
  codeVerifier(): Promise<string | undefined>;
15
17
  saveCodeVerifier(value: string): Promise<void>;
16
18
  discoveryState(): Promise<OAuthDiscoveryState | undefined>;
@@ -19,13 +21,14 @@ export interface McpOAuthStore {
19
21
  clearAll(): Promise<void>;
20
22
  }
21
23
 
22
- type CredentialScope = "tokens" | "client" | "verifier" | "discovery";
24
+ type CredentialScope = "tokens" | "client" | "client-config" | "verifier" | "discovery";
23
25
 
24
26
  function credentialNames(serverId: string): Record<CredentialScope, string> {
25
27
  const prefix = `mcp:${serverId}:oauth`;
26
28
  return {
27
29
  tokens: `${prefix}:tokens`,
28
30
  client: `${prefix}:client`,
31
+ "client-config": `${prefix}:client-config`,
29
32
  verifier: `${prefix}:verifier`,
30
33
  discovery: `${prefix}:discovery`,
31
34
  };
@@ -72,6 +75,14 @@ export class SecretMcpOAuthStore implements McpOAuthStore {
72
75
  return this.secrets.set(this.names.client, JSON.stringify(info));
73
76
  }
74
77
 
78
+ clientConfiguration(): Promise<string | undefined> {
79
+ return this.secrets.get(this.names["client-config"]);
80
+ }
81
+
82
+ saveClientConfiguration(fingerprint: string): Promise<void> {
83
+ return this.secrets.set(this.names["client-config"], fingerprint);
84
+ }
85
+
75
86
  codeVerifier(): Promise<string | undefined> {
76
87
  return this.secrets.get(this.names.verifier);
77
88
  }
@@ -17,6 +17,7 @@ export interface McpProviderOptions {
17
17
  export class McpProvider implements ToolProvider {
18
18
  readonly id: string;
19
19
  readonly kind = "mcp";
20
+ readonly namespace: string;
20
21
  private session?: McpSession;
21
22
  private descriptors?: readonly ToolDescriptor[];
22
23
  private readonly routes = new Map<string, string>();
@@ -24,6 +25,7 @@ export class McpProvider implements ToolProvider {
24
25
 
25
26
  constructor(private readonly options: McpProviderOptions) {
26
27
  this.id = `mcp:${options.serverId}`;
28
+ this.namespace = options.config.namespace;
27
29
  }
28
30
 
29
31
  async listTools(): Promise<readonly ToolDescriptor[]> {
@@ -8,7 +8,15 @@ export class NativeExtensionProvider implements ToolProvider {
8
8
  readonly id = "native";
9
9
  readonly kind = "native";
10
10
 
11
- constructor(private readonly homeOrPaths: string | Pick<OhMyToolPaths, "home">) {}
11
+ private readonly discover: (home: string) => InstalledExtension[];
12
+ private snapshot?: { extensions: InstalledExtension[]; routes: Map<string, InstalledExtension> };
13
+
14
+ constructor(
15
+ private readonly homeOrPaths: string | Pick<OhMyToolPaths, "home">,
16
+ discover: (home: string) => InstalledExtension[] = discoverExtensions,
17
+ ) {
18
+ this.discover = discover;
19
+ }
12
20
 
13
21
  private home(): string {
14
22
  return typeof this.homeOrPaths === "string" ? this.homeOrPaths : this.homeOrPaths.home;
@@ -16,7 +24,7 @@ export class NativeExtensionProvider implements ToolProvider {
16
24
 
17
25
  async listTools(): Promise<readonly ToolDescriptor[]> {
18
26
  const descriptors: ToolDescriptor[] = [];
19
- for (const extension of discoverExtensions(this.home())) {
27
+ for (const extension of this.getSnapshot().extensions) {
20
28
  for (const tool of extension.manifest.tools) {
21
29
  descriptors.push({
22
30
  id: tool.name,
@@ -40,18 +48,30 @@ export class NativeExtensionProvider implements ToolProvider {
40
48
  }
41
49
 
42
50
  async execute(toolId: string, input: unknown, context: ExecutionContext): Promise<ToolResult> {
43
- const extension = this.findExtension(toolId);
51
+ const extension = this.getSnapshot().routes.get(toolId);
52
+ if (!extension) throw new Error(`unknown native tool '${toolId}'`);
44
53
  const definition = await loadExtension(extension);
45
54
  const handler = definition.handlers[toolId];
46
55
  if (!handler) throw new Error(`no handler for ${toolId}`);
47
56
  return handler({ toolName: toolId, logger: context.logger, config: context.config, secrets: context.secrets }, input);
48
57
  }
49
58
 
50
- private findExtension(toolId: string): InstalledExtension {
51
- const extension = discoverExtensions(this.home()).find((candidate) =>
52
- candidate.manifest.tools.some((tool) => tool.name === toolId),
53
- );
54
- if (!extension) throw new Error(`unknown native tool '${toolId}'`);
55
- return extension;
59
+ async hasTool(toolId: string): Promise<boolean> {
60
+ return this.getSnapshot().routes.has(toolId);
61
+ }
62
+
63
+ installedExtensions(): readonly InstalledExtension[] {
64
+ return this.getSnapshot().extensions;
65
+ }
66
+
67
+ private getSnapshot(): { extensions: InstalledExtension[]; routes: Map<string, InstalledExtension> } {
68
+ if (this.snapshot) return this.snapshot;
69
+ const extensions = this.discover(this.home());
70
+ const routes = new Map<string, InstalledExtension>();
71
+ for (const extension of extensions) {
72
+ for (const tool of extension.manifest.tools) routes.set(tool.name, extension);
73
+ }
74
+ this.snapshot = { extensions, routes };
75
+ return this.snapshot;
56
76
  }
57
77
  }
@@ -3,10 +3,17 @@ export interface ToolResult {
3
3
  meta?: Record<string, unknown>;
4
4
  }
5
5
 
6
- export interface ExecutionResult {
7
- ok: boolean;
6
+ export interface ExecutionOk {
7
+ ok: true;
8
8
  toolId: string;
9
- output?: unknown;
10
- meta?: Record<string, unknown>;
11
- error?: { code: string; message: string; details?: unknown };
9
+ output: unknown;
10
+ meta: Record<string, unknown>;
12
11
  }
12
+
13
+ export interface ExecutionError {
14
+ ok: false;
15
+ toolId: string;
16
+ error: { code: string; message: string; details?: unknown };
17
+ }
18
+
19
+ export type ExecutionResult = ExecutionOk | ExecutionError;
@@ -1,4 +1,4 @@
1
- import type { ExecutionContext, ToolDescriptor, ToolProvider, ToolSearchResult } from "./provider";
1
+ import type { ExecutionContext, ProviderStatus, ToolDescriptor, ToolProvider, ToolSearchOptions, ToolSearchResult } from "./provider";
2
2
  import type { ExecutionResult } from "./result";
3
3
  import { executeRuntimeTool, type CreateExecutionContext, type PolicyPreflight } from "./executor";
4
4
  import { RuntimeError } from "./errors";
@@ -20,23 +20,30 @@ interface RuntimeState {
20
20
 
21
21
  export class ToolRuntime {
22
22
  private closePromise?: Promise<void>;
23
+ private readonly discovery = new Map<string, Promise<void>>();
24
+ private readonly statuses = new Map<string, ProviderStatus>();
25
+ private readonly closedProviders = new Set<string>();
23
26
 
24
27
  constructor(private readonly state: RuntimeState, private readonly registeredProviders: readonly ToolProvider[] = []) {}
25
28
 
26
- search(query: string): Promise<ToolSearchResult[]> {
27
- return Promise.resolve(this.state.tools.search(query));
29
+ async search(query: string, options?: ToolSearchOptions): Promise<ToolSearchResult[]> {
30
+ await this.discoverAll();
31
+ return this.state.tools.search(query, options);
28
32
  }
29
33
 
30
- describe(toolId: string): Promise<ToolDescriptor> {
34
+ async describe(toolId: string): Promise<ToolDescriptor> {
35
+ await this.discoverForTarget(toolId);
31
36
  const descriptor = this.state.tools.get(toolId);
32
- if (!descriptor) return Promise.reject(new RuntimeError("TOOL_NOT_FOUND", `unknown tool '${toolId}'`));
33
- return Promise.resolve(descriptor);
37
+ if (!descriptor) throw this.targetError(toolId);
38
+ return descriptor;
34
39
  }
35
40
 
36
41
  async run(toolId: string, input: unknown): Promise<ExecutionResult> {
42
+ await this.discoverForTarget(toolId);
37
43
  const descriptor = this.state.tools.get(toolId);
38
44
  if (!descriptor) {
39
- return { ok: false, toolId, error: { code: "TOOL_NOT_FOUND", message: `unknown tool '${toolId}'` } };
45
+ const error = this.targetError(toolId);
46
+ return { ok: false, toolId, error: { code: error.code, message: error.message } };
40
47
  }
41
48
  const provider = this.state.providers.require(descriptor.provider.id);
42
49
  return executeRuntimeTool({
@@ -47,32 +54,45 @@ export class ToolRuntime {
47
54
  }, (input ?? {}) as Record<string, unknown>);
48
55
  }
49
56
 
57
+ providerStatuses(): readonly ProviderStatus[] {
58
+ return [...this.statuses.values()].sort((a, b) => a.id.localeCompare(b.id));
59
+ }
60
+
50
61
  close(): Promise<void> {
51
62
  if (this.closePromise) return this.closePromise;
52
63
  this.closePromise = (async () => {
53
64
  const errors: unknown[] = [];
54
65
  for (const provider of [...this.registeredProviders].reverse()) {
55
- if (!provider.close) continue;
56
- try { await provider.close(); } catch (error) { errors.push(error); }
66
+ try { await this.closeProvider(provider); } catch (error) { errors.push(error); }
57
67
  }
58
68
  if (errors.length > 0) throw errors[0];
59
69
  })();
60
70
  return this.closePromise;
61
71
  }
62
- }
63
72
 
64
- async function closeProviders(providers: readonly ToolProvider[]): Promise<void> {
65
- await Promise.allSettled([...providers].reverse().map(async (provider) => {
66
- if (provider.close) await provider.close();
67
- }));
68
- }
73
+ private async discoverAll(): Promise<void> {
74
+ await Promise.all(this.registeredProviders.map((provider) => this.discoverProvider(provider)));
75
+ }
69
76
 
70
- export async function createToolRuntime(options: ToolRuntimeOptions): Promise<ToolRuntime> {
71
- const providers = new ProviderRegistry();
72
- const tools = new ToolRegistry();
73
- try {
74
- for (const provider of options.providers) {
75
- providers.register(provider);
77
+ private async discoverForTarget(toolId: string): Promise<void> {
78
+ const nativeProviders = this.registeredProviders.filter((provider) => provider.kind === "native");
79
+ await Promise.all(nativeProviders.map((provider) => this.discoverProvider(provider)));
80
+ if (this.state.tools.get(toolId)) return;
81
+ await Promise.all(this.registeredProviders
82
+ .filter((provider) => provider.kind !== "native")
83
+ .map((provider) => this.discoverProvider(provider)));
84
+ }
85
+
86
+ private discoverProvider(provider: ToolProvider): Promise<void> {
87
+ const current = this.discovery.get(provider.id);
88
+ if (current) return current;
89
+ const promise = this.discoverProviderOnce(provider);
90
+ this.discovery.set(provider.id, promise);
91
+ return promise;
92
+ }
93
+
94
+ private async discoverProviderOnce(provider: ToolProvider): Promise<void> {
95
+ try {
76
96
  const descriptors = await provider.listTools();
77
97
  for (const descriptor of descriptors) {
78
98
  if (descriptor.provider.id !== provider.id || descriptor.provider.kind !== provider.kind) {
@@ -82,12 +102,46 @@ export async function createToolRuntime(options: ToolRuntimeOptions): Promise<To
82
102
  );
83
103
  }
84
104
  }
85
- tools.register(descriptors);
105
+ this.state.tools.register(descriptors);
106
+ this.statuses.set(provider.id, {
107
+ id: provider.id,
108
+ kind: provider.kind,
109
+ status: "available",
110
+ ...("namespace" in provider && typeof provider.namespace === "string" ? { namespace: provider.namespace } : {}),
111
+ });
112
+ } catch (error) {
113
+ if (provider.kind !== "mcp") throw error;
114
+ const typed = error as { code?: unknown; message?: unknown };
115
+ this.statuses.set(provider.id, {
116
+ id: provider.id,
117
+ kind: provider.kind,
118
+ status: "unavailable",
119
+ ...("namespace" in provider && typeof provider.namespace === "string" ? { namespace: provider.namespace } : {}),
120
+ code: typeof typed.code === "string" ? typed.code : "PROVIDER_UNAVAILABLE",
121
+ message: typeof typed.message === "string" ? typed.message : "provider discovery failed",
122
+ });
123
+ try { await this.closeProvider(provider); } catch { /* preserve discovery status */ }
86
124
  }
87
- } catch (error) {
88
- await closeProviders(options.providers);
89
- throw error;
90
125
  }
126
+
127
+ private targetError(toolId: string): RuntimeError {
128
+ const unavailable = this.providerStatuses().find((status) =>
129
+ status.status === "unavailable" && status.namespace !== undefined && toolId.startsWith(`${status.namespace}.`));
130
+ if (unavailable) return new RuntimeError("PROVIDER_UNAVAILABLE", `provider '${unavailable.id}' is unavailable`);
131
+ return new RuntimeError("TOOL_NOT_FOUND", `unknown tool '${toolId}'`);
132
+ }
133
+
134
+ private async closeProvider(provider: ToolProvider): Promise<void> {
135
+ if (!provider.close || this.closedProviders.has(provider.id)) return;
136
+ this.closedProviders.add(provider.id);
137
+ await provider.close();
138
+ }
139
+ }
140
+
141
+ export async function createToolRuntime(options: ToolRuntimeOptions): Promise<ToolRuntime> {
142
+ const providers = new ProviderRegistry();
143
+ const tools = new ToolRegistry();
144
+ for (const provider of options.providers) providers.register(provider);
91
145
  return new ToolRuntime({
92
146
  providers,
93
147
  tools,
@@ -1,9 +1,12 @@
1
- import type { ToolDescriptor, ToolSearchResult } from "./provider";
1
+ import type { ToolDescriptor, ToolSearchOptions, ToolSearchResult } from "./provider";
2
2
  import { RuntimeError } from "./errors";
3
3
 
4
4
  const NAME_WEIGHT = 3;
5
5
  const KEYWORD_WEIGHT = 2;
6
6
  const DESCRIPTION_WEIGHT = 1;
7
+ const EXACT_BOOST = 4;
8
+ const PREFIX_BOOST = 2;
9
+ const MAX_SEARCH_RESULTS = 100;
7
10
 
8
11
  export class ToolRegistry {
9
12
  private readonly tools = new Map<string, ToolDescriptor>();
@@ -25,13 +28,17 @@ export class ToolRegistry {
25
28
  return this.tools.get(toolId);
26
29
  }
27
30
 
28
- search(query: string): ToolSearchResult[] {
31
+ search(query: string, options: ToolSearchOptions = {}): ToolSearchResult[] {
29
32
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
30
33
  if (tokens.length === 0) return [];
31
34
  return [...this.tools.values()]
35
+ .filter((descriptor) => options.provider === undefined || descriptor.provider.id === options.provider)
36
+ .filter((descriptor) => options.source === undefined || descriptor.source.id === options.source)
37
+ .filter((descriptor) => options.risk === undefined || descriptor.risk === options.risk)
32
38
  .map((descriptor) => ({ descriptor, score: this.score(descriptor, tokens) }))
33
39
  .filter((hit) => hit.score > 0)
34
- .sort((a, b) => b.score - a.score)
40
+ .sort((a, b) => b.score - a.score || a.descriptor.id.localeCompare(b.descriptor.id))
41
+ .slice(0, Math.min(Math.max(Math.trunc(options.limit ?? MAX_SEARCH_RESULTS), 1), MAX_SEARCH_RESULTS))
35
42
  .map(({ descriptor }) => {
36
43
  const { inputSchema: _inputSchema, ...summary } = descriptor;
37
44
  return summary;
@@ -43,12 +50,20 @@ export class ToolRegistry {
43
50
  const description = descriptor.description.toLowerCase();
44
51
  const keywords = (descriptor.keywords ?? []).map((keyword) => keyword.toLowerCase());
45
52
  return tokens.reduce((score, token) => {
46
- if (id.includes(token)) return score + NAME_WEIGHT;
47
- if (keywords.some((keyword) => keyword.includes(token) || token.includes(keyword))) {
48
- return score + KEYWORD_WEIGHT;
49
- }
50
- if (description.includes(token)) return score + DESCRIPTION_WEIGHT;
53
+ const idScore = matchWeight(id, token, NAME_WEIGHT);
54
+ if (idScore > 0) return score + idScore;
55
+ const keywordScore = Math.max(0, ...keywords.map((keyword) => matchWeight(keyword, token, KEYWORD_WEIGHT)));
56
+ if (keywordScore > 0) return score + keywordScore;
57
+ const descriptionScore = matchWeight(description, token, DESCRIPTION_WEIGHT);
58
+ if (descriptionScore > 0) return score + descriptionScore;
51
59
  return score;
52
60
  }, 0);
53
61
  }
54
62
  }
63
+
64
+ function matchWeight(value: string, token: string, base: number): number {
65
+ if (value === token) return base + EXACT_BOOST;
66
+ if (value.startsWith(token)) return base + PREFIX_BOOST;
67
+ if (value.includes(token) || token.includes(value)) return base;
68
+ return 0;
69
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.1";
1
+ export const VERSION = "0.3.3";
@@ -1,99 +0,0 @@
1
- import type { Logger, SecretStore, ToolContext, ToolResult } from "@oh-my-tool/sdk";
2
- import { ToolError } from "@oh-my-tool/sdk";
3
- import type { Config } from "../config/config";
4
- import { getConnectionConfig, sanitizeExtensionConnections } from "../config/config";
5
- import { resolveTool, OmtError, type Registry } from "./registry";
6
- import { validateInput, type Schema } from "./schema";
7
- import { validateConnectionInput, applyLimits, PolicyError } from "../policy/policy";
8
- import { loadExtension } from "../extension/loader";
9
- import type { OmtResult } from "./result";
10
-
11
- const noopLogger: Logger = {
12
- debug: () => {},
13
- info: () => {},
14
- warn: () => {},
15
- error: () => {},
16
- };
17
-
18
- export interface ExecutorDeps {
19
- registry: Registry;
20
- config: Config;
21
- secrets: SecretStore;
22
- logger?: Logger;
23
- }
24
-
25
- function hasConnection(schema: Schema | undefined): boolean {
26
- return Boolean(schema?.properties && "connection" in schema.properties);
27
- }
28
-
29
- export async function executeTool(
30
- deps: ExecutorDeps,
31
- toolName: string,
32
- rawInput: Record<string, unknown>,
33
- ): Promise<OmtResult> {
34
- const started = Date.now();
35
- try {
36
- const { extension, tool } = resolveTool(deps.registry, toolName);
37
- const schema = tool.inputSchema as Schema | undefined;
38
-
39
- const limits = applyLimits(rawInput);
40
- const normalized = { ...rawInput, maxRows: limits.maxRows, timeoutMs: limits.timeoutMs };
41
-
42
- const needsConnection = hasConnection(schema) || "connection" in normalized;
43
- if (needsConnection) {
44
- validateConnectionInput(normalized, deps.config, extension.id);
45
- }
46
-
47
- const input = validateInput(schema, normalized);
48
-
49
- const connectionCfg = needsConnection
50
- ? getConnectionConfig(deps.config, extension.id, String(input.connection))
51
- : undefined;
52
-
53
- const ctx: ToolContext = {
54
- toolName,
55
- logger: deps.logger ?? noopLogger,
56
- config: (connectionCfg ?? { connections: sanitizeExtensionConnections(deps.config)[extension.id] ?? {} }) as Record<string, unknown>,
57
- secrets: deps.secrets,
58
- };
59
-
60
- const def = await loadExtension(extension);
61
- const handler = def.handlers[toolName];
62
- if (!handler) {
63
- throw new OmtError("HANDLER_MISSING", `no handler for ${toolName}`);
64
- }
65
-
66
- const result: ToolResult = await handler(ctx, input);
67
- const durationMs = Date.now() - started;
68
- return {
69
- ok: true,
70
- tool: toolName,
71
- data: result.data,
72
- meta: { durationMs, ...(result.meta ?? {}) },
73
- };
74
- } catch (e) {
75
- const durationMs = Date.now() - started;
76
- if (e instanceof PolicyError) {
77
- return { ok: false, tool: toolName, error: { code: "POLICY_VIOLATION", message: e.message } };
78
- }
79
- if (e instanceof ToolError) {
80
- return { ok: false, tool: toolName, error: { code: e.code, message: e.message } };
81
- }
82
- if (e instanceof OmtError) {
83
- return { ok: false, tool: toolName, error: { code: e.code, message: e.message } };
84
- }
85
- if (e && typeof e === "object" && "code" in e && typeof (e as { code?: unknown }).code === "string") {
86
- return {
87
- ok: false,
88
- tool: toolName,
89
- error: { code: (e as { code: string }).code, message: e instanceof Error ? e.message : String(e) },
90
- };
91
- }
92
- return {
93
- ok: false,
94
- tool: toolName,
95
- error: { code: "EXECUTION_FAILED", message: e instanceof Error ? e.message : String(e) },
96
- };
97
- }
98
- }
99
-