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
465 linesCodeBlameRaw
1/**
2 * Customize → Connectors (docs.g1t.sh/guides/customize/, "Connectors").
3 * Yours: the MCP servers the workspace connected, each with how it is
4 * doing, its tools, which agents it reaches and what an owner does with
5 * it; then g1t's own integrations the workspace has connected (GitHub,
6 * trackers, alerts, model providers), looked after on their settings
7 * pages. Discover: the directory, remote MCP servers first, each connected
8 * in a step; g1t's integrations with where each is set up; what each
9 * person connects for themselves; and what is coming.
10 */
11import { ArrowRight, ExternalLink, Plus } from "lucide-react";
12import { Link, data, useFetcher, useSearchParams } from "react-router";
13
14import type { SkillLibrary, WorkspaceConnector } from "@g1t/contracts";
15import { type ConnectorView, connectorPath, connectorsFor } from "@g1t/contracts/connectors";
16import { customizePath } from "@g1t/contracts/customize";
17
18import type { Route } from "./+types/connectors";
19import { useCustomize } from "./layout";
20import { agentsAction, readOrNull } from "../../../components/agents/actions.server";
21import type { ActionResult } from "../../../components/agents/dialogs";
22import { ConnectorMark } from "../../../components/connectors";
23import { Caveat, Chips, FeaturedBanner, ListingCard, ListingGrid, NoAnswer, RequestButton, SectionHead, StateBadge, Waiting } from "../../../components/customize";
24import { ConnectorMenu, ConnectorReach, SetKeyDialog, WorkspaceConnectorMark } from "../../../components/customize-connector";
25import { ButtonLink, EmptyState, SubmitButton } from "../../../components/ui";
26import { Alert } from "../../../components/ui/alert";
27import { Badge } from "../../../components/ui/badge";
28import { Button } from "../../../components/ui/button";
29import { Card } from "../../../components/ui/card";
30import { Hint } from "../../../components/ui/hint";
31import { workspaceConnections } from "../../../lib/connected.server";
32import { type ConnectedState, categoryTitle } from "../../../lib/connectors";
33import { type IntegrationEntry, type McpDirectoryEntry, type ReachTargets, connectorListingRef, connectorNewPath, connectorPagePath, connectorsDirectory, flashOf, fromWords, matches, personalDirectory, readQuery, readView, requestedBy, waitingFor, withParams } from "../../../lib/customize";
34import { addConnectorThen, connectorAction } from "../../../lib/customize.server";
35import { page } from "../../../lib/meta";
36import { skillLibrary, workspaceAgents } from "../../../lib/services.server";
37import { requireUser, roleIn } from "../../../lib/session.server";
38
39export { shouldRevalidate } from "../../../lib/customize-revalidate";
40
41export function meta({ params, ...args }: Route.MetaArgs) {
42 return page(args, { title: `Connectors · Customize · ${params.owner} · g1t` });
43}
44
45type Loaded = {
46 connectors: WorkspaceConnector[] | null;
47 connected: Record<string, ConnectedState>;
48 unavailable: Record<string, string>;
49 /** Who an owner may give connectors to; null for anyone else. */
50 targets: ReachTargets | null;
51};
52
53export async function loader({ params, context, request }: Route.LoaderArgs): Promise<Loaded> {
54 const viewer = requireUser(context, request);
55 const slug = params.owner.toLowerCase();
56 const role = roleIn(viewer, slug);
57 if (!role) throw data(null, { status: 404 });
58 const [connectors, connections, library] = await Promise.all([
59 readOrNull(workspaceAgents.connectors(slug, viewer)),
60 workspaceConnections(slug, viewer),
61 role === "owner" ? readOrNull(skillLibrary.library(slug, viewer)) : Promise.resolve(null as SkillLibrary | null),
62 ]);
63 return { connectors, ...connections, targets: library ? { can_manage: true, teams: library.teams, agents: library.agents } : null };
64}
65
66/** Connect a directory entry, or anything a connector's menu does (lib/customize.server.ts). */
67export async function action({ params, context, request }: Route.ActionArgs): Promise<Response | ActionResult> {
68 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
69 const intent = String(form.get("intent") ?? "");
70 if (intent === "connect") {
71 const id = String(form.get("directory") ?? "");
72 const view = connectorsFor("workspace").find((v) => v.id === id);
73 if (!view?.mcp) return { ok: false, intent, error: "There is no such connector in the directory." };
74 const header = view.mcp.auth === "header" ? { name: String(form.get("header_name") ?? "").trim() || view.mcp.header || "Authorization", value: String(form.get("value") ?? "") } : null;
75 return addConnectorThen(slug, viewer, { from: { kind: "directory", id }, auth: view.mcp.auth, header });
76 }
77 return (await connectorAction(intent, form, slug, viewer, null)) ?? { ok: false, intent, error: "Unknown request." };
78}
79
80export default function CustomizeConnectors({ loaderData }: Route.ComponentProps) {
81 const [params] = useSearchParams();
82 const flash = flashOf(params);
83 // An old integration's address lands here with `?directory=`: that is Discover.
84 const discover = readView(params) === "discover" || params.has("directory");
85 return (
86 <div className="space-y-8">
87 {flash && (
88 <Alert tone={flash.tone} asChild>
89 <p role="alert">{flash.text}</p>
90 </Alert>
91 )}
92 {discover ? <Discover data={loaderData} /> : <Yours data={loaderData} />}
93 </div>
94 );
95}
96
97// ── Yours ────────────────────────────────────────────────────────────────
98
99function Yours({ data: loaded }: { data: Loaded }) {
100 const { slug, owner } = useCustomize();
101 const [params] = useSearchParams();
102 const query = readQuery(params);
103 const views = connectorsFor("workspace");
104 const connectors = (loaded.connectors ?? []).filter((connector) => matches([connector.title, connector.name, connector.url, connector.about, ...connector.tools.map((tool) => tool.name)], query));
105 const builtIn = views.filter((view) => loaded.connected[view.id] && matches([view.name, view.description, categoryTitle(view.category)], query));
106 return (
107 <>
108 <section aria-labelledby="connectors">
109 <SectionHead
110 id="connectors"
111 title="Connectors"
112 aside={
113 owner ? (
114 <ButtonLink to={connectorNewPath(slug)} variant="outline" size="xs">
115 <Plus size={12} />
116 Add by address
117 </ButtonLink>
118 ) : loaded.connectors ? (
119 `${connectors.length}`
120 ) : undefined
121 }
122 >
123 MCP servers the workspace connected once and gives to agents: every agent, a team&apos;s, or one. Each tool a server lists becomes an ability of the agents that have it, with a level like any other.
124 </SectionHead>
125 {!loaded.connectors ? (
126 <NoAnswer what="Connectors" />
127 ) : connectors.length === 0 ? (
128 <EmptyState title={query ? "No connector matches" : "No connectors yet"}>
129 {query ? (
130 "Try another word, or look in Discover."
131 ) : (
132 <>
133 Connect one from{" "}
134 <Link to={withParams(customizePath("connectors", slug), params, { view: "discover" })} className="text-accent hover:underline">
135 Discover
136 </Link>
137 {owner ? ", add one by address, or install a plugin that brings one." : ", or ask an owner for one."}
138 </>
139 )}
140 </EmptyState>
141 ) : (
142 <ListingGrid>
143 {connectors.map((connector) => (
144 <ConnectorCard key={connector.id} connector={connector} slug={slug} owner={owner} targets={loaded.targets} />
145 ))}
146 </ListingGrid>
147 )}
148 </section>
149
150 {builtIn.length > 0 && (
151 <section aria-labelledby="built-in">
152 <SectionHead id="built-in" title="Connected integrations" aside={<Link to={`/${slug}/-/integrations`} className="hover:text-fg">Integrations settings</Link>}>
153 g1t&apos;s own, connected for the whole workspace and looked after on their settings pages. Agents get their actions as abilities on their Abilities tab.
154 </SectionHead>
155 <ListingGrid>
156 {builtIn.map((view) => (
157 <IntegrationCard key={view.id} entry={{ view, state: loaded.connected[view.id]!, why: null, href: loaded.connected[view.id]!.manage ?? (view.href ? connectorPath(view.href, slug) : null) }} slug={slug} owner={owner} requested={false} waiting={0} />
158 ))}
159 </ListingGrid>
160 </section>
161 )}
162 </>
163 );
164}
165
166/** A workspace connector: how it is doing, what it offers, which agents have it, and what an owner does with it. */
167function ConnectorCard({ connector, slug, owner, targets }: { connector: WorkspaceConnector; slug: string; owner: boolean; targets: ReachTargets | null }) {
168 const caveat = connector.from.kind === "directory" ? (connectorsFor("workspace").find((view) => view.id === (connector.from as { id: string }).id)?.mcp?.caveat ?? null) : null;
169 return (
170 <ListingCard
171 mark={<WorkspaceConnectorMark connector={connector} />}
172 title={connector.title}
173 to={connectorPagePath(slug, connector.id)}
174 description={connector.about ?? connector.url}
175 badge={
176 <>
177 <StateBadge state={connector.state} problem={connector.problem} />
178 {owner && <ConnectorMenu connector={connector} caveat={caveat} />}
179 </>
180 }
181 meta={`${connector.tools.length} ${connector.tools.length === 1 ? "tool" : "tools"} · ${fromWords(connector.from)}`}
182 action={
183 connector.state === "needs_key" && owner ? (
184 <SetKeyDialog
185 connector={connector}
186 caveat={caveat}
187 trigger={
188 <Button type="button" size="xs" variant="accent">
189 Set the key
190 </Button>
191 }
192 />
193 ) : connector.state === "needs_sign_in" && owner ? (
194 <SignInButton connector={connector} />
195 ) : null
196 }
197 >
198 <ConnectorReach connector={connector} targets={targets} />
199 </ListingCard>
200 );
201}
202
203/** Signing a connector in: the browser leaves for the server's own page. */
204function SignInButton({ connector }: { connector: Pick<WorkspaceConnector, "id"> }) {
205 const fetcher = useFetcher<ActionResult>({ key: `connector-sign-in-${connector.id}` });
206 return (
207 <fetcher.Form method="post">
208 <input type="hidden" name="intent" value="sign_in" />
209 <input type="hidden" name="connector" value={connector.id} />
210 <SubmitButton size="xs" variant="accent" fetcher={fetcher} match={{ intent: "sign_in", connector: connector.id }} pending="Going to sign in…">
211 Sign in
212 </SubmitButton>
213 </fetcher.Form>
214 );
215}
216
217// ── Discover ─────────────────────────────────────────────────────────────
218
219function Discover({ data: loaded }: { data: Loaded }) {
220 const { slug, owner, username, requests } = useCustomize();
221 const [params] = useSearchParams();
222 const query = readQuery(params);
223 const chosen = params.get("directory");
224 const directory = connectorsDirectory(connectorsFor("workspace"), loaded.connectors ?? [], loaded.connected, loaded.unavailable, slug, query);
225 const personal = personalDirectory(connectorsFor("personal"), query);
226 const asked = requests?.requests ?? [];
227 const empty = directory.mcp.length + directory.integrations.length + directory.soon.length + personal.length === 0;
228 return (
229 <>
230 <FeaturedBanner
231 title="Connect what your team already uses"
232 action={
233 owner ? (
234 <ButtonLink to={connectorNewPath(slug)} variant="outline" size="sm">
235 <Plus size={14} />
236 Add by address
237 </ButtonLink>
238 ) : undefined
239 }
240 >
241 Remote MCP servers the workspace connects once, signing in with the browser or a key, then gives to agents. Any server with an HTTPS address works; these are the ones g1t knows.
242 </FeaturedBanner>
243 {empty && <EmptyState title="Nothing matches">Try another word. An owner can add any MCP server by its address.</EmptyState>}
244 {directory.mcp.length > 0 && (
245 <section aria-labelledby="mcp">
246 <SectionHead id="mcp" title="MCP servers" aside={`${directory.mcp.length}`}>
247 Connected from here, in a step. Their tools reach the agents you choose.
248 </SectionHead>
249 <ListingGrid>
250 {directory.mcp.map((entry) => (
251 <McpCard key={entry.view.id} entry={entry} slug={slug} owner={owner} highlighted={entry.view.id === chosen} requested={requestedBy(asked, connectorListingRef(entry.view.id), username)} waiting={owner ? waitingFor(asked, connectorListingRef(entry.view.id)) : 0} />
252 ))}
253 </ListingGrid>
254 </section>
255 )}
256 {directory.integrations.length > 0 && (
257 <section aria-labelledby="integrations">
258 <SectionHead id="integrations" title="g1t's integrations" aside={`${directory.integrations.length}`}>
259 Built into g1t and set up on their own pages: import and mirror code, read trackers and alerts, run models on your own account.
260 </SectionHead>
261 <ListingGrid>
262 {directory.integrations.map((entry) => (
263 <IntegrationCard key={entry.view.id} entry={entry} slug={slug} owner={owner} highlighted={entry.view.id === chosen} requested={requestedBy(asked, `integration:${entry.view.id}`, username)} waiting={owner ? waitingFor(asked, `integration:${entry.view.id}`) : 0} />
264 ))}
265 </ListingGrid>
266 </section>
267 )}
268 {personal.length > 0 && (
269 <section aria-labelledby="personal">
270 <SectionHead id="personal" title="Connected by each person" aside={`${personal.length}`}>
271 Each person connects these for themselves, under their own settings, and agents use them only when that person asks.
272 </SectionHead>
273 <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
274 {personal.map((view) => (
275 <PersonalRow key={view.id} view={view} slug={slug} />
276 ))}
277 </ul>
278 </section>
279 )}
280 {directory.soon.length > 0 && (
281 <section aria-labelledby="soon">
282 <SectionHead id="soon" title="Soon" aside={`${directory.soon.length}`}>
283 Planned, not built. Nobody can connect these yet.
284 </SectionHead>
285 <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
286 {directory.soon.map((view) => (
287 <SoonRow key={view.id} view={view} />
288 ))}
289 </ul>
290 </section>
291 )}
292 </>
293 );
294}
295
296/** A remote MCP server from the directory: connected (its page), or Connect, or Request. */
297function McpCard({ entry, slug, owner, highlighted, requested, waiting }: { entry: McpDirectoryEntry; slug: string; owner: boolean; highlighted: boolean; requested: boolean; waiting: number }) {
298 const { view, connector } = entry;
299 const fetcher = useFetcher<ActionResult>({ key: `connect-${view.id}` });
300 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
301 const mcp = view.mcp!;
302 const action = connector ? (
303 <ButtonLink to={connectorPagePath(slug, connector.id)} variant="outline" size="xs">
304 Open
305 <ArrowRight size={12} />
306 </ButtonLink>
307 ) : !owner ? (
308 <RequestButton slug={slug} listing={connectorListingRef(view.id)} name={view.name} requested={requested} size="xs" />
309 ) : mcp.auth === "header" ? (
310 <SetKeyDialog
311 connector={{ id: `directory-${view.id}`, title: view.name, header_name: mcp.header ?? null }}
312 caveat={mcp.caveat}
313 trigger={
314 <Button type="button" size="xs">
315 <Plus size={13} />
316 Connect
317 </Button>
318 }
319 connect={view.id}
320 />
321 ) : (
322 <fetcher.Form method="post">
323 <input type="hidden" name="intent" value="connect" />
324 <input type="hidden" name="directory" value={view.id} />
325 <SubmitButton size="xs" fetcher={fetcher} match={{ intent: "connect", directory: view.id }} pending={mcp.auth === "oauth" ? "Going to sign in…" : "Connecting…"}>
326 <Plus size={13} />
327 Connect
328 </SubmitButton>
329 </fetcher.Form>
330 );
331 return (
332 <div className={highlighted ? "rounded-xl ring-2 ring-accent/60" : undefined}>
333 <ListingCard
334 mark={<ConnectorMark view={view} />}
335 title={view.name}
336 to={connector ? connectorPagePath(slug, connector.id) : undefined}
337 description={view.description}
338 badge={
339 <>
340 <Caveat text={mcp.caveat} />
341 {connector ? <StateBadge state={connector.state} problem={connector.problem} /> : <Badge>{mcp.auth === "oauth" ? "Signs in" : mcp.auth === "header" ? "Needs a key" : "Open"}</Badge>}
342 </>
343 }
344 meta={
345 <>
346 {categoryTitle(view.category)}
347 {waiting > 0 && (
348 <>
349 {" · "}
350 <Waiting slug={slug} count={waiting} />
351 </>
352 )}
353 </>
354 }
355 action={action}
356 >
357 <Chips items={view.capabilities} label="What it does" />
358 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
359 </ListingCard>
360 </div>
361 );
362}
363
364/** One of g1t's integrations: connected (Manage), connectable (Connect, on its setup page), not available here (why), or asked for. */
365function IntegrationCard({ entry, slug, owner, highlighted = false, requested, waiting }: { entry: IntegrationEntry; slug: string; owner: boolean; highlighted?: boolean; requested: boolean; waiting: number }) {
366 const { view, state, why, href } = entry;
367 const action = state ? (
368 href ? (
369 <ButtonLink to={href} variant="outline" size="xs">
370 Manage
371 <ArrowRight size={12} />
372 </ButtonLink>
373 ) : null
374 ) : why ? null : owner ? (
375 href ? (
376 <ButtonLink to={href} size="xs">
377 <Plus size={13} />
378 Connect
379 </ButtonLink>
380 ) : null
381 ) : (
382 <RequestButton slug={slug} listing={`integration:${view.id}`} name={view.name} requested={requested} size="xs" />
383 );
384 return (
385 <div className={highlighted ? "rounded-xl ring-2 ring-accent/60" : undefined}>
386 <ListingCard
387 mark={<ConnectorMark view={view} />}
388 title={view.name}
389 to={state && href ? href : undefined}
390 description={view.description}
391 muted={!!why}
392 badge={
393 state ? (
394 state.problem ? (
395 <Hint label={state.problem}>
396 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
397 <Badge tone="warn">Needs attention</Badge>
398 </span>
399 </Hint>
400 ) : (
401 <Badge tone="success">Connected</Badge>
402 )
403 ) : why ? (
404 <Hint label={why}>
405 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent/40">
406 <Badge>Not available here</Badge>
407 </span>
408 </Hint>
409 ) : null
410 }
411 meta={
412 <>
413 {state?.detail ?? categoryTitle(view.category)}
414 {waiting > 0 && (
415 <>
416 {" · "}
417 <Waiting slug={slug} count={waiting} />
418 </>
419 )}
420 </>
421 }
422 action={action}
423 >
424 <Chips items={view.capabilities} muted={!!why} label="What it does" />
425 </ListingCard>
426 </div>
427 );
428}
429
430/** What each person connects for themselves: a row leading to their own settings. */
431function PersonalRow({ view, slug }: { view: ConnectorView; slug: string }) {
432 return (
433 <Card asChild className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:border-line-strong">
434 <li>
435 <ConnectorMark view={view} size={28} />
436 <span className="min-w-0 grow">
437 <span className="block truncate text-sm">{view.name}</span>
438 <span className="block truncate text-xs text-muted">{view.description}</span>
439 </span>
440 <ButtonLink to={connectorPath(view.href!, slug)} variant="ghost" size="xs" className="shrink-0">
441 Connect yours
442 <ExternalLink size={12} />
443 </ButtonLink>
444 </li>
445 </Card>
446 );
447}
448
449/** A planned connector: muted and dashed, with nothing to press. */
450function SoonRow({ view }: { view: ConnectorView }) {
451 return (
452 <Card asChild tone="plain" className="flex items-center gap-3 border-dashed px-3 py-2.5">
453 <li>
454 <span className="shrink-0 opacity-60 grayscale">
455 <ConnectorMark view={view} size={28} />
456 </span>
457 <span className="min-w-0 grow">
458 <span className="block truncate text-sm text-fg-soft">{view.name}</span>
459 <span className="block truncate text-xs text-faint">{view.description}</span>
460 </span>
461 <Badge className="shrink-0">Soon</Badge>
462 </li>
463 </Card>
464 );
465}