@oh-my-tool/cli 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@oh-my-tool/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Oh My Tool CLI - local and enterprise tools for agents",
6
- "keywords": ["ai", "agent", "cli", "tools", "extensions", "bun"],
6
+ "keywords": [
7
+ "ai",
8
+ "agent",
9
+ "cli",
10
+ "tools",
11
+ "extensions",
12
+ "bun"
13
+ ],
7
14
  "homepage": "https://github.com/oh-my-tool/oh-my-tool#readme",
8
15
  "repository": {
9
16
  "type": "git",
@@ -22,7 +29,7 @@
22
29
  "dependencies": {
23
30
  "@clack/prompts": "^1.7.0",
24
31
  "@modelcontextprotocol/client": "2.0.0",
25
- "@oh-my-tool/sdk": "0.3.0",
32
+ "@oh-my-tool/sdk": "0.3.2",
26
33
  "open": "11.0.0"
27
34
  },
28
35
  "engines": {
@@ -0,0 +1,94 @@
1
+ import { loadConfig } from "../../config/config";
2
+ import { createPaths } from "../../paths";
3
+ import { prepareHome } from "../../migration";
4
+ import { withRuntime } from "../context";
5
+
6
+ export interface ConnectionSummary {
7
+ extension: string;
8
+ name: string;
9
+ environment: string;
10
+ host: string;
11
+ port: number;
12
+ database: string;
13
+ username: string;
14
+ tls: boolean;
15
+ secretConfigured: boolean;
16
+ }
17
+
18
+ export interface ConnectionListResult {
19
+ connections: ConnectionSummary[];
20
+ count: number;
21
+ }
22
+
23
+ export interface ConnectionCheck {
24
+ extension: string;
25
+ name: string;
26
+ status: "ok" | "error" | "unsupported";
27
+ code?: string;
28
+ durationMs?: number;
29
+ }
30
+
31
+ export interface ConnectionCheckResult {
32
+ checks: ConnectionCheck[];
33
+ count: number;
34
+ }
35
+
36
+ export interface ConfigCheckResult {
37
+ valid: true;
38
+ connectionCount: number;
39
+ extensionCount: number;
40
+ }
41
+
42
+ async function configuredConnections(): Promise<ConnectionListResult> {
43
+ const paths = createPaths();
44
+ await prepareHome(paths);
45
+ const config = loadConfig(paths.home);
46
+ const connections = Object.entries(config.extensions)
47
+ .sort(([left], [right]) => left.localeCompare(right))
48
+ .flatMap(([extension, value]) => Object.entries(value.connections)
49
+ .sort(([left], [right]) => left.localeCompare(right))
50
+ .map(([name, connection]) => ({
51
+ extension,
52
+ name,
53
+ environment: connection.environment,
54
+ host: connection.host,
55
+ port: connection.port,
56
+ database: connection.database,
57
+ username: connection.username,
58
+ tls: connection.tls,
59
+ secretConfigured: connection.secret.length > 0,
60
+ })));
61
+ return { connections, count: connections.length };
62
+ }
63
+
64
+ export async function runConnectionList(): Promise<ConnectionListResult> {
65
+ return configuredConnections();
66
+ }
67
+
68
+ export async function runConfigCheck(): Promise<ConfigCheckResult> {
69
+ const paths = createPaths();
70
+ await prepareHome(paths);
71
+ const config = loadConfig(paths.home);
72
+ return {
73
+ valid: true,
74
+ connectionCount: Object.values(config.extensions).reduce((count, extension) => count + Object.keys(extension.connections).length, 0),
75
+ extensionCount: Object.keys(config.extensions).length,
76
+ };
77
+ }
78
+
79
+ export async function runConnectionCheck(): Promise<ConnectionCheckResult> {
80
+ const list = await configuredConnections();
81
+ return withRuntime(async (runtime) => {
82
+ const checks: ConnectionCheck[] = [];
83
+ for (const connection of list.connections) {
84
+ const started = Date.now();
85
+ const result = await runtime.run(`${connection.extension}.ping`, { connection: connection.name });
86
+ checks.push(result.ok
87
+ ? { extension: connection.extension, name: connection.name, status: "ok", durationMs: Date.now() - started }
88
+ : result.error?.code === "TOOL_NOT_FOUND"
89
+ ? { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED" }
90
+ : { extension: connection.extension, name: connection.name, status: "error", code: result.error?.code ?? "CHECK_FAILED", durationMs: Date.now() - started });
91
+ }
92
+ return { checks, count: checks.length };
93
+ }, { includeMcp: false });
94
+ }
@@ -18,7 +18,7 @@ export async function runDescribe(toolName: string): Promise<DescribedTool> {
18
18
  risk: descriptor.risk,
19
19
  inputSchema: descriptor.inputSchema,
20
20
  extension: descriptor.source.id,
21
- extensionVersion: "unknown",
21
+ extensionVersion: descriptor.source.version ?? "unknown",
22
22
  };
23
23
  });
24
24
  }
@@ -1,5 +1,5 @@
1
1
  import { discoverExtensions } from "../../extension/discovery";
2
- import { installLocalExtension, type InstalledRef } from "../../extension/install";
2
+ import { installExtension, type InstalledRef } from "../../extension/install";
3
3
  import { homeDir } from "../context";
4
4
 
5
5
  export interface InstalledInfo {
@@ -19,6 +19,6 @@ export async function runExtensionList(): Promise<InstalledInfo[]> {
19
19
  }
20
20
 
21
21
  export async function runExtensionInstall(spec: string): Promise<InstalledRef> {
22
- return installLocalExtension(homeDir(), spec);
22
+ return installExtension(homeDir(), spec);
23
23
  }
24
24
 
@@ -5,4 +5,5 @@ export * from "./extension";
5
5
  export * from "./secret";
6
6
  export * from "./integrate";
7
7
  export * from "./mcp";
8
+ export * from "./connections";
8
9
 
@@ -19,7 +19,7 @@ export async function runTool(
19
19
  return { ok: true, tool: toolName, data: result.output, meta: result.meta ?? {} };
20
20
  }
21
21
  return { ok: false, tool: toolName, error: result.error ?? { code: "EXECUTION_FAILED", message: "execution failed" } };
22
- });
22
+ }, { targetTool: toolName });
23
23
  }
24
24
 
25
25
  function readStdinJson(): Promise<Record<string, unknown>> {
@@ -30,7 +30,12 @@ function readStdinJson(): Promise<Record<string, unknown>> {
30
30
  stdin.on("data", (chunk: string) => (data += chunk));
31
31
  stdin.on("end", () => {
32
32
  try {
33
- resolve(JSON.parse(data));
33
+ const parsed: unknown = JSON.parse(data);
34
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
35
+ reject(new Error("stdin JSON must be an object"));
36
+ return;
37
+ }
38
+ resolve(parsed as Record<string, unknown>);
34
39
  } catch (e) {
35
40
  reject(e);
36
41
  }
@@ -1,10 +1,11 @@
1
1
  import { createPaths } from "../paths";
2
2
  import { prepareHome } from "../migration";
3
- import { loadConfig, getConnectionConfig, type McpEnabledServerConfig } from "../config/config";
3
+ import { loadConfig, getConnectionConfig, sanitizeExtensionConnections, type McpEnabledServerConfig } from "../config/config";
4
4
  import { SecretsManager } from "../secrets/secrets";
5
5
  import { applyLimits, validateConnectionInput } from "../policy/policy";
6
6
  import { NativeExtensionProvider } from "../runtime/providers/native/provider";
7
7
  import { McpProvider } from "../runtime/providers/mcp/provider";
8
+ import { discoverExtensions } from "../extension/discovery";
8
9
  import { createToolRuntime } from "../runtime/runtime";
9
10
  import type { ToolDescriptor } from "../runtime/provider";
10
11
 
@@ -12,15 +13,24 @@ export function homeDir(): string {
12
13
  return createPaths().home;
13
14
  }
14
15
 
15
- export async function createRuntime() {
16
+ export interface RuntimeOptions {
17
+ readonly includeMcp?: boolean;
18
+ readonly targetTool?: string;
19
+ }
20
+
21
+ export async function createRuntime(options: RuntimeOptions = {}) {
16
22
  const paths = createPaths();
17
23
  await prepareHome(paths);
18
24
  const config = loadConfig(paths.home);
19
25
  const secrets = new SecretsManager();
20
- const providers = [new NativeExtensionProvider(paths), ...Object.entries(config.mcp.servers)
26
+ const extensionConnections = sanitizeExtensionConnections(config);
27
+ const nativeTarget = options.targetTool !== undefined && discoverExtensions(paths.home)
28
+ .some((extension) => extension.manifest.tools.some((tool) => tool.name === options.targetTool));
29
+ const mcpProviders = options.includeMcp === false || nativeTarget ? [] : Object.entries(config.mcp.servers)
21
30
  .sort(([a], [b]) => a.localeCompare(b))
22
31
  .filter((entry): entry is [string, McpEnabledServerConfig] => entry[1].enabled)
23
- .map(([serverId, server]) => new McpProvider({ serverId, config: server, secrets }))];
32
+ .map(([serverId, server]) => new McpProvider({ serverId, config: server, secrets }));
33
+ const providers = [new NativeExtensionProvider(paths), ...mcpProviders];
24
34
  return createToolRuntime({
25
35
  providers,
26
36
  policy: {
@@ -42,15 +52,15 @@ export async function createRuntime() {
42
52
  : undefined;
43
53
  return {
44
54
  logger: { debug() {}, info() {}, warn() {}, error() {} },
45
- config: (connection ?? {}) as Record<string, unknown>,
55
+ config: (connection ?? { connections: extensionConnections[extensionId] ?? {} }) as Record<string, unknown>,
46
56
  secrets,
47
57
  };
48
58
  },
49
59
  });
50
60
  }
51
61
 
52
- export async function withRuntime<T>(operation: (runtime: Awaited<ReturnType<typeof createRuntime>>) => Promise<T>): Promise<T> {
53
- const runtime = await createRuntime();
62
+ export async function withRuntime<T>(operation: (runtime: Awaited<ReturnType<typeof createRuntime>>) => Promise<T>, options: RuntimeOptions = {}): Promise<T> {
63
+ const runtime = await createRuntime(options);
54
64
  let operationFailed = false;
55
65
  try {
56
66
  return await operation(runtime);
package/src/cli/index.ts CHANGED
@@ -14,17 +14,23 @@ import { AGENT_IDS } from "../integration";
14
14
  import { multiselect, isCancel } from "@clack/prompts";
15
15
  import { VERSION } from "../version";
16
16
  import { runMcpAuth, runMcpList, runMcpLogout } from "./commands/mcp";
17
+ import { runConnectionList, runConnectionCheck, runConfigCheck } from "./commands/connections";
17
18
  import { RuntimeError } from "../runtime/errors";
19
+ import { formatAiResult, formatJson, formatOutput, type OutputFormat } from "./output";
18
20
 
19
21
  const HELP = `Oh My Tool - local and enterprise tools for agents
20
22
 
21
23
  Usage:
22
24
  ohmytool search "<task>" search tools by intent
23
25
  ohmytool describe <tool> inspect a tool and its input schema
24
- ohmytool run <tool> [key=value ...] execute a tool
26
+ ohmytool run <tool> [key=value ...] execute a tool (AI-friendly text by default)
25
27
  ohmytool run <tool> --stdin execute with JSON from stdin
28
+ ohmytool run <tool> ... --json output the machine-readable JSON result
29
+ ohmytool connection list list configured connections
30
+ ohmytool connection check check MySQL/Redis connectivity
31
+ ohmytool config check validate configuration
26
32
  ohmytool extension list list installed extensions
27
- ohmytool extension install <path> install an extension from a local dir
33
+ ohmytool extension install <path|package> install a local or npm extension
28
34
  ohmytool secret set <name> set a secret (interactive hidden prompt or stdin pipe)
29
35
  ohmytool secret list list secret names (Windows only, values never shown)
30
36
  ohmytool mcp list list configured MCP servers
@@ -95,7 +101,15 @@ function readSecretHidden(prompt: string): Promise<string> {
95
101
  }
96
102
 
97
103
  function print(v: unknown): void {
98
- console.log(JSON.stringify(v, null, 2));
104
+ console.log(formatJson(v));
105
+ }
106
+
107
+ function outputFormat(parsed: ReturnType<typeof parseArgs>): OutputFormat {
108
+ const format = parsed.options.format ?? (parsed.options.json === "true" ? "json" : undefined);
109
+ if (format !== undefined && !["json", "text", "table", "csv"].includes(format)) {
110
+ throw new RuntimeError("INVALID_FORMAT", `unsupported output format '${format}'`);
111
+ }
112
+ return parsed.flags.includes("json") ? "json" : (format as OutputFormat | undefined) ?? "text";
99
113
  }
100
114
 
101
115
  function parseAgentIds(raw?: string): AgentId[] | undefined {
@@ -189,9 +203,15 @@ export interface CliDependencies {
189
203
  const defaultCliDependencies: CliDependencies = { runMcpAuth, runMcpLogout, runMcpList };
190
204
 
191
205
  export async function main(argv: string[], dependencies: CliDependencies = defaultCliDependencies): Promise<number> {
192
- const parsed = parseArgs(argv);
193
- const cmd = parsed.positional[0];
194
206
  try {
207
+ const parsed = parseArgs(argv);
208
+ const cmd = parsed.positional[0];
209
+ const allowedFlags = new Set(["help", "version", "json", "stdin", "yes", "dry-run", "force"]);
210
+ const allowedOptions = new Set(["format", "json", "agents"]);
211
+ const unknownFlag = parsed.flags.find((flag) => !allowedFlags.has(flag));
212
+ const unknownOption = Object.keys(parsed.options).find((option) => !allowedOptions.has(option));
213
+ if (unknownFlag) throw new RuntimeError("INVALID_ARGUMENT", `unknown option '--${unknownFlag}'`);
214
+ if (unknownOption) throw new RuntimeError("INVALID_ARGUMENT", `unknown option '--${unknownOption}'`);
195
215
  switch (cmd) {
196
216
  case "search": {
197
217
  const q = parsed.positional.slice(1).join(" ");
@@ -205,9 +225,30 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
205
225
  case "run": {
206
226
  const tool = parsed.positional[1];
207
227
  const res = await runTool(tool, parsed.keyValues, parsed.flags.includes("stdin"));
208
- print(res);
228
+ console.log(formatOutput(res, outputFormat(parsed)));
209
229
  return res.ok ? 0 : 1;
210
230
  }
231
+ case "connection": {
232
+ const action = parsed.positional[1];
233
+ if (action !== "list" && action !== "check") {
234
+ console.error("usage: ohmytool connection list | connection check");
235
+ return 1;
236
+ }
237
+ const result = action === "list" ? await runConnectionList() : await runConnectionCheck();
238
+ const wrapped = { ok: true as const, tool: `connection.${action}`, data: result, meta: {} };
239
+ console.log(formatOutput(wrapped, outputFormat(parsed)));
240
+ return 0;
241
+ }
242
+ case "config": {
243
+ if (parsed.positional[1] !== "check") {
244
+ console.error("usage: ohmytool config check");
245
+ return 1;
246
+ }
247
+ const result = await runConfigCheck();
248
+ const wrapped = { ok: true as const, tool: "config.check", data: result, meta: {} };
249
+ console.log(formatOutput(wrapped, outputFormat(parsed)));
250
+ return 0;
251
+ }
211
252
  case "secret": {
212
253
  const sub = parsed.positional[1];
213
254
  if (sub === "set") {
@@ -0,0 +1,165 @@
1
+ import type { OmtResult } from "../core/result";
2
+
3
+ export type OutputFormat = "text" | "json" | "table" | "csv";
4
+
5
+ export function formatAiResult(result: OmtResult): string {
6
+ const lines = [`status: ${result.ok ? "ok" : "error"}`, `tool: ${result.tool}`];
7
+ if (!result.ok) {
8
+ lines.push("error:");
9
+ appendObject(lines, result.error, 2);
10
+ return lines.join("\n");
11
+ }
12
+
13
+ if (isRowsResult(result.data)) {
14
+ lines.push("rows:");
15
+ for (const row of result.data.rows) lines.push(formatRow(row));
16
+ lines.push(`row_count: ${result.data.rows.length}`);
17
+ if (result.data.truncated === true) lines.push("truncated: true");
18
+ appendMeta(lines, result.meta, new Set(["returnedRows"]), false);
19
+ } else {
20
+ lines.push("data:");
21
+ appendValue(lines, result.data, 2);
22
+ appendMeta(lines, result.meta);
23
+ }
24
+ return lines.join("\n");
25
+ }
26
+
27
+ export function formatJson(value: unknown): string {
28
+ const seen = new WeakSet<object>();
29
+ return JSON.stringify(value, (_key, entry) => {
30
+ if (typeof entry === "bigint") return String(entry);
31
+ if (entry !== null && typeof entry === "object") {
32
+ if (seen.has(entry)) return "[Circular]";
33
+ seen.add(entry);
34
+ }
35
+ return entry;
36
+ }, 2) ?? "null";
37
+ }
38
+
39
+ export function formatOutput(result: OmtResult, format: OutputFormat): string {
40
+ if (format === "json") return formatJson(result);
41
+ if (format === "csv" || format === "table") return formatDelimited(result, format === "csv" ? "," : "\t");
42
+ return formatAiResult(result);
43
+ }
44
+
45
+ function formatDelimited(result: OmtResult, delimiter: string): string {
46
+ if (!result.ok) return formatAiResult(result);
47
+ const rows = isRowsResult(result.data)
48
+ ? { columns: result.data.columns, rows: result.data.rows }
49
+ : findObjectArray(result.data);
50
+ if (!rows) return formatAiResult(result);
51
+ return [rows.columns, ...rows.rows].map((row) => row.map((value) => quoteDelimited(value, delimiter)).join(delimiter)).join("\n");
52
+ }
53
+
54
+ function findObjectArray(value: unknown): { columns: unknown[]; rows: unknown[][] } | undefined {
55
+ if (!isRecord(value)) return undefined;
56
+ for (const entry of Object.values(value)) {
57
+ if (!Array.isArray(entry) || !entry.every(isRecord)) continue;
58
+ const columns = [...new Set(entry.flatMap((item) => Object.keys(item)))];
59
+ return { columns, rows: entry.map((item) => columns.map((column) => item[column] ?? null)) };
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function quoteDelimited(value: unknown, delimiter: string): string {
65
+ const text = value === null || value === undefined ? "" : typeof value === "string" ? value : formatInline(value);
66
+ return delimiter === "," && /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
67
+ }
68
+
69
+ interface RowsResult {
70
+ columns: unknown[];
71
+ rows: unknown[][];
72
+ truncated?: boolean;
73
+ }
74
+
75
+ function isRowsResult(value: unknown): value is RowsResult {
76
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
77
+ const candidate = value as { columns?: unknown; rows?: unknown };
78
+ return Array.isArray(candidate.columns) && Array.isArray(candidate.rows) && candidate.rows.every(Array.isArray);
79
+ }
80
+
81
+ function appendMeta(
82
+ lines: string[],
83
+ meta: Record<string, unknown> | undefined,
84
+ skip = new Set<string>(),
85
+ grouped = true,
86
+ ): void {
87
+ const entries = Object.entries(meta ?? {}).filter(([key]) => !skip.has(key));
88
+ if (entries.length === 0) return;
89
+ if (grouped) lines.push("meta:");
90
+ for (const [key, value] of entries) appendField(lines, key, value, grouped ? 2 : 0);
91
+ }
92
+
93
+ function appendValue(lines: string[], value: unknown, indent: number): void {
94
+ if (isRecord(value)) {
95
+ appendObject(lines, value, indent);
96
+ return;
97
+ }
98
+ lines.push(`${spaces(indent)}${formatInline(value)}`);
99
+ }
100
+
101
+ function appendObject(lines: string[], value: Record<string, unknown>, indent: number): void {
102
+ const entries = Object.entries(value);
103
+ if (entries.length === 0) {
104
+ lines.push(`${spaces(indent)}{}`);
105
+ return;
106
+ }
107
+ for (const [key, entry] of entries) appendField(lines, key, entry, indent);
108
+ }
109
+
110
+ function appendField(lines: string[], key: string, value: unknown, indent: number): void {
111
+ const prefix = `${spaces(indent)}${key}:`;
112
+ if (isRecord(value)) {
113
+ lines.push(prefix);
114
+ appendObject(lines, value, indent + 2);
115
+ return;
116
+ }
117
+ if (Array.isArray(value) && value.every(isRecord)) {
118
+ lines.push(prefix);
119
+ if (value.length === 0) {
120
+ lines.push(`${spaces(indent + 2)}[]`);
121
+ return;
122
+ }
123
+ for (const item of value) {
124
+ const entries = Object.entries(item);
125
+ if (entries.length === 0) {
126
+ lines.push(`${spaces(indent + 2)}- {}`);
127
+ continue;
128
+ }
129
+ const [firstKey, firstValue] = entries[0];
130
+ if (isRecord(firstValue)) {
131
+ lines.push(`${spaces(indent + 2)}- ${firstKey}:`);
132
+ appendObject(lines, firstValue, indent + 4);
133
+ } else {
134
+ lines.push(`${spaces(indent + 2)}- ${firstKey}: ${formatInline(firstValue)}`);
135
+ }
136
+ for (const [itemKey, itemValue] of entries.slice(1)) appendField(lines, itemKey, itemValue, indent + 4);
137
+ }
138
+ return;
139
+ }
140
+ lines.push(`${prefix} ${formatInline(value)}`);
141
+ }
142
+
143
+ function formatRow(row: unknown[], header = false): string {
144
+ return `[${row.map((value) => header && typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(value)
145
+ ? value
146
+ : formatInline(value)).join(", ")}]`;
147
+ }
148
+
149
+ function formatInline(value: unknown): string {
150
+ if (value === undefined) return "null";
151
+ if (typeof value === "string") return JSON.stringify(value);
152
+ if (typeof value === "bigint") return String(value);
153
+ if (value !== null && typeof value === "object") {
154
+ try { return JSON.stringify(value); } catch { return String(value); }
155
+ }
156
+ return String(value);
157
+ }
158
+
159
+ function isRecord(value: unknown): value is Record<string, unknown> {
160
+ return value !== null && typeof value === "object" && !Array.isArray(value);
161
+ }
162
+
163
+ function spaces(count: number): string {
164
+ return " ".repeat(count);
165
+ }
@@ -26,7 +26,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
26
26
  } else {
27
27
  const eq = arg.indexOf("=");
28
28
  if (eq > 0) {
29
- keyValues[arg.slice(0, eq)] = arg.slice(eq + 1);
29
+ const key = arg.slice(0, eq);
30
+ if (key in keyValues) throw new Error(`duplicate input key '${key}'`);
31
+ keyValues[key] = arg.slice(eq + 1);
30
32
  } else {
31
33
  positional.push(arg);
32
34
  }
@@ -38,6 +38,37 @@ export interface Config {
38
38
  }
39
39
 
40
40
  function invalid(path: string, reason: string): never { throw new RuntimeError("MCP_INVALID_CONFIG", `${path}: ${reason}`); }
41
+ function invalidConnection(path: string, reason: string): never { throw new RuntimeError("CONFIG_INVALID", `${path}: ${reason}`); }
42
+
43
+ function parseConnection(extensionId: string, name: string, value: unknown): ConnectionConfig {
44
+ const path = `extensions.${extensionId}.connections.${name}`;
45
+ if (value === null || typeof value !== "object" || Array.isArray(value)) invalidConnection(path, "must be a table");
46
+ const raw = value as Record<string, unknown>;
47
+ const stringField = (key: string, fallback = ""): string => {
48
+ const entry = raw[key];
49
+ if (entry === undefined) return fallback;
50
+ if (typeof entry !== "string") invalidConnection(`${path}.${key}`, "must be a string");
51
+ return entry;
52
+ };
53
+ const host = stringField("host");
54
+ if (host.trim().length === 0) invalidConnection(`${path}.host`, "must be a non-empty string");
55
+ const defaultPort = extensionId === "redis" ? 6379 : 3306;
56
+ const port = raw.port === undefined ? defaultPort : raw.port;
57
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
58
+ invalidConnection(`${path}.port`, "must be an integer from 1 through 65535");
59
+ }
60
+ const tls = raw.tls === undefined ? false : raw.tls;
61
+ if (typeof tls !== "boolean") invalidConnection(`${path}.tls`, "must be a boolean");
62
+ return {
63
+ environment: stringField("environment"),
64
+ host,
65
+ port,
66
+ database: stringField("database"),
67
+ username: stringField("username"),
68
+ secret: stringField("secret"),
69
+ tls,
70
+ };
71
+ }
41
72
 
42
73
  function parseStringMap(value: unknown, path: string): Record<string, string> {
43
74
  if (value === undefined) return {};
@@ -129,12 +160,18 @@ export function loadConfig(homeDir: string): Config {
129
160
  const parsed = Bun.TOML.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
130
161
  const extensions: Config["extensions"] = {};
131
162
  const extSection = parsed.extensions;
163
+ if (extSection !== undefined && (extSection === null || typeof extSection !== "object" || Array.isArray(extSection))) {
164
+ invalidConnection("extensions", "must be a table");
165
+ }
132
166
  if (extSection && typeof extSection === "object") for (const [extId, extVal] of Object.entries(extSection)) {
167
+ if (extVal === null || typeof extVal !== "object" || Array.isArray(extVal)) invalidConnection(`extensions.${extId}`, "must be a table");
133
168
  const connections: Record<string, ConnectionConfig> = {};
134
- const connSection = (extVal as Record<string, any>)?.connections;
169
+ const connSection = (extVal as Record<string, unknown>).connections;
170
+ if (connSection !== undefined && (connSection === null || typeof connSection !== "object" || Array.isArray(connSection))) {
171
+ invalidConnection(`extensions.${extId}.connections`, "must be a table");
172
+ }
135
173
  if (connSection && typeof connSection === "object") for (const [name, rawConn] of Object.entries(connSection)) {
136
- const rc = rawConn as Record<string, any>;
137
- connections[name] = { environment: String(rc.environment ?? ""), host: String(rc.host ?? ""), port: Number(rc.port ?? 3306), database: String(rc.database ?? ""), username: String(rc.username ?? ""), secret: String(rc.secret ?? ""), tls: Boolean(rc.tls ?? false) };
174
+ connections[name] = parseConnection(extId, name, rawConn);
138
175
  }
139
176
  extensions[extId] = { connections };
140
177
  }
@@ -153,3 +190,18 @@ export function loadConfig(homeDir: string): Config {
153
190
 
154
191
  export function getConnectionConfig(cfg: Config, extensionId: string, connection: string): ConnectionConfig | undefined { return cfg.extensions[extensionId]?.connections[connection]; }
155
192
  export function listConnections(cfg: Config, extensionId: string): string[] { return Object.keys(cfg.extensions[extensionId]?.connections ?? {}); }
193
+
194
+ export function sanitizeExtensionConnections(cfg: Config): Record<string, Record<string, Omit<ConnectionConfig, "secret"> & { secretConfigured: boolean }> > {
195
+ return Object.fromEntries(Object.entries(cfg.extensions).map(([extensionId, extension]) => [
196
+ extensionId,
197
+ Object.fromEntries(Object.entries(extension.connections).map(([name, connection]) => [name, {
198
+ environment: connection.environment,
199
+ host: connection.host,
200
+ port: connection.port,
201
+ database: connection.database,
202
+ username: connection.username,
203
+ tls: connection.tls,
204
+ secretConfigured: connection.secret.length > 0,
205
+ }])),
206
+ ]));
207
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Logger, SecretStore, ToolContext, ToolResult } from "@oh-my-tool/sdk";
2
2
  import { ToolError } from "@oh-my-tool/sdk";
3
3
  import type { Config } from "../config/config";
4
- import { getConnectionConfig } from "../config/config";
4
+ import { getConnectionConfig, sanitizeExtensionConnections } from "../config/config";
5
5
  import { resolveTool, OmtError, type Registry } from "./registry";
6
6
  import { validateInput, type Schema } from "./schema";
7
7
  import { validateConnectionInput, applyLimits, PolicyError } from "../policy/policy";
@@ -53,7 +53,7 @@ export async function executeTool(
53
53
  const ctx: ToolContext = {
54
54
  toolName,
55
55
  logger: deps.logger ?? noopLogger,
56
- config: (connectionCfg ?? {}) as Record<string, unknown>,
56
+ config: (connectionCfg ?? { connections: sanitizeExtensionConnections(deps.config)[extension.id] ?? {} }) as Record<string, unknown>,
57
57
  secrets: deps.secrets,
58
58
  };
59
59
 
@@ -1,24 +1,135 @@
1
- import { cp, mkdir, readFile } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { execFile as execFileCallback } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, join, resolve } from "node:path";
3
6
  import { parseManifest, validateManifest, checkSdkCompatibility } from "./manifest";
4
7
 
8
+ const execFile = promisify(execFileCallback);
9
+ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
10
+ const OFFICIAL_PACKAGE_RE = /^@oh-my-tool\/[a-z0-9][a-z0-9._-]*$/;
11
+ const EXTENSION_ID_RE = /^[a-z0-9][a-z0-9_-]*$/;
12
+
5
13
  export interface InstalledRef {
6
14
  id: string;
7
15
  version: string;
8
16
  target: string;
9
17
  }
10
18
 
11
- export async function installLocalExtension(home: string, srcDir: string): Promise<InstalledRef> {
19
+ export interface NpmExtensionSpec {
20
+ packageName: string;
21
+ npmSpec: string;
22
+ version?: string;
23
+ }
24
+
25
+ export class ExtensionInstallError extends Error {}
26
+
27
+ export interface NpmInstallDependencies {
28
+ install(spec: string, tempDir: string): Promise<void>;
29
+ }
30
+
31
+ export function normalizeNpmExtensionSpec(spec: string): NpmExtensionSpec {
32
+ const trimmed = spec.trim();
33
+ if (!trimmed) throw new ExtensionInstallError("extension package name must not be empty");
34
+
35
+ let packageName = trimmed;
36
+ let version: string | undefined;
37
+ const separator = trimmed.startsWith("@") ? trimmed.lastIndexOf("@") : trimmed.indexOf("@");
38
+ if (separator > 0) {
39
+ packageName = trimmed.slice(0, separator);
40
+ version = trimmed.slice(separator + 1);
41
+ if (!EXACT_VERSION_RE.test(version)) {
42
+ throw new ExtensionInstallError("npm extension versions must be exact semver values, for example 0.3.1");
43
+ }
44
+ }
45
+
46
+ if (!trimmed.startsWith("@")) packageName = `@oh-my-tool/${packageName}`;
47
+ if (!OFFICIAL_PACKAGE_RE.test(packageName)) {
48
+ throw new ExtensionInstallError("only official @oh-my-tool extension packages are supported");
49
+ }
50
+ return { packageName, npmSpec: `${packageName}${version === undefined ? "" : `@${version}`}`, ...(version === undefined ? {} : { version }) };
51
+ }
52
+
53
+ async function installExtensionDirectory(home: string, srcDir: string, expectedPackage?: NpmExtensionSpec): Promise<InstalledRef> {
12
54
  const manifest = parseManifest(await readFile(join(srcDir, "omt.manifest.json"), "utf8"));
13
55
  validateManifest(manifest);
14
56
  checkSdkCompatibility(manifest.sdkVersion);
57
+ if (!EXTENSION_ID_RE.test(manifest.id)) throw new ExtensionInstallError(`invalid extension id '${manifest.id}'`);
58
+ if (expectedPackage !== undefined) {
59
+ const packageJson = JSON.parse(await readFile(join(srcDir, "package.json"), "utf8")) as { name?: unknown; version?: unknown };
60
+ if (packageJson.name !== expectedPackage.packageName) {
61
+ throw new ExtensionInstallError(`package manifest mismatch: expected '${expectedPackage.packageName}'`);
62
+ }
63
+ if (packageJson.version !== manifest.version) {
64
+ throw new ExtensionInstallError("package.json and omt.manifest.json versions do not match");
65
+ }
66
+ if (expectedPackage.version !== undefined && packageJson.version !== expectedPackage.version) {
67
+ throw new ExtensionInstallError(`npm package '${expectedPackage.npmSpec}' resolved to an unexpected version`);
68
+ }
69
+ }
15
70
  const target = join(home, "extensions", manifest.id, manifest.version);
16
71
  await mkdir(target, { recursive: true });
17
72
  // 显式 force:true:Bun 的 fs.cp 在带 filter 时默认覆盖失效(重装不更新旧文件)
18
73
  await cp(srcDir, target, {
19
74
  recursive: true,
20
75
  force: true,
21
- filter: (s: string) => !s.includes("node_modules"),
76
+ filter: (s: string) => basename(s) !== "node_modules",
22
77
  });
23
78
  return { id: manifest.id, version: manifest.version, target };
24
- }
79
+ }
80
+
81
+ export async function installLocalExtension(home: string, srcDir: string): Promise<InstalledRef> {
82
+ return installExtensionDirectory(home, srcDir);
83
+ }
84
+
85
+ export async function installNpmExtension(
86
+ home: string,
87
+ spec: string,
88
+ dependencies: Partial<NpmInstallDependencies> = {},
89
+ ): Promise<InstalledRef> {
90
+ const normalized = normalizeNpmExtensionSpec(spec);
91
+ const temp = await mkdtemp(join(tmpdir(), "oh-my-tool-npm-"));
92
+ try {
93
+ await writeFile(join(temp, "package.json"), JSON.stringify({ private: true }), "utf8");
94
+ try {
95
+ if (dependencies.install !== undefined) {
96
+ await dependencies.install(normalized.npmSpec, temp);
97
+ } else {
98
+ await execFile("npm", [
99
+ "install",
100
+ "--prefix", temp,
101
+ "--ignore-scripts",
102
+ "--no-save",
103
+ "--no-package-lock",
104
+ "--omit=dev",
105
+ "--registry=https://registry.npmjs.org",
106
+ "--", normalized.npmSpec,
107
+ ], { timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
108
+ }
109
+ } catch {
110
+ throw new ExtensionInstallError(`failed to download npm extension '${normalized.npmSpec}'`);
111
+ }
112
+
113
+ const packageDir = join(temp, "node_modules", ...normalized.packageName.split("/"));
114
+ try {
115
+ await stat(packageDir);
116
+ } catch {
117
+ throw new ExtensionInstallError(`npm extension '${normalized.npmSpec}' was not installed`);
118
+ }
119
+ return await installExtensionDirectory(home, packageDir, normalized);
120
+ } finally {
121
+ await rm(temp, { recursive: true, force: true });
122
+ }
123
+ }
124
+
125
+ export async function installExtension(home: string, spec: string): Promise<InstalledRef> {
126
+ const candidate = resolve(spec);
127
+ let isLocal = false;
128
+ try {
129
+ await stat(join(candidate, "omt.manifest.json"));
130
+ isLocal = true;
131
+ } catch {
132
+ // Not a local extension directory; interpret the argument as an npm spec.
133
+ }
134
+ return isLocal ? installLocalExtension(home, candidate) : installNpmExtension(home, spec);
135
+ }
@@ -20,12 +20,18 @@ 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
  }
@@ -112,4 +118,4 @@ export function validateHandlers(
112
118
  );
113
119
  }
114
120
  }
115
- }
121
+ }
@@ -75,7 +75,7 @@ export function assertReadOnly(sql: string): void {
75
75
  throw new PolicyError("only a single read-only statement is allowed");
76
76
  }
77
77
  const cleaned = stripLiteralsAndComments(sql);
78
- if (FORBIDDEN.test(cleaned)) {
78
+ if (!/^(select|with|show|explain|describe|desc)\b/i.test(cleaned.trim()) || FORBIDDEN.test(cleaned) || /\bfor\s+update\b|\block\s+in\s+share\s+mode\b|\binto\s+(out|dump)file\b|\bload_file\s*\(/i.test(cleaned)) {
79
79
  throw new PolicyError("sql contains a non read-only statement");
80
80
  }
81
81
  }
@@ -8,7 +8,7 @@ export interface ToolDescriptor {
8
8
  risk: "read" | "write" | "admin";
9
9
  inputSchema?: Record<string, unknown>;
10
10
  provider: { id: string; kind: string };
11
- source: { id: string; kind: string };
11
+ source: { id: string; kind: string; version?: string };
12
12
  }
13
13
 
14
14
  export type ToolSearchResult = Omit<ToolDescriptor, "inputSchema">;
@@ -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
  }
@@ -32,7 +32,7 @@ export class NativeExtensionProvider implements ToolProvider {
32
32
  risk: tool.risk ?? "read",
33
33
  inputSchema: tool.inputSchema,
34
34
  provider: { id: this.id, kind: this.kind },
35
- source: { id: extension.manifest.id, kind: "extension" },
35
+ source: { id: extension.manifest.id, kind: "extension", version: extension.manifest.version },
36
36
  });
37
37
  }
38
38
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.0";
1
+ export const VERSION = "0.3.2";