@teampitch/mcpx 0.2.1 → 0.3.1
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 +1 -1
- package/src/backends.test.ts +5 -2
- package/src/backends.ts +67 -20
- package/src/config.ts +7 -0
- package/src/executor.test.ts +3 -3
- package/src/executor.ts +125 -31
- package/src/index.ts +337 -265
- package/src/oauth-client.ts +337 -0
- package/src/skills.test.ts +166 -0
- package/src/skills.ts +153 -0
- package/src/stdio.ts +5 -6
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth client for MCP HTTP backends.
|
|
3
|
+
* Handles: metadata discovery, client registration, auth code + PKCE, token storage, refresh.
|
|
4
|
+
* Tokens stored in .mcpx/tokens/{backend-name}.json
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
7
|
+
import { createServer, type Server } from "node:http";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
interface OAuthMetadata {
|
|
11
|
+
issuer: string;
|
|
12
|
+
authorization_endpoint: string;
|
|
13
|
+
token_endpoint: string;
|
|
14
|
+
registration_endpoint?: string;
|
|
15
|
+
response_types_supported?: string[];
|
|
16
|
+
grant_types_supported?: string[];
|
|
17
|
+
code_challenge_methods_supported?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface StoredToken {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken?: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
clientId: string;
|
|
25
|
+
clientSecret?: string;
|
|
26
|
+
tokenEndpoint: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PkceChallenge {
|
|
30
|
+
verifier: string;
|
|
31
|
+
challenge: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function generatePkce(): Promise<PkceChallenge> {
|
|
35
|
+
const array = new Uint8Array(32);
|
|
36
|
+
crypto.getRandomValues(array);
|
|
37
|
+
const verifier = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
38
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
39
|
+
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
40
|
+
.replace(/\+/g, "-")
|
|
41
|
+
.replace(/\//g, "_")
|
|
42
|
+
.replace(/=+$/, "");
|
|
43
|
+
return { verifier, challenge };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Discover OAuth metadata from an MCP server */
|
|
47
|
+
async function discoverMetadata(serverUrl: string): Promise<OAuthMetadata> {
|
|
48
|
+
const base = new URL(serverUrl);
|
|
49
|
+
const metadataUrl = `${base.origin}/.well-known/oauth-authorization-server`;
|
|
50
|
+
|
|
51
|
+
const res = await fetch(metadataUrl);
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
// Fall back to default endpoints
|
|
54
|
+
return {
|
|
55
|
+
issuer: base.origin,
|
|
56
|
+
authorization_endpoint: `${base.origin}/authorize`,
|
|
57
|
+
token_endpoint: `${base.origin}/token`,
|
|
58
|
+
registration_endpoint: `${base.origin}/register`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return res.json();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Register as an OAuth client with the server */
|
|
65
|
+
async function registerClient(
|
|
66
|
+
metadata: OAuthMetadata,
|
|
67
|
+
redirectUri: string,
|
|
68
|
+
): Promise<{ clientId: string; clientSecret?: string }> {
|
|
69
|
+
if (!metadata.registration_endpoint) {
|
|
70
|
+
throw new Error("Server doesn't support dynamic client registration");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const res = await fetch(metadata.registration_endpoint, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
client_name: "mcpx",
|
|
78
|
+
redirect_uris: [redirectUri],
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new Error(`Client registration failed: ${res.status} ${await res.text()}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const body = await res.json();
|
|
87
|
+
return { clientId: body.client_id, clientSecret: body.client_secret };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Exchange authorization code for tokens */
|
|
91
|
+
async function exchangeCode(
|
|
92
|
+
tokenEndpoint: string,
|
|
93
|
+
code: string,
|
|
94
|
+
redirectUri: string,
|
|
95
|
+
clientId: string,
|
|
96
|
+
clientSecret: string | undefined,
|
|
97
|
+
codeVerifier: string,
|
|
98
|
+
): Promise<{ accessToken: string; refreshToken?: string; expiresIn: number }> {
|
|
99
|
+
const body: Record<string, string> = {
|
|
100
|
+
grant_type: "authorization_code",
|
|
101
|
+
code,
|
|
102
|
+
redirect_uri: redirectUri,
|
|
103
|
+
client_id: clientId,
|
|
104
|
+
code_verifier: codeVerifier,
|
|
105
|
+
};
|
|
106
|
+
if (clientSecret) body.client_secret = clientSecret;
|
|
107
|
+
|
|
108
|
+
const res = await fetch(tokenEndpoint, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
throw new Error(`Token exchange failed: ${res.status} ${await res.text()}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const data = await res.json();
|
|
119
|
+
return {
|
|
120
|
+
accessToken: data.access_token,
|
|
121
|
+
refreshToken: data.refresh_token,
|
|
122
|
+
expiresIn: data.expires_in ?? 3600,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Refresh an expired access token */
|
|
127
|
+
async function refreshAccessToken(
|
|
128
|
+
tokenEndpoint: string,
|
|
129
|
+
refreshToken: string,
|
|
130
|
+
clientId: string,
|
|
131
|
+
clientSecret?: string,
|
|
132
|
+
): Promise<{ accessToken: string; refreshToken?: string; expiresIn: number }> {
|
|
133
|
+
const body: Record<string, string> = {
|
|
134
|
+
grant_type: "refresh_token",
|
|
135
|
+
refresh_token: refreshToken,
|
|
136
|
+
client_id: clientId,
|
|
137
|
+
};
|
|
138
|
+
if (clientSecret) body.client_secret = clientSecret;
|
|
139
|
+
|
|
140
|
+
const res = await fetch(tokenEndpoint, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify(body),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
throw new Error(`Token refresh failed: ${res.status}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const data = await res.json();
|
|
151
|
+
return {
|
|
152
|
+
accessToken: data.access_token,
|
|
153
|
+
refreshToken: data.refresh_token ?? refreshToken,
|
|
154
|
+
expiresIn: data.expires_in ?? 3600,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getTokenPath(tokensDir: string, backendName: string): string {
|
|
159
|
+
return join(tokensDir, `${backendName}.json`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function loadToken(tokensDir: string, backendName: string): StoredToken | null {
|
|
163
|
+
const path = getTokenPath(tokensDir, backendName);
|
|
164
|
+
if (!existsSync(path)) return null;
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function saveToken(tokensDir: string, backendName: string, token: StoredToken): void {
|
|
173
|
+
mkdirSync(tokensDir, { recursive: true });
|
|
174
|
+
writeFileSync(getTokenPath(tokensDir, backendName), JSON.stringify(token, null, 2) + "\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Start a temporary local server to receive the OAuth callback */
|
|
178
|
+
function startCallbackServer(port: number): Promise<{ code: string; server: Server }> {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const server = createServer((req, res) => {
|
|
181
|
+
const url = new URL(req.url!, `http://localhost:${port}`);
|
|
182
|
+
const code = url.searchParams.get("code");
|
|
183
|
+
const error = url.searchParams.get("error");
|
|
184
|
+
|
|
185
|
+
if (error) {
|
|
186
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
187
|
+
res.end(
|
|
188
|
+
`<html><body><h2>Authorization failed: ${error}</h2><p>You can close this tab.</p></body></html>`,
|
|
189
|
+
);
|
|
190
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
191
|
+
server.close();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (code) {
|
|
196
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
197
|
+
res.end(
|
|
198
|
+
"<html><body><h2>Authorization successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>",
|
|
199
|
+
);
|
|
200
|
+
resolve({ code, server });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
res.writeHead(400);
|
|
205
|
+
res.end("Missing code parameter");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
server.listen(port, () => {
|
|
209
|
+
// Server ready
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
server.on("error", reject);
|
|
213
|
+
|
|
214
|
+
// Timeout after 5 minutes
|
|
215
|
+
setTimeout(
|
|
216
|
+
() => {
|
|
217
|
+
server.close();
|
|
218
|
+
reject(new Error("OAuth callback timeout (5 minutes)"));
|
|
219
|
+
},
|
|
220
|
+
5 * 60 * 1000,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface OAuthClientConfig {
|
|
226
|
+
redirectUri?: string;
|
|
227
|
+
callbackPort?: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get a valid access token for an HTTP backend.
|
|
232
|
+
* Handles the full OAuth flow: discovery → registration → auth code → token exchange.
|
|
233
|
+
* Caches tokens in .mcpx/tokens/ and refreshes when expired.
|
|
234
|
+
*/
|
|
235
|
+
export async function getAccessToken(
|
|
236
|
+
backendName: string,
|
|
237
|
+
serverUrl: string,
|
|
238
|
+
tokensDir: string,
|
|
239
|
+
oauthConfig: OAuthClientConfig = {},
|
|
240
|
+
): Promise<string> {
|
|
241
|
+
const callbackPort = oauthConfig.callbackPort ?? 9876;
|
|
242
|
+
const redirectUri = oauthConfig.redirectUri ?? `http://localhost:${callbackPort}/oauth/callback`;
|
|
243
|
+
|
|
244
|
+
// Check for cached token
|
|
245
|
+
const cached = loadToken(tokensDir, backendName);
|
|
246
|
+
if (cached) {
|
|
247
|
+
// Token still valid (with 60s buffer)
|
|
248
|
+
if (cached.expiresAt > Date.now() + 60_000) {
|
|
249
|
+
return cached.accessToken;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Try refresh
|
|
253
|
+
if (cached.refreshToken) {
|
|
254
|
+
try {
|
|
255
|
+
console.log(` ${backendName}: refreshing OAuth token...`);
|
|
256
|
+
const refreshed = await refreshAccessToken(
|
|
257
|
+
cached.tokenEndpoint,
|
|
258
|
+
cached.refreshToken,
|
|
259
|
+
cached.clientId,
|
|
260
|
+
cached.clientSecret,
|
|
261
|
+
);
|
|
262
|
+
const token: StoredToken = {
|
|
263
|
+
accessToken: refreshed.accessToken,
|
|
264
|
+
refreshToken: refreshed.refreshToken,
|
|
265
|
+
expiresAt: Date.now() + refreshed.expiresIn * 1000,
|
|
266
|
+
clientId: cached.clientId,
|
|
267
|
+
clientSecret: cached.clientSecret,
|
|
268
|
+
tokenEndpoint: cached.tokenEndpoint,
|
|
269
|
+
};
|
|
270
|
+
saveToken(tokensDir, backendName, token);
|
|
271
|
+
return token.accessToken;
|
|
272
|
+
} catch {
|
|
273
|
+
console.log(` ${backendName}: refresh failed, re-authenticating...`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Full OAuth flow
|
|
279
|
+
console.log(` ${backendName}: discovering OAuth metadata...`);
|
|
280
|
+
const metadata = await discoverMetadata(serverUrl);
|
|
281
|
+
|
|
282
|
+
console.log(` ${backendName}: registering OAuth client...`);
|
|
283
|
+
const { clientId, clientSecret } = await registerClient(metadata, redirectUri);
|
|
284
|
+
|
|
285
|
+
const pkce = await generatePkce();
|
|
286
|
+
const state = crypto.randomUUID();
|
|
287
|
+
|
|
288
|
+
const authUrl = new URL(metadata.authorization_endpoint);
|
|
289
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
290
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
291
|
+
authUrl.searchParams.set("response_type", "code");
|
|
292
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
293
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
294
|
+
authUrl.searchParams.set("state", state);
|
|
295
|
+
|
|
296
|
+
// Start callback server and prompt user
|
|
297
|
+
console.log(`\n ${backendName}: authorize mcpx at:\n`);
|
|
298
|
+
console.log(` ${authUrl.toString()}\n`);
|
|
299
|
+
console.log(` Waiting for callback on ${redirectUri}...\n`);
|
|
300
|
+
|
|
301
|
+
// Try to open browser automatically
|
|
302
|
+
try {
|
|
303
|
+
const open =
|
|
304
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
305
|
+
Bun.spawn([open, authUrl.toString()], {
|
|
306
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
307
|
+
});
|
|
308
|
+
} catch {
|
|
309
|
+
// Manual open is fine
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { code, server } = await startCallbackServer(callbackPort);
|
|
313
|
+
server.close();
|
|
314
|
+
|
|
315
|
+
console.log(` ${backendName}: exchanging code for token...`);
|
|
316
|
+
const tokens = await exchangeCode(
|
|
317
|
+
metadata.token_endpoint,
|
|
318
|
+
code,
|
|
319
|
+
redirectUri,
|
|
320
|
+
clientId,
|
|
321
|
+
clientSecret,
|
|
322
|
+
pkce.verifier,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const stored: StoredToken = {
|
|
326
|
+
accessToken: tokens.accessToken,
|
|
327
|
+
refreshToken: tokens.refreshToken,
|
|
328
|
+
expiresAt: Date.now() + tokens.expiresIn * 1000,
|
|
329
|
+
clientId,
|
|
330
|
+
clientSecret,
|
|
331
|
+
tokenEndpoint: metadata.token_endpoint,
|
|
332
|
+
};
|
|
333
|
+
saveToken(tokensDir, backendName, stored);
|
|
334
|
+
console.log(` ${backendName}: OAuth token saved`);
|
|
335
|
+
|
|
336
|
+
return stored.accessToken;
|
|
337
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
loadSkills,
|
|
8
|
+
saveSkill,
|
|
9
|
+
registerSkill,
|
|
10
|
+
recordExecution,
|
|
11
|
+
searchSkills,
|
|
12
|
+
generateSkillTypeDefs,
|
|
13
|
+
type Skill,
|
|
14
|
+
} from "./skills.js";
|
|
15
|
+
|
|
16
|
+
let skillsDir: string;
|
|
17
|
+
let skills: Map<string, Skill>;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
skillsDir = join(tmpdir(), `mcpx-skills-test-${Date.now()}`);
|
|
21
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
22
|
+
skills = new Map();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
try {
|
|
27
|
+
rmSync(skillsDir, { recursive: true });
|
|
28
|
+
} catch {}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("registerSkill", () => {
|
|
32
|
+
test("creates a new skill", () => {
|
|
33
|
+
const skill = registerSkill(skillsDir, skills, {
|
|
34
|
+
name: "find-errors",
|
|
35
|
+
description: "Find error logs in Loki",
|
|
36
|
+
code: 'const r = await grafana.queryLokiLogs({ logql: "{level=\\"error\\"}" }); return r;',
|
|
37
|
+
});
|
|
38
|
+
expect(skill.name).toBe("find-errors");
|
|
39
|
+
expect(skill.trust).toBe("untrusted");
|
|
40
|
+
expect(skill.stats.runs).toBe(0);
|
|
41
|
+
expect(skills.has("find-errors")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("updates an existing skill preserving stats", () => {
|
|
45
|
+
registerSkill(skillsDir, skills, {
|
|
46
|
+
name: "my-skill",
|
|
47
|
+
description: "v1",
|
|
48
|
+
code: "return 1;",
|
|
49
|
+
});
|
|
50
|
+
recordExecution(skillsDir, skills, "my-skill", true);
|
|
51
|
+
registerSkill(skillsDir, skills, {
|
|
52
|
+
name: "my-skill",
|
|
53
|
+
description: "v2",
|
|
54
|
+
code: "return 2;",
|
|
55
|
+
});
|
|
56
|
+
const updated = skills.get("my-skill")!;
|
|
57
|
+
expect(updated.description).toBe("v2");
|
|
58
|
+
expect(updated.code).toBe("return 2;");
|
|
59
|
+
expect(updated.stats.runs).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("recordExecution", () => {
|
|
64
|
+
test("increments success count", () => {
|
|
65
|
+
registerSkill(skillsDir, skills, {
|
|
66
|
+
name: "s1",
|
|
67
|
+
description: "test",
|
|
68
|
+
code: "return 1;",
|
|
69
|
+
});
|
|
70
|
+
recordExecution(skillsDir, skills, "s1", true);
|
|
71
|
+
recordExecution(skillsDir, skills, "s1", true);
|
|
72
|
+
expect(skills.get("s1")!.stats.successes).toBe(2);
|
|
73
|
+
expect(skills.get("s1")!.stats.runs).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("increments failure count", () => {
|
|
77
|
+
registerSkill(skillsDir, skills, {
|
|
78
|
+
name: "s1",
|
|
79
|
+
description: "test",
|
|
80
|
+
code: "return 1;",
|
|
81
|
+
});
|
|
82
|
+
recordExecution(skillsDir, skills, "s1", false);
|
|
83
|
+
expect(skills.get("s1")!.stats.failures).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("promotes to provisional at 10 runs with 90% success", () => {
|
|
87
|
+
registerSkill(skillsDir, skills, {
|
|
88
|
+
name: "s1",
|
|
89
|
+
description: "test",
|
|
90
|
+
code: "return 1;",
|
|
91
|
+
});
|
|
92
|
+
for (let i = 0; i < 9; i++) recordExecution(skillsDir, skills, "s1", true);
|
|
93
|
+
expect(skills.get("s1")!.trust).toBe("untrusted");
|
|
94
|
+
recordExecution(skillsDir, skills, "s1", true);
|
|
95
|
+
expect(skills.get("s1")!.trust).toBe("provisional");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("loadSkills", () => {
|
|
100
|
+
test("loads skills from directory", () => {
|
|
101
|
+
registerSkill(skillsDir, skills, {
|
|
102
|
+
name: "a",
|
|
103
|
+
description: "A",
|
|
104
|
+
code: "return 'a';",
|
|
105
|
+
});
|
|
106
|
+
registerSkill(skillsDir, skills, {
|
|
107
|
+
name: "b",
|
|
108
|
+
description: "B",
|
|
109
|
+
code: "return 'b';",
|
|
110
|
+
});
|
|
111
|
+
const loaded = loadSkills(skillsDir);
|
|
112
|
+
expect(loaded.size).toBe(2);
|
|
113
|
+
expect(loaded.get("a")!.description).toBe("A");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("returns empty map for missing directory", () => {
|
|
117
|
+
const loaded = loadSkills("/tmp/nonexistent-mcpx-skills-dir");
|
|
118
|
+
expect(loaded.size).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("searchSkills", () => {
|
|
123
|
+
test("finds by name", () => {
|
|
124
|
+
registerSkill(skillsDir, skills, {
|
|
125
|
+
name: "find-errors",
|
|
126
|
+
description: "Find errors",
|
|
127
|
+
code: "",
|
|
128
|
+
});
|
|
129
|
+
registerSkill(skillsDir, skills, {
|
|
130
|
+
name: "check-latency",
|
|
131
|
+
description: "Check API latency",
|
|
132
|
+
code: "",
|
|
133
|
+
});
|
|
134
|
+
const results = searchSkills(skills, "error");
|
|
135
|
+
expect(results).toHaveLength(1);
|
|
136
|
+
expect(results[0].name).toBe("find-errors");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("finds by description", () => {
|
|
140
|
+
registerSkill(skillsDir, skills, {
|
|
141
|
+
name: "s1",
|
|
142
|
+
description: "Query Grafana dashboards",
|
|
143
|
+
code: "",
|
|
144
|
+
});
|
|
145
|
+
const results = searchSkills(skills, "grafana");
|
|
146
|
+
expect(results).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("generateSkillTypeDefs", () => {
|
|
151
|
+
test("returns empty string for no skills", () => {
|
|
152
|
+
expect(generateSkillTypeDefs(new Map())).toBe("");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("generates type stubs with trust level", () => {
|
|
156
|
+
registerSkill(skillsDir, skills, {
|
|
157
|
+
name: "find-errors",
|
|
158
|
+
description: "Find errors in Loki",
|
|
159
|
+
code: "",
|
|
160
|
+
});
|
|
161
|
+
const defs = generateSkillTypeDefs(skills);
|
|
162
|
+
expect(defs).toContain("skill_find_errors");
|
|
163
|
+
expect(defs).toContain("[untrusted]");
|
|
164
|
+
expect(defs).toContain("Find errors in Loki");
|
|
165
|
+
});
|
|
166
|
+
});
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, watch } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface Skill {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
inputSchema: Record<string, unknown>;
|
|
8
|
+
code: string;
|
|
9
|
+
trust: TrustLevel;
|
|
10
|
+
stats: { runs: number; successes: number; failures: number };
|
|
11
|
+
createdAt: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TrustLevel = "untrusted" | "provisional" | "trusted";
|
|
16
|
+
|
|
17
|
+
function computeTrust(stats: Skill["stats"]): TrustLevel {
|
|
18
|
+
if (stats.runs >= 100 && stats.successes / stats.runs >= 0.95) return "trusted";
|
|
19
|
+
if (stats.runs >= 10 && stats.successes / stats.runs >= 0.9) return "provisional";
|
|
20
|
+
return "untrusted";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Load all skills from .mcpx/skills/ directory */
|
|
24
|
+
export function loadSkills(skillsDir: string): Map<string, Skill> {
|
|
25
|
+
const skills = new Map<string, Skill>();
|
|
26
|
+
if (!existsSync(skillsDir)) return skills;
|
|
27
|
+
|
|
28
|
+
for (const file of readdirSync(skillsDir)) {
|
|
29
|
+
if (!file.endsWith(".json")) continue;
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(join(skillsDir, file), "utf-8");
|
|
32
|
+
const skill: Skill = JSON.parse(raw);
|
|
33
|
+
skills.set(skill.name, skill);
|
|
34
|
+
} catch {
|
|
35
|
+
// skip invalid files
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return skills;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Save a skill to disk */
|
|
43
|
+
export function saveSkill(skillsDir: string, skill: Skill): void {
|
|
44
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
45
|
+
const filename = `${skill.name.replace(/[^a-zA-Z0-9_-]/g, "-")}.json`;
|
|
46
|
+
writeFileSync(join(skillsDir, filename), JSON.stringify(skill, null, 2) + "\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Register a new skill from working code */
|
|
50
|
+
export function registerSkill(
|
|
51
|
+
skillsDir: string,
|
|
52
|
+
skills: Map<string, Skill>,
|
|
53
|
+
params: {
|
|
54
|
+
name: string;
|
|
55
|
+
description: string;
|
|
56
|
+
inputSchema?: Record<string, unknown>;
|
|
57
|
+
code: string;
|
|
58
|
+
},
|
|
59
|
+
): Skill {
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const existing = skills.get(params.name);
|
|
62
|
+
|
|
63
|
+
const skill: Skill = {
|
|
64
|
+
name: params.name,
|
|
65
|
+
description: params.description,
|
|
66
|
+
inputSchema: params.inputSchema ?? { type: "object", properties: {} },
|
|
67
|
+
code: params.code,
|
|
68
|
+
trust: existing?.trust ?? "untrusted",
|
|
69
|
+
stats: existing?.stats ?? { runs: 0, successes: 0, failures: 0 },
|
|
70
|
+
createdAt: existing?.createdAt ?? now,
|
|
71
|
+
updatedAt: now,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
skills.set(skill.name, skill);
|
|
75
|
+
saveSkill(skillsDir, skill);
|
|
76
|
+
return skill;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Record execution result for trust progression */
|
|
80
|
+
export function recordExecution(
|
|
81
|
+
skillsDir: string,
|
|
82
|
+
skills: Map<string, Skill>,
|
|
83
|
+
name: string,
|
|
84
|
+
success: boolean,
|
|
85
|
+
): void {
|
|
86
|
+
const skill = skills.get(name);
|
|
87
|
+
if (!skill) return;
|
|
88
|
+
|
|
89
|
+
if (success) skill.stats.successes++;
|
|
90
|
+
else skill.stats.failures++;
|
|
91
|
+
skill.stats.runs++;
|
|
92
|
+
skill.trust = computeTrust(skill.stats);
|
|
93
|
+
skill.updatedAt = new Date().toISOString();
|
|
94
|
+
|
|
95
|
+
saveSkill(skillsDir, skill);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Search skills by query (simple text matching) */
|
|
99
|
+
export function searchSkills(skills: Map<string, Skill>, query: string): Skill[] {
|
|
100
|
+
const q = query.toLowerCase();
|
|
101
|
+
return Array.from(skills.values()).filter(
|
|
102
|
+
(s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Watch skills directory for changes */
|
|
107
|
+
export function watchSkills(
|
|
108
|
+
skillsDir: string,
|
|
109
|
+
skills: Map<string, Skill>,
|
|
110
|
+
onChange?: () => void,
|
|
111
|
+
): () => void {
|
|
112
|
+
if (!existsSync(skillsDir)) {
|
|
113
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
117
|
+
const watcher = watch(skillsDir, () => {
|
|
118
|
+
if (debounce) clearTimeout(debounce);
|
|
119
|
+
debounce = setTimeout(() => {
|
|
120
|
+
const fresh = loadSkills(skillsDir);
|
|
121
|
+
skills.clear();
|
|
122
|
+
for (const [name, skill] of fresh) {
|
|
123
|
+
skills.set(name, skill);
|
|
124
|
+
}
|
|
125
|
+
onChange?.();
|
|
126
|
+
}, 500);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return () => {
|
|
130
|
+
if (debounce) clearTimeout(debounce);
|
|
131
|
+
watcher.close();
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Generate type stubs for skills (for LLM context) */
|
|
136
|
+
export function generateSkillTypeDefs(skills: Map<string, Skill>): string {
|
|
137
|
+
if (skills.size === 0) return "";
|
|
138
|
+
|
|
139
|
+
const lines: string[] = ["// === Saved Skills ==="];
|
|
140
|
+
for (const skill of skills.values()) {
|
|
141
|
+
const params = skill.inputSchema?.properties
|
|
142
|
+
? Object.entries(skill.inputSchema.properties as Record<string, { type?: string }>)
|
|
143
|
+
.map(([k, v]) => `${k}: ${v.type ?? "any"}`)
|
|
144
|
+
.join(", ")
|
|
145
|
+
: "";
|
|
146
|
+
lines.push(
|
|
147
|
+
`// [${skill.trust}] ${skill.description}`,
|
|
148
|
+
`declare function skill_${skill.name.replace(/[^a-zA-Z0-9_]/g, "_")}(args: { ${params} }): Promise<any>;`,
|
|
149
|
+
"",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
package/src/stdio.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
1
3
|
// Entrypoint for stdio mode — Claude Code runs this directly as a subprocess
|
|
2
4
|
// Usage: mcpx stdio mcpx.json
|
|
3
5
|
// Or: bunx mcpx stdio mcpx.json
|
|
@@ -23,7 +25,8 @@ export async function startStdioServer(configPath: string): Promise<void> {
|
|
|
23
25
|
process.stderr.write(` config: ${configPath}\n`);
|
|
24
26
|
process.stderr.write(` backends: ${Object.keys(config.backends).join(", ")}\n`);
|
|
25
27
|
|
|
26
|
-
const
|
|
28
|
+
const tokensDir = join(configPath.replace(/[^/]+$/, ""), ".mcpx", "tokens");
|
|
29
|
+
const backends = await connectBackends(config.backends, { tokensDir });
|
|
27
30
|
|
|
28
31
|
if (backends.size === 0 && !config.failOpen) {
|
|
29
32
|
process.stderr.write("No backends connected. Use failOpen: true to start anyway.\n");
|
|
@@ -131,12 +134,8 @@ ${toolListing}`,
|
|
|
131
134
|
Write an async function body. Available tool functions (call with await):
|
|
132
135
|
${typeDefs}
|
|
133
136
|
|
|
134
|
-
Example
|
|
137
|
+
Example:
|
|
135
138
|
const result = await grafana.searchDashboards({ query: "pods" });
|
|
136
|
-
return result;
|
|
137
|
-
|
|
138
|
-
Example (classic style):
|
|
139
|
-
const result = await grafana_search_dashboards({ query: "pods" });
|
|
140
139
|
return result;`,
|
|
141
140
|
{ code: z.string().describe("JavaScript async function body to execute") },
|
|
142
141
|
async ({ code }) => {
|