@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.
@@ -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 backends = await connectBackends(config.backends);
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 (namespace style):
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 }) => {