@oh-my-tool/cli 0.3.1 → 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 +2 -2
- package/src/cli/commands/connections.ts +1 -5
- package/src/cli/commands/extension.ts +2 -2
- package/src/cli/index.ts +1 -1
- package/src/extension/install.ts +116 -5
- package/src/extension/manifest.ts +7 -1
- package/src/runtime/providers/mcp/oauth-provider.ts +22 -1
- package/src/runtime/providers/mcp/oauth-store.ts +12 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@clack/prompts": "^1.7.0",
|
|
31
31
|
"@modelcontextprotocol/client": "2.0.0",
|
|
32
|
-
"@oh-my-tool/sdk": "0.3.
|
|
32
|
+
"@oh-my-tool/sdk": "0.3.2",
|
|
33
33
|
"open": "11.0.0"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
@@ -81,16 +81,12 @@ export async function runConnectionCheck(): Promise<ConnectionCheckResult> {
|
|
|
81
81
|
return withRuntime(async (runtime) => {
|
|
82
82
|
const checks: ConnectionCheck[] = [];
|
|
83
83
|
for (const connection of list.connections) {
|
|
84
|
-
if (connection.extension !== "redis" && connection.extension !== "mysql") {
|
|
85
|
-
checks.push({ extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED" });
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
84
|
const started = Date.now();
|
|
89
85
|
const result = await runtime.run(`${connection.extension}.ping`, { connection: connection.name });
|
|
90
86
|
checks.push(result.ok
|
|
91
87
|
? { extension: connection.extension, name: connection.name, status: "ok", durationMs: Date.now() - started }
|
|
92
88
|
: result.error?.code === "TOOL_NOT_FOUND"
|
|
93
|
-
? { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED"
|
|
89
|
+
? { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED" }
|
|
94
90
|
: { extension: connection.extension, name: connection.name, status: "error", code: result.error?.code ?? "CHECK_FAILED", durationMs: Date.now() - started });
|
|
95
91
|
}
|
|
96
92
|
return { checks, count: checks.length };
|
|
@@ -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/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ Usage:
|
|
|
30
30
|
ohmytool connection check check MySQL/Redis connectivity
|
|
31
31
|
ohmytool config check validate configuration
|
|
32
32
|
ohmytool extension list list installed extensions
|
|
33
|
-
ohmytool extension install <path>
|
|
33
|
+
ohmytool extension install <path|package> install a local or npm extension
|
|
34
34
|
ohmytool secret set <name> set a secret (interactive hidden prompt or stdin pipe)
|
|
35
35
|
ohmytool secret list list secret names (Windows only, values never shown)
|
|
36
36
|
ohmytool mcp list list configured MCP servers
|
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
|
+
}
|
|
@@ -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
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.3.
|
|
1
|
+
export const VERSION = "0.3.2";
|