@ciridae/ui 0.8.2 → 0.10.0
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/dist/components/blocks/copy-field.d.ts +18 -0
- package/dist/components/blocks/copy-field.js +61 -0
- package/dist/components/blocks/generated-connect-link.d.ts +22 -0
- package/dist/components/blocks/generated-connect-link.js +68 -0
- package/dist/components/blocks/mcp-access-grouped-tool-picker.d.ts +19 -0
- package/dist/components/blocks/mcp-access-grouped-tool-picker.js +116 -0
- package/dist/components/blocks/mcp-access-tool-picker-internals.d.ts +35 -0
- package/dist/components/blocks/mcp-access-tool-picker-internals.js +24 -0
- package/dist/components/blocks/mcp-access-tool-picker.d.ts +22 -0
- package/dist/components/blocks/mcp-access-tool-picker.js +33 -0
- package/dist/components/blocks/mcp-access-types.d.ts +41 -0
- package/dist/components/blocks/mcp-access-types.js +7 -0
- package/dist/components/blocks/mcp-client-setup.d.ts +29 -0
- package/dist/components/blocks/mcp-client-setup.js +58 -0
- package/dist/components/blocks/mcp-connect-clients.d.ts +43 -0
- package/dist/components/blocks/mcp-connect-clients.js +66 -0
- package/dist/components/blocks/mcp-plan-choice.d.ts +17 -0
- package/dist/components/blocks/mcp-plan-choice.js +28 -0
- package/dist/components/ui/checkbox.js +2 -2
- package/dist/entries/copy-field.d.ts +1 -0
- package/dist/entries/copy-field.js +1 -0
- package/dist/entries/generated-connect-link.d.ts +1 -0
- package/dist/entries/generated-connect-link.js +1 -0
- package/dist/entries/mcp-access-grouped-tool-picker.d.ts +1 -0
- package/dist/entries/mcp-access-grouped-tool-picker.js +1 -0
- package/dist/entries/mcp-access-tool-picker.d.ts +1 -0
- package/dist/entries/mcp-access-tool-picker.js +1 -0
- package/dist/entries/mcp-access-types.d.ts +1 -0
- package/dist/entries/mcp-access-types.js +1 -0
- package/dist/entries/mcp-client-setup.d.ts +1 -0
- package/dist/entries/mcp-client-setup.js +1 -0
- package/dist/entries/mcp-connect-clients.d.ts +1 -0
- package/dist/entries/mcp-connect-clients.js +1 -0
- package/dist/entries/mcp-plan-choice.d.ts +1 -0
- package/dist/entries/mcp-plan-choice.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One value as a labelled read-only text field, so it reads like the field it
|
|
3
|
+
* is about to be pasted into. A real `input` rather than a styled block: it
|
|
4
|
+
* selects, tabs, and reads to assistive tech like its destination. The row
|
|
5
|
+
* stays when there is no value yet, because dropping it left the steps above
|
|
6
|
+
* pointing at nothing.
|
|
7
|
+
*/
|
|
8
|
+
export declare function CopyField({ label, placeholder, value, }: {
|
|
9
|
+
label: string;
|
|
10
|
+
/**
|
|
11
|
+
* What an absent value means, which is not the same for every field. A
|
|
12
|
+
* minted secret is not generated yet; a deployment-wide URL that is
|
|
13
|
+
* missing will never arrive, and saying otherwise sends someone back to
|
|
14
|
+
* the mint button indefinitely.
|
|
15
|
+
*/
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
value: string | undefined;
|
|
18
|
+
}): import("@types/react").JSX.Element;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
3
|
+
import { useEffect, useId, useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "../ui/button";
|
|
6
|
+
import { Input } from "../ui/input";
|
|
7
|
+
import { Label } from "../ui/label";
|
|
8
|
+
/** PostHog's own no-capture vocabulary — a fixed class name, not app config. */
|
|
9
|
+
const POSTHOG_NO_CAPTURE_CLASS_NAME = "ph-no-capture";
|
|
10
|
+
const COPIED_RESET_DELAY = 2000;
|
|
11
|
+
/**
|
|
12
|
+
* One value as a labelled read-only text field, so it reads like the field it
|
|
13
|
+
* is about to be pasted into. A real `input` rather than a styled block: it
|
|
14
|
+
* selects, tabs, and reads to assistive tech like its destination. The row
|
|
15
|
+
* stays when there is no value yet, because dropping it left the steps above
|
|
16
|
+
* pointing at nothing.
|
|
17
|
+
*/
|
|
18
|
+
export function CopyField({ label, placeholder = "Not generated yet", value, }) {
|
|
19
|
+
const fieldId = useId();
|
|
20
|
+
return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-1", children: [_jsx(Label, { className: "text-muted-foreground text-xs", htmlFor: fieldId, children: label }), _jsxs("div", { className: "flex min-w-0 items-center gap-1", children: [_jsx(Input, { className: `${POSTHOG_NO_CAPTURE_CLASS_NAME} h-8 font-mono text-[11px]`, "data-sentry-mask": true, id: fieldId, onFocus: (event) => {
|
|
21
|
+
event.target.select();
|
|
22
|
+
}, placeholder: placeholder, readOnly: true, value: value ?? "" }), value === undefined ? null : _jsx(CopyButton, { label: label, text: value })] })] }));
|
|
23
|
+
}
|
|
24
|
+
function CopyButton({ label, text }) {
|
|
25
|
+
// What was copied, not that something was: this button survives a remint,
|
|
26
|
+
// so a boolean would keep claiming the checkmark over a value the clipboard
|
|
27
|
+
// has never held. Comparing makes a new `text` read as uncopied at once.
|
|
28
|
+
const [copiedText, setCopiedText] = useState(null);
|
|
29
|
+
const copied = copiedText === text;
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!copied)
|
|
32
|
+
return;
|
|
33
|
+
const timer = window.setTimeout(() => {
|
|
34
|
+
setCopiedText(null);
|
|
35
|
+
}, COPIED_RESET_DELAY);
|
|
36
|
+
return () => {
|
|
37
|
+
window.clearTimeout(timer);
|
|
38
|
+
};
|
|
39
|
+
}, [copied]);
|
|
40
|
+
/**
|
|
41
|
+
* `await` rather than `.catch`, because the two failures arrive differently:
|
|
42
|
+
* a denied permission or an unfocused document rejects, while an insecure
|
|
43
|
+
* origin has no `navigator.clipboard` at all and throws before a promise
|
|
44
|
+
* exists. Either way the user is about to paste a stale clipboard into a
|
|
45
|
+
* credential setup, so neither can pass silently.
|
|
46
|
+
*/
|
|
47
|
+
const copy = async () => {
|
|
48
|
+
try {
|
|
49
|
+
await navigator.clipboard.writeText(text);
|
|
50
|
+
setCopiedText(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
toast.error(`Could not copy the ${label.toLowerCase()}`, {
|
|
54
|
+
description: "Select the field and copy it by hand.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return (_jsx(Button, { "aria-label": copied ? `${label} copied` : `Copy ${label.toLowerCase()}`, onClick: () => {
|
|
59
|
+
void copy();
|
|
60
|
+
}, size: "icon-xs", variant: "ghost", children: copied ? _jsx(CheckIcon, {}) : _jsx(CopyIcon, {}) }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { McpAccessMode, McpAccessToolSelector, McpConnectLink } from "./mcp-access-types";
|
|
2
|
+
/**
|
|
3
|
+
* The slice of the app's mint mutation this control drives, shaped so a
|
|
4
|
+
* TanStack Query mutation result can be passed whole. `data` is the link the
|
|
5
|
+
* last mint produced, absent until one succeeds.
|
|
6
|
+
*/
|
|
7
|
+
export type CreateConnectLinkState = {
|
|
8
|
+
data: McpConnectLink | undefined;
|
|
9
|
+
isPending: boolean;
|
|
10
|
+
mutate: () => void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The mint button and whatever link it has produced. Split from the sheet
|
|
14
|
+
* because this is the one control that acts on the chosen access, and what it
|
|
15
|
+
* is named and coloured for has to track that choice rather than the mode.
|
|
16
|
+
*/
|
|
17
|
+
export declare function GeneratedConnectLink({ access, createConnectLink, currentTools, disabled, }: {
|
|
18
|
+
access: McpAccessMode | undefined;
|
|
19
|
+
createConnectLink: CreateConnectLinkState;
|
|
20
|
+
currentTools: McpAccessToolSelector[] | null;
|
|
21
|
+
disabled: boolean;
|
|
22
|
+
}): import("@types/react").JSX.Element;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button } from "../ui/button";
|
|
3
|
+
import { keyOf } from "./mcp-access-tool-picker";
|
|
4
|
+
import { Spinner } from "../ui/spinner";
|
|
5
|
+
const expiryFormatter = new Intl.DateTimeFormat(undefined, {
|
|
6
|
+
dateStyle: "medium",
|
|
7
|
+
timeStyle: "short",
|
|
8
|
+
});
|
|
9
|
+
const ACCESS_LABEL = { read_only: "read-only", all: "full", selected: "custom" };
|
|
10
|
+
const generateLabel = (hasLink, access, writes) => {
|
|
11
|
+
if (hasLink)
|
|
12
|
+
return "Generate new code";
|
|
13
|
+
if (access === "all")
|
|
14
|
+
return "Generate full-access code";
|
|
15
|
+
return writes ? "Generate write-access code" : "Generate code";
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The methods the tool compiler treats as read-only, mirroring its
|
|
19
|
+
* `_READ_ONLY_METHODS` — the `readOnly` an operation reports is that set
|
|
20
|
+
* membership and nothing else, so a selector's own method already answers this.
|
|
21
|
+
*/
|
|
22
|
+
const READ_ONLY_METHODS = new Set(["GET"]);
|
|
23
|
+
/**
|
|
24
|
+
* Whether the link this button mints can write. `all` always can, and so can a
|
|
25
|
+
* Custom set holding a non-`GET` operation. Read off the selectors the grant is
|
|
26
|
+
* already made of rather than the compiled inventory: the two agree by
|
|
27
|
+
* construction, and asking the server would put a network boundary — and a
|
|
28
|
+
* failure mode — under a pure predicate.
|
|
29
|
+
*/
|
|
30
|
+
function grantsWrite(access, currentTools) {
|
|
31
|
+
if (access === "all")
|
|
32
|
+
return true;
|
|
33
|
+
if (access !== "selected")
|
|
34
|
+
return false;
|
|
35
|
+
return (currentTools ?? []).some((tool) => !READ_ONLY_METHODS.has(tool.method));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The mint button and whatever link it has produced. Split from the sheet
|
|
39
|
+
* because this is the one control that acts on the chosen access, and what it
|
|
40
|
+
* is named and coloured for has to track that choice rather than the mode.
|
|
41
|
+
*/
|
|
42
|
+
export function GeneratedConnectLink({ access, createConnectLink, currentTools, disabled, }) {
|
|
43
|
+
const link = createConnectLink.data;
|
|
44
|
+
const writeGranting = grantsWrite(access, currentTools);
|
|
45
|
+
// The link reports the access it was minted with, not the current setting,
|
|
46
|
+
// so switching modes — or changing a custom selection — with a link on
|
|
47
|
+
// screen labels it honestly instead of silently re-describing a credential
|
|
48
|
+
// that already exists.
|
|
49
|
+
const changedSinceMint = link !== undefined &&
|
|
50
|
+
(link.access !== access ||
|
|
51
|
+
!sameSelection(link.access === "selected" ? link.tools : null, currentTools));
|
|
52
|
+
const staleLabel = changedSinceMint && access !== undefined ? ACCESS_LABEL[access] : null;
|
|
53
|
+
return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [_jsx(Button, { disabled: createConnectLink.isPending || disabled, onClick: () => {
|
|
54
|
+
// What the button is labelled for, not what was last read.
|
|
55
|
+
createConnectLink.mutate();
|
|
56
|
+
}, size: "sm",
|
|
57
|
+
// Named and coloured for what it will mint: the access is chosen
|
|
58
|
+
// well above this button, and this is the click that acts on it.
|
|
59
|
+
// Keyed on whether the grant can write rather than on the mode, so
|
|
60
|
+
// a Custom set holding an action tool does not read as read-only.
|
|
61
|
+
variant: writeGranting ? "destructive" : "default", children: generateLabel(link !== undefined, access, writeGranting) }), createConnectLink.isPending ? (_jsx(Spinner, { "aria-label": "Generating connection code", className: "text-muted-foreground" })) : null, link === undefined ? null : (_jsxs("p", { className: "text-muted-foreground/70 text-xs", children: [_jsxs("span", { className: "font-medium", children: [ACCESS_LABEL[link.access], " access"] }), " \u00B7 single use \u00B7 expires ", expiryFormatter.format(new Date(link.expiresAt))] }))] }), staleLabel === null ? null : (_jsxs("p", { className: "text-destructive text-xs", children: ["This code was generated before you changed the access above. Generate a new one to share", " ", staleLabel, " access."] }))] }));
|
|
62
|
+
}
|
|
63
|
+
function sameSelection(left, right) {
|
|
64
|
+
if (left === null || right === null)
|
|
65
|
+
return left === right;
|
|
66
|
+
const keys = new Set(left.map((tool) => keyOf(tool)));
|
|
67
|
+
return right.length === left.length && right.every((tool) => keys.has(keyOf(tool)));
|
|
68
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
|
|
2
|
+
import type { McpAccessToolSelector } from "./mcp-access-types";
|
|
3
|
+
/**
|
|
4
|
+
* The tool picker for inventories big enough to need navigation: one scroll
|
|
5
|
+
* region grouped by each tool's first OpenAPI tag, each group split into Read
|
|
6
|
+
* and Actions with a select-all per split, and a filter box on top. An
|
|
7
|
+
* inventory with no tags renders as a single unlabeled group, so the split,
|
|
8
|
+
* the select-alls, and the filter still apply.
|
|
9
|
+
*
|
|
10
|
+
* Every change is rebuilt from the full inventory against the one shared
|
|
11
|
+
* selection, so a tool selected but hidden by the filter is never dropped —
|
|
12
|
+
* only the rows on screen, and the select-alls over them, can change it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function McpAccessGroupedToolPicker({ busy, inventory, selected, onChange, }: {
|
|
15
|
+
busy: boolean;
|
|
16
|
+
inventory: McpToolInventoryState;
|
|
17
|
+
selected: McpAccessToolSelector[];
|
|
18
|
+
onChange: (tools: McpAccessToolSelector[]) => void;
|
|
19
|
+
}): import("@types/react").JSX.Element;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Checkbox } from "../ui/checkbox";
|
|
4
|
+
import { Input } from "../ui/input";
|
|
5
|
+
import { Spinner } from "../ui/spinner";
|
|
6
|
+
import { EmptySelectionWarning, InventoryUnavailableNote, keyOf, PickerRow, } from "./mcp-access-tool-picker-internals";
|
|
7
|
+
const UNTAGGED_LABEL = "Other";
|
|
8
|
+
/**
|
|
9
|
+
* The tool picker for inventories big enough to need navigation: one scroll
|
|
10
|
+
* region grouped by each tool's first OpenAPI tag, each group split into Read
|
|
11
|
+
* and Actions with a select-all per split, and a filter box on top. An
|
|
12
|
+
* inventory with no tags renders as a single unlabeled group, so the split,
|
|
13
|
+
* the select-alls, and the filter still apply.
|
|
14
|
+
*
|
|
15
|
+
* Every change is rebuilt from the full inventory against the one shared
|
|
16
|
+
* selection, so a tool selected but hidden by the filter is never dropped —
|
|
17
|
+
* only the rows on screen, and the select-alls over them, can change it.
|
|
18
|
+
*/
|
|
19
|
+
export function McpAccessGroupedToolPicker({ busy, inventory, selected, onChange, }) {
|
|
20
|
+
const [query, setQuery] = useState("");
|
|
21
|
+
if (inventory.data === undefined) {
|
|
22
|
+
return _jsx(InventoryUnavailableNote, { isPending: inventory.isPending });
|
|
23
|
+
}
|
|
24
|
+
const tools = inventory.data.tools;
|
|
25
|
+
const selectedKeys = new Set(selected.map((tool) => keyOf(tool)));
|
|
26
|
+
const applyKeys = (keys, checked) => {
|
|
27
|
+
const next = new Set(selectedKeys);
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
if (checked)
|
|
30
|
+
next.add(key);
|
|
31
|
+
else
|
|
32
|
+
next.delete(key);
|
|
33
|
+
}
|
|
34
|
+
onChange(tools
|
|
35
|
+
.filter((tool) => next.has(keyOf(tool)))
|
|
36
|
+
.map((tool) => ({ method: tool.method, pathTemplate: tool.pathTemplate })));
|
|
37
|
+
};
|
|
38
|
+
const needle = query.trim().toLowerCase();
|
|
39
|
+
const groups = groupByFirstTag(tools);
|
|
40
|
+
const visibleGroups = groups
|
|
41
|
+
.map((group) => ({ ...group, visible: group.tools.filter((tool) => matches(tool, needle)) }))
|
|
42
|
+
.filter((group) => group.visible.length > 0);
|
|
43
|
+
const visibleCount = visibleGroups.reduce((count, group) => count + group.visible.length, 0);
|
|
44
|
+
const chosen = tools.filter((tool) => selectedKeys.has(keyOf(tool)));
|
|
45
|
+
return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsx(Input, { "aria-label": "Filter tools", className: "h-8 text-xs", onChange: (event) => {
|
|
46
|
+
setQuery(event.target.value);
|
|
47
|
+
}, placeholder: `Filter ${tools.length} tools…`, type: "search", value: query }), _jsx("div", { className: "max-h-96 overflow-y-auto rounded-md border border-border/60", children: visibleGroups.length === 0 ? (_jsx("p", { className: "text-muted-foreground px-3 py-2 text-xs", children: tools.length === 0 ? "This API exposes no tools." : "No tools match the filter." })) : (visibleGroups.map((group) => (_jsx(GroupSection, { busy: busy, group: group, onApply: applyKeys, selectedKeys: selectedKeys }, group.label ?? "")))) }), _jsx(PickerFooter, { busy: busy, chosen: chosen.length, matching: needle === "" ? null : visibleCount, total: tools.length })] }));
|
|
48
|
+
}
|
|
49
|
+
function PickerFooter({ busy, chosen, matching, total, }) {
|
|
50
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [busy ? _jsx(Spinner, { "aria-label": "Saving selection", className: "text-muted-foreground" }) : null, _jsxs("p", { "aria-live": "polite", className: "text-muted-foreground text-xs", children: [chosen, " of ", total, " tools selected"] }), matching === null ? null : (_jsxs("p", { className: "text-muted-foreground text-xs", children: [matching, " match", matching === 1 ? "es" : "", " the filter"] })), chosen === 0 ? _jsx(EmptySelectionWarning, {}) : null] }));
|
|
51
|
+
}
|
|
52
|
+
function matches(tool, needle) {
|
|
53
|
+
if (needle === "")
|
|
54
|
+
return true;
|
|
55
|
+
return [tool.name, tool.description, `${tool.method} ${tool.pathTemplate}`].some((text) => text.toLowerCase().includes(needle));
|
|
56
|
+
}
|
|
57
|
+
/** One tag group: its sticky header with counts, then the Read and Actions runs. */
|
|
58
|
+
function GroupSection({ busy, group, selectedKeys, onApply, }) {
|
|
59
|
+
const chosenInGroup = group.tools.filter((tool) => selectedKeys.has(keyOf(tool))).length;
|
|
60
|
+
return (_jsxs("section", { children: [group.label === null ? null : (_jsxs("h4", { className: "bg-background sticky top-0 z-10 flex items-baseline justify-between gap-2 border-b border-border/60 px-3 py-1.5 font-mono text-xs font-semibold", children: [_jsx("span", { className: "truncate", children: group.label }), _jsxs("span", { className: "text-muted-foreground shrink-0 text-[10px] font-normal tabular-nums", children: [chosenInGroup, " / ", group.tools.length] })] })), [
|
|
61
|
+
{ label: "Read", rows: group.visible.filter((tool) => tool.readOnly) },
|
|
62
|
+
{ label: "Actions", rows: group.visible.filter((tool) => !tool.readOnly) },
|
|
63
|
+
].map(({ label, rows }) => rows.length === 0 ? null : (_jsx(SubsectionRows, { busy: busy, groupLabel: group.label, label: label, onApply: onApply, rows: rows, selectedKeys: selectedKeys }, label)))] }));
|
|
64
|
+
}
|
|
65
|
+
/** One Read or Actions run within a group: its header with select-all, then rows. */
|
|
66
|
+
function SubsectionRows({ busy, groupLabel, label, rows, selectedKeys, onApply, }) {
|
|
67
|
+
const rowKeys = rows.map((tool) => keyOf(tool));
|
|
68
|
+
const picked = rowKeys.filter((key) => selectedKeys.has(key)).length;
|
|
69
|
+
const allState = picked === 0 ? false : picked === rowKeys.length ? true : "indeterminate";
|
|
70
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 pt-2 pb-1", children: [_jsx("span", { className: label === "Actions"
|
|
71
|
+
? "text-primary text-[10px] font-semibold tracking-wide uppercase"
|
|
72
|
+
: "text-muted-foreground text-[10px] font-semibold tracking-wide uppercase", children: label }), _jsx(Checkbox, { "aria-label": `Select all ${label.toLowerCase()}${groupLabel === null ? "" : ` in ${groupLabel}`}`, checked: allState, disabled: busy, onCheckedChange: (next) => {
|
|
73
|
+
// The rows on screen only: with a filter active this is
|
|
74
|
+
// "select what I searched for", never the hidden rest.
|
|
75
|
+
onApply(rowKeys, next === true);
|
|
76
|
+
} })] }), _jsx("ul", { className: "divide-y divide-border/50", children: rows.map((tool) => (_jsx(PickerRow, { checked: selectedKeys.has(keyOf(tool)), disabled: busy,
|
|
77
|
+
// The subheader above already says Read or Actions.
|
|
78
|
+
kindBadge: false, onToggle: (key, checked) => {
|
|
79
|
+
onApply([key], checked);
|
|
80
|
+
}, tool: tool }, keyOf(tool)))) })] }));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Buckets by first tag in first-appearance order — a compiler emits tools in
|
|
84
|
+
* source order, so tag order is the API's own organization. Untagged tools
|
|
85
|
+
* trail as one "Other" bucket, unless nothing is tagged at all: then the one
|
|
86
|
+
* group is unlabeled rather than titled "Other" for no contrast.
|
|
87
|
+
*/
|
|
88
|
+
function groupByFirstTag(tools) {
|
|
89
|
+
const buckets = new Map();
|
|
90
|
+
for (const tool of tools) {
|
|
91
|
+
const firstTag = tool.tags?.[0];
|
|
92
|
+
if (firstTag === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
const bucket = buckets.get(firstTag);
|
|
95
|
+
if (bucket)
|
|
96
|
+
bucket.push(tool);
|
|
97
|
+
else
|
|
98
|
+
buckets.set(firstTag, [tool]);
|
|
99
|
+
}
|
|
100
|
+
if (buckets.size === 0)
|
|
101
|
+
return [{ label: null, tools }];
|
|
102
|
+
const untagged = tools.filter((tool) => (tool.tags?.length ?? 0) === 0);
|
|
103
|
+
if (untagged.length > 0) {
|
|
104
|
+
// Into the real "Other" tag's bucket when one exists, so two groups
|
|
105
|
+
// never share a label (and with it a React key).
|
|
106
|
+
const bucket = buckets.get(UNTAGGED_LABEL);
|
|
107
|
+
if (bucket)
|
|
108
|
+
bucket.push(...untagged);
|
|
109
|
+
else
|
|
110
|
+
buckets.set(UNTAGGED_LABEL, untagged);
|
|
111
|
+
}
|
|
112
|
+
return [...buckets.entries()].map(([label, bucketTools]) => ({
|
|
113
|
+
label,
|
|
114
|
+
tools: bucketTools,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { McpAccessToolListing } from "./mcp-access-types";
|
|
2
|
+
/** The coordinate a selector and a compiled operation are matched on. */
|
|
3
|
+
export declare const keyOf: (tool: {
|
|
4
|
+
method: string;
|
|
5
|
+
pathTemplate: string;
|
|
6
|
+
}) => string;
|
|
7
|
+
/**
|
|
8
|
+
* The slice of the app's inventory query the pickers read, shaped so a
|
|
9
|
+
* TanStack Query result can be passed whole. `data` is `undefined` for both
|
|
10
|
+
* "still loading" and "failed to load"; `isPending` says which.
|
|
11
|
+
*/
|
|
12
|
+
export type McpToolInventoryState = {
|
|
13
|
+
data: {
|
|
14
|
+
tools: McpAccessToolListing[];
|
|
15
|
+
} | undefined;
|
|
16
|
+
isPending: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** The one line an absent inventory renders, in both pickers. */
|
|
19
|
+
export declare function InventoryUnavailableNote({ isPending }: {
|
|
20
|
+
isPending: boolean;
|
|
21
|
+
}): import("@types/react").JSX.Element;
|
|
22
|
+
/** Why an empty selection blocks the mint, in both pickers' footers. */
|
|
23
|
+
export declare function EmptySelectionWarning(): import("@types/react").JSX.Element;
|
|
24
|
+
/**
|
|
25
|
+
* One selectable tool row. `kindBadge` shows the read-only/action label — the
|
|
26
|
+
* flat list's only signal, redundant under the grouped picker's Read/Actions
|
|
27
|
+
* subheaders.
|
|
28
|
+
*/
|
|
29
|
+
export declare function PickerRow({ checked, disabled, kindBadge, tool, onToggle, }: {
|
|
30
|
+
checked: boolean;
|
|
31
|
+
disabled: boolean;
|
|
32
|
+
kindBadge: boolean;
|
|
33
|
+
tool: McpAccessToolListing;
|
|
34
|
+
onToggle: (key: string, checked: boolean) => void;
|
|
35
|
+
}): import("@types/react").JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { Checkbox } from "../ui/checkbox";
|
|
4
|
+
/** The coordinate a selector and a compiled operation are matched on. */
|
|
5
|
+
export const keyOf = (tool) => `${tool.method} ${tool.pathTemplate}`;
|
|
6
|
+
/** The one line an absent inventory renders, in both pickers. */
|
|
7
|
+
export function InventoryUnavailableNote({ isPending }) {
|
|
8
|
+
return (_jsx("p", { className: "text-muted-foreground text-xs", children: isPending ? "Loading the tool list…" : "Couldn't load the tool list." }));
|
|
9
|
+
}
|
|
10
|
+
/** Why an empty selection blocks the mint, in both pickers' footers. */
|
|
11
|
+
export function EmptySelectionWarning() {
|
|
12
|
+
return (_jsx("p", { className: "text-destructive text-xs", children: "Select at least one tool to generate a connection code." }));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* One selectable tool row. `kindBadge` shows the read-only/action label — the
|
|
16
|
+
* flat list's only signal, redundant under the grouped picker's Read/Actions
|
|
17
|
+
* subheaders.
|
|
18
|
+
*/
|
|
19
|
+
export function PickerRow({ checked, disabled, kindBadge, tool, onToggle, }) {
|
|
20
|
+
const id = useId();
|
|
21
|
+
return (_jsxs("li", { className: "flex min-h-11 items-start gap-3 px-3 py-2", children: [_jsx(Checkbox, { checked: checked, disabled: disabled, id: id, onCheckedChange: (next) => {
|
|
22
|
+
onToggle(keyOf(tool), next === true);
|
|
23
|
+
} }), _jsxs("label", { className: "min-w-0 flex-1 cursor-pointer", htmlFor: id, children: [kindBadge ? (_jsxs("span", { className: "flex flex-wrap items-center gap-x-2", children: [_jsx("span", { className: "truncate font-mono text-xs", children: tool.name }), _jsx("span", { className: "text-muted-foreground text-[10px] uppercase", children: tool.readOnly ? "read-only" : "action" })] })) : (_jsx("span", { className: "block truncate font-mono text-xs", children: tool.name })), _jsx("span", { className: "text-muted-foreground block truncate text-xs", children: tool.description })] })] }));
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
|
|
2
|
+
import type { McpAccessToolSelector } from "./mcp-access-types";
|
|
3
|
+
export { keyOf } from "./mcp-access-tool-picker-internals";
|
|
4
|
+
export type { McpToolInventoryState } from "./mcp-access-tool-picker-internals";
|
|
5
|
+
/**
|
|
6
|
+
* An exact tool selection, applied as it is clicked. There is nothing to
|
|
7
|
+
* confirm: the list on screen is always what a code will be minted with, and an
|
|
8
|
+
* empty selection is reported but never stored because it is not a grant.
|
|
9
|
+
*
|
|
10
|
+
* Controlled rather than seeded once: the sheet owns the selection because the
|
|
11
|
+
* mint gate has to agree with these boxes, and a copy here could only be kept
|
|
12
|
+
* in step by remounting — which loses the scroll position on every click and
|
|
13
|
+
* still leaves the gate reading the older value.
|
|
14
|
+
*/
|
|
15
|
+
export declare function McpAccessToolPicker({ busy, inventory, selected, onChange, }: {
|
|
16
|
+
busy: boolean;
|
|
17
|
+
/** The compiled inventory a Custom selection chooses from. The app owns
|
|
18
|
+
* the fetch — and with it when the request is made at all. */
|
|
19
|
+
inventory: McpToolInventoryState;
|
|
20
|
+
selected: McpAccessToolSelector[];
|
|
21
|
+
onChange: (tools: McpAccessToolSelector[]) => void;
|
|
22
|
+
}): import("@types/react").JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Spinner } from "../ui/spinner";
|
|
3
|
+
import { EmptySelectionWarning, InventoryUnavailableNote, keyOf, PickerRow, } from "./mcp-access-tool-picker-internals";
|
|
4
|
+
export { keyOf } from "./mcp-access-tool-picker-internals";
|
|
5
|
+
/**
|
|
6
|
+
* An exact tool selection, applied as it is clicked. There is nothing to
|
|
7
|
+
* confirm: the list on screen is always what a code will be minted with, and an
|
|
8
|
+
* empty selection is reported but never stored because it is not a grant.
|
|
9
|
+
*
|
|
10
|
+
* Controlled rather than seeded once: the sheet owns the selection because the
|
|
11
|
+
* mint gate has to agree with these boxes, and a copy here could only be kept
|
|
12
|
+
* in step by remounting — which loses the scroll position on every click and
|
|
13
|
+
* still leaves the gate reading the older value.
|
|
14
|
+
*/
|
|
15
|
+
export function McpAccessToolPicker({ busy, inventory, selected, onChange, }) {
|
|
16
|
+
const selectedKeys = new Set(selected.map((tool) => keyOf(tool)));
|
|
17
|
+
if (inventory.data === undefined) {
|
|
18
|
+
return _jsx(InventoryUnavailableNote, { isPending: inventory.isPending });
|
|
19
|
+
}
|
|
20
|
+
const tools = inventory.data.tools;
|
|
21
|
+
const toggle = (key, checked) => {
|
|
22
|
+
const next = new Set(selectedKeys);
|
|
23
|
+
if (checked)
|
|
24
|
+
next.add(key);
|
|
25
|
+
else
|
|
26
|
+
next.delete(key);
|
|
27
|
+
onChange(tools
|
|
28
|
+
.filter((tool) => next.has(keyOf(tool)))
|
|
29
|
+
.map((tool) => ({ method: tool.method, pathTemplate: tool.pathTemplate })));
|
|
30
|
+
};
|
|
31
|
+
const chosen = tools.filter((tool) => selectedKeys.has(keyOf(tool)));
|
|
32
|
+
return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-2", children: [_jsx("ul", { className: "max-h-64 divide-y divide-border/50 overflow-y-auto rounded-md border border-border/60", children: tools.map((tool) => (_jsx(PickerRow, { checked: selectedKeys.has(keyOf(tool)), disabled: busy, kindBadge: true, onToggle: toggle, tool: tool }, keyOf(tool)))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [busy ? _jsx(Spinner, { "aria-label": "Saving selection", className: "text-muted-foreground" }) : null, _jsxs("p", { "aria-live": "polite", className: "text-muted-foreground text-xs", children: [chosen.length, " of ", tools.length, " tools selected"] }), chosen.length === 0 ? _jsx(EmptySelectionWarning, {}) : null] })] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary the mcp-access controls speak. The gateway owns these
|
|
3
|
+
* shapes; they travel unchanged from its database through an app's backend to
|
|
4
|
+
* here. Plain types rather than schemas: parsing belongs to the app's own
|
|
5
|
+
* data layer, and the components only need to name what they are handed.
|
|
6
|
+
*/
|
|
7
|
+
/** The gateway owns this vocabulary; it travels unchanged from its database to here. */
|
|
8
|
+
export type McpAccessMode = "read_only" | "all" | "selected";
|
|
9
|
+
/** Who adds the connector, which is the only thing the two setups differ on. */
|
|
10
|
+
export type ConnectPlan = "personal" | "workspace";
|
|
11
|
+
export type McpHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
12
|
+
/** The durable coordinate a Custom selection stores per operation. */
|
|
13
|
+
export type McpAccessToolSelector = {
|
|
14
|
+
method: McpHttpMethod;
|
|
15
|
+
pathTemplate: string;
|
|
16
|
+
};
|
|
17
|
+
/** A selector's coordinate plus its display labels. */
|
|
18
|
+
export type McpAccessToolListing = {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
readOnly: boolean;
|
|
22
|
+
method: McpHttpMethod;
|
|
23
|
+
pathTemplate: string;
|
|
24
|
+
/** The operation's OpenAPI tags; the grouped picker buckets by the first. */
|
|
25
|
+
tags?: readonly string[];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* What a mint produced, as the fields these controls report: the access the
|
|
29
|
+
* code was stamped with, and when it stops working. The code itself is not
|
|
30
|
+
* here — the app shows it through its own `CopyField`, and a type that never
|
|
31
|
+
* carries the credential cannot leak it. Discriminated on `access`: only a
|
|
32
|
+
* selected link carries tools, and it always does.
|
|
33
|
+
*/
|
|
34
|
+
export type McpConnectLink = {
|
|
35
|
+
access: Exclude<McpAccessMode, "selected">;
|
|
36
|
+
expiresAt: string;
|
|
37
|
+
} | {
|
|
38
|
+
access: "selected";
|
|
39
|
+
tools: McpAccessToolSelector[];
|
|
40
|
+
expiresAt: string;
|
|
41
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary the mcp-access controls speak. The gateway owns these
|
|
3
|
+
* shapes; they travel unchanged from its database through an app's backend to
|
|
4
|
+
* here. Plain types rather than schemas: parsing belongs to the app's own
|
|
5
|
+
* data layer, and the components only need to name what they are handed.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ConnectPlan } from "./mcp-access-types";
|
|
3
|
+
import type { McpConnectClient } from "./mcp-connect-clients";
|
|
4
|
+
/**
|
|
5
|
+
* Per-client setup for the connection.
|
|
6
|
+
*
|
|
7
|
+
* One client at a time, because every client wants the same values and only
|
|
8
|
+
* the steps differ — listing all of them stacked repeated the same URL five
|
|
9
|
+
* times and buried the one set of values the reader actually needs. The values
|
|
10
|
+
* sit above the tabs so switching clients never moves them.
|
|
11
|
+
*/
|
|
12
|
+
export declare function McpClientSetup({ clientId, code, connectorName, footer, onClientChange, onPlanChange, plan, url, urlNote, }: {
|
|
13
|
+
clientId: McpConnectClient;
|
|
14
|
+
/** The minted secret, absent until the member generates one. */
|
|
15
|
+
code: string | undefined;
|
|
16
|
+
/** Interpolated unquoted into the copyable CLI commands, so it must be a
|
|
17
|
+
* plain slug (no spaces, no leading `-`): quoting would have to pick a
|
|
18
|
+
* shell, and a leading `-` reaches the CLIs as an option. A name that
|
|
19
|
+
* fails the check renders the placeholder instead of a broken command. */
|
|
20
|
+
connectorName: string;
|
|
21
|
+
/** Minting lives in the card, beside the field it fills. */
|
|
22
|
+
footer: ReactNode;
|
|
23
|
+
onClientChange: (client: McpConnectClient) => void;
|
|
24
|
+
onPlanChange: (plan: ConnectPlan) => void;
|
|
25
|
+
plan: ConnectPlan;
|
|
26
|
+
url: string | undefined;
|
|
27
|
+
/** What to show in place of `url`, given why it is absent. */
|
|
28
|
+
urlNote: string;
|
|
29
|
+
}): import("@types/react").JSX.Element;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Tabs, TabsList, TabsTrigger } from "../ui/tabs";
|
|
3
|
+
import { CopyField } from "./copy-field";
|
|
4
|
+
import { CONNECT_CLIENTS, clientSteps, planAffectsClient } from "./mcp-connect-clients";
|
|
5
|
+
import { McpPlanChoice } from "./mcp-plan-choice";
|
|
6
|
+
/**
|
|
7
|
+
* The shape a connector name must have to be interpolated unquoted into the
|
|
8
|
+
* CLI commands below. Stricter than any one CLI needs: the first character
|
|
9
|
+
* may not be `-` because `--help-readonly` is a legal name by looser rules
|
|
10
|
+
* yet reaches both CLIs as an option, and there is no quoting that works for
|
|
11
|
+
* POSIX shells and Windows `cmd` at once.
|
|
12
|
+
*/
|
|
13
|
+
const PLAIN_SLUG = /^[\w.][\w.-]*$/;
|
|
14
|
+
/**
|
|
15
|
+
* Per-client setup for the connection.
|
|
16
|
+
*
|
|
17
|
+
* One client at a time, because every client wants the same values and only
|
|
18
|
+
* the steps differ — listing all of them stacked repeated the same URL five
|
|
19
|
+
* times and buried the one set of values the reader actually needs. The values
|
|
20
|
+
* sit above the tabs so switching clients never moves them.
|
|
21
|
+
*/
|
|
22
|
+
export function McpClientSetup({ clientId, code, connectorName, footer, onClientChange, onPlanChange, plan, url, urlNote, }) {
|
|
23
|
+
const client = CONNECT_CLIENTS.find((candidate) => candidate.id === clientId);
|
|
24
|
+
return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-3", children: [_jsx(ValueCard, { code: code, connectorName: connectorName, footer: footer, isCli: clientId === "cli", url: url, urlNote: urlNote }), _jsx(Tabs, { onValueChange: (value) => {
|
|
25
|
+
// Narrowed through the catalogue rather than asserted: the tab list is
|
|
26
|
+
// built from it, so a value that is not in it is not a client.
|
|
27
|
+
const next = CONNECT_CLIENTS.find((candidate) => candidate.id === value);
|
|
28
|
+
if (next)
|
|
29
|
+
onClientChange(next.id);
|
|
30
|
+
}, value: clientId, children: _jsx(TabsList, { className: "w-full justify-start gap-x-2 overflow-x-auto", variant: "line", children: CONNECT_CLIENTS.map((candidate) => (_jsxs(TabsTrigger, { className: "text-xs", value: candidate.id, children: [_jsx(candidate.Icon, { "aria-hidden": "true", className: "size-3" }), candidate.tab] }, candidate.id))) }) }), client && planAffectsClient(client) ? (_jsx(McpPlanChoice, { onChange: onPlanChange, plan: plan })) : null, _jsx("p", { className: "text-muted-foreground text-sm", children: client ? clientSteps(client, plan) : null })] }));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The values, shown once. The CLIs take a whole command rather than a name and
|
|
34
|
+
* a URL, so that tab swaps the card's contents instead of adding a third field
|
|
35
|
+
* nobody else uses.
|
|
36
|
+
*
|
|
37
|
+
* The footer sits with the fields it fills, so minting no longer needs a
|
|
38
|
+
* sentence pointing at a control elsewhere on screen.
|
|
39
|
+
*/
|
|
40
|
+
function ValueCard({ code, connectorName, footer, isCli, url, urlNote, }) {
|
|
41
|
+
// The URL is deployment configuration now, not a mint result, so reusing
|
|
42
|
+
// the minted secret's "not generated yet" would point at a button that can
|
|
43
|
+
// never fill it. The caller owns which of the absent-URL states applies.
|
|
44
|
+
// The commands interpolate the name bare, so only a plain slug may reach
|
|
45
|
+
// them — `--help-readonly` would parse as an option, a space as two argv
|
|
46
|
+
// entries — and neither is visible in the rendered field. Each absence
|
|
47
|
+
// names its own cause: a bad name with a good URL must not read as a
|
|
48
|
+
// missing URL.
|
|
49
|
+
const cliName = PLAIN_SLUG.test(connectorName) ? connectorName : undefined;
|
|
50
|
+
const cliNote = url !== undefined && cliName === undefined
|
|
51
|
+
? "The connector name can't be placed in a command — it needs to be a plain slug."
|
|
52
|
+
: urlNote;
|
|
53
|
+
return (_jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)] gap-3 rounded-lg border border-border bg-muted/30 p-3", children: [isCli ? (_jsxs(_Fragment, { children: [_jsx(CopyField, { label: "Claude Code CLI", placeholder: cliNote, value: url === undefined || cliName === undefined
|
|
54
|
+
? undefined
|
|
55
|
+
: `claude mcp add --transport http ${cliName} ${url}` }), _jsx(CopyField, { label: "Codex CLI", placeholder: cliNote, value: url === undefined || cliName === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: `codex mcp add ${cliName} --url ${url}` })] })) : (_jsxs(_Fragment, { children: [_jsx(CopyField, { label: "Name", placeholder: urlNote, value: url === undefined ? undefined : connectorName }), _jsx(CopyField, { label: "URL", placeholder: urlNote, value: url })] })), _jsx(CopyField, { label: "Connection code", value: code }), _jsx("div", { className: "border-border border-t pt-3", children: footer })] }));
|
|
58
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
import type { ConnectPlan } from "./mcp-access-types";
|
|
3
|
+
/**
|
|
4
|
+
* Closed: an unknown client is not a reason to show a broken dialog.
|
|
5
|
+
*
|
|
6
|
+
* One `cli` token, not one per CLI: both commands take the same two values, so
|
|
7
|
+
* a tab each would have shown the same link twice to say `--url` differently.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MCP_CONNECT_CLIENT_VALUES: readonly ["claude-web", "claude-desktop", "chatgpt-web", "chatgpt-desktop", "cli"];
|
|
10
|
+
export type McpConnectClient = (typeof MCP_CONNECT_CLIENT_VALUES)[number];
|
|
11
|
+
type ConnectClientMetadata = {
|
|
12
|
+
tab: string;
|
|
13
|
+
/** The label carries the brand, so the icon carries the surface — and the
|
|
14
|
+
* surface is what decides whether a local gateway works. */
|
|
15
|
+
Icon: LucideIcon;
|
|
16
|
+
steps: string;
|
|
17
|
+
/**
|
|
18
|
+
* Steps for a plan where only an owner may add a connector, or `null` for
|
|
19
|
+
* a client where the distinction does not exist.
|
|
20
|
+
*
|
|
21
|
+
* Null is the meaningful state rather than a missing key: a desktop app or
|
|
22
|
+
* CLI is installed per-machine, so everyone adds their own connector
|
|
23
|
+
* whatever plan the workspace is on, and the Plan control hides itself
|
|
24
|
+
* rather than offering a choice that changes nothing.
|
|
25
|
+
*/
|
|
26
|
+
workspaceSteps: string | null;
|
|
27
|
+
};
|
|
28
|
+
export type ConnectClient = ConnectClientMetadata & {
|
|
29
|
+
id: McpConnectClient;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The steps to show for one client on one plan.
|
|
33
|
+
*
|
|
34
|
+
* `workspaceSteps` is nullable, so this has to answer for a null; the sheet
|
|
35
|
+
* never asks, because it keys the plan by client and writes it only where the
|
|
36
|
+
* control renders. Answering with the personal steps rather than nothing keeps
|
|
37
|
+
* the worst case a wrong-but-followable instruction instead of a blank panel.
|
|
38
|
+
*/
|
|
39
|
+
export declare const clientSteps: (client: ConnectClient, plan: ConnectPlan) => string;
|
|
40
|
+
/** Whether the plan choice changes anything for this client. */
|
|
41
|
+
export declare const planAffectsClient: (client: ConnectClientMetadata) => boolean;
|
|
42
|
+
export declare const CONNECT_CLIENTS: ConnectClient[];
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { GlobeIcon, MonitorIcon, TerminalIcon } from "lucide-react";
|
|
2
|
+
/**
|
|
3
|
+
* Closed: an unknown client is not a reason to show a broken dialog.
|
|
4
|
+
*
|
|
5
|
+
* One `cli` token, not one per CLI: both commands take the same two values, so
|
|
6
|
+
* a tab each would have shown the same link twice to say `--url` differently.
|
|
7
|
+
*/
|
|
8
|
+
export const MCP_CONNECT_CLIENT_VALUES = [
|
|
9
|
+
"claude-web",
|
|
10
|
+
"claude-desktop",
|
|
11
|
+
"chatgpt-web",
|
|
12
|
+
"chatgpt-desktop",
|
|
13
|
+
"cli",
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Keyed by the URL token rather than listed alongside it: a `Record` over the
|
|
17
|
+
* client union is exhaustive, so adding a token without metadata fails to
|
|
18
|
+
* compile instead of deep-linking to a sheet with no tab and blank steps.
|
|
19
|
+
*/
|
|
20
|
+
const CONNECT_CLIENT_METADATA = {
|
|
21
|
+
"claude-web": {
|
|
22
|
+
Icon: GlobeIcon,
|
|
23
|
+
steps: "Customize → Connectors → Add → Add custom connector. Paste the values, click Add, then paste your connection code when Claude asks for it.",
|
|
24
|
+
tab: "Claude",
|
|
25
|
+
workspaceSteps: "An owner adds the connector once using the name and URL above. After that, select Connect on it and paste your own connection code. On Team and Enterprise plans only owners can add connectors.",
|
|
26
|
+
},
|
|
27
|
+
"claude-desktop": {
|
|
28
|
+
Icon: MonitorIcon,
|
|
29
|
+
steps: "Settings → Connectors → Add custom connector. Paste the values, then paste your connection code when Claude asks for it.",
|
|
30
|
+
tab: "Claude Desktop",
|
|
31
|
+
workspaceSteps: null,
|
|
32
|
+
},
|
|
33
|
+
"chatgpt-web": {
|
|
34
|
+
Icon: GlobeIcon,
|
|
35
|
+
steps: "Settings → Connectors → Create. Paste the values, tick the risk acknowledgement, and Create, then paste your connection code when asked.",
|
|
36
|
+
tab: "ChatGPT",
|
|
37
|
+
workspaceSteps: "An owner adds the connector once using the name and URL above. After that, select Connect on it and paste your own connection code.",
|
|
38
|
+
},
|
|
39
|
+
"chatgpt-desktop": {
|
|
40
|
+
Icon: MonitorIcon,
|
|
41
|
+
steps: "Settings → Connectors → Create. Choose Streamable HTTP, paste the values, and Save, then paste your connection code when asked.",
|
|
42
|
+
tab: "ChatGPT Desktop",
|
|
43
|
+
workspaceSteps: null,
|
|
44
|
+
},
|
|
45
|
+
cli: {
|
|
46
|
+
Icon: TerminalIcon,
|
|
47
|
+
steps: "Run the command for your CLI, then paste your connection code when asked on first use.",
|
|
48
|
+
tab: "CLI",
|
|
49
|
+
workspaceSteps: null,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The steps to show for one client on one plan.
|
|
54
|
+
*
|
|
55
|
+
* `workspaceSteps` is nullable, so this has to answer for a null; the sheet
|
|
56
|
+
* never asks, because it keys the plan by client and writes it only where the
|
|
57
|
+
* control renders. Answering with the personal steps rather than nothing keeps
|
|
58
|
+
* the worst case a wrong-but-followable instruction instead of a blank panel.
|
|
59
|
+
*/
|
|
60
|
+
export const clientSteps = (client, plan) => plan === "workspace" ? (client.workspaceSteps ?? client.steps) : client.steps;
|
|
61
|
+
/** Whether the plan choice changes anything for this client. */
|
|
62
|
+
export const planAffectsClient = (client) => client.workspaceSteps !== null;
|
|
63
|
+
export const CONNECT_CLIENTS = MCP_CONNECT_CLIENT_VALUES.map((id) => ({
|
|
64
|
+
id,
|
|
65
|
+
...CONNECT_CLIENT_METADATA[id],
|
|
66
|
+
}));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ConnectPlan } from "./mcp-access-types";
|
|
2
|
+
/**
|
|
3
|
+
* Who adds the connector, as a named pair matching the access control above it.
|
|
4
|
+
*
|
|
5
|
+
* This asks rather than infers. The alternative was gating on this app's own
|
|
6
|
+
* admin role, but a project admin here is not necessarily an owner of the
|
|
7
|
+
* Claude or ChatGPT workspace: the two are unrelated systems, so inferring one
|
|
8
|
+
* from the other would hide the URL from the person who needs it and show it
|
|
9
|
+
* to someone who cannot use it. Only the reader knows which plan they are on.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here changes what is minted or what it may reach. The plan selects
|
|
12
|
+
* which setup steps are true, and the values above stay identical either way.
|
|
13
|
+
*/
|
|
14
|
+
export declare function McpPlanChoice({ onChange, plan, }: {
|
|
15
|
+
onChange: (plan: ConnectPlan) => void;
|
|
16
|
+
plan: ConnectPlan;
|
|
17
|
+
}): import("@types/react").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button } from "../ui/button";
|
|
3
|
+
const PLAN_LABEL = {
|
|
4
|
+
personal: "Personal",
|
|
5
|
+
workspace: "Team or Enterprise",
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Who adds the connector, as a named pair matching the access control above it.
|
|
9
|
+
*
|
|
10
|
+
* This asks rather than infers. The alternative was gating on this app's own
|
|
11
|
+
* admin role, but a project admin here is not necessarily an owner of the
|
|
12
|
+
* Claude or ChatGPT workspace: the two are unrelated systems, so inferring one
|
|
13
|
+
* from the other would hide the URL from the person who needs it and show it
|
|
14
|
+
* to someone who cannot use it. Only the reader knows which plan they are on.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here changes what is minted or what it may reach. The plan selects
|
|
17
|
+
* which setup steps are true, and the values above stay identical either way.
|
|
18
|
+
*/
|
|
19
|
+
export function McpPlanChoice({ onChange, plan, }) {
|
|
20
|
+
return (_jsxs("div", { "aria-label": "Who adds the connector", className: "inline-flex w-fit rounded-md border border-border p-0.5", role: "group", children: [_jsx(PlanOption, { label: PLAN_LABEL.personal, onSelect: () => {
|
|
21
|
+
onChange("personal");
|
|
22
|
+
}, selected: plan === "personal" }), _jsx(PlanOption, { label: PLAN_LABEL.workspace, onSelect: () => {
|
|
23
|
+
onChange("workspace");
|
|
24
|
+
}, selected: plan === "workspace" })] }));
|
|
25
|
+
}
|
|
26
|
+
function PlanOption({ label, onSelect, selected, }) {
|
|
27
|
+
return (_jsx(Button, { "aria-pressed": selected, onClick: onSelect, size: "sm", variant: selected ? "secondary" : "ghost", children: label }));
|
|
28
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { CheckIcon } from "lucide-react";
|
|
3
|
+
import { CheckIcon, MinusIcon } from "lucide-react";
|
|
4
4
|
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
|
5
5
|
import { cn } from "../../lib/utils";
|
|
6
6
|
function Checkbox({ className, ...props }) {
|
|
7
|
-
return (_jsx(CheckboxPrimitive.Root, { "data-slot": "checkbox", className: cn("peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary", className), ...props, children: _jsx(CheckboxPrimitive.Indicator, { "data-slot": "checkbox-indicator", className: "grid place-content-center text-current transition-none", children: _jsx(CheckIcon, { className: "size-3.5" }) }) }));
|
|
7
|
+
return (_jsx(CheckboxPrimitive.Root, { "data-slot": "checkbox", className: cn("peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary", className), ...props, children: _jsx(CheckboxPrimitive.Indicator, { "data-slot": "checkbox-indicator", className: "grid place-content-center text-current transition-none", children: props.checked === "indeterminate" ? (_jsx(MinusIcon, { className: "size-3.5" })) : (_jsx(CheckIcon, { className: "size-3.5" })) }) }));
|
|
8
8
|
}
|
|
9
9
|
export { Checkbox };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/copy-field";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/copy-field";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/generated-connect-link";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/generated-connect-link";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-access-grouped-tool-picker";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-access-grouped-tool-picker";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-access-tool-picker";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-access-tool-picker";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-access-types";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-access-types";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-client-setup";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-client-setup";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-connect-clients";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-connect-clients";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-plan-choice";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "../components/blocks/mcp-plan-choice";
|