pi-mcp-extension 1.11.4 → 1.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/auth-challenge.ts +25 -0
- package/src/authorize-flow.ts +9 -2
- package/src/config.ts +3 -1
- package/src/connect-policy.ts +55 -0
- package/src/errors.ts +1 -1
- package/src/index.ts +7 -2
- package/src/server-manager.ts +1 -7
- package/src/tool-bridge.ts +32 -18
- package/src/transport-factory.ts +16 -3
package/README.md
CHANGED
|
@@ -163,7 +163,7 @@ For Pi 1.x compatibility, server entries also accept `type` as an alias for `tra
|
|
|
163
163
|
| `cwd` | `string` | session working directory | Working directory for a `stdio` server (rejected on `streamable-http` / `sse`). A relative path resolves against the session working directory and a leading `~` expands to your home directory |
|
|
164
164
|
| `env` | `Record<string, string>` | — | Extra environment variables for the child process (literal values, no `${VAR}` interpolation) |
|
|
165
165
|
| `url` | `string` | — | Server URL (**required** for `streamable-http` and `sse`) |
|
|
166
|
-
| `headers` | `Record<string, string>` | — | Static headers sent with `streamable-http` and `sse` requests |
|
|
166
|
+
| `headers` | `Record<string, string>` | — | Static headers sent with `streamable-http` and `sse` requests. When `auth` is set, OAuth owns `Authorization`, so an `Authorization` entry here is ignored (other headers still apply) |
|
|
167
167
|
| `auth` (or `oauth`) | `object` | — | OAuth 2.1 configuration described below and in [OAUTH.md](./OAUTH.md) (`streamable-http` / `sse` only) |
|
|
168
168
|
| `lifecycle` | `"eager" \| "lazy"` | `"lazy"` (`"eager"` for `pi.registerMcpServer()` unless `enabled: false`) | `eager` = auto-start on `session_start`, `lazy` = connect on request via `mcp_connect`, `/mcp:start`, or `/mcp:auth` |
|
|
169
169
|
| `enabled` | `boolean` | `true` | Pi 1.3.0 switch: `false` keeps the entry configured but out of the eager set (normalized to `lifecycle: "lazy"`, even when `lifecycle: "eager"` is also set) |
|
package/package.json
CHANGED
package/src/auth-challenge.ts
CHANGED
|
@@ -20,6 +20,31 @@ export interface ManualAuthChallengeOptions {
|
|
|
20
20
|
signal?: AbortSignal | undefined;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Drop any `Authorization` header (any casing) from a configured static header set.
|
|
25
|
+
*
|
|
26
|
+
* WHY: `Authorization` is the one header an OAuth server owns. The MCP SDK builds it from the
|
|
27
|
+
* OAuth provider's access token and then spreads `requestInit.headers` on top, so an exact-case
|
|
28
|
+
* `Authorization` key silently replaces the token, while `authorization` in any other casing
|
|
29
|
+
* survives as a second entry that `new Headers()` comma-joins into one malformed value. The same
|
|
30
|
+
* static value would also ride along on the OAuth discovery, registration, and token requests
|
|
31
|
+
* made through `createHeadersFetch`, leaking a resource-server credential to the authorization
|
|
32
|
+
* server. Drop it (and only it) whenever OAuth is configured; every other static header
|
|
33
|
+
* (tenant keys, gateways) still applies.
|
|
34
|
+
*/
|
|
35
|
+
export function stripAuthorizationHeader(
|
|
36
|
+
headers: Record<string, string> | undefined,
|
|
37
|
+
): Record<string, string> | undefined {
|
|
38
|
+
if (!headers) return undefined;
|
|
39
|
+
const kept = Object.entries(headers).filter(([name]) => !isAuthorizationHeader(name));
|
|
40
|
+
return kept.length > 0 ? Object.fromEntries(kept) : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Case-insensitive test for the single header an OAuth server owns. */
|
|
44
|
+
export function isAuthorizationHeader(name: string): boolean {
|
|
45
|
+
return name.toLowerCase() === "authorization";
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
/**
|
|
24
49
|
* Wrap a fetch implementation so configured server headers are attached as
|
|
25
50
|
* defaults on every request (e.g. during SDK OAuth metadata discovery and
|
package/src/authorize-flow.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { isSafeAuthorizationUrl } from "./callback-url.js";
|
|
|
9
9
|
import {
|
|
10
10
|
discoverManualAuthChallenge,
|
|
11
11
|
createHeadersFetch,
|
|
12
|
+
stripAuthorizationHeader,
|
|
12
13
|
type ManualAuthChallenge,
|
|
13
14
|
} from "./auth-challenge.js";
|
|
14
15
|
import {
|
|
@@ -629,16 +630,22 @@ export async function authorize(
|
|
|
629
630
|
// if empty, which the callback server then rejects as a CSRF error.
|
|
630
631
|
authProvider.setState(oauthState);
|
|
631
632
|
|
|
633
|
+
// WHY: `Authorization` belongs to the OAuth flow here. Filter the same set the transport
|
|
634
|
+
// uses, so this flow's own challenge probe and its SDK `fetchFn` (metadata, registration,
|
|
635
|
+
// token) never carry a static credential that was configured for the MCP server — the
|
|
636
|
+
// authorization server is usually a different origin.
|
|
637
|
+
const staticHeaders = stripAuthorizationHeader(config.headers);
|
|
638
|
+
|
|
632
639
|
const authChallenge = await discoverChallengeWithFallback({
|
|
633
640
|
serverName,
|
|
634
641
|
serverUrl,
|
|
635
|
-
headers:
|
|
642
|
+
headers: staticHeaders,
|
|
636
643
|
signal,
|
|
637
644
|
});
|
|
638
645
|
assertActive();
|
|
639
646
|
|
|
640
647
|
const callbackPromise = registerCallbackPromise(callbackServer, oauthState);
|
|
641
|
-
const customFetch =
|
|
648
|
+
const customFetch = staticHeaders ? createHeadersFetch(staticHeaders) : undefined;
|
|
642
649
|
|
|
643
650
|
const authResult = await runInitialSdkAuth(
|
|
644
651
|
authProvider,
|
package/src/config.ts
CHANGED
|
@@ -187,7 +187,9 @@ export const ServerConfigSchema = z.preprocess(
|
|
|
187
187
|
/**
|
|
188
188
|
* Static HTTP headers to include with every request (streamable-http / sse only).
|
|
189
189
|
* Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
|
|
190
|
-
* For OAuth2, use the "auth" field instead
|
|
190
|
+
* For OAuth2, use the "auth" field instead: when "auth" is set, OAuth owns the
|
|
191
|
+
* Authorization header and any Authorization entry here is ignored (other headers
|
|
192
|
+
* still apply).
|
|
191
193
|
*/
|
|
192
194
|
headers: z.record(z.string(), z.string()).optional(),
|
|
193
195
|
/**
|
package/src/connect-policy.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Owns:
|
|
5
5
|
* - Pure OAuth 401/403 connect and tool error detection (`isOAuthUnauthorizedError`)
|
|
6
|
+
* - Pure SDK request-timeout detection (`isSdkTimeoutError`) and cancellation detection
|
|
7
|
+
* (`isCancellationError`)
|
|
6
8
|
* - Fixed retry backoff schedule (`RETRY_DELAYS_MS`)
|
|
7
9
|
* - Connect-failure classification into retry, auth-required, fast-fail, give-up, or cancelled (`classifyConnectFailure`)
|
|
8
10
|
*/
|
|
@@ -138,6 +140,59 @@ export function isOAuthUnauthorizedError(error: unknown, seen = new Set<unknown>
|
|
|
138
140
|
/** Fixed retry delay schedule — predictable, no jitter math needed. */
|
|
139
141
|
export const RETRY_DELAYS_MS = [1000, 3000, 5000, 10000, 30000] as const;
|
|
140
142
|
|
|
143
|
+
/** Message text of an error-ish value, preferring a real `message` field over `String()`. */
|
|
144
|
+
function errorMessageOf(err: unknown): string {
|
|
145
|
+
// WHY: transports reject with plain `{ code, message, data }` objects too, where
|
|
146
|
+
// `String(err)` is "[object Object]" and the message would be lost.
|
|
147
|
+
const message = (err as { message?: unknown } | null)?.message;
|
|
148
|
+
return typeof message === "string" ? message : String(err);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether `err` is the MCP SDK's own request-timeout rejection.
|
|
153
|
+
*
|
|
154
|
+
* The SDK rejects a timed-out `initialize`, `tools/list`, or `tools/call` with **its**
|
|
155
|
+
* `McpError` carrying the numeric JSON-RPC code `ErrorCode.RequestTimeout` (-32001) —
|
|
156
|
+
* a different class from pi-mcp's own {@link McpError}, so an `instanceof McpError`
|
|
157
|
+
* check here only excludes codes this extension already decided on. Any layer that
|
|
158
|
+
* re-wraps an SDK error must keep the timeout distinction: `classifyConnectFailure`
|
|
159
|
+
* fast-fails an error coded `"protocol"`, which would turn a transient discovery
|
|
160
|
+
* timeout into a first-attempt failure with no retries.
|
|
161
|
+
*/
|
|
162
|
+
export function isSdkTimeoutError(err: unknown): boolean {
|
|
163
|
+
// WHY: our own McpError already carries a deliberate code; never re-derive it from
|
|
164
|
+
// the message (e.g. "Connection timed out after 5000ms" is already code "timeout").
|
|
165
|
+
if (err instanceof McpError) return false;
|
|
166
|
+
const code = (err as { code?: unknown } | null)?.code;
|
|
167
|
+
// WHY: a negative numeric code is a JSON-RPC error code, and a server error response
|
|
168
|
+
// arrives with its own (e.g. ErrorCode.InternalError = -32603) plus a message that may
|
|
169
|
+
// well say "timed out". Only the SDK's own RequestTimeout means the client gave up on
|
|
170
|
+
// the server, so no other negative code may fall through to the message heuristic.
|
|
171
|
+
if (typeof code === "number" && code < 0) return code === -32001;
|
|
172
|
+
return /Request timed out|timed out/i.test(errorMessageOf(err));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether `err`/`signal` are our own cancellation rather than a failure of the server.
|
|
177
|
+
*
|
|
178
|
+
* The SDK's `Protocol.request` rejects an in-flight request on abort as
|
|
179
|
+
* `McpError(ErrorCode.RequestTimeout, String(signal.reason))` — carrying the *timeout* code
|
|
180
|
+
* for a caller cancellation (`cancel()` in the SDK is shared by the timeout handler and the
|
|
181
|
+
* abort listener). Anything that classifies an SDK error must therefore test cancellation
|
|
182
|
+
* first, or a cancelled tool call is reported as a server timeout.
|
|
183
|
+
*/
|
|
184
|
+
export function isCancellationError(err: unknown, signal?: AbortSignal | undefined): boolean {
|
|
185
|
+
// WHY: the caller's own abort is authoritative — it survives the SDK rewrapping the reason.
|
|
186
|
+
if (signal?.aborted || err instanceof AuthCancelledError) return true;
|
|
187
|
+
if (!(err instanceof Error)) return false;
|
|
188
|
+
// WHY: fallback for an abort-shaped rejection that arrives without our signal — the SDK's
|
|
189
|
+
// abort listener sets `signal.aborted` before rejecting, so this leg only matters when the
|
|
190
|
+
// rejection came from a signal we were never handed (or a transport-level abort). Still
|
|
191
|
+
// narrow on purpose: only a -32001 carrier is inspected, so a JSON-RPC error response whose
|
|
192
|
+
// message merely mentions "aborted" keeps its own class.
|
|
193
|
+
return err.name === "AbortError" || ((err as { code?: unknown }).code === -32001 && /abort/i.test(err.message));
|
|
194
|
+
}
|
|
195
|
+
|
|
141
196
|
export type TransportType = ServerConfig["transport"];
|
|
142
197
|
|
|
143
198
|
export type ConnectFailureAction =
|
package/src/errors.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type McpErrorCode =
|
|
|
9
9
|
| "connection" // Transport/connection failed
|
|
10
10
|
| "auth" // Authentication/authorization failed
|
|
11
11
|
| "timeout" // Request or connection timed out
|
|
12
|
-
| "protocol" // JSON-RPC protocol violation (server error response,
|
|
12
|
+
| "protocol" // JSON-RPC protocol violation (a server error response, a malformed payload, or a call the caller cancelled)
|
|
13
13
|
| "tool"; // Tool execution error (isError: true from server)
|
|
14
14
|
|
|
15
15
|
export class McpError extends Error {
|
package/src/index.ts
CHANGED
|
@@ -406,10 +406,15 @@ function formatMcpAuthError(err: unknown, hasUI: boolean): string | undefined {
|
|
|
406
406
|
return undefined;
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
-
function
|
|
409
|
+
function formatMcpConnectFailureMessage(err: unknown): string {
|
|
410
410
|
if (err instanceof McpError && err.code === "protocol") {
|
|
411
411
|
return "MCP tool discovery failed. Check the server and retry.";
|
|
412
412
|
}
|
|
413
|
+
// WHY: a request timeout means the server was reachable but too slow, so pointing at the
|
|
414
|
+
// configuration would send the user down the wrong path (#209).
|
|
415
|
+
if (err instanceof McpError && err.code === "timeout") {
|
|
416
|
+
return "MCP server did not respond in time. Check that it is running and retry.";
|
|
417
|
+
}
|
|
413
418
|
return "MCP connection failed. Check the server configuration and retry.";
|
|
414
419
|
}
|
|
415
420
|
|
|
@@ -420,7 +425,7 @@ function formatMcpConnectError(err: unknown, options: McpConnectErrorOptions): s
|
|
|
420
425
|
if (!options.serverExists) {
|
|
421
426
|
return "Unknown MCP server name. Use mcp_status to list configured servers.";
|
|
422
427
|
}
|
|
423
|
-
return formatMcpAuthError(err, options.hasUI) ??
|
|
428
|
+
return formatMcpAuthError(err, options.hasUI) ?? formatMcpConnectFailureMessage(err);
|
|
424
429
|
}
|
|
425
430
|
|
|
426
431
|
function registerManagementTools(
|
package/src/server-manager.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { createTransport } from "./transport-factory.js";
|
|
|
38
38
|
import {
|
|
39
39
|
classifyConnectFailure,
|
|
40
40
|
isOAuthUnauthorizedError,
|
|
41
|
+
isSdkTimeoutError,
|
|
41
42
|
type ConnectFailureAction,
|
|
42
43
|
} from "./connect-policy.js";
|
|
43
44
|
|
|
@@ -182,13 +183,6 @@ function createConnectRaceGuard(
|
|
|
182
183
|
return { outcome, dispose };
|
|
183
184
|
}
|
|
184
185
|
|
|
185
|
-
function isSdkTimeoutError(err: unknown): boolean {
|
|
186
|
-
if (err instanceof McpError) return false;
|
|
187
|
-
if ((err as { code?: unknown } | null)?.code === -32001) return true;
|
|
188
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
189
|
-
return /Request timed out|timed out/i.test(message);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
186
|
function rethrowNormalizedConnectError(
|
|
193
187
|
err: unknown,
|
|
194
188
|
signal: AbortSignal,
|
package/src/tool-bridge.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
|
18
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
19
|
import { McpError, AuthCancelledError } from "./errors.js";
|
|
20
20
|
import type { Settings } from "./config.js";
|
|
21
|
-
import { isOAuthUnauthorizedError } from "./
|
|
21
|
+
import { isCancellationError, isOAuthUnauthorizedError, isSdkTimeoutError } from "./connect-policy.js";
|
|
22
22
|
import { convertJsonSchemaToTypebox } from "./schema-convert.js";
|
|
23
23
|
import { listAllTools, type McpToolDefinition } from "./tool-discovery.js";
|
|
24
24
|
|
|
@@ -353,8 +353,9 @@ export class ToolBridge {
|
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
/**
|
|
356
|
-
* Discover a server's tools, normalizing aborts into {@link AuthCancelledError}
|
|
357
|
-
*
|
|
356
|
+
* Discover a server's tools, normalizing aborts into {@link AuthCancelledError},
|
|
357
|
+
* SDK request timeouts into `McpError("timeout")`, and every other failure into
|
|
358
|
+
* `McpError("protocol")`.
|
|
358
359
|
*/
|
|
359
360
|
private async _fetchToolsForServer(
|
|
360
361
|
serverName: string,
|
|
@@ -365,20 +366,20 @@ export class ToolBridge {
|
|
|
365
366
|
try {
|
|
366
367
|
return await listAllTools(client, effectiveTimeoutMs, effectiveSignal, serverName);
|
|
367
368
|
} catch (err) {
|
|
368
|
-
if (
|
|
369
|
-
err instanceof AuthCancelledError ||
|
|
370
|
-
effectiveSignal?.aborted ||
|
|
371
|
-
(err instanceof Error && err.name === "AbortError")
|
|
372
|
-
) {
|
|
369
|
+
if (isCancellationError(err, effectiveSignal)) {
|
|
373
370
|
throw err instanceof AuthCancelledError ? err : new AuthCancelledError(serverName);
|
|
374
371
|
}
|
|
375
372
|
if (isOAuthUnauthorizedError(err)) {
|
|
376
373
|
await this.onAuthError?.(serverName, err);
|
|
377
374
|
}
|
|
375
|
+
// WHY: an SDK request timeout is retryable — `classifyConnectFailure` fast-fails on
|
|
376
|
+
// "protocol", so mislabelling it would skip `maxRetries` and report a discovery
|
|
377
|
+
// timeout as a protocol violation (#209).
|
|
378
|
+
const code = isSdkTimeoutError(err) ? "timeout" : "protocol";
|
|
378
379
|
throw new McpError(
|
|
379
380
|
`Failed to list tools from ${serverName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
380
381
|
serverName,
|
|
381
|
-
|
|
382
|
+
code,
|
|
382
383
|
err,
|
|
383
384
|
);
|
|
384
385
|
}
|
|
@@ -482,11 +483,13 @@ export class ToolBridge {
|
|
|
482
483
|
if (isOAuthUnauthorizedError(err)) {
|
|
483
484
|
await notifyAuthError(err);
|
|
484
485
|
}
|
|
485
|
-
// Protocol-level errors (JSON-RPC error response,
|
|
486
|
+
// Protocol-level errors (JSON-RPC error response, malformed payload) keep
|
|
487
|
+
// "protocol"; an SDK request timeout keeps its own code so callers can tell
|
|
488
|
+
// "the server answered badly" from "the server did not answer in time" (#209).
|
|
486
489
|
throw new McpError(
|
|
487
490
|
err instanceof Error ? err.message : String(err),
|
|
488
491
|
serverName,
|
|
489
|
-
"protocol",
|
|
492
|
+
isSdkTimeoutError(err) ? "timeout" : "protocol",
|
|
490
493
|
err,
|
|
491
494
|
);
|
|
492
495
|
}
|
|
@@ -509,13 +512,24 @@ export class ToolBridge {
|
|
|
509
512
|
throw new McpError("Tool execution cancelled", serverName, "protocol");
|
|
510
513
|
}
|
|
511
514
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
515
|
+
let result: ToolCallResponse;
|
|
516
|
+
try {
|
|
517
|
+
result = await callToolWithClient(
|
|
518
|
+
client,
|
|
519
|
+
toolName,
|
|
520
|
+
params,
|
|
521
|
+
this.serverTimeouts.get(serverName) ?? this.settings.requestTimeoutMs,
|
|
522
|
+
signal,
|
|
523
|
+
);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
// WHY: an abort mid-call is rejected by the SDK with its RequestTimeout error, so the
|
|
526
|
+
// cancellation has to be recognised here — otherwise a caller cancellation is reported
|
|
527
|
+
// as a server timeout, and blamed on the server (#209).
|
|
528
|
+
if (isCancellationError(err, signal)) {
|
|
529
|
+
throw new McpError("Tool execution cancelled", serverName, "protocol");
|
|
530
|
+
}
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
519
533
|
const content = buildToolCallContent(result);
|
|
520
534
|
|
|
521
535
|
// Tool execution errors (isError: true) — distinct from protocol errors
|
package/src/transport-factory.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.
|
|
|
16
16
|
import { McpError } from "./errors.js";
|
|
17
17
|
import type { ServerConfig } from "./config.js";
|
|
18
18
|
import { McpOAuthProvider } from "./oauth-provider.js";
|
|
19
|
+
import { isAuthorizationHeader, stripAuthorizationHeader } from "./auth-challenge.js";
|
|
20
|
+
import { reportDiagnostic } from "./debug-log.js";
|
|
19
21
|
import { createLineSplitter } from "./stdio-process.js";
|
|
20
22
|
import { sanitizeTerminalText } from "./terminal-text.js";
|
|
21
23
|
|
|
@@ -32,9 +34,9 @@ function resolveEnv(extra?: Record<string, string>): Record<string, string> {
|
|
|
32
34
|
return env;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
function createRequestInit(
|
|
37
|
+
function createRequestInit(headers: Record<string, string> | undefined): RequestInit | undefined {
|
|
36
38
|
// Build requestInit for static headers (API keys, etc.)
|
|
37
|
-
return
|
|
39
|
+
return headers ? { headers } : undefined;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
function createOAuthProvider(
|
|
@@ -188,7 +190,18 @@ export function createTransport(
|
|
|
188
190
|
): Transport {
|
|
189
191
|
const cwd = typeof cwdOrOnStderr === "string" ? cwdOrOnStderr : cwdArg;
|
|
190
192
|
const onStderr = typeof cwdOrOnStderr === "function" ? cwdOrOnStderr : undefined;
|
|
191
|
-
|
|
193
|
+
// WHY: with OAuth configured the SDK owns `Authorization` (see stripAuthorizationHeader);
|
|
194
|
+
// a leftover static header is a migration artifact, so drop it and record that we did.
|
|
195
|
+
const staticHeaders = config.auth ? stripAuthorizationHeader(config.headers) : config.headers;
|
|
196
|
+
if (config.auth && config.headers && Object.keys(config.headers).some(isAuthorizationHeader)) {
|
|
197
|
+
reportDiagnostic({
|
|
198
|
+
level: "info",
|
|
199
|
+
event: "config.static_authorization_ignored",
|
|
200
|
+
server: serverName,
|
|
201
|
+
message: `Ignoring the static Authorization header for "${serverName}": OAuth is configured, so requests carry the OAuth access token instead.`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const requestInit = createRequestInit(staticHeaders);
|
|
192
205
|
const authProvider = createOAuthProvider(serverName, config, storageDir);
|
|
193
206
|
|
|
194
207
|
switch (config.transport) {
|