Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions packages/cli/build-and-link.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/bin/bash

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'

echo -e "${BLUE}🔨 Building and linking @xmcp-dev/cli for local testing...${NC}\n"

# Ensure script runs from packages/cli
if [ ! -f "package.json" ] || [ ! -d "src" ]; then
echo -e "${RED}❌ Error: Run this script from packages/cli${NC}"
exit 1
fi

# Validate package name
if ! grep -q '"name": "@xmcp-dev/cli"' package.json; then
echo -e "${RED}❌ Error: This does not look like the @xmcp-dev/cli package${NC}"
exit 1
fi

# Install deps if needed
if [ ! -d "node_modules" ]; then
echo -e "${YELLOW}📦 Installing dependencies...${NC}"
pnpm install || {
echo -e "${RED}❌ Failed to install dependencies${NC}"
exit 1
}
echo -e "${GREEN}✅ Dependencies installed${NC}\n"
fi

# Clean previous build
echo -e "${YELLOW}🧹 Cleaning previous build...${NC}"
rm -rf dist/
echo -e "${GREEN}✅ Cleaned${NC}\n"

# Build CLI
echo -e "${YELLOW}🔨 Building CLI...${NC}"
pnpm build || {
echo -e "${RED}❌ Build failed${NC}"
exit 1
}
echo -e "${GREEN}✅ Build successful${NC}\n"

# Link globally
echo -e "${YELLOW}🔗 Linking CLI globally with pnpm...${NC}"
pnpm link --global || {
echo -e "${RED}❌ Failed to link CLI${NC}"
exit 1
}
echo -e "${GREEN}✅ CLI linked globally${NC}\n"

# Test command
echo -e "${BLUE}🧪 Testing CLI command...${NC}"
if command -v xmcp-dev-cli >/dev/null 2>&1; then
xmcp-dev-cli --help >/dev/null 2>&1 && echo -e "${GREEN}✅ CLI is accessible!${NC}\n"
else
echo -e "${YELLOW}⚠️ CLI command not found. Restart your terminal or ensure pnpm global bin dir is on PATH.${NC}\n"
fi

# Instructions
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}🎉 Success! @xmcp-dev/cli is now linked globally.${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"

echo -e "${BLUE}📋 To use it in another project:${NC}"
echo -e "${YELLOW} 1. Navigate to the project directory${NC}"
echo -e " cd /path/to/project"
echo -e ""
echo -e "${YELLOW} 2. Link the CLI into that project${NC}"
echo -e " pnpm link --global @xmcp-dev/cli"
echo -e ""
echo -e "${YELLOW} 3. Run the command${NC}"
echo -e " npx xmcp-dev-cli generate --help"
echo -e ""
echo -e "${BLUE}🔄 After changes:${NC}"
echo -e " Re-run this script to rebuild/link the CLI"
echo -e ""
echo -e "${BLUE}🧹 To unlink when finished:${NC}"
echo -e "${YELLOW} pnpm unlink --global @xmcp-dev/cli${NC}"
echo -e ""

51 changes: 51 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@xmcp-dev/cli",
"description": "Official XMCP developer CLI",
"version": "0.0.0",
"author": {
"name": "xmcp",
"email": "[email protected]",
"url": "https://xmcp.dev"
},
"contributors": [
{
"name": "Valentina Bearzotti",
"email": "[email protected]",
"url": "https://github.com/valebearzotti"
},
{
"name": "Jose Rago",
"email": "[email protected]",
"url": "https://github.com/ragojose"
}
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/basementstudio/xmcp",
"directory": "packages/cli"
},
"type": "module",
"bin": {
"xmcp-dev-cli": "./dist/index.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -w -p tsconfig.json",
"clean": "rm -rf dist"
},
"dependencies": {
"bundle-require": "^5.1.0",
"xmcp": "workspace:*",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/node": "^22",
"typescript": "^5.8.3"
}
}
157 changes: 157 additions & 0 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import fs from "node:fs";
import path from "node:path";
import pkg from "xmcp";
const { createHTTPClient } = pkg;
import {
loadClientDefinitions,
type ClientDefinition,
type LoadedClients,
} from "../utils/client-definitions.js";
import { pascalCase, toFileSafeName } from "../utils/naming.js";
import {
buildClientFileContents,
writeGeneratedClientsIndex,
type GeneratedFileInfo,
} from "../utils/templates.js";

export interface GenerateOptions {
url?: string;
out?: string;
clientsFile?: string;
}

const DEFAULT_OUTPUT = "src/generated/tools.ts";
const DEFAULT_CLIENTS_FILE = "src/clients.ts";

export async function runGenerate(options: GenerateOptions = {}) {
const loadedClients = await loadClientDefinitions(
options.clientsFile,
DEFAULT_CLIENTS_FILE
);
const clientDefinitions = loadedClients?.definitions ?? [];
const targets = resolveTargets(options.url, clientDefinitions);

logDetectedClients(loadedClients, clientDefinitions);

const {
resolvedOut,
outIsFile,
outputDir,
multiFileBaseName,
fileExtension,
} = resolveOutputPaths(options.out ?? DEFAULT_OUTPUT);

const generatedFiles: GeneratedFileInfo[] = [];

for (const target of targets) {
const client = await createHTTPClient({ url: target.url });
const { tools } = await client.listTools();
const exportName = `client${pascalCase(target.name)}`;

const outputPath =
targets.length === 1 && outIsFile
? resolvedOut
: path.join(
outputDir,
`${outIsFile ? `${multiFileBaseName}.` : ""}${toFileSafeName(target.name)}${
outIsFile ? path.extname(resolvedOut) : fileExtension
}`
);

fs.mkdirSync(path.dirname(outputPath), { recursive: true });

const fileContents = buildClientFileContents(
tools,
JSON.stringify(target.url),
exportName
);
fs.writeFileSync(outputPath, fileContents);

generatedFiles.push({
clientName: target.name,
exportName,
outputPath,
});

console.log(
`Generated ${tools.length} tool${
tools.length === 1 ? "" : "s"
} for "${target.name}" client -> ${path.relative(
process.cwd(),
outputPath
)}`
);
}

if (generatedFiles.length > 1) {
writeGeneratedClientsIndex(
generatedFiles,
path.join(outputDir, `${multiFileBaseName}.index.ts`)
);
}
}

function resolveTargets(
explicitUrl: string | undefined,
clientDefinitions: ClientDefinition[]
): ClientDefinition[] {
if (explicitUrl || process.env.MCP_URL) {
const resolvedUrl = explicitUrl ?? process.env.MCP_URL!;
return [
{
name: clientDefinitions[0]?.name ?? "client",
url: resolvedUrl,
},
];
}

if (clientDefinitions.length === 0) {
throw new Error(
"Unable to determine MCP URL. Provide --url, set MCP_URL, or add entries to clients.ts."
);
}

return clientDefinitions;
}

function logDetectedClients(
loadedClients: LoadedClients | undefined,
clientDefinitions: ClientDefinition[]
) {
if (!loadedClients || clientDefinitions.length === 0) {
return;
}

console.log(
`Detected ${clientDefinitions.length} client${
clientDefinitions.length === 1 ? "" : "s"
} from ${path.relative(process.cwd(), loadedClients.sourcePath)}`
);
}

type OutputPaths = {
resolvedOut: string;
outIsFile: boolean;
outputDir: string;
multiFileBaseName: string;
fileExtension: string;
};

function resolveOutputPaths(out: string): OutputPaths {
const resolvedOut = path.resolve(process.cwd(), out);
const outExt = path.extname(resolvedOut);
const outIsFile = outExt === ".ts";
const outputDir = outIsFile ? path.dirname(resolvedOut) : resolvedOut;
const baseFileName = outIsFile
? path.basename(resolvedOut, outExt)
: "client";
const multiFileBaseName = outIsFile ? "client" : baseFileName;

return {
resolvedOut,
outIsFile,
outputDir,
multiFileBaseName,
fileExtension: ".ts",
};
}
97 changes: 97 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node
import { runGenerate } from "./commands/generate.js";

interface ParsedArgs {
command?: string;
options: Record<string, string | boolean | undefined>;
helpRequested: boolean;
}

const HELP_TEXT = `xmcp Developer CLI

Usage:
npx @xmcp-dev/cli <command> [options]

Commands:
generate Generate a typed remote tool client file

Options:
-u, --url <url> MCP server URL (overrides clients.ts)
-o, --out <path> Output file path (default: src/generated/tools.ts)
-c, --clients <path> Path to clients config (default: src/clients.ts)
-h, --help Show this help message
`;

function parseArgs(argv: string[]): ParsedArgs {
const [, , maybeCommand, ...rest] = argv;
const options: Record<string, string | boolean> = {};
let helpRequested = false;

const positionalCommand =
maybeCommand && !maybeCommand.startsWith("-") ? maybeCommand : undefined;
const args = positionalCommand ? rest : [maybeCommand, ...rest];

for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg) continue;

switch (arg) {
case "-h":
case "--help":
helpRequested = true;
break;
case "-u":
case "--url":
options.url = args[++i];
break;
case "-o":
case "--out":
options.out = args[++i];
break;
case "-c":
case "--clients":
options.clients = args[++i];
break;
default:
// ignore unknown flags for now
break;
}
}

return { command: positionalCommand, options, helpRequested };
}

function printHelp() {
console.log(HELP_TEXT);
}

async function main() {
const { command, options, helpRequested } = parseArgs(process.argv);

if (!command || helpRequested || command === "help") {
printHelp();
if (command && command !== "help") {
process.exit(1);
}
return;
}

if (command !== "generate") {
console.error(`Unknown command "${command}".\n`);
printHelp();
process.exit(1);
return;
}

await runGenerate({
url: typeof options.url === "string" ? options.url : undefined,
out: typeof options.out === "string" ? options.out : undefined,
clientsFile:
typeof options.clients === "string" ? options.clients : undefined,
});
}

main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
Loading