Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
163 linesCodeBlameRaw
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 */
8import { ArrowLeft, Plug } from "lucide-react";
9import { useId, useState } from "react";
10import { Form, Link, data, useActionData, useNavigation } from "react-router";
11
12import { type ConnectorView, connectorById, connectorView } from "@g1t/contracts/connectors";
13import { type ConnectorAuth, customizePath } from "@g1t/contracts/customize";
14
15import type { Route } from "./+types/connector-new";
16import { agentsAction } from "../../../components/agents/actions.server";
17import type { ActionResult } from "../../../components/agents/dialogs";
18import { ConnectorMark } from "../../../components/connectors";
19import { Alert } from "../../../components/ui/alert";
20import { Button } from "../../../components/ui/button";
21import { Card } from "../../../components/ui/card";
22import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../../../components/ui/field";
23import { Input } from "../../../components/ui/input";
24import { RadioGroup, RadioOption } from "../../../components/ui/radio-group";
25import { AUTH_WORDS, readAuth } from "../../../lib/customize";
26import { addConnectorThen } from "../../../lib/customize.server";
27import { page } from "../../../lib/meta";
28import { requireUser, roleIn } from "../../../lib/session.server";
29
30export 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. */
35function 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
41export 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
49export 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
61export 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&apos;s owners add connectors. Find one in Discover and choose Request, and they&apos;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}