Add emit-ts command with types and client modes
This commit is contained in:
parent
f36be7254e
commit
b60816c11c
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
160
docs/emit-ts.md
160
docs/emit-ts.md
@ -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 object’s lifetime is
|
||||
the caller’s 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 object’s `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.
|
||||
|
||||
15
src/cli.ts
15
src/cli.ts
@ -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
199
src/cli/emit-ts-command.ts
Normal 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,
|
||||
};
|
||||
161
src/cli/emit-ts-templates.ts
Normal file
161
src/cli/emit-ts-templates.ts
Normal 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;
|
||||
}
|
||||
@ -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
92
tests/emit-ts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user