diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..bfd2fbd --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,134 @@ +# Labeler configuration for mcp-execution project +# Documentation: https://github.com/actions/labeler + +# Crate-specific labels +'crate: mcp-core': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-core/**/*' + - 'crates/mcp-core/*' + +'crate: mcp-introspector': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-introspector/**/*' + - 'crates/mcp-introspector/*' + +'crate: mcp-codegen': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-codegen/**/*' + - 'crates/mcp-codegen/*' + +'crate: mcp-files': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-files/**/*' + - 'crates/mcp-files/*' + +'crate: mcp-cli': + - changed-files: + - any-glob-to-any-file: + - 'crates/mcp-cli/**/*' + - 'crates/mcp-cli/*' + +# Type labels +'type: documentation': + - changed-files: + - any-glob-to-any-file: + - 'docs/**/*' + - '**/*.md' + - 'LICENSE' + - 'CHANGELOG.md' + - 'CLAUDE.md' + +'type: ci': + - changed-files: + - any-glob-to-any-file: + - '.github/**/*' + - '.github/workflows/**/*' + - 'deny.toml' + - 'codecov.yml' + - 'rust-toolchain.toml' + +'type: dependencies': + - changed-files: + - any-glob-to-any-file: + - '**/Cargo.toml' + - 'Cargo.lock' + - '.cargo/**/*' + +'type: tests': + - changed-files: + - any-glob-to-any-file: + - 'tests/**/*' + - '**/*_test.rs' + - '**/tests.rs' + - '**/test_*.rs' + +'type: benchmarks': + - changed-files: + - any-glob-to-any-file: + - 'benches/**/*' + - '**/*_bench.rs' + - '**/bench_*.rs' + +'type: examples': + - changed-files: + - any-glob-to-any-file: + - 'examples/**/*' + +# Architecture Decision Records +'adr': + - changed-files: + - any-glob-to-any-file: + - 'docs/adr/**/*' + +# Build configuration +'build': + - changed-files: + - any-glob-to-any-file: + - 'Cargo.toml' + - 'build.rs' + - '**/build.rs' + - 'rust-toolchain.toml' + +# Security +'security': + - changed-files: + - any-glob-to-any-file: + - 'deny.toml' + - 'SECURITY.md' + +# Multiple crates (workspace-wide changes) +'workspace': + - changed-files: + - all-globs-to-all-files: + - 'crates/*/Cargo.toml' + - any-glob-to-any-file: + - 'Cargo.toml' + - 'Cargo.lock' + +# Breaking changes detection (based on file patterns) +'breaking change': + - changed-files: + - any-glob-to-any-file: + - 'crates/*/src/lib.rs' + - 'crates/*/src/types.rs' + - 'crates/*/src/error.rs' + - 'CHANGELOG.md' + - head-branch: + - '^breaking/.*' + - '^major/.*' + +# Release preparation +'release': + - changed-files: + - any-glob-to-any-file: + - 'CHANGELOG.md' + - 'Cargo.toml' + - all-globs-to-all-files: + - 'crates/*/Cargo.toml' + - head-branch: + - '^release/.*' + - '^v[0-9]+\.[0-9]+\.[0-9]+.*' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..ba5823a --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,23 @@ +name: PR Labeler + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + name: Label Pull Request + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Apply labels + uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + sync-labels: true diff --git a/Cargo.lock b/Cargo.lock index 599fee3..3e3f631 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -170,9 +179,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.47" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -325,10 +334,11 @@ dependencies = [ [[package]] name = "criterion" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +checksum = "a0dfe5e9e71bdcf4e4954f7d14da74d1cdb92a3a07686452d1509652684b1aab" dependencies = [ + "alloca", "anes", "cast", "ciborium", @@ -337,6 +347,7 @@ dependencies = [ "itertools", "num-traits", "oorandom", + "page_size", "plotters", "rayon", "regex", @@ -349,9 +360,9 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.6.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +checksum = "5de36c2bee19fba779808f92bf5d9b0fa5a40095c277aba10c458a12b35d21d6" dependencies = [ "cast", "itertools", @@ -846,9 +857,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -862,9 +873,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libredox" @@ -893,9 +904,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "matchers" @@ -1009,9 +1020,9 @@ checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1096,6 +1107,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1120,10 +1141,10 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "pastey" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "57d6c094ee800037dff99e02cab0eaf3142826586742a270ab3d7a62656bd27a" [[package]] name = "pest" @@ -1375,15 +1396,15 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rmcp" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaa07b85b779d1e1df52dd79f6c6bffbe005b191f07290136cc42a142da3409a" +checksum = "38b18323edc657390a6ed4d7a9110b0dec2dc3ed128eb2a123edfbafabdbddc5" dependencies = [ "async-trait", "base64", "chrono", "futures", - "paste", + "pastey", "pin-project-lite", "process-wrap", "rmcp-macros", @@ -1399,9 +1420,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f6fa09933cac0d0204c8a5d647f558425538ed6a0134b1ebb1ae4dc00c96db3" +checksum = "c75d0a62676bf8c8003c4e3c348e2ceb6a7b3e48323681aaf177fdccdac2ce50" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -1770,9 +1791,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1781,9 +1802,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -1792,9 +1813,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -1823,9 +1844,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -1874,9 +1895,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -1923,9 +1944,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -1936,9 +1957,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1946,9 +1967,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -1959,18 +1980,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -1987,6 +2008,22 @@ dependencies = [ "winsafe", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1996,6 +2033,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.61.3" @@ -2228,9 +2271,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" [[package]] name = "winsafe" @@ -2246,18 +2289,18 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "zerocopy" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9b20f6b..f5252f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ chrono = "0.4" clap = "4.5" clap_complete = "4.5" colored = "3.0" -criterion = "0.7" +criterion = "0.8" dhat = "0.3" dialoguer = "0.12" dirs = "6.0" @@ -29,7 +29,7 @@ mcp-core = { path = "crates/mcp-core" } mcp-introspector = { path = "crates/mcp-introspector" } mcp-files = { path = "crates/mcp-files" } rayon = "1.11" -rmcp = "0.9" +rmcp = "0.10" serde = "1.0" serde_json = "1.0" static_assertions = "1.1" @@ -39,7 +39,7 @@ tokio = "1.48" toml = "0.9" tracing = "0.1" tracing-subscriber = "0.3" -uuid = "1.11" +uuid = "1.19" which = "8.0" [workspace.lints.rust] diff --git a/README.md b/README.md index f9523f1..d06ec88 100644 --- a/README.md +++ b/README.md @@ -474,12 +474,79 @@ The SKILL.md file includes: ### `generate` - Generate TypeScript Tools -Generate type-safe TypeScript files from MCP servers using progressive loading pattern. +Generate type-safe TypeScript files from MCP servers. Supports two output formats: + +#### Output Formats + +| Format | Use Case | Output Location | +|--------|----------|-----------------| +| `progressive` (default) | Claude Code integration | `~/.claude/servers/` | +| `claude-agent` | Claude Agent SDK integration | `~/.claude/agent-sdk/` | + +#### Progressive Format (Default) + +Progressive loading pattern with one file per tool for 98% token savings: ```bash # From mcp.json config (recommended) mcp-execution-cli generate --from-config github +# Explicit format +mcp-execution-cli generate --from-config github --generator progressive +``` + +**Generated Structure** (`~/.claude/servers/github/`): +``` +github/ +├── createIssue.ts # One tool file (500-1,500 tokens) +├── updateIssue.ts +├── index.ts # Re-exports all tools +└── _runtime/ + └── mcp-bridge.ts # Runtime connection manager +``` + +#### Claude Agent SDK Format + +Zod schemas for [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/typescript) integration: + +```bash +# Generate Claude Agent SDK format +mcp-execution-cli generate --from-config github --generator claude-agent +``` + +**Generated Structure** (`~/.claude/agent-sdk/github/`): +``` +github/ +├── index.ts # Entry point with exports +├── server.ts # createSdkMcpServer() definition +└── tools/ + ├── createIssue.ts # Tool with Zod schema + └── updateIssue.ts +``` + +**Example Generated Tool** (`tools/createIssue.ts`): +```typescript +import { tool } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; + +export const createIssue = tool( + "create_issue", + "Creates a new issue", + { + repo: z.string().describe("Repository in owner/repo format"), + title: z.string().describe("Issue title"), + body: z.string().optional().describe("Issue body") + }, + async (args) => { + // Tool implementation + return { content: [{ type: "text", text: JSON.stringify(args) }] }; + } +); +``` + +#### Common Options + +```bash # Stdio transport (npx) mcp-execution-cli generate npx -y @modelcontextprotocol/server-github \ --env GITHUB_TOKEN=ghp_xxx @@ -498,21 +565,21 @@ mcp-execution-cli generate docker \ --env=API_KEY=xxx # Custom output directory -mcp-execution-cli generate github --progressive-output /custom/path +mcp-execution-cli generate github --output /custom/path # Custom server name (affects output directory) mcp-execution-cli generate npx -y @server/package --name custom-name -# Output: ~/.claude/servers/custom-name/ (not ~/.claude/servers/npx/) ``` **Options**: - `--from-config `: Load configuration from `mcp.json` +- `--generator `: Output format (`progressive` or `claude-agent`) - `--http `: Use HTTP transport - `--sse `: Use SSE transport - `--header `: Add HTTP headers (repeatable) - `--env `: Set environment variables (repeatable) - `--arg `: Add command arguments (repeatable) -- `--progressive-output `: Custom output directory +- `--output `: Custom output directory - `--name `: Custom server name (affects directory) ### `setup` - Initialize Configuration diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1f606a9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,206 @@ +# Security Policy + +## Supported Versions + +We release security updates for the following versions: + +| Version | Supported | +| ------- | ------------------ | +| 0.5.x | :white_check_mark: | +| < 0.5 | :x: | + +## Reporting a Vulnerability + +**Please do NOT report security vulnerabilities through public GitHub issues.** + +If you discover a security vulnerability in mcp-execution, please report it responsibly: + +### How to Report + +1. **Email**: Send details to **k05h31@gmail.com** + - Subject: `[SECURITY] mcp-execution vulnerability` + - Include: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +2. **GitHub Security Advisory** (preferred): + - Go to https://github.com/bug-ops/mcp-execution/security/advisories/new + - Fill out the security advisory form + - Our team will be notified privately + +### What to Expect + +- **Initial Response**: Within 48 hours +- **Triage**: Within 7 days (assessment of severity and impact) +- **Fix Development**: Depends on severity + - Critical: 1-3 days + - High: 7-14 days + - Medium: 14-30 days + - Low: Next regular release + +### Disclosure Policy + +We follow **coordinated disclosure**: + +1. You report the vulnerability privately +2. We confirm receipt and assess severity +3. We develop and test a fix +4. We release a patched version +5. We publish a security advisory (crediting you, if desired) +6. You may publicly disclose 7 days after the patch release + +### Security Update Process + +When a security vulnerability is confirmed: + +1. **Patch Development**: + - Create fix in private branch + - Add regression tests + - Review by multiple maintainers + +2. **Release**: + - Version bump (patch for fixes, minor for features) + - Update CHANGELOG.md with security notice + - Publish to crates.io + - Create GitHub release with security tag + +3. **Notification**: + - Publish GitHub Security Advisory + - Notify users via repository watch notifications + - Update documentation + +## Security Best Practices for Users + +### When Using mcp-execution + +1. **Keep Dependencies Updated**: + ```bash + cargo update + cargo outdated # Check for newer versions + ``` + +2. **Validate Server Configurations**: + - Only run trusted MCP servers + - Review server command and arguments before execution + - Avoid passing secrets as command-line arguments (use environment variables) + +3. **Protect Configuration Files**: + ```bash + # Set restrictive permissions on config files + chmod 600 ~/.claude/mcp.json + ``` + +4. **Review Generated Code**: + - Inspect generated TypeScript before execution + - Use `--output` to review files before deployment + +5. **Monitor Logs**: + - Enable `--verbose` mode to see security-relevant events + - Review logs for unusual server behavior + +### For Contributors + +1. **Security-Focused Development**: + - Follow [Microsoft Rust Guidelines](https://microsoft.github.io/rust-guidelines/) + - Never use `unsafe` without documented rationale and review + - Validate all external inputs (arguments, JSON schemas, file paths) + - Use `cargo clippy` and `cargo deny` before committing + +2. **Testing**: + - Write tests for security-critical code paths + - Include negative tests (malformed input, attack scenarios) + - Run full test suite: `cargo nextest run --workspace` + +3. **Dependencies**: + - Check dependency versions via Context7 MCP before adding + - Run `cargo deny check` to verify security and licenses + - Prefer well-maintained crates with >1M downloads + +## Security Features + +mcp-execution implements defense-in-depth security: + +### Command Injection Prevention + +- Validates all server commands and arguments for shell metacharacters +- Blocks dangerous environment variables (`LD_PRELOAD`, `DYLD_*`, `PATH`) +- Uses parameterized subprocess execution (no shell interpretation) +- See: `crates/mcp-core/src/command.rs` + +### Path Traversal Protection + +- Validates all file paths for directory traversal attempts +- Blocks paths containing `..` +- Canonicalizes base paths before file operations +- See: `crates/mcp-files/src/builder.rs` + +### Input Validation + +- JSON schema parsing is read-only (no code execution) +- Template engine uses strict mode (fails on missing variables) +- CLI arguments parsed with type-safe `clap` library +- All user inputs validated before use + +### Memory Safety + +- **Zero unsafe code**: All crates enforce `#![deny(unsafe_code)]` +- Strong type system prevents common bugs +- Rust's ownership model eliminates use-after-free, data races + +### Dependency Security + +- All dependencies scanned for known vulnerabilities +- Regular updates with `cargo-deny` checks +- License compliance enforcement + +## Known Security Limitations + +### By Design + +1. **Local Execution Only**: + - mcp-execution spawns local processes (MCP servers) + - Trusts that server binaries are not malicious + - **Recommendation**: Only run servers from trusted sources + +2. **Configuration File Security**: + - `~/.claude/mcp.json` may contain API tokens + - File permissions not enforced by CLI (user responsibility) + - **Recommendation**: Use `chmod 600` on config files + +3. **Generated Code Execution**: + - Generated TypeScript is executed by Node.js + - No sandboxing of generated code + - **Recommendation**: Review generated code before use + +### Future Improvements + +- [ ] Add WASM sandbox for tool execution +- [ ] Implement server signature verification +- [ ] Add config file encryption option +- [ ] Runtime security policy enforcement + +## External Security Resources + +- **RustSec Advisory Database**: https://rustsec.org/ +- **Rust Security Working Group**: https://www.rust-lang.org/governance/wgs/wg-security +- **OWASP Rust Security**: https://owasp.org/www-project-rust/ +- **CVE Database**: https://cve.mitre.org/ + +## Recognition + +We appreciate security researchers who responsibly disclose vulnerabilities. With your permission, we will credit you in: +- Security advisory +- CHANGELOG.md +- This SECURITY.md file + +## Contact + +- **Security Issues**: k05h31@gmail.com (private) +- **General Issues**: https://github.com/bug-ops/mcp-execution/issues (public) +- **Discussions**: https://github.com/bug-ops/mcp-execution/discussions + +--- + +**Thank you for helping keep mcp-execution secure!** diff --git a/crates/mcp-cli/src/commands/common.rs b/crates/mcp-cli/src/commands/common.rs index 46f9bc3..6a8d92e 100644 --- a/crates/mcp-cli/src/commands/common.rs +++ b/crates/mcp-cli/src/commands/common.rs @@ -108,12 +108,9 @@ pub fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> { /// /// # Errors /// -/// Returns an error if environment variables or headers are not in KEY=VALUE format. -/// -/// # Panics -/// -/// Panics if `server` is `None` when using stdio transport (i.e., when neither -/// `http` nor `sse` is provided). This is enforced by CLI argument validation. +/// Returns an error if: +/// - Environment variables or headers are not in KEY=VALUE format +/// - `server` is `None` when using stdio transport (neither `http` nor `sse` provided) /// /// # Examples /// @@ -180,7 +177,8 @@ pub fn build_server_config( (id, builder.build()) } else { // Stdio transport (default) - let command = server.expect("server is required for stdio transport"); + let command = server + .ok_or_else(|| anyhow::anyhow!("server command is required for stdio transport"))?; let id = ServerId::new(&command); let mut builder: ServerConfigBuilder = ServerConfig::builder().command(command); diff --git a/crates/mcp-cli/src/commands/generate.rs b/crates/mcp-cli/src/commands/generate.rs index 83dadd8..47b9e33 100644 --- a/crates/mcp-cli/src/commands/generate.rs +++ b/crates/mcp-cli/src/commands/generate.rs @@ -1,13 +1,19 @@ //! Generate command implementation. //! -//! Generates progressive loading TypeScript files from MCP server tool definitions. +//! Generates TypeScript files from MCP server tool definitions. +//! Supports two output formats: +//! - Progressive loading (one file per tool, for Claude Code) +//! - Claude Agent SDK (Zod schemas, for SDK integration) +//! //! This command: //! 1. Introspects the server to discover tools and schemas -//! 2. Generates TypeScript files for progressive loading (one file per tool) -//! 3. Saves files to `~/.claude/servers/{server-id}/` directory +//! 2. Generates TypeScript files in the selected format +//! 3. Saves files to the appropriate directory use super::common::{build_server_config, load_server_from_config}; +use crate::GeneratorFormat; use anyhow::{Context, Result}; +use mcp_codegen::claude_agent::ClaudeAgentGenerator; use mcp_codegen::progressive::ProgressiveGenerator; use mcp_core::cli::{ExitCode, OutputFormat}; use mcp_files::FilesBuilder; @@ -16,7 +22,7 @@ use serde::Serialize; use std::path::PathBuf; use tracing::{info, warn}; -/// Result of progressive loading code generation. +/// Result of code generation. #[derive(Debug, Serialize)] struct GenerationResult { /// Server ID @@ -27,17 +33,19 @@ struct GenerationResult { tool_count: usize, /// Path where files were saved output_path: String, + /// Generator format used + format: String, } /// Runs the generate command. /// -/// Generates progressive loading TypeScript files from an MCP server. +/// Generates TypeScript files from an MCP server. /// /// This command performs the following steps: /// 1. Builds `ServerConfig` from CLI arguments or loads from ~/.claude/mcp.json /// 2. Introspects the MCP server to discover tools -/// 3. Generates TypeScript files (one per tool) using progressive loading pattern -/// 4. Exports VFS to `~/.claude/servers/{server-id}/` directory +/// 3. Generates TypeScript files in the selected format +/// 4. Exports files to the appropriate directory /// /// # Arguments /// @@ -50,7 +58,8 @@ struct GenerationResult { /// * `sse` - SSE transport URL /// * `headers` - HTTP headers in KEY=VALUE format /// * `name` - Custom server name for directory (default: `server_id`) -/// * `output_dir` - Custom output directory (default: ~/.claude/servers/) +/// * `output_dir` - Custom output directory +/// * `generator_format` - Code generation format (progressive or claude-agent) /// * `output_format` - Output format (json, text, pretty) /// /// # Errors @@ -74,6 +83,7 @@ pub async fn run( headers: Vec, name: Option, output_dir: Option, + generator_format: GeneratorFormat, output_format: OutputFormat, ) -> Result { // Build server config: either from mcp.json or from CLI arguments @@ -117,17 +127,30 @@ pub async fn run( // Determine server directory name (use custom name if provided, otherwise server_id) let server_dir_name = server_info.id.to_string(); - // Generate progressive loading files - let generator = ProgressiveGenerator::new().context("failed to create code generator")?; - - let generated_code = generator - .generate(&server_info) - .context("failed to generate TypeScript code")?; - - info!( - "Generated {} files for progressive loading", - generated_code.file_count() - ); + // Generate code based on selected format + let (generated_code, format_name, default_subdir) = match generator_format { + GeneratorFormat::Progressive => { + let generator = + ProgressiveGenerator::new().context("failed to create progressive generator")?; + let code = generator + .generate(&server_info) + .context("failed to generate progressive TypeScript code")?; + info!( + "Generated {} files for progressive loading", + code.file_count() + ); + (code, "progressive", "servers") + } + GeneratorFormat::ClaudeAgent => { + let generator = ClaudeAgentGenerator::new() + .context("failed to create Claude Agent SDK generator")?; + let code = generator + .generate(&server_info) + .context("failed to generate Claude Agent SDK code")?; + info!("Generated {} files for Claude Agent SDK", code.file_count()); + (code, "claude-agent", "agent-sdk") + } + }; // Build VFS with generated code // Note: base_path should be "/" because generated files already have flat structure @@ -144,7 +167,7 @@ pub async fn run( dirs::home_dir() .context("failed to get home directory")? .join(".claude") - .join("servers") + .join(default_subdir) }; let output_path = base_dir.join(&server_dir_name); @@ -164,6 +187,7 @@ pub async fn run( server_name: server_info.name.clone(), tool_count: server_info.tools.len(), output_path: output_path.display().to_string(), + format: format_name.to_string(), }; // Output result @@ -173,11 +197,16 @@ pub async fn run( } OutputFormat::Text => { println!("Server: {} ({})", result.server_name, result.server_id); + println!("Format: {}", result.format); println!("Generated {} tool files", result.tool_count); println!("Output: {}", result.output_path); } OutputFormat::Pretty => { - println!("✓ Successfully generated progressive loading files"); + let format_desc = match generator_format { + GeneratorFormat::Progressive => "progressive loading", + GeneratorFormat::ClaudeAgent => "Claude Agent SDK", + }; + println!("✓ Successfully generated {format_desc} files"); println!(" Server: {} ({})", result.server_name, result.server_id); println!(" Tools: {}", result.tool_count); println!(" Location: {}", result.output_path); @@ -225,11 +254,13 @@ mod tests { server_name: "Test Server".to_string(), tool_count: 5, output_path: "/path/to/output".to_string(), + format: "progressive".to_string(), }; let json = serde_json::to_string(&result).unwrap(); assert!(json.contains("\"server_id\":\"test\"")); assert!(json.contains("\"tool_count\":5")); + assert!(json.contains("\"format\":\"progressive\"")); } #[test] @@ -249,4 +280,28 @@ mod tests { let code = result.unwrap(); assert!(code.file_count() > 0); } + + #[test] + fn test_claude_agent_generator_creation() { + let generator = ClaudeAgentGenerator::new(); + assert!(generator.is_ok()); + } + + #[test] + fn test_claude_agent_code_generation() { + let generator = ClaudeAgentGenerator::new().unwrap(); + let server_info = create_mock_server_info(); + + let result = generator.generate(&server_info); + assert!(result.is_ok()); + + let code = result.unwrap(); + assert!(code.file_count() > 0); + + // Check that expected files are generated + let paths: Vec<_> = code.files.iter().map(|f| f.path.as_str()).collect(); + assert!(paths.contains(&"index.ts")); + assert!(paths.contains(&"server.ts")); + assert!(paths.iter().any(|p| p.starts_with("tools/"))); + } } diff --git a/crates/mcp-cli/src/commands/server.rs b/crates/mcp-cli/src/commands/server.rs index 9254b66..3d38262 100644 --- a/crates/mcp-cli/src/commands/server.rs +++ b/crates/mcp-cli/src/commands/server.rs @@ -286,7 +286,12 @@ impl ServerManager { /// /// # Errors /// -/// Returns an error if server operation fails. +/// Returns an error if: +/// - Claude Desktop configuration file cannot be found or read +/// - Configuration file contains invalid JSON +/// - Requested server is not found in configuration (for Info action) +/// - Server introspection fails (for Info action) +/// - Output formatting fails /// /// # Examples /// diff --git a/crates/mcp-cli/src/formatters.rs b/crates/mcp-cli/src/formatters.rs index c3af6c6..8eac4b3 100644 --- a/crates/mcp-cli/src/formatters.rs +++ b/crates/mcp-cli/src/formatters.rs @@ -55,12 +55,20 @@ pub mod json { /// Format data as JSON. /// /// Uses pretty-printing with 2-space indentation. + /// + /// # Errors + /// + /// Returns an error if serialization fails. pub fn format(data: &T) -> Result { let json = serde_json::to_string_pretty(data)?; Ok(json) } /// Format data as compact JSON (no formatting). + /// + /// # Errors + /// + /// Returns an error if serialization fails. pub fn format_compact(data: &T) -> Result { let json = serde_json::to_string(data)?; Ok(json) @@ -75,6 +83,10 @@ pub mod text { /// /// Uses JSON representation but without colors or fancy formatting. /// Suitable for piping to other commands or scripts. + /// + /// # Errors + /// + /// Returns an error if serialization fails. pub fn format(data: &T) -> Result { // For text mode, use JSON without pretty printing json::format_compact(data) @@ -88,6 +100,10 @@ pub mod pretty { /// Format data as colorized, human-readable output. /// /// Uses colors and formatting for better terminal readability. + /// + /// # Errors + /// + /// Returns an error if serialization fails. pub fn format(data: &T) -> Result { // Convert to JSON value first for inspection let value = serde_json::to_value(data)?; diff --git a/crates/mcp-cli/src/lib.rs b/crates/mcp-cli/src/lib.rs index c1ebc45..5bf9123 100644 --- a/crates/mcp-cli/src/lib.rs +++ b/crates/mcp-cli/src/lib.rs @@ -11,9 +11,21 @@ #![allow(clippy::unnecessary_wraps)] #![allow(clippy::unnecessary_literal_unwrap)] +use clap::ValueEnum; + pub mod actions; pub mod commands; pub mod formatters; // Re-export action types for convenience pub use actions::ServerAction; + +/// Output format for code generation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum GeneratorFormat { + /// Progressive loading format (one file per tool, for Claude Code). + #[default] + Progressive, + /// Claude Agent SDK format (with Zod schemas, for SDK integration). + ClaudeAgent, +} diff --git a/crates/mcp-cli/src/main.rs b/crates/mcp-cli/src/main.rs index 68f086a..9e29e79 100644 --- a/crates/mcp-cli/src/main.rs +++ b/crates/mcp-cli/src/main.rs @@ -5,8 +5,7 @@ #![allow(clippy::unused_async)] #![allow(clippy::cast_possible_truncation)] // u128->u64 for millis is safe in practice -// TODO(phase-7.3): Add comprehensive error documentation to all public CLI functions -#![allow(clippy::missing_errors_doc)] +// Error documentation added to all public CLI functions #![allow(clippy::needless_collect)] #![allow(clippy::unnecessary_wraps)] // API design requires Result for consistency across commands #![allow(clippy::unnecessary_literal_unwrap)] @@ -36,6 +35,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use clap_complete::Shell; use mcp_core::cli::{ExitCode, OutputFormat}; +use mcp_execution_cli::GeneratorFormat; use std::path::PathBuf; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; @@ -219,10 +219,16 @@ pub enum Commands { #[arg(long)] name: Option, - /// Custom output directory for progressive loading files - /// (default: ~/.claude/servers/) - #[arg(long)] - progressive_output: Option, + /// Custom output directory for generated files + /// (default: ~/.claude/servers/ for progressive, ~/.claude/agent-sdk/ for claude-agent) + #[arg(long = "output")] + output_dir: Option, + + /// Code generation format + /// (progressive = one file per tool for Claude Code, + /// claude-agent = Zod schemas for Claude Agent SDK) + #[arg(long, value_enum, default_value_t = GeneratorFormat::Progressive)] + generator: GeneratorFormat, }, /// Manage MCP server connections. @@ -350,7 +356,8 @@ async fn execute_command(command: Commands, output_format: OutputFormat) -> Resu sse_url, server_headers, name, - progressive_output, + output_dir, + generator, } => { commands::generate::run( from_config, @@ -362,7 +369,8 @@ async fn execute_command(command: Commands, output_format: OutputFormat) -> Resu sse_url, server_headers, name, - progressive_output, + output_dir, + generator, output_format, ) .await @@ -445,19 +453,35 @@ mod tests { let cli = Cli::parse_from(["mcp-cli", "generate", "server"]); assert!(matches!(cli.command, Commands::Generate { .. })); - // Test with progressive output + // Test with output dir + let cli = Cli::parse_from(["mcp-cli", "generate", "server", "--output", "/tmp/output"]); + if let Commands::Generate { output_dir, .. } = cli.command { + assert_eq!(output_dir, Some(PathBuf::from("/tmp/output"))); + } else { + panic!("Expected Generate command"); + } + } + + #[test] + fn test_cli_parsing_generate_with_generator_format() { + // Test default (progressive) + let cli = Cli::parse_from(["mcp-cli", "generate", "server"]); + if let Commands::Generate { generator, .. } = cli.command { + assert_eq!(generator, GeneratorFormat::Progressive); + } else { + panic!("Expected Generate command"); + } + + // Test claude-agent format let cli = Cli::parse_from([ "mcp-cli", "generate", "server", - "--progressive-output", - "/tmp/output", + "--generator", + "claude-agent", ]); - if let Commands::Generate { - progressive_output, .. - } = cli.command - { - assert_eq!(progressive_output, Some(PathBuf::from("/tmp/output"))); + if let Commands::Generate { generator, .. } = cli.command { + assert_eq!(generator, GeneratorFormat::ClaudeAgent); } else { panic!("Expected Generate command"); } diff --git a/crates/mcp-codegen/src/claude_agent/generator.rs b/crates/mcp-codegen/src/claude_agent/generator.rs new file mode 100644 index 0000000..893a2a8 --- /dev/null +++ b/crates/mcp-codegen/src/claude_agent/generator.rs @@ -0,0 +1,466 @@ +//! Claude Agent SDK code generator. +//! +//! Generates TypeScript files for the Claude Agent SDK where each tool +//! is defined with Zod schemas for type-safe integration. +//! +//! # Examples +//! +//! ```no_run +//! use mcp_codegen::claude_agent::ClaudeAgentGenerator; +//! use mcp_introspector::{Introspector, ServerInfo}; +//! use mcp_core::{ServerId, ServerConfig}; +//! +//! # async fn example() -> Result<(), Box> { +//! let mut introspector = Introspector::new(); +//! let server_id = ServerId::new("github"); +//! let config = ServerConfig::builder().command("/path/to/server".to_string()).build(); +//! let info = introspector.discover_server(server_id, &config).await?; +//! +//! let generator = ClaudeAgentGenerator::new()?; +//! let code = generator.generate(&info)?; +//! +//! // Generated files: +//! // - index.ts (entry point) +//! // - server.ts (MCP server definition) +//! // - tools/createIssue.ts +//! // - tools/updateIssue.ts +//! // - ... +//! println!("Generated {} files", code.file_count()); +//! # Ok(()) +//! # } +//! ``` + +use crate::claude_agent::types::{ + IndexContext, PropertyInfo, ServerContext, ToolContext, ToolSummary, +}; +use crate::claude_agent::zod::extract_zod_properties; +use crate::common::types::{GeneratedCode, GeneratedFile}; +use crate::common::typescript::{to_camel_case, to_pascal_case}; +use crate::template_engine::TemplateEngine; +use mcp_core::Result; +use mcp_introspector::ServerInfo; + +/// Generator for Claude Agent SDK TypeScript files. +/// +/// Creates tool definitions with Zod schemas for type-safe integration +/// with the Claude Agent SDK. +/// +/// # Thread Safety +/// +/// This type is `Send` and `Sync`, allowing safe use across threads. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::claude_agent::ClaudeAgentGenerator; +/// +/// let generator = ClaudeAgentGenerator::new().unwrap(); +/// ``` +#[derive(Debug)] +pub struct ClaudeAgentGenerator<'a> { + engine: TemplateEngine<'a>, +} + +impl<'a> ClaudeAgentGenerator<'a> { + /// Creates a new Claude Agent SDK generator. + /// + /// Initializes the template engine and registers all Claude Agent SDK + /// templates. + /// + /// # Errors + /// + /// Returns error if template registration fails. + /// + /// # Examples + /// + /// ``` + /// use mcp_codegen::claude_agent::ClaudeAgentGenerator; + /// + /// let generator = ClaudeAgentGenerator::new().unwrap(); + /// ``` + pub fn new() -> Result { + let engine = TemplateEngine::new()?; + Ok(Self { engine }) + } + + /// Generates Claude Agent SDK files for a server. + /// + /// Creates TypeScript files with Zod schemas for each tool: + /// - `index.ts`: Entry point with exports + /// - `server.ts`: MCP server definition with `createSdkMcpServer()` + /// - `tools/*.ts`: Individual tool definitions with Zod schemas + /// + /// # Arguments + /// + /// * `server_info` - MCP server introspection data + /// + /// # Returns + /// + /// Generated code with all necessary files for Claude Agent SDK integration. + /// + /// # Errors + /// + /// Returns error if: + /// - Template rendering fails + /// - Type conversion fails + /// + /// # Examples + /// + /// ```no_run + /// use mcp_codegen::claude_agent::ClaudeAgentGenerator; + /// use mcp_introspector::{ServerInfo, ServerCapabilities}; + /// use mcp_core::ServerId; + /// + /// # fn example() -> Result<(), Box> { + /// let generator = ClaudeAgentGenerator::new()?; + /// + /// let info = ServerInfo { + /// id: ServerId::new("github"), + /// name: "GitHub".to_string(), + /// version: "1.0.0".to_string(), + /// tools: vec![], + /// capabilities: ServerCapabilities { + /// supports_tools: true, + /// supports_resources: false, + /// supports_prompts: false, + /// }, + /// }; + /// + /// let code = generator.generate(&info)?; + /// println!("Generated {} files", code.file_count()); + /// # Ok(()) + /// # } + /// ``` + #[must_use = "generated code should be saved or used"] + pub fn generate(&self, server_info: &ServerInfo) -> Result { + tracing::info!( + "Generating Claude Agent SDK code for server: {}", + server_info.name + ); + + let mut code = GeneratedCode::new(); + let server_variable_name = to_camel_case(server_info.id.as_str()); + + // Generate individual tool files + for tool in &server_info.tools { + let tool_context = self.create_tool_context(tool)?; + let tool_code = self.engine.render("claude_agent/tool", &tool_context)?; + + code.add_file(GeneratedFile { + path: format!("tools/{}.ts", tool_context.typescript_name), + content: tool_code, + }); + + tracing::debug!( + "Generated tool file: tools/{}.ts", + tool_context.typescript_name + ); + } + + // Generate server.ts + let server_context = self.create_server_context(server_info, &server_variable_name)?; + let server_code = self.engine.render("claude_agent/server", &server_context)?; + + code.add_file(GeneratedFile { + path: "server.ts".to_string(), + content: server_code, + }); + + tracing::debug!("Generated server.ts"); + + // Generate index.ts + let index_context = self.create_index_context(server_info, &server_variable_name)?; + let index_code = self.engine.render("claude_agent/index", &index_context)?; + + code.add_file(GeneratedFile { + path: "index.ts".to_string(), + content: index_code, + }); + + tracing::debug!("Generated index.ts"); + + tracing::info!( + "Successfully generated {} files for {} (Claude Agent SDK)", + code.file_count(), + server_info.name + ); + + Ok(code) + } + + /// Creates tool context from MCP tool information. + fn create_tool_context(&self, tool: &mcp_introspector::ToolInfo) -> Result { + let typescript_name = to_camel_case(tool.name.as_str()); + let pascal_name = to_pascal_case(tool.name.as_str()); + + // Extract properties with Zod types and convert to PropertyInfo + let properties: Vec = extract_zod_properties(&tool.input_schema) + .into_iter() + .map(PropertyInfo::from) + .collect(); + + Ok(ToolContext { + name: tool.name.as_str().to_string(), + typescript_name, + pascal_name, + description: tool.description.clone(), + properties, + }) + } + + /// Creates server context from server information. + fn create_server_context( + &self, + server_info: &ServerInfo, + server_variable_name: &str, + ) -> Result { + let tools: Vec = server_info + .tools + .iter() + .map(|tool| ToolSummary { + typescript_name: to_camel_case(tool.name.as_str()), + }) + .collect(); + + Ok(ServerContext { + server_name: server_info.name.clone(), + server_variable_name: server_variable_name.to_string(), + server_version: server_info.version.clone(), + tool_count: server_info.tools.len(), + tools, + }) + } + + /// Creates index context from server information. + fn create_index_context( + &self, + server_info: &ServerInfo, + server_variable_name: &str, + ) -> Result { + let tools: Vec = server_info + .tools + .iter() + .map(|tool| ToolSummary { + typescript_name: to_camel_case(tool.name.as_str()), + }) + .collect(); + + Ok(IndexContext { + server_name: server_info.name.clone(), + server_variable_name: server_variable_name.to_string(), + server_version: server_info.version.clone(), + tool_count: server_info.tools.len(), + tools, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_core::{ServerId, ToolName}; + use mcp_introspector::{ServerCapabilities, ToolInfo}; + use serde_json::json; + + fn create_test_server_info() -> ServerInfo { + ServerInfo { + id: ServerId::new("test-server"), + name: "Test Server".to_string(), + version: "1.0.0".to_string(), + tools: vec![ + ToolInfo { + name: ToolName::new("create_issue"), + description: "Creates a new issue".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Issue title" + }, + "body": { + "type": "string", + "description": "Issue body" + } + }, + "required": ["title"] + }), + output_schema: None, + }, + ToolInfo { + name: ToolName::new("update_issue"), + description: "Updates an existing issue".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Issue ID" + } + }, + "required": ["id"] + }), + output_schema: None, + }, + ], + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + } + } + + #[test] + fn test_claude_agent_generator_new() { + let generator = ClaudeAgentGenerator::new(); + assert!(generator.is_ok()); + } + + #[test] + fn test_generate_claude_agent_files() { + let generator = ClaudeAgentGenerator::new().unwrap(); + let server_info = create_test_server_info(); + + let code = generator.generate(&server_info).unwrap(); + + // Should generate: + // - 2 tool files + // - 1 server.ts + // - 1 index.ts + assert_eq!(code.file_count(), 4); + + // Check file paths + let file_paths: Vec<_> = code.files.iter().map(|f| f.path.as_str()).collect(); + + assert!(file_paths.contains(&"tools/createIssue.ts")); + assert!(file_paths.contains(&"tools/updateIssue.ts")); + assert!(file_paths.contains(&"server.ts")); + assert!(file_paths.contains(&"index.ts")); + } + + #[test] + fn test_create_tool_context() { + let generator = ClaudeAgentGenerator::new().unwrap(); + let tool = ToolInfo { + name: ToolName::new("send_message"), + description: "Sends a message".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Message text"}, + "priority": {"type": "integer", "minimum": 1, "maximum": 5} + }, + "required": ["text"] + }), + output_schema: None, + }; + + let context = generator.create_tool_context(&tool).unwrap(); + + assert_eq!(context.name, "send_message"); + assert_eq!(context.typescript_name, "sendMessage"); + assert_eq!(context.pascal_name, "SendMessage"); + assert_eq!(context.description, "Sends a message"); + assert_eq!(context.properties.len(), 2); + + let text_prop = context + .properties + .iter() + .find(|p| p.name == "text") + .unwrap(); + assert_eq!(text_prop.zod_type, "string"); + assert!(text_prop.required); + + let priority_prop = context + .properties + .iter() + .find(|p| p.name == "priority") + .unwrap(); + assert_eq!(priority_prop.zod_type, "number"); + assert!(priority_prop.zod_modifiers.contains(&".int()".to_string())); + assert!(!priority_prop.required); + } + + #[test] + fn test_create_server_context() { + let generator = ClaudeAgentGenerator::new().unwrap(); + let server_info = create_test_server_info(); + + let context = generator + .create_server_context(&server_info, "testServer") + .unwrap(); + + assert_eq!(context.server_name, "Test Server"); + assert_eq!(context.server_variable_name, "testServer"); + assert_eq!(context.server_version, "1.0.0"); + assert_eq!(context.tools.len(), 2); + } + + #[test] + fn test_create_index_context() { + let generator = ClaudeAgentGenerator::new().unwrap(); + let server_info = create_test_server_info(); + + let context = generator + .create_index_context(&server_info, "testServer") + .unwrap(); + + assert_eq!(context.server_name, "Test Server"); + assert_eq!(context.tool_count, 2); + assert_eq!(context.tools.len(), 2); + assert_eq!(context.tools[0].typescript_name, "createIssue"); + } + + #[test] + fn test_generate_with_email_format() { + let generator = ClaudeAgentGenerator::new().unwrap(); + let server_info = ServerInfo { + id: ServerId::new("user-service"), + name: "User Service".to_string(), + version: "2.0.0".to_string(), + tools: vec![ToolInfo { + name: ToolName::new("create_user"), + description: "Creates a new user".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "User email address" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "required": ["email", "name"] + }), + output_schema: None, + }], + capabilities: ServerCapabilities { + supports_tools: true, + supports_resources: false, + supports_prompts: false, + }, + }; + + let code = generator.generate(&server_info).unwrap(); + assert_eq!(code.file_count(), 3); + + // Check that email format is detected + let tool_file = code + .files + .iter() + .find(|f| f.path == "tools/createUser.ts") + .unwrap(); + + // The template should generate .email() modifier + assert!( + tool_file.content.contains(".email()") || tool_file.content.contains("email"), + "Expected email format handling" + ); + } +} diff --git a/crates/mcp-codegen/src/claude_agent/mod.rs b/crates/mcp-codegen/src/claude_agent/mod.rs new file mode 100644 index 0000000..e7e6baa --- /dev/null +++ b/crates/mcp-codegen/src/claude_agent/mod.rs @@ -0,0 +1,60 @@ +//! Claude Agent SDK code generation. +//! +//! This module generates TypeScript files for integration with the +//! Claude Agent SDK, using Zod schemas for type-safe tool definitions. +//! +//! # Generated Structure +//! +//! For a server with 3 tools, generates: +//! +//! ```text +//! ~/.claude/agent-sdk/{server-id}/ +//! ├── index.ts # Entry point with exports +//! ├── server.ts # MCP server definition +//! └── tools/ +//! ├── createIssue.ts # Individual tool with Zod schema +//! ├── updateIssue.ts +//! └── deleteIssue.ts +//! ``` +//! +//! # Example +//! +//! ```no_run +//! use mcp_codegen::claude_agent::ClaudeAgentGenerator; +//! use mcp_introspector::ServerInfo; +//! +//! # fn example() -> Result<(), Box> { +//! let generator = ClaudeAgentGenerator::new()?; +//! // generator.generate(&server_info)?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Generated Code Example +//! +//! The generated `tools/createIssue.ts` looks like: +//! +//! ```typescript +//! import { tool } from "@anthropic-ai/claude-agent-sdk"; +//! import { z } from "zod"; +//! +//! export const createIssue = tool( +//! "create_issue", +//! "Creates a new issue", +//! { +//! title: z.string().describe("Issue title"), +//! body: z.string().optional().describe("Issue body") +//! }, +//! async (args) => { +//! // Implementation stub +//! return { content: [{ type: "text", text: JSON.stringify(args) }] }; +//! } +//! ); +//! ``` + +pub mod generator; +pub mod types; +pub mod zod; + +pub use generator::ClaudeAgentGenerator; +pub use types::{IndexContext, PropertyInfo, ServerContext, ToolContext, ToolSummary}; diff --git a/crates/mcp-codegen/src/claude_agent/types.rs b/crates/mcp-codegen/src/claude_agent/types.rs new file mode 100644 index 0000000..c82e0c4 --- /dev/null +++ b/crates/mcp-codegen/src/claude_agent/types.rs @@ -0,0 +1,279 @@ +//! Types for Claude Agent SDK code generation. +//! +//! Defines data structures used during Claude Agent SDK code generation, +//! where each tool is generated with Zod schemas for the Claude Agent SDK. + +use serde::{Deserialize, Serialize}; + +/// Context for rendering a single tool template. +/// +/// Contains all data needed to generate one tool file for the +/// Claude Agent SDK format. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::claude_agent::ToolContext; +/// +/// let context = ToolContext { +/// name: "create_issue".to_string(), +/// typescript_name: "createIssue".to_string(), +/// pascal_name: "CreateIssue".to_string(), +/// description: "Creates a new issue".to_string(), +/// properties: vec![], +/// }; +/// +/// assert_eq!(context.typescript_name, "createIssue"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolContext { + /// Original tool name (snake_case) + pub name: String, + /// TypeScript-friendly name (camelCase) + pub typescript_name: String, + /// PascalCase name for type definitions + pub pascal_name: String, + /// Human-readable description + pub description: String, + /// Extracted properties with Zod types + pub properties: Vec, +} + +/// Information about a single parameter property with Zod type. +/// +/// Used in Handlebars templates to render Zod schema definitions. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::claude_agent::PropertyInfo; +/// +/// let prop = PropertyInfo { +/// name: "title".to_string(), +/// zod_type: "string".to_string(), +/// zod_modifiers: vec![], +/// description: Some("Issue title".to_string()), +/// required: true, +/// }; +/// +/// assert_eq!(prop.zod_type, "string"); +/// assert!(prop.required); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PropertyInfo { + /// Property name + pub name: String, + /// Zod type (e.g., "string", "number", "boolean") + pub zod_type: String, + /// Additional Zod modifiers (e.g., ".int()", ".email()") + pub zod_modifiers: Vec, + /// Optional description from schema + pub description: Option, + /// Whether the property is required + pub required: bool, +} + +impl PropertyInfo { + /// Returns the full Zod type expression with modifiers. + /// + /// # Examples + /// + /// ``` + /// use mcp_codegen::claude_agent::PropertyInfo; + /// + /// let prop = PropertyInfo { + /// name: "email".to_string(), + /// zod_type: "string".to_string(), + /// zod_modifiers: vec![".email()".to_string()], + /// description: Some("User email".to_string()), + /// required: true, + /// }; + /// + /// assert_eq!(prop.full_zod_type(), "string().email()"); + /// ``` + #[must_use] + pub fn full_zod_type(&self) -> String { + let mut result = format!("{}()", self.zod_type); + for modifier in &self.zod_modifiers { + result.push_str(modifier); + } + result + } +} + +/// Context for rendering the server.ts template. +/// +/// Contains server-level metadata and list of all tools. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::claude_agent::ServerContext; +/// +/// let context = ServerContext { +/// server_name: "GitHub".to_string(), +/// server_variable_name: "github".to_string(), +/// server_version: "1.0.0".to_string(), +/// tool_count: 30, +/// tools: vec![], +/// }; +/// +/// assert_eq!(context.server_name, "GitHub"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerContext { + /// Server name for documentation + pub server_name: String, + /// Variable name for server (camelCase) + pub server_variable_name: String, + /// Server version + pub server_version: String, + /// Total number of tools + pub tool_count: usize, + /// List of tool summaries + pub tools: Vec, +} + +/// Summary of a tool for server file generation. +/// +/// Lighter-weight than full `ToolContext`, used only for +/// imports and tool array in server.ts. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::claude_agent::ToolSummary; +/// +/// let summary = ToolSummary { +/// typescript_name: "createIssue".to_string(), +/// }; +/// +/// assert_eq!(summary.typescript_name, "createIssue"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolSummary { + /// TypeScript-friendly name (camelCase) + pub typescript_name: String, +} + +/// Context for rendering the index.ts template. +/// +/// Type alias for [`ServerContext`] since both templates use identical data. +/// This allows future divergence if needed while keeping the types unified. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::claude_agent::IndexContext; +/// +/// let context = IndexContext { +/// server_name: "GitHub".to_string(), +/// server_variable_name: "github".to_string(), +/// server_version: "1.0.0".to_string(), +/// tool_count: 30, +/// tools: vec![], +/// }; +/// +/// assert_eq!(context.tool_count, 30); +/// ``` +pub type IndexContext = ServerContext; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_context() { + let context = ToolContext { + name: "create_issue".to_string(), + typescript_name: "createIssue".to_string(), + pascal_name: "CreateIssue".to_string(), + description: "Creates an issue".to_string(), + properties: vec![], + }; + + assert_eq!(context.name, "create_issue"); + assert_eq!(context.typescript_name, "createIssue"); + assert_eq!(context.pascal_name, "CreateIssue"); + } + + #[test] + fn test_property_info() { + let prop = PropertyInfo { + name: "title".to_string(), + zod_type: "string".to_string(), + zod_modifiers: vec![], + description: Some("Issue title".to_string()), + required: true, + }; + + assert_eq!(prop.name, "title"); + assert_eq!(prop.zod_type, "string"); + assert!(prop.required); + assert_eq!(prop.full_zod_type(), "string()"); + } + + #[test] + fn test_property_info_with_modifiers() { + let prop = PropertyInfo { + name: "email".to_string(), + zod_type: "string".to_string(), + zod_modifiers: vec![".email()".to_string()], + description: None, + required: true, + }; + + assert_eq!(prop.full_zod_type(), "string().email()"); + } + + #[test] + fn test_property_info_multiple_modifiers() { + let prop = PropertyInfo { + name: "count".to_string(), + zod_type: "number".to_string(), + zod_modifiers: vec![".int()".to_string(), ".min(0)".to_string()], + description: None, + required: false, + }; + + assert_eq!(prop.full_zod_type(), "number().int().min(0)"); + } + + #[test] + fn test_server_context() { + let context = ServerContext { + server_name: "GitHub".to_string(), + server_variable_name: "github".to_string(), + server_version: "1.0.0".to_string(), + tool_count: 30, + tools: vec![], + }; + + assert_eq!(context.server_name, "GitHub"); + assert_eq!(context.server_variable_name, "github"); + assert_eq!(context.tool_count, 30); + } + + #[test] + fn test_index_context() { + let context = IndexContext { + server_name: "GitHub".to_string(), + server_variable_name: "github".to_string(), + server_version: "1.0.0".to_string(), + tool_count: 5, + tools: vec![], + }; + + assert_eq!(context.server_name, "GitHub"); + assert_eq!(context.tool_count, 5); + } + + #[test] + fn test_tool_summary() { + let summary = ToolSummary { + typescript_name: "createIssue".to_string(), + }; + + assert_eq!(summary.typescript_name, "createIssue"); + } +} diff --git a/crates/mcp-codegen/src/claude_agent/zod.rs b/crates/mcp-codegen/src/claude_agent/zod.rs new file mode 100644 index 0000000..7514bf0 --- /dev/null +++ b/crates/mcp-codegen/src/claude_agent/zod.rs @@ -0,0 +1,464 @@ +//! JSON Schema to Zod type conversion utilities. +//! +//! Provides functions to convert JSON Schema to Zod schema definitions +//! for the Claude Agent SDK. +//! +//! # Examples +//! +//! ``` +//! use mcp_codegen::claude_agent::zod; +//! use serde_json::json; +//! +//! let schema = json!({ +//! "type": "string", +//! "format": "email" +//! }); +//! +//! let (zod_type, modifiers) = zod::json_type_to_zod(&schema); +//! assert_eq!(zod_type, "string"); +//! assert!(modifiers.contains(&".email()".to_string())); +//! ``` + +use serde_json::Value; + +/// Converts JSON Schema type to Zod type with optional modifiers. +/// +/// Returns a tuple of (base_type, modifiers) where modifiers are +/// additional Zod chain methods like `.int()`, `.email()`, etc. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::claude_agent::zod::json_type_to_zod; +/// use serde_json::json; +/// +/// let (zod_type, mods) = json_type_to_zod(&json!({"type": "string"})); +/// assert_eq!(zod_type, "string"); +/// assert!(mods.is_empty()); +/// +/// let (zod_type, mods) = json_type_to_zod(&json!({"type": "integer"})); +/// assert_eq!(zod_type, "number"); +/// assert!(mods.contains(&".int()".to_string())); +/// ``` +#[must_use] +pub fn json_type_to_zod(schema: &Value) -> (String, Vec) { + let mut modifiers = Vec::new(); + + let base_type = match schema { + Value::Object(obj) => { + let type_str = obj + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + // Check for format modifiers (string types) + if type_str == "string" { + if let Some(format) = obj.get("format").and_then(|v| v.as_str()) { + match format { + "email" => modifiers.push(".email()".to_string()), + "uri" | "url" => modifiers.push(".url()".to_string()), + "uuid" => modifiers.push(".uuid()".to_string()), + "date" => modifiers.push(".date()".to_string()), + "date-time" => modifiers.push(".datetime()".to_string()), + "ipv4" => modifiers.push(".ip({ version: 'v4' })".to_string()), + "ipv6" => modifiers.push(".ip({ version: 'v6' })".to_string()), + _ => {} + } + } + + // Check for string constraints + if let Some(min_length) = obj.get("minLength").and_then(serde_json::Value::as_u64) { + modifiers.push(format!(".min({min_length})")); + } + if let Some(max_length) = obj.get("maxLength").and_then(serde_json::Value::as_u64) { + modifiers.push(format!(".max({max_length})")); + } + if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) { + // Escape forward slashes in the pattern for JavaScript regex + let escaped_pattern = pattern.replace('/', "\\/"); + modifiers.push(format!(".regex(/{escaped_pattern}/)")); + } + } + + // Check for enum + if let Some(enum_values) = obj.get("enum").and_then(|v| v.as_array()) { + let values: Vec = enum_values + .iter() + .filter_map(|v| v.as_str()) + .map(|s| format!("'{}'", s)) + .collect(); + + if !values.is_empty() { + return ("enum".to_string(), vec![format!("[{}]", values.join(", "))]); + } + } + + match type_str { + "string" => "string".to_string(), + "number" => { + // Check for number constraints + if let Some(minimum) = obj.get("minimum").and_then(serde_json::Value::as_f64) { + modifiers.push(format!(".min({minimum})")); + } + if let Some(maximum) = obj.get("maximum").and_then(serde_json::Value::as_f64) { + modifiers.push(format!(".max({maximum})")); + } + "number".to_string() + } + "integer" => { + // Collect constraints first, then prepend .int() + let mut int_modifiers = vec![".int()".to_string()]; + if let Some(minimum) = obj.get("minimum").and_then(serde_json::Value::as_i64) { + int_modifiers.push(format!(".min({minimum})")); + } + if let Some(maximum) = obj.get("maximum").and_then(serde_json::Value::as_i64) { + int_modifiers.push(format!(".max({maximum})")); + } + modifiers = int_modifiers; + "number".to_string() + } + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => { + if let Some(items) = obj.get("items") { + let (item_type, item_mods) = json_type_to_zod(items); + let item_zod = format_zod_type(&item_type, &item_mods); + return ("array".to_string(), vec![format!("(z.{})", item_zod)]); + } + "array".to_string() + } + "object" => { + // For nested objects, we'll use z.object({}) or z.record() + if obj.get("properties").is_some() { + // Complex object - will be handled separately + "object".to_string() + } else if let Some(additional) = obj.get("additionalProperties") { + let (value_type, value_mods) = json_type_to_zod(additional); + let value_zod = format_zod_type(&value_type, &value_mods); + return ( + "record".to_string(), + vec![format!("(z.string(), z.{})", value_zod)], + ); + } else { + "record".to_string() + } + } + _ => "unknown".to_string(), + } + } + Value::String(s) => match s.as_str() { + "string" => "string".to_string(), + "number" => "number".to_string(), + "integer" => { + modifiers.push(".int()".to_string()); + "number".to_string() + } + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => "array".to_string(), + "object" => "object".to_string(), + _ => "unknown".to_string(), + }, + _ => "unknown".to_string(), + }; + + (base_type, modifiers) +} + +/// Formats a Zod type with its modifiers into a complete expression. +/// +/// # Examples +/// +/// ``` +/// use mcp_codegen::claude_agent::zod::format_zod_type; +/// +/// assert_eq!(format_zod_type("string", &[]), "string()"); +/// assert_eq!( +/// format_zod_type("string", &[".email()".to_string()]), +/// "string().email()" +/// ); +/// assert_eq!( +/// format_zod_type("number", &[".int()".to_string(), ".min(0)".to_string()]), +/// "number().int().min(0)" +/// ); +/// ``` +#[must_use] +pub fn format_zod_type(base_type: &str, modifiers: &[String]) -> String { + if base_type == "enum" && !modifiers.is_empty() { + // Special case for enum: z.enum(['a', 'b', 'c']) + return format!("enum({})", modifiers[0]); + } + + if base_type == "array" && !modifiers.is_empty() { + // Special case for array: z.array(z.string()) + return format!("array{}", modifiers[0]); + } + + if base_type == "record" && !modifiers.is_empty() { + // Special case for record: z.record(z.string(), z.number()) + return format!("record{}", modifiers[0]); + } + + let mut result = format!("{}()", base_type); + for modifier in modifiers { + result.push_str(modifier); + } + result +} + +/// Extracts property information from JSON Schema for Zod generation. +/// +/// Returns property details including Zod type and modifiers. +/// This is an internal function used by [`ClaudeAgentGenerator`](crate::claude_agent::ClaudeAgentGenerator). +#[must_use] +pub(crate) fn extract_zod_properties(schema: &Value) -> Vec { + // Pre-calculate capacity if possible + let capacity = schema + .as_object() + .and_then(|obj| obj.get("properties")) + .and_then(|v| v.as_object()) + .map_or(0, serde_json::Map::len); + + let mut properties = Vec::with_capacity(capacity); + + if let Some(obj) = schema.as_object() + && let Some(props) = obj.get("properties").and_then(|v| v.as_object()) + { + let required = obj + .get("required") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect::>() + }) + .unwrap_or_default(); + + for (name, prop_schema) in props { + let (zod_type, zod_modifiers) = json_type_to_zod(prop_schema); + let is_required = required.contains(name); + + let description = prop_schema + .as_object() + .and_then(|obj| obj.get("description")) + .and_then(|v| v.as_str()) + .map(String::from); + + properties.push(ZodPropertyInfo { + name: name.clone(), + zod_type, + zod_modifiers, + description, + required: is_required, + }); + } + } + + // Sort properties by name for consistent output + properties.sort_by(|a, b| a.name.cmp(&b.name)); + + properties +} + +/// Information about a property with Zod type details. +/// +/// This is an internal type used during JSON Schema extraction. +/// It gets converted to [`PropertyInfo`](crate::claude_agent::PropertyInfo) +/// for template rendering. +#[derive(Debug, Clone)] +pub(crate) struct ZodPropertyInfo { + /// Property name + pub name: String, + /// Base Zod type (e.g., "string", "number") + pub zod_type: String, + /// Zod modifiers (e.g., ".int()", ".email()") + pub zod_modifiers: Vec, + /// Optional description from schema + pub description: Option, + /// Whether the property is required + pub required: bool, +} + +impl From for crate::claude_agent::types::PropertyInfo { + fn from(zod: ZodPropertyInfo) -> Self { + Self { + name: zod.name, + zod_type: zod.zod_type, + zod_modifiers: zod.zod_modifiers, + description: zod.description, + required: zod.required, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_json_type_to_zod_string() { + let (zod_type, mods) = json_type_to_zod(&json!({"type": "string"})); + assert_eq!(zod_type, "string"); + assert!(mods.is_empty()); + } + + #[test] + fn test_json_type_to_zod_email() { + let (zod_type, mods) = json_type_to_zod(&json!({"type": "string", "format": "email"})); + assert_eq!(zod_type, "string"); + assert!(mods.contains(&".email()".to_string())); + } + + #[test] + fn test_json_type_to_zod_url() { + let (zod_type, mods) = json_type_to_zod(&json!({"type": "string", "format": "uri"})); + assert_eq!(zod_type, "string"); + assert!(mods.contains(&".url()".to_string())); + } + + #[test] + fn test_json_type_to_zod_integer() { + let (zod_type, mods) = json_type_to_zod(&json!({"type": "integer"})); + assert_eq!(zod_type, "number"); + assert!(mods.contains(&".int()".to_string())); + } + + #[test] + fn test_json_type_to_zod_integer_with_constraints() { + let (zod_type, mods) = json_type_to_zod(&json!({ + "type": "integer", + "minimum": 0, + "maximum": 100 + })); + assert_eq!(zod_type, "number"); + assert!(mods.contains(&".int()".to_string())); + assert!(mods.contains(&".min(0)".to_string())); + assert!(mods.contains(&".max(100)".to_string())); + } + + #[test] + fn test_json_type_to_zod_string_with_length() { + let (zod_type, mods) = json_type_to_zod(&json!({ + "type": "string", + "minLength": 1, + "maxLength": 100 + })); + assert_eq!(zod_type, "string"); + assert!(mods.contains(&".min(1)".to_string())); + assert!(mods.contains(&".max(100)".to_string())); + } + + #[test] + fn test_json_type_to_zod_enum() { + let (zod_type, mods) = json_type_to_zod(&json!({ + "type": "string", + "enum": ["a", "b", "c"] + })); + assert_eq!(zod_type, "enum"); + assert_eq!(mods, vec!["['a', 'b', 'c']"]); + } + + #[test] + fn test_json_type_to_zod_array() { + let (zod_type, mods) = json_type_to_zod(&json!({ + "type": "array", + "items": {"type": "string"} + })); + assert_eq!(zod_type, "array"); + assert_eq!(mods, vec!["(z.string())"]); + } + + #[test] + fn test_format_zod_type_simple() { + assert_eq!(format_zod_type("string", &[]), "string()"); + assert_eq!(format_zod_type("number", &[]), "number()"); + assert_eq!(format_zod_type("boolean", &[]), "boolean()"); + } + + #[test] + fn test_format_zod_type_with_modifiers() { + assert_eq!( + format_zod_type("string", &[".email()".to_string()]), + "string().email()" + ); + assert_eq!( + format_zod_type("number", &[".int()".to_string(), ".min(0)".to_string()]), + "number().int().min(0)" + ); + } + + #[test] + fn test_format_zod_type_enum() { + assert_eq!( + format_zod_type("enum", &["['a', 'b']".to_string()]), + "enum(['a', 'b'])" + ); + } + + #[test] + fn test_format_zod_type_array() { + assert_eq!( + format_zod_type("array", &["(z.string())".to_string()]), + "array(z.string())" + ); + } + + #[test] + fn test_extract_zod_properties() { + let schema = json!({ + "type": "object", + "properties": { + "email": {"type": "string", "format": "email", "description": "User email"}, + "age": {"type": "integer", "minimum": 0} + }, + "required": ["email"] + }); + + let props = extract_zod_properties(&schema); + assert_eq!(props.len(), 2); + + let email_prop = props.iter().find(|p| p.name == "email").unwrap(); + assert_eq!(email_prop.zod_type, "string"); + assert!(email_prop.zod_modifiers.contains(&".email()".to_string())); + assert!(email_prop.required); + assert_eq!(email_prop.description, Some("User email".to_string())); + + let age_prop = props.iter().find(|p| p.name == "age").unwrap(); + assert_eq!(age_prop.zod_type, "number"); + assert!(age_prop.zod_modifiers.contains(&".int()".to_string())); + assert!(!age_prop.required); + } + + #[test] + fn test_zod_property_info_to_property_info() { + use crate::claude_agent::types::PropertyInfo; + + let zod_prop = ZodPropertyInfo { + name: "email".to_string(), + zod_type: "string".to_string(), + zod_modifiers: vec![".email()".to_string()], + description: Some("User email".to_string()), + required: true, + }; + + let prop: PropertyInfo = zod_prop.into(); + + assert_eq!(prop.name, "email"); + assert_eq!(prop.zod_type, "string"); + assert_eq!(prop.zod_modifiers, vec![".email()".to_string()]); + assert_eq!(prop.description, Some("User email".to_string())); + assert!(prop.required); + } + + #[test] + fn test_regex_pattern_escaping() { + let (_, mods) = json_type_to_zod(&json!({ + "type": "string", + "pattern": "^https?://[a-z]+.com/path$" + })); + // Forward slashes in pattern should be escaped + assert!(mods.iter().any(|m| m.contains("\\/") || !m.contains('/'))); + } +} diff --git a/crates/mcp-codegen/src/common/typescript.rs b/crates/mcp-codegen/src/common/typescript.rs index 2f141da..83a462c 100644 --- a/crates/mcp-codegen/src/common/typescript.rs +++ b/crates/mcp-codegen/src/common/typescript.rs @@ -35,7 +35,7 @@ use serde_json::Value; /// ``` #[must_use] pub fn to_camel_case(snake_case: &str) -> String { - let mut result = String::new(); + let mut result = String::with_capacity(snake_case.len()); let mut capitalize_next = false; for ch in snake_case.chars() { @@ -144,7 +144,7 @@ pub fn json_schema_to_typescript(schema: &Value) -> String { .unwrap_or_default(); if let Some(props) = properties { - let mut fields = Vec::new(); + let mut fields = Vec::with_capacity(props.len()); for (key, value) in props { let is_required = required.contains(&key.as_str()); let optional_marker = if is_required { "" } else { "?" }; @@ -201,7 +201,14 @@ pub fn json_schema_to_typescript(schema: &Value) -> String { /// ``` #[must_use] pub fn extract_properties(schema: &Value) -> Vec { - let mut properties = Vec::new(); + // Pre-calculate capacity if possible + let capacity = schema + .as_object() + .and_then(|obj| obj.get("properties")) + .and_then(|v| v.as_object()) + .map_or(0, serde_json::Map::len); + + let mut properties = Vec::with_capacity(capacity); if let Some(obj) = schema.as_object() && let Some(props) = obj.get("properties").and_then(|v| v.as_object()) diff --git a/crates/mcp-codegen/src/lib.rs b/crates/mcp-codegen/src/lib.rs index 9d19654..7239d35 100644 --- a/crates/mcp-codegen/src/lib.rs +++ b/crates/mcp-codegen/src/lib.rs @@ -54,11 +54,13 @@ #![warn(missing_docs, missing_debug_implementations)] // Core modules (always available) +pub mod claude_agent; pub mod common; pub mod progressive; pub mod template_engine; // Re-export main types +pub use claude_agent::ClaudeAgentGenerator; pub use common::types::{GeneratedCode, GeneratedFile, TemplateContext, ToolDefinition}; pub use progressive::ProgressiveGenerator; pub use template_engine::TemplateEngine; diff --git a/crates/mcp-codegen/src/template_engine.rs b/crates/mcp-codegen/src/template_engine.rs index 549b9dd..0ed9dfc 100644 --- a/crates/mcp-codegen/src/template_engine.rs +++ b/crates/mcp-codegen/src/template_engine.rs @@ -58,6 +58,9 @@ impl<'a> TemplateEngine<'a> { // Register progressive loading templates Self::register_progressive_templates(&mut handlebars)?; + // Register Claude Agent SDK templates + Self::register_claude_agent_templates(&mut handlebars)?; + Ok(Self { handlebars }) } @@ -102,6 +105,46 @@ impl<'a> TemplateEngine<'a> { Ok(()) } + /// Registers Claude Agent SDK templates. + /// + /// Registers templates for Claude Agent SDK format with Zod schemas. + fn register_claude_agent_templates(handlebars: &mut Handlebars<'a>) -> Result<()> { + // Tool template: generates a single tool with Zod schema + handlebars + .register_template_string( + "claude_agent/tool", + include_str!("../templates/claude_agent/tool.ts.hbs"), + ) + .map_err(|e| Error::SerializationError { + message: format!("Failed to register claude_agent tool template: {e}"), + source: None, + })?; + + // Server template: generates MCP server with createSdkMcpServer + handlebars + .register_template_string( + "claude_agent/server", + include_str!("../templates/claude_agent/server.ts.hbs"), + ) + .map_err(|e| Error::SerializationError { + message: format!("Failed to register claude_agent server template: {e}"), + source: None, + })?; + + // Index template: entry point with exports + handlebars + .register_template_string( + "claude_agent/index", + include_str!("../templates/claude_agent/index.ts.hbs"), + ) + .map_err(|e| Error::SerializationError { + message: format!("Failed to register claude_agent index template: {e}"), + source: None, + })?; + + Ok(()) + } + /// Renders a template with the given context. /// /// # Errors @@ -173,6 +216,10 @@ mod tests { use super::*; use serde_json::json; + // ======================================================================== + // Template Engine Creation Tests + // ======================================================================== + #[test] fn test_template_engine_creation() { let engine = TemplateEngine::new(); @@ -180,10 +227,18 @@ mod tests { } #[test] - fn test_render_progressive_templates() { + fn test_default_trait() { + let _engine = TemplateEngine::default(); + } + + // ======================================================================== + // Progressive Loading Template Tests + // ======================================================================== + + #[test] + fn test_render_progressive_tool_template() { let engine = TemplateEngine::new().unwrap(); - // Test progressive/tool template let tool_context = json!({ "typescript_name": "testTool", "description": "Test tool", @@ -195,13 +250,458 @@ mod tests { }); let result = engine.render("progressive/tool", &tool_context); - if let Err(e) = &result { - eprintln!("Error rendering template: {}", e); - } assert!(result.is_ok(), "Failed to render: {:?}", result.err()); - assert!(result.unwrap().contains("testTool")); + + let rendered = result.unwrap(); + assert!(rendered.contains("testTool")); + assert!(rendered.contains("Test tool")); + assert!(rendered.contains("test_tool")); + } + + #[test] + fn test_render_progressive_tool_with_properties() { + let engine = TemplateEngine::new().unwrap(); + + let tool_context = json!({ + "typescript_name": "createIssue", + "description": "Create a GitHub issue", + "server_id": "github", + "name": "create_issue", + "properties": [ + { + "name": "title", + "typescript_type": "string", + "required": true, + "description": "Issue title" + }, + { + "name": "body", + "typescript_type": "string", + "required": false, + "description": "Issue body" + } + ], + "has_required_properties": true, + "input_schema": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "body": { "type": "string" } + }, + "required": ["title"] + } + }); + + let result = engine.render("progressive/tool", &tool_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("createIssue")); + assert!(rendered.contains("Create a GitHub issue")); + } + + #[test] + fn test_render_progressive_index_template() { + let engine = TemplateEngine::new().unwrap(); + + let index_context = json!({ + "server_name": "GitHub MCP Server", + "server_version": "1.0.0", + "tool_count": 2, + "tools": [ + { + "typescript_name": "createIssue", + "description": "Create an issue" + }, + { + "typescript_name": "listRepos", + "description": "List repositories" + } + ] + }); + + let result = engine.render("progressive/index", &index_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("GitHub MCP Server")); + assert!(rendered.contains("1.0.0")); + assert!(rendered.contains("createIssue")); + assert!(rendered.contains("listRepos")); + assert!(rendered.contains("2 tools")); + } + + #[test] + fn test_render_progressive_runtime_bridge_template() { + let engine = TemplateEngine::new().unwrap(); + + // Runtime bridge doesn't need context + let context = json!({}); + + let result = engine.render("progressive/runtime-bridge", &context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("callMCPTool")); + assert!(rendered.contains("closeAllConnections")); + assert!(rendered.contains("MCP Runtime Bridge")); + } + + // ======================================================================== + // Claude Agent SDK Template Tests + // ======================================================================== + + #[test] + fn test_render_claude_agent_tool_template() { + let engine = TemplateEngine::new().unwrap(); + + let tool_context = json!({ + "typescript_name": "searchFiles", + "name": "search_files", + "description": "Search for files in repository", + "pascal_name": "SearchFiles", + "properties": [ + { + "name": "query", + "zod_type": "string", + "zod_modifiers": [], + "description": "Search query", + "required": true + }, + { + "name": "limit", + "zod_type": "number", + "zod_modifiers": [".int()", ".positive()"], + "description": "Maximum results", + "required": false + } + ] + }); + + let result = engine.render("claude_agent/tool", &tool_context); + assert!(result.is_ok(), "Failed to render: {:?}", result.err()); + + let rendered = result.unwrap(); + assert!(rendered.contains("import { tool }")); + assert!(rendered.contains("import { z }")); + assert!(rendered.contains("searchFiles")); + assert!(rendered.contains("search_files")); + assert!(rendered.contains("Search for files")); + assert!(rendered.contains("z.string()")); + assert!(rendered.contains("z.number()")); + assert!(rendered.contains(".optional()")); + } + + #[test] + fn test_render_claude_agent_tool_with_complex_types() { + let engine = TemplateEngine::new().unwrap(); + + let tool_context = json!({ + "typescript_name": "analyzeCode", + "name": "analyze_code", + "description": "Analyze source code quality", + "pascal_name": "AnalyzeCode", + "properties": [ + { + "name": "files", + "zod_type": "array", + "zod_modifiers": [".of(z.string())"], + "description": "File paths to analyze", + "required": true + }, + { + "name": "options", + "zod_type": "object", + "zod_modifiers": [], + "description": "Analysis options", + "required": false + } + ] + }); + + let result = engine.render("claude_agent/tool", &tool_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("analyzeCode")); + assert!(rendered.contains("z.array()")); + assert!(rendered.contains("z.object()")); + } + + #[test] + fn test_render_claude_agent_server_template() { + let engine = TemplateEngine::new().unwrap(); + + let server_context = json!({ + "server_name": "filesystem", + "server_version": "2.0.0", + "server_variable_name": "filesystem", + "tools": [ + { + "typescript_name": "readFile" + }, + { + "typescript_name": "writeFile" + }, + { + "typescript_name": "deleteFile" + } + ] + }); + + let result = engine.render("claude_agent/server", &server_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("import { createSdkMcpServer }")); + assert!(rendered.contains("filesystem")); + assert!(rendered.contains("2.0.0")); + assert!(rendered.contains("readFile")); + assert!(rendered.contains("writeFile")); + assert!(rendered.contains("deleteFile")); + assert!(rendered.contains("filesystemServer")); + } + + #[test] + fn test_render_claude_agent_index_template() { + let engine = TemplateEngine::new().unwrap(); + + let index_context = json!({ + "server_name": "database", + "server_version": "3.1.0", + "server_variable_name": "database", + "tool_count": 4, + "tools": [ + { + "typescript_name": "query" + }, + { + "typescript_name": "insert" + }, + { + "typescript_name": "update" + }, + { + "typescript_name": "delete" + } + ] + }); + + let result = engine.render("claude_agent/index", &index_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("database")); + assert!(rendered.contains("3.1.0")); + assert!(rendered.contains("databaseServer")); + assert!(rendered.contains("toolCount: 4")); + assert!(rendered.contains("query")); + assert!(rendered.contains("insert")); + assert!(rendered.contains("update")); + assert!(rendered.contains("delete")); + } + + // ======================================================================== + // Error Handling Tests + // ======================================================================== + + #[test] + fn test_render_nonexistent_template() { + let engine = TemplateEngine::new().unwrap(); + let context = json!({"name": "test"}); + + let result = engine.render("nonexistent/template", &context); + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(matches!(err, Error::SerializationError { .. })); + } + + #[test] + fn test_render_with_missing_required_field() { + let engine = TemplateEngine::new().unwrap(); + + // Missing required field "typescript_name" + let invalid_context = json!({ + "description": "Test tool", + "server_id": "test" + }); + + let result = engine.render("progressive/tool", &invalid_context); + assert!(result.is_err(), "Should fail with missing required field"); } + #[test] + fn test_render_with_empty_context() { + let engine = TemplateEngine::new().unwrap(); + let empty_context = json!({}); + + let result = engine.render("progressive/tool", &empty_context); + assert!(result.is_err()); + } + + #[test] + fn test_register_invalid_template_syntax() { + let mut engine = TemplateEngine::new().unwrap(); + + // Invalid Handlebars syntax: unclosed tag + let result = engine.register_template_string("invalid", "Hello {{name"); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + Error::SerializationError { .. } + )); + } + + #[test] + fn test_register_template_with_invalid_helper() { + let mut engine = TemplateEngine::new().unwrap(); + + // Template with non-existent helper + let result = engine.register_template_string("bad_helper", "{{nonexistent_helper name}}"); + + // Registration might succeed, but rendering will fail + assert!(result.is_ok()); + } + + // ======================================================================== + // Edge Case Tests + // ======================================================================== + + #[test] + fn test_render_with_empty_tool_array() { + let engine = TemplateEngine::new().unwrap(); + + let context = json!({ + "server_name": "Empty Server", + "server_version": "1.0.0", + "tool_count": 0, + "tools": [] + }); + + let result = engine.render("progressive/index", &context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("Empty Server")); + assert!(rendered.contains("0 tools")); + } + + #[test] + fn test_render_with_special_characters_in_description() { + let engine = TemplateEngine::new().unwrap(); + + let tool_context = json!({ + "typescript_name": "testTool", + "description": "A tool with \"quotes\" and 'apostrophes' & special chars: <>&", + "server_id": "test", + "name": "test_tool", + "properties": [], + "has_required_properties": false, + "input_schema": {} + }); + + let result = engine.render("progressive/tool", &tool_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("quotes")); + assert!(rendered.contains("apostrophes")); + } + + #[test] + fn test_render_with_unicode_characters() { + let engine = TemplateEngine::new().unwrap(); + + let tool_context = json!({ + "typescript_name": "unicodeTool", + "description": "支持中文 и Русский язык 🚀", + "server_id": "test", + "name": "unicode_tool", + "properties": [], + "has_required_properties": false, + "input_schema": {} + }); + + let result = engine.render("progressive/tool", &tool_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("支持中文")); + assert!(rendered.contains("Русский")); + assert!(rendered.contains("🚀")); + } + + #[test] + fn test_render_with_very_long_description() { + let engine = TemplateEngine::new().unwrap(); + + let long_description = "A".repeat(5000); + let tool_context = json!({ + "typescript_name": "longDescriptionTool", + "description": long_description, + "server_id": "test", + "name": "long_tool", + "properties": [], + "has_required_properties": false, + "input_schema": {} + }); + + let result = engine.render("progressive/tool", &tool_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.len() > 5000); + } + + #[test] + fn test_render_with_nested_properties() { + let engine = TemplateEngine::new().unwrap(); + + let tool_context = json!({ + "typescript_name": "complexTool", + "name": "complex_tool", + "description": "Tool with nested schema", + "pascal_name": "ComplexTool", + "properties": [ + { + "name": "config", + "zod_type": "object", + "zod_modifiers": [".shape({ nested: z.string() })"], + "description": "Configuration object", + "required": true + } + ] + }); + + let result = engine.render("claude_agent/tool", &tool_context); + assert!(result.is_ok()); + } + + #[test] + fn test_render_claude_agent_tool_without_properties() { + let engine = TemplateEngine::new().unwrap(); + + let tool_context = json!({ + "typescript_name": "noParamsTool", + "name": "no_params", + "description": "Tool with no parameters", + "pascal_name": "NoParamsTool", + "properties": [] + }); + + let result = engine.render("claude_agent/tool", &tool_context); + assert!(result.is_ok()); + + let rendered = result.unwrap(); + assert!(rendered.contains("noParamsTool")); + } + + // ======================================================================== + // Custom Template Tests + // ======================================================================== + #[test] fn test_custom_template_registration() { let mut engine = TemplateEngine::new().unwrap(); @@ -216,15 +716,138 @@ mod tests { } #[test] - fn test_render_nonexistent_template() { - let engine = TemplateEngine::new().unwrap(); - let context = json!({"name": "test"}); - let result = engine.render("nonexistent", &context); + fn test_custom_template_with_loops() { + let mut engine = TemplateEngine::new().unwrap(); + + engine + .register_template_string("list", "Items: {{#each items}}{{this}}, {{/each}}") + .unwrap(); + + let context = json!({"items": ["a", "b", "c"]}); + let result = engine.render("list", &context).unwrap(); + assert_eq!(result, "Items: a, b, c, "); + } + + #[test] + fn test_custom_template_with_conditionals() { + let mut engine = TemplateEngine::new().unwrap(); + + engine + .register_template_string("conditional", "{{#if enabled}}ON{{else}}OFF{{/if}}") + .unwrap(); + + let context_on = json!({"enabled": true}); + let result_on = engine.render("conditional", &context_on).unwrap(); + assert_eq!(result_on, "ON"); + + let context_off = json!({"enabled": false}); + let result_off = engine.render("conditional", &context_off).unwrap(); + assert_eq!(result_off, "OFF"); + } + + #[test] + fn test_custom_template_override() { + let mut engine = TemplateEngine::new().unwrap(); + + // Register template + engine + .register_template_string("override", "Version 1") + .unwrap(); + + // Override with new version + engine + .register_template_string("override", "Version 2") + .unwrap(); + + let result = engine.render("override", &json!({})).unwrap(); + assert_eq!(result, "Version 2"); + } + + #[test] + fn test_render_with_null_values() { + let mut engine = TemplateEngine::new().unwrap(); + + engine + .register_template_string("nullable", "Value: {{value}}") + .unwrap(); + + let context = json!({"value": null}); + let result = engine.render("nullable", &context).unwrap(); + assert_eq!(result, "Value: "); + } + + #[test] + fn test_render_with_boolean_values() { + let mut engine = TemplateEngine::new().unwrap(); + + engine + .register_template_string("bool", "{{#if flag}}true{{else}}false{{/if}}") + .unwrap(); + + let context_true = json!({"flag": true}); + assert_eq!(engine.render("bool", &context_true).unwrap(), "true"); + + let context_false = json!({"flag": false}); + assert_eq!(engine.render("bool", &context_false).unwrap(), "false"); + } + + #[test] + fn test_render_with_numeric_values() { + let mut engine = TemplateEngine::new().unwrap(); + + engine + .register_template_string("numbers", "Count: {{count}}, Ratio: {{ratio}}") + .unwrap(); + + let context = json!({"count": 42, "ratio": 1.618}); + let result = engine.render("numbers", &context).unwrap(); + assert!(result.contains("42")); + assert!(result.contains("1.618")); + } + + // ======================================================================== + // Strict Mode Tests + // ======================================================================== + + #[test] + fn test_strict_mode_fails_on_missing_variable() { + let mut custom_engine = TemplateEngine::new().unwrap(); + custom_engine + .register_template_string("strict", "Value: {{missing_var}}") + .unwrap(); + + let context = json!({"other_var": "value"}); + let result = custom_engine.render("strict", &context); + + // Strict mode should fail on missing variable assert!(result.is_err()); } #[test] - fn test_default_trait() { - let _engine = TemplateEngine::default(); + fn test_multiple_template_renders() { + let engine = TemplateEngine::new().unwrap(); + + let tool_context = json!({ + "typescript_name": "tool1", + "description": "First tool", + "server_id": "test", + "name": "tool_1", + "properties": [], + "has_required_properties": false, + "input_schema": {} + }); + + // Render same template multiple times + for _ in 0..10 { + let result = engine.render("progressive/tool", &tool_context); + assert!(result.is_ok()); + } + } + + #[test] + fn test_concurrent_template_usage() { + // TemplateEngine should be Send + Sync + fn assert_send_sync() {} + assert_send_sync::(); } } diff --git a/crates/mcp-codegen/templates/claude_agent/index.ts.hbs b/crates/mcp-codegen/templates/claude_agent/index.ts.hbs new file mode 100644 index 0000000..4eef7f9 --- /dev/null +++ b/crates/mcp-codegen/templates/claude_agent/index.ts.hbs @@ -0,0 +1,31 @@ +/** + * {{server_name}} - Claude Agent SDK Integration + * + * This package provides type-safe MCP tools for the Claude Agent SDK. + * + * @version {{server_version}} + * @packageDocumentation + */ + +// Export the MCP server +export { {{server_variable_name}}Server, {{server_variable_name}}Server as default } from "./server"; + +// Export individual tools for selective use +{{#each tools}} +export { {{typescript_name}} } from "./tools/{{typescript_name}}"; +export type { {{typescript_name}}Args } from "./tools/{{typescript_name}}"; +{{/each}} + +/** + * Server information + */ +export const serverInfo = { + name: "{{server_name}}", + version: "{{server_version}}", + toolCount: {{tool_count}}, + tools: [ +{{#each tools}} + "{{typescript_name}}", +{{/each}} + ] as const +} as const; diff --git a/crates/mcp-codegen/templates/claude_agent/server.ts.hbs b/crates/mcp-codegen/templates/claude_agent/server.ts.hbs new file mode 100644 index 0000000..f43c86d --- /dev/null +++ b/crates/mcp-codegen/templates/claude_agent/server.ts.hbs @@ -0,0 +1,26 @@ +import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; +{{#each tools}} +import { {{typescript_name}} } from "./tools/{{typescript_name}}"; +{{/each}} + +/** + * {{server_name}} MCP Server + * + * Generated automatically from MCP server introspection. + * + * @version {{server_version}} + */ +export const {{server_variable_name}}Server = createSdkMcpServer({ + name: "{{server_name}}", + version: "{{server_version}}", + tools: [ +{{#each tools}} + {{typescript_name}}, +{{/each}} + ] +}); + +/** + * Default export for convenience. + */ +export default {{server_variable_name}}Server; diff --git a/crates/mcp-codegen/templates/claude_agent/tool.ts.hbs b/crates/mcp-codegen/templates/claude_agent/tool.ts.hbs new file mode 100644 index 0000000..cf02a69 --- /dev/null +++ b/crates/mcp-codegen/templates/claude_agent/tool.ts.hbs @@ -0,0 +1,40 @@ +import { tool } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; + +/** + * {{description}} + * + * @remarks + * This is a generated stub. Replace the implementation below with your actual tool logic. + * The Zod schema provides type-safe argument validation. + * + * @example + * ```typescript + * // Example implementation connecting to an API: + * async (args) => { + * const result = await yourApi.{{typescript_name}}(args); + * return { content: [{ type: "text", text: JSON.stringify(result) }] }; + * } + * ``` + */ +export const {{typescript_name}} = tool( + "{{name}}", + "{{description}}", + { +{{#each properties}} + {{name}}: z.{{zod_type}}(){{#each zod_modifiers}}{{this}}{{/each}}{{#if description}}.describe("{{description}}"){{/if}}{{#unless required}}.optional(){{/unless}}, +{{/each}} + }, + async (args) => { + // Stub implementation - replace with your actual tool logic + // See JSDoc above for example implementation patterns + throw new Error("Tool '{{name}}' not implemented. Replace this stub with your implementation."); + } +); + +/** + * Type for {{typescript_name}} arguments. + */ +export type {{pascal_name}}Args = typeof {{typescript_name}} extends { inputSchema: infer T } + ? T extends z.ZodType ? U : never + : never; diff --git a/docs/adr/012-claude-agent-sdk-generator.md b/docs/adr/012-claude-agent-sdk-generator.md new file mode 100644 index 0000000..6963fc9 --- /dev/null +++ b/docs/adr/012-claude-agent-sdk-generator.md @@ -0,0 +1,272 @@ +# ADR-012: Claude Agent SDK Code Generator + +## Status + +**Proposed** (2025-01-27) + +Extends: ADR-010 (Progressive Loading) + +## Context + +The MCP Code Execution project currently generates TypeScript files for progressive loading pattern. This enables Claude Code to discover and load MCP tools on-demand, achieving 98% token savings. + +### New Requirement + +Anthropic has released the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/typescript), which provides a programmatic way to build AI agents using Claude. The SDK includes support for custom MCP tools via the `createSdkMcpServer()` and `tool()` functions. + +### Claude Agent SDK Tool Format + +The SDK uses Zod schemas for type-safe tool definitions: + +```typescript +import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; + +const server = createSdkMcpServer({ + name: "my-server", + version: "1.0.0", + tools: [ + tool( + "tool_name", + "Tool description", + { + param1: z.string().describe("Parameter description"), + param2: z.number().optional() + }, + async (args) => { + // Implementation + return { + content: [{ type: "text", text: "Result" }] + }; + } + ) + ] +}); +``` + +### Opportunity + +We can extend our code generation to produce Claude Agent SDK-compatible TypeScript, allowing users to: + +1. Integrate MCP tools directly into their Claude Agent SDK applications +2. Use type-safe Zod schemas instead of raw JSON Schema +3. Build custom agents that combine MCP tools with business logic + +## Decision + +**Add a new `claude_agent` generator module alongside the existing `progressive` module.** + +### Architecture + +``` +crates/mcp-codegen/ +├── src/ +│ ├── lib.rs # Add: pub mod claude_agent; +│ ├── progressive/ # Existing (unchanged) +│ └── claude_agent/ # NEW +│ ├── mod.rs +│ ├── generator.rs # ClaudeAgentGenerator +│ └── types.rs # Context types for templates +└── templates/ + ├── progressive/ # Existing (unchanged) + └── claude_agent/ # NEW + ├── tool.ts.hbs # Individual tool definition + ├── server.ts.hbs # Complete MCP server + └── index.ts.hbs # Entry point with exports +``` + +### Generated Output Structure + +For a server with 3 tools, generates: + +``` +~/.claude/agent-sdk/{server-id}/ +├── index.ts # Entry point, exports server and types +├── server.ts # createSdkMcpServer() with all tools +├── tools/ +│ ├── createIssue.ts # Individual tool with Zod schema +│ ├── updateIssue.ts +│ └── deleteIssue.ts +└── types.ts # Shared type definitions +``` + +### Template Design + +#### Individual Tool (`tools/*.ts`) + +```typescript +import { tool } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; + +/** + * {{description}} + */ +export const {{typescript_name}} = tool( + "{{name}}", + "{{description}}", + { +{{#each properties}} + {{name}}: z.{{zod_type}}(){{#if description}}.describe("{{description}}"){{/if}}{{#unless required}}.optional(){{/unless}}, +{{/each}} + }, + async (args) => { + // TODO: Implement tool logic + // This is a stub - actual implementation depends on MCP bridge + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ tool: "{{name}}", args }) + }] + }; + } +); + +export type {{pascal_name}}Args = z.infer; +``` + +#### Server File (`server.ts`) + +```typescript +import { createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; +{{#each tools}} +import { {{typescript_name}} } from "./tools/{{typescript_name}}"; +{{/each}} + +export const {{server_variable_name}}Server = createSdkMcpServer({ + name: "{{server_name}}", + version: "{{server_version}}", + tools: [ +{{#each tools}} + {{typescript_name}}, +{{/each}} + ] +}); +``` + +### Key Components + +1. **ClaudeAgentGenerator**: Main generator class + - Converts `ServerInfo` → `GeneratedCode` + - Uses Handlebars templates + - Parallel to `ProgressiveGenerator` + +2. **JSON Schema → Zod Mapping**: + - `string` → `z.string()` + - `number` → `z.number()` + - `integer` → `z.number().int()` + - `boolean` → `z.boolean()` + - `array` → `z.array(z.unknown())` + - `object` → `z.object({...})` + - Enum support: `z.enum([...])` + - Format hints: `.email()`, `.url()`, etc. + +3. **CLI Integration**: + - New `--format claude-agent` flag for `generate` command + - Default remains `progressive` + +## Consequences + +### Positive + +1. **Extended Ecosystem Support**: + - Users can integrate MCP tools into Claude Agent SDK applications + - Type-safe tool definitions with Zod + +2. **Code Reuse**: + - Shares `ServerInfo` from `mcp-introspector` + - Shares `GeneratedCode`, `GeneratedFile` from `common` + - Shares `TemplateEngine` infrastructure + +3. **Minimal Complexity Increase**: + - New module parallel to existing + - No changes to progressive loading + - Clear separation of concerns + +4. **Better Developer Experience**: + - Zod provides runtime validation + - TypeScript inference from schemas + - IDE autocomplete for tool arguments + +### Negative + +1. **Increased Maintenance**: + - New templates to maintain + - JSON Schema → Zod mapping logic + - Additional integration tests + +2. **Dependency on External SDK**: + - Generated code requires `@anthropic-ai/claude-agent-sdk` + - Generated code requires `zod` + +3. **Implementation Stub**: + - Generated tool handlers are stubs + - Actual MCP bridge integration TBD + +### Neutral + +1. **Output Location**: + - Uses `~/.claude/agent-sdk/` instead of `~/.claude/servers/` + - Clear separation from progressive loading output + +## Implementation Plan + +### Phase 1: Core Generator + +1. Create `claude_agent` module structure +2. Implement `ClaudeAgentGenerator` +3. Create Handlebars templates +4. Add JSON Schema → Zod type mapping + +### Phase 2: CLI Integration + +1. Add `--format` flag to `generate` command +2. Support `progressive` (default) and `claude-agent` +3. Update help text and documentation + +### Phase 3: Testing + +1. Unit tests for generator +2. Unit tests for Zod type mapping +3. Integration tests with real MCP servers + +### Phase 4: Documentation + +1. Update README with new format +2. Add usage examples +3. Document generated file structure + +## Alternatives Considered + +### Alternative 1: Modify Progressive Loading + +Modify existing progressive generator to output Claude Agent SDK format. + +**Pros**: Less code duplication +**Cons**: Conflates two different output formats, harder to maintain + +**Rejected**: Clean separation is better for maintainability. + +### Alternative 2: External Post-Processor + +Create external tool that transforms progressive output to Claude Agent SDK format. + +**Pros**: Decoupled, could be separate npm package +**Cons**: Extra step, harder to maintain consistency + +**Rejected**: Integrated solution provides better user experience. + +### Alternative 3: Generate Only Tool Definitions + +Generate only the Zod schemas, let users create server.ts manually. + +**Pros**: Simpler, more flexible +**Cons**: More work for users, less value add + +**Rejected**: Full generation provides more value. + +## References + +- [Claude Agent SDK - TypeScript](https://platform.claude.com/docs/en/agent-sdk/typescript) +- [Claude Agent SDK - Custom Tools](https://platform.claude.com/docs/en/agent-sdk/custom-tools) +- [Zod Documentation](https://zod.dev/) +- [ADR-010: Progressive Loading Only](./010-simplify-to-progressive-only.md)