| 1 | /** |
| 2 | * Adding a connector: from the directory (`?directory=<id>`, the entry |
| 3 | * filled in and what is known about its sign-in said), or by address: a |
| 4 | * name, an HTTPS address, and how it signs in (not at all, a key sent as |
| 5 | * a header, or the browser). Once added, the browser signs it in when it |
| 6 | * needs that, or lands on its page. |
| 7 | */ |
| 8 | import { ArrowLeft, Plug } from "lucide-react"; |
| 9 | import { useId, useState } from "react"; |
| 10 | import { Form, Link, data, useActionData, useNavigation } from "react-router"; |
| 11 | |
| 12 | import { type ConnectorView, connectorById, connectorView } from "@g1t/contracts/connectors"; |
| 13 | import { type ConnectorAuth, customizePath } from "@g1t/contracts/customize"; |
| 14 | |
| 15 | import type { Route } from "./+types/connector-new"; |
| 16 | import { agentsAction } from "../../../components/agents/actions.server"; |
| 17 | import type { ActionResult } from "../../../components/agents/dialogs"; |
| 18 | import { ConnectorMark } from "../../../components/connectors"; |
| 19 | import { Alert } from "../../../components/ui/alert"; |
| 20 | import { Button } from "../../../components/ui/button"; |
| 21 | import { Card } from "../../../components/ui/card"; |
| 22 | import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../../../components/ui/field"; |
| 23 | import { Input } from "../../../components/ui/input"; |
| 24 | import { RadioGroup, RadioOption } from "../../../components/ui/radio-group"; |
| 25 | import { AUTH_WORDS, readAuth } from "../../../lib/customize"; |
| 26 | import { addConnectorThen } from "../../../lib/customize.server"; |
| 27 | import { page } from "../../../lib/meta"; |
| 28 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 29 | |
| 30 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 31 | return page(args, { title: `Add a connector · Customize · ${params.owner} · g1t` }); |
| 32 | } |
| 33 | |
| 34 | /** The directory entry asked for, when there is one with an MCP server. */ |
| 35 | function entryOf(id: string | null): ConnectorView | null { |
| 36 | const connector = id ? connectorById(id) : null; |
| 37 | const view = connector ? connectorView(connector, "workspace") : null; |
| 38 | return view?.mcp ? view : null; |
| 39 | } |
| 40 | |
| 41 | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ slug: string; owner: boolean; entry: ConnectorView | null }> { |
| 42 | const viewer = requireUser(context, request); |
| 43 | const slug = params.owner.toLowerCase(); |
| 44 | const role = roleIn(viewer, slug); |
| 45 | if (!role) throw data(null, { status: 404 }); |
| 46 | return { slug, owner: role === "owner", entry: entryOf(new URL(request.url).searchParams.get("directory")) }; |
| 47 | } |
| 48 | |
| 49 | export async function action({ params, context, request }: Route.ActionArgs): Promise<Response | ActionResult> { |
| 50 | const { viewer, slug, form } = await agentsAction(request, context, params.owner); |
| 51 | const entry = entryOf(String(form.get("directory") ?? "") || null); |
| 52 | const auth: ConnectorAuth = entry ? entry.mcp!.auth : readAuth(form.get("auth")); |
| 53 | const header = auth === "header" ? { name: String(form.get("header_name") ?? "").trim() || "Authorization", value: String(form.get("value") ?? "") } : null; |
| 54 | if (entry) return addConnectorThen(slug, viewer, { from: { kind: "directory", id: entry.id }, auth, header }); |
| 55 | const name = String(form.get("name") ?? "").trim(); |
| 56 | const url = String(form.get("url") ?? "").trim(); |
| 57 | if (!name || !url) return { ok: false, intent: "connect", error: "Give the connector a name and its address." }; |
| 58 | return addConnectorThen(slug, viewer, { from: { kind: "address", name, url }, auth, header }); |
| 59 | } |
| 60 | |
| 61 | export default function ConnectorNew({ loaderData }: Route.ComponentProps) { |
| 62 | const { slug, owner, entry } = loaderData; |
| 63 | const result = useActionData<ActionResult>(); |
| 64 | const navigation = useNavigation(); |
| 65 | const busy = navigation.state !== "idle" && navigation.formMethod === "POST"; |
| 66 | const [auth, setAuth] = useState<ConnectorAuth>(entry?.mcp?.auth ?? "none"); |
| 67 | const id = useId(); |
| 68 | const error = result && !result.ok ? result.error : null; |
| 69 | const back = ( |
| 70 | <Link to={`${customizePath("connectors", slug)}?view=discover`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| 71 | <ArrowLeft size={14} /> |
| 72 | Connectors |
| 73 | </Link> |
| 74 | ); |
| 75 | if (!owner) { |
| 76 | return ( |
| 77 | <div className="space-y-6"> |
| 78 | {back} |
| 79 | <Alert tone="info">Only the workspace's owners add connectors. Find one in Discover and choose Request, and they'll hear about it.</Alert> |
| 80 | </div> |
| 81 | ); |
| 82 | } |
| 83 | return ( |
| 84 | <div className="space-y-6"> |
| 85 | {back} |
| 86 | <header> |
| 87 | <h2 className="text-xl font-semibold tracking-tight">{entry ? `Connect ${entry.name}` : "Add a connector"}</h2> |
| 88 | <p className="mt-1 max-w-2xl text-sm text-muted"> |
| 89 | {entry |
| 90 | ? entry.description |
| 91 | : "An MCP server the workspace connects once, then gives to agents: every agent, a team's, or one. Its tools become abilities, each with a level, like any other."} |
| 92 | </p> |
| 93 | </header> |
| 94 | <Card asChild className="max-w-2xl p-5"> |
| 95 | <Form method="post" className="grid gap-5"> |
| 96 | {entry ? ( |
| 97 | <> |
| 98 | <input type="hidden" name="directory" value={entry.id} /> |
| 99 | <div className="flex items-center gap-3"> |
| 100 | <ConnectorMark view={entry} /> |
| 101 | <div className="min-w-0"> |
| 102 | <p className="text-sm font-medium">{entry.name}</p> |
| 103 | <p className="truncate font-mono text-xs text-muted">{entry.mcp!.url}</p> |
| 104 | </div> |
| 105 | </div> |
| 106 | <p className="text-sm text-muted">{AUTH_WORDS[entry.mcp!.auth]}.</p> |
| 107 | </> |
| 108 | ) : ( |
| 109 | <> |
| 110 | <Field> |
| 111 | <FieldLabel htmlFor={`${id}-name`}>Name</FieldLabel> |
| 112 | <Input id={`${id}-name`} name="name" placeholder="weather" required autoComplete="off" pattern="[a-z0-9][a-z0-9-]{1,31}" spellCheck={false} /> |
| 113 | <FieldDescription>Lowercase letters, digits and hyphens. Agents call its tools as name__tool.</FieldDescription> |
| 114 | </Field> |
| 115 | <Field> |
| 116 | <FieldLabel htmlFor={`${id}-url`}>Address</FieldLabel> |
| 117 | <Input id={`${id}-url`} name="url" type="url" placeholder="https://mcp.example.com/mcp" required autoComplete="off" spellCheck={false} /> |
| 118 | <FieldDescription>Where it speaks MCP over HTTPS, on a public host.</FieldDescription> |
| 119 | </Field> |
| 120 | <FieldSet> |
| 121 | <FieldLegend>How it signs in</FieldLegend> |
| 122 | <RadioGroup name="auth" value={auth} onValueChange={(next) => setAuth(readAuth(next))}> |
| 123 | <RadioOption value="none" label="No sign-in" description="Open to anyone, like a documentation server." /> |
| 124 | <RadioOption value="header" label="A key" description="A header sent with every call, kept sealed." /> |
| 125 | <RadioOption value="oauth" label="The browser" description="You sign in on the server's own page; g1t keeps the tokens and refreshes them." /> |
| 126 | </RadioGroup> |
| 127 | </FieldSet> |
| 128 | </> |
| 129 | )} |
| 130 | {entry?.mcp?.caveat && ( |
| 131 | <Alert tone="warn" asChild> |
| 132 | <p>{entry.mcp.caveat}</p> |
| 133 | </Alert> |
| 134 | )} |
| 135 | {auth === "header" && ( |
| 136 | <> |
| 137 | <Field> |
| 138 | <FieldLabel htmlFor={`${id}-header`}>Header</FieldLabel> |
| 139 | <Input id={`${id}-header`} name="header_name" defaultValue={entry?.mcp?.header ?? "Authorization"} required autoComplete="off" spellCheck={false} className="font-mono" /> |
| 140 | </Field> |
| 141 | <Field> |
| 142 | <FieldLabel htmlFor={`${id}-value`}>Value</FieldLabel> |
| 143 | <Input id={`${id}-value`} name="value" type="password" placeholder="Bearer …" required autoComplete="off" spellCheck={false} className="font-mono" /> |
| 144 | <FieldDescription>The whole value, scheme included. It is sealed when saved and never shown again.</FieldDescription> |
| 145 | </Field> |
| 146 | </> |
| 147 | )} |
| 148 | {error && ( |
| 149 | <Alert asChild> |
| 150 | <p role="alert">{error}</p> |
| 151 | </Alert> |
| 152 | )} |
| 153 | <div className="flex items-center justify-end gap-2"> |
| 154 | <Button type="submit" variant="accent" disabled={busy}> |
| 155 | <Plug size={14} /> |
| 156 | {busy ? (auth === "oauth" ? "Going to sign in…" : "Connecting…") : auth === "oauth" ? "Connect and sign in" : "Connect"} |
| 157 | </Button> |
| 158 | </div> |
| 159 | </Form> |
| 160 | </Card> |
| 161 | </div> |
| 162 | ); |
| 163 | } |