From d38aef8155dff5aac759f189292de9ce2c39f3fc Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:19:36 -0300
Subject: [PATCH 01/25] docs: add typegen CLI design doc
---
docs/plans/2026-02-25-typegen-cli-design.md | 132 ++++++++++++++++++++
1 file changed, 132 insertions(+)
create mode 100644 docs/plans/2026-02-25-typegen-cli-design.md
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)
From 95ab1f52e68cc285f57746a6ff9757ae7117d9f0 Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:24:37 -0300
Subject: [PATCH 02/25] docs: add typegen implementation plan
---
.../2026-02-25-typegen-implementation.md | 774 ++++++++++++++++++
1 file changed, 774 insertions(+)
create mode 100644 docs/plans/2026-02-25-typegen-implementation.md
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) |
From e05cfbd9c3b352ec5a1736ba73356c01ccb59286 Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:29:45 -0300
Subject: [PATCH 03/25] feat(typegen): scaffold package structure
---
packages/typegen/package.json | 31 +++++++++++++++++++++++++++++++
packages/typegen/tsconfig.json | 14 ++++++++++++++
packages/typegen/tsup.config.ts | 25 +++++++++++++++++++++++++
3 files changed, 70 insertions(+)
create mode 100644 packages/typegen/package.json
create mode 100644 packages/typegen/tsconfig.json
create mode 100644 packages/typegen/tsup.config.ts
diff --git a/packages/typegen/package.json b/packages/typegen/package.json
new file mode 100644
index 0000000000..27fbddf7c0
--- /dev/null
+++ b/packages/typegen/package.json
@@ -0,0 +1,31 @@
+{
+ "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" }
+}
diff --git a/packages/typegen/tsconfig.json b/packages/typegen/tsconfig.json
new file mode 100644
index 0000000000..2cd1674632
--- /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"]
+}
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);
From 610e65afec7683c107a7034ed46a13dc8f035061 Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:29:57 -0300
Subject: [PATCH 04/25] feat(typegen): add public types
---
packages/typegen/src/index.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 packages/typegen/src/index.ts
diff --git a/packages/typegen/src/index.ts b/packages/typegen/src/index.ts
new file mode 100644
index 0000000000..cdadc1b0bf
--- /dev/null
+++ b/packages/typegen/src/index.ts
@@ -0,0 +1,15 @@
+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";
From 5c4caaf437e9145f991e40b100ed2a7b9af1f2ac Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:30:37 -0300
Subject: [PATCH 05/25] feat(typegen): add createMeshClient runtime with Proxy
---
packages/typegen/src/runtime.test.ts | 136 +++++++++++++++++++++++++++
packages/typegen/src/runtime.ts | 52 ++++++++++
2 files changed, 188 insertions(+)
create mode 100644 packages/typegen/src/runtime.test.ts
create mode 100644 packages/typegen/src/runtime.ts
diff --git a/packages/typegen/src/runtime.test.ts b/packages/typegen/src/runtime.test.ts
new file mode 100644
index 0000000000..2d98c9165b
--- /dev/null
+++ b/packages/typegen/src/runtime.test.ts
@@ -0,0 +1,136 @@
+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",
+ );
+ });
+});
diff --git a/packages/typegen/src/runtime.ts b/packages/typegen/src/runtime.ts
new file mode 100644
index 0000000000..0e0a019209
--- /dev/null
+++ b/packages/typegen/src/runtime.ts
@@ -0,0 +1,52 @@
+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;
+ };
+ },
+ });
+}
From 5a8e632f486aad41facac40a67aedf551c5dcfbe Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:31:55 -0300
Subject: [PATCH 06/25] =?UTF-8?q?feat(typegen):=20add=20codegen=20?=
=?UTF-8?q?=E2=80=94=20schema=20to=20TypeScript=20client?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/typegen/src/codegen.test.ts | 92 ++++++++++++++++++++++++++++
packages/typegen/src/codegen.ts | 76 +++++++++++++++++++++++
2 files changed, 168 insertions(+)
create mode 100644 packages/typegen/src/codegen.test.ts
create mode 100644 packages/typegen/src/codegen.ts
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);
+}
From b0ec0b1413fce846c0e35d4f1dbcbde3401b57bf Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:32:12 -0300
Subject: [PATCH 07/25] feat(typegen): add CLI entry point
---
packages/typegen/src/cli.ts | 84 +++++++++++++++++++++++++++++++++++++
1 file changed, 84 insertions(+)
create mode 100644 packages/typegen/src/cli.ts
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);
+});
From 22d77e4e4dc129f7f9e2c398c37312cf692a6254 Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:33:25 -0300
Subject: [PATCH 08/25] feat(typegen): build verification and formatting
---
packages/typegen/package.json | 13 ++++++++++---
packages/typegen/src/runtime.test.ts | 12 ++++--------
packages/typegen/src/runtime.ts | 6 +++++-
packages/typegen/tsconfig.json | 2 +-
4 files changed, 20 insertions(+), 13 deletions(-)
diff --git a/packages/typegen/package.json b/packages/typegen/package.json
index 27fbddf7c0..ad94919974 100644
--- a/packages/typegen/package.json
+++ b/packages/typegen/package.json
@@ -9,7 +9,9 @@
"bin": {
"typegen": "./dist/cli.js"
},
- "files": ["dist/**/*"],
+ "files": [
+ "dist/**/*"
+ ],
"scripts": {
"build": "tsup",
"dev": "tsx src/cli.ts",
@@ -21,11 +23,16 @@
"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" }
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
}
diff --git a/packages/typegen/src/runtime.test.ts b/packages/typegen/src/runtime.test.ts
index 2d98c9165b..c37a0a41f0 100644
--- a/packages/typegen/src/runtime.test.ts
+++ b/packages/typegen/src/runtime.test.ts
@@ -2,13 +2,7 @@ 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;
- }) => ({
+ async ({ name, arguments: args }: { name: string; arguments: unknown }) => ({
isError: false,
structuredContent: { tool: name, args },
}),
@@ -95,7 +89,9 @@ describe("createMeshClient", () => {
};
const client = createMeshClient({ mcpId: "vmc_test", apiKey: "sk" });
- await expect(client.FAIL_TOOL({})).rejects.toThrow("Tool failed: bad input");
+ await expect(client.FAIL_TOOL({})).rejects.toThrow(
+ "Tool failed: bad input",
+ );
});
test("builds URL with correct mcpId and baseUrl", async () => {
diff --git a/packages/typegen/src/runtime.ts b/packages/typegen/src/runtime.ts
index 0e0a019209..93e33d2932 100644
--- a/packages/typegen/src/runtime.ts
+++ b/packages/typegen/src/runtime.ts
@@ -1,6 +1,10 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
-import type { MeshClientInstance, MeshClientOptions, ToolMap } from "./index.js";
+import type {
+ MeshClientInstance,
+ MeshClientOptions,
+ ToolMap,
+} from "./index.js";
const DEFAULT_BASE_URL = "https://mesh-admin.decocms.com";
diff --git a/packages/typegen/tsconfig.json b/packages/typegen/tsconfig.json
index 2cd1674632..6b4b3bbd89 100644
--- a/packages/typegen/tsconfig.json
+++ b/packages/typegen/tsconfig.json
@@ -10,5 +10,5 @@
"sourceMap": true
},
"include": ["src/**/*"],
- "exclude": ["node_modules", "dist"]
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
From d6190f6d75192ce4afb41c3d1a08d9a6624fd3cd Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:38:38 -0300
Subject: [PATCH 09/25] docs(typegen): add README
---
packages/typegen/README.md | 76 ++++++++++++++++++++++++++++++++++++++
1 file changed, 76 insertions(+)
create mode 100644 packages/typegen/README.md
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
+```
From 16a04aaa860c330280086c5e7c99fe2254750cfb Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:51:21 -0300
Subject: [PATCH 10/25] feat(typegen): add Generate typed client section to
Share Agent dialog
---
.../virtual-mcp/virtual-mcp-share-modal.tsx | 130 +++++++++++++++++-
1 file changed, 129 insertions(+), 1 deletion(-)
diff --git a/apps/mesh/src/web/components/details/virtual-mcp/virtual-mcp-share-modal.tsx b/apps/mesh/src/web/components/details/virtual-mcp/virtual-mcp-share-modal.tsx
index 3066f31a65..1a755aa9ba 100644
--- a/apps/mesh/src/web/components/details/virtual-mcp/virtual-mcp-share-modal.tsx
+++ b/apps/mesh/src/web/components/details/virtual-mcp/virtual-mcp-share-modal.tsx
@@ -17,6 +17,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@deco/ui/components/tooltip.tsx";
+import {
+ SELF_MCP_ALIAS_ID,
+ useMCPClient,
+ useProjectContext,
+} from "@decocms/mesh-sdk";
import type { VirtualMCPEntity } from "@decocms/mesh-sdk";
import {
ArrowsRight,
@@ -24,9 +29,11 @@ import {
Code01,
Copy01,
InfoCircle,
+ Key01,
Lightbulb02,
+ Loading01,
} from "@untitledui/icons";
-import { useState } from "react";
+import { Suspense, useState } from "react";
import { toast } from "sonner";
/**
@@ -178,6 +185,122 @@ function InstallClaudeButton({ url, serverName }: ShareWithNameProps) {
);
}
+/**
+ * Typegen section inner — uses Suspense-based useMCPClient
+ */
+function TypegenSectionInner({ virtualMcp }: { virtualMcp: VirtualMCPEntity }) {
+ const { org } = useProjectContext();
+ const client = useMCPClient({
+ connectionId: SELF_MCP_ALIAS_ID,
+ orgId: org.id,
+ });
+ const [apiKey, setApiKey] = useState(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}`
+ : `bunx @decocms/typegen@latest --mcp ${mcpId} --key `;
+
+ 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.
+
+ )}
+
+
+
+ {command}
+
+
+
+
+ );
+}
+
+function TypegenSection({ virtualMcp }: { virtualMcp: VirtualMCPEntity }) {
+ return (
+ }
+ >
+
+
+ );
+}
+
/**
* Share Modal - Virtual MCP sharing and IDE integration
*/
@@ -363,6 +486,11 @@ export function VirtualMCPShareModal({
/>
+
+
+
+ {/* Typegen */}
+
From 16aefad7dde916d98aa3eb7fdc993fa0393f1461 Mon Sep 17 00:00:00 2001
From: viktormarinho
Date: Wed, 25 Feb 2026 15:55:53 -0300
Subject: [PATCH 11/25] fix(typegen): widen Share Agent dialog to max-w-3xl
---
.../components/details/virtual-mcp/virtual-mcp-share-modal.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/mesh/src/web/components/details/virtual-mcp/virtual-mcp-share-modal.tsx b/apps/mesh/src/web/components/details/virtual-mcp/virtual-mcp-share-modal.tsx
index 1a755aa9ba..d50c875e68 100644
--- a/apps/mesh/src/web/components/details/virtual-mcp/virtual-mcp-share-modal.tsx
+++ b/apps/mesh/src/web/components/details/virtual-mcp/virtual-mcp-share-modal.tsx
@@ -341,7 +341,7 @@ export function VirtualMCPShareModal({
return (
)}
-
-
+
+
{command}