Add emit-ts command with types and client modes

This commit is contained in:
Peter Steinberger 2025-11-07 00:53:11 +00:00
parent f36be7254e
commit b60816c11c
7 changed files with 556 additions and 99 deletions

View File

@ -47,13 +47,14 @@ The goals below align `mcporter list`, the TypeScript CLI generator, and any fut
3. Added assertions in `tests/list-detail-helpers.test.ts` for the new metadata, keeping the abstraction covered.
- **Next**: With the model centralised, future surfaces (e.g., `--emit-ts`) can render signatures/examples/options straight from `buildToolDoc`.
## 6. Future TS/DTS Export Mode
## 6. `emit-ts` Export Mode *(Completed)*
- **Goal**: With the shared doc model in place, add `mcporter list <server> --emit-ts <file>` that writes a proxy interface identical to the generated CLI signatures.
- **Steps**:
1. Reuse `buildToolDoc()` to emit `interface ServerNameTools { … }` plus optional helper functions.
2. Add docs describing the flag under `docs/call-syntax.md` or a new `docs/ts-export.md`.
3. Integration test: run the command against a fixture server and assert the emitted file matches the snapshot.
- **Goal**: Provide a typed contract (and optional client) for each MCP server so agents/tools no longer scrape CLI output.
- **What we did**:
1. Added `mcporter emit-ts <server>` with `--mode types|client`, auto-overwriting targets and deriving `.d.ts` names for client mode.
2. Reused `buildToolDoc` + new templates to emit interfaces (promisified signatures + doc comments) and executable wrappers that return `CallResult` objects.
3. Added `tests/emit-ts.test.ts` to snapshot the templates and run the command end-to-end with a stub runtime; documented the workflow in `docs/emit-ts.md`.
- **Next**: Consider supporting per-tool filters and shared schema maps inside the generated client for faster cold starts.
---

View File

@ -1,110 +1,90 @@
# `mcporter emit-ts` Plan
# `mcporter emit-ts`
## Why
Our "agents call TypeScript via our proxy" mode and external integrators both need a
stable, IDE-friendly description of each MCP server. Today they either scrape
`mcporter list` output or parse JSON schema on the fly, which is brittle and
impossible to type-check. An `emit-ts` command gives us a single, reproducible
artifact (Think `.ts`/`.d.ts`) that mirrors the pseudo-TypeScript we already
print, so:
- Agents get autocompletion + type safety when composing calls.
- We can run `tsc` against agent-generated snippets before invoking remote tools.
- The exported contract doubles as documentation and feeds future generators.
## CLI Surface
`mcporter emit-ts` turns a configured MCP server into TypeScript artifacts so
agents, tests, and tooling can consume the server through strongly typed APIs.
It reuses the same `buildToolDoc()` data that powers `mcporter list`, so doc
comments, parameter hints, and signatures stay perfectly in sync.
```
mcporter emit-ts <server> --out linear-client.ts [--mode types|client] [--include-optional]
mcporter emit-ts <server> --out linear-client.ts \
[--mode types|client] \
[--include-optional]
```
- Default `--mode types`: emits TypeScript declarations only.
- `--mode client`: emits executable wrappers that internally use `createServerProxy` and return `CallResult` objects.
- `--include-optional`: mirror `mcporter list --all-parameters` to include every
parameter in the signature.
- Outputs overwrite existing files automatically (no `--force` needed).
- `--mode types` (default) emits a `.d.ts` interface (`LinearTools`) with
docblocks + promisified signatures. Missing output schemas fall back to
`CallResult`.
- `--mode client` emits both the interface (auto-derived `.d.ts`) **and** an
executable `.ts` helper that wraps `createServerProxy`. Each method returns a
`CallResult`, and the factory exposes a `close()` helper for runtimes the client
creates.
- `--include-optional` mirrors `mcporter list --all-parameters`, ensuring every
parameter is shown even when optional.
## Output Modes
Outputs overwrite existing files automatically so you can regenerate artifacts
whenever the server schema changes.
### 1. Types (default)
## Examples
- File layout:
- Header comment with generator metadata + source definition.
- `export interface <ServerName>Tools { ... }` each method matches
`ToolDocModel.tsSignature` minus the leading `function` keyword.
- Optional type aliases for inferred return types (when schemas expose titles);
otherwise return type defaults to `CallResult` (wrapping the raw response).
- Doc comments pulled verbatim from `doc.docLines`.
- Inline hints (optional summary / flag usage) emitted as `//` comments.
- Emits a `.d.ts` file by default. When `--mode client` targets `foo.ts`, the
interface file becomes `foo.d.ts` unless `--types-out` overrides it.
### 1. Types-only header
### 2. Client wrappers (`--mode client`)
```
mcporter emit-ts linear --out types/linear-tools.d.ts
```
- Emits the interface (inline or via the `.d.ts`) plus a factory that returns an
object whose methods forward to `createServerProxy`. The objects lifetime is
the callers responsibility; they pass an existing runtime or the factory
creates/closes one if omitted.
- Example stub:
```ts
import { createRuntime, createServerProxy, createCallResult } from 'mcporter';
import type { LinearTools } from './linear-client.d.ts';
Produces:
export async function createLinearClient(options?: CreateRuntimeOptions) {
const runtime = options?.runtime ?? (await createRuntime(options));
const proxy = createServerProxy(runtime, 'linear');
return {
async list_comments(params: Parameters<LinearTools['list_comments']>[0]) {
const raw = await proxy.list_comments(params);
return createCallResult(raw);
},
// …
} satisfies LinearTools;
}
```
- Because return schemas are often missing, wrappers always resolve to
`CallResult`, giving callers a consistent API regardless of server metadata.
```ts
import type { CallResult } from 'mcporter';
## Implementation Steps
export interface LinearTools {
/**
* List comments for a specific Linear issue.
*
* @param issueId The issue ID
*/
list_comments(params: { issueId: string }): Promise<CallResult>;
}
```
1. **Command wiring**
- Add `emit-ts` subcommand (preferred over `--emit-ts` flag) with
options: `--server`, `--out`, `--mode`, `--include-optional`, `--types-out`.
- Default `--mode types`, derive `.d.ts` path from `--out` when needed.
Include the file in your agent/project and you can type-check code like
`const result = await proxy.list_comments({ issueId: '...' });`.
2. **Doc model reuse**
- Fetch tools with `includeSchema: true`, map through `buildToolDoc`
(respecting `requiredOnly` vs `--include-optional`).
- Collect metadata (server name, source path, transport) for header comments.
### 2. Client wrappers
3. **Templates**
- Types template consumes `ToolDocModel` array to emit doc comments + method
signatures (no runtime imports). Unknown schemas → `CallResult` return type.
- Client template imports the interface (from `.d.ts`), emits factory + helper
wrappers that call `createServerProxy` and wrap results with `createCallResult`.
```
mcporter emit-ts linear --mode client --out clients/linear.ts
```
4. **Filesystem**
- Write outputs atomically (tmp file + rename) and overwrite existing files.
- When `--mode client`, emit both `--out` (client) and derived `.d.ts` unless
the user supplies `--types-out`.
- Optionally record generator metadata (similar to CLI artifacts) for future
inspection.
Generates two files:
5. **Testing**
- Add `tests/emit-ts.test.ts` that runs the command against the integration
server. Assertions:
* Types mode: snapshot `.d.ts`, run `tsc --noEmit` to ensure validity.
* Client mode: snapshot `.ts`, run `ts-node` with a mocked runtime to
ensure wrappers call the proxy correctly and return `CallResult`.
- `clients/linear.d.ts` same interface as the types-only mode.
- `clients/linear.ts` imports `createRuntime`, `createServerProxy`, and
`createCallResult`, then exposes a `createLinearClient()` factory:
6. **Docs**
- Point `docs/call-syntax.md` (and README) to `docs/emit-ts.md` for usage.
- Include before/after snippets demonstrating both modes and how agents
consume the outputs.
```ts
const client = await createLinearClient({ configPath: './mcporter.json' });
const comments = await client.list_comments({ issueId: 'LIN-1234' });
console.log(comments.text());
await client.close();
```
## Open Questions
If you pass an existing runtime (`{ runtime }`), the factory reuses it; the
returned objects `close()` becomes a no-op.
- Should client wrappers auto-close runtimes they create? (Default: caller
controls lifetime; we may add `withClient` helper later.)
- Do we support emitting only a subset of tools? (Future enhancement.)
## Flags
| Flag | Description |
| --- | --- |
| `--out <path>` | Required. `.d.ts` target for `types`, `.ts` target for `client`. |
| `--mode types|client` | Output kind (defaults to `types`). |
| `--types-out <path>` | Optional override for the `.d.ts` file when `--mode client`. Default: derive from `--out`. |
| `--include-optional` | Include every parameter (not just the minimum 5 + required). |
## Testing
`tests/emit-ts.test.ts` covers:
- Template rendering (doc comments, `Promise<…>` return types, proxy wrappers).
- End-to-end CLI invocation with a stub runtime, ensuring both `.ts` and `.d.ts`
files are written successfully.

View File

@ -5,6 +5,7 @@ import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServe
import { CliUsageError } from './cli/errors.js';
import { inferCommandRouting } from './cli/command-inference.js';
import { extractEphemeralServerFlags } from './cli/ephemeral-flags.js';
import { handleEmitTs } from './cli/emit-ts-command.js';
import { handleList } from './cli/list-command.js';
import { formatSourceSuffix } from './cli/list-format.js';
import { getActiveLogger, getActiveLogLevel, logError, logInfo, logWarn, setLogLevel } from './cli/logger-context.js';
@ -62,6 +63,20 @@ async function main(): Promise<void> {
return;
}
if (command === 'emit-ts') {
const runtime = await createRuntime({
configPath: globalFlags['--config'],
rootDir: globalFlags['--root'],
logger: getActiveLogger(),
});
try {
await handleEmitTs(runtime, argv);
} finally {
await runtime.close().catch(() => {});
}
return;
}
const runtime = await createRuntime({
configPath: globalFlags['--config'],
rootDir: globalFlags['--root'],

199
src/cli/emit-ts-command.ts Normal file
View File

@ -0,0 +1,199 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ServerDefinition } from '../config.js';
import type { Runtime, ServerToolInfo } from '../runtime.js';
import { buildToolDoc } from './list-detail-helpers.js';
import { renderClientModule, renderTypesModule, type EmitMetadata, type ToolDocEntry } from './emit-ts-templates.js';
import { extractOptions, toProxyMethodName } from './generate/tools.js';
import { readPackageMetadata } from './generate/template.js';
interface EmitTsFlags {
server?: string;
outPath?: string;
mode: 'types' | 'client';
includeOptional: boolean;
typesOutPath?: string;
}
interface ParsedEmitTsOptions extends Required<Omit<EmitTsFlags, 'server' | 'outPath' | 'typesOutPath'>> {
server: string;
outPath: string;
typesOutPath?: string;
}
export async function handleEmitTs(runtime: Runtime, args: string[]): Promise<void> {
const options = parseEmitTsArgs(args);
const definition = getServerDefinition(runtime, options.server);
const tools = await runtime.listTools(options.server, { includeSchema: true, autoAuthorize: false });
const generator = await readPackageMetadata();
const metadata: EmitMetadata = {
server: definition,
generatorLabel: `${generator.name}@${generator.version}`,
generatedAt: new Date(),
};
const docEntries = buildDocEntries(options.server, tools, options.includeOptional);
const interfaceName = buildInterfaceName(options.server);
if (options.mode === 'types') {
const source = renderTypesModule({ interfaceName, docs: docEntries, metadata });
await writeFile(options.outPath, source);
console.log(`Emitted TypeScript definitions for ${options.server}${options.outPath}`);
return;
}
const typesOutPath = options.typesOutPath ?? deriveTypesOutPath(options.outPath);
const relativeImportPath = computeImportPath(options.outPath, typesOutPath);
const typesSource = renderTypesModule({ interfaceName, docs: docEntries, metadata });
const clientSource = renderClientModule({
interfaceName,
docs: docEntries,
metadata,
typesImportPath: relativeImportPath,
});
await writeFile(typesOutPath, typesSource);
await writeFile(options.outPath, clientSource);
console.log(`Emitted client + types for ${options.server}${options.outPath} / ${typesOutPath}`);
}
function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions {
const flags: EmitTsFlags = {
mode: 'types',
includeOptional: false,
};
let index = 0;
while (index < args.length) {
const token = args[index];
if (!token) {
index += 1;
continue;
}
if (token === '--out') {
const value = args[index + 1];
if (!value) {
throw new Error("Flag '--out' requires a path.");
}
flags.outPath = value;
args.splice(index, 2);
continue;
}
if (token === '--types-out') {
const value = args[index + 1];
if (!value) {
throw new Error("Flag '--types-out' requires a path.");
}
flags.typesOutPath = value;
args.splice(index, 2);
continue;
}
if (token === '--mode') {
const value = args[index + 1];
if (value !== 'types' && value !== 'client') {
throw new Error("--mode must be 'types' or 'client'.");
}
flags.mode = value;
args.splice(index, 2);
continue;
}
if (token === '--include-optional' || token === '--all-parameters') {
flags.includeOptional = true;
args.splice(index, 1);
continue;
}
if (token.startsWith('--')) {
throw new Error(`Unknown flag '${token}' for emit-ts.`);
}
index += 1;
}
const server = args.shift();
if (!server) {
throw new Error('Usage: mcporter emit-ts <server> --out <file> [--mode types|client]');
}
const outPath = flags.outPath;
if (!outPath) {
throw new Error("Flag '--out' is required for emit-ts.");
}
if (flags.mode === 'client' && !outPath.endsWith('.ts')) {
throw new Error('--out should point to a .ts file when --mode client is used.');
}
if (flags.mode === 'types' && !outPath.endsWith('.ts') && !outPath.endsWith('.d.ts')) {
throw new Error('--out should be a .ts or .d.ts file for --mode types.');
}
return {
server,
outPath: path.resolve(outPath),
mode: flags.mode,
includeOptional: flags.includeOptional,
typesOutPath: flags.typesOutPath ? path.resolve(flags.typesOutPath) : undefined,
};
}
function getServerDefinition(runtime: Runtime, name: string): ServerDefinition {
try {
return runtime.getDefinition(name);
} catch (error) {
if (error instanceof Error) {
throw new Error(error.message);
}
throw error;
}
}
function buildDocEntries(serverName: string, tools: ServerToolInfo[], includeOptional: boolean): ToolDocEntry[] {
return tools.map((tool) => {
const doc = buildToolDoc({
serverName,
toolName: tool.name,
description: tool.description,
outputSchema: tool.outputSchema,
options: extractOptions(tool),
requiredOnly: !includeOptional,
colorize: false,
defaultReturnType: 'CallResult',
});
return {
toolName: tool.name,
methodName: toProxyMethodName(tool.name),
doc,
};
});
}
function buildInterfaceName(serverName: string): string {
const cleaned = serverName
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join('');
const base = cleaned.length > 0 ? cleaned : 'Server';
return `${base}Tools`;
}
function deriveTypesOutPath(tsPath: string): string {
const dir = path.dirname(tsPath);
const base = path.basename(tsPath, path.extname(tsPath));
return path.join(dir, `${base}.d.ts`);
}
async function writeFile(targetPath: string, contents: string): Promise<void> {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, `${contents}\n`, 'utf8');
}
function computeImportPath(fromPath: string, typesPath: string): string {
const fromDir = path.dirname(fromPath);
const relative = path.relative(fromDir, typesPath).replace(/\\/g, '/');
const withoutExt = relative.replace(/\.[^.]+$/, '');
if (withoutExt.startsWith('.')) {
return withoutExt;
}
return `./${withoutExt}`;
}
export const __test = {
parseEmitTsArgs,
buildInterfaceName,
deriveTypesOutPath,
computeImportPath,
buildDocEntries,
};

View File

@ -0,0 +1,161 @@
import path from 'node:path';
import type { ServerDefinition } from '../config.js';
import type { ToolDocModel } from './list-detail-helpers.js';
export interface ToolDocEntry {
toolName: string;
methodName: string;
doc: ToolDocModel;
}
export interface EmitMetadata {
server: ServerDefinition;
generatorLabel: string;
generatedAt: Date;
}
export interface EmitTypesTemplateInput {
interfaceName: string;
docs: ToolDocEntry[];
metadata: EmitMetadata;
}
export interface EmitClientTemplateInput extends EmitTypesTemplateInput {
typesImportPath: string;
}
export function renderTypesModule(input: EmitTypesTemplateInput): string {
const lines: string[] = [];
lines.push(...renderHeader(input.metadata));
lines.push("import type { CallResult } from 'mcporter';");
lines.push('');
lines.push(`export interface ${input.interfaceName} {`);
input.docs.forEach((entry, index) => {
lines.push(...renderDocComment(entry.doc.docLines, ' '));
const methodSignature = toInterfaceSignature(entry.doc.tsSignature, { wrapInPromise: true });
lines.push(` ${methodSignature}`);
if (entry.doc.optionalSummary) {
lines.push(` // ${entry.doc.optionalSummary.replace(/^\/\//, '').trim()}`);
}
if (index !== input.docs.length - 1) {
lines.push('');
}
});
if (input.docs.length === 0) {
lines.push(' // No tools reported for this server.');
}
lines.push('}');
lines.push('');
return lines.join('\n');
}
export function renderClientModule(input: EmitClientTemplateInput): string {
const lines: string[] = [];
lines.push(...renderHeader(input.metadata));
lines.push("import { createCallResult, createRuntime, createServerProxy } from 'mcporter';");
lines.push(`import type { ${input.interfaceName} } from '${input.typesImportPath}';`);
lines.push('');
lines.push('type RuntimeInstance = Awaited<ReturnType<typeof createRuntime>>;');
const clientType = `${input.interfaceName.replace(/Tools$/, 'Client')}`;
const factoryName = `create${input.interfaceName.replace(/Tools$/, '')}Client`;
const serverName = input.metadata.server.name;
lines.push(`export type ${clientType} = ${input.interfaceName} & { close(): Promise<void> };`);
lines.push('');
lines.push('export interface CreateClientOptions {');
lines.push(' runtime?: RuntimeInstance;');
lines.push(' configPath?: string;');
lines.push(' rootDir?: string;');
lines.push('}');
lines.push('');
lines.push(`export async function ${factoryName}(options: CreateClientOptions = {}): Promise<${clientType}> {`);
lines.push(' const runtime = options.runtime ?? (await createRuntime({');
lines.push(' configPath: options.configPath,');
lines.push(' rootDir: options.rootDir,');
lines.push(' }));');
lines.push(' const ownsRuntime = !options.runtime;');
lines.push(` const proxy = createServerProxy(runtime, ${JSON.stringify(serverName)});`);
lines.push(` const client: ${clientType} = {`);
input.docs.forEach((entry, index) => {
const methodName = entry.doc.tsSignature.match(/^function\s+([^\(]+)/)?.[1] ?? entry.toolName;
lines.push(` async ${methodName}(params: Parameters<${input.interfaceName}['${methodName}']>[0]) {`);
lines.push(
` const tool = proxy.${entry.methodName} as (args: Parameters<${input.interfaceName}['${methodName}']>[0]) => Promise<unknown>;`
);
lines.push(' const raw = await tool(params);');
lines.push(' return createCallResult(raw);');
lines.push(' },');
lines.push('');
});
lines.push(' async close() {');
lines.push(' if (ownsRuntime) {');
lines.push(` await runtime.close(${JSON.stringify(serverName)}).catch(() => {});`);
lines.push(' }');
lines.push(' },');
lines.push(' };');
lines.push(' return client;');
lines.push('}');
lines.push('');
return lines.join('\n');
}
function renderHeader(metadata: EmitMetadata): string[] {
const lines: string[] = [];
const timestamp = metadata.generatedAt.toISOString();
lines.push(`// Generated on ${timestamp} by ${metadata.generatorLabel}`);
if (metadata.server.description) {
lines.push(`// Server: ${metadata.server.name}${metadata.server.description}`);
} else {
lines.push(`// Server: ${metadata.server.name}`);
}
const source = describeSource(metadata.server);
if (source) {
lines.push(`// Source: ${source}`);
}
const transport = describeTransport(metadata.server);
if (transport) {
lines.push(`// Transport: ${transport}`);
}
lines.push('');
return lines;
}
function renderDocComment(docLines: string[] | undefined, indent: string): string[] {
if (!docLines || docLines.length === 0) {
return [];
}
return docLines.map((line) => `${indent}${line}`);
}
function toInterfaceSignature(signature: string, options?: { wrapInPromise?: boolean }): string {
const trimmed = signature.trim();
const match = trimmed.match(/^function\s+([^(]+)\((.*)\)\s*(?::\s*([^;]+))?;?$/);
if (!match) {
return trimmed.replace(/^function\s+/, '');
}
const [, name, params, returnTypeRaw] = match;
const returnType = (returnTypeRaw ?? 'void').trim();
const finalReturn = options?.wrapInPromise ? `Promise<${returnType}>` : returnType;
return `${name}(${params}): ${finalReturn};`;
}
function describeTransport(definition: ServerDefinition): string | undefined {
if (definition.command.kind === 'http') {
const url = definition.command.url instanceof URL ? definition.command.url.href : String(definition.command.url);
return `HTTP ${url}`;
}
if (definition.command.kind === 'stdio') {
const cmd = [definition.command.command, ...(definition.command.args ?? [])].join(' ').trim();
return cmd.length > 0 ? `STDIO ${cmd}` : 'STDIO';
}
return undefined;
}
function describeSource(definition: ServerDefinition): string | undefined {
if (definition.source?.kind === 'import') {
return path.normalize(definition.source.path);
}
if (definition.source?.kind === 'local') {
return definition.source.path;
}
return undefined;
}

View File

@ -22,6 +22,7 @@ export interface ToolDocInput {
colorize?: boolean;
exampleMaxLength?: number;
flagExtras?: FlagUsageExtra[];
defaultReturnType?: string;
}
export interface ToolDocModel {
@ -186,6 +187,7 @@ export function formatOptionalSummary(hiddenOptions: GeneratedOption[], options?
interface SignatureFormatOptions {
colorize?: boolean;
defaultReturnType?: string;
}
export function formatFunctionSignature(
@ -198,7 +200,7 @@ export function formatFunctionSignature(
const keyword = colorize ? extraDimText('function') : 'function';
const formattedName = colorize ? cyanText(name) : name;
const paramsText = options.map((option) => formatInlineParameter(option, colorize)).join(', ');
const returnType = inferReturnTypeName(outputSchema);
const returnType = inferReturnTypeName(outputSchema) ?? formatOptions?.defaultReturnType;
const signature = `${keyword} ${formattedName}(${paramsText})`;
return returnType ? `${signature}: ${returnType};` : `${signature};`;
}
@ -294,11 +296,18 @@ export function buildToolDoc(input: ToolDocInput): ToolDocModel {
colorize = true,
exampleMaxLength,
flagExtras,
defaultReturnType,
} = input;
const { displayOptions, hiddenOptions } = selectDisplayOptions(options, requiredOnly);
const docLines = buildDocComment(description, options, { colorize });
const signature = formatFunctionSignature(toolName, displayOptions, outputSchema, { colorize });
const tsSignature = formatFunctionSignature(toolName, displayOptions, outputSchema, { colorize: false });
const signature = formatFunctionSignature(toolName, displayOptions, outputSchema, {
colorize,
defaultReturnType,
});
const tsSignature = formatFunctionSignature(toolName, displayOptions, outputSchema, {
colorize: false,
defaultReturnType,
});
const flagUsage = formatFlagUsage(displayOptions, flagExtras, { colorize });
const optionalSummary = hiddenOptions.length > 0 ? formatOptionalSummary(hiddenOptions, { colorize }) : undefined;
const optionDocs = options.map((option) => buildOptionDoc(option, { colorize }));

92
tests/emit-ts.test.ts Normal file
View File

@ -0,0 +1,92 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import type { Runtime, ServerToolInfo } from '../src/runtime.js';
import { handleEmitTs, __test as emitTsTestInternals } from '../src/cli/emit-ts-command.js';
import { renderClientModule, renderTypesModule } from '../src/cli/emit-ts-templates.js';
const sampleDefinition: ServerDefinition = {
name: 'integration',
description: 'Integration test server',
command: { kind: 'http', url: 'https://example.com/mcp' },
transport: 'stdio',
};
const sampleTool: ServerToolInfo = {
name: 'list_comments',
description: 'List comments for an issue',
inputSchema: {
type: 'object',
properties: {
issueId: { type: 'string', description: 'Issue identifier' },
limit: { type: 'number', description: 'Limit results', default: 10 },
},
required: ['issueId'],
},
outputSchema: { title: 'CommentList' },
};
function createRuntimeStub(): Runtime {
return {
listServers: () => ['integration'],
getDefinitions: () => [sampleDefinition],
getDefinition: () => sampleDefinition,
registerDefinition: () => {},
listTools: async () => [sampleTool],
callTool: async () => ({}),
listResources: async () => ({}),
connect: async () => {
throw new Error('not implemented');
},
close: async () => {},
} as unknown as Runtime;
}
describe('emit-ts templates', () => {
it('renders type declarations with CallResult returns', () => {
const docs = emitTsTestInternals.buildDocEntries('integration', [sampleTool], false);
const metadata = {
server: sampleDefinition,
generatorLabel: 'mcporter@test',
generatedAt: new Date('2025-11-07T00:00:00Z'),
};
const source = renderTypesModule({ interfaceName: 'IntegrationTools', docs, metadata });
expect(source).toContain('export interface IntegrationTools');
expect(source).toContain('Promise<CommentList>');
expect(source).toContain('Issue identifier');
});
it('renders client module that wraps proxy calls', () => {
const docs = emitTsTestInternals.buildDocEntries('integration', [sampleTool], true);
const metadata = {
server: sampleDefinition,
generatorLabel: 'mcporter@test',
generatedAt: new Date('2025-11-07T00:00:00Z'),
};
const source = renderClientModule({
interfaceName: 'IntegrationTools',
docs,
metadata,
typesImportPath: './integration-client',
});
expect(source).toContain('createIntegrationClient');
expect(source).toContain('createCallResult');
expect(source).toContain('proxy.listComments');
});
});
describe('handleEmitTs', () => {
it('writes client and types files to disk', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'emit-ts-'));
const runtime = createRuntimeStub();
const clientPath = path.join(tmpDir, 'integration-client.ts');
await handleEmitTs(runtime, ['integration', '--out', clientPath, '--mode', 'client']);
const typesPath = path.join(tmpDir, 'integration-client.d.ts');
const clientSource = await fs.readFile(clientPath, 'utf8');
const typesSource = await fs.readFile(typesPath, 'utf8');
expect(clientSource).toContain('createIntegrationClient');
expect(typesSource).toContain('export interface IntegrationTools');
});
});