Skip to content

Commit eb3b05d

Browse files
Merge pull request #293 from basementstudio/xmcp-devcli-generate-package-xmcp-250
@xmcp-dev/cli
2 parents c9bf821 + 5a18291 commit eb3b05d

File tree

10 files changed

+887
-277
lines changed

10 files changed

+887
-277
lines changed

packages/cli/build-and-link.sh

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/bin/bash
2+
3+
# Colors
4+
RED='\033[0;31m'
5+
GREEN='\033[0;32m'
6+
YELLOW='\033[1;33m'
7+
BLUE='\033[0;34m'
8+
CYAN='\033[0;36m'
9+
NC='\033[0m'
10+
11+
echo -e "${BLUE}🔨 Building and linking @xmcp-dev/cli for local testing...${NC}\n"
12+
13+
# Ensure script runs from packages/cli
14+
if [ ! -f "package.json" ] || [ ! -d "src" ]; then
15+
echo -e "${RED}❌ Error: Run this script from packages/cli${NC}"
16+
exit 1
17+
fi
18+
19+
# Validate package name
20+
if ! grep -q '"name": "@xmcp-dev/cli"' package.json; then
21+
echo -e "${RED}❌ Error: This does not look like the @xmcp-dev/cli package${NC}"
22+
exit 1
23+
fi
24+
25+
# Install deps if needed
26+
if [ ! -d "node_modules" ]; then
27+
echo -e "${YELLOW}📦 Installing dependencies...${NC}"
28+
pnpm install || {
29+
echo -e "${RED}❌ Failed to install dependencies${NC}"
30+
exit 1
31+
}
32+
echo -e "${GREEN}✅ Dependencies installed${NC}\n"
33+
fi
34+
35+
# Clean previous build
36+
echo -e "${YELLOW}🧹 Cleaning previous build...${NC}"
37+
rm -rf dist/
38+
echo -e "${GREEN}✅ Cleaned${NC}\n"
39+
40+
# Build CLI
41+
echo -e "${YELLOW}🔨 Building CLI...${NC}"
42+
pnpm build || {
43+
echo -e "${RED}❌ Build failed${NC}"
44+
exit 1
45+
}
46+
echo -e "${GREEN}✅ Build successful${NC}\n"
47+
48+
# Link globally
49+
echo -e "${YELLOW}🔗 Linking CLI globally with pnpm...${NC}"
50+
pnpm link --global || {
51+
echo -e "${RED}❌ Failed to link CLI${NC}"
52+
exit 1
53+
}
54+
echo -e "${GREEN}✅ CLI linked globally${NC}\n"
55+
56+
# Test command
57+
echo -e "${BLUE}🧪 Testing CLI command...${NC}"
58+
if command -v xmcp-dev-cli >/dev/null 2>&1; then
59+
xmcp-dev-cli --help >/dev/null 2>&1 && echo -e "${GREEN}✅ CLI is accessible!${NC}\n"
60+
else
61+
echo -e "${YELLOW}⚠️ CLI command not found. Restart your terminal or ensure pnpm global bin dir is on PATH.${NC}\n"
62+
fi
63+
64+
# Instructions
65+
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
66+
echo -e "${GREEN}🎉 Success! @xmcp-dev/cli is now linked globally.${NC}"
67+
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
68+
69+
echo -e "${BLUE}📋 To use it in another project:${NC}"
70+
echo -e "${YELLOW} 1. Navigate to the project directory${NC}"
71+
echo -e " cd /path/to/project"
72+
echo -e ""
73+
echo -e "${YELLOW} 2. Link the CLI into that project${NC}"
74+
echo -e " pnpm link --global @xmcp-dev/cli"
75+
echo -e ""
76+
echo -e "${YELLOW} 3. Run the command${NC}"
77+
echo -e " npx xmcp-dev-cli generate --help"
78+
echo -e ""
79+
echo -e "${BLUE}🔄 After changes:${NC}"
80+
echo -e " Re-run this script to rebuild/link the CLI"
81+
echo -e ""
82+
echo -e "${BLUE}🧹 To unlink when finished:${NC}"
83+
echo -e "${YELLOW} pnpm unlink --global @xmcp-dev/cli${NC}"
84+
echo -e ""
85+

packages/cli/package.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "@xmcp-dev/cli",
3+
"description": "Official XMCP developer CLI",
4+
"version": "0.0.0",
5+
"author": {
6+
"name": "xmcp",
7+
"email": "[email protected]",
8+
"url": "https://xmcp.dev"
9+
},
10+
"contributors": [
11+
{
12+
"name": "Valentina Bearzotti",
13+
"email": "[email protected]",
14+
"url": "https://github.com/valebearzotti"
15+
},
16+
{
17+
"name": "Jose Rago",
18+
"email": "[email protected]",
19+
"url": "https://github.com/ragojose"
20+
}
21+
],
22+
"license": "MIT",
23+
"repository": {
24+
"type": "git",
25+
"url": "https://github.com/basementstudio/xmcp",
26+
"directory": "packages/cli"
27+
},
28+
"type": "module",
29+
"bin": {
30+
"xmcp-dev-cli": "./dist/index.js"
31+
},
32+
"main": "./dist/index.js",
33+
"types": "./dist/index.d.ts",
34+
"files": [
35+
"dist"
36+
],
37+
"scripts": {
38+
"build": "tsc -p tsconfig.json",
39+
"dev": "tsc -w -p tsconfig.json",
40+
"clean": "rm -rf dist"
41+
},
42+
"dependencies": {
43+
"bundle-require": "^5.1.0",
44+
"xmcp": "workspace:*",
45+
"zod": "^4.1.13"
46+
},
47+
"devDependencies": {
48+
"@types/node": "^22",
49+
"typescript": "^5.8.3"
50+
}
51+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import pkg from "xmcp";
4+
const { createHTTPClient } = pkg;
5+
import {
6+
loadClientDefinitions,
7+
type ClientDefinition,
8+
type LoadedClients,
9+
} from "../utils/client-definitions.js";
10+
import { pascalCase, toFileSafeName } from "../utils/naming.js";
11+
import {
12+
buildClientFileContents,
13+
writeGeneratedClientsIndex,
14+
type GeneratedFileInfo,
15+
} from "../utils/templates.js";
16+
17+
export interface GenerateOptions {
18+
url?: string;
19+
out?: string;
20+
clientsFile?: string;
21+
}
22+
23+
const DEFAULT_OUTPUT = "src/generated/tools.ts";
24+
const DEFAULT_CLIENTS_FILE = "src/clients.ts";
25+
26+
export async function runGenerate(options: GenerateOptions = {}) {
27+
const loadedClients = await loadClientDefinitions(
28+
options.clientsFile,
29+
DEFAULT_CLIENTS_FILE
30+
);
31+
const clientDefinitions = loadedClients?.definitions ?? [];
32+
const targets = resolveTargets(options.url, clientDefinitions);
33+
34+
logDetectedClients(loadedClients, clientDefinitions);
35+
36+
const {
37+
resolvedOut,
38+
outIsFile,
39+
outputDir,
40+
multiFileBaseName,
41+
fileExtension,
42+
} = resolveOutputPaths(options.out ?? DEFAULT_OUTPUT);
43+
44+
const generatedFiles: GeneratedFileInfo[] = [];
45+
46+
for (const target of targets) {
47+
const client = await createHTTPClient({ url: target.url });
48+
const { tools } = await client.listTools();
49+
const exportName = `client${pascalCase(target.name)}`;
50+
51+
const outputPath =
52+
targets.length === 1 && outIsFile
53+
? resolvedOut
54+
: path.join(
55+
outputDir,
56+
`${outIsFile ? `${multiFileBaseName}.` : ""}${toFileSafeName(target.name)}${
57+
outIsFile ? path.extname(resolvedOut) : fileExtension
58+
}`
59+
);
60+
61+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
62+
63+
const fileContents = buildClientFileContents(
64+
tools,
65+
JSON.stringify(target.url),
66+
exportName
67+
);
68+
fs.writeFileSync(outputPath, fileContents);
69+
70+
generatedFiles.push({
71+
clientName: target.name,
72+
exportName,
73+
outputPath,
74+
});
75+
76+
console.log(
77+
`Generated ${tools.length} tool${
78+
tools.length === 1 ? "" : "s"
79+
} for "${target.name}" client -> ${path.relative(
80+
process.cwd(),
81+
outputPath
82+
)}`
83+
);
84+
}
85+
86+
if (generatedFiles.length > 1) {
87+
writeGeneratedClientsIndex(
88+
generatedFiles,
89+
path.join(outputDir, `${multiFileBaseName}.index.ts`)
90+
);
91+
}
92+
}
93+
94+
function resolveTargets(
95+
explicitUrl: string | undefined,
96+
clientDefinitions: ClientDefinition[]
97+
): ClientDefinition[] {
98+
if (explicitUrl || process.env.MCP_URL) {
99+
const resolvedUrl = explicitUrl ?? process.env.MCP_URL!;
100+
return [
101+
{
102+
name: clientDefinitions[0]?.name ?? "client",
103+
url: resolvedUrl,
104+
},
105+
];
106+
}
107+
108+
if (clientDefinitions.length === 0) {
109+
throw new Error(
110+
"Unable to determine MCP URL. Provide --url, set MCP_URL, or add entries to clients.ts."
111+
);
112+
}
113+
114+
return clientDefinitions;
115+
}
116+
117+
function logDetectedClients(
118+
loadedClients: LoadedClients | undefined,
119+
clientDefinitions: ClientDefinition[]
120+
) {
121+
if (!loadedClients || clientDefinitions.length === 0) {
122+
return;
123+
}
124+
125+
console.log(
126+
`Detected ${clientDefinitions.length} client${
127+
clientDefinitions.length === 1 ? "" : "s"
128+
} from ${path.relative(process.cwd(), loadedClients.sourcePath)}`
129+
);
130+
}
131+
132+
type OutputPaths = {
133+
resolvedOut: string;
134+
outIsFile: boolean;
135+
outputDir: string;
136+
multiFileBaseName: string;
137+
fileExtension: string;
138+
};
139+
140+
function resolveOutputPaths(out: string): OutputPaths {
141+
const resolvedOut = path.resolve(process.cwd(), out);
142+
const outExt = path.extname(resolvedOut);
143+
const outIsFile = outExt === ".ts";
144+
const outputDir = outIsFile ? path.dirname(resolvedOut) : resolvedOut;
145+
const baseFileName = outIsFile
146+
? path.basename(resolvedOut, outExt)
147+
: "client";
148+
const multiFileBaseName = outIsFile ? "client" : baseFileName;
149+
150+
return {
151+
resolvedOut,
152+
outIsFile,
153+
outputDir,
154+
multiFileBaseName,
155+
fileExtension: ".ts",
156+
};
157+
}

packages/cli/src/index.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env node
2+
import { runGenerate } from "./commands/generate.js";
3+
4+
interface ParsedArgs {
5+
command?: string;
6+
options: Record<string, string | boolean | undefined>;
7+
helpRequested: boolean;
8+
}
9+
10+
const HELP_TEXT = `xmcp Developer CLI
11+
12+
Usage:
13+
npx @xmcp-dev/cli <command> [options]
14+
15+
Commands:
16+
generate Generate a typed remote tool client file
17+
18+
Options:
19+
-u, --url <url> MCP server URL (overrides clients.ts)
20+
-o, --out <path> Output file path (default: src/generated/tools.ts)
21+
-c, --clients <path> Path to clients config (default: src/clients.ts)
22+
-h, --help Show this help message
23+
`;
24+
25+
function parseArgs(argv: string[]): ParsedArgs {
26+
const [, , maybeCommand, ...rest] = argv;
27+
const options: Record<string, string | boolean> = {};
28+
let helpRequested = false;
29+
30+
const positionalCommand =
31+
maybeCommand && !maybeCommand.startsWith("-") ? maybeCommand : undefined;
32+
const args = positionalCommand ? rest : [maybeCommand, ...rest];
33+
34+
for (let i = 0; i < args.length; i++) {
35+
const arg = args[i];
36+
if (!arg) continue;
37+
38+
switch (arg) {
39+
case "-h":
40+
case "--help":
41+
helpRequested = true;
42+
break;
43+
case "-u":
44+
case "--url":
45+
options.url = args[++i];
46+
break;
47+
case "-o":
48+
case "--out":
49+
options.out = args[++i];
50+
break;
51+
case "-c":
52+
case "--clients":
53+
options.clients = args[++i];
54+
break;
55+
default:
56+
// ignore unknown flags for now
57+
break;
58+
}
59+
}
60+
61+
return { command: positionalCommand, options, helpRequested };
62+
}
63+
64+
function printHelp() {
65+
console.log(HELP_TEXT);
66+
}
67+
68+
async function main() {
69+
const { command, options, helpRequested } = parseArgs(process.argv);
70+
71+
if (!command || helpRequested || command === "help") {
72+
printHelp();
73+
if (command && command !== "help") {
74+
process.exit(1);
75+
}
76+
return;
77+
}
78+
79+
if (command !== "generate") {
80+
console.error(`Unknown command "${command}".\n`);
81+
printHelp();
82+
process.exit(1);
83+
return;
84+
}
85+
86+
await runGenerate({
87+
url: typeof options.url === "string" ? options.url : undefined,
88+
out: typeof options.out === "string" ? options.out : undefined,
89+
clientsFile:
90+
typeof options.clients === "string" ? options.clients : undefined,
91+
});
92+
}
93+
94+
main().catch((error) => {
95+
console.error(error instanceof Error ? error.message : error);
96+
process.exit(1);
97+
});

0 commit comments

Comments
 (0)