From 35a93568f7475ace3603c7a6ca30e7bf22435738 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 25 Jul 2026 22:32:25 +0400 Subject: [PATCH 1/3] feat(setup): import credential-free remote MCP entries --- README.md | 17 +-- docs/cli.md | 4 +- docs/console-api.md | 4 +- src/cli/setup-client-entry-import.ts | 10 +- src/console/console-application-service.ts | 8 +- src/console/console-assets.ts | 10 +- src/setup/client-entry-import.ts | 109 ++++++++++++++++--- tests/client-entry-import.test.ts | 118 +++++++++++++++++++++ tests/console-application-service.test.ts | 34 ++++++ tests/console-server.test.ts | 55 ++++++++++ tests/readme-product-contract.test.ts | 6 +- tests/setup-command.test.ts | 45 ++++++++ 12 files changed, 382 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index fa1640ff..f3dbd40a 100644 --- a/README.md +++ b/README.md @@ -219,9 +219,9 @@ On macOS and Linux, `--local-command` accepts a bare executable such as `node` o For one reviewed local executable, use `local-stdio` for arguments and a working directory. For several named upstreams, profile-specific overrides, or other advanced topology, use the [Configuration reference](docs/config.md). Always keep subprocess arguments as arrays; Miftah does not need a shell command string. -## Reuse one existing local stdio MCP entry +## Reuse one existing MCP client entry -If you already have a local Claude Desktop, Claude Code, Cursor, or VS Code MCP entry, `miftah setup` can create a safe first Miftah configuration from one entry you explicitly choose. It does not scan or modify the source client file. +If you already have a local or remote Claude Desktop, Claude Code, Cursor, or VS Code MCP entry, `miftah setup` can create a safe first Miftah configuration from one entry you explicitly choose. It does not scan or modify the source client file. ```bash miftah setup posthog-work \ @@ -231,13 +231,16 @@ miftah setup posthog-work \ --client claude-desktop ``` -`--import-file` must be an absolute regular JSON file, and `--import-entry` is the exact entry name under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code). Miftah reads that one file through a bounded verified handle and leaves it byte-for-byte untouched. It imports only a finite **static launch grammar**: a literal local executable, an optional absolute working directory, and either an exact-version package-runner launch with a runner-specific safe prefix and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags. +`--import-file` must be an absolute regular JSON file, and `--import-entry` is the exact entry name under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code). Miftah reads that one file through a bounded verified handle and leaves it byte-for-byte untouched. It accepts one of two strict shapes: + +- a finite **static launch grammar** for local stdio: a literal local executable, an optional absolute working directory, and either an exact-version package-runner launch with a runner-specific safe prefix and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags; or +- a credential-free HTTPS remote entry: a `url` under `mcpServers` or VS Code `servers`, explicitly marked `type: "http"` or `"streamable-http"`. The URL cannot contain userinfo, a query, a fragment, or an opaque credential-shaped path segment. On Windows, the import path accepts only a direct absolute `.exe` or `.com` executable. Bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. -The importer rejects remote transports, `env`, headers, shell settings, unsupported fields, environment wrappers, inline code, opaque values or assignments, URL userinfo, unpinned package references, and credential-shaped arguments. It creates one `default` profile with a read-only policy and treats unknown tools as destructive until you deliberately configure a policy. It does not launch the imported executable or copy credentials; `--verify` is rejected because an imported entry has no reviewed provider adapter. It does not infer OAuth ownership from a command or URL. If the entry needs custom values, OAuth, or an API key, use advanced manual setup, then configure the upstream's documented flow and Miftah secret references separately. +The importer rejects `env`, headers, shell settings, unsupported fields, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, URL userinfo, opaque credential-shaped URL path segments, unpinned package references, and credential-shaped arguments. It creates one `default` profile with a read-only policy and treats unknown tools as destructive until you deliberately configure a policy. It does not launch the imported executable or copy credentials. For a credential-free HTTPS remote entry, it does not discover OAuth or call the remote endpoint. `--verify` is rejected because an imported entry has no reviewed provider adapter. It does not infer OAuth ownership from a command or URL. If the entry needs custom values, OAuth, an API key, headers, or an opaque endpoint token, use advanced manual setup, then configure the upstream's documented flow and Miftah secret references separately. -For a browser-first first run, `miftah dashboard` offers the same paste-only import path. The Console parses the pasted JSON only for that local request, does not persist or return it, and clears it from the page afterwards. +For a browser-first first run, `miftah dashboard` offers the same paste-only local and credential-free remote import path. The Console parses the pasted JSON only for that local request, does not persist or return it, and clears it from the page afterwards. ### What a multi-profile configuration contains @@ -281,13 +284,13 @@ The easiest first run is: miftah dashboard ``` -Without `--config`, `miftah dashboard` finds safe direct Miftah JSON configurations in `~/.config/miftah` and asks you to choose one. It does not scan Claude Desktop settings, running processes, or arbitrary folders. For true first-run onboarding, it uses `~/.config/miftah/miftah.json` by default and can create a known-preset configuration, a Native remote OAuth profile, or one explicitly pasted local stdio entry there; it never overwrites an existing file. Native OAuth first-run asks only for a configuration name, account profile, and exact remote HTTPS endpoint; it discovers the OAuth details before creating anything. Pass `--config ~/.config/miftah/github.json` when you want to open exactly one configuration and skip the selector. +Without `--config`, `miftah dashboard` finds safe direct Miftah JSON configurations in `~/.config/miftah` and asks you to choose one. It does not scan Claude Desktop settings, running processes, or arbitrary folders. For true first-run onboarding, it uses `~/.config/miftah/miftah.json` by default and can create a known-preset configuration, a Native remote OAuth profile, or one explicitly pasted local stdio or credential-free HTTPS remote entry there; it never overwrites an existing file. Native OAuth first-run asks only for a configuration name, account profile, and exact remote HTTPS endpoint; it discovers the OAuth details before creating anything. Pass `--config ~/.config/miftah/github.json` when you want to open exactly one configuration and skip the selector. The optional dashboard: 1. starts a foreground-only service on literal `127.0.0.1`; 2. opens the system browser and asks for the one-time bootstrap code printed in the terminal; -3. creates a first validated known-preset configuration, Native remote OAuth profile and connection, or explicitly pasted local stdio entry when the selected config path does not exist; native OAuth discovery completes before the configuration is written; +3. creates a first validated known-preset configuration, Native remote OAuth profile and connection, or explicitly pasted local stdio or credential-free HTTPS remote entry when the selected config path does not exist; native OAuth discovery completes before the configuration is written; 4. offers a separate **Connect** action that starts the reviewed system-browser authorization; 5. shows redacted connection and audit state; and 6. generates client JSON for you to review and copy. diff --git a/docs/cli.md b/docs/cli.md index d90660c1..3745d2ae 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ Documented command names, options, JSON success forms, and exit categories are c | `miftah validate --config ` | `--config` | `--config ` | Validates the JSON configuration without starting an upstream. Writes a JSON object with `ok`, `name`, and `profiles`. | | `miftah doctor --config ` | `--config` | `--config `, `--json` | Validates configuration and checks upstream readiness. Default output is a human-readable report; `--json` writes only the JSON report. A healthy or degraded report exits `0`; a failed report exits `1`. | | `miftah schema` | none | none | Writes the Miftah JSON Schema as pretty-printed JSON. | -| `miftah setup [name]` | none, or `--add-profile --config ` | The safe preset options accepted by `init`, except `--interactive`, plus `--verify`; `--import-file ` with `--import-entry `; or `--add-profile --config --profile --oauth-client-secrets-file ` for a reviewed provider-owned account | Opens the guided first-configuration flow. It asks for no secret value, validates the complete candidate, creates the output exclusively with owner-restricted permissions, and can print client JSON for manual review. For `local-stdio`, it collects literal argv elements, shows a no-secret review summary, and requires acknowledgement before saving; it never starts that generic local executable. For Google Search Console it can collect one or more named accounts and an explicit default. `--add-profile` adds one later account only when the selected existing configuration remains wholly inside a reviewed provider-adapter boundary. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile, or just the newly added account, after the write; it is rejected for a client-entry import because no reviewed adapter is inferred. The explicit import path accepts only one selected local stdio entry and never edits a client configuration file. | +| `miftah setup [name]` | none, or `--add-profile --config ` | The safe preset options accepted by `init`, except `--interactive`, plus `--verify`; `--import-file ` with `--import-entry `; or `--add-profile --config --profile --oauth-client-secrets-file ` for a reviewed provider-owned account | Opens the guided first-configuration flow. It asks for no secret value, validates the complete candidate, creates the output exclusively with owner-restricted permissions, and can print client JSON for manual review. For `local-stdio`, it collects literal argv elements, shows a no-secret review summary, and requires acknowledgement before saving; it never starts that generic local executable. For Google Search Console it can collect one or more named accounts and an explicit default. `--add-profile` adds one later account only when the selected existing configuration remains wholly inside a reviewed provider-adapter boundary. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile, or just the newly added account, after the write; it is rejected for a client-entry import because no reviewed adapter is inferred. The explicit import path accepts one selected local stdio or credential-free HTTPS remote entry and never edits a client configuration file. | | `miftah init [name]` | none | `--name `, `--preset `, `--output `, `--interactive`, `--client `, `--credential-env `, `--npm-package `, `--docker-image `, `--url `, `--header-name `, `--header-prefix `, `--oauth-client-secrets-file `, `--local-command `, repeated `--arg `, `--cwd `, `--accept-local-command` | Writes a strict catalog configuration with exclusive creation and can print client JSON snippets. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | | `miftah migrate-config --config ` | `--config` | `--config `, `--write` | Plans a supported configuration-format migration and writes a safe JSON report. It is dry-run by default. `--write` validates the candidate, makes an exact exclusive `.bak`, then uses a same-directory non-overwriting publication for a changed regular non-symlink source; it never resolves secrets or starts an upstream. | | `miftah connection add --config ` | `--config`, `--profile`, `--issuer`, `--client-registration` | `--connection `, `--upstream `, repeated `--scope `, `--write` | Plans a v3 OAuth binding by default. `--write` applies the reviewed candidate with a unique recovery backup and configured audit event. It never resolves credentials or starts an upstream. | @@ -72,7 +72,7 @@ miftah validate --config "$HOME/Miftah configs/work wrapper.json" `miftah setup --add-profile --config ` is the returning-user path for a reviewed provider-owned adapter. In a noninteractive invocation it requires `--profile` and `--oauth-client-secrets-file`; `--description`, `--make-default`, and `--verify` are optional. It first proves that the entire selected configuration matches one adapter and that every existing account has a literal absolute credential-file reference plus a distinct, canonical provider-state directory. It then atomically adds one new profile and another isolated state directory through that adapter contract. Miftah never reads the credential file or the upstream token cache. Unsupported, modified, mixed, or shared-state configurations fail closed with `PROVIDER_ACCOUNT_ADDITION_UNSUPPORTED`; an invalid credential-file path fails with `PROVIDER_ACCOUNT_INPUT_INVALID`. `--verify` checks the new account only. The durable default changes only when `--make-default` is present, so existing MCP clients still need a restart or new connection. -`miftah setup --import-file --import-entry ` is a separate no-secret flow, not a generic client migration. The source file must be an absolute regular non-symlink file. Miftah reads it through one bounded verified handle, requires an explicitly selected entry, and never changes the source. It accepts only a local `stdio` entry under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code) that fits its finite static launch grammar: a literal local executable, optional absolute working directory, and either an exact-version package-runner launch with only that runner's fixed safe prefix flags and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags. On Windows, the import path accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. It rejects remote entries, `env`, headers, shell settings, unknown fields, environment wrappers, inline code, opaque values or assignments, URL userinfo, unpinned package references, and credential-shaped arguments. It creates a read-only default profile with unknown tool risk set to destructive and does not launch the imported program. `--verify` is rejected before publication because no reviewed provider adapter is inferred. Use advanced manual setup when the existing entry does not fit this grammar; configure upstream credentials or OAuth separately through the upstream's documented path and Miftah secret references. +`miftah setup --import-file --import-entry ` is a separate no-secret flow, not a generic client migration. The source file must be an absolute regular non-symlink file. Miftah reads it through one bounded verified handle, requires an explicitly selected entry, and never changes the source. It accepts either a local `stdio` entry under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code) that fits its finite static launch grammar—literal executable, optional absolute working directory, and either an exact-version package-runner launch with only that runner's fixed safe prefix flags and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags—or one credential-free HTTPS remote entry. A remote import uses `url` under `mcpServers` or `servers` and must explicitly declare `type: "http"` or `"streamable-http"`. It requires HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments, and does not discover OAuth or call the remote endpoint. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. It rejects `env`, headers, shell settings, unknown fields, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, URL userinfo, opaque credential-shaped URL path segments, unpinned package references, and credential-shaped arguments. It creates a read-only default profile with unknown tool risk set to destructive and does not launch the imported program. `--verify` is rejected before publication because no reviewed provider adapter is inferred. Use advanced manual setup when the existing entry does not fit this grammar; configure upstream credentials or OAuth separately through the upstream's documented path and Miftah secret references. ### `migrate-config` diff --git a/docs/console-api.md b/docs/console-api.md index b5c1886d..88816de1 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -33,13 +33,13 @@ Browser sessions have a 15-minute idle limit and a one-hour absolute limit. Rest Every request must use the exact listener `Host`. Browser mutations, including bootstrap, must also use the exact listener `Origin`. Normal same-origin navigation and authenticated `GET` and `HEAD` requests may omit `Origin`, because browsers do not consistently attach it to read requests; hostile or duplicate Origin values are still rejected, cross-site cookies remain blocked by `SameSite=Strict`, and every mutation still requires exact Origin plus CSRF. Except for the bootstrap exchange and static application assets, every API endpoint requires the session cookie. JSON request bodies are capped at 64 KiB and must use `Content-Type: application/json`, except the client-entry onboarding endpoint has a bounded envelope limit of up to 136 KiB so it can carry one separately byte-limited 64 KiB pasted document after JSON escaping. An explicitly configured request-size cap remains authoritative. Headers are capped at 16 KiB. The process admits at most 240 trusted requests per minute and only eight bootstrap attempts per minute; excess requests receive `429` plus `Retry-After`. -`POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/test`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. `POST /api/v1/profile-readiness` accepts only a selected `profile` plus an optional `upstream`; the Console always selects an exact configured upstream when there is more than one. It can invoke only a provider-declared safe read-only check after audit and policy preflight, binds that operation to the browser request so a disconnected client cancels it, and never accepts a tool name or arguments. Native-OAuth first run accepts only non-secret configuration name, profile, description, exact resource/issuer, client-registration mode, and scopes. Reviewed-preset first run accepts only safe catalog metadata; for Google Search Console that can include strict named account objects containing a profile name, optional description, and client-secrets path, plus an explicit default when there is more than one account. The `local-stdio` preset additionally accepts one literal executable, a bounded literal argument array, optional native absolute working directory, optional credential-environment name, and a required `acceptLocalCommand: true` acknowledgement. It never uses a shell or starts that generic local executable during onboarding; shell/wrapper, URL, control, environment-reference, and credential-shaped values are rejected. On Windows, it accepts only a direct absolute `.exe` or `.com` binary, not a bare command or `.cmd`/`.bat` shim, so the direct argv path cannot fall back to a command processor. Client-entry first run accepts a bounded in-memory JSON document plus an explicit entry name; it accepts only a selected local stdio entry that fits the same finite static launch grammar as the CLI and never persists, returns, or audits the pasted document. On Windows, this import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. Token, password, cookie, raw secret contents, callback, environment wrappers, inline code, opaque values or assignments, and arbitrary extra fields are rejected. +`POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/test`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. `POST /api/v1/profile-readiness` accepts only a selected `profile` plus an optional `upstream`; the Console always selects an exact configured upstream when there is more than one. It can invoke only a provider-declared safe read-only check after audit and policy preflight, binds that operation to the browser request so a disconnected client cancels it, and never accepts a tool name or arguments. Native-OAuth first run accepts only non-secret configuration name, profile, description, exact resource/issuer, client-registration mode, and scopes. Reviewed-preset first run accepts only safe catalog metadata; for Google Search Console that can include strict named account objects containing a profile name, optional description, and client-secrets path, plus an explicit default when there is more than one account. The `local-stdio` preset additionally accepts one literal executable, a bounded literal argument array, optional native absolute working directory, optional credential-environment name, and a required `acceptLocalCommand: true` acknowledgement. It never uses a shell or starts that generic local executable during onboarding; shell/wrapper, URL, control, environment-reference, and credential-shaped values are rejected. On Windows, it accepts only a direct absolute `.exe` or `.com` binary, not a bare command or `.cmd`/`.bat` shim, so the direct argv path cannot fall back to a command processor. Client-entry first run accepts a bounded in-memory JSON document plus an explicit entry name; it accepts a selected local stdio entry that fits the finite static launch grammar or one credential-free HTTPS remote entry. A remote entry must be a `url` under `mcpServers` or `servers`, explicitly marked `type: "http"` or `"streamable-http"`; it must be HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments. The Console never persists, returns, or audits the pasted document, never copies `env` or headers, and does not discover OAuth or call the remote endpoint during import. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. Token, password, cookie, raw secret contents, callback, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, opaque credential-shaped URL path segments, and arbitrary extra fields are rejected. | Method and path | Purpose | | --- | --- | | `POST /api/v1/sessions` | Exchange the one-use bootstrap code for one browser session. | | `POST /api/v1/onboarding/preset` | Exclusively create the first validated configuration from a reviewed preset and safe connector metadata. Requires CSRF; GSC accepts only structured account names/descriptions/client-secrets paths and rejects raw credential values. `local-stdio` additionally requires an explicit acknowledgement and stores only a literal executable/argument array; onboarding does not launch it. | -| `POST /api/v1/onboarding/client-entry` | Exclusively create the first validated configuration from one explicitly selected local stdio client entry. Requires CSRF; the pasted document is in-memory only and only the shared static launch grammar is accepted. On Windows, it accepts only a direct absolute `.exe` or `.com` executable, not bare runners or `.cmd`/`.bat` shims. `env`, headers, shell settings, remote transports, environment wrappers, inline code, opaque values or assignments, unpinned package references, and credential-shaped arguments are rejected. | +| `POST /api/v1/onboarding/client-entry` | Exclusively create the first validated configuration from one explicitly selected local stdio or credential-free HTTPS remote client entry. Requires CSRF; the pasted document is in-memory only. Local imports use the shared static launch grammar; remote imports require a `url` plus explicit `type: "http"` or `"streamable-http"`, HTTPS without userinfo/query/fragment or opaque credential-shaped path segments, and never trigger OAuth discovery or an upstream call. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable, not bare runners or `.cmd`/`.bat` shims. `env`, headers, shell settings, unsupported remote transports, environment wrappers, inline code, opaque values or assignments, unpinned package references, and credential-shaped arguments are rejected. | | `POST /api/v1/onboarding/native-oauth` | Exclusively create the first validated v3 native-OAuth profile, upstream, and connection. Requires CSRF and refuses an existing file. | | `GET /api/v1/health` | Return safe config identity, Console audit health, and restart-required guidance. | | `GET /api/v1/config` | Return allowlisted configuration metadata only. | diff --git a/src/cli/setup-client-entry-import.ts b/src/cli/setup-client-entry-import.ts index 708e57fd..a6bf9f84 100644 --- a/src/cli/setup-client-entry-import.ts +++ b/src/cli/setup-client-entry-import.ts @@ -93,7 +93,7 @@ function writeClientHandoff(options: ClientEntryImportSetupOptions, result: Init } /** - * Publishes a safe Miftah configuration from one explicitly selected, local stdio client entry. + * Publishes a safe Miftah configuration from one explicitly selected local stdio or HTTPS remote client entry. * It never edits, launches, or otherwise trusts the source MCP client configuration. */ export async function runClientEntryImportSetup( @@ -134,7 +134,13 @@ export async function runClientEntryImportSetup( const result: InitCommandResult = { output: plan.path, config }; context.output.write(`Created ${plan.path}\n`); - context.output.write("Imported one local stdio MCP entry without copying credentials. Configure authentication separately when required.\n"); + if (config.upstream?.transport === "streamable-http") { + context.output.write( + "Imported one HTTPS remote MCP entry without copying credentials. Miftah did not discover OAuth or call the upstream. Configure authentication separately when required.\n" + ); + } else { + context.output.write("Imported one local stdio MCP entry without copying credentials. Configure authentication separately when required.\n"); + } writeClientHandoff(options, result, context); return result; } diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index cd549641..c504d98e 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -150,7 +150,7 @@ export interface ConsolePresetOnboardingReport { readonly actions: readonly string[]; } -/** Non-secret, explicitly selected local stdio MCP entry pasted into first-run Console setup. */ +/** Non-secret, explicitly selected local stdio or canonical HTTPS MCP entry pasted into first-run Console setup. */ export interface ConsoleClientEntryOnboardingRequest { readonly name: string; readonly entry: string; @@ -588,7 +588,11 @@ export class ConsoleApplicationService implements ConsoleControlApplication { name: config.name, defaultProfile: config.defaultProfile, profileCount: Object.keys(config.profiles).length, - actions: [`Created Miftah configuration '${config.name}' from one selected local stdio client entry.`] + actions: [ + config.upstream?.transport === "streamable-http" + ? `Created Miftah configuration '${config.name}' from one selected HTTPS remote client entry without OAuth discovery or an upstream call.` + : `Created Miftah configuration '${config.name}' from one selected local stdio client entry.` + ] }; } diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts index c284daad..560fb49c 100644 --- a/src/console/console-assets.ts +++ b/src/console/console-assets.ts @@ -125,16 +125,16 @@ const page = ` @@ -1059,7 +1059,7 @@ const script = `(() => { clientEntryOnboardingForm.addEventListener("submit", async (event) => { event.preventDefault(); const documentInput = clientEntryOnboardingForm.querySelector("textarea[name='document']"); - message("Importing one selected local stdio MCP entry…"); + message("Importing one selected MCP entry…"); try { const data = new FormData(clientEntryOnboardingForm); await api("/api/v1/onboarding/client-entry", { diff --git a/src/setup/client-entry-import.ts b/src/setup/client-entry-import.ts index 0cf46168..86ad1df7 100644 --- a/src/setup/client-entry-import.ts +++ b/src/setup/client-entry-import.ts @@ -1,5 +1,5 @@ import { isAbsolute } from "node:path"; -import { buildSafeStandardConfig } from "../config/presets.js"; +import { buildPresetConfig, buildSafeStandardConfig } from "../config/presets.js"; import type { MiftahConfig } from "../config/types.js"; import { validateConfig } from "../config/validate-config.js"; @@ -18,6 +18,9 @@ const staticFlag = /^--?[A-Za-z][A-Za-z0-9-]*$/u; const staticPackageSpecifier = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*@v?\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/u; const staticScriptPath = /^[A-Za-z0-9._~@%+\\/-]+$/u; const staticScriptExtension = /\.(?:cjs|cts|js|mjs|mts|php|pl|py|rb|ts)$/iu; +const remotePathSegment = /^[A-Za-z0-9._~-]+$/u; +/** A long opaque URL path component is commonly a capability token; do not copy it into Miftah configuration. */ +const opaqueRemotePathSegment = /^[A-Za-z0-9._~-]{24,}$/u; interface PackageRunnerGrammar { readonly prefixFlags: ReadonlySet; readonly mutuallyExclusivePrefixFlags?: ReadonlySet; @@ -84,6 +87,12 @@ interface ImportedStdioEntry { readonly cwd?: string; } +interface ImportedRemoteEntry { + readonly url: string; +} + +type ImportedClientEntry = ImportedStdioEntry | ImportedRemoteEntry; + function importError(message: string, reason: ClientEntryImportErrorReason = "invalid"): never { throw new ClientEntryImportError(message, reason); } @@ -122,6 +131,18 @@ function safeArgument(value: unknown): string { return value; } +function safeRemotePathSegment(value: string): boolean { + if (value.length === 0) return true; + try { + const decoded = decodeURIComponent(value); + return remotePathSegment.test(decoded) + && !credentialBearingArgument(decoded) + && !opaqueRemotePathSegment.test(decoded); + } catch { + return false; + } +} + function normalizeCredentialText(value: string): string { return value.replace(/([a-z0-9])([A-Z])/gu, "$1-$2"); } @@ -315,23 +336,79 @@ function selectedStdioEntry(value: unknown, container: ClientEntryContainer): Im return { command, args, cwd }; } -function safeImportedConfig(name: string, upstream: ImportedStdioEntry): MiftahConfig { +function selectedRemoteEntry(value: unknown): ImportedRemoteEntry { + if (!isRecord(value)) importError("The selected MCP entry must be an object."); + + if (Object.hasOwn(value, "env") || Object.hasOwn(value, "headers")) { + importError("Credential-bearing environment or header values cannot be imported. Configure a Miftah secret reference separately."); + } + if (Object.hasOwn(value, "shell")) { + importError("Shell execution settings cannot be imported. Use a canonical HTTPS MCP endpoint instead."); + } + for (const key of Object.keys(value)) { + if (!["type", "url"].includes(key)) { + importError("The selected MCP entry contains unsupported or credential-bearing fields."); + } + } + + const type = value.type; + if (type !== undefined && type !== "http" && type !== "streamable-http") { + importError("Only canonical HTTPS Streamable HTTP MCP entries can be imported in this setup flow."); + } + if (type === undefined) { + importError("Remote MCP entries must declare type 'http' or 'streamable-http' for this setup flow."); + } + + const url = safeText(value.url, "The selected remote MCP entry requires a literal HTTPS URL."); + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:" + || parsed.username + || parsed.password + || parsed.search + || parsed.hash + || !parsed.pathname.split("/").every(safeRemotePathSegment)) { + importError("The selected remote MCP entry requires an HTTPS URL without userinfo, query, fragment, or opaque credential-shaped path segments."); + } + } catch (error) { + if (error instanceof ClientEntryImportError) throw error; + importError("The selected remote MCP entry requires a valid HTTPS URL without userinfo, query, fragment, or opaque credential-shaped path segments."); + } + return { url }; +} + +function selectedClientEntry(value: unknown, container: ClientEntryContainer): ImportedClientEntry { + if (!isRecord(value)) importError("The selected MCP entry must be an object."); + const hasCommand = Object.hasOwn(value, "command"); + const hasUrl = Object.hasOwn(value, "url"); + if (hasCommand && hasUrl) { + importError("The selected MCP entry must describe either a local executable or one remote HTTPS URL, not both."); + } + return hasUrl ? selectedRemoteEntry(value) : selectedStdioEntry(value, container); +} + +function safeImportedConfig(name: string, upstream: ImportedClientEntry): MiftahConfig { try { - const baseline = buildSafeStandardConfig(name, { - transport: "stdio", - command: upstream.command, - args: [...upstream.args], - ...(upstream.cwd === undefined ? {} : { cwd: upstream.cwd }) - }); + const baseline = "command" in upstream + ? buildSafeStandardConfig(name, { + transport: "stdio", + command: upstream.command, + args: [...upstream.args], + ...(upstream.cwd === undefined ? {} : { cwd: upstream.cwd }) + }) + : buildPresetConfig(name, "streamable-http", { url: upstream.url }); + const serializedUpstream = "command" in upstream + ? { + transport: "stdio" as const, + command: upstream.command, + args: [...upstream.args], + ...(upstream.cwd === undefined ? {} : { cwd: upstream.cwd }) + } + : { transport: "streamable-http" as const, url: upstream.url }; return validateConfig({ ...baseline, description: `${name} imported from an existing MCP client entry`, - upstream: { - transport: "stdio", - command: upstream.command, - args: [...upstream.args], - ...(upstream.cwd === undefined ? {} : { cwd: upstream.cwd }) - }, + upstream: serializedUpstream, profiles: { default: { description: "Imported MCP entry; configure authentication separately when required.", @@ -350,11 +427,11 @@ function safeImportedConfig(name: string, upstream: ImportedStdioEntry): MiftahC } /** - * Converts one explicitly selected, non-secret local stdio entry into a safe-default Miftah configuration. + * Converts one explicitly selected, credential-free local stdio or canonical HTTPS remote entry into a safe-default Miftah configuration. * This function is pure: it does not read files, start a process, write configuration, or mutate a client. */ export function createImportedClientConfiguration(request: ImportedClientConfigurationRequest): MiftahConfig { const parsed = parseClientConfiguration(request.document); - const entry = selectedStdioEntry(selectEntry(request, parsed), parsed.container); + const entry = selectedClientEntry(selectEntry(request, parsed), parsed.container); return safeImportedConfig(request.configurationName, entry); } diff --git a/tests/client-entry-import.test.ts b/tests/client-entry-import.test.ts index 678c48c8..1da0f424 100644 --- a/tests/client-entry-import.test.ts +++ b/tests/client-entry-import.test.ts @@ -54,6 +54,124 @@ describe("client entry import", () => { }); }); + it("imports one selected canonical HTTPS remote entry without importing authentication", () => { + const config = createImportedClientConfiguration({ + configurationName: "remote-analytics", + document: JSON.stringify({ + mcpServers: { + analytics: { + type: "http", + url: "https://mcp.example.test/mcp" + } + } + }), + entry: "analytics" + }); + + expect(validateConfig(config)).toEqual(config); + expect(config).toMatchObject({ + version: "3", + name: "remote-analytics", + defaultProfile: "default", + upstream: { transport: "streamable-http", url: "https://mcp.example.test/mcp" }, + profiles: { default: { policy: "readonly" } }, + tooling: { unknownToolRisk: "destructive" } + }); + expect(config).not.toHaveProperty("oauth"); + }); + + it.each([ + { + label: "a typed mcpServers HTTP entry", + document: { + mcpServers: { remote: { type: "http", url: "https://mcp.example.test/mcp" } } + } + }, + { + label: "a typed VS Code HTTP entry", + document: { + servers: { remote: { type: "http", url: "https://mcp.example.test/mcp" } } + } + }, + { + label: "a typed Streamable HTTP entry", + document: { + mcpServers: { remote: { type: "streamable-http", url: "https://mcp.example.test/mcp" } } + } + } + ])("imports $label through the same credential-free remote boundary", ({ document }) => { + const config = createImportedClientConfiguration({ + configurationName: "remote-import", + document: JSON.stringify(document), + entry: "remote" + }); + + expect(config.upstream).toEqual({ transport: "streamable-http", url: "https://mcp.example.test/mcp" }); + expect(config.profiles.default).toMatchObject({ policy: "readonly" }); + }); + + it.each([ + { type: "http", url: "https://user:gF7r2Uv9Qx@example.test/mcp" }, + { type: "http", url: "https://example.test/mcp?token=gF7r2Uv9Qx" }, + { type: "http", url: "https://example.test/mcp#token" }, + { type: "http", url: "http://example.test/mcp" }, + { type: "http", url: "https://mcp.example.test/mcp", headers: { Authorization: "Bearer gF7r2Uv9Qx" } }, + { type: "http", url: "https://mcp.example.test/mcp", env: { MCP_TOKEN: "gF7r2Uv9Qx" } }, + { type: "sse", url: "https://mcp.example.test/mcp" }, + { type: "http", command: "node", url: "https://mcp.example.test/mcp" }, + { type: "http", url: "https://mcp.example.test/mcp", metadata: "safe-looking-but-unsupported" } + ])("rejects a non-canonical or credential-bearing remote entry without echoing values", (entry) => { + const secret = "gF7r2Uv9Qx"; + let error: unknown; + try { + createImportedClientConfiguration({ + configurationName: "unsafe-remote", + document: JSON.stringify({ mcpServers: { remote: entry } }), + entry: "remote" + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ClientEntryImportError); + expect(error).toHaveProperty("message", expect.not.stringContaining(secret)); + }); + + it.each([ + "gF7r2Uv9QxL5nK3pR8sT6wY4zA1bC0dE", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.gF7r2Uv9QxL5nK3p", + "abcdefghijklmnopqrstuvwxyzabcdef", + "12345678901234567890123456789012" + ])("rejects an opaque credential-shaped endpoint path segment without echoing it", (secret) => { + let error: unknown; + try { + createImportedClientConfiguration({ + configurationName: "path-credential", + document: JSON.stringify({ + mcpServers: { + remote: { type: "http", url: `https://mcp.example.test/api/mcp/s/${secret}/mcp` } + } + }), + entry: "remote" + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ClientEntryImportError); + expect(error).toHaveProperty("message", expect.not.stringContaining(secret)); + }); + + it.each(["mcpServers", "servers"] as const)("requires an explicit HTTP transport type for a %s remote entry", (container) => { + expect(() => createImportedClientConfiguration({ + configurationName: "missing-vscode-transport", + document: JSON.stringify({ + [container]: { remote: { url: "https://mcp.example.test/mcp" } } + }), + entry: "remote" + })).toThrow("must declare type 'http' or 'streamable-http'"); + }); + it("imports an exact-version pnpm dlx launch without treating dlx as the package", () => { const request = { configurationName: "posthog-work", diff --git a/tests/console-application-service.test.ts b/tests/console-application-service.test.ts index b215b937..dfd26fea 100644 --- a/tests/console-application-service.test.ts +++ b/tests/console-application-service.test.ts @@ -525,6 +525,40 @@ describe("Console application service", () => { }); }); + it("creates a first-run configuration from one explicitly selected HTTPS remote client entry without OAuth discovery", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-remote-")); + temporaryDirectories.push(root); + const privateParent = await createPrivateConsoleDirectory(root); + const configPath = join(privateParent, "miftah", "miftah.json"); + const service = new ConsoleApplicationService(configPath); + + await expect(service.onboardClientEntry({ + name: "remote-analytics", + entry: "analytics", + document: JSON.stringify({ + servers: { + analytics: { type: "http", url: "https://mcp.example.test/mcp" } + } + }) + })).resolves.toEqual({ + changed: true, + write: true, + name: "remote-analytics", + defaultProfile: "default", + profileCount: 1, + actions: [ + "Created Miftah configuration 'remote-analytics' from one selected HTTPS remote client entry without OAuth discovery or an upstream call." + ] + }); + + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + name: "remote-analytics", + upstream: { transport: "streamable-http", url: "https://mcp.example.test/mcp" }, + profiles: { default: { policy: "readonly" } }, + tooling: { unknownToolRisk: "destructive" } + }); + }); + it("gives a bounded advanced-manual recovery code for an entry outside the static launch grammar", async () => { const root = await mkdtemp(join(tmpdir(), "miftah-console-client-entry-static-launch-")); temporaryDirectories.push(root); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index 8fb0e6e9..4dc44f27 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -744,7 +744,12 @@ describe("local Console control server", () => { expect(html).toContain('id="preset-onboarding-view"'); expect(html).toContain('id="client-entry-onboarding-view"'); expect(html).toContain('id="client-entry-onboarding-form"'); + expect(html).toContain("Import one MCP client entry"); + expect(html).not.toContain("Import one local stdio MCP"); expect(html).toContain("static launch grammar"); + expect(html).toContain("credential-free HTTPS remote entry"); + expect(html).toContain("explicitly marked type:"); + expect(html).toContain("Remote import does not discover OAuth or call the endpoint."); expect(html).toContain("advanced manual setup"); expect(html).toContain('id="gsc-account-list"'); expect(html).toContain('id="gsc-default-profile"'); @@ -1401,6 +1406,56 @@ describe("local Console control server", () => { } }); + it("imports one selected HTTPS remote client entry through the same CSRF-protected no-secret endpoint", async () => { + const server = await startConsoleServer(configPath, { + bootstrapCredential: "test-only-bootstrap-credential", + allowMissingConfig: true + }); + + try { + const session = await bootstrapSession(server); + const response = await fetch(new URL("/api/v1/onboarding/client-entry", server.url), { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ + name: "remote-analytics", + entry: "analytics", + document: JSON.stringify({ + mcpServers: { + analytics: { type: "http", url: "https://mcp.example.test/mcp" } + } + }) + }) + }); + + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ + data: { + changed: true, + write: true, + name: "remote-analytics", + defaultProfile: "default", + profileCount: 1, + actions: [ + "Created Miftah configuration 'remote-analytics' from one selected HTTPS remote client entry without OAuth discovery or an upstream call." + ] + } + }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + upstream: { transport: "streamable-http", url: "https://mcp.example.test/mcp" }, + profiles: { default: { policy: "readonly" } }, + tooling: { unknownToolRisk: "destructive" } + }); + } finally { + await server.close(); + } + }); + it("accepts a maximum-sized client document even when its JSON request envelope is larger than 64 KiB", async () => { const server = await startConsoleServer(configPath, { bootstrapCredential: "test-only-bootstrap-credential", diff --git a/tests/readme-product-contract.test.ts b/tests/readme-product-contract.test.ts index bc6f7305..9e5b1bce 100644 --- a/tests/readme-product-contract.test.ts +++ b/tests/readme-product-contract.test.ts @@ -123,12 +123,14 @@ describe("product README", () => { expect(readme).toContain("uses `~/.config/miftah/miftah.json` by default"); }); - it("documents the explicit no-secret local stdio client-entry import path", () => { - expect(readme).toContain("## Reuse one existing local stdio MCP entry"); + it("documents the explicit no-secret local and remote client-entry import paths", () => { + expect(readme).toContain("## Reuse one existing MCP client entry"); expect(readme).toContain("--import-file"); expect(readme).toContain("--import-entry"); expect(readme).toContain("does not scan or modify the source client file"); expect(readme).toContain("does not infer OAuth ownership"); + expect(readme).toContain("credential-free HTTPS remote entry"); + expect(readme).toContain("does not discover OAuth or call the remote endpoint"); expect(readme).toContain("`--verify` is rejected because an imported entry has no reviewed provider adapter"); expect(readme).toContain("static launch grammar"); expect(readme).toContain("exact version"); diff --git a/tests/setup-command.test.ts b/tests/setup-command.test.ts index be97bccf..eb8af9a7 100644 --- a/tests/setup-command.test.ts +++ b/tests/setup-command.test.ts @@ -449,6 +449,51 @@ describe("setup command", () => { expect(await readFile(source, "utf8")).toBe(document); }); + it("imports one explicitly selected remote HTTPS entry without OAuth discovery or an upstream call", async () => { + const source = resolve(outputRoot, "cursor-remote.json"); + const output = resolve(outputRoot, "remote-analytics.json"); + const document = JSON.stringify({ + mcpServers: { + analytics: { type: "http", url: "https://mcp.example.test/mcp" } + } + }); + await mkdir(outputRoot, { recursive: true, mode: 0o700 }); + await writeFile(source, document, { mode: 0o600 }); + const streams = createStreams(); + const nativeOAuthFetch = vi.fn(async (): Promise => { + throw new Error("remote client-entry import must not discover OAuth"); + }); + + const result = await runSetupCommand({ + name: "remote-analytics", + output, + importFile: source, + importEntry: "analytics" + } as Parameters[0], { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + }, + nativeOAuthFetch + }); + streams.input.end(); + + expect(result).toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + expect(validateConfig(JSON.parse(await readFile(output, "utf8")))).toMatchObject({ + name: "remote-analytics", + upstream: { transport: "streamable-http", url: "https://mcp.example.test/mcp" }, + profiles: { default: { policy: "readonly" } } + }); + expect(await readFile(source, "utf8")).toBe(document); + expect(nativeOAuthFetch).not.toHaveBeenCalled(); + expect(streams.transcript.contents).toContain( + "Imported one HTTPS remote MCP entry without copying credentials. Miftah did not discover OAuth or call the upstream." + ); + }); + it("accepts a remote MCP source without making native OAuth assumptions", async () => { const output = resolve(outputRoot, "remote-tools.json"); const streams = createStreams(); From 760854d248ff93406834480528ac0491b6ff1428 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 25 Jul 2026 22:46:43 +0400 Subject: [PATCH 2/3] refactor(setup): preserve imported preset upstreams --- src/setup/client-entry-import.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/setup/client-entry-import.ts b/src/setup/client-entry-import.ts index 86ad1df7..5b3df090 100644 --- a/src/setup/client-entry-import.ts +++ b/src/setup/client-entry-import.ts @@ -397,14 +397,10 @@ function safeImportedConfig(name: string, upstream: ImportedClientEntry): Miftah ...(upstream.cwd === undefined ? {} : { cwd: upstream.cwd }) }) : buildPresetConfig(name, "streamable-http", { url: upstream.url }); - const serializedUpstream = "command" in upstream - ? { - transport: "stdio" as const, - command: upstream.command, - args: [...upstream.args], - ...(upstream.cwd === undefined ? {} : { cwd: upstream.cwd }) - } - : { transport: "streamable-http" as const, url: upstream.url }; + const serializedUpstream = baseline.upstream; + if (serializedUpstream === undefined) { + importError("The imported MCP entry could not be converted into a valid Miftah configuration."); + } return validateConfig({ ...baseline, description: `${name} imported from an existing MCP client entry`, From 5b608c85d4d1ce239be72b66a283198f824e223a Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 25 Jul 2026 22:58:20 +0400 Subject: [PATCH 3/3] fix(setup): preserve credential-free import boundary --- src/setup/client-entry-import.ts | 19 +++++++--- ...ient-entry-import-builder-boundary.test.ts | 36 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 tests/client-entry-import-builder-boundary.test.ts diff --git a/src/setup/client-entry-import.ts b/src/setup/client-entry-import.ts index 5b3df090..f0e100ec 100644 --- a/src/setup/client-entry-import.ts +++ b/src/setup/client-entry-import.ts @@ -387,6 +387,19 @@ function selectedClientEntry(value: unknown, container: ClientEntryContainer): I return hasUrl ? selectedRemoteEntry(value) : selectedStdioEntry(value, container); } +function credentialFreeBaselineUpstream(baseline: MiftahConfig): NonNullable { + const upstream = baseline.upstream; + if ( + upstream === undefined + || Object.hasOwn(upstream, "env") + || Object.hasOwn(upstream, "headers") + || upstream.trustToolAnnotations === true + ) { + importError("The imported MCP entry could not be converted into a credential-free Miftah configuration."); + } + return upstream; +} + function safeImportedConfig(name: string, upstream: ImportedClientEntry): MiftahConfig { try { const baseline = "command" in upstream @@ -397,10 +410,7 @@ function safeImportedConfig(name: string, upstream: ImportedClientEntry): Miftah ...(upstream.cwd === undefined ? {} : { cwd: upstream.cwd }) }) : buildPresetConfig(name, "streamable-http", { url: upstream.url }); - const serializedUpstream = baseline.upstream; - if (serializedUpstream === undefined) { - importError("The imported MCP entry could not be converted into a valid Miftah configuration."); - } + const serializedUpstream = credentialFreeBaselineUpstream(baseline); return validateConfig({ ...baseline, description: `${name} imported from an existing MCP client entry`, @@ -415,6 +425,7 @@ function safeImportedConfig(name: string, upstream: ImportedClientEntry): Miftah tooling: { ...baseline.tooling, unknownToolRisk: "destructive" } }); } catch (error) { + if (error instanceof ClientEntryImportError) throw error; if (error instanceof Error) { importError("The imported MCP entry could not be converted into a valid Miftah configuration."); } diff --git a/tests/client-entry-import-builder-boundary.test.ts b/tests/client-entry-import-builder-boundary.test.ts new file mode 100644 index 00000000..319f42aa --- /dev/null +++ b/tests/client-entry-import-builder-boundary.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../src/config/presets.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildPresetConfig(...args: Parameters) { + const config = actual.buildPresetConfig(...args); + return { + ...config, + upstream: config.upstream === undefined + ? undefined + : { ...config.upstream, headers: { "x-test": "static" } } + }; + } + }; +}); + +import { + ClientEntryImportError, + createImportedClientConfiguration +} from "../src/setup/client-entry-import.js"; + +describe("client entry import builder boundary", () => { + it("rejects credential-bearing upstream fields introduced by a future preset builder", () => { + expect(() => createImportedClientConfiguration({ + configurationName: "remote-analytics", + document: JSON.stringify({ + mcpServers: { + analytics: { type: "http", url: "https://mcp.example.test/mcp" } + } + }), + entry: "analytics" + })).toThrow(ClientEntryImportError); + }); +});