diff --git a/.github/workflows/publish-typegen-npm.yaml b/.github/workflows/publish-typegen-npm.yaml new file mode 100644 index 0000000000..7963a45b2a --- /dev/null +++ b/.github/workflows/publish-typegen-npm.yaml @@ -0,0 +1,87 @@ +name: Publish @decocms/typegen + +on: + push: + branches: [main] + paths: + - "packages/typegen/**" + workflow_dispatch: + +permissions: + contents: write + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Node.js for npm registry + uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + working-directory: packages/typegen + + - name: Check if version changed + id: version-check + run: | + CURRENT_VERSION=$(node -e "console.log(require('./package.json').version)") + + if npm view @decocms/typegen@$CURRENT_VERSION version >/dev/null 2>&1; then + echo "version-changed=false" >> $GITHUB_OUTPUT + echo "⏭️ Version $CURRENT_VERSION already published, skipping publish" + else + echo "version-changed=true" >> $GITHUB_OUTPUT + echo "βœ… Version $CURRENT_VERSION not found in npm, will publish" + fi + + echo "current-version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + + if [[ "$CURRENT_VERSION" == *-* ]]; then + echo "npm-tag=next" >> $GITHUB_OUTPUT + echo "πŸ“¦ Prerelease version detected, will publish with tag 'next'" + else + echo "npm-tag=latest" >> $GITHUB_OUTPUT + echo "πŸ“¦ Stable version detected, will publish with tag 'latest'" + fi + working-directory: packages/typegen + + - name: Publish to npm + if: steps.version-check.outputs.version-changed == 'true' + run: npm publish --access public --tag ${{ steps.version-check.outputs.npm-tag }} --provenance + working-directory: packages/typegen + + - name: Create Release + if: steps.version-check.outputs.version-changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "typegen-v${{ steps.version-check.outputs.current-version }}" \ + --title "@decocms/typegen v${{ steps.version-check.outputs.current-version }}" \ + --notes "## Changes + + Automated release of @decocms/typegen package. + + ### Installation + \`\`\`bash + npm install @decocms/typegen + \`\`\` + + ### Usage + \`\`\`bash + bunx @decocms/typegen --mcp --key --output client.ts + \`\`\`" diff --git a/apps/mesh/src/web/components/details/virtual-mcp/index.tsx b/apps/mesh/src/web/components/details/virtual-mcp/index.tsx index 9d9b0f549d..4cc4da7f40 100644 --- a/apps/mesh/src/web/components/details/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/components/details/virtual-mcp/index.tsx @@ -41,7 +41,7 @@ import { Loading01, Play, Plus, - Share07, + ZapCircle, Tool01, Users03, } from "@untitledui/icons"; @@ -308,24 +308,17 @@ function VirtualMcpDetailViewWithData({ Test this agent in chat - - - - - - - Share - + (null); + const [generating, setGenerating] = useState(false); + const [copied, setCopied] = useState(false); + + const mcpId = virtualMcp.id; + const agentName = virtualMcp.title || `agent-${mcpId.slice(0, 8)}`; + const command = apiKey + ? `bunx @decocms/typegen@latest --mcp ${mcpId} --key ${apiKey} --output client.ts` + : `bunx @decocms/typegen@latest --mcp ${mcpId} --key --output client.ts`; + + const handleGenerateKey = async () => { + setGenerating(true); + try { + const result = (await client.callTool({ + name: "API_KEY_CREATE", + arguments: { + name: `typegen-${agentName}`, + permissions: { [mcpId]: ["*"] }, + }, + })) as { structuredContent?: { key?: string } }; + const key = result.structuredContent?.key; + if (!key) throw new Error("No key in response"); + setApiKey(key); + } catch { + toast.error("Failed to generate API key"); + } finally { + setGenerating(false); + } + }; + + const handleCopy = async () => { + await navigator.clipboard.writeText(command); + setCopied(true); + toast.success("Command copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+

+ Generate typed client +

+

+ Introspects this agent and writes a typed{" "} + client.ts you can import + directly. +

+
+ {!apiKey && ( + + )} +
+ + {apiKey && ( +

+ Store this key securely β€” it won't be shown again. +

+ )} + +

+ Generate client +

+
+
+ + {command} + + +
+
+ +

+ Runtime variables +

+ +
+ ); +} + +function EnvVarsBlock({ apiKey }: { apiKey: string | null }) { + const [copied, setCopied] = useState(false); + const meshUrl = window.location.origin; + const keyLine = apiKey ? `MESH_API_KEY=${apiKey}` : `MESH_API_KEY=`; + const urlLine = `MESH_BASE_URL=${meshUrl}`; + const envBlock = `${keyLine}\n${urlLine}`; + + const handleCopy = async () => { + await navigator.clipboard.writeText(envBlock); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ + {keyLine} +
+ {urlLine} +
+ +
+
+ ); +} + +function TypegenSection({ virtualMcp }: { virtualMcp: VirtualMCPEntity }) { + return ( + } + > + + + ); +} + /** * Share Modal - Virtual MCP sharing and IDE integration */ @@ -218,9 +391,9 @@ export function VirtualMCPShareModal({ return ( - + - Share Agent + Connect
{/* Mode Selection */} @@ -363,6 +536,11 @@ export function VirtualMCPShareModal({ />
+ +
+ + {/* Typegen */} +
diff --git a/docs/plans/2026-02-25-typegen-cli-design.md b/docs/plans/2026-02-25-typegen-cli-design.md new file mode 100644 index 0000000000..2ab7393dd6 --- /dev/null +++ b/docs/plans/2026-02-25-typegen-cli-design.md @@ -0,0 +1,132 @@ +# Design: `@decocms/typegen` β€” MCP TypeScript Client Generator + +**Date:** 2026-02-25 +**Status:** Approved + +## Overview + +A new package `packages/typegen` published as `@decocms/typegen` with two purposes: + +1. **CLI bin** (dev time) β€” connects to a Virtual MCP, introspects its tools, and generates a typed TypeScript client file +2. **Runtime library** (ships with user app) β€” exports `createMeshClient()` used by the generated file + +## Usage + +```bash +bunx @decocms/typegen --mcp --key --output client.ts +``` + +## Package Structure + +``` +packages/typegen/ + src/ + cli.ts # bin entry point β€” parses args, orchestrates codegen + codegen.ts # connects to MCP, lists tools, generates TypeScript + runtime.ts # createMeshClient factory + Proxy + types + index.ts # library exports (createMeshClient, MeshClientInstance) + package.json + tsup.config.ts + tsconfig.json +``` + +**Name:** `@decocms/typegen` +**Bin:** `typegen` (invoked via `bunx @decocms/typegen`) + +## Dependencies + +Bundled inside `@decocms/typegen` β€” users only install this one package: + +- `@modelcontextprotocol/sdk` β€” CLI uses it to connect + list tools; runtime uses it for `createMeshClient` +- `json-schema-to-typescript` β€” converts JSON Schema β†’ TypeScript interfaces during codegen + +## Generated Output + +```typescript +// client.ts (generated) +import { createMeshClient } from "@decocms/typegen"; + +export interface Tools { + SEARCH_TOOL: { + input: { query: string; limit?: number }; + output: { results: Array<{ id: string; title: string }> }; + }; + SUBMIT_FORM: { + input: { name: string; data: Record }; + output: { id: string; status: "created" }; + }; +} + +export const client = createMeshClient({ + mcpId: "vmc_abc123", + apiKey: process.env.MESH_API_KEY, + baseUrl: process.env.MESH_BASE_URL, +}); +``` + +Callers use: + +```typescript +import { client } from "./client"; + +const result = await client.SEARCH_TOOL({ query: "hello", limit: 10 }); +// result is typed: { results: Array<{ id: string; title: string }> } +``` + +## CLI Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--mcp` | yes | β€” | Virtual MCP ID | +| `--key` | no | `MESH_API_KEY` env var | Mesh API key | +| `--url` | no | `https://mesh-admin.decocms.com` | Mesh base URL | +| `--output` | no | `client.ts` | Output file path | + +## Codegen Logic (`codegen.ts`) + +1. Connect to `/mcp/virtual-mcp/:mcpId` using MCP SDK `Client` + `StreamableHTTPClientTransport` with `Authorization: Bearer ` +2. Call `client.listTools()` β€” each tool has `inputSchema` (JSON Schema) and optionally `outputSchema` (supported in MCP protocol; Mesh's `defineTool` populates it) +3. For each tool: + - Convert `inputSchema` β†’ TypeScript via `json-schema-to-typescript` β†’ `input` type + - Convert `outputSchema` β†’ TypeScript if present; otherwise fall back to `unknown` β†’ `output` type +4. Assemble the `Tools` interface and `createMeshClient({...})` call +5. Format with Prettier +6. Write to `--output` + +## Runtime (`runtime.ts`) + +```typescript +type ToolMap = Record; + +export type MeshClientInstance = { + [K in keyof T]: (input: T[K]["input"]) => Promise; +}; + +export interface MeshClientOptions { + mcpId: string; + apiKey?: string; // falls back to process.env.MESH_API_KEY + baseUrl?: string; // falls back to https://mesh-admin.decocms.com +} + +export function createMeshClient( + opts: MeshClientOptions +): MeshClientInstance; +``` + +Implementation: +- Lazy-connects on the first tool call using MCP SDK `Client` + `StreamableHTTPClientTransport` +- Caches the connection β€” subsequent calls on the same `client` instance reuse it +- Returns a `Proxy` that traps property access: `client.TOOL_NAME(input)` β†’ `mcpClient.callTool({ name: "TOOL_NAME", arguments: input })` β†’ `result.structuredContent` +- Throws if `result.isError` is true, with the error text from `result.content` + +## Error Handling + +- CLI: prints clear error + exits with code 1 if connection fails, auth fails, or MCP not found +- Runtime: throws `MeshToolError` with `toolName` and message on tool call failure + +## Build + +Uses `tsup` (same pattern as `packages/cli`): +- Entry points: `src/cli.ts` (bin) + `src/index.ts` (library) +- Format: ESM +- Bundle: true (includes SDK + json-schema-to-typescript so users don't install separately) diff --git a/docs/plans/2026-02-25-typegen-implementation.md b/docs/plans/2026-02-25-typegen-implementation.md new file mode 100644 index 0000000000..17763c0b06 --- /dev/null +++ b/docs/plans/2026-02-25-typegen-implementation.md @@ -0,0 +1,774 @@ +# `@decocms/typegen` Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a new `packages/typegen` package that generates a typed TypeScript MCP client and exports a `createMeshClient()` runtime factory. + +**Architecture:** A dual-purpose package β€” a CLI bin (`bunx @decocms/typegen`) that connects to a Virtual MCP via the MCP SDK, introspects its tools, and writes a generated `client.ts`; plus a runtime export (`createMeshClient`) that returns a Proxy typed to the generated `Tools` interface. The MCP SDK and `json-schema-to-typescript` are listed as direct dependencies so `npm install @decocms/typegen` brings everything. + +**Tech Stack:** TypeScript, Bun test runner, tsup (ESM bundle), `@modelcontextprotocol/sdk@1.26.0`, `json-schema-to-typescript`, Prettier (for formatting generated output) + +--- + +## Context: Relevant Files to Read First + +Before starting, read these files to understand patterns used in the repo: + +- `packages/cli/package.json` β€” package.json shape, dep versions, bin setup +- `packages/cli/tsup.config.ts` β€” tsup config pattern (external deps, ESM, splitting) +- `packages/cli/tsconfig.json` β€” tsconfig extends pattern +- `packages/cli/src/lib/mcp.ts` β€” how MCP SDK Client is used (StreamableHTTPClientTransport, connect, callTool) +- `tsconfig.json` (root) β€” root tsconfig that all packages extend + +--- + +## Task 1: Scaffold package files + +**Files:** +- Create: `packages/typegen/package.json` +- Create: `packages/typegen/tsconfig.json` +- Create: `packages/typegen/tsup.config.ts` + +**Step 1: Create `packages/typegen/package.json`** + +```json +{ + "name": "@decocms/typegen", + "version": "0.1.0", + "description": "Generate typed TypeScript clients for Mesh Virtual MCPs", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "typegen": "./dist/cli.js" + }, + "files": ["dist/**/*"], + "scripts": { + "build": "tsup", + "dev": "tsx src/cli.ts", + "check": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.26.0", + "json-schema-to-typescript": "^15.0.4", + "prettier": "^3.6.2" + }, + "devDependencies": { + "@types/node": "^24.6.2", + "tsup": "^8.5.0", + "tsx": "^4.7.1", + "typescript": "^5.9.3" + }, + "engines": { "node": ">=20.0.0" }, + "publishConfig": { "access": "public" } +} +``` + +**Step 2: Create `packages/typegen/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "allowImportingTsExtensions": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +**Step 3: Create `packages/typegen/tsup.config.ts`** + +```typescript +import { defineConfig, type Options } from "tsup"; + +const config: Options = { + entry: { + index: "src/index.ts", + cli: "src/cli.ts", + }, + format: ["esm"], + target: "es2022", + bundle: true, + sourcemap: true, + clean: true, + dts: true, + splitting: true, + treeshake: true, + shims: true, + external: [ + "node:*", + "@modelcontextprotocol/sdk", + "json-schema-to-typescript", + "prettier", + ], +}; + +export default defineConfig(config); +``` + +**Step 4: Install deps** + +```bash +bun install +``` + +Expected: deps resolved, `node_modules` updated. + +**Step 5: Commit** + +```bash +git add packages/typegen/ +git commit -m "feat(typegen): scaffold package structure" +``` + +--- + +## Task 2: Runtime types + +**Files:** +- Create: `packages/typegen/src/index.ts` + +**Step 1: Create `packages/typegen/src/index.ts`** + +This file exports the public API β€” types and the runtime factory. The implementation comes in Task 3. + +```typescript +export type ToolMap = Record; + +export type MeshClientInstance = { + [K in keyof T]: (input: T[K]["input"]) => Promise; +}; + +export interface MeshClientOptions { + mcpId: string; + /** Falls back to process.env.MESH_API_KEY */ + apiKey?: string; + /** Falls back to https://mesh-admin.decocms.com */ + baseUrl?: string; +} + +export { createMeshClient } from "./runtime.js"; +``` + +**Step 2: Commit** + +```bash +git add packages/typegen/src/index.ts +git commit -m "feat(typegen): add public types" +``` + +--- + +## Task 3: Runtime β€” `createMeshClient` + +**Files:** +- Create: `packages/typegen/src/runtime.ts` +- Create: `packages/typegen/src/runtime.test.ts` + +**Step 1: Write the failing test** + +Create `packages/typegen/src/runtime.test.ts`: + +```typescript +import { describe, test, expect, mock, beforeEach } from "bun:test"; + +// Mock the MCP SDK before importing runtime +const mockCallTool = mock(async ({ name, arguments: args }: { name: string; arguments: unknown }) => ({ + isError: false, + structuredContent: { tool: name, args }, +})); + +const mockConnect = mock(async () => {}); + +const MockClient = mock(function () { + return { callTool: mockCallTool, connect: mockConnect }; +}); + +const MockTransport = mock(function () {}); + +mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ + Client: MockClient, +})); + +mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: MockTransport, +})); + +// Import AFTER mocking +const { createMeshClient } = await import("./runtime.js"); + +describe("createMeshClient", () => { + beforeEach(() => { + mockCallTool.mockClear(); + mockConnect.mockClear(); + MockClient.mockClear(); + MockTransport.mockClear(); + }); + + test("returns an object with callable tool methods", async () => { + type Tools = { + MY_TOOL: { input: { id: string }; output: { name: string } }; + }; + + const client = createMeshClient({ + mcpId: "vmc_test", + apiKey: "sk_test", + }); + + const result = await client.MY_TOOL({ id: "123" }); + + expect(result).toEqual({ tool: "MY_TOOL", args: { id: "123" } }); + expect(mockCallTool).toHaveBeenCalledWith({ + name: "MY_TOOL", + arguments: { id: "123" }, + }); + }); + + test("lazy-connects on first call", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + + const client = createMeshClient({ mcpId: "vmc_test", apiKey: "sk" }); + + expect(mockConnect).not.toHaveBeenCalled(); + + await client.TOOL({}); + + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + test("reuses connection on subsequent calls", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + + const client = createMeshClient({ mcpId: "vmc_test", apiKey: "sk" }); + + await client.TOOL({}); + await client.TOOL({}); + await client.TOOL({}); + + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + test("throws on isError response", async () => { + mockCallTool.mockResolvedValueOnce({ + isError: true, + content: [{ text: "Tool failed: bad input" }], + }); + + type Tools = { FAIL_TOOL: { input: Record; output: unknown } }; + const client = createMeshClient({ mcpId: "vmc_test", apiKey: "sk" }); + + await expect(client.FAIL_TOOL({})).rejects.toThrow("Tool failed: bad input"); + }); + + test("builds URL with correct mcpId and baseUrl", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + + createMeshClient({ + mcpId: "vmc_abc123", + apiKey: "sk_key", + baseUrl: "https://custom.example.com", + }); + + // Transport is constructed lazily, so call a tool to trigger connect + const client = createMeshClient({ + mcpId: "vmc_abc123", + apiKey: "sk_key", + baseUrl: "https://custom.example.com", + }); + + await client.TOOL({}); + + const transportArg = MockTransport.mock.calls[0][0] as URL; + expect(transportArg.toString()).toBe( + "https://custom.example.com/mcp/virtual-mcp/vmc_abc123" + ); + }); + + test("defaults baseUrl to https://mesh-admin.decocms.com", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + const client = createMeshClient({ mcpId: "vmc_abc", apiKey: "sk" }); + + await client.TOOL({}); + + const transportArg = MockTransport.mock.calls[0][0] as URL; + expect(transportArg.toString()).toBe( + "https://mesh-admin.decocms.com/mcp/virtual-mcp/vmc_abc" + ); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +bun test packages/typegen/src/runtime.test.ts +``` + +Expected: FAIL β€” `./runtime.js` module not found. + +**Step 3: Write `packages/typegen/src/runtime.ts`** + +```typescript +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { MeshClientInstance, MeshClientOptions, ToolMap } from "./index.js"; + +const DEFAULT_BASE_URL = "https://mesh-admin.decocms.com"; + +export function createMeshClient( + opts: MeshClientOptions, +): MeshClientInstance { + let mcpClient: Client | null = null; + + async function getClient(): Promise { + if (mcpClient) return mcpClient; + + const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL; + const apiKey = opts.apiKey ?? process.env.MESH_API_KEY; + const url = new URL(`/mcp/virtual-mcp/${opts.mcpId}`, baseUrl); + + const client = new Client({ name: "@decocms/typegen", version: "1.0.0" }); + await client.connect( + new StreamableHTTPClientTransport(url, { + requestInit: { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + }, + }), + ); + + mcpClient = client; + return client; + } + + return new Proxy({} as MeshClientInstance, { + get(_target, toolName: string) { + return async (input: unknown) => { + const client = await getClient(); + const result = await client.callTool({ + name: toolName, + arguments: input as Record, + }); + + if (result.isError) { + const message = Array.isArray(result.content) + ? result.content.map((c) => ("text" in c ? c.text : "")).join(" ") + : "Tool call failed"; + throw new Error(message); + } + + return result.structuredContent; + }; + }, + }); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +bun test packages/typegen/src/runtime.test.ts +``` + +Expected: all 5 tests PASS. + +**Step 5: Commit** + +```bash +git add packages/typegen/src/runtime.ts packages/typegen/src/runtime.test.ts +git commit -m "feat(typegen): add createMeshClient runtime with Proxy" +``` + +--- + +## Task 4: Codegen β€” schema-to-TypeScript conversion + +**Files:** +- Create: `packages/typegen/src/codegen.ts` +- Create: `packages/typegen/src/codegen.test.ts` + +**Step 1: Write the failing test** + +Create `packages/typegen/src/codegen.test.ts`: + +```typescript +import { describe, test, expect } from "bun:test"; +import { generateClientCode } from "./codegen.js"; + +describe("generateClientCode", () => { + test("generates Tools interface with input and output types", async () => { + const output = await generateClientCode({ + mcpId: "vmc_abc123", + tools: [ + { + name: "SEARCH", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + limit: { type: "number" }, + }, + required: ["query"], + }, + outputSchema: { + type: "object", + properties: { + results: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["results"], + }, + }, + ], + }); + + // Must export Tools interface + expect(output).toContain("export interface Tools"); + // Must have the tool key + expect(output).toContain("SEARCH:"); + // Must have input/output subkeys + expect(output).toContain("input:"); + expect(output).toContain("output:"); + // Must import createMeshClient + expect(output).toContain('from "@decocms/typegen"'); + // Must call createMeshClient with the mcpId + expect(output).toContain("vmc_abc123"); + expect(output).toContain("createMeshClient"); + }); + + test("uses unknown for missing outputSchema", async () => { + const output = await generateClientCode({ + mcpId: "vmc_test", + tools: [ + { + name: "NO_OUTPUT", + inputSchema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + ], + }); + + expect(output).toContain("output: unknown"); + }); + + test("handles multiple tools", async () => { + const output = await generateClientCode({ + mcpId: "vmc_multi", + tools: [ + { + name: "TOOL_A", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + { + name: "TOOL_B", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + ], + }); + + expect(output).toContain("TOOL_A:"); + expect(output).toContain("TOOL_B:"); + }); + + test("exports a client const", async () => { + const output = await generateClientCode({ + mcpId: "vmc_test", + tools: [], + }); + + expect(output).toContain("export const client ="); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +bun test packages/typegen/src/codegen.test.ts +``` + +Expected: FAIL β€” `./codegen.js` not found. + +**Step 3: Write `packages/typegen/src/codegen.ts`** + +```typescript +import { compile } from "json-schema-to-typescript"; +import { format } from "prettier"; + +export interface ToolDefinition { + name: string; + inputSchema: object; + outputSchema?: object; +} + +export interface GenerateOptions { + mcpId: string; + tools: ToolDefinition[]; +} + +const BANNER = `// This file was auto-generated by @decocms/typegen. Do not edit manually. +// Regenerate with: bunx @decocms/typegen --mcp --key --output +`; + +const PRETTIER_CONFIG = { + parser: "typescript" as const, + printWidth: 80, + singleQuote: false, + trailingComma: "all" as const, + semi: true, +}; + +async function schemaToTs( + schema: object, + typeName: string, +): Promise { + const raw = await compile(schema as never, typeName, { + bannerComment: "", + additionalProperties: false, + }); + // compile() emits `export interface TypeName { ... }` or `export type TypeName = ...` + // We only want the body, so strip the export declaration wrapper + return raw + .replace(/^export\s+(interface|type)\s+\S+\s*(=\s*)?/m, "") + .replace(/;\s*$/, "") + .trim(); +} + +export async function generateClientCode( + opts: GenerateOptions, +): Promise { + const { mcpId, tools } = opts; + + const toolEntries: string[] = []; + + for (const tool of tools) { + const inputType = await schemaToTs(tool.inputSchema, `${tool.name}Input`); + const outputType = tool.outputSchema + ? await schemaToTs(tool.outputSchema, `${tool.name}Output`) + : "unknown"; + + toolEntries.push( + ` ${tool.name}: {\n input: ${inputType};\n output: ${outputType};\n };`, + ); + } + + const toolsInterface = + tools.length === 0 + ? "export interface Tools {}" + : `export interface Tools {\n${toolEntries.join("\n")}\n}`; + + const code = `${BANNER} +import { createMeshClient } from "@decocms/typegen"; + +${toolsInterface} + +export const client = createMeshClient({ + mcpId: "${mcpId}", + apiKey: process.env.MESH_API_KEY, + baseUrl: process.env.MESH_BASE_URL, +}); +`; + + return format(code, PRETTIER_CONFIG); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +bun test packages/typegen/src/codegen.test.ts +``` + +Expected: all 4 tests PASS. + +**Step 5: Commit** + +```bash +git add packages/typegen/src/codegen.ts packages/typegen/src/codegen.test.ts +git commit -m "feat(typegen): add codegen β€” schema to TypeScript client" +``` + +--- + +## Task 5: CLI entry point + +**Files:** +- Create: `packages/typegen/src/cli.ts` + +No unit tests here β€” the CLI is a thin orchestration layer over already-tested modules. Manual smoke test at the end. + +**Step 1: Create `packages/typegen/src/cli.ts`** + +```typescript +#!/usr/bin/env node + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { writeFile } from "node:fs/promises"; +import process from "node:process"; +import { generateClientCode } from "./codegen.js"; + +const DEFAULT_BASE_URL = "https://mesh-admin.decocms.com"; + +function parseArgs(argv: string[]): { + mcpId: string; + apiKey: string | undefined; + baseUrl: string; + output: string; +} { + const args = argv.slice(2); + const get = (flag: string): string | undefined => { + const i = args.indexOf(flag); + return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined; + }; + + const mcpId = get("--mcp"); + if (!mcpId) { + console.error("Error: --mcp is required"); + process.exit(1); + } + + return { + mcpId, + apiKey: get("--key") ?? process.env.MESH_API_KEY, + baseUrl: get("--url") ?? process.env.MESH_BASE_URL ?? DEFAULT_BASE_URL, + output: get("--output") ?? "client.ts", + }; +} + +async function main() { + const { mcpId, apiKey, baseUrl, output } = parseArgs(process.argv); + + console.log(`Connecting to Virtual MCP: ${mcpId}`); + + const url = new URL(`/mcp/virtual-mcp/${mcpId}`, baseUrl); + const client = new Client({ name: "@decocms/typegen", version: "1.0.0" }); + + try { + await client.connect( + new StreamableHTTPClientTransport(url, { + requestInit: { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + }, + }), + ); + } catch (err) { + console.error( + `Error: Failed to connect to ${url}\n${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + const { tools } = await client.listTools(); + + console.log(`Found ${tools.length} tool(s): ${tools.map((t) => t.name).join(", ") || "(none)"}`); + + const code = await generateClientCode({ + mcpId, + tools: tools.map((t) => ({ + name: t.name, + inputSchema: t.inputSchema as object, + outputSchema: (t as { outputSchema?: object }).outputSchema, + })), + }); + + await writeFile(output, code, "utf-8"); + console.log(`Generated: ${output}`); + + await client.close(); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); +``` + +**Step 2: Commit** + +```bash +git add packages/typegen/src/cli.ts +git commit -m "feat(typegen): add CLI entry point" +``` + +--- + +## Task 6: Build and verify + +**Step 1: Run type check** + +```bash +bun run --cwd=packages/typegen check +``` + +Expected: no TypeScript errors. + +**Step 2: Run all typegen tests** + +```bash +bun test packages/typegen/ +``` + +Expected: all tests PASS. + +**Step 3: Build the package** + +```bash +bun run --cwd=packages/typegen build +``` + +Expected: `packages/typegen/dist/` created with `index.js`, `cli.js`, `.d.ts` files, no errors. + +**Step 4: Smoke test the CLI against a local or real MCP (optional)** + +If you have a running Mesh instance and a valid API key + virtual MCP ID: + +```bash +node packages/typegen/dist/cli.js --mcp --key --output /tmp/test-client.ts +cat /tmp/test-client.ts +``` + +Expected: a valid TypeScript file with `export interface Tools { ... }` and `export const client = createMeshClient({ ... })`. + +**Step 5: Run fmt** + +```bash +bun run fmt +``` + +Expected: no changes, or only whitespace diffs that then pass. + +**Step 6: Final commit** + +```bash +git add packages/typegen/ +git commit -m "feat(typegen): build verification and formatting" +``` + +--- + +## Summary of Files Created + +| File | Purpose | +|------|---------| +| `packages/typegen/package.json` | Package manifest, deps, bin | +| `packages/typegen/tsconfig.json` | TS config extending root | +| `packages/typegen/tsup.config.ts` | Build config (ESM, split) | +| `packages/typegen/src/index.ts` | Public types + re-exports | +| `packages/typegen/src/runtime.ts` | `createMeshClient` Proxy factory | +| `packages/typegen/src/runtime.test.ts` | Runtime unit tests | +| `packages/typegen/src/codegen.ts` | Schema β†’ TypeScript generator | +| `packages/typegen/src/codegen.test.ts` | Codegen unit tests | +| `packages/typegen/src/cli.ts` | CLI bin (arg parse + orchestrate) | diff --git a/knip.jsonc b/knip.jsonc index 25df043d7b..19838c67fd 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -25,6 +25,9 @@ }, "packages/mesh-sdk": { "ignoreDependencies": ["sonner"] + }, + "packages/typegen": { + "ignore": ["src/*.test.ts"] } }, "ignoreDependencies": ["@types/*"], diff --git a/packages/typegen/README.md b/packages/typegen/README.md new file mode 100644 index 0000000000..1388860560 --- /dev/null +++ b/packages/typegen/README.md @@ -0,0 +1,76 @@ +# @decocms/typegen + +Generate typed TypeScript clients for [Mesh](https://github.com/decocms/mesh) Virtual MCPs. + +## Usage + +### 1. Generate a client + +Connect to a Virtual MCP and write a typed `client.ts`: + +```bash +bunx @decocms/typegen --mcp --key --output client.ts +``` + +| Flag | Env var | Default | +|------|---------|---------| +| `--mcp` | β€” | **required** | +| `--key` | `MESH_API_KEY` | β€” | +| `--url` | `MESH_BASE_URL` | `https://mesh-admin.decocms.com` | +| `--output` | β€” | `client.ts` | + +### 2. Use the generated client + +The generated `client.ts` looks like this: + +```ts +// client.ts (auto-generated) +import { createMeshClient } from "@decocms/typegen"; + +export interface Tools { + SEARCH: { + input: { query: string; limit?: number }; + output: { results: string[] }; + }; +} + +export const client = createMeshClient({ + mcpId: "vmc_abc123", + apiKey: process.env.MESH_API_KEY, + baseUrl: process.env.MESH_BASE_URL, +}); +``` + +Import and call it: + +```ts +import { client } from "./client.js"; + +const { results } = await client.SEARCH({ query: "hello" }); +``` + +Each method is fully typed β€” inputs and outputs match the tool's schema. + +## Runtime API + +```ts +import { createMeshClient } from "@decocms/typegen"; + +const client = createMeshClient({ + mcpId: "vmc_abc123", // Virtual MCP ID + apiKey: "sk_...", // Falls back to process.env.MESH_API_KEY + baseUrl: "https://...", // Falls back to https://mesh-admin.decocms.com +}); +``` + +- Connects lazily on first call +- Reuses the connection for subsequent calls +- Throws on tool errors with the error message from the server + +## Regenerating + +Re-run the CLI whenever the Virtual MCP's tools change: + +```bash +bunx @decocms/typegen --mcp vmc_abc123 --output client.ts +``` diff --git a/packages/typegen/package.json b/packages/typegen/package.json new file mode 100644 index 0000000000..ad94919974 --- /dev/null +++ b/packages/typegen/package.json @@ -0,0 +1,38 @@ +{ + "name": "@decocms/typegen", + "version": "0.1.0", + "description": "Generate typed TypeScript clients for Mesh Virtual MCPs", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "typegen": "./dist/cli.js" + }, + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "tsup", + "dev": "tsx src/cli.ts", + "check": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.26.0", + "json-schema-to-typescript": "^15.0.4", + "prettier": "^3.6.2" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^24.6.2", + "tsup": "^8.5.0", + "tsx": "^4.7.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/typegen/src/cli.ts b/packages/typegen/src/cli.ts new file mode 100644 index 0000000000..ec6d821d53 --- /dev/null +++ b/packages/typegen/src/cli.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { writeFile } from "node:fs/promises"; +import process from "node:process"; +import { generateClientCode } from "./codegen.js"; + +const DEFAULT_BASE_URL = "https://mesh-admin.decocms.com"; + +function parseArgs(argv: string[]): { + mcpId: string; + apiKey: string | undefined; + baseUrl: string; + output: string; +} { + const args = argv.slice(2); + const get = (flag: string): string | undefined => { + const i = args.indexOf(flag); + return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined; + }; + + const mcpId = get("--mcp"); + if (!mcpId) { + console.error("Error: --mcp is required"); + process.exit(1); + } + + return { + mcpId, + apiKey: get("--key") ?? process.env.MESH_API_KEY, + baseUrl: get("--url") ?? process.env.MESH_BASE_URL ?? DEFAULT_BASE_URL, + output: get("--output") ?? "client.ts", + }; +} + +async function main() { + const { mcpId, apiKey, baseUrl, output } = parseArgs(process.argv); + + console.log(`Connecting to Virtual MCP: ${mcpId}`); + + const url = new URL(`/mcp/virtual-mcp/${mcpId}`, baseUrl); + const client = new Client({ name: "@decocms/typegen", version: "1.0.0" }); + + try { + await client.connect( + new StreamableHTTPClientTransport(url, { + requestInit: { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + }, + }), + ); + } catch (err) { + console.error( + `Error: Failed to connect to ${url}\n${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + const { tools } = await client.listTools(); + + console.log( + `Found ${tools.length} tool(s): ${tools.map((t) => t.name).join(", ") || "(none)"}`, + ); + + const code = await generateClientCode({ + mcpId, + tools: tools.map((t) => ({ + name: t.name, + inputSchema: t.inputSchema as object, + outputSchema: (t as { outputSchema?: object }).outputSchema, + })), + }); + + await writeFile(output, code, "utf-8"); + console.log(`Generated: ${output}`); + + await client.close(); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/packages/typegen/src/codegen.test.ts b/packages/typegen/src/codegen.test.ts new file mode 100644 index 0000000000..92dcea43a2 --- /dev/null +++ b/packages/typegen/src/codegen.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect } from "bun:test"; +import { generateClientCode } from "./codegen.js"; + +describe("generateClientCode", () => { + test("generates Tools interface with input and output types", async () => { + const output = await generateClientCode({ + mcpId: "vmc_abc123", + tools: [ + { + name: "SEARCH", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + limit: { type: "number" }, + }, + required: ["query"], + }, + outputSchema: { + type: "object", + properties: { + results: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["results"], + }, + }, + ], + }); + + // Must export Tools interface + expect(output).toContain("export interface Tools"); + // Must have the tool key + expect(output).toContain("SEARCH:"); + // Must have input/output subkeys + expect(output).toContain("input:"); + expect(output).toContain("output:"); + // Must import createMeshClient + expect(output).toContain('from "@decocms/typegen"'); + // Must call createMeshClient with the mcpId + expect(output).toContain("vmc_abc123"); + expect(output).toContain("createMeshClient"); + }); + + test("uses unknown for missing outputSchema", async () => { + const output = await generateClientCode({ + mcpId: "vmc_test", + tools: [ + { + name: "NO_OUTPUT", + inputSchema: { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + }, + }, + ], + }); + + expect(output).toContain("output: unknown"); + }); + + test("handles multiple tools", async () => { + const output = await generateClientCode({ + mcpId: "vmc_multi", + tools: [ + { + name: "TOOL_A", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + { + name: "TOOL_B", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + ], + }); + + expect(output).toContain("TOOL_A:"); + expect(output).toContain("TOOL_B:"); + }); + + test("exports a client const", async () => { + const output = await generateClientCode({ + mcpId: "vmc_test", + tools: [], + }); + + expect(output).toContain("export const client ="); + }); +}); diff --git a/packages/typegen/src/codegen.ts b/packages/typegen/src/codegen.ts new file mode 100644 index 0000000000..59a87058c0 --- /dev/null +++ b/packages/typegen/src/codegen.ts @@ -0,0 +1,76 @@ +import { compile } from "json-schema-to-typescript"; +import { format } from "prettier"; + +export interface ToolDefinition { + name: string; + inputSchema: object; + outputSchema?: object; +} + +export interface GenerateOptions { + mcpId: string; + tools: ToolDefinition[]; +} + +const BANNER = `// This file was auto-generated by @decocms/typegen. Do not edit manually. +// Regenerate with: bunx @decocms/typegen --mcp --key --output +`; + +const PRETTIER_CONFIG = { + parser: "typescript" as const, + printWidth: 80, + singleQuote: false, + trailingComma: "all" as const, + semi: true, +}; + +async function schemaToTs(schema: object, typeName: string): Promise { + const raw = await compile(schema as never, typeName, { + bannerComment: "", + additionalProperties: false, + }); + // compile() emits `export interface TypeName { ... }` or `export type TypeName = ...` + // We only want the body, so strip the export declaration wrapper + return raw + .replace(/^export\s+(interface|type)\s+\S+\s*(=\s*)?/m, "") + .replace(/;\s*$/, "") + .trim(); +} + +export async function generateClientCode( + opts: GenerateOptions, +): Promise { + const { mcpId, tools } = opts; + + const toolEntries: string[] = []; + + for (const tool of tools) { + const inputType = await schemaToTs(tool.inputSchema, `${tool.name}Input`); + const outputType = tool.outputSchema + ? await schemaToTs(tool.outputSchema, `${tool.name}Output`) + : "unknown"; + + toolEntries.push( + ` ${tool.name}: {\n input: ${inputType};\n output: ${outputType};\n };`, + ); + } + + const toolsInterface = + tools.length === 0 + ? "export interface Tools {}" + : `export interface Tools {\n${toolEntries.join("\n")}\n}`; + + const code = `${BANNER} +import { createMeshClient } from "@decocms/typegen"; + +${toolsInterface} + +export const client = createMeshClient({ + mcpId: "${mcpId}", + apiKey: process.env.MESH_API_KEY, + baseUrl: process.env.MESH_BASE_URL, +}); +`; + + return format(code, PRETTIER_CONFIG); +} diff --git a/packages/typegen/src/index.ts b/packages/typegen/src/index.ts new file mode 100644 index 0000000000..7e6a7827b1 --- /dev/null +++ b/packages/typegen/src/index.ts @@ -0,0 +1,20 @@ +export type ToolMap = Record; + +export type MeshClientInstance = { + [K in keyof T]: (input: T[K]["input"]) => Promise; +}; + +export type MeshClient = MeshClientInstance & { + /** Close the underlying MCP connection and reset it so the next call reconnects. */ + close(): Promise; +}; + +export interface MeshClientOptions { + mcpId: string; + /** Falls back to process.env.MESH_API_KEY */ + apiKey?: string; + /** Falls back to https://mesh-admin.decocms.com */ + baseUrl?: string; +} + +export { createMeshClient } from "./runtime.js"; diff --git a/packages/typegen/src/runtime.test.ts b/packages/typegen/src/runtime.test.ts new file mode 100644 index 0000000000..ebc67c9456 --- /dev/null +++ b/packages/typegen/src/runtime.test.ts @@ -0,0 +1,164 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { createMeshClient } from "./runtime.js"; +import type { MeshClientDeps } from "./runtime.js"; + +// Build mock constructors without touching the module registry +const mockCallTool = mock( + async ({ name, arguments: args }: { name: string; arguments: unknown }) => ({ + isError: false, + structuredContent: { tool: name, args }, + }), +); +const mockConnect = mock(async () => {}); +const mockClose = mock(async () => {}); + +function MockClient() { + return { callTool: mockCallTool, connect: mockConnect, close: mockClose }; +} +function MockTransport() {} + +const deps = { + Client: MockClient, + Transport: MockTransport, +} as unknown as MeshClientDeps; + +describe("createMeshClient", () => { + beforeEach(() => { + mockCallTool.mockClear(); + mockConnect.mockClear(); + mockClose.mockClear(); + }); + + test("returns an object with callable tool methods", async () => { + type Tools = { + MY_TOOL: { input: { id: string }; output: { name: string } }; + }; + + const client = createMeshClient( + { mcpId: "vmc_test", apiKey: "sk_test" }, + deps, + ); + + const result = await client.MY_TOOL({ id: "123" }); + + expect(result).toEqual({ tool: "MY_TOOL", args: { id: "123" } }); + expect(mockCallTool).toHaveBeenCalledWith({ + name: "MY_TOOL", + arguments: { id: "123" }, + }); + }); + + test("lazy-connects on first call", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + + const client = createMeshClient( + { mcpId: "vmc_test", apiKey: "sk" }, + deps, + ); + + expect(mockConnect).not.toHaveBeenCalled(); + await client.TOOL({}); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + test("reuses connection on subsequent calls", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + + const client = createMeshClient( + { mcpId: "vmc_test", apiKey: "sk" }, + deps, + ); + + await client.TOOL({}); + await client.TOOL({}); + await client.TOOL({}); + + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + test("throws on isError response", async () => { + mockCallTool.mockResolvedValueOnce({ + isError: true, + content: [{ text: "Tool failed: bad input" }], + }); + + type Tools = { + FAIL_TOOL: { input: Record; output: unknown }; + }; + const client = createMeshClient( + { mcpId: "vmc_test", apiKey: "sk" }, + deps, + ); + + await expect(client.FAIL_TOOL({})).rejects.toThrow( + "Tool failed: bad input", + ); + }); + + test("close() closes the underlying client and allows reconnect", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + const client = createMeshClient( + { mcpId: "vmc_test", apiKey: "sk" }, + deps, + ); + + await client.TOOL({}); + expect(mockConnect).toHaveBeenCalledTimes(1); + + await client.close(); + expect(mockClose).toHaveBeenCalledTimes(1); + + await client.TOOL({}); + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + + test("builds URL with correct mcpId and baseUrl", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + + const capturedUrls: URL[] = []; + const capturingTransport = function (url: URL) { + capturedUrls.push(url); + }; + + const client = createMeshClient( + { + mcpId: "vmc_abc123", + apiKey: "sk_key", + baseUrl: "https://custom.example.com", + }, + { + ...deps, + Transport: capturingTransport as unknown as MeshClientDeps["Transport"], + }, + ); + + await client.TOOL({}); + + expect(capturedUrls[0]?.toString()).toBe( + "https://custom.example.com/mcp/virtual-mcp/vmc_abc123", + ); + }); + + test("defaults baseUrl to https://mesh-admin.decocms.com", async () => { + type Tools = { TOOL: { input: Record; output: unknown } }; + + const capturedUrls: URL[] = []; + const capturingTransport = function (url: URL) { + capturedUrls.push(url); + }; + + const client = createMeshClient( + { mcpId: "vmc_abc", apiKey: "sk" }, + { + ...deps, + Transport: capturingTransport as unknown as MeshClientDeps["Transport"], + }, + ); + + await client.TOOL({}); + + expect(capturedUrls[0]?.toString()).toBe( + "https://mesh-admin.decocms.com/mcp/virtual-mcp/vmc_abc", + ); + }); +}); diff --git a/packages/typegen/src/runtime.ts b/packages/typegen/src/runtime.ts new file mode 100644 index 0000000000..999edb13d7 --- /dev/null +++ b/packages/typegen/src/runtime.ts @@ -0,0 +1,83 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { MeshClient, MeshClientOptions, ToolMap } from "./index.js"; + +const DEFAULT_BASE_URL = "https://mesh-admin.decocms.com"; + +/** @internal - overrideable constructors for testing */ +export interface MeshClientDeps { + Client: typeof Client; + Transport: typeof StreamableHTTPClientTransport; +} + +export function createMeshClient( + opts: MeshClientOptions, + /** @internal */ _deps?: Partial, +): MeshClient { + const ClientCtor = _deps?.Client ?? Client; + const TransportCtor = _deps?.Transport ?? StreamableHTTPClientTransport; + + // Shared promise prevents concurrent calls from creating multiple connections + let connectPromise: Promise | null = null; + + function getClient(): Promise { + if (connectPromise) return connectPromise; + + connectPromise = (async () => { + const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + const apiKey = opts.apiKey ?? process.env.MESH_API_KEY; + // Build URL with string concat so a path-prefixed baseUrl is preserved, + // and encode mcpId to guard against special characters in the ID. + const url = new URL( + `${base}/mcp/virtual-mcp/${encodeURIComponent(opts.mcpId)}`, + ); + + const client = new ClientCtor({ + name: "@decocms/typegen", + version: "1.0.0", + }); + await client.connect( + new TransportCtor(url, { + requestInit: { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + }, + }), + ); + + return client; + })(); + + return connectPromise; + } + + return new Proxy({} as MeshClient, { + get(_target, toolName: string) { + if (toolName === "close") { + return async () => { + if (connectPromise) { + const client = await connectPromise; + await client.close(); + connectPromise = null; + } + }; + } + + return async (input: unknown) => { + const client = await getClient(); + const result = await client.callTool({ + name: toolName, + arguments: input as Record, + }); + + if (result.isError) { + const message = Array.isArray(result.content) + ? result.content.map((c) => ("text" in c ? c.text : "")).join(" ") + : "Tool call failed"; + throw new Error(message); + } + + return result.structuredContent; + }; + }, + }); +} diff --git a/packages/typegen/tsconfig.json b/packages/typegen/tsconfig.json new file mode 100644 index 0000000000..6b4b3bbd89 --- /dev/null +++ b/packages/typegen/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "allowImportingTsExtensions": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/typegen/tsup.config.ts b/packages/typegen/tsup.config.ts new file mode 100644 index 0000000000..12544e8f3b --- /dev/null +++ b/packages/typegen/tsup.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, type Options } from "tsup"; + +const config: Options = { + entry: { + index: "src/index.ts", + cli: "src/cli.ts", + }, + format: ["esm"], + target: "es2022", + bundle: true, + sourcemap: true, + clean: true, + dts: true, + splitting: true, + treeshake: true, + shims: true, + external: [ + "node:*", + "@modelcontextprotocol/sdk", + "json-schema-to-typescript", + "prettier", + ], +}; + +export default defineConfig(config);