@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 +10 -3
- package/src/cli/commands/connections.ts +94 -0
- package/src/cli/commands/describe.ts +1 -1
- package/src/cli/commands/extension.ts +2 -2
- package/src/cli/commands/index.ts +1 -0
- package/src/cli/commands/run.ts +7 -2
- package/src/cli/context.ts +17 -7
- package/src/cli/index.ts +47 -6
- package/src/cli/output.ts +165 -0
- package/src/cli/parseArgs.ts +3 -1
- package/src/config/config.ts +55 -3
- package/src/core/executor.ts +2 -2
- package/src/extension/install.ts +116 -5
- package/src/extension/manifest.ts +7 -1
- package/src/policy/policy.ts +1 -1
- package/src/runtime/provider.ts +1 -1
- package/src/runtime/providers/mcp/oauth-provider.ts +22 -1
- package/src/runtime/providers/mcp/oauth-store.ts +12 -1
- package/src/runtime/providers/native/provider.ts +1 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-tool/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Oh My Tool CLI - local and enterprise tools for agents",
|
|
6
|
-
"keywords": [
|
|
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.
|
|
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 {
|
|
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
|
|
22
|
+
return installExtension(homeDir(), spec);
|
|
23
23
|
}
|
|
24
24
|
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/cli/context.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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>
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cli/parseArgs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/executor.ts
CHANGED
|
@@ -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
|
|
package/src/extension/install.ts
CHANGED
|
@@ -1,24 +1,135 @@
|
|
|
1
|
-
import { cp, mkdir, readFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
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
|
|
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) =>
|
|
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
|
+
}
|
package/src/policy/policy.ts
CHANGED
|
@@ -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
|
}
|
package/src/runtime/provider.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
1
|
+
export const VERSION = "0.3.2";
|