feat: add mcp oauth support via /mcp connect (#570)

This commit is contained in:
jnjpng
2026-01-16 17:59:39 -08:00
committed by GitHub
parent 147e7f6dfd
commit 167f30972d
6 changed files with 801 additions and 20 deletions

View File

@@ -90,6 +90,7 @@ import { ErrorMessage } from "./components/ErrorMessageRich";
import { FeedbackDialog } from "./components/FeedbackDialog";
import { HelpDialog } from "./components/HelpDialog";
import { Input } from "./components/InputRich";
import { McpConnectFlow } from "./components/McpConnectFlow";
import { McpSelector } from "./components/McpSelector";
import { MemoryTabViewer } from "./components/MemoryTabViewer";
import { MessageSearch } from "./components/MessageSearch";
@@ -819,6 +820,7 @@ export default function App({
| "pin"
| "new"
| "mcp"
| "mcp-connect"
| "help"
| null;
const [activeOverlay, setActiveOverlay] = useState<ActiveOverlay>(null);
@@ -3563,6 +3565,12 @@ export default function App({
return { submitted: true };
}
// /mcp connect - interactive TUI for connecting with OAuth
if (firstWord === "connect") {
setActiveOverlay("mcp-connect");
return { submitted: true };
}
// Unknown subcommand
handleMcpUsage(mcpCtx, msg);
return { submitted: true };
@@ -7739,15 +7747,28 @@ Plan file path: ${planFilePath}`;
<McpSelector
agentId={agentId}
onAdd={() => {
// Close overlay and prompt user to use /mcp add command
// Switch to the MCP connect flow
setActiveOverlay("mcp-connect");
}}
onCancel={closeOverlay}
/>
)}
{/* MCP Connect Flow - interactive TUI for OAuth connection */}
{activeOverlay === "mcp-connect" && (
<McpConnectFlow
onComplete={(serverName, serverId, toolCount) => {
closeOverlay();
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: "/mcp",
input: "/mcp connect",
output:
"Use /mcp add --transport <http|sse|stdio> <name> <url|command> [...] to add a new server",
`Successfully created MCP server "${serverName}"\n` +
`ID: ${serverId}\n` +
`Discovered ${toolCount} tool${toolCount === 1 ? "" : "s"}\n` +
"Open /mcp to attach or detach tools for this server.",
phase: "finished",
success: true,
});

View File

@@ -355,7 +355,12 @@ export function handleMcpUsage(ctx: McpCommandContext, msg: string): void {
ctx.buffersRef,
ctx.refreshDerived,
msg,
'Usage: /mcp [add ...]\n /mcp - list MCP servers\n /mcp add --transport <http|sse|stdio> <name> <url|command> [...] - add a new server\n\nExamples:\n /mcp add --transport http notion https://mcp.notion.com/mcp\n /mcp add --transport http api https://api.example.com --header "Authorization: Bearer token"',
"Usage: /mcp [subcommand ...]\n" +
" /mcp - Open MCP server manager\n" +
" /mcp add ... - Add a new server (without OAuth)\n" +
" /mcp connect - Interactive wizard with OAuth support\n\n" +
"Examples:\n" +
" /mcp add --transport http notion https://mcp.notion.com/mcp",
false,
);
}

View File

@@ -178,7 +178,7 @@ export const commands: Record<string, Command> = {
},
},
"/mcp": {
desc: "Manage MCP servers",
desc: "Manage MCP servers (add, connect with OAuth)",
order: 32,
handler: () => {
// Handled specially in App.tsx to show MCP server selector

View File

@@ -0,0 +1,599 @@
/**
* Interactive TUI for connecting to MCP servers with OAuth support.
* Flow: Select transport → Enter URL → Connect (OAuth if needed) → Enter name → Create
*/
import { Box, Text, useInput } from "ink";
import { memo, useCallback, useState } from "react";
import { getClient } from "../../agent/client";
import {
connectMcpServer,
type McpConnectConfig,
type McpTool,
OauthStreamEvent,
} from "../helpers/mcpOauth";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { PasteAwareTextInput } from "./PasteAwareTextInput";
const SOLID_LINE = "─";
// Validate URL (outside component to avoid useCallback dependency)
function validateUrl(url: string): string | null {
if (!url.trim()) {
return "URL is required";
}
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
return "URL must use http or https protocol";
}
} catch {
return "Invalid URL format";
}
return null;
}
// Validate server name (outside component to avoid useCallback dependency)
function validateName(name: string): string | null {
if (!name.trim()) {
return "Server name is required";
}
if (!/^[a-zA-Z0-9_-]+$/.test(name.trim())) {
return "Name can only contain letters, numbers, hyphens, and underscores";
}
if (name.trim().length > 64) {
return "Name must be 64 characters or less";
}
return null;
}
interface McpConnectFlowProps {
onComplete: (serverName: string, serverId: string, toolCount: number) => void;
onCancel: () => void;
}
type Step =
| "select-transport"
| "enter-url"
| "connecting"
| "enter-name"
| "creating";
type Transport = "http" | "sse";
const TRANSPORTS: { value: Transport; label: string; description: string }[] = [
{
value: "http",
label: "Streamable HTTP",
description: "Modern HTTP-based transport (recommended)",
},
{
value: "sse",
label: "Server-Sent Events",
description: "SSE-based transport for legacy servers",
},
];
export const McpConnectFlow = memo(function McpConnectFlow({
onComplete,
onCancel,
}: McpConnectFlowProps) {
const terminalWidth = useTerminalWidth();
const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10));
// Step state
const [step, setStep] = useState<Step>("select-transport");
// Transport selection
const [transportIndex, setTransportIndex] = useState(0);
const [selectedTransport, setSelectedTransport] = useState<Transport | null>(
null,
);
// URL input
const [urlInput, setUrlInput] = useState("");
const [urlError, setUrlError] = useState("");
// Connection state
const [connectionStatus, setConnectionStatus] = useState("");
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [discoveredTools, setDiscoveredTools] = useState<McpTool[]>([]);
const [connectionError, setConnectionError] = useState<string | null>(null);
// Name input
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState("");
// Creating state
const [creatingStatus, setCreatingStatus] = useState("");
// Handle transport selection
useInput(
(input, key) => {
if (key.ctrl && input === "c") {
onCancel();
return;
}
if (key.escape) {
onCancel();
return;
}
if (step === "select-transport") {
if (key.upArrow) {
setTransportIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setTransportIndex((prev) =>
Math.min(TRANSPORTS.length - 1, prev + 1),
);
} else if (key.return) {
const selected = TRANSPORTS[transportIndex];
if (selected) {
setSelectedTransport(selected.value);
setStep("enter-url");
}
}
}
},
{ isActive: step === "select-transport" },
);
// Handle URL input escape
useInput(
(input, key) => {
if (key.ctrl && input === "c") {
onCancel();
return;
}
if (key.escape) {
// Go back to transport selection
setStep("select-transport");
setUrlInput("");
setUrlError("");
}
},
{ isActive: step === "enter-url" },
);
// Handle connection step escape
useInput(
(input, key) => {
if (key.ctrl && input === "c") {
onCancel();
return;
}
if (key.escape && connectionError) {
// Go back to URL input on error
setStep("enter-url");
setConnectionError(null);
setConnectionStatus("");
setAuthUrl(null);
}
},
{ isActive: step === "connecting" },
);
// Handle name input escape
useInput(
(input, key) => {
if (key.ctrl && input === "c") {
onCancel();
return;
}
if (key.escape) {
// Go back to URL input
setStep("enter-url");
setNameInput("");
setNameError("");
}
},
{ isActive: step === "enter-name" },
);
// Handle URL submission
const handleUrlSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
const error = validateUrl(trimmed);
if (error) {
setUrlError(error);
return;
}
setUrlError("");
setStep("connecting");
setConnectionStatus("Connecting...");
setConnectionError(null);
setAuthUrl(null);
const config: McpConnectConfig = {
server_name: "temp-connection-test",
type: selectedTransport === "http" ? "streamable_http" : "sse",
server_url: trimmed,
};
try {
const tools = await connectMcpServer(config, {
onEvent: (event) => {
switch (event.event) {
case OauthStreamEvent.CONNECTION_ATTEMPT:
setConnectionStatus("Connecting to server...");
break;
case OauthStreamEvent.OAUTH_REQUIRED:
setConnectionStatus("OAuth authentication required...");
break;
case OauthStreamEvent.AUTHORIZATION_URL:
if (event.url) {
const authorizationUrl = event.url;
setAuthUrl(authorizationUrl);
setConnectionStatus("Opening browser for authorization...");
// Open browser
import("open")
.then(({ default: open }) => open(authorizationUrl))
.catch(() => {});
}
break;
case OauthStreamEvent.WAITING_FOR_AUTH:
setConnectionStatus("Waiting for authorization in browser...");
break;
}
},
});
// Success!
setDiscoveredTools(tools);
setConnectionStatus("");
// Generate default name from URL
try {
const parsed = new URL(trimmed);
const defaultName =
parsed.hostname.replace(/^(www|mcp|api)\./, "").split(".")[0] ||
"mcp-server";
setNameInput(defaultName);
} catch {
setNameInput("mcp-server");
}
setStep("enter-name");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setConnectionError(message);
setConnectionStatus("");
}
},
[selectedTransport],
);
// Handle name submission and create server
const handleNameSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
const error = validateName(trimmed);
if (error) {
setNameError(error);
return;
}
setNameError("");
setStep("creating");
setCreatingStatus("Creating MCP server...");
try {
const client = await getClient();
const serverConfig =
selectedTransport === "http"
? {
mcp_server_type: "streamable_http" as const,
server_url: urlInput.trim(),
}
: {
mcp_server_type: "sse" as const,
server_url: urlInput.trim(),
};
const server = await client.mcpServers.create({
server_name: trimmed,
config: serverConfig,
});
onComplete(trimmed, server.id || "", discoveredTools.length);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setNameError(`Failed to create server: ${message}`);
setStep("enter-name");
setCreatingStatus("");
}
},
[selectedTransport, urlInput, discoveredTools.length, onComplete],
);
// Render transport selection step
if (step === "select-transport") {
return (
<Box flexDirection="column">
<Text dimColor>{"> /mcp connect"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Text bold color={colors.selector.title}>
Connect to MCP Server
</Text>
<Box height={1} />
<Box paddingLeft={2}>
<Text>Select transport type:</Text>
</Box>
<Box height={1} />
<Box flexDirection="column">
{TRANSPORTS.map((transport, index) => {
const isSelected = index === transportIndex;
return (
<Box
key={transport.value}
flexDirection="column"
marginBottom={1}
>
<Box>
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
bold={isSelected}
>
{isSelected ? "> " : " "}
{transport.label}
</Text>
</Box>
<Box paddingLeft={4}>
<Text dimColor>{transport.description}</Text>
</Box>
</Box>
);
})}
</Box>
<Box height={1} />
<Box paddingLeft={2}>
<Text dimColor> navigate · Enter select · Esc cancel</Text>
</Box>
</Box>
);
}
// Render URL input step
if (step === "enter-url") {
const transportLabel =
TRANSPORTS.find((t) => t.value === selectedTransport)?.label || "";
return (
<Box flexDirection="column">
<Text dimColor>{"> /mcp connect"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Text bold color={colors.selector.title}>
Connect to MCP Server
</Text>
<Box height={1} />
<Box paddingLeft={2}>
<Text>
Transport: <Text bold>{transportLabel}</Text>
</Text>
</Box>
<Box height={1} />
<Box paddingLeft={2}>
<Text>Enter the server URL:</Text>
</Box>
<Box marginTop={1}>
<Text color={colors.selector.itemHighlighted}>{">"}</Text>
<Text> </Text>
<PasteAwareTextInput
value={urlInput}
onChange={(val) => {
setUrlInput(val);
setUrlError("");
}}
onSubmit={handleUrlSubmit}
placeholder="https://mcp.example.com/mcp"
/>
</Box>
{urlError && (
<Box paddingLeft={2} marginTop={1}>
<Text color="red">{urlError}</Text>
</Box>
)}
<Box height={1} />
<Box paddingLeft={2}>
<Text dimColor>Enter submit · Esc back</Text>
</Box>
</Box>
);
}
// Render connecting step
if (step === "connecting") {
const transportLabel =
TRANSPORTS.find((t) => t.value === selectedTransport)?.label || "";
return (
<Box flexDirection="column">
<Text dimColor>{"> /mcp connect"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Text bold color={colors.selector.title}>
Connect to MCP Server
</Text>
<Box height={1} />
<Box paddingLeft={2} flexDirection="column">
<Text>
Transport: <Text bold>{transportLabel}</Text>
</Text>
<Text>
URL: <Text bold>{urlInput}</Text>
</Text>
</Box>
<Box height={1} />
{connectionStatus && (
<Box paddingLeft={2}>
<Text color="yellow">{connectionStatus}</Text>
</Box>
)}
{authUrl && (
<Box paddingLeft={2} marginTop={1} flexDirection="column">
<Text dimColor>Authorization URL:</Text>
<Text dimColor>{authUrl}</Text>
</Box>
)}
{connectionError && (
<Box paddingLeft={2} marginTop={1} flexDirection="column">
<Text color="red">Connection failed:</Text>
<Text color="red">{connectionError}</Text>
<Box marginTop={1}>
<Text dimColor>Esc to go back and try again</Text>
</Box>
</Box>
)}
</Box>
);
}
// Render name input step
if (step === "enter-name") {
const transportLabel =
TRANSPORTS.find((t) => t.value === selectedTransport)?.label || "";
return (
<Box flexDirection="column">
<Text dimColor>{"> /mcp connect"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Text bold color={colors.selector.title}>
Connect to MCP Server
</Text>
<Box height={1} />
<Box paddingLeft={2} flexDirection="column">
<Text>
Transport: <Text bold>{transportLabel}</Text>
</Text>
<Text>
URL: <Text bold>{urlInput}</Text>
</Text>
</Box>
<Box height={1} />
<Box paddingLeft={2}>
<Text color="green">
Connection successful! Discovered {discoveredTools.length} tool
{discoveredTools.length === 1 ? "" : "s"}
</Text>
</Box>
{discoveredTools.length > 0 && (
<Box paddingLeft={4} marginTop={1} flexDirection="column">
{discoveredTools.slice(0, 5).map((tool) => (
<Text key={tool.name} dimColor>
{tool.name}
</Text>
))}
{discoveredTools.length > 5 && (
<Text dimColor>... and {discoveredTools.length - 5} more</Text>
)}
</Box>
)}
<Box height={1} />
<Box paddingLeft={2}>
<Text>Enter a name for this server:</Text>
</Box>
<Box marginTop={1}>
<Text color={colors.selector.itemHighlighted}>{">"}</Text>
<Text> </Text>
<PasteAwareTextInput
value={nameInput}
onChange={(val) => {
setNameInput(val);
setNameError("");
}}
onSubmit={handleNameSubmit}
placeholder="my-mcp-server"
/>
</Box>
{nameError && (
<Box paddingLeft={2} marginTop={1}>
<Text color="red">{nameError}</Text>
</Box>
)}
<Box height={1} />
<Box paddingLeft={2}>
<Text dimColor>Enter create · Esc back</Text>
</Box>
</Box>
);
}
// Render creating step
if (step === "creating") {
return (
<Box flexDirection="column">
<Text dimColor>{"> /mcp connect"}</Text>
<Text dimColor>{solidLine}</Text>
<Box height={1} />
<Text bold color={colors.selector.title}>
Connect to MCP Server
</Text>
<Box height={1} />
<Box paddingLeft={2}>
<Text color="yellow">{creatingStatus}</Text>
</Box>
</Box>
);
}
return null;
});
McpConnectFlow.displayName = "McpConnectFlow";

View File

@@ -109,6 +109,16 @@ export const McpSelector = memo(function McpSelector({
}
}, []);
const fetchAttachedToolIds = useCallback(
async (client: Awaited<ReturnType<typeof getClient>>) => {
const agent = await client.agents.retrieve(agentId, {
include: ["agent.tools"],
});
return new Set(agent.tools?.map((t) => t.id) || []);
},
[agentId],
);
// Load tools for a specific server
const loadTools = useCallback(
async (server: McpServer) => {
@@ -138,8 +148,7 @@ export const McpSelector = memo(function McpSelector({
setTools(toolsList);
// Fetch agent's current tools to check which are attached
const agent = await client.agents.retrieve(agentId);
const agentToolIds = new Set(agent.tools?.map((t) => t.id) || []);
const agentToolIds = await fetchAttachedToolIds(client);
setAttachedToolIds(agentToolIds);
setToolsPage(0);
@@ -153,7 +162,7 @@ export const McpSelector = memo(function McpSelector({
setToolsLoading(false);
}
},
[agentId],
[fetchAttachedToolIds],
);
// Refresh tools from MCP server
@@ -174,8 +183,7 @@ export const McpSelector = memo(function McpSelector({
setTools(toolsList);
// Refresh agent's current tools
const agent = await client.agents.retrieve(agentId);
const agentToolIds = new Set(agent.tools?.map((t) => t.id) || []);
const agentToolIds = await fetchAttachedToolIds(client);
setAttachedToolIds(agentToolIds);
setToolsPage(0);
@@ -194,7 +202,7 @@ export const McpSelector = memo(function McpSelector({
} finally {
setToolsLoading(false);
}
}, [agentId, viewingServer]);
}, [agentId, fetchAttachedToolIds, viewingServer]);
// Toggle tool attachment
const toggleTool = useCallback(
@@ -213,8 +221,7 @@ export const McpSelector = memo(function McpSelector({
}
// Fetch agent's current tools to get accurate total count
const agent = await client.agents.retrieve(agentId);
const agentToolIds = new Set(agent.tools?.map((t) => t.id) || []);
const agentToolIds = await fetchAttachedToolIds(client);
setAttachedToolIds(agentToolIds);
} catch (err) {
setToolsError(
@@ -226,7 +233,7 @@ export const McpSelector = memo(function McpSelector({
setIsTogglingTool(false);
}
},
[agentId, attachedToolIds],
[agentId, attachedToolIds, fetchAttachedToolIds],
);
// Attach all tools
@@ -244,8 +251,7 @@ export const McpSelector = memo(function McpSelector({
);
// Fetch agent's current tools to get accurate total count
const agent = await client.agents.retrieve(agentId);
const agentToolIds = new Set(agent.tools?.map((t) => t.id) || []);
const agentToolIds = await fetchAttachedToolIds(client);
setAttachedToolIds(agentToolIds);
} catch (err) {
setToolsError(
@@ -254,7 +260,7 @@ export const McpSelector = memo(function McpSelector({
} finally {
setIsTogglingTool(false);
}
}, [agentId, tools, attachedToolIds]);
}, [agentId, tools, attachedToolIds, fetchAttachedToolIds]);
// Detach all tools
const detachAllTools = useCallback(async () => {
@@ -271,8 +277,7 @@ export const McpSelector = memo(function McpSelector({
);
// Fetch agent's current tools to get accurate total count
const agent = await client.agents.retrieve(agentId);
const agentToolIds = new Set(agent.tools?.map((t) => t.id) || []);
const agentToolIds = await fetchAttachedToolIds(client);
setAttachedToolIds(agentToolIds);
} catch (err) {
setToolsError(
@@ -281,7 +286,7 @@ export const McpSelector = memo(function McpSelector({
} finally {
setIsTogglingTool(false);
}
}, [agentId, tools, attachedToolIds]);
}, [agentId, tools, attachedToolIds, fetchAttachedToolIds]);
useEffect(() => {
loadServers();

151
src/cli/helpers/mcpOauth.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* MCP OAuth SSE client for connecting to MCP servers that require OAuth authentication.
* Uses the /v1/tools/mcp/servers/connect SSE streaming endpoint.
*/
import { getServerUrl } from "../../agent/client";
import { settingsManager } from "../../settings-manager";
// Match backend's OauthStreamEvent enum
export enum OauthStreamEvent {
CONNECTION_ATTEMPT = "connection_attempt",
SUCCESS = "success",
ERROR = "error",
OAUTH_REQUIRED = "oauth_required",
AUTHORIZATION_URL = "authorization_url",
WAITING_FOR_AUTH = "waiting_for_auth",
}
export interface McpOauthEvent {
event: OauthStreamEvent;
url?: string; // For AUTHORIZATION_URL
session_id?: string; // For tracking
tools?: McpTool[]; // For SUCCESS
message?: string; // For ERROR/info
server_name?: string; // Server name
}
export interface McpTool {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}
export interface McpConnectConfig {
server_name: string;
type: "sse" | "streamable_http";
server_url: string;
auth_header?: string;
auth_token?: string;
custom_headers?: Record<string, string>;
}
export interface McpConnectOptions {
onEvent?: (event: McpOauthEvent) => void;
abortSignal?: AbortSignal;
}
/**
* Connect to an MCP server with OAuth support via SSE streaming.
* Returns the list of available tools on success.
*
* The flow:
* 1. Opens SSE stream to /v1/tools/mcp/servers/connect
* 2. Receives CONNECTION_ATTEMPT event
* 3. If OAuth is required:
* - Receives OAUTH_REQUIRED event
* - Receives AUTHORIZATION_URL event with OAuth URL
* - Receives WAITING_FOR_AUTH event
* - Caller should open browser with the URL
* - After user authorizes, receives SUCCESS event
* 4. Returns tools array on SUCCESS, throws on ERROR
*/
export async function connectMcpServer(
config: McpConnectConfig,
options: McpConnectOptions = {},
): Promise<McpTool[]> {
const { onEvent, abortSignal } = options;
const settings = await settingsManager.getSettingsWithSecureTokens();
const baseUrl = getServerUrl();
const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY;
if (!apiKey) {
throw new Error("Missing LETTA_API_KEY");
}
const response = await fetch(`${baseUrl}/v1/tools/mcp/servers/connect`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
Authorization: `Bearer ${apiKey}`,
"X-Letta-Source": "letta-code",
},
body: JSON.stringify(config),
signal: abortSignal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Connection failed (${response.status}): ${errorText}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Failed to get response stream reader");
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
throw new Error("Stream ended unexpectedly without success or error");
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim() || line.trim() === "[DONE]") continue;
let data = line;
if (line.startsWith("data: ")) {
data = line.slice(6);
}
if (data.trim() === "[DONE]") continue;
try {
const event = JSON.parse(data) as McpOauthEvent;
onEvent?.(event);
switch (event.event) {
case OauthStreamEvent.SUCCESS:
return event.tools || [];
case OauthStreamEvent.ERROR:
throw new Error(event.message || "Connection failed");
case OauthStreamEvent.AUTHORIZATION_URL:
// Event handler should open browser
// Continue processing stream for WAITING_FOR_AUTH and SUCCESS
break;
// Other events are informational (CONNECTION_ATTEMPT, OAUTH_REQUIRED, WAITING_FOR_AUTH)
}
} catch (parseError) {
// Skip unparseable lines (might be partial SSE data)
if (parseError instanceof SyntaxError) continue;
throw parseError;
}
}
}
} finally {
reader.releaseLock();
}
}