From beef2a1eb7d095d78aeb64b96f9d4439a4a335a5 Mon Sep 17 00:00:00 2001 From: syn Date: Wed, 3 Jun 2026 21:38:47 -0500 Subject: [PATCH 01/18] feat(mcp-gateway): add management dashboard --- .specs/mcp-gateway-auth.md | 3 +- .../mcp-gateway/McpGatewayDetailContent.tsx | 473 +++++++++++++++++ .../mcp-gateway/McpGatewayListContent.tsx | 260 +++++++++ .../mcp-gateway/McpGatewaySetupContent.tsx | 420 +++++++++++++++ .../cloud/mcp-gateway/[configId]/page.tsx | 19 + .../app/(app)/cloud/mcp-gateway/new/page.tsx | 14 + .../src/app/(app)/cloud/mcp-gateway/page.tsx | 14 + .../components/OrganizationAppSidebar.tsx | 9 + .../(app)/components/PersonalAppSidebar.tsx | 9 + .../cloud/mcp-gateway/[configId]/page.tsx | 20 + .../[id]/cloud/mcp-gateway/new/page.tsx | 19 + .../[id]/cloud/mcp-gateway/page.tsx | 19 + .../mcp-gateway/oauth/mcp/callback/route.ts | 3 + .../[ownerId]/[configId]/[routeKey]/route.ts | 0 .../web/src/lib/mcp-gateway/config-service.ts | 120 ++++- apps/web/src/lib/mcp-gateway/config.ts | 10 +- .../src/lib/mcp-gateway/oauth-flow.test.ts | 50 +- .../lib/mcp-gateway/provider-oauth-service.ts | 149 +++++- apps/web/src/lib/mcp-gateway/routes.ts | 10 + apps/web/src/lib/mcp-gateway/token-service.ts | 7 +- .../src/routers/mcp-gateway-router.test.ts | 64 +++ apps/web/src/routers/mcp-gateway-router.ts | 493 ++++++++++++++++++ apps/web/src/routers/root-router.ts | 2 + .../mcp-gateway/scripts/generate-keys.mjs | 1 + .../src/handlers/connect.handler.ts | 29 +- .../handlers/protected-resource.handler.ts | 8 +- services/mcp-gateway/src/lib/jwt.ts | 6 +- .../src/mcp-gateway.worker.test.ts | 19 + 28 files changed, 2197 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx create mode 100644 apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx create mode 100644 apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx create mode 100644 apps/web/src/app/(app)/cloud/mcp-gateway/[configId]/page.tsx create mode 100644 apps/web/src/app/(app)/cloud/mcp-gateway/new/page.tsx create mode 100644 apps/web/src/app/(app)/cloud/mcp-gateway/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/[configId]/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/new/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/page.tsx rename apps/web/src/app/api/mcp-gateway/oauth/register/{ => resource}/[scope]/[ownerId]/[configId]/[routeKey]/route.ts (100%) create mode 100644 apps/web/src/lib/mcp-gateway/routes.ts create mode 100644 apps/web/src/routers/mcp-gateway-router.test.ts create mode 100644 apps/web/src/routers/mcp-gateway-router.ts diff --git a/.specs/mcp-gateway-auth.md b/.specs/mcp-gateway-auth.md index 8f47382fc8..92c02eb7c7 100644 --- a/.specs/mcp-gateway-auth.md +++ b/.specs/mcp-gateway-auth.md @@ -111,7 +111,7 @@ when they appear in all capitals. | `GET /.well-known/oauth-authorization-server/oauth/authorize` | App canonical route, Worker discovery alias | Metadata alias for clients that discover from the authorization route. The Worker alias redirects to the app canonical route. | | `GET /.well-known/oauth-authorization-server/mcp-connect/...` | Worker discovery alias | Path-aware compatibility alias for clients that start discovery from one scoped connect URL; redirects to app canonical metadata. | | `POST /api/mcp-gateway/oauth/register` | App | Dynamic client registration. | -| `POST /api/mcp-gateway/oauth/register/{scope}/{owner_id}/{config_id}/{route_key}` | App | Resource-specific registration after route eligibility discovery. | +| `POST /api/mcp-gateway/oauth/register/resource/{scope}/{owner_id}/{config_id}/{route_key}` | App | Resource-specific registration after route eligibility discovery. | | `GET|PUT|DELETE /api/mcp-gateway/oauth/register/{client_id}` | App | Registration management authorized by registration token. | | `GET /api/mcp-gateway/oauth/authorize` | App | Generic authorization-code flow; requires `resource`. | | `GET /api/mcp-gateway/oauth/authorize/{scope}/{owner_id}/{config_id}/{route_key}` | App | Route-specific authorization-code flow. | @@ -371,4 +371,3 @@ when they appear in all capitals. - Group/team assignment. - External `/v0.1/servers` registry projection. - A Worker-side provider token-exchange API. -- Dashboard UI or feature-flagged management pages. diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx new file mode 100644 index 0000000000..9aecd201ae --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx @@ -0,0 +1,473 @@ +'use client'; + +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { SecretTokenInput } from '@/components/ui/secret-token-input'; +import { ArrowLeft, Copy, RotateCw, ShieldAlert, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +type McpGatewayDetailContentProps = { + configId: string; + organizationId?: string; +}; + +function requiresProviderSignIn(authMode: string) { + return authMode === 'oauth_dynamic' || authMode === 'oauth_static'; +} + +function authLabel(authMode: string) { + switch (authMode) { + case 'none': + return 'No provider sign-in'; + case 'static_headers': + return 'Static headers'; + case 'oauth_dynamic': + return 'Provider sign-in'; + case 'oauth_static': + return 'Provider sign-in'; + default: + return authMode; + } +} + +export function McpGatewayDetailContent({ + configId, + organizationId, +}: McpGatewayDetailContentProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const routes = getMcpGatewayRoutes(organizationId); + const managedConfigInput = { configId, organizationId }; + const [staticHeaderName, setStaticHeaderName] = useState('Authorization'); + const [staticHeaderValue, setStaticHeaderValue] = useState(''); + const [providerClientId, setProviderClientId] = useState(''); + const [providerClientSecret, setProviderClientSecret] = useState(''); + const [assignedUserId, setAssignedUserId] = useState(''); + const detailQuery = useQuery( + organizationId + ? trpc.mcpGateway.getOrganization.queryOptions({ organizationId, configId }) + : trpc.mcpGateway.getPersonal.queryOptions({ configId }) + ); + const refresh = () => { + void queryClient.invalidateQueries({ + queryKey: organizationId + ? trpc.mcpGateway.getOrganization.queryKey({ organizationId, configId }) + : trpc.mcpGateway.getPersonal.queryKey({ configId }), + }); + void queryClient.invalidateQueries({ + queryKey: organizationId + ? trpc.mcpGateway.listOrganization.queryKey({ organizationId }) + : trpc.mcpGateway.listPersonal.queryKey(), + }); + }; + const rotateMutation = useMutation( + trpc.mcpGateway.rotateRoute.mutationOptions({ + onSuccess: () => { + toast.success('Connect URL rotated'); + refresh(); + }, + onError: error => toast.error(error.message || 'Could not rotate the connect URL'), + }) + ); + const disableMutation = useMutation( + trpc.mcpGateway.disable.mutationOptions({ + onSuccess: () => { + toast.success('Connection disabled'); + refresh(); + }, + onError: error => toast.error(error.message || 'Could not disable the connection'), + }) + ); + const deleteMutation = useMutation( + trpc.mcpGateway.delete.mutationOptions({ + onSuccess: () => { + toast.success('Connection deleted'); + window.location.assign(routes.list); + }, + onError: error => toast.error(error.message || 'Could not delete the connection'), + }) + ); + const staticHeadersMutation = useMutation( + trpc.mcpGateway.upsertStaticHeaders.mutationOptions({ + onSuccess: () => { + toast.success('Static headers saved'); + setStaticHeaderValue(''); + refresh(); + }, + onError: error => toast.error(error.message || 'Could not save static headers'), + }) + ); + const assignMutation = useMutation( + trpc.mcpGateway.assignUser.mutationOptions({ + onSuccess: () => { + toast.success('User assigned'); + setAssignedUserId(''); + refresh(); + }, + onError: error => toast.error(error.message || 'Could not assign the user'), + }) + ); + const revokeAssignmentMutation = useMutation( + trpc.mcpGateway.revokeAssignment.mutationOptions({ + onSuccess: () => { + toast.success('User access revoked'); + refresh(); + }, + onError: error => toast.error(error.message || 'Could not revoke user access'), + }) + ); + const providerSignInMutation = useMutation( + trpc.mcpGateway.startProviderSignIn.mutationOptions({ + onSuccess: data => { + window.location.assign(data.authorizationUrl); + }, + onError: error => toast.error(error.message || 'Could not start provider sign-in'), + }) + ); + const staticProviderMutation = useMutation( + trpc.mcpGateway.upsertStaticProviderCredentials.mutationOptions({ + onSuccess: () => { + toast.success('Provider credentials saved'); + setProviderClientSecret(''); + refresh(); + }, + onError: error => toast.error(error.message || 'Could not save provider credentials'), + }) + ); + + async function copyConnectUrl(url: string) { + try { + await navigator.clipboard.writeText(url); + toast.success('Connect URL copied'); + } catch { + toast.error('Could not copy the connect URL'); + } + } + + if (detailQuery.isLoading) { + return
Loading connection…
; + } + if (detailQuery.isError || !detailQuery.data) { + return ( +
+

We couldn't load this connection. Try again.

+ +
+ ); + } + const connection = detailQuery.data; + + return ( +
+
+ + + Back to connections + +
+

{connection.name}

+ + {connection.enabled ? 'Active' : 'Disabled'} + +
+

{connection.remoteUrl}

+
+ + + + Connection + Current connection definition and runtime status. + + +
+

Connection

+
+
+
Remote MCP URL
+
{connection.remoteUrl}
+
+
+
Sharing mode
+
+ {connection.sharingMode === 'single_user' ? 'Single user' : 'Shared endpoint'} +
+
+
+
Provider sign-in
+
{authLabel(connection.authMode)}
+
+
+
Path passthrough
+
{connection.pathPassthrough ? 'Allowed' : 'Disabled'}
+
+
+
+ +
+

Access

+
+ {organizationId && ( +
+
Assigned users
+
{connection.assignmentCount}
+
+ )} +
+
Active instances
+
{connection.instanceCount}
+
+
+
Provider grants
+
{connection.activeGrantCount}
+
+
+ {!organizationId && ( +

+ Personal connections are available only in your personal context. +

+ )} + {organizationId && ( + <> +
+ setAssignedUserId(event.target.value)} + placeholder="User ID" + aria-label="Assign user ID" + className="sm:max-w-xs" + /> + +
+ {connection.assignments.length > 0 && ( +
+ {connection.assignments.map(assignment => ( +
+ + {assignment.userId} + + +
+ ))} +
+ )} + + )} +
+ +
+

Provider sign-in

+ {!requiresProviderSignIn(connection.authMode) && ( +

+ This connection does not require provider sign-in. +

+ )} + {requiresProviderSignIn(connection.authMode) && ( + <> +

+ {connection.activeGrantCount > 0 + ? 'At least one assigned user has an active provider sign-in.' + : organizationId + ? 'Assigned users complete provider sign-in when they start using this connection.' + : 'No active provider sign-in yet. Start sign-in before using this connection.'} +

+
+ {!organizationId && ( + + )} + + {connection.hasDynamicRegistration + ? 'Automatic provider sign-in available' + : 'Automatic provider sign-in not available'} + + + {connection.hasStaticProviderCredentials + ? 'Manual provider credentials saved' + : 'Manual provider credentials not saved'} + +
+ + )} +
+ +
+

Credentials

+

+ Stored secrets are not shown again after saving. +

+ {connection.authMode === 'static_headers' && ( +
+
+ + setStaticHeaderName(event.target.value)} + /> +
+
+ + setStaticHeaderValue(event.target.value)} + placeholder="Secret value" + toggleLabel="Show static header value" + /> +
+ +
+ )} + {connection.authMode === 'oauth_static' && ( +
+
+ + setProviderClientId(event.target.value)} + toggleLabel="Show provider client ID" + /> +
+
+ + setProviderClientSecret(event.target.value)} + placeholder="Secret value" + toggleLabel="Show provider client secret" + /> +
+ +
+ )} + {(connection.authMode === 'none' || connection.authMode === 'oauth_dynamic') && ( +

+ This connection does not use manually managed credentials. +

+ )} +
+ +
+

Connect URL

+
+ + {connection.canonicalUrl} + +
+ + +
+
+

+ Rotating the route key invalidates the old connect URL immediately. +

+
+ +
+

Danger zone

+
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx new file mode 100644 index 0000000000..7853247077 --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx @@ -0,0 +1,260 @@ +'use client'; + +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Copy, ExternalLink, Plus, RotateCw } from 'lucide-react'; +import { toast } from 'sonner'; + +type McpGatewayListContentProps = { + organizationId?: string; +}; + +function statusBadge(params: { enabled: boolean; activeGrantCount: number; authMode: string }) { + if (!params.enabled) return Disabled; + if (params.authMode === 'none' || params.authMode === 'static_headers') { + return Ready; + } + if (params.activeGrantCount > 0) return Provider signed in; + return Needs sign-in; +} + +function authLabel(authMode: string) { + switch (authMode) { + case 'none': + return 'No provider sign-in'; + case 'static_headers': + return 'Static headers'; + case 'oauth_dynamic': + return 'Provider sign-in'; + case 'oauth_static': + return 'Provider sign-in'; + default: + return authMode; + } +} + +export function McpGatewayListContent({ organizationId }: McpGatewayListContentProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const routes = getMcpGatewayRoutes(organizationId); + const [filter, setFilter] = useState(''); + const listQuery = useQuery( + organizationId + ? trpc.mcpGateway.listOrganization.queryOptions({ organizationId }) + : trpc.mcpGateway.listPersonal.queryOptions() + ); + const rotateMutation = useMutation( + trpc.mcpGateway.rotateRoute.mutationOptions({ + onSuccess: () => { + toast.success('Connect URL rotated'); + void queryClient.invalidateQueries({ + queryKey: organizationId + ? trpc.mcpGateway.listOrganization.queryKey({ organizationId }) + : trpc.mcpGateway.listPersonal.queryKey(), + }); + }, + onError: () => toast.error('Could not rotate the connect URL'), + }) + ); + const filteredConnections = useMemo(() => { + const connections = listQuery.data ?? []; + const query = filter.trim().toLowerCase(); + if (!query) return connections; + return connections.filter(connection => + [connection.name, connection.authMode, connection.sharingMode] + .join(' ') + .toLowerCase() + .includes(query) + ); + }, [filter, listQuery.data]); + + async function copyConnectUrl(url: string) { + try { + await navigator.clipboard.writeText(url); + toast.success('Connect URL copied'); + } catch { + toast.error('Could not copy the connect URL'); + } + } + + return ( +
+
+
+

MCP Gateway

+

+ Create and manage remote MCP server connections for Kilo Code. +

+
+ +
+ + + +
+ Connections + + Remote MCP servers that can be connected through Kilo Code. + +
+ setFilter(event.target.value)} + placeholder="Filter connections" + aria-label="Filter connections" + className="w-full sm:w-64" + /> +
+ + {listQuery.isLoading && ( +
+ + + +
+ )} + {listQuery.isError && ( +
+

We couldn't load connections. Try again.

+ +
+ )} + {!listQuery.isLoading && !listQuery.isError && filteredConnections.length === 0 && ( +
+

+ {listQuery.data?.length + ? 'No connections match that filter.' + : 'No MCP connections yet.'} +

+

+ {listQuery.data?.length + ? 'Clear the filter to see every connection.' + : 'Create one to connect Kilo Code to a remote MCP server.'} +

+ {listQuery.data?.length ? ( + + ) : ( + + )} +
+ )} + {!listQuery.isLoading && !listQuery.isError && filteredConnections.length > 0 && ( +
+ + + + Name + Status + Provider sign-in + Sharing + {organizationId && Assigned users} + Last updated + Actions + + + + {filteredConnections.map(connection => ( + + +
+ + {connection.name} + + + {connection.canonicalUrl} + +
+
+ {statusBadge(connection)} + + {authLabel(connection.authMode)} + + + {connection.sharingMode === 'single_user' ? 'Single user' : 'Shared'} + + {organizationId && ( + {connection.assignmentCount} + )} + + {new Date(connection.updatedAt).toLocaleString()} + + +
+ + + +
+
+
+ ))} +
+
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx new file mode 100644 index 0000000000..7e31986712 --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx @@ -0,0 +1,420 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { useTRPC } from '@/lib/trpc/utils'; +import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { SecretTokenInput } from '@/components/ui/secret-token-input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ArrowLeft, ArrowRight, Check } from 'lucide-react'; +import Link from 'next/link'; +import { toast } from 'sonner'; + +type McpGatewaySetupContentProps = { + organizationId?: string; +}; + +type SetupDraft = { + name: string; + remoteUrl: string; + authMode: 'none' | 'static_headers' | 'oauth_dynamic' | 'oauth_static'; + sharingMode: 'single_user' | 'multi_user'; + initialAssignedUserId: string; + providerIssuer: string; + staticProviderClientId: string; + staticProviderClientSecret: string; + pathPassthrough: boolean; +}; + +function isAuthMode(value: string): value is SetupDraft['authMode'] { + return ['none', 'static_headers', 'oauth_dynamic', 'oauth_static'].includes(value); +} + +function isSharingMode(value: string): value is SetupDraft['sharingMode'] { + return value === 'single_user' || value === 'multi_user'; +} + +export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupContentProps) { + const trpc = useTRPC(); + const router = useRouter(); + const routes = getMcpGatewayRoutes(organizationId); + const [step, setStep] = useState(1); + const [draft, setDraft] = useState({ + name: '', + remoteUrl: '', + authMode: 'oauth_dynamic', + sharingMode: organizationId ? 'multi_user' : 'single_user', + initialAssignedUserId: '', + providerIssuer: '', + staticProviderClientId: '', + staticProviderClientSecret: '', + pathPassthrough: false, + }); + const discoveryMutation = useMutation( + trpc.mcpGateway.discover.mutationOptions({ + onError: error => + toast.error( + error.message || + "We couldn't discover that remote MCP server. Check the URL and try again." + ), + }) + ); + const createPersonalMutation = useMutation( + trpc.mcpGateway.createPersonal.mutationOptions({ + onSuccess: data => { + toast.success('Connection created'); + router.push(routes.detail(data.configId)); + }, + onError: error => + toast.error( + error.message || "We couldn't create the connection. Check the details and try again." + ), + }) + ); + const createOrganizationMutation = useMutation( + trpc.mcpGateway.createOrganization.mutationOptions({ + onSuccess: data => { + toast.success('Connection created'); + router.push(routes.detail(data.configId)); + }, + onError: error => + toast.error( + error.message || "We couldn't create the connection. Check the details and try again." + ), + }) + ); + + const currentRemoteUrl = (() => { + try { + return new URL(draft.remoteUrl).toString(); + } catch { + return null; + } + })(); + const discovery = + discoveryMutation.data && discoveryMutation.data.remoteUrl === currentRemoteUrl + ? discoveryMutation.data + : undefined; + const dynamicAvailable = + discovery?.providerCandidates.some(candidate => candidate.hasRegistrationEndpoint) ?? false; + const selectedProviderIssuer = + draft.providerIssuer || discovery?.providerCandidates[0]?.issuer || ''; + const selectedAuthMode = useMemo(() => { + if (draft.authMode === 'oauth_dynamic' && !dynamicAvailable && discovery) return 'oauth_static'; + return draft.authMode; + }, [draft.authMode, discovery, dynamicAvailable]); + + function updateDraft(values: Partial) { + setDraft(current => ({ ...current, ...values })); + } + + function runDiscovery() { + if (!draft.name || !draft.remoteUrl) { + toast.error('Please enter a connection name and remote MCP URL.'); + return; + } + discoveryMutation.mutate({ remoteUrl: draft.remoteUrl }); + } + + function createConnection() { + if (organizationId) { + createOrganizationMutation.mutate({ + organizationId, + name: draft.name, + remoteUrl: draft.remoteUrl, + authMode: selectedAuthMode, + providerIssuer: selectedProviderIssuer || undefined, + staticProviderClientId: draft.staticProviderClientId || undefined, + staticProviderClientSecret: draft.staticProviderClientSecret || undefined, + sharingMode: draft.sharingMode, + initialAssignedUserId: + draft.sharingMode === 'single_user' + ? draft.initialAssignedUserId || undefined + : undefined, + pathPassthrough: draft.pathPassthrough, + }); + return; + } + createPersonalMutation.mutate({ + name: draft.name, + remoteUrl: draft.remoteUrl, + authMode: selectedAuthMode, + providerIssuer: selectedProviderIssuer || undefined, + + staticProviderClientId: draft.staticProviderClientId || undefined, + staticProviderClientSecret: draft.staticProviderClientSecret || undefined, + pathPassthrough: draft.pathPassthrough, + }); + } + + return ( +
+
+ + + Back to connections + +

Create connection

+

+ Connect Kilo Code to a remote MCP server. We check the remote server before asking for + provider sign-in details. +

+
+ + + + Step {step} of 3 + + {step === 1 && 'Connection details'} + {step === 2 && 'Remote discovery'} + {step === 3 && 'Access and credentials'} + + + + {step === 1 && ( +
+
+ + updateDraft({ name: event.target.value })} + placeholder="Production tools" + /> +
+
+ + { + discoveryMutation.reset(); + updateDraft({ remoteUrl: event.target.value, providerIssuer: '' }); + }} + placeholder="https://mcp.example.com" + /> +

+ The server must be publicly reachable over HTTPS. +

+
+ {organizationId && ( +
+ + +
+ )} + {organizationId && draft.sharingMode === 'single_user' && ( +
+ + updateDraft({ initialAssignedUserId: event.target.value })} + placeholder="User ID" + /> +
+ )} + +
+ )} + {step === 2 && ( +
+
+

{draft.remoteUrl || 'Remote MCP URL not set'}

+

+ Discovery checks the remote server and provider metadata. +

+
+ {discovery && ( +
+
+ Provider discovery + {discovery.providerCandidates.length > 0 ? 'Found' : 'Not found'} +
+
+ Dynamic registration + {dynamicAvailable ? 'Available' : 'Not available'} +
+ {discovery.providerCandidates.length > 1 && ( +
+ + +
+ )} + {!dynamicAvailable && ( +

+ This provider does not advertise automatic registration. Add manual provider + credentials before creating the connection. +

+ )} +
+ )} + {!discovery && ( +

Run discovery to continue.

+ )} +
+ )} + {step === 3 && ( +
+
+ + + {selectedAuthMode === 'oauth_static' && ( +
+

+ Stored provider credentials are not shown again after saving. +

+ + updateDraft({ staticProviderClientId: event.target.value }) + } + placeholder="Provider client ID" + aria-label="Provider client ID" + toggleLabel="Show provider client ID" + /> + + updateDraft({ staticProviderClientSecret: event.target.value }) + } + placeholder="Provider client secret" + aria-label="Provider client secret" + toggleLabel="Show provider client secret" + /> +
+ )} +
+
+

Review

+
+
+
Name
+
{draft.name}
+
+
+
Remote server
+
{draft.remoteUrl}
+
+
+
Provider sign-in
+
{selectedAuthMode.replaceAll('_', ' ')}
+
+
+
+
+ )} +
+ +
+ {step === 2 && ( + + )} + {step < 3 && ( + + )} + {step === 3 && ( + + )} +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/[configId]/page.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/[configId]/page.tsx new file mode 100644 index 0000000000..52dda5a97c --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/[configId]/page.tsx @@ -0,0 +1,19 @@ +import { getUserFromAuth } from '@/lib/user/server'; +import { notFound } from 'next/navigation'; +import { PageContainer } from '@/components/layouts/PageContainer'; +import { McpGatewayDetailContent } from '../McpGatewayDetailContent'; + +export default async function McpGatewayDetailPage({ + params, +}: { + params: Promise<{ configId: string }>; +}) { + const { user } = await getUserFromAuth({ adminOnly: true }); + if (!user) notFound(); + const { configId } = await params; + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/new/page.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/new/page.tsx new file mode 100644 index 0000000000..17d1184eb7 --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/new/page.tsx @@ -0,0 +1,14 @@ +import { getUserFromAuth } from '@/lib/user/server'; +import { notFound } from 'next/navigation'; +import { PageContainer } from '@/components/layouts/PageContainer'; +import { McpGatewaySetupContent } from '../McpGatewaySetupContent'; + +export default async function McpGatewaySetupPage() { + const { user } = await getUserFromAuth({ adminOnly: true }); + if (!user) notFound(); + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/page.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/page.tsx new file mode 100644 index 0000000000..a9ba3b67df --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/page.tsx @@ -0,0 +1,14 @@ +import { getUserFromAuth } from '@/lib/user/server'; +import { notFound } from 'next/navigation'; +import { PageContainer } from '@/components/layouts/PageContainer'; +import { McpGatewayListContent } from './McpGatewayListContent'; + +export default async function McpGatewayPage() { + const { user } = await getUserFromAuth({ adminOnly: true }); + if (!user) notFound(); + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx b/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx index f3c929a4de..83a77b3c4f 100644 --- a/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx @@ -174,6 +174,15 @@ export default function OrganizationAppSidebar({ icon: Cloud, url: `/organizations/${organizationId}/cloud`, }, + ...(user?.is_admin + ? [ + { + title: 'MCP Gateway', + icon: Cable, + url: `/organizations/${organizationId}/cloud/mcp-gateway`, + }, + ] + : []), { title: 'Sessions', icon: List, diff --git a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx index c9ffcead5e..9070dcbcf1 100644 --- a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx @@ -137,6 +137,15 @@ export default function PersonalAppSidebar(props: React.ComponentProps; +}) { + const { id, configId } = await params; + return ( + { + if (!isGlobalAdmin) notFound(); + return ; + }} + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/new/page.tsx b/apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/new/page.tsx new file mode 100644 index 0000000000..0b6cfef3ff --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/new/page.tsx @@ -0,0 +1,19 @@ +import { notFound } from 'next/navigation'; +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { McpGatewaySetupContent } from '@/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent'; + +export default async function OrganizationMcpGatewaySetupPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + return ( + { + if (!isGlobalAdmin) notFound(); + return ; + }} + /> + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/page.tsx b/apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/page.tsx new file mode 100644 index 0000000000..178a6cd388 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/cloud/mcp-gateway/page.tsx @@ -0,0 +1,19 @@ +import { notFound } from 'next/navigation'; +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { McpGatewayListContent } from '@/app/(app)/cloud/mcp-gateway/McpGatewayListContent'; + +export default async function OrganizationMcpGatewayPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + return ( + { + if (!isGlobalAdmin) notFound(); + return ; + }} + /> + ); +} diff --git a/apps/web/src/app/api/mcp-gateway/oauth/mcp/callback/route.ts b/apps/web/src/app/api/mcp-gateway/oauth/mcp/callback/route.ts index 1516baceda..946b7bce94 100644 --- a/apps/web/src/app/api/mcp-gateway/oauth/mcp/callback/route.ts +++ b/apps/web/src/app/api/mcp-gateway/oauth/mcp/callback/route.ts @@ -30,6 +30,9 @@ export async function GET(request: NextRequest) { code, userId: user.id, }); + if (!callback.authorizationRequest) { + return NextResponse.redirect(callback.completionUrl); + } const finalized = await services.authorizationService.completeProviderAuthorization({ authorizationRequest: callback.authorizationRequest, }); diff --git a/apps/web/src/app/api/mcp-gateway/oauth/register/[scope]/[ownerId]/[configId]/[routeKey]/route.ts b/apps/web/src/app/api/mcp-gateway/oauth/register/resource/[scope]/[ownerId]/[configId]/[routeKey]/route.ts similarity index 100% rename from apps/web/src/app/api/mcp-gateway/oauth/register/[scope]/[ownerId]/[configId]/[routeKey]/route.ts rename to apps/web/src/app/api/mcp-gateway/oauth/register/resource/[scope]/[ownerId]/[configId]/[routeKey]/route.ts diff --git a/apps/web/src/lib/mcp-gateway/config-service.ts b/apps/web/src/lib/mcp-gateway/config-service.ts index 37126c999b..a700f35418 100644 --- a/apps/web/src/lib/mcp-gateway/config-service.ts +++ b/apps/web/src/lib/mcp-gateway/config-service.ts @@ -7,6 +7,7 @@ import { buildScopedConnectRootPath, createGatewayError, GatewayErrorCode, + GatewayAuthMode, } from '@kilocode/mcp-gateway'; import { mcp_gateway_assignments, @@ -19,7 +20,6 @@ import { } from '@kilocode/db/schema'; import { encryptKeyedEnvelope } from '@kilocode/encryption'; import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; -import type { GatewayAuthMode } from '@kilocode/mcp-gateway'; import type { GatewayAppConfig } from './config'; import { createGatewayRepository, type GatewayRepository } from './repository'; import { configSecretAad, nowIso, randomToken } from './crypto'; @@ -34,10 +34,16 @@ export function createConfigService(params: { config: GatewayAppConfig; discoveryService: GatewayDiscoveryService; }) { - async function discoverProviderMetadata(input: { remoteUrl: string; authMode: GatewayAuthMode }) { + async function discoverProviderMetadata(input: { + remoteUrl: string; + authMode: GatewayAuthMode; + providerIssuer?: string; + }) { if (input.authMode !== 'oauth_dynamic' && input.authMode !== 'oauth_static') return null; const discovery = await params.discoveryService.discoverRemoteProvider(input.remoteUrl); - const provider = discovery.providerCandidates[0]; + const provider = input.providerIssuer + ? discovery.providerCandidates.find(candidate => candidate.issuer === input.providerIssuer) + : discovery.providerCandidates[0]; if (!provider) { throw createGatewayError( GatewayErrorCode.InvalidRequest, @@ -55,17 +61,77 @@ export function createConfigService(params: { return provider; } + function initialStaticProviderCredentials(input: { + authMode: GatewayAuthMode; + staticProviderClientId?: string; + staticProviderClientSecret?: string; + }) { + const hasClientId = input.staticProviderClientId !== undefined; + const hasClientSecret = input.staticProviderClientSecret !== undefined; + if (!hasClientId && !hasClientSecret) return null; + if ( + input.authMode !== GatewayAuthMode.OAuthStatic || + !input.staticProviderClientId || + !input.staticProviderClientSecret + ) { + throw createGatewayError( + GatewayErrorCode.InvalidRequest, + 'Manual provider credentials require OAuth static mode and both credential values', + 400 + ); + } + return { + clientId: input.staticProviderClientId, + clientSecret: input.staticProviderClientSecret, + }; + } + + function encryptSecret(input: { + configId: string; + kind: (typeof GatewaySecretKind)[keyof typeof GatewaySecretKind]; + value: Record; + }) { + return encryptKeyedEnvelope( + JSON.stringify({ kind: input.kind, value: input.value }), + secretScheme, + params.config.credentialKeyset.active, + configSecretAad(input.configId, input.kind) + ); + } + + async function insertInitialStaticProviderCredentials( + tx: GatewayRepository['database'], + configId: string, + credentials: { clientId: string; clientSecret: string } | null + ) { + if (!credentials) return; + await tx.insert(mcp_gateway_config_secrets).values({ + config_id: configId, + secret_kind: GatewaySecretKind.StaticProviderCredentials, + encrypted_secret: encryptSecret({ + configId, + kind: GatewaySecretKind.StaticProviderCredentials, + value: credentials, + }), + }); + } + async function createPersonalConfig(input: { userId: string; name: string; remoteUrl: string; authMode: GatewayAuthMode; + providerIssuer?: string; + staticProviderClientId?: string; + staticProviderClientSecret?: string; pathPassthrough?: boolean; }) { + const staticProviderCredentials = initialStaticProviderCredentials(input); await validatePublicHttpsDestination(input.remoteUrl); const discoveredProviderMetadata = await discoverProviderMetadata(input); return await params.repository.database.transaction(async tx => { const repository = createGatewayRepository(tx); + const auditService = createAuditService(repository); const created = await repository.createConfigWithRoute({ ownerScope: GatewayOwnerScope.Personal, ownerId: input.userId, @@ -78,7 +144,12 @@ export function createConfigService(params: { createdByUserId: input.userId, gatewayBaseUrl: params.config.gatewayBaseUrl, }); - await createAuditService(repository).record({ + await insertInitialStaticProviderCredentials( + tx, + created.config.config_id, + staticProviderCredentials + ); + await auditService.record({ actorUserId: input.userId, ownerScope: created.config.owner_scope, ownerId: created.config.owner_id, @@ -87,6 +158,17 @@ export function createConfigService(params: { eventType: 'config_created', outcome: 'success', }); + if (staticProviderCredentials) { + await auditService.record({ + actorUserId: input.userId, + ownerScope: created.config.owner_scope, + ownerId: created.config.owner_id, + configId: created.config.config_id, + eventType: 'config_secret_updated', + outcome: 'success', + metadata: { kind: GatewaySecretKind.StaticProviderCredentials }, + }); + } return created; }); } @@ -97,10 +179,14 @@ export function createConfigService(params: { name: string; remoteUrl: string; authMode: GatewayAuthMode; + providerIssuer?: string; + staticProviderClientId?: string; + staticProviderClientSecret?: string; sharingMode: GatewaySharingMode; initialAssignedUserId?: string; pathPassthrough?: boolean; }) { + const staticProviderCredentials = initialStaticProviderCredentials(input); await validatePublicHttpsDestination(input.remoteUrl); const discoveredProviderMetadata = await discoverProviderMetadata(input); if (input.sharingMode === GatewaySharingMode.SingleUser && !input.initialAssignedUserId) { @@ -112,6 +198,7 @@ export function createConfigService(params: { } return await params.repository.database.transaction(async tx => { const repository = createGatewayRepository(tx); + const auditService = createAuditService(repository); if (input.initialAssignedUserId) { const membership = await repository.findMembership( input.initialAssignedUserId, @@ -146,7 +233,12 @@ export function createConfigService(params: { input.sharingMode === GatewaySharingMode.SingleUser ? 'single_user' : null, }); } - await createAuditService(repository).record({ + await insertInitialStaticProviderCredentials( + tx, + created.config.config_id, + staticProviderCredentials + ); + await auditService.record({ actorUserId: input.actorUserId, ownerScope: created.config.owner_scope, ownerId: created.config.owner_id, @@ -155,6 +247,17 @@ export function createConfigService(params: { eventType: 'config_created', outcome: 'success', }); + if (staticProviderCredentials) { + await auditService.record({ + actorUserId: input.actorUserId, + ownerScope: created.config.owner_scope, + ownerId: created.config.owner_id, + configId: created.config.config_id, + eventType: 'config_secret_updated', + outcome: 'success', + metadata: { kind: GatewaySecretKind.StaticProviderCredentials }, + }); + } return created; }); } @@ -219,12 +322,7 @@ export function createConfigService(params: { parseStaticHeaders(stringHeaders); } - const encryptedSecret = encryptKeyedEnvelope( - JSON.stringify({ kind: input.kind, value: input.value }), - secretScheme, - params.config.credentialKeyset.active, - configSecretAad(input.configId, input.kind) - ); + const encryptedSecret = encryptSecret(input); const materialChange = input.kind === GatewaySecretKind.StaticProviderCredentials; return await params.repository.database.transaction(async tx => { diff --git a/apps/web/src/lib/mcp-gateway/config.ts b/apps/web/src/lib/mcp-gateway/config.ts index 82319b7f70..4024f396b0 100644 --- a/apps/web/src/lib/mcp-gateway/config.ts +++ b/apps/web/src/lib/mcp-gateway/config.ts @@ -1,14 +1,11 @@ import 'server-only'; import { getEnvVariable } from '@/lib/dotenvx'; -import type { JsonWebKey } from 'node:crypto'; import { z } from 'zod'; const JWTKeySchema = z.object({ keyId: z.string().min(1), - publicJwk: z.custom( - value => value !== null && typeof value === 'object', - 'publicJwk must be an object' - ), + publicJwk: z.record(z.string(), z.unknown()), + publicKeyPem: z.string().min(1), privateKeyPem: z.string().min(1).optional(), }); @@ -35,7 +32,8 @@ const CredentialKeysetSchema = z.object({ export type GatewayJWTKey = { keyId: string; - publicJwk: JsonWebKey; + publicJwk: Record; + publicKeyPem: string; privateKeyPem?: string; }; diff --git a/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts b/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts index 5f0a9484cc..747387773a 100644 --- a/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts +++ b/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts @@ -51,7 +51,14 @@ function createTestConfig(): Promise { jwtKeyset: { issuer: 'https://app.kilo.ai', activeKeyId: 'jwt-active', - keys: [{ keyId: 'jwt-active', publicJwk, privateKeyPem: jwtKeys.privateKey }], + keys: [ + { + keyId: 'jwt-active', + publicJwk, + publicKeyPem: jwtKeys.publicKey, + privateKeyPem: jwtKeys.privateKey, + }, + ], }, credentialKeyset: { active: { keyId: 'credential-active', publicKeyPem: credentialKeys.publicKey }, @@ -420,6 +427,46 @@ describe('MCP gateway app OAuth flow', () => { }); }); + it('persists initial static provider credentials atomically without a version rotation', async () => { + const config = await createTestConfig(); + const services = createGatewayServices({ config, fetchImpl: providerDiscoveryFetch }); + const user = await insertTestUser({ id: `gateway-user-${crypto.randomUUID()}` }); + const created = await services.configService.createPersonalConfig({ + userId: user.id, + name: 'Static OAuth MCP', + remoteUrl: 'https://example.com/mcp', + authMode: 'oauth_static', + staticProviderClientId: 'provider-client', + staticProviderClientSecret: 'provider-secret', + }); + + const persistedConfig = await db + .select() + .from(mcp_gateway_configs) + .where(eq(mcp_gateway_configs.config_id, created.config.config_id)) + .then(rows => rows[0]); + expect(persistedConfig?.config_version).toBe(1); + await expect( + services.repository.findActiveSecret(created.config.config_id, 'static_provider_credentials') + ).resolves.toBeTruthy(); + }); + + it('rejects partial initial static provider credentials', async () => { + const config = await createTestConfig(); + const services = createGatewayServices({ config, fetchImpl: providerDiscoveryFetch }); + const user = await insertTestUser({ id: `gateway-user-${crypto.randomUUID()}` }); + + await expect( + services.configService.createPersonalConfig({ + userId: user.id, + name: 'Static OAuth MCP', + remoteUrl: 'https://example.com/mcp', + authMode: 'oauth_static', + staticProviderClientId: 'provider-client', + }) + ).rejects.toMatchObject({ code: 'invalid_request' }); + }); + it('rejects oauth_dynamic configs when the provider has no registration endpoint', async () => { const config = await createTestConfig(); const fetchImpl: typeof fetch = async input => { @@ -716,6 +763,7 @@ describe('MCP gateway app OAuth flow', () => { .from(mcp_gateway_provider_grants) .where(eq(mcp_gateway_provider_grants.instance_id, callback.instance.instance_id)); expect(grants).toHaveLength(1); + if (!callback.authorizationRequest) throw new Error('Expected authorization request'); const finalized = await services.authorizationService.completeProviderAuthorization({ authorizationRequest: callback.authorizationRequest, }); diff --git a/apps/web/src/lib/mcp-gateway/provider-oauth-service.ts b/apps/web/src/lib/mcp-gateway/provider-oauth-service.ts index f798e017ff..c3866fc24c 100644 --- a/apps/web/src/lib/mcp-gateway/provider-oauth-service.ts +++ b/apps/web/src/lib/mcp-gateway/provider-oauth-service.ts @@ -6,6 +6,7 @@ import { ProviderAuthorizationServerMetadataSchema, ProviderTokenResponseSchema, GatewayExecutionContextSchema, + GatewayOwnerScope, createGatewayError, GatewayErrorCode, } from '@kilocode/mcp-gateway'; @@ -15,8 +16,13 @@ import { mcp_gateway_config_secrets, mcp_gateway_pending_provider_authorizations, } from '@kilocode/db/schema'; +import type { + mcp_gateway_connection_instances, + mcp_gateway_provider_grants, +} from '@kilocode/db/schema'; import { and, eq, gt, sql } from 'drizzle-orm'; import { z } from 'zod'; +import type { GatewayExecutionContext, ScopedConnectRoute } from '@kilocode/mcp-gateway'; import type { GatewayAppConfig } from './config'; import type { GatewayRepository, ResolvedGatewayRoute } from './repository'; import type { GatewayRouteService } from './route-service'; @@ -55,6 +61,16 @@ type ProviderCredentials = { clientSecret?: string; }; +type ProviderCallbackResult = { + pending: typeof mcp_gateway_pending_provider_authorizations.$inferSelect; + authorizationRequest: typeof mcp_gateway_authorization_requests.$inferSelect | null; + grant: typeof mcp_gateway_provider_grants.$inferSelect; + instance: typeof mcp_gateway_connection_instances.$inferSelect; + resolved: ResolvedGatewayRoute; + route: ScopedConnectRoute; + completionUrl: string; +}; + function pendingStateAad(pendingId: string): string { return `mcp-gateway:pending-provider:${pendingId}`; } @@ -306,6 +322,87 @@ export function createProviderOAuthService(params: { return { pending, authorizationUrl: url.toString() }; } + async function startDashboardProviderSignIn(paramsInput: { + resolved: ResolvedGatewayRoute; + route: ScopedConnectRoute; + userId: string; + executionContext: GatewayExecutionContext; + }) { + await params.routeService.authorize({ + resolved: paramsInput.resolved, + route: paramsInput.route, + userId: paramsInput.userId, + executionContext: paramsInput.executionContext, + }); + const instance = await params.repository.ensureConnectionInstance({ + ownerScope: paramsInput.resolved.config.owner_scope, + ownerId: paramsInput.resolved.config.owner_id, + configId: paramsInput.resolved.config.config_id, + userId: paramsInput.userId, + }); + const credentials = await getProviderCredentials(paramsInput.resolved); + if (!credentials) { + throw createGatewayError( + GatewayErrorCode.InvalidRequest, + 'Config does not require provider OAuth', + 400 + ); + } + const authorizationEndpoint = await validatePublicHttpsDestination( + credentials.metadata.authorization_endpoint + ); + const tokenEndpoint = await validatePublicHttpsDestination(credentials.metadata.token_endpoint); + const codeVerifier = randomToken(48); + const state = randomToken(32); + const redirectUri = new URL( + '/api/mcp-gateway/oauth/mcp/callback', + params.config.appBaseUrl + ).toString(); + const scopes = ['profile']; + const encryptedState = encryptKeyedEnvelope( + JSON.stringify({ + codeVerifier, + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + tokenEndpoint: tokenEndpoint.toString(), + redirectUri, + scopes, + }), + pendingStateScheme, + params.config.credentialKeyset.active, + pendingStateAad(state) + ); + await params.repository.database.insert(mcp_gateway_pending_provider_authorizations).values({ + state_hash: hashToken(state), + authorization_request_id: null, + config_id: paramsInput.resolved.config.config_id, + instance_id: instance.instance_id, + owner_scope: paramsInput.resolved.config.owner_scope, + owner_id: paramsInput.resolved.config.owner_id, + kilo_user_id: paramsInput.userId, + route_key: paramsInput.resolved.route.route_key, + canonical_resource_url: paramsInput.resolved.route.canonical_url, + remote_url: paramsInput.resolved.config.remote_url, + auth_mode: paramsInput.resolved.config.auth_mode, + provider_authorization_endpoint: authorizationEndpoint.toString(), + provider_token_endpoint: tokenEndpoint.toString(), + encrypted_state: encryptedState, + execution_context: paramsInput.executionContext, + config_version: paramsInput.resolved.config.config_version, + pending_status: GatewayPendingProviderAuthorizationStatus.Pending, + expires_at: expiresAtIso(30 * 60), + }); + const url = new URL(authorizationEndpoint.toString()); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', credentials.clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('state', state); + url.searchParams.set('code_challenge', pkceChallenge(codeVerifier)); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('scope', scopes.join(' ')); + return { authorizationUrl: url.toString() }; + } + async function consumeProviderError(paramsInput: { state: string; userId: string }) { const [pending] = await params.repository.database .update(mcp_gateway_pending_provider_authorizations) @@ -351,7 +448,7 @@ export function createProviderOAuthService(params: { state: string; code: string; userId: string; - }) { + }): Promise { const [pending] = await params.repository.database .update(mcp_gateway_pending_provider_authorizations) .set({ @@ -429,31 +526,30 @@ export function createProviderOAuthService(params: { ); } const state = PendingProviderStateSchema.parse(rawState); - if (!pending.authorization_request_id) { - throw createGatewayError( - GatewayErrorCode.InvalidRequest, - 'Authorization request is unavailable', - 400 - ); - } - const [authorizationRequest] = await params.repository.database - .select() - .from(mcp_gateway_authorization_requests) - .where( - eq( - mcp_gateway_authorization_requests.authorization_request_id, - pending.authorization_request_id - ) - ) - .limit(1); - if (!authorizationRequest) { + const authorizationRequest = pending.authorization_request_id + ? await params.repository.database + .select() + .from(mcp_gateway_authorization_requests) + .where( + eq( + mcp_gateway_authorization_requests.authorization_request_id, + pending.authorization_request_id + ) + ) + .limit(1) + .then(rows => rows[0] ?? null) + : null; + if (pending.authorization_request_id && !authorizationRequest) { throw createGatewayError( GatewayErrorCode.InvalidRequest, 'Authorization request is unavailable', 400 ); } - if (authorizationRequest.granted_scopes.join(' ') !== state.scopes.join(' ')) { + if ( + authorizationRequest && + authorizationRequest.granted_scopes.join(' ') !== state.scopes.join(' ') + ) { throw createGatewayError( GatewayErrorCode.AccessDenied, 'Provider authorization scope mismatch', @@ -528,12 +624,23 @@ export function createProviderOAuthService(params: { eventType: 'provider_authorization_completed', outcome: 'success', }); - return { pending, authorizationRequest, grant, instance, resolved, route }; + const completionUrl = + resolved.config.owner_scope === GatewayOwnerScope.Organization + ? new URL( + `/organizations/${resolved.config.owner_id}/cloud/mcp-gateway/${resolved.config.config_id}`, + params.config.appBaseUrl + ).toString() + : new URL( + `/cloud/mcp-gateway/${resolved.config.config_id}`, + params.config.appBaseUrl + ).toString(); + return { pending, authorizationRequest, grant, instance, resolved, route, completionUrl }; } return { getProviderCredentials, initiateProviderAuthorization, + startDashboardProviderSignIn, consumeProviderError, handleProviderCallback, }; diff --git a/apps/web/src/lib/mcp-gateway/routes.ts b/apps/web/src/lib/mcp-gateway/routes.ts new file mode 100644 index 0000000000..1361dce2f6 --- /dev/null +++ b/apps/web/src/lib/mcp-gateway/routes.ts @@ -0,0 +1,10 @@ +export function getMcpGatewayRoutes(organizationId?: string) { + const base = organizationId + ? `/organizations/${organizationId}/cloud/mcp-gateway` + : '/cloud/mcp-gateway'; + return { + list: base, + create: `${base}/new`, + detail: (configId: string) => `${base}/${configId}`, + }; +} diff --git a/apps/web/src/lib/mcp-gateway/token-service.ts b/apps/web/src/lib/mcp-gateway/token-service.ts index a341ed6367..62068c76f5 100644 --- a/apps/web/src/lib/mcp-gateway/token-service.ts +++ b/apps/web/src/lib/mcp-gateway/token-service.ts @@ -16,7 +16,6 @@ import { import { mcp_gateway_authorization_codes, mcp_gateway_refresh_tokens } from '@kilocode/db/schema'; import type { mcp_gateway_oauth_clients } from '@kilocode/db/schema'; import jwt from 'jsonwebtoken'; -import { createPublicKey } from 'node:crypto'; import { timingSafeEqual } from '@kilocode/encryption'; import { and, eq, gt, isNull, sql } from 'drizzle-orm'; import type { GatewayAppConfig, GatewayJWTKey } from './config'; @@ -123,11 +122,7 @@ export function createTokenService(params: { if (!key) { throw createGatewayError(GatewayErrorCode.InvalidGrant, 'Token key is unknown', 401); } - const publicKeyPem = createPublicKey({ key: key.publicJwk, format: 'jwk' }).export({ - format: 'pem', - type: 'spki', - }); - const payload = jwt.verify(token, publicKeyPem, { + const payload = jwt.verify(token, key.publicKeyPem, { algorithms: ['RS256'], issuer: params.config.issuer, }); diff --git a/apps/web/src/routers/mcp-gateway-router.test.ts b/apps/web/src/routers/mcp-gateway-router.test.ts new file mode 100644 index 0000000000..977be48a99 --- /dev/null +++ b/apps/web/src/routers/mcp-gateway-router.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, beforeEach } from '@jest/globals'; +import { cleanupDbForTest, db } from '@/lib/drizzle'; +import { mcp_gateway_configs, mcp_gateway_connect_resources } from '@kilocode/db/schema'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { createCallerFactory, createTRPCRouter } from '@/lib/trpc/init'; +import { mcpGatewayRouter } from '@/routers/mcp-gateway-router'; +import { findUserById } from '@/lib/user'; + +const createCaller = createCallerFactory(createTRPCRouter({ mcpGateway: mcpGatewayRouter })); + +async function createCallerForUser(userId: string) { + const user = await findUserById(userId); + if (!user) throw new Error(`Test user not found: ${userId}`); + return createCaller({ user }); +} + +describe('mcpGateway admin rollout', () => { + beforeEach(async () => { + await cleanupDbForTest(); + }); + + it('denies non-admin users', async () => { + const user = await insertTestUser({ is_admin: false }); + const caller = await createCallerForUser(user.id); + await expect(caller.mcpGateway.listPersonal(undefined)).rejects.toThrow( + 'Admin access required' + ); + }); + + it('allows admin users to list personal connections', async () => { + const user = await insertTestUser({ is_admin: true }); + const caller = await createCallerForUser(user.id); + await expect(caller.mcpGateway.listPersonal(undefined)).resolves.toEqual([]); + }); + + it('does not allow an admin to mutate another admins personal connection', async () => { + const owner = await insertTestUser({ is_admin: true }); + const otherAdmin = await insertTestUser({ is_admin: true }); + const otherCaller = await createCallerForUser(otherAdmin.id); + const [config] = await db + .insert(mcp_gateway_configs) + .values({ + owner_scope: 'personal', + owner_id: owner.id, + name: 'Personal MCP', + remote_url: 'https://example.com/mcp', + auth_mode: 'none', + sharing_mode: 'single_user', + created_by_kilo_user_id: owner.id, + }) + .returning(); + await db.insert(mcp_gateway_connect_resources).values({ + config_id: config.config_id, + owner_scope: 'personal', + owner_id: owner.id, + route_key: 'abcdefghijklmnopqrstuvwxyzABCDEF', + canonical_url: `https://mcp.kilo.ai/mcp-connect/user/${owner.id}/${config.config_id}/abcdefghijklmnopqrstuvwxyzABCDEF`, + }); + + await expect( + otherCaller.mcpGateway.rotateRoute({ configId: config.config_id }) + ).rejects.toThrow('Connection not found'); + }); +}); diff --git a/apps/web/src/routers/mcp-gateway-router.ts b/apps/web/src/routers/mcp-gateway-router.ts new file mode 100644 index 0000000000..55a7ff0da3 --- /dev/null +++ b/apps/web/src/routers/mcp-gateway-router.ts @@ -0,0 +1,493 @@ +import 'server-only'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { + mcp_gateway_assignments, + mcp_gateway_config_secrets, + mcp_gateway_configs, + mcp_gateway_connect_resources, + mcp_gateway_connection_instances, + mcp_gateway_provider_grants, +} from '@kilocode/db/schema'; +import { + GatewayAuthMode, + GatewayOwnerScope, + GatewaySharingMode, + GatewaySecretKind, +} from '@kilocode/mcp-gateway'; +import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { createGatewayServices } from '@/lib/mcp-gateway/services'; +import { db } from '@/lib/drizzle'; +import { createGatewayRepository } from '@/lib/mcp-gateway/repository'; +import { and, desc, eq, inArray, isNull } from 'drizzle-orm'; + +const ConfigIdSchema = z.string().uuid(); +const OrganizationIdSchema = z.string().uuid(); +const RemoteUrlSchema = z.string().url(); +const AuthModeSchema = z.enum([ + GatewayAuthMode.None, + GatewayAuthMode.StaticHeaders, + GatewayAuthMode.OAuthDynamic, + GatewayAuthMode.OAuthStatic, +]); +const SharingModeSchema = z.enum([GatewaySharingMode.SingleUser, GatewaySharingMode.MultiUser]); +const StaticHeadersSchema = z.record(z.string(), z.string().min(1)); +const ManagedConfigInputSchema = z.object({ + configId: ConfigIdSchema, + organizationId: OrganizationIdSchema.optional(), +}); +const mcpGatewayAdminProcedure = adminProcedure; + +type ConfigRow = typeof mcp_gateway_configs.$inferSelect; +type RouteRow = typeof mcp_gateway_connect_resources.$inferSelect; +type AssignmentRow = typeof mcp_gateway_assignments.$inferSelect; +type InstanceRow = typeof mcp_gateway_connection_instances.$inferSelect; + +function configProjection(params: { + config: ConfigRow; + route: RouteRow; + assignments: AssignmentRow[]; + instances: InstanceRow[]; + activeGrantCount: number; + secretKinds: string[]; +}) { + return { + configId: params.config.config_id, + name: params.config.name, + ownerScope: params.config.owner_scope, + ownerId: params.config.owner_id, + remoteUrl: params.config.remote_url, + authMode: params.config.auth_mode, + sharingMode: params.config.sharing_mode, + enabled: params.config.enabled, + pathPassthrough: params.config.path_passthrough, + configVersion: params.config.config_version, + canonicalUrl: params.route.canonical_url, + routeVersion: params.route.route_version, + routeStatus: params.route.route_status, + registryMetadata: params.config.registry_metadata, + auxiliaryHeaders: params.config.auxiliary_headers, + createdAt: params.config.created_at, + updatedAt: params.config.updated_at, + assignmentCount: params.assignments.length, + instanceCount: params.instances.length, + activeGrantCount: params.activeGrantCount, + hasStaticHeaders: params.secretKinds.includes(GatewaySecretKind.StaticHeaders), + hasStaticProviderCredentials: params.secretKinds.includes( + GatewaySecretKind.StaticProviderCredentials + ), + hasDynamicRegistration: params.secretKinds.includes(GatewaySecretKind.DynamicRegistration), + }; +} + +type ConfigProjection = ReturnType; + +function detailProjection(params: { + projection: ConfigProjection; + assignments: AssignmentRow[]; + instances: InstanceRow[]; +}) { + return { + ...params.projection, + assignments: params.assignments.map(assignment => ({ + assignmentId: assignment.assignment_id, + userId: assignment.kilo_user_id, + assignedByUserId: assignment.assigned_by_kilo_user_id, + createdAt: assignment.created_at, + })), + instances: params.instances.map(instance => ({ + instanceId: instance.instance_id, + userId: instance.kilo_user_id, + status: instance.instance_status, + lastUsedAt: instance.last_used_at, + })), + }; +} + +async function loadConfigRows(configIds: string[]) { + const repository = createGatewayRepository(db); + const assignments = configIds.length + ? await repository.database + .select() + .from(mcp_gateway_assignments) + .where( + and( + inArray(mcp_gateway_assignments.config_id, configIds), + isNull(mcp_gateway_assignments.revoked_at) + ) + ) + : []; + const instances = configIds.length + ? await repository.database + .select() + .from(mcp_gateway_connection_instances) + .where( + and( + inArray(mcp_gateway_connection_instances.config_id, configIds), + inArray(mcp_gateway_connection_instances.instance_status, ['active', 'needs_reauth']) + ) + ) + : []; + const instanceIds = instances.map(instance => instance.instance_id); + const grants = instanceIds.length + ? await repository.database + .select() + .from(mcp_gateway_provider_grants) + .where( + and( + inArray(mcp_gateway_provider_grants.instance_id, instanceIds), + eq(mcp_gateway_provider_grants.grant_status, 'active') + ) + ) + : []; + const secrets = configIds.length + ? await repository.database + .select({ + configId: mcp_gateway_config_secrets.config_id, + kind: mcp_gateway_config_secrets.secret_kind, + }) + .from(mcp_gateway_config_secrets) + .where( + and( + inArray(mcp_gateway_config_secrets.config_id, configIds), + isNull(mcp_gateway_config_secrets.revoked_at) + ) + ) + : []; + return { assignments, instances, grants, secrets }; +} + +async function listConfigs(params: { + ownerScope: (typeof GatewayOwnerScope)[keyof typeof GatewayOwnerScope]; + ownerId: string; +}) { + const repository = createGatewayRepository(db); + const rows = await repository.database + .select({ config: mcp_gateway_configs, route: mcp_gateway_connect_resources }) + .from(mcp_gateway_configs) + .innerJoin( + mcp_gateway_connect_resources, + eq(mcp_gateway_connect_resources.config_id, mcp_gateway_configs.config_id) + ) + .where( + and( + eq(mcp_gateway_configs.owner_scope, params.ownerScope), + eq(mcp_gateway_configs.owner_id, params.ownerId), + isNull(mcp_gateway_configs.deleted_at), + eq(mcp_gateway_connect_resources.route_status, 'active') + ) + ) + .orderBy(desc(mcp_gateway_configs.updated_at)); + const configIds = rows.map(row => row.config.config_id); + const related = await loadConfigRows(configIds); + return rows.map(({ config, route }) => + configProjection({ + config, + route, + assignments: related.assignments.filter( + assignment => assignment.config_id === config.config_id + ), + instances: related.instances.filter(instance => instance.config_id === config.config_id), + activeGrantCount: related.grants.filter(grant => + related.instances.some( + instance => + instance.instance_id === grant.instance_id && instance.config_id === config.config_id + ) + ).length, + secretKinds: related.secrets + .filter(secret => secret.configId === config.config_id) + .map(secret => secret.kind), + }) + ); +} + +async function getConfigDetail(params: { + configId: string; + ownerScope: (typeof GatewayOwnerScope)[keyof typeof GatewayOwnerScope]; + ownerId: string; +}) { + const services = createGatewayServices(); + const resolved = await services.repository.findActiveRouteByConfigId(params.configId); + if ( + !resolved || + resolved.config.owner_scope !== params.ownerScope || + resolved.config.owner_id !== params.ownerId + ) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Connection not found' }); + } + const related = await loadConfigRows([params.configId]); + return detailProjection({ + projection: configProjection({ + config: resolved.config, + route: resolved.route, + assignments: related.assignments, + instances: related.instances, + activeGrantCount: related.grants.length, + secretKinds: related.secrets.map(secret => secret.kind), + }), + assignments: related.assignments, + instances: related.instances, + }); +} + +async function requireManagedConfig(params: { + configId: string; + organizationId?: string; + userId: string; +}) { + const repository = createGatewayRepository(db); + const resolved = await repository.findActiveRouteByConfigId(params.configId); + if (!resolved) throw new TRPCError({ code: 'NOT_FOUND', message: 'Connection not found' }); + const belongsToScope = params.organizationId + ? resolved.config.owner_scope === GatewayOwnerScope.Organization && + resolved.config.owner_id === params.organizationId + : resolved.config.owner_scope === GatewayOwnerScope.Personal && + resolved.config.owner_id === params.userId; + if (!belongsToScope) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Connection not found' }); + } + return resolved; +} + +export const mcpGatewayRouter = createTRPCRouter({ + discover: mcpGatewayAdminProcedure + .input(z.object({ remoteUrl: RemoteUrlSchema })) + .mutation(async ({ input }) => { + const services = createGatewayServices(); + const discovery = await services.discoveryService.discoverRemoteProvider(input.remoteUrl); + return { + remoteUrl: discovery.remoteUrl, + providerCandidates: discovery.providerCandidates.map(candidate => ({ + issuer: candidate.issuer, + authorizationEndpoint: candidate.authorization_endpoint, + tokenEndpoint: candidate.token_endpoint, + hasRegistrationEndpoint: Boolean(candidate.registration_endpoint), + })), + }; + }), + listPersonal: mcpGatewayAdminProcedure.query(async ({ ctx }) => + listConfigs({ ownerScope: GatewayOwnerScope.Personal, ownerId: ctx.user.id }) + ), + listOrganization: mcpGatewayAdminProcedure + .input(z.object({ organizationId: OrganizationIdSchema })) + .query(async ({ input }) => + listConfigs({ ownerScope: GatewayOwnerScope.Organization, ownerId: input.organizationId }) + ), + getPersonal: mcpGatewayAdminProcedure + .input(z.object({ configId: ConfigIdSchema })) + .query(async ({ input, ctx }) => + getConfigDetail({ + configId: input.configId, + ownerScope: GatewayOwnerScope.Personal, + ownerId: ctx.user.id, + }) + ), + getOrganization: mcpGatewayAdminProcedure + .input(z.object({ organizationId: OrganizationIdSchema, configId: ConfigIdSchema })) + .query(async ({ input }) => + getConfigDetail({ + configId: input.configId, + ownerScope: GatewayOwnerScope.Organization, + ownerId: input.organizationId, + }) + ), + createPersonal: mcpGatewayAdminProcedure + .input( + z.object({ + name: z.string().min(1).max(200), + remoteUrl: RemoteUrlSchema, + authMode: AuthModeSchema, + providerIssuer: z.string().url().optional(), + staticProviderClientId: z.string().min(1).optional(), + staticProviderClientSecret: z.string().min(1).optional(), + pathPassthrough: z.boolean().optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const services = createGatewayServices(); + const created = await services.configService.createPersonalConfig({ + userId: ctx.user.id, + name: input.name, + remoteUrl: input.remoteUrl, + authMode: input.authMode, + providerIssuer: input.providerIssuer, + staticProviderClientId: input.staticProviderClientId, + staticProviderClientSecret: input.staticProviderClientSecret, + pathPassthrough: input.pathPassthrough, + }); + return { configId: created.config.config_id }; + }), + createOrganization: mcpGatewayAdminProcedure + .input( + z.object({ + organizationId: OrganizationIdSchema, + name: z.string().min(1).max(200), + remoteUrl: RemoteUrlSchema, + authMode: AuthModeSchema, + providerIssuer: z.string().url().optional(), + staticProviderClientId: z.string().min(1).optional(), + staticProviderClientSecret: z.string().min(1).optional(), + sharingMode: SharingModeSchema, + initialAssignedUserId: z.string().min(1).optional(), + pathPassthrough: z.boolean().optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + const services = createGatewayServices(); + const created = await services.configService.createOrganizationConfig({ + organizationId: input.organizationId, + actorUserId: ctx.user.id, + name: input.name, + remoteUrl: input.remoteUrl, + authMode: input.authMode, + providerIssuer: input.providerIssuer, + staticProviderClientId: input.staticProviderClientId, + staticProviderClientSecret: input.staticProviderClientSecret, + sharingMode: input.sharingMode, + initialAssignedUserId: input.initialAssignedUserId, + pathPassthrough: input.pathPassthrough, + }); + return { configId: created.config.config_id }; + }), + startProviderSignIn: mcpGatewayAdminProcedure + .input(ManagedConfigInputSchema) + .mutation(async ({ input, ctx }) => { + const resolved = await requireManagedConfig({ + configId: input.configId, + organizationId: input.organizationId, + userId: ctx.user.id, + }); + const services = createGatewayServices(); + const route = services.routeService.parseResource(resolved.route.canonical_url); + const provider = await services.providerOAuthService.startDashboardProviderSignIn({ + resolved, + route, + userId: ctx.user.id, + executionContext: + resolved.config.owner_scope === GatewayOwnerScope.Organization + ? { type: 'organization', organizationId: resolved.config.owner_id } + : { type: 'personal' }, + }); + return { authorizationUrl: provider.authorizationUrl }; + }), + rotateRoute: mcpGatewayAdminProcedure + .input(ManagedConfigInputSchema) + .mutation(async ({ input, ctx }) => { + await requireManagedConfig({ + configId: input.configId, + organizationId: input.organizationId, + userId: ctx.user.id, + }); + const services = createGatewayServices(); + const route = await services.configService.rotateRoute({ configId: input.configId }); + return { routeKey: route.route_key, canonicalUrl: route.canonical_url }; + }), + disable: mcpGatewayAdminProcedure + .input(ManagedConfigInputSchema) + .mutation(async ({ input, ctx }) => { + await requireManagedConfig({ + configId: input.configId, + organizationId: input.organizationId, + userId: ctx.user.id, + }); + const services = createGatewayServices(); + const config = await services.configService.disableConfig(input.configId); + if (!config) throw new TRPCError({ code: 'NOT_FOUND', message: 'Connection not found' }); + return { configId: config.config_id, enabled: config.enabled }; + }), + delete: mcpGatewayAdminProcedure + .input(ManagedConfigInputSchema) + .mutation(async ({ input, ctx }) => { + await requireManagedConfig({ + configId: input.configId, + organizationId: input.organizationId, + userId: ctx.user.id, + }); + const services = createGatewayServices(); + const config = await services.configService.deleteConfig(input.configId); + if (!config) throw new TRPCError({ code: 'NOT_FOUND', message: 'Connection not found' }); + return { configId: config.config_id }; + }), + upsertStaticHeaders: mcpGatewayAdminProcedure + .input(ManagedConfigInputSchema.extend({ headers: StaticHeadersSchema })) + .mutation(async ({ input, ctx }) => { + const resolved = await requireManagedConfig({ + configId: input.configId, + organizationId: input.organizationId, + userId: ctx.user.id, + }); + if (resolved.config.auth_mode !== GatewayAuthMode.StaticHeaders) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Connection does not use static headers', + }); + } + const services = createGatewayServices(); + const secret = await services.configService.upsertSecret({ + configId: input.configId, + kind: GatewaySecretKind.StaticHeaders, + value: { headers: input.headers }, + }); + return { secretId: secret.config_secret_id }; + }), + upsertStaticProviderCredentials: mcpGatewayAdminProcedure + .input( + ManagedConfigInputSchema.extend({ + clientId: z.string().min(1), + clientSecret: z.string().min(1), + }) + ) + .mutation(async ({ input, ctx }) => { + const resolved = await requireManagedConfig({ + configId: input.configId, + organizationId: input.organizationId, + userId: ctx.user.id, + }); + if (resolved.config.auth_mode !== GatewayAuthMode.OAuthStatic) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Connection does not use manual provider credentials', + }); + } + const services = createGatewayServices(); + const secret = await services.configService.upsertSecret({ + configId: input.configId, + kind: GatewaySecretKind.StaticProviderCredentials, + value: { clientId: input.clientId, clientSecret: input.clientSecret }, + }); + return { secretId: secret.config_secret_id }; + }), + assignUser: mcpGatewayAdminProcedure + .input(ManagedConfigInputSchema.extend({ userId: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + await requireManagedConfig({ + configId: input.configId, + organizationId: input.organizationId, + userId: ctx.user.id, + }); + const services = createGatewayServices(); + const assignment = await services.configService.assignUser({ + configId: input.configId, + userId: input.userId, + actorUserId: ctx.user.id, + }); + if (!assignment) throw new TRPCError({ code: 'NOT_FOUND', message: 'Connection not found' }); + return { assignmentId: assignment.assignment_id }; + }), + revokeAssignment: mcpGatewayAdminProcedure + .input(ManagedConfigInputSchema.extend({ userId: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + await requireManagedConfig({ + configId: input.configId, + organizationId: input.organizationId, + userId: ctx.user.id, + }); + const services = createGatewayServices(); + const assignment = await services.configService.revokeAssignment({ + configId: input.configId, + userId: input.userId, + }); + if (!assignment) throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found' }); + return { assignmentId: assignment.assignment_id }; + }), +}); diff --git a/apps/web/src/routers/root-router.ts b/apps/web/src/routers/root-router.ts index 2dbc10bd68..5b24a8daec 100644 --- a/apps/web/src/routers/root-router.ts +++ b/apps/web/src/routers/root-router.ts @@ -42,6 +42,7 @@ import { codingPlansRouter } from '@/routers/coding-plans-router'; import { unifiedSessionsRouter } from '@/routers/unified-sessions-router'; import { activeSessionsRouter } from '@/routers/active-sessions-router'; import { usageAnalyticsRouter } from '@/routers/usage-analytics-router'; +import { mcpGatewayRouter } from '@/routers/mcp-gateway-router'; export const rootRouter = createTRPCRouter({ test: testRouter, organizations: organizationsRouter, @@ -85,6 +86,7 @@ export const rootRouter = createTRPCRouter({ unifiedSessions: unifiedSessionsRouter, activeSessions: activeSessionsRouter, usageAnalytics: usageAnalyticsRouter, + mcpGateway: mcpGatewayRouter, }); // export type definition of API export type RootRouter = typeof rootRouter; diff --git a/services/mcp-gateway/scripts/generate-keys.mjs b/services/mcp-gateway/scripts/generate-keys.mjs index 632834f27a..09fe7bfae8 100644 --- a/services/mcp-gateway/scripts/generate-keys.mjs +++ b/services/mcp-gateway/scripts/generate-keys.mjs @@ -111,6 +111,7 @@ function createBundle(options) { { keyId: jwtKeyId, publicJwk, + publicKeyPem: jwtPair.publicKey, privateKeyPem: jwtPair.privateKey, }, ], diff --git a/services/mcp-gateway/src/handlers/connect.handler.ts b/services/mcp-gateway/src/handlers/connect.handler.ts index f941a44fb7..20e6bae959 100644 --- a/services/mcp-gateway/src/handlers/connect.handler.ts +++ b/services/mcp-gateway/src/handlers/connect.handler.ts @@ -1,4 +1,5 @@ import type { Context } from 'hono'; +import { z } from 'zod'; import { buildMCPID, buildScopedConnectCanonicalUrl, @@ -99,24 +100,44 @@ function logUpstreamServerError(params: { ); } -function requestRoute(c: Context): ScopedConnectRoute { +function requestRoute( + c: Context, + params: UserConnectRouteParams | OrgConnectRouteParams +): ScopedConnectRoute { const route = parseScopedConnectPath(c.req.path); if (!route) { throw createGatewayError(GatewayErrorCode.InvalidRequest, 'Invalid scoped route', 400); } + const expectedRoute = + 'userId' in params + ? parseScopedConnectPath( + `/mcp-connect/user/${params.userId}/${params.configId}/${params.routeKey}` + ) + : parseScopedConnectPath( + `/mcp-connect/org/${params.orgId}/${params.configId}/${params.routeKey}` + ); + if ( + !expectedRoute || + route.ownerScope !== expectedRoute.ownerScope || + route.ownerId !== expectedRoute.ownerId || + route.configId !== expectedRoute.configId || + route.routeKey !== expectedRoute.routeKey + ) { + throw createGatewayError(GatewayErrorCode.InvalidRequest, 'Invalid scoped route', 400); + } return route; } async function handleConnect( c: Context, - _params: UserConnectRouteParams | OrgConnectRouteParams + params: UserConnectRouteParams | OrgConnectRouteParams ) { let phase: RuntimePhase = 'parse_route'; let loggedRoute: ScopedConnectRoute | null = null; let hasBearerToken = false; let authMode: GatewayAuthMode | undefined; try { - const route = requestRoute(c); + const route = requestRoute(c, params); loggedRoute = route; const canonicalUrl = buildScopedConnectCanonicalUrl(c.env.MCP_GATEWAY_BASE_URL, { ownerScope: route.ownerScope, @@ -297,6 +318,7 @@ export async function handleUserConnect(c: Context, params: UserC return await handleConnect(c, validatedParams); } catch (error) { if (error instanceof GatewayError) return gatewayHandlerError(c, error); + if (error instanceof z.ZodError) return c.json({ error: 'not_found' }, 404); return c.json({ error: 'server_error' }, 500); } } @@ -307,6 +329,7 @@ export async function handleOrgConnect(c: Context, params: OrgCon return await handleConnect(c, validatedParams); } catch (error) { if (error instanceof GatewayError) return gatewayHandlerError(c, error); + if (error instanceof z.ZodError) return c.json({ error: 'not_found' }, 404); return c.json({ error: 'server_error' }, 500); } } diff --git a/services/mcp-gateway/src/handlers/protected-resource.handler.ts b/services/mcp-gateway/src/handlers/protected-resource.handler.ts index df5867a993..126d819f7c 100644 --- a/services/mcp-gateway/src/handlers/protected-resource.handler.ts +++ b/services/mcp-gateway/src/handlers/protected-resource.handler.ts @@ -26,7 +26,9 @@ export async function handleUserProtectedResourceMetadata( c: Context, params: UserConnectRouteParams ) { - const validatedParams = UserConnectRouteParamsSchema.parse(params); + const parsedParams = UserConnectRouteParamsSchema.safeParse(params); + if (!parsedParams.success) return c.json({ error: 'not_found' }, 404); + const validatedParams = parsedParams.data; const route = parseScopedConnectPath( `/mcp-connect/user/${validatedParams.userId}/${validatedParams.configId}/${validatedParams.routeKey}` ); @@ -46,7 +48,9 @@ export async function handleOrgProtectedResourceMetadata( c: Context, params: OrgConnectRouteParams ) { - const validatedParams = OrgConnectRouteParamsSchema.parse(params); + const parsedParams = OrgConnectRouteParamsSchema.safeParse(params); + if (!parsedParams.success) return c.json({ error: 'not_found' }, 404); + const validatedParams = parsedParams.data; const route = parseScopedConnectPath( `/mcp-connect/org/${validatedParams.orgId}/${validatedParams.configId}/${validatedParams.routeKey}` ); diff --git a/services/mcp-gateway/src/lib/jwt.ts b/services/mcp-gateway/src/lib/jwt.ts index 8b1e6a4558..4bf62e31d7 100644 --- a/services/mcp-gateway/src/lib/jwt.ts +++ b/services/mcp-gateway/src/lib/jwt.ts @@ -9,7 +9,11 @@ import { z } from 'zod'; const JWKSchema = z .object({ kid: z.string().min(1), - kty: z.string().min(1), + kty: z.literal('RSA'), + n: z.string().min(1), + e: z.string().min(1), + alg: z.literal('RS256').optional(), + use: z.literal('sig').optional(), }) .passthrough(); diff --git a/services/mcp-gateway/src/mcp-gateway.worker.test.ts b/services/mcp-gateway/src/mcp-gateway.worker.test.ts index bce7c57346..670e10a1b2 100644 --- a/services/mcp-gateway/src/mcp-gateway.worker.test.ts +++ b/services/mcp-gateway/src/mcp-gateway.worker.test.ts @@ -169,6 +169,25 @@ describe('MCP gateway route surface', () => { expect(response.status).toBe(404); }); + it('fails closed for malformed scoped route params', async () => { + const responses = await Promise.all([ + request('/mcp-connect/user/user-123/not-a-uuid/abcdefghijklmnopqrstuvwxyzABCDEF'), + request( + '/mcp-connect/org/not-a-uuid/33333333-3333-4333-8333-333333333333/abcdefghijklmnopqrstuvwxyzABCDEF' + ), + request( + '/.well-known/oauth-protected-resource/mcp-connect/user/user-123/not-a-uuid/abcdefghijklmnopqrstuvwxyzABCDEF' + ), + request( + '/.well-known/oauth-protected-resource/mcp-connect/org/not-a-uuid/33333333-3333-4333-8333-333333333333/abcdefghijklmnopqrstuvwxyzABCDEF' + ), + ]); + + for (const response of responses) { + expect(response.status).toBe(404); + } + }); + it('does not expose legacy opaque connect routes', async () => { const response = await request('/mcp-connect/opaque-connect-id'); From 9f96d4badd92fc7883eaae0476a92003491e069f Mon Sep 17 00:00:00 2001 From: syn Date: Wed, 3 Jun 2026 22:14:06 -0500 Subject: [PATCH 02/18] fix(mcp-gateway): address dashboard review findings --- .../mcp-gateway/McpGatewayDetailContent.tsx | 131 ++++++++++++++---- .../mcp-gateway/McpGatewayListContent.tsx | 38 +---- .../mcp-gateway/McpGatewaySetupContent.tsx | 12 +- .../lib/mcp-gateway/oauth-client-service.ts | 9 ++ .../src/lib/mcp-gateway/oauth-flow.test.ts | 34 +++++ .../src/routers/mcp-gateway-router.test.ts | 57 +++++++- apps/web/src/routers/mcp-gateway-router.ts | 18 ++- 7 files changed, 231 insertions(+), 68 deletions(-) diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx index 9aecd201ae..ca8a7f79cb 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx @@ -10,6 +10,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { SecretTokenInput } from '@/components/ui/secret-token-input'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; import { ArrowLeft, Copy, RotateCw, ShieldAlert, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -51,6 +62,9 @@ export function McpGatewayDetailContent({ const [providerClientId, setProviderClientId] = useState(''); const [providerClientSecret, setProviderClientSecret] = useState(''); const [assignedUserId, setAssignedUserId] = useState(''); + const [rotateDialogOpen, setRotateDialogOpen] = useState(false); + const [disableDialogOpen, setDisableDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const detailQuery = useQuery( organizationId ? trpc.mcpGateway.getOrganization.queryOptions({ organizationId, configId }) @@ -72,6 +86,7 @@ export function McpGatewayDetailContent({ trpc.mcpGateway.rotateRoute.mutationOptions({ onSuccess: () => { toast.success('Connect URL rotated'); + setRotateDialogOpen(false); refresh(); }, onError: error => toast.error(error.message || 'Could not rotate the connect URL'), @@ -81,6 +96,7 @@ export function McpGatewayDetailContent({ trpc.mcpGateway.disable.mutationOptions({ onSuccess: () => { toast.success('Connection disabled'); + setDisableDialogOpen(false); refresh(); }, onError: error => toast.error(error.message || 'Could not disable the connection'), @@ -90,6 +106,7 @@ export function McpGatewayDetailContent({ trpc.mcpGateway.delete.mutationOptions({ onSuccess: () => { toast.success('Connection deleted'); + setDeleteDialogOpen(false); window.location.assign(routes.list); }, onError: error => toast.error(error.message || 'Could not delete the connection'), @@ -430,14 +447,35 @@ export function McpGatewayDetailContent({ Copy - + + + + + + + Rotate this connect URL? + + The current URL and any gateway tokens bound to it stop working immediately. + Provider sign-in grants remain available on the new URL. + + + + + Cancel + + rotateMutation.mutate(managedConfigInput)} + disabled={rotateMutation.isPending} + > + Rotate URL + + + +

@@ -448,22 +486,67 @@ export function McpGatewayDetailContent({

Danger zone

- - + + + + + + + Disable this connection? + + Requests through this connection will be blocked immediately after this + action. + + + + + Cancel + + disableMutation.mutate(managedConfigInput)} + disabled={disableMutation.isPending} + > + Disable connection + + + + + + + + + + + Delete this connection? + + This permanently invalidates its connect URL and revokes dependent instances, + provider grants, and pending provider sign-ins. + + + + + Cancel + + deleteMutation.mutate(managedConfigInput)} + disabled={deleteMutation.isPending} + > + Delete connection + + + +
diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx index 7853247077..e4c831386a 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useMemo, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; import { Button } from '@/components/ui/button'; @@ -18,7 +18,7 @@ import { TableRow, } from '@/components/ui/table'; import { Skeleton } from '@/components/ui/skeleton'; -import { Copy, ExternalLink, Plus, RotateCw } from 'lucide-react'; +import { Copy, ExternalLink, Plus } from 'lucide-react'; import { toast } from 'sonner'; type McpGatewayListContentProps = { @@ -51,7 +51,6 @@ function authLabel(authMode: string) { export function McpGatewayListContent({ organizationId }: McpGatewayListContentProps) { const trpc = useTRPC(); - const queryClient = useQueryClient(); const routes = getMcpGatewayRoutes(organizationId); const [filter, setFilter] = useState(''); const listQuery = useQuery( @@ -59,19 +58,6 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP ? trpc.mcpGateway.listOrganization.queryOptions({ organizationId }) : trpc.mcpGateway.listPersonal.queryOptions() ); - const rotateMutation = useMutation( - trpc.mcpGateway.rotateRoute.mutationOptions({ - onSuccess: () => { - toast.success('Connect URL rotated'); - void queryClient.invalidateQueries({ - queryKey: organizationId - ? trpc.mcpGateway.listOrganization.queryKey({ organizationId }) - : trpc.mcpGateway.listPersonal.queryKey(), - }); - }, - onError: () => toast.error('Could not rotate the connect URL'), - }) - ); const filteredConnections = useMemo(() => { const connections = listQuery.data ?? []; const query = filter.trim().toLowerCase(); @@ -213,14 +199,6 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP
-
diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx index 7e31986712..7b0c936cb9 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx @@ -107,10 +107,14 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten discoveryMutation.data && discoveryMutation.data.remoteUrl === currentRemoteUrl ? discoveryMutation.data : undefined; - const dynamicAvailable = - discovery?.providerCandidates.some(candidate => candidate.hasRegistrationEndpoint) ?? false; - const selectedProviderIssuer = - draft.providerIssuer || discovery?.providerCandidates[0]?.issuer || ''; + const defaultProvider = + discovery?.providerCandidates.find(candidate => candidate.hasRegistrationEndpoint) ?? + discovery?.providerCandidates[0]; + const selectedProvider = + discovery?.providerCandidates.find(candidate => candidate.issuer === draft.providerIssuer) ?? + defaultProvider; + const selectedProviderIssuer = selectedProvider?.issuer ?? ''; + const dynamicAvailable = selectedProvider?.hasRegistrationEndpoint ?? false; const selectedAuthMode = useMemo(() => { if (draft.authMode === 'oauth_dynamic' && !dynamicAvailable && discovery) return 'oauth_static'; return draft.authMode; diff --git a/apps/web/src/lib/mcp-gateway/oauth-client-service.ts b/apps/web/src/lib/mcp-gateway/oauth-client-service.ts index 13a0211ba4..be38752f45 100644 --- a/apps/web/src/lib/mcp-gateway/oauth-client-service.ts +++ b/apps/web/src/lib/mcp-gateway/oauth-client-service.ts @@ -175,6 +175,15 @@ export function createOAuthClientService(params: { 400 ); } + const existing = await findClientById(input.clientId); + if (!existing) return null; + if (existing.token_endpoint_auth_method !== metadata.data.token_endpoint_auth_method) { + throw createGatewayError( + GatewayErrorCode.InvalidClientMetadata, + 'Token endpoint authentication method cannot be changed after registration', + 400 + ); + } const rows = await params.repository.database .update(mcp_gateway_oauth_clients) .set({ diff --git a/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts b/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts index 747387773a..9a88cf2384 100644 --- a/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts +++ b/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts @@ -294,6 +294,40 @@ describe('MCP gateway app OAuth flow', () => { expect(tokenResponse.access_token).toBeTruthy(); }); + it('rejects changes to a registered client authentication method', async () => { + const config = await createTestConfig(); + const services = createGatewayServices({ config }); + const registration = await services.clientService.registerClient({ + metadata: { + redirect_uris: ['http://localhost:3000/callback'], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'profile', + }, + headers: new Headers({ 'x-vercel-forwarded-for': '203.0.113.32' }), + }); + + await expect( + services.clientService.updateClient({ + clientId: registration.clientId, + metadata: { + redirect_uris: ['http://localhost:3000/callback'], + token_endpoint_auth_method: 'client_secret_post', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'profile', + }, + }) + ).rejects.toMatchObject({ code: 'invalid_client_metadata' }); + await expect( + services.clientService.findClientById(registration.clientId) + ).resolves.toMatchObject({ + token_endpoint_auth_method: 'none', + client_secret_hash: null, + }); + }); + it('does not redeem an authorization code after it expires', async () => { const config = await createTestConfig(); const services = createGatewayServices({ config }); diff --git a/apps/web/src/routers/mcp-gateway-router.test.ts b/apps/web/src/routers/mcp-gateway-router.test.ts index 977be48a99..67b3eb9a38 100644 --- a/apps/web/src/routers/mcp-gateway-router.test.ts +++ b/apps/web/src/routers/mcp-gateway-router.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, beforeEach } from '@jest/globals'; import { cleanupDbForTest, db } from '@/lib/drizzle'; -import { mcp_gateway_configs, mcp_gateway_connect_resources } from '@kilocode/db/schema'; +import { + mcp_gateway_assignments, + mcp_gateway_configs, + mcp_gateway_connect_resources, + mcp_gateway_connection_instances, +} from '@kilocode/db/schema'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createCallerFactory, createTRPCRouter } from '@/lib/trpc/init'; import { mcpGatewayRouter } from '@/routers/mcp-gateway-router'; @@ -33,6 +38,56 @@ describe('mcpGateway admin rollout', () => { await expect(caller.mcpGateway.listPersonal(undefined)).resolves.toEqual([]); }); + it('serializes dashboard timestamps as ISO strings', async () => { + const user = await insertTestUser({ is_admin: true }); + const caller = await createCallerForUser(user.id); + const organizationId = crypto.randomUUID(); + const rawTimestamp = '2026-04-29 01:16:12.945+00'; + const [config] = await db + .insert(mcp_gateway_configs) + .values({ + owner_scope: 'organization', + owner_id: organizationId, + name: 'Organization MCP', + remote_url: 'https://example.com/mcp', + auth_mode: 'none', + sharing_mode: 'multi_user', + created_by_kilo_user_id: user.id, + created_at: rawTimestamp, + updated_at: rawTimestamp, + }) + .returning(); + await db.insert(mcp_gateway_connect_resources).values({ + config_id: config.config_id, + owner_scope: 'organization', + owner_id: organizationId, + route_key: 'abcdefghijklmnopqrstuvwxyzABCDEF', + canonical_url: `https://mcp.kilo.ai/mcp-connect/org/${organizationId}/${config.config_id}/abcdefghijklmnopqrstuvwxyzABCDEF`, + }); + await db.insert(mcp_gateway_assignments).values({ + config_id: config.config_id, + kilo_user_id: user.id, + assigned_by_kilo_user_id: user.id, + created_at: rawTimestamp, + }); + await db.insert(mcp_gateway_connection_instances).values({ + config_id: config.config_id, + owner_scope: 'organization', + owner_id: organizationId, + kilo_user_id: user.id, + last_used_at: rawTimestamp, + }); + + const detail = await caller.mcpGateway.getOrganization({ + organizationId, + configId: config.config_id, + }); + expect(detail.createdAt).toBe('2026-04-29T01:16:12.945Z'); + expect(detail.updatedAt).toBe('2026-04-29T01:16:12.945Z'); + expect(detail.assignments[0]?.createdAt).toBe('2026-04-29T01:16:12.945Z'); + expect(detail.instances[0]?.lastUsedAt).toBe('2026-04-29T01:16:12.945Z'); + }); + it('does not allow an admin to mutate another admins personal connection', async () => { const owner = await insertTestUser({ is_admin: true }); const otherAdmin = await insertTestUser({ is_admin: true }); diff --git a/apps/web/src/routers/mcp-gateway-router.ts b/apps/web/src/routers/mcp-gateway-router.ts index 55a7ff0da3..3a1aa6c698 100644 --- a/apps/web/src/routers/mcp-gateway-router.ts +++ b/apps/web/src/routers/mcp-gateway-router.ts @@ -43,6 +43,12 @@ type RouteRow = typeof mcp_gateway_connect_resources.$inferSelect; type AssignmentRow = typeof mcp_gateway_assignments.$inferSelect; type InstanceRow = typeof mcp_gateway_connection_instances.$inferSelect; +function serializeTimestamp(value: string): string; +function serializeTimestamp(value: string | null): string | null; +function serializeTimestamp(value: string | null) { + return value ? new Date(value).toISOString() : null; +} + function configProjection(params: { config: ConfigRow; route: RouteRow; @@ -67,8 +73,8 @@ function configProjection(params: { routeStatus: params.route.route_status, registryMetadata: params.config.registry_metadata, auxiliaryHeaders: params.config.auxiliary_headers, - createdAt: params.config.created_at, - updatedAt: params.config.updated_at, + createdAt: serializeTimestamp(params.config.created_at), + updatedAt: serializeTimestamp(params.config.updated_at), assignmentCount: params.assignments.length, instanceCount: params.instances.length, activeGrantCount: params.activeGrantCount, @@ -93,13 +99,13 @@ function detailProjection(params: { assignmentId: assignment.assignment_id, userId: assignment.kilo_user_id, assignedByUserId: assignment.assigned_by_kilo_user_id, - createdAt: assignment.created_at, + createdAt: serializeTimestamp(assignment.created_at), })), instances: params.instances.map(instance => ({ instanceId: instance.instance_id, userId: instance.kilo_user_id, status: instance.instance_status, - lastUsedAt: instance.last_used_at, + lastUsedAt: serializeTimestamp(instance.last_used_at), })), }; } @@ -206,8 +212,8 @@ async function getConfigDetail(params: { ownerScope: (typeof GatewayOwnerScope)[keyof typeof GatewayOwnerScope]; ownerId: string; }) { - const services = createGatewayServices(); - const resolved = await services.repository.findActiveRouteByConfigId(params.configId); + const repository = createGatewayRepository(db); + const resolved = await repository.findActiveRouteByConfigId(params.configId); if ( !resolved || resolved.config.owner_scope !== params.ownerScope || From 24093e63917a6c0c2c5a5b6ee9b19193ef518d6c Mon Sep 17 00:00:00 2001 From: syn Date: Thu, 4 Jun 2026 12:10:51 -0500 Subject: [PATCH 03/18] feat(mcp-gateway): refine connection setup and management --- .../mcp-gateway/ConnectionStatusBadge.tsx | 59 ++ .../mcp-gateway/McpGatewayDetailContent.tsx | 572 +++++++------- .../mcp-gateway/McpGatewayListContent.tsx | 176 ++++- .../mcp-gateway/McpGatewaySetupContent.tsx | 713 ++++++++++++------ .../cloud/mcp-gateway/OrgMemberPicker.tsx | 136 ++++ .../web/src/lib/mcp-gateway/config-service.ts | 61 ++ apps/web/src/routers/mcp-gateway-router.ts | 4 + 7 files changed, 1194 insertions(+), 527 deletions(-) create mode 100644 apps/web/src/app/(app)/cloud/mcp-gateway/ConnectionStatusBadge.tsx create mode 100644 apps/web/src/app/(app)/cloud/mcp-gateway/OrgMemberPicker.tsx diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/ConnectionStatusBadge.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/ConnectionStatusBadge.tsx new file mode 100644 index 0000000000..beff44ae5e --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/ConnectionStatusBadge.tsx @@ -0,0 +1,59 @@ +import { cn } from '@/lib/utils'; + +type ConnectionStatusInput = { + enabled: boolean; + authMode: string; + activeGrantCount: number; +}; + +type StatusTone = 'positive' | 'attention' | 'neutral'; + +type ConnectionStatus = { + label: string; + description: string; + tone: StatusTone; +}; + +export function getConnectionStatus(connection: ConnectionStatusInput): ConnectionStatus { + if (!connection.enabled) { + return { label: 'Disabled', description: 'Requests are blocked', tone: 'neutral' }; + } + if (connection.authMode === 'none' || connection.authMode === 'static_headers') { + return { label: 'Ready', description: 'No provider sign-in required', tone: 'positive' }; + } + if (connection.activeGrantCount > 0) { + return { label: 'Signed in', description: 'A user has an active grant', tone: 'positive' }; + } + return { label: 'Needs sign-in', description: 'No active provider grant yet', tone: 'attention' }; +} + +const toneDot: Record = { + positive: 'bg-green-400', + attention: 'bg-amber-400', + neutral: 'bg-muted-foreground', +}; + +const toneText: Record = { + positive: 'text-foreground', + attention: 'text-amber-200', + neutral: 'text-muted-foreground', +}; + +export function ConnectionStatusBadge({ + connection, + className, +}: { + connection: ConnectionStatusInput; + className?: string; +}) { + const status = getConnectionStatus(connection); + return ( + + + {status.label} + + ); +} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx index ca8a7f79cb..a5eec3dc3e 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx @@ -5,10 +5,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; +import { ConnectionStatusBadge } from './ConnectionStatusBadge'; +import { OrgMemberPicker } from './OrgMemberPicker'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; import { SecretTokenInput } from '@/components/ui/secret-token-input'; import { AlertDialog, @@ -21,8 +23,8 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; -import { ArrowLeft, Copy, RotateCw, ShieldAlert, Trash2 } from 'lucide-react'; -import { useState } from 'react'; +import { ArrowLeft, CheckCircle2, Copy, RotateCw, ShieldAlert, Trash2 } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { toast } from 'sonner'; type McpGatewayDetailContentProps = { @@ -41,14 +43,32 @@ function authLabel(authMode: string) { case 'static_headers': return 'Static headers'; case 'oauth_dynamic': - return 'Provider sign-in'; + return 'Automatic provider sign-in'; case 'oauth_static': - return 'Provider sign-in'; + return 'Manual provider credentials'; default: return authMode; } } +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + export function McpGatewayDetailContent({ configId, organizationId, @@ -70,6 +90,18 @@ export function McpGatewayDetailContent({ ? trpc.mcpGateway.getOrganization.queryOptions({ organizationId, configId }) : trpc.mcpGateway.getPersonal.queryOptions({ configId }) ); + const membersQuery = useQuery({ + ...trpc.organizations.withMembers.queryOptions({ organizationId: organizationId ?? '' }), + enabled: Boolean(organizationId), + }); + const memberById = useMemo(() => { + const map = new Map(); + for (const member of membersQuery.data?.members ?? []) { + if (member.status === 'active') + map.set(member.id, { name: member.name, email: member.email }); + } + return map; + }, [membersQuery.data]); const refresh = () => { void queryClient.invalidateQueries({ queryKey: organizationId @@ -170,7 +202,26 @@ export function McpGatewayDetailContent({ } if (detailQuery.isLoading) { - return
Loading connection…
; + return ( +
+
+ + + +
+ + + + + + + + + + + +
+ ); } if (detailQuery.isError || !detailQuery.data) { return ( @@ -183,6 +234,12 @@ export function McpGatewayDetailContent({ ); } const connection = detailQuery.data; + const needsSignIn = requiresProviderSignIn(connection.authMode); + const signedIn = connection.activeGrantCount > 0; + const managesCredentials = + connection.authMode === 'static_headers' || connection.authMode === 'oauth_static'; + const missingStaticCredentials = + connection.authMode === 'oauth_static' && !connection.hasStaticProviderCredentials; return (
@@ -191,174 +248,186 @@ export function McpGatewayDetailContent({ href={routes.list} className="text-muted-foreground inline-flex items-center gap-2 text-sm hover:text-foreground" > - + Back to connections

{connection.name}

- - {connection.enabled ? 'Active' : 'Disabled'} - +
-

{connection.remoteUrl}

+

{connection.remoteUrl}

- Connection - Current connection definition and runtime status. + Overview + What this connection points at, and its live usage. -
-

Connection

-
-
-
Remote MCP URL
-
{connection.remoteUrl}
-
-
-
Sharing mode
-
- {connection.sharingMode === 'single_user' ? 'Single user' : 'Shared endpoint'} -
-
-
-
Provider sign-in
-
{authLabel(connection.authMode)}
+
+ + {connection.remoteUrl} + + + {connection.sharingMode === 'single_user' ? 'Single user' : 'Multiple users'} + + {authLabel(connection.authMode)} + + {connection.pathPassthrough ? 'Allowed' : 'Exact endpoint only'} + +
+
+ {organizationId && } + + +
+ + + + {organizationId && ( + + + Access + Choose who can use this connection. + + +
+ +
+ {organizationId && ( + assignment.userId)} + /> + )} +
-
-
Path passthrough
-
{connection.pathPassthrough ? 'Allowed' : 'Disabled'}
+
+ {connection.assignments.length > 0 ? ( +
+ {connection.assignments.map(assignment => { + const member = memberById.get(assignment.userId); + return ( +
+
+
+ {member?.name || member?.email || 'Unknown member'} +
+
+ {member?.name ? member.email : assignment.userId} +
+
+ +
+ ); + })}
-
-
+ ) : ( +

No members assigned yet.

+ )} +
+
+ )} -
-

Access

-
- {organizationId && ( -
-
Assigned users
-
{connection.assignmentCount}
+ {needsSignIn && ( + + + Provider sign-in + + {organizationId + ? 'Assigned users sign in with their own provider account.' + : 'Sign in so Kilo Code can call this server on your behalf.'} + + + + {signedIn ? ( +
+ +
+

+ {organizationId ? 'Provider sign-in active' : "You're signed in"} +

+

+ {connection.activeGrantCount === 1 + ? '1 active provider grant.' + : `${connection.activeGrantCount} active provider grants.`}{' '} + Kilo Code can reach this server now. +

- )} -
-
Active instances
-
{connection.instanceCount}
-
-
-
Provider grants
-
{connection.activeGrantCount}
-
- {!organizationId && ( -

- Personal connections are available only in your personal context. -

- )} - {organizationId && ( - <> -
- setAssignedUserId(event.target.value)} - placeholder="User ID" - aria-label="Assign user ID" - className="sm:max-w-xs" - /> - + ) : ( +
+ +
+

Not signed in yet

+

+ {organizationId + ? 'Assigned users complete sign-in when they first use this connection.' + : 'Sign in to start using this connection.'} +

- {connection.assignments.length > 0 && ( -
- {connection.assignments.map(assignment => ( -
- - {assignment.userId} - - -
- ))} -
- )} - +
)} -
- -
-

Provider sign-in

- {!requiresProviderSignIn(connection.authMode) && ( -

- This connection does not require provider sign-in. + {missingStaticCredentials && ( +

+ Add provider credentials in the Credentials section below before signing in.

)} - {requiresProviderSignIn(connection.authMode) && ( - <> -

- {connection.activeGrantCount > 0 - ? 'At least one assigned user has an active provider sign-in.' - : organizationId - ? 'Assigned users complete provider sign-in when they start using this connection.' - : 'No active provider sign-in yet. Start sign-in before using this connection.'} -

-
- {!organizationId && ( - - )} - - {connection.hasDynamicRegistration - ? 'Automatic provider sign-in available' - : 'Automatic provider sign-in not available'} - - - {connection.hasStaticProviderCredentials - ? 'Manual provider credentials saved' - : 'Manual provider credentials not saved'} - -
- + {!organizationId && ( + )} -
+ + + )} -
-

Credentials

-

- Stored secrets are not shown again after saving. -

+ {managesCredentials && ( + + + Credentials + Stored secrets are not shown again after saving. + + {connection.authMode === 'static_headers' && (
- +
- + - Save static header + {staticHeadersMutation.isPending ? 'Saving...' : 'Save static header'}
)} @@ -425,130 +494,129 @@ export function McpGatewayDetailContent({ !providerClientId || !providerClientSecret || staticProviderMutation.isPending } > - Save provider credentials + {staticProviderMutation.isPending ? 'Saving...' : 'Save provider credentials'}
)} - {(connection.authMode === 'none' || connection.authMode === 'oauth_dynamic') && ( -

- This connection does not use manually managed credentials. -

- )} -
+ + + )} -
-

Connect URL

-
- - {connection.canonicalUrl} - -
- - - - - - - - Rotate this connect URL? - - The current URL and any gateway tokens bound to it stop working immediately. - Provider sign-in grants remain available on the new URL. - - - - - Cancel - - rotateMutation.mutate(managedConfigInput)} - disabled={rotateMutation.isPending} - > - Rotate URL - - - - -
-
-

- Rotating the route key invalidates the old connect URL immediately. -

-
- -
-

Danger zone

-
- - - - - - - Disable this connection? - - Requests through this connection will be blocked immediately after this - action. - - - - - Cancel - - disableMutation.mutate(managedConfigInput)} - disabled={disableMutation.isPending} - > - Disable connection - - - - - + + + Connect URL + + Point Kilo Code at this URL. Rotating it invalidates the old URL immediately. + + + +
+ + {connection.canonicalUrl} + +
+ + - - Delete this connection? + Rotate this connect URL? - This permanently invalidates its connect URL and revokes dependent instances, - provider grants, and pending provider sign-ins. + The current URL and any gateway tokens bound to it stop working immediately. + Provider sign-in grants remain available on the new URL. - + Cancel deleteMutation.mutate(managedConfigInput)} - disabled={deleteMutation.isPending} + onClick={() => rotateMutation.mutate(managedConfigInput)} + disabled={rotateMutation.isPending} > - Delete connection + Rotate URL
-
+ + + + + + + Danger zone + These actions take effect immediately. + + +
+ + + + + + + Disable this connection? + + Requests through this connection will be blocked immediately after this action. + + + + Cancel + disableMutation.mutate(managedConfigInput)} + disabled={disableMutation.isPending} + > + Disable connection + + + + + + + + + + + Delete this connection? + + This permanently invalidates its connect URL and revokes dependent instances, + provider grants, and pending provider sign-ins. + + + + Cancel + deleteMutation.mutate(managedConfigInput)} + disabled={deleteMutation.isPending} + > + Delete connection + + + + +
diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx index e4c831386a..76248f9ada 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx @@ -2,11 +2,11 @@ import Link from 'next/link'; import { useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; +import { ConnectionStatusBadge } from './ConnectionStatusBadge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { @@ -18,20 +18,31 @@ import { TableRow, } from '@/components/ui/table'; import { Skeleton } from '@/components/ui/skeleton'; -import { Copy, ExternalLink, Plus } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Copy, Plus, Settings, Trash2, User, Users } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; type McpGatewayListContentProps = { organizationId?: string; }; -function statusBadge(params: { enabled: boolean; activeGrantCount: number; authMode: string }) { - if (!params.enabled) return Disabled; - if (params.authMode === 'none' || params.authMode === 'static_headers') { - return Ready; +function remoteHost(remoteUrl: string) { + try { + return new URL(remoteUrl).host; + } catch { + return remoteUrl; } - if (params.activeGrantCount > 0) return Provider signed in; - return Needs sign-in; } function authLabel(authMode: string) { @@ -51,8 +62,10 @@ function authLabel(authMode: string) { export function McpGatewayListContent({ organizationId }: McpGatewayListContentProps) { const trpc = useTRPC(); + const queryClient = useQueryClient(); const routes = getMcpGatewayRoutes(organizationId); const [filter, setFilter] = useState(''); + const [deleteConfigId, setDeleteConfigId] = useState(null); const listQuery = useQuery( organizationId ? trpc.mcpGateway.listOrganization.queryOptions({ organizationId }) @@ -69,6 +82,23 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP .includes(query) ); }, [filter, listQuery.data]); + const deletingConnection = (listQuery.data ?? []).find( + connection => connection.configId === deleteConfigId + ); + const deleteMutation = useMutation( + trpc.mcpGateway.delete.mutationOptions({ + onSuccess: () => { + toast.success('Connection deleted'); + setDeleteConfigId(null); + void queryClient.invalidateQueries({ + queryKey: organizationId + ? trpc.mcpGateway.listOrganization.queryKey({ organizationId }) + : trpc.mcpGateway.listPersonal.queryKey(), + }); + }, + onError: error => toast.error(error.message || 'Could not delete the connection'), + }) + ); async function copyConnectUrl(url: string) { try { @@ -172,7 +202,7 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP {filteredConnections.map(connection => ( -
+
- {connection.canonicalUrl} + {remoteHost(connection.remoteUrl)}
- {statusBadge(connection)} + + + {authLabel(connection.authMode)} - - {connection.sharingMode === 'single_user' ? 'Single user' : 'Shared'} + + + + + {connection.sharingMode === 'single_user' ? ( + + ) : ( + + )} + {connection.sharingMode === 'single_user' ? 'Single' : 'Shared'} + + + + {connection.sharingMode === 'single_user' + ? 'Only one user can be assigned' + : 'Multiple users can be assigned'} + + {organizationId && ( {connection.assignmentCount} )} - - {new Date(connection.updatedAt).toLocaleString()} + + + {formatDistanceToNow(new Date(connection.updatedAt), { + addSuffix: true, + })} +
- - + + + + + Manage connection + + + + + + Copy connect URL + + + + + + Delete connection +
@@ -227,6 +304,37 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP )} + { + if (!open) setDeleteConfigId(null); + }} + > + + + Delete this connection? + + {deletingConnection + ? `${deletingConnection.name} will stop working immediately. Existing connect URLs and provider sign-ins for this connection will no longer be usable.` + : 'This connection will stop working immediately. Existing connect URLs and provider sign-ins for this connection will no longer be usable.'} + + + + + Keep connection + + { + if (!deletingConnection) return; + deleteMutation.mutate({ configId: deletingConnection.configId, organizationId }); + }} + > + {deleteMutation.isPending ? 'Deleting...' : 'Delete connection'} + + + +
); } diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx index 7b0c936cb9..53b7a8b0c9 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx @@ -1,14 +1,16 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { useTRPC } from '@/lib/trpc/utils'; import { getMcpGatewayRoutes } from '@/lib/mcp-gateway/routes'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; import { SecretTokenInput } from '@/components/ui/secret-token-input'; import { Select, @@ -18,7 +20,8 @@ import { SelectValue, } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; -import { ArrowLeft, ArrowRight, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ArrowLeft, ArrowRight, Check, RotateCcw, ShieldCheck, TriangleAlert } from 'lucide-react'; import Link from 'next/link'; import { toast } from 'sonner'; @@ -30,20 +33,83 @@ type SetupDraft = { name: string; remoteUrl: string; authMode: 'none' | 'static_headers' | 'oauth_dynamic' | 'oauth_static'; - sharingMode: 'single_user' | 'multi_user'; - initialAssignedUserId: string; providerIssuer: string; staticProviderClientId: string; staticProviderClientSecret: string; + staticHeaderName: string; + staticHeaderValue: string; pathPassthrough: boolean; }; +const STEPS = [ + { id: 1, label: 'Server' }, + { id: 2, label: 'Access' }, +] as const; + +const DISCOVERY_DEBOUNCE_MS = 600; + function isAuthMode(value: string): value is SetupDraft['authMode'] { return ['none', 'static_headers', 'oauth_dynamic', 'oauth_static'].includes(value); } -function isSharingMode(value: string): value is SetupDraft['sharingMode'] { - return value === 'single_user' || value === 'multi_user'; +function authModeLabel(authMode: SetupDraft['authMode']) { + switch (authMode) { + case 'oauth_dynamic': + return 'Automatic provider sign-in'; + case 'oauth_static': + return 'Manual provider credentials'; + case 'static_headers': + return 'Static headers'; + case 'none': + return 'No provider sign-in'; + } +} + +function hostOf(value: string) { + try { + return new URL(value).host; + } catch { + return value; + } +} + +function Stepper({ current }: { current: number }) { + return ( +
    + {STEPS.map((step, index) => { + const isDone = current > step.id; + const isCurrent = current === step.id; + return ( +
  1. +
    + + {isDone ? : step.id} + + + {step.label} + +
    + {index < STEPS.length - 1 && ( + + )} +
  2. + ); + })} +
+ ); } export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupContentProps) { @@ -55,22 +121,15 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten name: '', remoteUrl: '', authMode: 'oauth_dynamic', - sharingMode: organizationId ? 'multi_user' : 'single_user', - initialAssignedUserId: '', providerIssuer: '', staticProviderClientId: '', staticProviderClientSecret: '', + staticHeaderName: 'Authorization', + staticHeaderValue: '', pathPassthrough: false, }); - const discoveryMutation = useMutation( - trpc.mcpGateway.discover.mutationOptions({ - onError: error => - toast.error( - error.message || - "We couldn't discover that remote MCP server. Check the URL and try again." - ), - }) - ); + const [discoveryAttemptedUrl, setDiscoveryAttemptedUrl] = useState(null); + const discoveryMutation = useMutation(trpc.mcpGateway.discover.mutationOptions()); const createPersonalMutation = useMutation( trpc.mcpGateway.createPersonal.mutationOptions({ onSuccess: data => { @@ -107,6 +166,10 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten discoveryMutation.data && discoveryMutation.data.remoteUrl === currentRemoteUrl ? discoveryMutation.data : undefined; + const discoveryPendingForCurrent = + discoveryMutation.isPending && discoveryAttemptedUrl === currentRemoteUrl; + const discoveryFailedForCurrent = + discoveryMutation.isError && discoveryAttemptedUrl === currentRemoteUrl; const defaultProvider = discovery?.providerCandidates.find(candidate => candidate.hasRegistrationEndpoint) ?? discovery?.providerCandidates[0]; @@ -124,14 +187,50 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten setDraft(current => ({ ...current, ...values })); } - function runDiscovery() { - if (!draft.name || !draft.remoteUrl) { - toast.error('Please enter a connection name and remote MCP URL.'); + function runDiscovery(remoteUrl: string) { + setDiscoveryAttemptedUrl(remoteUrl); + discoveryMutation.mutate({ remoteUrl }); + } + + // Auto-probe a valid URL shortly after the user stops typing. Triggering + // discovery (onBlur / Re-check) sets discoveryAttemptedUrl, which makes this + // effect re-run, hit the early return, and cancel the pending debounce. + useEffect(() => { + if (!currentRemoteUrl) return; + if (discovery || discoveryAttemptedUrl === currentRemoteUrl) return; + const handle = setTimeout(() => { + setDiscoveryAttemptedUrl(currentRemoteUrl); + discoveryMutation.mutate({ remoteUrl: currentRemoteUrl }); + }, DISCOVERY_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [currentRemoteUrl, discovery, discoveryAttemptedUrl, discoveryMutation]); + + function checkNow() { + if (!currentRemoteUrl) { + toast.error('Enter a valid HTTPS MCP URL first.'); return; } - discoveryMutation.mutate({ remoteUrl: draft.remoteUrl }); + runDiscovery(currentRemoteUrl); } + const canLeaveServerStep = Boolean(draft.name.trim() && currentRemoteUrl && discovery); + const credentialsIncomplete = + selectedAuthMode === 'oauth_static' && + (!draft.staticProviderClientId || !draft.staticProviderClientSecret); + const staticHeaderIncomplete = + selectedAuthMode === 'static_headers' && + draft.staticHeaderValue.trim().length > 0 && + draft.staticHeaderName.trim().length === 0; + const accessIncomplete = credentialsIncomplete || staticHeaderIncomplete; + const isCreating = createPersonalMutation.isPending || createOrganizationMutation.isPending; + + const staticHeaders = + selectedAuthMode === 'static_headers' && + draft.staticHeaderName.trim() && + draft.staticHeaderValue.trim() + ? { [draft.staticHeaderName.trim()]: draft.staticHeaderValue } + : undefined; + function createConnection() { if (organizationId) { createOrganizationMutation.mutate({ @@ -142,11 +241,8 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten providerIssuer: selectedProviderIssuer || undefined, staticProviderClientId: draft.staticProviderClientId || undefined, staticProviderClientSecret: draft.staticProviderClientSecret || undefined, - sharingMode: draft.sharingMode, - initialAssignedUserId: - draft.sharingMode === 'single_user' - ? draft.initialAssignedUserId || undefined - : undefined, + staticHeaders, + sharingMode: 'multi_user', pathPassthrough: draft.pathPassthrough, }); return; @@ -156,13 +252,22 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten remoteUrl: draft.remoteUrl, authMode: selectedAuthMode, providerIssuer: selectedProviderIssuer || undefined, - staticProviderClientId: draft.staticProviderClientId || undefined, staticProviderClientSecret: draft.staticProviderClientSecret || undefined, + staticHeaders, pathPassthrough: draft.pathPassthrough, }); } + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (step === 1) { + if (canLeaveServerStep) setStep(2); + return; + } + if (!accessIncomplete && !isCreating) createConnection(); + } + return (
@@ -170,255 +275,381 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten href={routes.list} className="text-muted-foreground inline-flex items-center gap-2 text-sm hover:text-foreground" > - + Back to connections

Create connection

-

- Connect Kilo Code to a remote MCP server. We check the remote server before asking for - provider sign-in details. +

+ Connect Kilo Code to a remote MCP server and choose how it signs in.

- - Step {step} of 3 - - {step === 1 && 'Connection details'} - {step === 2 && 'Remote discovery'} - {step === 3 && 'Access and credentials'} - + + - - {step === 1 && ( -
-
- - updateDraft({ name: event.target.value })} - placeholder="Production tools" - /> -
-
- - { - discoveryMutation.reset(); - updateDraft({ remoteUrl: event.target.value, providerIssuer: '' }); - }} - placeholder="https://mcp.example.com" - /> -

- The server must be publicly reachable over HTTPS. -

+ +
+ {step === 1 && ( +
+
+ + { + discoveryMutation.reset(); + setDiscoveryAttemptedUrl(null); + updateDraft({ remoteUrl: event.target.value, providerIssuer: '' }); + }} + onBlur={() => { + if (currentRemoteUrl && discoveryAttemptedUrl !== currentRemoteUrl) { + runDiscovery(currentRemoteUrl); + } + }} + placeholder="https://mcp.example.com/mcp" + aria-describedby="remote-url-hint" + /> +

+ Public HTTPS endpoint. We check it automatically and detect how it signs in. +

+ +
+ +
+ + updateDraft({ name: event.target.value })} + placeholder="Production tools" + aria-describedby="connection-name-hint" + /> +

+ Shown in the connections list and to teammates who use it. +

+
+ + {organizationId && ( +

+ Assign teammates after the connection is created. How each one authenticates + depends on the sign-in method you choose next. +

+ )} + +
- {organizationId && ( + )} + + {step === 2 && ( +
+ {discovery && discovery.providerCandidates.length > 1 && ( +
+ + +

+ {hostOf(currentRemoteUrl ?? '')} advertises more than one sign-in provider. +

+
+ )} +
- + -
- )} - {organizationId && draft.sharingMode === 'single_user' && ( -
- - updateDraft({ initialAssignedUserId: event.target.value })} - placeholder="User ID" - /> -
- )} - -
- )} - {step === 2 && ( -
-
-

{draft.remoteUrl || 'Remote MCP URL not set'}

-

- Discovery checks the remote server and provider metadata. -

-
- {discovery && ( -
-
- Provider discovery - {discovery.providerCandidates.length > 0 ? 'Found' : 'Not found'} -
-
- Dynamic registration - {dynamicAvailable ? 'Available' : 'Not available'} -
- {discovery.providerCandidates.length > 1 && ( -
- - + {selectedAuthMode === 'oauth_dynamic' && ( +

+ {selectedProviderIssuer + ? `${hostOf(selectedProviderIssuer)} registers Kilo Code automatically. Each assigned user signs in with their own provider account after the connection is created.` + : 'The server registers Kilo Code automatically. Each assigned user signs in with their own provider account after the connection is created.'} +

+ )} + {selectedAuthMode === 'oauth_static' && ( +
+

+ {dynamicAvailable + ? 'Use a provider app you registered yourself; each assigned user still signs in with their own account.' + : "This server doesn't advertise automatic registration, so register a provider app and add its credentials here. Each assigned user still signs in with their own account."}{' '} + Credentials are encrypted and not shown again after saving. +

+ + updateDraft({ staticProviderClientId: event.target.value }) + } + placeholder="Provider client ID" + aria-label="Provider client ID" + toggleLabel="Show provider client ID" + /> + + updateDraft({ staticProviderClientSecret: event.target.value }) + } + placeholder="Provider client secret" + aria-label="Provider client secret" + toggleLabel="Show provider client secret" + /> +
+ )} + {selectedAuthMode === 'static_headers' && ( +
+

+ Sent on every upstream request and shared by all assigned users. Encrypted + and not shown again after saving. +

+ updateDraft({ staticHeaderName: event.target.value })} + placeholder="Header name" + aria-label="Static header name" + /> + updateDraft({ staticHeaderValue: event.target.value })} + placeholder="Header value" + aria-label="Static header value" + toggleLabel="Show header value" + />
)} - {!dynamicAvailable && ( + {selectedAuthMode === 'none' && (

- This provider does not advertise automatic registration. Add manual provider - credentials before creating the connection. + Kilo Code forwards requests without any credentials. Nobody signs in.

)}
- )} - {!discovery && ( -

Run discovery to continue.

- )} -
- )} - {step === 3 && ( -
-
- - - {selectedAuthMode === 'oauth_static' && ( -
-

- Stored provider credentials are not shown again after saving. -

- - updateDraft({ staticProviderClientId: event.target.value }) - } - placeholder="Provider client ID" - aria-label="Provider client ID" - toggleLabel="Show provider client ID" - /> - - updateDraft({ staticProviderClientSecret: event.target.value }) - } - placeholder="Provider client secret" - aria-label="Provider client secret" - toggleLabel="Show provider client secret" - /> -
- )} -
-
-

Review

-
-
-
Name
-
{draft.name}
-
-
-
Remote server
-
{draft.remoteUrl}
-
-
-
Provider sign-in
-
{selectedAuthMode.replaceAll('_', ' ')}
-
+ +
+ + +
-
- )} -
- -
- {step === 2 && ( - + ) : ( + )} - {step < 3 && ( - - )} - {step === 3 && ( - )}
-
+
); } + +function ReviewRow({ + label, + value, + mono, + last, +}: { + label: string; + value: string; + mono?: boolean; + last?: boolean; +}) { + return ( +
+
{label}
+
+ {value || Not set} +
+
+ ); +} + +function DiscoveryStatus({ + hasUrl, + host, + pending, + failed, + errorMessage, + providerCount, + dynamicAvailable, + providerHost, + onRetry, +}: { + hasUrl: boolean; + host: string; + pending: boolean; + failed: boolean; + errorMessage?: string; + providerCount: number | null; + dynamicAvailable: boolean; + providerHost: string; + onRetry: () => void; +}) { + if (!hasUrl) return null; + + if (pending) { + return ( +
+

Checking {host}...

+
+ + +
+
+ ); + } + + if (failed) { + return ( +
+
+ +
+

Couldn't reach {host}

+

+ {errorMessage || 'Check that the server uses public HTTPS, then try again.'} +

+ +
+
+
+ ); + } + + if (providerCount === null) return null; + + const hasProvider = providerCount > 0; + return ( +
+
+

+ + {host} is reachable +

+ +
+
+ {hasProvider ? ( + <> + + {providerCount > 1 ? `${providerCount} sign-in providers` : 'Sign-in provider found'} + + + {dynamicAvailable ? 'Automatic sign-in' : 'Manual credentials'} + + {providerHost && ( + {providerHost} + )} + + ) : ( + No OAuth provider advertised + )} +
+ {!hasProvider && ( +

+ Choose static headers or no sign-in on the next step. +

+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/OrgMemberPicker.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/OrgMemberPicker.tsx new file mode 100644 index 0000000000..f221ab0315 --- /dev/null +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/OrgMemberPicker.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +type OrgMemberPickerProps = { + organizationId: string; + value: string; + onValueChange: (userId: string) => void; + excludeUserIds?: string[]; + disabled?: boolean; + id?: string; + placeholder?: string; +}; + +function initials(name: string, email: string) { + const source = name.trim() || email.trim(); + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return source.slice(0, 2).toUpperCase(); +} + +export function OrgMemberPicker({ + organizationId, + value, + onValueChange, + excludeUserIds, + disabled, + id, + placeholder = 'Select a member', +}: OrgMemberPickerProps) { + const trpc = useTRPC(); + const [open, setOpen] = useState(false); + const membersQuery = useQuery(trpc.organizations.withMembers.queryOptions({ organizationId })); + + const members = useMemo(() => { + const excluded = new Set(excludeUserIds ?? []); + return (membersQuery.data?.members ?? []).flatMap(member => + member.status === 'active' && !excluded.has(member.id) + ? [{ id: member.id, name: member.name, email: member.email }] + : [] + ); + }, [membersQuery.data, excludeUserIds]); + + const selected = members.find(member => member.id === value); + + return ( + + + + + + + itemValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0 + } + > + + + {membersQuery.isLoading && ( +
Loading members...
+ )} + {membersQuery.isError && ( +
Couldn't load members.
+ )} + {!membersQuery.isLoading && !membersQuery.isError && ( + <> + No members found. + + {members.map(member => ( + { + onValueChange(member.id === value ? '' : member.id); + setOpen(false); + }} + className="gap-2" + > + + + {initials(member.name, member.email)} + + +
+ {member.name || member.email} + {member.name && ( + + {member.email} + + )} +
+ +
+ ))} +
+ + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/lib/mcp-gateway/config-service.ts b/apps/web/src/lib/mcp-gateway/config-service.ts index a700f35418..ac52773a42 100644 --- a/apps/web/src/lib/mcp-gateway/config-service.ts +++ b/apps/web/src/lib/mcp-gateway/config-service.ts @@ -86,6 +86,22 @@ export function createConfigService(params: { }; } + function initialStaticHeaders(input: { + authMode: GatewayAuthMode; + staticHeaders?: Record; + }) { + if (!input.staticHeaders || Object.keys(input.staticHeaders).length === 0) return null; + if (input.authMode !== GatewayAuthMode.StaticHeaders) { + throw createGatewayError( + GatewayErrorCode.InvalidRequest, + 'Static headers require static-headers auth mode', + 400 + ); + } + parseStaticHeaders(input.staticHeaders); + return input.staticHeaders; + } + function encryptSecret(input: { configId: string; kind: (typeof GatewaySecretKind)[keyof typeof GatewaySecretKind]; @@ -116,6 +132,23 @@ export function createConfigService(params: { }); } + async function insertInitialStaticHeaders( + tx: GatewayRepository['database'], + configId: string, + headers: Record | null + ) { + if (!headers) return; + await tx.insert(mcp_gateway_config_secrets).values({ + config_id: configId, + secret_kind: GatewaySecretKind.StaticHeaders, + encrypted_secret: encryptSecret({ + configId, + kind: GatewaySecretKind.StaticHeaders, + value: { headers }, + }), + }); + } + async function createPersonalConfig(input: { userId: string; name: string; @@ -124,9 +157,11 @@ export function createConfigService(params: { providerIssuer?: string; staticProviderClientId?: string; staticProviderClientSecret?: string; + staticHeaders?: Record; pathPassthrough?: boolean; }) { const staticProviderCredentials = initialStaticProviderCredentials(input); + const staticHeaders = initialStaticHeaders(input); await validatePublicHttpsDestination(input.remoteUrl); const discoveredProviderMetadata = await discoverProviderMetadata(input); return await params.repository.database.transaction(async tx => { @@ -149,6 +184,7 @@ export function createConfigService(params: { created.config.config_id, staticProviderCredentials ); + await insertInitialStaticHeaders(tx, created.config.config_id, staticHeaders); await auditService.record({ actorUserId: input.userId, ownerScope: created.config.owner_scope, @@ -169,6 +205,17 @@ export function createConfigService(params: { metadata: { kind: GatewaySecretKind.StaticProviderCredentials }, }); } + if (staticHeaders) { + await auditService.record({ + actorUserId: input.userId, + ownerScope: created.config.owner_scope, + ownerId: created.config.owner_id, + configId: created.config.config_id, + eventType: 'config_secret_updated', + outcome: 'success', + metadata: { kind: GatewaySecretKind.StaticHeaders }, + }); + } return created; }); } @@ -182,11 +229,13 @@ export function createConfigService(params: { providerIssuer?: string; staticProviderClientId?: string; staticProviderClientSecret?: string; + staticHeaders?: Record; sharingMode: GatewaySharingMode; initialAssignedUserId?: string; pathPassthrough?: boolean; }) { const staticProviderCredentials = initialStaticProviderCredentials(input); + const staticHeaders = initialStaticHeaders(input); await validatePublicHttpsDestination(input.remoteUrl); const discoveredProviderMetadata = await discoverProviderMetadata(input); if (input.sharingMode === GatewaySharingMode.SingleUser && !input.initialAssignedUserId) { @@ -238,6 +287,7 @@ export function createConfigService(params: { created.config.config_id, staticProviderCredentials ); + await insertInitialStaticHeaders(tx, created.config.config_id, staticHeaders); await auditService.record({ actorUserId: input.actorUserId, ownerScope: created.config.owner_scope, @@ -258,6 +308,17 @@ export function createConfigService(params: { metadata: { kind: GatewaySecretKind.StaticProviderCredentials }, }); } + if (staticHeaders) { + await auditService.record({ + actorUserId: input.actorUserId, + ownerScope: created.config.owner_scope, + ownerId: created.config.owner_id, + configId: created.config.config_id, + eventType: 'config_secret_updated', + outcome: 'success', + metadata: { kind: GatewaySecretKind.StaticHeaders }, + }); + } return created; }); } diff --git a/apps/web/src/routers/mcp-gateway-router.ts b/apps/web/src/routers/mcp-gateway-router.ts index 3a1aa6c698..0c14949d6e 100644 --- a/apps/web/src/routers/mcp-gateway-router.ts +++ b/apps/web/src/routers/mcp-gateway-router.ts @@ -306,6 +306,7 @@ export const mcpGatewayRouter = createTRPCRouter({ providerIssuer: z.string().url().optional(), staticProviderClientId: z.string().min(1).optional(), staticProviderClientSecret: z.string().min(1).optional(), + staticHeaders: StaticHeadersSchema.optional(), pathPassthrough: z.boolean().optional(), }) ) @@ -319,6 +320,7 @@ export const mcpGatewayRouter = createTRPCRouter({ providerIssuer: input.providerIssuer, staticProviderClientId: input.staticProviderClientId, staticProviderClientSecret: input.staticProviderClientSecret, + staticHeaders: input.staticHeaders, pathPassthrough: input.pathPassthrough, }); return { configId: created.config.config_id }; @@ -333,6 +335,7 @@ export const mcpGatewayRouter = createTRPCRouter({ providerIssuer: z.string().url().optional(), staticProviderClientId: z.string().min(1).optional(), staticProviderClientSecret: z.string().min(1).optional(), + staticHeaders: StaticHeadersSchema.optional(), sharingMode: SharingModeSchema, initialAssignedUserId: z.string().min(1).optional(), pathPassthrough: z.boolean().optional(), @@ -349,6 +352,7 @@ export const mcpGatewayRouter = createTRPCRouter({ providerIssuer: input.providerIssuer, staticProviderClientId: input.staticProviderClientId, staticProviderClientSecret: input.staticProviderClientSecret, + staticHeaders: input.staticHeaders, sharingMode: input.sharingMode, initialAssignedUserId: input.initialAssignedUserId, pathPassthrough: input.pathPassthrough, From 3a7ea415a8e9fb73e4a92d8d51419cb5aeb31283 Mon Sep 17 00:00:00 2001 From: syn Date: Thu, 4 Jun 2026 12:50:49 -0500 Subject: [PATCH 04/18] Clean up --- .../mcp-gateway/ConnectionStatusBadge.tsx | 19 ++- .../mcp-gateway/McpGatewayDetailContent.tsx | 45 +++--- .../mcp-gateway/McpGatewayListContent.tsx | 85 ++++------- .../mcp-gateway/McpGatewaySetupContent.tsx | 142 +++++++++++------- apps/web/src/lib/mcp-gateway/config.ts | 3 +- .../src/lib/mcp-gateway/discovery-service.ts | 2 + .../src/lib/mcp-gateway/oauth-flow.test.ts | 60 ++++++++ .../lib/mcp-gateway/provider-oauth-service.ts | 2 + 8 files changed, 220 insertions(+), 138 deletions(-) diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/ConnectionStatusBadge.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/ConnectionStatusBadge.tsx index beff44ae5e..306760e935 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/ConnectionStatusBadge.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/ConnectionStatusBadge.tsx @@ -29,14 +29,14 @@ export function getConnectionStatus(connection: ConnectionStatusInput): Connecti const toneDot: Record = { positive: 'bg-green-400', - attention: 'bg-amber-400', + attention: 'bg-yellow-400', neutral: 'bg-muted-foreground', }; -const toneText: Record = { - positive: 'text-foreground', - attention: 'text-amber-200', - neutral: 'text-muted-foreground', +const toneClassName: Record = { + positive: 'bg-green-500/10 text-green-400 ring-green-500/20', + attention: 'bg-yellow-500/10 text-yellow-300 ring-yellow-500/20', + neutral: 'bg-secondary text-muted-foreground ring-border', }; export function ConnectionStatusBadge({ @@ -49,10 +49,15 @@ export function ConnectionStatusBadge({ const status = getConnectionStatus(connection); return ( - + {status.label} ); diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx index a5eec3dc3e..c1dd4e7fc1 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx @@ -62,7 +62,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode } function Stat({ label, value }: { label: string; value: number }) { return ( -
+
{value}
{label}
@@ -242,7 +242,7 @@ export function McpGatewayDetailContent({ connection.authMode === 'oauth_static' && !connection.hasStaticProviderCredentials; return ( -
+
Overview - What this connection points at, and its live usage. + Endpoint, auth, and current connection state. -
+
{connection.remoteUrl} - - {connection.sharingMode === 'single_user' ? 'Single user' : 'Multiple users'} + + {organizationId ? 'Assigned organization members' : 'Personal owner'} {authLabel(connection.authMode)} {connection.pathPassthrough ? 'Allowed' : 'Exact endpoint only'}
-
+
{organizationId && } @@ -288,7 +288,7 @@ export function McpGatewayDetailContent({ Access - Choose who can use this connection. + Assign each member who can use this connection.
@@ -375,16 +375,17 @@ export function McpGatewayDetailContent({ {organizationId ? 'Provider sign-in active' : "You're signed in"}

- {connection.activeGrantCount === 1 - ? '1 active provider grant.' - : `${connection.activeGrantCount} active provider grants.`}{' '} - Kilo Code can reach this server now. + {organizationId + ? connection.activeGrantCount === 1 + ? '1 assigned user has an active provider grant.' + : `${connection.activeGrantCount} assigned users have active provider grants.` + : 'Kilo Code can reach this server now.'}

) : ( -
- +
+

Not signed in yet

@@ -396,7 +397,7 @@ export function McpGatewayDetailContent({

)} {missingStaticCredentials && ( -

+

Add provider credentials in the Credentials section below before signing in.

)} @@ -511,7 +512,7 @@ export function McpGatewayDetailContent({
- + {connection.canonicalUrl}
@@ -536,7 +537,7 @@ export function McpGatewayDetailContent({ - Cancel + Keep current URL - Cancel + + Keep connection + disableMutation.mutate(managedConfigInput)} @@ -591,7 +594,7 @@ export function McpGatewayDetailContent({ - @@ -605,7 +608,9 @@ export function McpGatewayDetailContent({ - Cancel + + Keep connection + deleteMutation.mutate(managedConfigInput)} diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx index 76248f9ada..28292adef8 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayListContent.tsx @@ -29,7 +29,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { Copy, Plus, Settings, Trash2, User, Users } from 'lucide-react'; +import { Copy, Plus, Settings, Trash2 } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; @@ -45,21 +45,6 @@ function remoteHost(remoteUrl: string) { } } -function authLabel(authMode: string) { - switch (authMode) { - case 'none': - return 'No provider sign-in'; - case 'static_headers': - return 'Static headers'; - case 'oauth_dynamic': - return 'Provider sign-in'; - case 'oauth_static': - return 'Provider sign-in'; - default: - return authMode; - } -} - export function McpGatewayListContent({ organizationId }: McpGatewayListContentProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -76,7 +61,7 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP const query = filter.trim().toLowerCase(); if (!query) return connections; return connections.filter(connection => - [connection.name, connection.authMode, connection.sharingMode] + [connection.name, connection.remoteUrl, remoteHost(connection.remoteUrl), connection.authMode] .join(' ') .toLowerCase() .includes(query) @@ -110,7 +95,7 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP } return ( -
+

MCP Gateway

@@ -127,12 +112,17 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP
- +
- Connections - - Remote MCP servers that can be connected through Kilo Code. - +
+ Connections + {!listQuery.isLoading && !listQuery.isError && ( + + {listQuery.data?.length ?? 0} + + )} +
+ Remote MCP servers available to Kilo Code.
- + {listQuery.isLoading && ( -
+
)} {listQuery.isError && ( -
+

We couldn't load connections. Try again.

)} {!listQuery.isLoading && !listQuery.isError && filteredConnections.length === 0 && ( -
+

{listQuery.data?.length ? 'No connections match that filter.' @@ -185,23 +175,21 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP

)} {!listQuery.isLoading && !listQuery.isError && filteredConnections.length > 0 && ( -
- +
+
Name - Status - Provider sign-in - Sharing - {organizationId && Assigned users} - Last updated - Actions + Status + {organizationId && Assigned users} + Last updated + Actions {filteredConnections.map(connection => ( - +
- - {authLabel(connection.authMode)} - - - - - - {connection.sharingMode === 'single_user' ? ( - - ) : ( - - )} - {connection.sharingMode === 'single_user' ? 'Single' : 'Shared'} - - - - {connection.sharingMode === 'single_user' - ? 'Only one user can be assigned' - : 'Multiple users can be assigned'} - - - {organizationId && ( {connection.assignmentCount} )} @@ -285,7 +251,7 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP variant="ghost" size="icon" aria-label={`Delete ${connection.name}`} - className="text-destructive hover:text-destructive" + className="text-muted-foreground hover:text-foreground" disabled={deleteMutation.isPending} onClick={() => setDeleteConfigId(connection.configId)} > @@ -324,6 +290,7 @@ export function McpGatewayListContent({ organizationId }: McpGatewayListContentP Keep connection { if (!deletingConnection) return; diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx index 53b7a8b0c9..4c048ba644 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx @@ -173,15 +173,20 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten const defaultProvider = discovery?.providerCandidates.find(candidate => candidate.hasRegistrationEndpoint) ?? discovery?.providerCandidates[0]; + const hasProvider = (discovery?.providerCandidates.length ?? 0) > 0; const selectedProvider = discovery?.providerCandidates.find(candidate => candidate.issuer === draft.providerIssuer) ?? defaultProvider; const selectedProviderIssuer = selectedProvider?.issuer ?? ''; const dynamicAvailable = selectedProvider?.hasRegistrationEndpoint ?? false; const selectedAuthMode = useMemo(() => { - if (draft.authMode === 'oauth_dynamic' && !dynamicAvailable && discovery) return 'oauth_static'; + if (!discovery) return draft.authMode; + if (!hasProvider && (draft.authMode === 'oauth_dynamic' || draft.authMode === 'oauth_static')) { + return 'static_headers'; + } + if (draft.authMode === 'oauth_dynamic' && !dynamicAvailable) return 'oauth_static'; return draft.authMode; - }, [draft.authMode, discovery, dynamicAvailable]); + }, [draft.authMode, discovery, dynamicAvailable, hasProvider]); function updateDraft(values: Partial) { setDraft(current => ({ ...current, ...values })); @@ -216,11 +221,10 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten const canLeaveServerStep = Boolean(draft.name.trim() && currentRemoteUrl && discovery); const credentialsIncomplete = selectedAuthMode === 'oauth_static' && - (!draft.staticProviderClientId || !draft.staticProviderClientSecret); + (!selectedProviderIssuer || !draft.staticProviderClientId || !draft.staticProviderClientSecret); const staticHeaderIncomplete = selectedAuthMode === 'static_headers' && - draft.staticHeaderValue.trim().length > 0 && - draft.staticHeaderName.trim().length === 0; + (draft.staticHeaderName.trim().length === 0 || draft.staticHeaderValue.trim().length === 0); const accessIncomplete = credentialsIncomplete || staticHeaderIncomplete; const isCreating = createPersonalMutation.isPending || createOrganizationMutation.isPending; @@ -269,7 +273,7 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten } return ( -
+
@@ -372,7 +376,7 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten {step === 2 && (
{discovery && discovery.providerCandidates.length > 1 && (
@@ -413,7 +417,9 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten Automatic provider sign-in - Manual provider credentials + + Manual provider credentials + Static headers No provider sign-in @@ -421,57 +427,91 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten {selectedAuthMode === 'oauth_dynamic' && (

{selectedProviderIssuer - ? `${hostOf(selectedProviderIssuer)} registers Kilo Code automatically. Each assigned user signs in with their own provider account after the connection is created.` - : 'The server registers Kilo Code automatically. Each assigned user signs in with their own provider account after the connection is created.'} + ? `${hostOf(selectedProviderIssuer)} registers Kilo Code automatically. ${ + organizationId + ? 'Each assigned user signs in with their own provider account after the connection is created.' + : 'You sign in with your provider account after the connection is created.' + }` + : organizationId + ? 'The server registers Kilo Code automatically. Each assigned user signs in with their own provider account after the connection is created.' + : 'The server registers Kilo Code automatically. You sign in with your provider account after the connection is created.'}

)} {selectedAuthMode === 'oauth_static' && (

{dynamicAvailable - ? 'Use a provider app you registered yourself; each assigned user still signs in with their own account.' - : "This server doesn't advertise automatic registration, so register a provider app and add its credentials here. Each assigned user still signs in with their own account."}{' '} + ? organizationId + ? 'Use a provider app you registered yourself. Each assigned user still signs in with their own account.' + : 'Use a provider app you registered yourself. You still sign in with your own account.' + : organizationId + ? "This server doesn't advertise automatic registration, so register a provider app and add its credentials here. Each assigned user still signs in with their own account." + : "This server doesn't advertise automatic registration, so register a provider app and add its credentials here. You still sign in with your own account."}{' '} Credentials are encrypted and not shown again after saving.

- - updateDraft({ staticProviderClientId: event.target.value }) - } - placeholder="Provider client ID" - aria-label="Provider client ID" - toggleLabel="Show provider client ID" - /> - - updateDraft({ staticProviderClientSecret: event.target.value }) - } - placeholder="Provider client secret" - aria-label="Provider client secret" - toggleLabel="Show provider client secret" - /> +
+
+ + + updateDraft({ staticProviderClientId: event.target.value }) + } + placeholder="Client ID" + toggleLabel="Show provider client ID" + /> +
+
+ + + updateDraft({ staticProviderClientSecret: event.target.value }) + } + placeholder="Client secret" + toggleLabel="Show provider client secret" + /> +
+
)} {selectedAuthMode === 'static_headers' && (

- Sent on every upstream request and shared by all assigned users. Encrypted - and not shown again after saving. + {organizationId + ? 'Sent on every upstream request and shared by all assigned users.' + : 'Sent on every upstream request.'}{' '} + Encrypted and not shown again after saving.

- updateDraft({ staticHeaderName: event.target.value })} - placeholder="Header name" - aria-label="Static header name" - /> - updateDraft({ staticHeaderValue: event.target.value })} - placeholder="Header value" - aria-label="Static header value" - toggleLabel="Show header value" - /> +
+
+ + + updateDraft({ staticHeaderName: event.target.value }) + } + placeholder="Authorization" + /> +
+
+ + + updateDraft({ staticHeaderValue: event.target.value }) + } + placeholder="Header value" + toggleLabel="Show header value" + /> +
+
)} {selectedAuthMode === 'none' && ( @@ -484,11 +524,11 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten
- + + {organizationId && ( + + )} + {!organizationId && }
)} diff --git a/apps/web/src/lib/mcp-gateway/config.ts b/apps/web/src/lib/mcp-gateway/config.ts index 4024f396b0..7173375532 100644 --- a/apps/web/src/lib/mcp-gateway/config.ts +++ b/apps/web/src/lib/mcp-gateway/config.ts @@ -1,4 +1,5 @@ import 'server-only'; +import { APP_URL } from '@/lib/constants'; import { getEnvVariable } from '@/lib/dotenvx'; import { z } from 'zod'; @@ -94,7 +95,7 @@ export function getGatewayAppConfig(): GatewayAppConfig { } return { - appBaseUrl: getEnvVariable('MCP_GATEWAY_APP_BASE_URL') || 'https://app.kilo.ai', + appBaseUrl: getEnvVariable('MCP_GATEWAY_APP_BASE_URL') || APP_URL, gatewayBaseUrl: getEnvVariable('MCP_GATEWAY_BASE_URL') || 'https://mcp.kilo.ai', issuer: jwtKeyset.issuer, accessTokenTtlSeconds: Number(getEnvVariable('MCP_GATEWAY_ACCESS_TOKEN_TTL_SECONDS') || '900'), diff --git a/apps/web/src/lib/mcp-gateway/discovery-service.ts b/apps/web/src/lib/mcp-gateway/discovery-service.ts index 38a7b92abd..5cc3c71910 100644 --- a/apps/web/src/lib/mcp-gateway/discovery-service.ts +++ b/apps/web/src/lib/mcp-gateway/discovery-service.ts @@ -11,6 +11,7 @@ import { } from '@kilocode/mcp-gateway'; const maxDiscoveryBodyBytes = 128 * 1024; +const dynamicProviderClientName = 'Kilo MCP Gateway'; export function validatePublicHttpsUrl(value: string): URL { let url: URL; @@ -190,6 +191,7 @@ export function createDiscoveryService(params: { fetchImpl?: typeof fetch }) { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ + client_name: dynamicProviderClientName, redirect_uris: [paramsInput.redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], diff --git a/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts b/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts index 9a88cf2384..7d5826c54d 100644 --- a/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts +++ b/apps/web/src/lib/mcp-gateway/oauth-flow.test.ts @@ -535,6 +535,66 @@ describe('MCP gateway app OAuth flow', () => { ).rejects.toMatchObject({ code: 'invalid_request' }); }); + it('identifies Kilo when dynamically registering with a provider', async () => { + const config = await createTestConfig(); + let registrationBody: unknown = null; + const fetchImpl: typeof fetch = async (input, init) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const discovery = providerDiscoveryResponse(url); + if (discovery) return discovery; + if (url === 'https://example.com/register') { + registrationBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ client_id: 'provider-client', client_secret: 'provider-secret' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }; + const services = createGatewayServices({ config, fetchImpl }); + const user = await insertTestUser({ id: `gateway-user-${crypto.randomUUID()}` }); + const created = await services.configService.createPersonalConfig({ + userId: user.id, + name: 'OAuth MCP', + remoteUrl: 'https://example.com/mcp', + authMode: 'oauth_dynamic', + }); + const registration = await services.clientService.registerClient({ + metadata: { + redirect_uris: ['http://localhost:3000/callback'], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'profile', + }, + headers: new Headers({ 'x-vercel-forwarded-for': '203.0.113.32' }), + }); + const verifier = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~abcdefghijk'; + const authorization = await services.authorizationService.authorize({ + query: OAuthAuthorizationQuerySchema.parse({ + client_id: registration.clientId, + redirect_uri: 'http://localhost:3000/callback', + response_type: 'code', + resource: created.route.canonical_url, + code_challenge: pkceChallenge(verifier), + code_challenge_method: 'S256', + }), + userId: user.id, + executionContext: { type: 'personal' }, + }); + + expect(authorization.kind).toBe('provider_redirect'); + expect(registrationBody).toMatchObject({ + client_name: 'Kilo MCP Gateway', + redirect_uris: ['https://app.kilo.ai/api/mcp-gateway/oauth/mcp/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + }); + }); + it('consumes provider state when the provider returns an error', async () => { const config = await createTestConfig(); const fetchImpl: typeof fetch = async input => { diff --git a/apps/web/src/lib/mcp-gateway/provider-oauth-service.ts b/apps/web/src/lib/mcp-gateway/provider-oauth-service.ts index c3866fc24c..ce5e69349d 100644 --- a/apps/web/src/lib/mcp-gateway/provider-oauth-service.ts +++ b/apps/web/src/lib/mcp-gateway/provider-oauth-service.ts @@ -33,6 +33,7 @@ import { createAuditService } from './audit-service'; const secretScheme = 'mcp-gateway-credential-rsa-aes-256-gcm'; const pendingStateScheme = 'mcp-gateway-provider-pending-state-rsa-aes-256-gcm'; +const dynamicProviderClientName = 'Kilo MCP Gateway'; const ProviderCredentialSchema = z.object({ clientId: z.string().min(1), @@ -163,6 +164,7 @@ export function createProviderOAuthService(params: { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ + client_name: dynamicProviderClientName, redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], From f39c9c4d6ee8919f2914579bd1553639ef6983f7 Mon Sep 17 00:00:00 2001 From: syn Date: Thu, 4 Jun 2026 13:04:19 -0500 Subject: [PATCH 05/18] fix(mcp-gateway): polish setup accessibility --- .../mcp-gateway/McpGatewayDetailContent.tsx | 133 +++++++++--------- .../mcp-gateway/McpGatewaySetupContent.tsx | 63 +++++++-- apps/web/src/components/ui/checkbox.tsx | 6 +- 3 files changed, 128 insertions(+), 74 deletions(-) diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx index c1dd4e7fc1..a595c1a0ce 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewayDetailContent.tsx @@ -290,68 +290,75 @@ export function McpGatewayDetailContent({ Access Assign each member who can use this connection. - -
- -
- {organizationId && ( - assignment.userId)} - /> - )} - + +
+
+ +
+ {organizationId && ( + assignment.userId)} + /> + )} + +
-
- {connection.assignments.length > 0 ? ( -
- {connection.assignments.map(assignment => { - const member = memberById.get(assignment.userId); - return ( -
-
-
- {member?.name || member?.email || 'Unknown member'} -
-
- {member?.name ? member.email : assignment.userId} + {connection.assignments.length > 0 ? ( +
+

+ Assigned members ({connection.assignments.length}) +

+
+ {connection.assignments.map(assignment => { + const member = memberById.get(assignment.userId); + return ( +
+
+
+ {member?.name || member?.email || 'Unknown member'} +
+
+ {member?.name ? member.email : assignment.userId} +
+
+
-
- -
- ); - })} -
- ) : ( -

No members assigned yet.

- )} + ); + })} +
+
+ ) : ( +

No members assigned yet.

+ )} +
)} @@ -366,9 +373,9 @@ export function McpGatewayDetailContent({ : 'Sign in so Kilo Code can call this server on your behalf.'} - + {signedIn ? ( -
+

@@ -384,7 +391,7 @@ export function McpGatewayDetailContent({

) : ( -
+

Not signed in yet

diff --git a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx index 4c048ba644..62b06d8785 100644 --- a/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx +++ b/apps/web/src/app/(app)/cloud/mcp-gateway/McpGatewaySetupContent.tsx @@ -80,7 +80,11 @@ function Stepper({ current }: { current: number }) { const isDone = current > step.id; const isCurrent = current === step.id; return ( -
  • +
  • + Step {step.id} of 2: {step.label} + + {isCurrent ? ', current step' : isDone ? ', completed' : ', not started'} +
    {index < STEPS.length - 1 && ( @@ -277,7 +285,7 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten
    Back to connections @@ -306,6 +314,7 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten type="url" inputMode="url" autoFocus + className="h-11 sm:h-9" value={draft.remoteUrl} onChange={event => { discoveryMutation.reset(); @@ -340,6 +349,7 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten updateDraft({ name: event.target.value })} placeholder="Production tools" @@ -357,9 +367,9 @@ export function McpGatewaySetupContent({ organizationId }: McpGatewaySetupConten

    )} -