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 ( - + Share Agent From 8afaed04b6fa72bf5b48943a68e18001cb810e1d Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 15:56:41 -0300 Subject: [PATCH 12/25] fix(typegen): prevent long command string from blowing out dialog width --- .../details/virtual-mcp/virtual-mcp-share-modal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 d50c875e68..5e0f58f9e8 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 @@ -269,8 +269,8 @@ function TypegenSectionInner({ virtualMcp }: { virtualMcp: VirtualMCPEntity }) {

)} -
- +
+ {command} +
+
+ + {command} + + +
); From 93b29a7c492f973a656eb1a4eef8ff4725a65cf4 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:01:56 -0300 Subject: [PATCH 16/25] feat(typegen): add env vars code block to Share Agent dialog --- .../virtual-mcp/virtual-mcp-share-modal.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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 a47709a3c4..29bee5ef21 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 @@ -289,6 +289,47 @@ function TypegenSectionInner({ virtualMcp }: { virtualMcp: VirtualMCPEntity }) {
+ + + + ); +} + +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} +
+ +
); } From 46fdc54af058b7e2b7d870fa8a14a02299c9d89b Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:02:40 -0300 Subject: [PATCH 17/25] feat(typegen): add section titles and --output client.ts to command --- .../details/virtual-mcp/virtual-mcp-share-modal.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 29bee5ef21..b4cea17933 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 @@ -201,8 +201,8 @@ function TypegenSectionInner({ virtualMcp }: { virtualMcp: VirtualMCPEntity }) { 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 `; + ? `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); @@ -269,6 +269,9 @@ function TypegenSectionInner({ virtualMcp }: { virtualMcp: VirtualMCPEntity }) {

)} +

+ Generate client +

@@ -290,6 +293,9 @@ function TypegenSectionInner({ virtualMcp }: { virtualMcp: VirtualMCPEntity }) {
+

+ Runtime variables +

); From 8078e18e2c6a08951a9b8c83c3b87123eb9342d7 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:05:33 -0300 Subject: [PATCH 18/25] feat(typegen): rename Share to Connect button with Link01 icon --- .../components/details/virtual-mcp/index.tsx | 31 +++++++------------ .../virtual-mcp/virtual-mcp-share-modal.tsx | 2 +- 2 files changed, 13 insertions(+), 20 deletions(-) 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..43a77a4c90 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, + Link01, Tool01, Users03, } from "@untitledui/icons"; @@ -308,24 +308,17 @@ function VirtualMcpDetailViewWithData({ Test this agent in chat - - - - - - - Share - + - Share Agent + Connect
{/* Mode Selection */} From c377e80c615960d084ed596334e900090d1bae6f Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:09:53 -0300 Subject: [PATCH 19/25] feat(typegen): swap Connect button icon to ZapCircle --- apps/mesh/src/web/components/details/virtual-mcp/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 43a77a4c90..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, - Link01, + ZapCircle, Tool01, Users03, } from "@untitledui/icons"; @@ -316,7 +316,7 @@ function VirtualMcpDetailViewWithData({ dispatch({ type: "SET_SHARE_DIALOG_OPEN", payload: true }) } > - + Connect From 7384ca99a54e8ebe75b259cabd5a345f7639c883 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:15:33 -0300 Subject: [PATCH 20/25] fix(typegen): fix race condition, URL construction, injection risk in runtime; fix cn lint error --- .../virtual-mcp/virtual-mcp-share-modal.tsx | 3 +- packages/typegen/src/runtime.ts | 48 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) 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 3417b3d5f7..3f97293d36 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 @@ -33,6 +33,7 @@ import { Lightbulb02, Loading01, } from "@untitledui/icons"; +import { cn } from "@deco/ui/lib/utils.ts"; import { Suspense, useState } from "react"; import { toast } from "sonner"; @@ -318,7 +319,7 @@ function EnvVarsBlock({ apiKey }: { apiKey: string | null }) {
- {keyLine} + {keyLine}
{urlLine}
diff --git a/packages/typegen/src/runtime.ts b/packages/typegen/src/runtime.ts index 93e33d2932..2c9709bf9a 100644 --- a/packages/typegen/src/runtime.ts +++ b/packages/typegen/src/runtime.ts @@ -11,26 +11,34 @@ 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; + // 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 Client({ name: "@decocms/typegen", version: "1.0.0" }); + await client.connect( + new StreamableHTTPClientTransport(url, { + requestInit: { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + }, + }), + ); + + return client; + })(); + + return connectPromise; } return new Proxy({} as MeshClientInstance, { From c5a9bf794752f4f2dcfc6ca8a0e0a3d40cb97ddc Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:17:09 -0300 Subject: [PATCH 21/25] ci(typegen): add npm publish workflow --- .github/workflows/publish-typegen-npm.yaml | 87 ++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/publish-typegen-npm.yaml 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 + \`\`\`" From 6cd8e01c2555da0f92edc1ee50a78fcde3b1c2ce Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:20:08 -0300 Subject: [PATCH 22/25] fix(typegen): expose close() on MeshClient to prevent connection leaks --- packages/typegen/src/index.ts | 5 +++++ packages/typegen/src/runtime.test.ts | 19 ++++++++++++++++++- packages/typegen/src/runtime.ts | 20 +++++++++++++------- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/typegen/src/index.ts b/packages/typegen/src/index.ts index cdadc1b0bf..7e6a7827b1 100644 --- a/packages/typegen/src/index.ts +++ b/packages/typegen/src/index.ts @@ -4,6 +4,11 @@ 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 */ diff --git a/packages/typegen/src/runtime.test.ts b/packages/typegen/src/runtime.test.ts index c37a0a41f0..076d51f4f8 100644 --- a/packages/typegen/src/runtime.test.ts +++ b/packages/typegen/src/runtime.test.ts @@ -9,9 +9,10 @@ const mockCallTool = mock( ); const mockConnect = mock(async () => {}); +const mockClose = mock(async () => {}); const MockClient = mock(function () { - return { callTool: mockCallTool, connect: mockConnect }; + return { callTool: mockCallTool, connect: mockConnect, close: mockClose }; }); const MockTransport = mock(function () {}); @@ -31,6 +32,7 @@ describe("createMeshClient", () => { beforeEach(() => { mockCallTool.mockClear(); mockConnect.mockClear(); + mockClose.mockClear(); MockClient.mockClear(); MockTransport.mockClear(); }); @@ -118,6 +120,21 @@ describe("createMeshClient", () => { ); }); + 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" }); + + await client.TOOL({}); + expect(mockConnect).toHaveBeenCalledTimes(1); + + await client.close(); + expect(mockClose).toHaveBeenCalledTimes(1); + + // After close, next call should reconnect + await client.TOOL({}); + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + 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" }); diff --git a/packages/typegen/src/runtime.ts b/packages/typegen/src/runtime.ts index 2c9709bf9a..9c1ae1c94c 100644 --- a/packages/typegen/src/runtime.ts +++ b/packages/typegen/src/runtime.ts @@ -1,16 +1,12 @@ 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 { MeshClient, MeshClientOptions, ToolMap } from "./index.js"; const DEFAULT_BASE_URL = "https://mesh-admin.decocms.com"; export function createMeshClient( opts: MeshClientOptions, -): MeshClientInstance { +): MeshClient { // Shared promise prevents concurrent calls from creating multiple connections let connectPromise: Promise | null = null; @@ -41,8 +37,18 @@ export function createMeshClient( return connectPromise; } - return new Proxy({} as MeshClientInstance, { + 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({ From 8e8531d39b2244397a84e733292ddef29f0fe386 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:27:46 -0300 Subject: [PATCH 23/25] fix(typegen): add knip ignore for test files --- knip.jsonc | 3 +++ 1 file changed, 3 insertions(+) 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/*"], From aeb43e18d8cec00200ee574978b2125716405691 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:33:33 -0300 Subject: [PATCH 24/25] fix(typegen): restore module mocks in afterAll to prevent leak into other test files --- packages/typegen/src/runtime.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/typegen/src/runtime.test.ts b/packages/typegen/src/runtime.test.ts index 076d51f4f8..ca1af6c65b 100644 --- a/packages/typegen/src/runtime.test.ts +++ b/packages/typegen/src/runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"; // Mock the MCP SDK before importing runtime const mockCallTool = mock( @@ -29,6 +29,10 @@ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ const { createMeshClient } = await import("./runtime.js"); describe("createMeshClient", () => { + afterAll(() => { + mock.restore(); + }); + beforeEach(() => { mockCallTool.mockClear(); mockConnect.mockClear(); From d2e13694b802004f21c99108531dd61e8e258adf Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Wed, 25 Feb 2026 16:38:59 -0300 Subject: [PATCH 25/25] fix(typegen): replace mock.module with dependency injection to prevent SDK mock from leaking into CI test suite --- packages/typegen/src/runtime.test.ts | 129 +++++++++++++++------------ packages/typegen/src/runtime.ts | 17 +++- 2 files changed, 85 insertions(+), 61 deletions(-) diff --git a/packages/typegen/src/runtime.test.ts b/packages/typegen/src/runtime.test.ts index ca1af6c65b..ebc67c9456 100644 --- a/packages/typegen/src/runtime.test.ts +++ b/packages/typegen/src/runtime.test.ts @@ -1,44 +1,32 @@ -import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"; +import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { createMeshClient } from "./runtime.js"; +import type { MeshClientDeps } from "./runtime.js"; -// Mock the MCP SDK before importing runtime +// 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 () => {}); -const MockClient = mock(function () { +function MockClient() { return { callTool: mockCallTool, connect: mockConnect, close: mockClose }; -}); - -const MockTransport = mock(function () {}); +} +function MockTransport() {} -mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ +const deps = { Client: MockClient, -})); - -mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: MockTransport, -})); - -// Import AFTER mocking -const { createMeshClient } = await import("./runtime.js"); + Transport: MockTransport, +} as unknown as MeshClientDeps; describe("createMeshClient", () => { - afterAll(() => { - mock.restore(); - }); - beforeEach(() => { mockCallTool.mockClear(); mockConnect.mockClear(); mockClose.mockClear(); - MockClient.mockClear(); - MockTransport.mockClear(); }); test("returns an object with callable tool methods", async () => { @@ -46,10 +34,10 @@ describe("createMeshClient", () => { MY_TOOL: { input: { id: string }; output: { name: string } }; }; - const client = createMeshClient({ - mcpId: "vmc_test", - apiKey: "sk_test", - }); + const client = createMeshClient( + { mcpId: "vmc_test", apiKey: "sk_test" }, + deps, + ); const result = await client.MY_TOOL({ id: "123" }); @@ -63,19 +51,23 @@ describe("createMeshClient", () => { test("lazy-connects on first call", async () => { type Tools = { TOOL: { input: Record; output: unknown } }; - const client = createMeshClient({ mcpId: "vmc_test", apiKey: "sk" }); + 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" }); + const client = createMeshClient( + { mcpId: "vmc_test", apiKey: "sk" }, + deps, + ); await client.TOOL({}); await client.TOOL({}); @@ -93,60 +85,79 @@ describe("createMeshClient", () => { type Tools = { FAIL_TOOL: { input: Record; output: unknown }; }; - const client = createMeshClient({ mcpId: "vmc_test", apiKey: "sk" }); + const client = createMeshClient( + { mcpId: "vmc_test", apiKey: "sk" }, + deps, + ); await expect(client.FAIL_TOOL({})).rejects.toThrow( "Tool failed: bad input", ); }); - test("builds URL with correct mcpId and baseUrl", async () => { + 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, + ); - createMeshClient({ - mcpId: "vmc_abc123", - apiKey: "sk_key", - baseUrl: "https://custom.example.com", - }); + await client.TOOL({}); + expect(mockConnect).toHaveBeenCalledTimes(1); - // 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.close(); + expect(mockClose).toHaveBeenCalledTimes(1); 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", - ); + expect(mockConnect).toHaveBeenCalledTimes(2); }); - test("close() closes the underlying client and allows reconnect", async () => { + test("builds URL with correct mcpId and baseUrl", async () => { type Tools = { TOOL: { input: Record; output: unknown } }; - const client = createMeshClient({ mcpId: "vmc_test", apiKey: "sk" }); - await client.TOOL({}); - expect(mockConnect).toHaveBeenCalledTimes(1); + const capturedUrls: URL[] = []; + const capturingTransport = function (url: URL) { + capturedUrls.push(url); + }; - await client.close(); - expect(mockClose).toHaveBeenCalledTimes(1); + const client = createMeshClient( + { + mcpId: "vmc_abc123", + apiKey: "sk_key", + baseUrl: "https://custom.example.com", + }, + { + ...deps, + Transport: capturingTransport as unknown as MeshClientDeps["Transport"], + }, + ); - // After close, next call should reconnect await client.TOOL({}); - expect(mockConnect).toHaveBeenCalledTimes(2); + + 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 client = createMeshClient({ mcpId: "vmc_abc", apiKey: "sk" }); + + 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({}); - const transportArg = MockTransport.mock.calls[0][0] as URL; - expect(transportArg.toString()).toBe( + 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 index 9c1ae1c94c..999edb13d7 100644 --- a/packages/typegen/src/runtime.ts +++ b/packages/typegen/src/runtime.ts @@ -4,9 +4,19 @@ 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; @@ -22,9 +32,12 @@ export function createMeshClient( `${base}/mcp/virtual-mcp/${encodeURIComponent(opts.mcpId)}`, ); - const client = new Client({ name: "@decocms/typegen", version: "1.0.0" }); + const client = new ClientCtor({ + name: "@decocms/typegen", + version: "1.0.0", + }); await client.connect( - new StreamableHTTPClientTransport(url, { + new TransportCtor(url, { requestInit: { headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, },