Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 56 additions & 57 deletions packages/plugins/mcp/src/react/AddMcpSource.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useAtomSet } from "@effect/atom-react";
import * as Exit from "effect/Exit";

import { useScope } from "@executor-js/react/api/scope-context";
import { Button } from "@executor-js/react/components/button";
Expand Down Expand Up @@ -268,8 +269,8 @@ export default function AddMcpSource(props: {
);

const scopeId = useScope();
const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });
const doAdd = useAtomSet(addMcpSource, { mode: "promise" });
const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
const doAdd = useAtomSet(addMcpSource, { mode: "promiseExit" });
const { beginAdd } = usePendingSources();
const secretList = useSecretPickerSecrets();
const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
Expand Down Expand Up @@ -333,24 +334,24 @@ export default function AddMcpSource(props: {

const handleProbe = useCallback(async () => {
dispatch({ type: "probe-start" });
try {
const { headers, queryParams } = serializeHttpCredentials(remoteCredentials);
const result = await doProbe({
params: { scopeId },
payload: {
endpoint: state.url.trim(),
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
},
});
setRemoteAuthMode(result.requiresOAuth ? "oauth2" : "none");
dispatch({ type: "probe-ok", probe: result });
} catch (e) {
const { headers, queryParams } = serializeHttpCredentials(remoteCredentials);
const exit = await doProbe({
params: { scopeId },
payload: {
endpoint: state.url.trim(),
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
},
});
if (Exit.isFailure(exit)) {
dispatch({
type: "probe-fail",
error: e instanceof Error ? e.message : "Failed to connect",
error: "Failed to connect",
});
return;
}
setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none");
dispatch({ type: "probe-ok", probe: exit.value });
}, [state.url, scopeId, doProbe, remoteCredentials]);

// Keep the latest handleProbe in a ref so the debounced effect can call it
Expand Down Expand Up @@ -473,33 +474,32 @@ export default function AddMcpSource(props: {
kind: "mcp",
url: state.url.trim(),
});
try {
await doAdd({
params: { scopeId },
payload: {
transport: "remote" as const,
name: displayName,
namespace: slugNamespace || undefined,
endpoint: state.url.trim(),
auth,
...(Object.keys(remoteRequestHeaders).length > 0
? { headers: remoteRequestHeaders }
: {}),
...(Object.keys(credentials.queryParams).length > 0
? { queryParams: credentials.queryParams }
: {}),
},
reactivityKeys: sourceWriteKeys,
});
props.onComplete();
} catch (e) {
const exit = await doAdd({
params: { scopeId },
payload: {
transport: "remote" as const,
name: displayName,
namespace: slugNamespace || undefined,
endpoint: state.url.trim(),
auth,
...(Object.keys(remoteRequestHeaders).length > 0
? { headers: remoteRequestHeaders }
: {}),
...(Object.keys(credentials.queryParams).length > 0
? { queryParams: credentials.queryParams }
: {}),
},
reactivityKeys: sourceWriteKeys,
});
placeholder.done();
if (Exit.isFailure(exit)) {
dispatch({
type: "add-fail",
error: e instanceof Error ? e.message : "Failed to add source",
error: "Failed to add source",
});
} finally {
placeholder.done();
return;
}
props.onComplete();
}, [
probe,
remoteAuthMode,
Expand Down Expand Up @@ -553,26 +553,25 @@ export default function AddMcpSource(props: {
name: displayName,
kind: "mcp",
});
try {
await doAdd({
params: { scopeId },
payload: {
transport: "stdio" as const,
name: displayName,
namespace: slugNamespace || undefined,
command: cmd,
args: parseStdioArgs(stdioArgs),
env: parseStdioEnv(stdioEnv),
},
reactivityKeys: sourceWriteKeys,
});
props.onComplete();
} catch (e) {
setStdioError(e instanceof Error ? e.message : "Failed to add source");
const exit = await doAdd({
params: { scopeId },
payload: {
transport: "stdio" as const,
name: displayName,
namespace: slugNamespace || undefined,
command: cmd,
args: parseStdioArgs(stdioArgs),
env: parseStdioEnv(stdioEnv),
},
reactivityKeys: sourceWriteKeys,
});
placeholder.done();
if (Exit.isFailure(exit)) {
setStdioError("Failed to add source");
setStdioAdding(false);
} finally {
placeholder.done();
return;
}
props.onComplete();
}, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]);

// ---- Render ----
Expand Down
35 changes: 18 additions & 17 deletions packages/plugins/mcp/src/react/EditMcpSource.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useAtomValue, useAtomSet } from "@effect/atom-react";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import * as Exit from "effect/Exit";
import { mcpSourceAtom, updateMcpSource } from "./atoms";
import { useScope } from "@executor-js/react/api/scope-context";
import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
Expand Down Expand Up @@ -35,7 +36,7 @@ function RemoteEditForm(props: {
onSave: () => void;
}) {
const scopeId = useScope();
const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" });
const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" });
const secretList = useSecretPickerSecrets();

const identity = useSourceIdentity({
Expand Down Expand Up @@ -64,24 +65,24 @@ function RemoteEditForm(props: {
setSaving(true);
setError(null);
const { headers, queryParams } = serializeHttpCredentials(credentials);
try {
await doUpdate({
params: { scopeId, namespace: props.sourceId },
payload: {
name: identity.name.trim() || undefined,
endpoint: endpoint.trim() || undefined,
headers,
queryParams,
},
reactivityKeys: sourceWriteKeys,
});
setDirty(false);
props.onSave();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update source");
} finally {
const exit = await doUpdate({
params: { scopeId, namespace: props.sourceId },
payload: {
name: identity.name.trim() || undefined,
endpoint: endpoint.trim() || undefined,
headers,
queryParams,
},
reactivityKeys: sourceWriteKeys,
});
if (Exit.isFailure(exit)) {
setError("Failed to update source");
setSaving(false);
return;
}
setDirty(false);
setSaving(false);
props.onSave();
};

return (
Expand Down
46 changes: 29 additions & 17 deletions packages/react/src/plugins/oauth-sign-in.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useAtomSet } from "@effect/atom-react";
import * as Exit from "effect/Exit";

import { cancelOAuth, startOAuth } from "../api/atoms";
import { openOAuthPopup, type OAuthPopupResult } from "../api/oauth-popup";
Expand Down Expand Up @@ -80,8 +81,8 @@ export function useOAuthPopupFlow<
startErrorMessage,
} = options;
const scopeId = useScope();
const doStartOAuth = useAtomSet(startOAuth, { mode: "promise" });
const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promise" });
const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
Expand All @@ -92,7 +93,7 @@ export function useOAuthPopupFlow<
void doCancelOAuth({
params: { scopeId },
payload: { sessionId },
}).catch(() => undefined);
});
},
[doCancelOAuth, scopeId],
);
Expand Down Expand Up @@ -122,6 +123,7 @@ export function useOAuthPopupFlow<
cancel();
setBusy(true);
setError(null);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller-provided Promise starts OAuth and reports a stable UI error
try {
const response = await input.run();
if (response.authorizationUrl === null) {
Expand Down Expand Up @@ -151,11 +153,12 @@ export function useOAuthPopupFlow<
return;
}

// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller-provided success callback reports a stable UI error
try {
await input.onSuccess(result);
setBusy(false);
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to persist new connection";
} catch {
const message = "Failed to persist new connection";
setBusy(false);
setError(message);
input.onError?.(message);
Expand All @@ -182,9 +185,8 @@ export function useOAuthPopupFlow<
input.onError?.(message);
},
});
} catch (e) {
const message =
e instanceof Error ? e.message : (startErrorMessage ?? "Failed to start sign-in");
} catch {
const message = startErrorMessage ?? "Failed to start sign-in";
setBusy(false);
setError(message);
input.onError?.(message);
Expand All @@ -203,21 +205,31 @@ export function useOAuthPopupFlow<

const start = useCallback(
async (input: StartOAuthPopupInput<TPayload>) => {
cancel();
setBusy(true);
setError(null);
const exit = await doStartOAuth({
params: { scopeId },
payload: {
...input.payload,
redirectUrl: input.payload.redirectUrl ?? oauthCallbackUrl(callbackPath),
},
});
if (Exit.isFailure(exit)) {
const message = startErrorMessage ?? "Failed to start sign-in";
setBusy(false);
setError(message);
input.onError?.(message);
return;
}
await openAuthorization({
onSuccess: input.onSuccess,
onError: input.onError,
onAuthorizationStarted: input.onAuthorizationStarted,
run: () =>
doStartOAuth({
params: { scopeId },
payload: {
...input.payload,
redirectUrl: input.payload.redirectUrl ?? oauthCallbackUrl(callbackPath),
},
}),
run: async () => exit.value,
});
},
[callbackPath, doStartOAuth, openAuthorization, scopeId],
[callbackPath, cancel, doStartOAuth, openAuthorization, scopeId, startErrorMessage],
);

return {
Expand Down
Loading