Add emit-ts improvements and shared CLI infrastructure
This commit is contained in:
parent
e880dc48bb
commit
1e29d71305
53
docs/cli-reference.md
Normal file
53
docs/cli-reference.md
Normal file
@ -0,0 +1,53 @@
|
||||
# mcporter CLI Reference
|
||||
|
||||
A quick reference for the primary `mcporter` subcommands. Each command inherits
|
||||
`--config <file>` and `--root <dir>` to override where servers are loaded from.
|
||||
|
||||
## `mcporter list [server]`
|
||||
- Without arguments, lists every configured server (with live discovery + brief
|
||||
status).
|
||||
- With a server name, prints TypeScript-style signatures for each tool, doc
|
||||
comments, and optional summaries.
|
||||
- Flags:
|
||||
- `--all-parameters` – include every optional parameter in the signature.
|
||||
- `--schema` – pretty-print the JSON schema for each tool.
|
||||
- `--timeout <ms>` – per-server timeout when enumerating all servers.
|
||||
|
||||
## `mcporter call <server.tool>`
|
||||
- Invokes a tool once and prints the response; supports positional arguments via
|
||||
pseudo-TS syntax and `--arg` flags.
|
||||
- Useful flags:
|
||||
- `--server`, `--tool` – alternate way to target a tool.
|
||||
- `--timeout <ms>` – override call timeout (defaults to `CALL_TIMEOUT_MS`).
|
||||
- `--output text|markdown|json|raw` – choose how to render the `CallResult`.
|
||||
- `--tail-log` – stream tail output when the tool returns log handles.
|
||||
|
||||
## `mcporter generate-cli`
|
||||
- Produces a standalone CLI for a single MCP server (optionally bundling or
|
||||
compiling with Bun).
|
||||
- Key flags:
|
||||
- `--server <name>` (or inline JSON) – choose the server definition.
|
||||
- `--output <path>` – where to write the TypeScript template.
|
||||
- `--bundle <path>` – emit a bundle (Node/Bun) ready for `bun x`.
|
||||
- `--compile <path>` – compile with Bun (implies `--runtime bun`).
|
||||
- `--timeout <ms>` / `--runtime node|bun` – shared via the generator flag
|
||||
parser so defaults stay consistent.
|
||||
|
||||
## `mcporter emit-ts <server>`
|
||||
- Emits TypeScript definitions (and optionally a ready-to-use client) describing
|
||||
a server’s tools. This reuses the same formatter as `mcporter list` so doc
|
||||
comments, signatures, and examples stay in sync.
|
||||
- Modes:
|
||||
- `--mode types --out <file.d.ts>` (default) – export an interface whose
|
||||
methods return `Promise<CallResult>`, with doc comments and optional
|
||||
summaries.
|
||||
- `--mode client --out <file.ts>` – emit both the interface (`<file>.d.ts`)
|
||||
and a factory that wraps `createServerProxy`, returning objects whose
|
||||
methods resolve to `CallResult`.
|
||||
- Other flags:
|
||||
- `--include-optional` (alias `--all-parameters`) – show every optional field.
|
||||
- `--types-out <file>` – override where the `.d.ts` sits when using client
|
||||
mode.
|
||||
|
||||
For more detail (behavioral nuances, OAuth flows, etc.), see `docs/spec.md` and
|
||||
command-specific docs under `docs/`.
|
||||
@ -1,66 +0,0 @@
|
||||
# CLI ↔ Generator Code Reuse Plan
|
||||
|
||||
The goals below align `mcporter list`, the TypeScript CLI generator, and any future TS export modes so we build (and test) formatting logic once.
|
||||
|
||||
## 1. Shared Example Rendering
|
||||
|
||||
- **Problem**: `list-detail-helpers.ts` shortens `mcporter call` examples, but `generate/template.ts` still prints `--flag` examples via `buildExampleInvocation`.
|
||||
- **Plan**:
|
||||
1. Export a non-colored `formatExampleBlock()` utility (and the internal `truncateExample()` helper) from `list-detail-helpers.ts`.
|
||||
2. Import that helper inside `renderToolCommand()` and replace `buildExampleInvocation` with the shared function-call output.
|
||||
3. Drop the duplicate `buildExampleInvocation/pickExampleValue` logic once Commander help uses the shared examples.
|
||||
4. Update the generator tests (in `tests/generate-cli.test.ts`) to expect the new syntax.
|
||||
|
||||
## 2. Optional Summary in Generated Help *(Completed)*
|
||||
|
||||
- **Problem**: The runtime CLI prints `// optional (n): …` while generated CLIs enumerate every flag.
|
||||
- **What we did**:
|
||||
1. Reused `selectDisplayOptions()` so both CLI/GH generator decide which params to display.
|
||||
2. Added `formatOptionalSummary()` + `buildToolDoc()` wiring so each surface appends the same `// optional (…)` hint only when options were hidden.
|
||||
3. Updated `renderToolCommand()` to include the shared hint via `.addHelpText('afterAll', …)` and aligned tests.
|
||||
- **Next**: No further action unless we change the minimum-visible threshold.
|
||||
|
||||
## 3. Consolidate Example Literal Selection *(Completed)*
|
||||
|
||||
- **Problem**: CLI used bespoke `buildExampleLiteral`/`buildFallbackLiteral` logic, while generator helpers guessed examples via `buildExampleValue`, so the call expressions could diverge.
|
||||
- **What we did**:
|
||||
1. Moved the literal + fallback logic into `pickExampleLiteral()` / `buildFallbackLiteral()` exported from `src/cli/generate/tools.ts`.
|
||||
2. `buildToolDoc` now imports those helpers, so both `mcporter list` and generated CLIs share the same example arguments.
|
||||
3. Added unit tests in `tests/generate-cli-helpers.test.ts` covering enums, arrays, and ID/url fallbacks to keep behavior locked.
|
||||
- **Next**: Consider reusing these helpers for any future docs/export modes that show sample invocations.
|
||||
|
||||
## 4. Usage String Builder Parity *(Completed)*
|
||||
|
||||
- **Problem**: Commander `.usage()` strings were hand-built (and always listed every flag) while `mcporter list` used the pseudo-TS formatter, so the two could diverge.
|
||||
- **What we did**:
|
||||
1. Added `formatFlagUsage()` + `flagExtras` support inside `buildToolDoc`, so the same selector that powers TS signatures now emits the flag-based usage line.
|
||||
2. Updated `renderToolCommand()` to consume `doc.flagUsage` for both `.summary()` and `.usage()`; optional summaries stay unified through `buildToolDoc`.
|
||||
3. Added helper unit tests covering mixed required/optional flags and extra entries (e.g., `--raw <json>`).
|
||||
- **Next**: Expose the shared usage string in `mcporter list` once we add a `--flags` view.
|
||||
|
||||
## 5. ToolDocModel Abstraction *(Completed)*
|
||||
|
||||
- **Problem**: The runtime CLI and generator still built flag labels + option descriptions separately, so changes to detail formatting required touching multiple files.
|
||||
- **What we did**:
|
||||
1. Expanded `ToolDocModel` with `optionDocs` + `formatFlagLabel()`, so each tool’s flag label/description is computed once inside `buildToolDoc`.
|
||||
2. Updated `renderToolCommand()` to consume `doc.optionDocs` instead of reassembling strings, leaving only the parser wiring as generator-specific.
|
||||
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. `emit-ts` Export Mode *(Completed)*
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
Sequencing recommendation:
|
||||
1. Implement shared example helper (small change, immediate parity). **Done** – `list-detail-helpers.ts` now exports `formatExampleBlock`, `formatCallExpressionExample`, & the generator consumes them.
|
||||
2. Extract `ToolDocModel` + optional summary builder. **Done** – `buildToolDoc` in `src/cli/list-detail-helpers.ts` now feeds both `handleList` and `renderToolCommand`.
|
||||
3. Update generator to consume the shared helpers (examples + optional summary + signatures). **In progress** – signatures/examples unified; `ToolDocModel` still pending.
|
||||
4. Add unit tests for the new helper module.
|
||||
5. Build the `--emit-ts` mode once reuse is in place.
|
||||
@ -3,7 +3,8 @@
|
||||
`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.
|
||||
comments, parameter hints, and signatures stay perfectly in sync. For a broader
|
||||
overview of every CLI command, see `docs/cli-reference.md`.
|
||||
|
||||
```
|
||||
mcporter emit-ts <server> --out linear-client.ts \
|
||||
|
||||
22
docs/known-issues.md
Normal file
22
docs/known-issues.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Known Issues
|
||||
|
||||
This file tracks limitations that users regularly run into. Most of these require upstream cooperation or larger refactors—feel free to reference this when triaging bugs.
|
||||
|
||||
## Hosted OAuth servers (Supabase, GitHub MCP, etc.)
|
||||
- Supabase’s hosted MCP server rejects the standard `mcp:tools` scope and only accepts Supabase-specific scopes (`projects:read`, `database:write`, ...). Because they do not expose OAuth discovery metadata or scope negotiation, mcporter cannot auto-register or complete the flow. Workarounds:
|
||||
- Use Supabase’s supported clients (Cursor, Windsurf).
|
||||
- Self-host their MCP server and configure PAT headers / custom OAuth.
|
||||
- Ask Supabase to accept the MCP scope or publish their scope list.
|
||||
- GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) returns “does not support dynamic client registration” when mcporter attempts to connect. Copilot’s backend expects pre-registered client credentials. Until GitHub publishes a dynamic-registration API (or client secrets), mcporter cannot interact with their hosted server.
|
||||
|
||||
## Output schemas missing/buggy on many servers
|
||||
- The MCP spec allows servers to omit `outputSchema`. In practice, many hosted MCPs return empty or inconsistent schemas, so features that rely on return types (TypeScript signatures, generated CLIs, `createServerProxy` return helpers) may degrade to `unknown`.
|
||||
- Workarounds: inspect the server’s README / manual docs for output details, or wrap the tool via `createServerProxy` and handle the raw envelope manually.
|
||||
- Potential improvement: allow user-provided schema overrides (e.g., `mcporter config patch`, CLI flag to load schema JSON) so we can fill gaps on a per-tool basis.
|
||||
|
||||
## Next Steps
|
||||
- Implement true scope negotiation (read discovery metadata, allow `--oauth-scope`).
|
||||
- Keep lobbying providers for spec-compliant OAuth behavior.
|
||||
- Consider adding schema override hooks or auto-caching schema snapshots per tool.
|
||||
|
||||
If you run into other recurring pain points, append them here so we can prioritize fixes.
|
||||
59
docs/refactor.md
Normal file
59
docs/refactor.md
Normal file
@ -0,0 +1,59 @@
|
||||
# Next-Step Refactor Checklist
|
||||
|
||||
This doc tracks remaining reuse/refactor work now that the original plan is done.
|
||||
Each section lists the goal, why it matters, and the concrete steps/tests needed.
|
||||
|
||||
## 1. Shared Tool Schema Cache *(Completed)*
|
||||
- **Problem**: `generate-cli` and `emit-ts` both fetch & serialize tool schemas
|
||||
independently (and `mcporter list` re-parses them too).
|
||||
- **What we did**:
|
||||
1. Added `src/cli/tool-cache.ts` with `loadToolMetadata()` caching tool metadata per runtime/server/options.
|
||||
2. Switched `mcporter list` (single-server path) and `emit-ts` to consume the helper, so both reuse `ToolMetadata`.
|
||||
3. Added `tests/tool-cache.test.ts` + updated emit-ts tests to ensure the helper is covered.
|
||||
- **Next**: Consider integrating the cache into `generate-cli` if we ever reuse runtime instances there.
|
||||
|
||||
## 2. Unified Flag Parsing for Generator-style Commands *(Completed)*
|
||||
- **Problem**: `generate-cli`, `regenerate-cli`, and `emit-ts` each
|
||||
reimplemented `--runtime`, `--timeout`, and `--include-optional` handling.
|
||||
- **What we did**:
|
||||
1. Added `extractGeneratorFlags()` in `src/cli/generate/flag-parser.ts` to
|
||||
normalize shared flags while mutating `args` in place.
|
||||
2. Updated all three commands to call the helper before parsing
|
||||
command-specific options.
|
||||
3. Added `tests/generator-flag-parser.test.ts` to cover runtime/timeout and
|
||||
optional flags.
|
||||
|
||||
## 3. Test Fixture Reuse *(Completed)*
|
||||
- **Problem**: Emit-ts/tool-cache/unit tests each defined their own tool/definition
|
||||
fixtures, leading to divergence.
|
||||
- **What we did**:
|
||||
1. Added `tests/fixtures/tool-fixtures.ts` (shared definition + tools).
|
||||
2. Updated `tests/emit-ts.test.ts` and `tests/tool-cache.test.ts` to import
|
||||
the shared fixtures (and reuse them via `buildToolMetadata`).
|
||||
3. Ensured the fixture covers required+optional parameters so both suites hit
|
||||
the same edge cases.
|
||||
|
||||
## 4. CallResult Helper Extraction *(Completed)*
|
||||
- **Problem**: `call-command.ts` and the emit-ts client template both wrapped
|
||||
results with `createCallResult`, but there was no shared helper.
|
||||
- **What we did**:
|
||||
1. Added `wrapCallResult()` to `result-utils.ts` and exported it from the
|
||||
package entry.
|
||||
2. Updated `call-command.ts` and the emit-ts template to reuse the helper so
|
||||
they stay in sync.
|
||||
3. Adjusted emit-ts tests to assert the helper is referenced.
|
||||
|
||||
## 5. CLI Docs Consolidation *(Completed)*
|
||||
- **Problem**: CLI usage guidance was scattered across README, `docs/spec.md`,
|
||||
and various feature docs.
|
||||
- **What we did**:
|
||||
1. Added `docs/cli-reference.md` summarizing `list`, `call`, `generate-cli`,
|
||||
and `emit-ts` flags/modes in one place.
|
||||
2. Pointed emit-ts users to that doc so we can converge other references over
|
||||
time (no README/spec churn yet to avoid clobbering parallel work).
|
||||
- **Next**: Once the other doc changes land, update README/spec to link to the
|
||||
reference and drop redundant sections.
|
||||
|
||||
---
|
||||
Tracking the above here keeps future agents aligned. Update this checklist as
|
||||
items ship (mark sections “Completed” when done, or delete the doc once empty).
|
||||
49
src/cli.ts
49
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 { extractGeneratorFlags } from './cli/generate/flag-parser.js';
|
||||
import { handleEmitTs } from './cli/emit-ts-command.js';
|
||||
import { handleList } from './cli/list-command.js';
|
||||
import { formatSourceSuffix } from './cli/list-format.js';
|
||||
@ -183,6 +184,7 @@ interface GenerateFlags {
|
||||
|
||||
// parseGenerateFlags extracts generate-cli specific flags from argv.
|
||||
function parseGenerateFlags(args: string[]): GenerateFlags {
|
||||
const common = extractGeneratorFlags(args);
|
||||
let server: string | undefined;
|
||||
let name: string | undefined;
|
||||
let command: string | undefined;
|
||||
@ -190,8 +192,8 @@ function parseGenerateFlags(args: string[]): GenerateFlags {
|
||||
let output: string | undefined;
|
||||
let bundle: boolean | string | undefined;
|
||||
let compile: boolean | string | undefined;
|
||||
let runtime: 'node' | 'bun' | undefined;
|
||||
let timeout = 30_000;
|
||||
let runtime: 'node' | 'bun' | undefined = common.runtime;
|
||||
let timeout = common.timeout ?? 30_000;
|
||||
let minify = false;
|
||||
|
||||
let index = 0;
|
||||
@ -226,24 +228,6 @@ function parseGenerateFlags(args: string[]): GenerateFlags {
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
if (token === '--runtime') {
|
||||
const value = expectValue(token, args[index + 1]);
|
||||
if (value !== 'node' && value !== 'bun') {
|
||||
throw new Error("--runtime must be 'node' or 'bun'.");
|
||||
}
|
||||
runtime = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
if (token === '--timeout') {
|
||||
const value = Number.parseInt(expectValue(token, args[index + 1]), 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error('--timeout must be a positive integer.');
|
||||
}
|
||||
timeout = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
if (token === '--bundle') {
|
||||
const next = args[index + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
@ -396,6 +380,13 @@ interface RegenerateParseResult {
|
||||
function parseRegenerateFlags(args: string[]): RegenerateParseResult {
|
||||
const overrides: RegenerateOverrides = {};
|
||||
let dryRun = false;
|
||||
const common = extractGeneratorFlags(args);
|
||||
if (common.runtime) {
|
||||
overrides.runtime = common.runtime;
|
||||
}
|
||||
if (common.timeout) {
|
||||
overrides.timeoutMs = common.timeout;
|
||||
}
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
@ -418,24 +409,6 @@ function parseRegenerateFlags(args: string[]): RegenerateParseResult {
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
if (token === '--runtime') {
|
||||
const value = expectValue(token, args[index + 1]);
|
||||
if (value !== 'node' && value !== 'bun') {
|
||||
throw new Error("--runtime must be 'node' or 'bun'.");
|
||||
}
|
||||
overrides.runtime = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
if (token === '--timeout') {
|
||||
const value = Number.parseInt(expectValue(token, args[index + 1]), 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error('--timeout must be a positive integer.');
|
||||
}
|
||||
overrides.timeoutMs = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
if (token === '--minify') {
|
||||
overrides.minify = true;
|
||||
args.splice(index, 1);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ServerToolInfo } from '../runtime.js';
|
||||
import { createCallResult } from '../result-utils.js';
|
||||
import { wrapCallResult } from '../result-utils.js';
|
||||
import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './adhoc-server.js';
|
||||
import { parseCallExpressionFragment } from './call-expression-parser.js';
|
||||
import { CliUsageError } from './errors.js';
|
||||
@ -261,7 +261,7 @@ export async function handleCall(
|
||||
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
|
||||
const { result } = await invokeWithAutoCorrection(runtime, server, tool, hydratedArgs, timeoutMs);
|
||||
|
||||
const wrapped = createCallResult(result);
|
||||
const { callResult: wrapped } = wrapCallResult(result);
|
||||
printCallOutput(wrapped, result, parsed.output);
|
||||
tailLogIfRequested(result, parsed.tailLog);
|
||||
dumpActiveHandles('after call (formatted result)');
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
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 type { Runtime } 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 type { ToolMetadata } from './generate/tools.js';
|
||||
import { extractGeneratorFlags } from './generate/flag-parser.js';
|
||||
import { loadToolMetadata } from './tool-cache.js';
|
||||
import { readPackageMetadata } from './generate/template.js';
|
||||
|
||||
interface EmitTsFlags {
|
||||
@ -24,14 +26,14 @@ interface ParsedEmitTsOptions extends Required<Omit<EmitTsFlags, 'server' | 'out
|
||||
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 metadataEntries = await loadToolMetadata(runtime, 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 docEntries = buildDocEntries(options.server, metadataEntries, options.includeOptional);
|
||||
const interfaceName = buildInterfaceName(options.server);
|
||||
|
||||
if (options.mode === 'types') {
|
||||
@ -60,6 +62,10 @@ function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions {
|
||||
mode: 'types',
|
||||
includeOptional: false,
|
||||
};
|
||||
const common = extractGeneratorFlags(args, { allowIncludeOptional: true });
|
||||
if (common.includeOptional) {
|
||||
flags.includeOptional = true;
|
||||
}
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
@ -94,11 +100,6 @@ function parseEmitTsArgs(args: string[]): ParsedEmitTsOptions {
|
||||
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.`);
|
||||
}
|
||||
@ -139,21 +140,21 @@ function getServerDefinition(runtime: Runtime, name: string): ServerDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
function buildDocEntries(serverName: string, tools: ServerToolInfo[], includeOptional: boolean): ToolDocEntry[] {
|
||||
return tools.map((tool) => {
|
||||
function buildDocEntries(serverName: string, metadataEntries: ToolMetadata[], includeOptional: boolean): ToolDocEntry[] {
|
||||
return metadataEntries.map((entry) => {
|
||||
const doc = buildToolDoc({
|
||||
serverName,
|
||||
toolName: tool.name,
|
||||
description: tool.description,
|
||||
outputSchema: tool.outputSchema,
|
||||
options: extractOptions(tool),
|
||||
toolName: entry.tool.name,
|
||||
description: entry.tool.description,
|
||||
outputSchema: entry.tool.outputSchema,
|
||||
options: entry.options,
|
||||
requiredOnly: !includeOptional,
|
||||
colorize: false,
|
||||
defaultReturnType: 'CallResult',
|
||||
});
|
||||
return {
|
||||
toolName: tool.name,
|
||||
methodName: toProxyMethodName(tool.name),
|
||||
toolName: entry.tool.name,
|
||||
methodName: entry.methodName,
|
||||
doc,
|
||||
};
|
||||
});
|
||||
|
||||
@ -52,7 +52,7 @@ export function renderTypesModule(input: EmitTypesTemplateInput): string {
|
||||
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 { createRuntime, createServerProxy, wrapCallResult } from 'mcporter';");
|
||||
lines.push(`import type { ${input.interfaceName} } from '${input.typesImportPath}';`);
|
||||
lines.push('');
|
||||
lines.push('type RuntimeInstance = Awaited<ReturnType<typeof createRuntime>>;');
|
||||
@ -82,7 +82,7 @@ export function renderClientModule(input: EmitClientTemplateInput): string {
|
||||
` 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(' return wrapCallResult(raw).callResult;');
|
||||
lines.push(' },');
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
50
src/cli/generate/flag-parser.ts
Normal file
50
src/cli/generate/flag-parser.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export interface GeneratorCommonFlags {
|
||||
runtime?: 'node' | 'bun';
|
||||
timeout?: number;
|
||||
includeOptional?: boolean;
|
||||
}
|
||||
|
||||
interface ExtractOptions {
|
||||
allowIncludeOptional?: boolean;
|
||||
}
|
||||
|
||||
export function extractGeneratorFlags(args: string[], options: ExtractOptions = {}): GeneratorCommonFlags {
|
||||
const result: GeneratorCommonFlags = {};
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
if (!token) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === '--runtime') {
|
||||
const value = args[index + 1];
|
||||
if (value !== 'node' && value !== 'bun') {
|
||||
throw new Error("--runtime must be 'node' or 'bun'.");
|
||||
}
|
||||
result.runtime = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
if (token === '--timeout') {
|
||||
const raw = args[index + 1];
|
||||
if (!raw) {
|
||||
throw new Error("Flag '--timeout' requires a value.");
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error('--timeout must be a positive integer.');
|
||||
}
|
||||
result.timeout = parsed;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
if (options.allowIncludeOptional && (token === '--include-optional' || token === '--all-parameters')) {
|
||||
result.includeOptional = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@ -4,12 +4,10 @@ import type { ServerToolInfo } from '../runtime.js';
|
||||
import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './adhoc-server.js';
|
||||
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
|
||||
import type { GeneratedOption } from './generate/tools.js';
|
||||
import { extractOptions } from './generate/tools.js';
|
||||
import type { ToolMetadata } from './generate/tools.js';
|
||||
import { chooseClosestIdentifier } from './identifier-helpers.js';
|
||||
import {
|
||||
buildToolDoc,
|
||||
formatExampleBlock,
|
||||
} from './list-detail-helpers.js';
|
||||
import { buildToolDoc, formatExampleBlock } from './list-detail-helpers.js';
|
||||
import { loadToolMetadata } from './tool-cache.js';
|
||||
import type { ListSummaryResult, StatusCategory } from './list-format.js';
|
||||
import { classifyListError, formatSourceSuffix, renderServerListRow } from './list-format.js';
|
||||
import { boldText, cyanText, dimText, extraDimText, supportsSpinner, yellowText } from './terminal.js';
|
||||
@ -196,12 +194,15 @@ export async function handleList(
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
|
||||
const tools = await withTimeout(runtime.listTools(target, { includeSchema: true }), timeoutMs);
|
||||
const metadataEntries = await withTimeout(
|
||||
loadToolMetadata(runtime, target, { includeSchema: true }),
|
||||
timeoutMs
|
||||
);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const summaryLine = printSingleServerHeader(definition, tools.length, durationMs, transportSummary, sourcePath, {
|
||||
const summaryLine = printSingleServerHeader(definition, metadataEntries.length, durationMs, transportSummary, sourcePath, {
|
||||
printSummaryNow: false,
|
||||
});
|
||||
if (tools.length === 0) {
|
||||
if (metadataEntries.length === 0) {
|
||||
console.log(' Tools: <none>');
|
||||
console.log(summaryLine);
|
||||
console.log('');
|
||||
@ -209,8 +210,8 @@ export async function handleList(
|
||||
}
|
||||
const examples: string[] = [];
|
||||
let optionalOmitted = false;
|
||||
for (const tool of tools) {
|
||||
const detail = printToolDetail(target, tool, Boolean(flags.schema), flags.requiredOnly);
|
||||
for (const entry of metadataEntries) {
|
||||
const detail = printToolDetail(target, entry, Boolean(flags.schema), flags.requiredOnly);
|
||||
examples.push(...detail.examples);
|
||||
optionalOmitted ||= detail.optionalOmitted;
|
||||
}
|
||||
@ -293,19 +294,13 @@ function printSingleServerHeader(
|
||||
return summaryLine;
|
||||
}
|
||||
|
||||
function printToolDetail(
|
||||
serverName: string,
|
||||
tool: { name: string; description?: string; inputSchema?: unknown; outputSchema?: unknown },
|
||||
includeSchema: boolean,
|
||||
requiredOnly: boolean
|
||||
): ToolDetailResult {
|
||||
const options = extractOptions(tool as ServerToolInfo);
|
||||
function printToolDetail(serverName: string, metadata: ToolMetadata, includeSchema: boolean, requiredOnly: boolean): ToolDetailResult {
|
||||
const doc = buildToolDoc({
|
||||
serverName,
|
||||
toolName: tool.name,
|
||||
description: tool.description,
|
||||
outputSchema: tool.outputSchema,
|
||||
options,
|
||||
toolName: metadata.tool.name,
|
||||
description: metadata.tool.description,
|
||||
outputSchema: metadata.tool.outputSchema,
|
||||
options: metadata.options,
|
||||
requiredOnly,
|
||||
colorize: true,
|
||||
});
|
||||
@ -318,9 +313,9 @@ function printToolDetail(
|
||||
if (doc.optionalSummary && requiredOnly) {
|
||||
console.log(` ${doc.optionalSummary}`);
|
||||
}
|
||||
if (includeSchema && tool.inputSchema) {
|
||||
if (includeSchema && metadata.tool.inputSchema) {
|
||||
// Schemas can be large — indenting keeps multi-line JSON legible without disrupting surrounding output.
|
||||
console.log(indent(JSON.stringify(tool.inputSchema, null, 2), ' '));
|
||||
console.log(indent(JSON.stringify(metadata.tool.inputSchema, null, 2), ' '));
|
||||
}
|
||||
console.log('');
|
||||
return {
|
||||
|
||||
34
src/cli/server-lookup.ts
Normal file
34
src/cli/server-lookup.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { ServerDefinition } from '../config.js';
|
||||
|
||||
export function findServerByHttpUrl(
|
||||
definitions: readonly ServerDefinition[],
|
||||
urlString: string
|
||||
): string | undefined {
|
||||
const normalizedTarget = normalizeUrl(urlString);
|
||||
if (!normalizedTarget) {
|
||||
return undefined;
|
||||
}
|
||||
for (const definition of definitions) {
|
||||
if (definition.command.kind !== 'http') {
|
||||
continue;
|
||||
}
|
||||
const normalizedDefinitionUrl = normalizeUrl(definition.command.url);
|
||||
if (!normalizedDefinitionUrl) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedDefinitionUrl === normalizedTarget) {
|
||||
return definition.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string | URL): string | undefined {
|
||||
try {
|
||||
const url = value instanceof URL ? value : new URL(value);
|
||||
// URL#href always ends with a trailing slash for bare origins; keep it for consistency
|
||||
return url.href.replace(/\/$/, '/');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
43
src/cli/tool-cache.ts
Normal file
43
src/cli/tool-cache.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type { Runtime } from '../runtime.js';
|
||||
import { buildToolMetadata, type ToolMetadata } from './generate/tools.js';
|
||||
|
||||
interface LoadToolMetadataOptions {
|
||||
includeSchema?: boolean;
|
||||
autoAuthorize?: boolean;
|
||||
}
|
||||
|
||||
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
|
||||
|
||||
function cacheKey(serverName: string, options: LoadToolMetadataOptions): string {
|
||||
const includeSchema = options.includeSchema !== false;
|
||||
const autoAuthorize = options.autoAuthorize !== false;
|
||||
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}`;
|
||||
}
|
||||
|
||||
export async function loadToolMetadata(
|
||||
runtime: Runtime,
|
||||
serverName: string,
|
||||
options: LoadToolMetadataOptions = {}
|
||||
): Promise<ToolMetadata[]> {
|
||||
const key = cacheKey(serverName, options);
|
||||
let cache = runtimeCache.get(runtime);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
runtimeCache.set(runtime, cache);
|
||||
}
|
||||
const existing = cache.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const includeSchema = options.includeSchema !== false;
|
||||
const autoAuthorize = options.autoAuthorize !== false;
|
||||
const promise = runtime
|
||||
.listTools(serverName, { includeSchema, autoAuthorize })
|
||||
.then((tools) => tools.map((tool) => buildToolMetadata(tool)))
|
||||
.catch((error) => {
|
||||
cache?.delete(key);
|
||||
throw error;
|
||||
});
|
||||
cache.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
export type { CommandSpec, ServerDefinition } from './config.js';
|
||||
export { loadServerDefinitions } from './config.js';
|
||||
export type { CallResult } from './result-utils.js';
|
||||
export { createCallResult } from './result-utils.js';
|
||||
export { createCallResult, wrapCallResult } from './result-utils.js';
|
||||
export type {
|
||||
CallOptions,
|
||||
ListToolsOptions,
|
||||
|
||||
@ -202,3 +202,7 @@ export function createCallResult<T = unknown>(raw: T): CallResult<T> {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapCallResult<T = unknown>(raw: T): { raw: T; callResult: CallResult<T> } {
|
||||
return { raw, callResult: createCallResult(raw) };
|
||||
}
|
||||
|
||||
@ -289,7 +289,7 @@ describe('CLI list classification', () => {
|
||||
expect(
|
||||
lines.some((line) => line.includes('Optional parameters hidden; run with --all-parameters to view all fields'))
|
||||
).toBe(false);
|
||||
expect(listToolsSpy).toHaveBeenCalledWith('calculator', { includeSchema: true });
|
||||
expect(listToolsSpy).toHaveBeenCalledWith('calculator', expect.objectContaining({ includeSchema: true }));
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
@ -316,7 +316,7 @@ describe('CLI list classification', () => {
|
||||
expect(
|
||||
lines.some((line) => line.includes('Optional parameters hidden; run with --all-parameters to view all fields'))
|
||||
).toBe(true);
|
||||
expect(listToolsSpy).toHaveBeenCalledWith('linear', { includeSchema: true });
|
||||
expect(listToolsSpy).toHaveBeenCalledWith('linear', expect.objectContaining({ includeSchema: true }));
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
@ -416,7 +416,7 @@ describe('CLI list classification', () => {
|
||||
expect(lines.some((line) => line.includes('limit?: number'))).toBe(true);
|
||||
expect(lines.some((line) => line.includes('orderBy?: "createdAt" | "updatedAt"'))).toBe(true);
|
||||
expect(lines.some((line) => line.includes('includeArchived?: boolean'))).toBe(true);
|
||||
expect(listToolsSpy).toHaveBeenCalledWith('linear', { includeSchema: true });
|
||||
expect(listToolsSpy).toHaveBeenCalledWith('linear', expect.objectContaining({ includeSchema: true }));
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
@ -528,7 +528,7 @@ describe('CLI list classification', () => {
|
||||
|
||||
expect(registerDefinition).toHaveBeenCalled();
|
||||
expect(definitions.get('mcp-example-com-mcp')).toBeDefined();
|
||||
expect(listTools).toHaveBeenCalledWith('mcp-example-com-mcp', { includeSchema: true });
|
||||
expect(listTools).toHaveBeenCalledWith('mcp-example-com-mcp', expect.objectContaining({ includeSchema: true }));
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
@ -554,7 +554,7 @@ describe('CLI list classification', () => {
|
||||
await handleList(runtime, ['linera']);
|
||||
|
||||
expect(getDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(listTools).toHaveBeenCalledWith('linear', { includeSchema: true });
|
||||
expect(listTools).toHaveBeenCalledWith('linear', expect.objectContaining({ includeSchema: true }));
|
||||
const messages = logSpy.mock.calls.map((call) => stripAnsi(call.join(' ')));
|
||||
expect(messages.some((line) => line.includes('Auto-corrected server name to linear'))).toBe(true);
|
||||
|
||||
|
||||
@ -2,39 +2,19 @@ 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 type { Runtime } 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' },
|
||||
};
|
||||
import { buildToolMetadata } from '../src/cli/generate/tools.js';
|
||||
import { integrationDefinition, listCommentsTool } from './fixtures/tool-fixtures.js';
|
||||
|
||||
function createRuntimeStub(): Runtime {
|
||||
return {
|
||||
listServers: () => ['integration'],
|
||||
getDefinitions: () => [sampleDefinition],
|
||||
getDefinition: () => sampleDefinition,
|
||||
getDefinitions: () => [integrationDefinition],
|
||||
getDefinition: () => integrationDefinition,
|
||||
registerDefinition: () => {},
|
||||
listTools: async () => [sampleTool],
|
||||
listTools: async () => [listCommentsTool],
|
||||
callTool: async () => ({}),
|
||||
listResources: async () => ({}),
|
||||
connect: async () => {
|
||||
@ -46,9 +26,9 @@ function createRuntimeStub(): Runtime {
|
||||
|
||||
describe('emit-ts templates', () => {
|
||||
it('renders type declarations with CallResult returns', () => {
|
||||
const docs = emitTsTestInternals.buildDocEntries('integration', [sampleTool], false);
|
||||
const docs = emitTsTestInternals.buildDocEntries('integration', [buildToolMetadata(listCommentsTool)], false);
|
||||
const metadata = {
|
||||
server: sampleDefinition,
|
||||
server: integrationDefinition,
|
||||
generatorLabel: 'mcporter@test',
|
||||
generatedAt: new Date('2025-11-07T00:00:00Z'),
|
||||
};
|
||||
@ -59,9 +39,9 @@ describe('emit-ts templates', () => {
|
||||
});
|
||||
|
||||
it('renders client module that wraps proxy calls', () => {
|
||||
const docs = emitTsTestInternals.buildDocEntries('integration', [sampleTool], true);
|
||||
const docs = emitTsTestInternals.buildDocEntries('integration', [buildToolMetadata(listCommentsTool)], true);
|
||||
const metadata = {
|
||||
server: sampleDefinition,
|
||||
server: integrationDefinition,
|
||||
generatorLabel: 'mcporter@test',
|
||||
generatedAt: new Date('2025-11-07T00:00:00Z'),
|
||||
};
|
||||
@ -72,7 +52,7 @@ describe('emit-ts templates', () => {
|
||||
typesImportPath: './integration-client',
|
||||
});
|
||||
expect(source).toContain('createIntegrationClient');
|
||||
expect(source).toContain('createCallResult');
|
||||
expect(source).toContain('wrapCallResult');
|
||||
expect(source).toContain('proxy.listComments');
|
||||
});
|
||||
});
|
||||
|
||||
36
tests/fixtures/tool-fixtures.ts
vendored
Normal file
36
tests/fixtures/tool-fixtures.ts
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
import type { ServerDefinition } from '../../src/config.js';
|
||||
import type { ServerToolInfo } from '../../src/runtime.js';
|
||||
|
||||
export const integrationDefinition: ServerDefinition = {
|
||||
name: 'integration',
|
||||
description: 'Integration test server',
|
||||
command: { kind: 'http', url: 'https://example.com/mcp' },
|
||||
transport: 'stdio',
|
||||
};
|
||||
|
||||
export const listCommentsTool: 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' },
|
||||
};
|
||||
|
||||
export const demoTool: ServerToolInfo = {
|
||||
name: 'demo_tool',
|
||||
description: 'Demo tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string', description: 'Value' },
|
||||
},
|
||||
required: ['value'],
|
||||
},
|
||||
outputSchema: { title: 'DemoResult' },
|
||||
};
|
||||
19
tests/generator-flag-parser.test.ts
Normal file
19
tests/generator-flag-parser.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractGeneratorFlags } from '../src/cli/generate/flag-parser.js';
|
||||
|
||||
describe('extractGeneratorFlags', () => {
|
||||
it('parses runtime and timeout flags', () => {
|
||||
const args = ['--runtime', 'bun', '--timeout', '4500', 'extra'];
|
||||
const common = extractGeneratorFlags(args);
|
||||
expect(common.runtime).toBe('bun');
|
||||
expect(common.timeout).toBe(4500);
|
||||
expect(args).toEqual(['extra']);
|
||||
});
|
||||
|
||||
it('handles include optional aliases when enabled', () => {
|
||||
const args = ['--include-optional', '--all-parameters'];
|
||||
const common = extractGeneratorFlags(args, { allowIncludeOptional: true });
|
||||
expect(common.includeOptional).toBe(true);
|
||||
expect(args).toEqual([]);
|
||||
});
|
||||
});
|
||||
42
tests/tool-cache.test.ts
Normal file
42
tests/tool-cache.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Runtime } from '../src/runtime.js';
|
||||
import { loadToolMetadata } from '../src/cli/tool-cache.js';
|
||||
import { demoTool } from './fixtures/tool-fixtures.js';
|
||||
|
||||
function createRuntimeStub(listToolsImpl: Runtime['listTools']): Runtime {
|
||||
return {
|
||||
listServers: () => [],
|
||||
getDefinitions: () => [],
|
||||
getDefinition: () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
registerDefinition: () => {},
|
||||
listTools: listToolsImpl,
|
||||
callTool: async () => ({}),
|
||||
listResources: async () => ({}),
|
||||
connect: async () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
close: async () => {},
|
||||
} as unknown as Runtime;
|
||||
}
|
||||
|
||||
describe('loadToolMetadata', () => {
|
||||
it('caches repeated calls per runtime/server/options', async () => {
|
||||
const listTools = vi.fn(async () => [demoTool]);
|
||||
const runtime = createRuntimeStub(listTools);
|
||||
const first = await loadToolMetadata(runtime, 'integration', { includeSchema: true });
|
||||
const second = await loadToolMetadata(runtime, 'integration', { includeSchema: true });
|
||||
expect(listTools).toHaveBeenCalledTimes(1);
|
||||
expect(first[0]?.tool.name).toBe('demo_tool');
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('differentiates cache entries by includeSchema flag', async () => {
|
||||
const listTools = vi.fn(async () => [demoTool]);
|
||||
const runtime = createRuntimeStub(listTools);
|
||||
await loadToolMetadata(runtime, 'integration', { includeSchema: true });
|
||||
await loadToolMetadata(runtime, 'integration', { includeSchema: false });
|
||||
expect(listTools).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user