Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/console-v5/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Route as AuthenticatedOrganizationOrganizationIdAlertsIndexRouteImport
import { Route as AuthenticatedOrganizationOrganizationIdClusterIdIndexRouteImport } from './routes/_authenticated/organization/$organizationId/$clusterId/index'
import { Route as AuthenticatedOrganizationOrganizationIdSettingsWebhookRouteImport } from './routes/_authenticated/organization/$organizationId/settings/webhook'
import { Route as AuthenticatedOrganizationOrganizationIdSettingsMembersRouteImport } from './routes/_authenticated/organization/$organizationId/settings/members'
import { Route as AuthenticatedOrganizationOrganizationIdSettingsMcpServerRouteImport } from './routes/_authenticated/organization/$organizationId/settings/mcp-server'
import { Route as AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRouteImport } from './routes/_authenticated/organization/$organizationId/settings/labels-annotations'
import { Route as AuthenticatedOrganizationOrganizationIdSettingsHelmRepositoriesRouteImport } from './routes/_authenticated/organization/$organizationId/settings/helm-repositories'
import { Route as AuthenticatedOrganizationOrganizationIdSettingsGitRepositoryAccessRouteImport } from './routes/_authenticated/organization/$organizationId/settings/git-repository-access'
Expand Down Expand Up @@ -299,6 +300,13 @@ const AuthenticatedOrganizationOrganizationIdSettingsMembersRoute =
getParentRoute: () =>
AuthenticatedOrganizationOrganizationIdSettingsRouteRoute,
} as any)
const AuthenticatedOrganizationOrganizationIdSettingsMcpServerRoute =
AuthenticatedOrganizationOrganizationIdSettingsMcpServerRouteImport.update({
id: '/mcp-server',
path: '/mcp-server',
getParentRoute: () =>
AuthenticatedOrganizationOrganizationIdSettingsRouteRoute,
} as any)
const AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRoute =
AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRouteImport.update(
{
Expand Down Expand Up @@ -1380,6 +1388,7 @@ export interface FileRoutesByFullPath {
'/organization/$organizationId/settings/git-repository-access': typeof AuthenticatedOrganizationOrganizationIdSettingsGitRepositoryAccessRoute
'/organization/$organizationId/settings/helm-repositories': typeof AuthenticatedOrganizationOrganizationIdSettingsHelmRepositoriesRoute
'/organization/$organizationId/settings/labels-annotations': typeof AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRoute
'/organization/$organizationId/settings/mcp-server': typeof AuthenticatedOrganizationOrganizationIdSettingsMcpServerRoute
'/organization/$organizationId/settings/members': typeof AuthenticatedOrganizationOrganizationIdSettingsMembersRoute
'/organization/$organizationId/settings/webhook': typeof AuthenticatedOrganizationOrganizationIdSettingsWebhookRoute
'/organization/$organizationId/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute
Expand Down Expand Up @@ -1523,6 +1532,7 @@ export interface FileRoutesByTo {
'/organization/$organizationId/settings/git-repository-access': typeof AuthenticatedOrganizationOrganizationIdSettingsGitRepositoryAccessRoute
'/organization/$organizationId/settings/helm-repositories': typeof AuthenticatedOrganizationOrganizationIdSettingsHelmRepositoriesRoute
'/organization/$organizationId/settings/labels-annotations': typeof AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRoute
'/organization/$organizationId/settings/mcp-server': typeof AuthenticatedOrganizationOrganizationIdSettingsMcpServerRoute
'/organization/$organizationId/settings/members': typeof AuthenticatedOrganizationOrganizationIdSettingsMembersRoute
'/organization/$organizationId/settings/webhook': typeof AuthenticatedOrganizationOrganizationIdSettingsWebhookRoute
'/organization/$organizationId/$clusterId': typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute
Expand Down Expand Up @@ -1661,6 +1671,7 @@ export interface FileRoutesById {
'/_authenticated/organization/$organizationId/settings/git-repository-access': typeof AuthenticatedOrganizationOrganizationIdSettingsGitRepositoryAccessRoute
'/_authenticated/organization/$organizationId/settings/helm-repositories': typeof AuthenticatedOrganizationOrganizationIdSettingsHelmRepositoriesRoute
'/_authenticated/organization/$organizationId/settings/labels-annotations': typeof AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRoute
'/_authenticated/organization/$organizationId/settings/mcp-server': typeof AuthenticatedOrganizationOrganizationIdSettingsMcpServerRoute
'/_authenticated/organization/$organizationId/settings/members': typeof AuthenticatedOrganizationOrganizationIdSettingsMembersRoute
'/_authenticated/organization/$organizationId/settings/webhook': typeof AuthenticatedOrganizationOrganizationIdSettingsWebhookRoute
'/_authenticated/organization/$organizationId/$clusterId/': typeof AuthenticatedOrganizationOrganizationIdClusterIdIndexRoute
Expand Down Expand Up @@ -1810,6 +1821,7 @@ export interface FileRouteTypes {
| '/organization/$organizationId/settings/git-repository-access'
| '/organization/$organizationId/settings/helm-repositories'
| '/organization/$organizationId/settings/labels-annotations'
| '/organization/$organizationId/settings/mcp-server'
| '/organization/$organizationId/settings/members'
| '/organization/$organizationId/settings/webhook'
| '/organization/$organizationId/$clusterId'
Expand Down Expand Up @@ -1953,6 +1965,7 @@ export interface FileRouteTypes {
| '/organization/$organizationId/settings/git-repository-access'
| '/organization/$organizationId/settings/helm-repositories'
| '/organization/$organizationId/settings/labels-annotations'
| '/organization/$organizationId/settings/mcp-server'
| '/organization/$organizationId/settings/members'
| '/organization/$organizationId/settings/webhook'
| '/organization/$organizationId/$clusterId'
Expand Down Expand Up @@ -2090,6 +2103,7 @@ export interface FileRouteTypes {
| '/_authenticated/organization/$organizationId/settings/git-repository-access'
| '/_authenticated/organization/$organizationId/settings/helm-repositories'
| '/_authenticated/organization/$organizationId/settings/labels-annotations'
| '/_authenticated/organization/$organizationId/settings/mcp-server'
| '/_authenticated/organization/$organizationId/settings/members'
| '/_authenticated/organization/$organizationId/settings/webhook'
| '/_authenticated/organization/$organizationId/$clusterId/'
Expand Down Expand Up @@ -2381,6 +2395,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsMembersRouteImport
parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRouteRoute
}
'/_authenticated/organization/$organizationId/settings/mcp-server': {
id: '/_authenticated/organization/$organizationId/settings/mcp-server'
path: '/mcp-server'
fullPath: '/organization/$organizationId/settings/mcp-server'
preLoaderRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsMcpServerRouteImport
parentRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsRouteRoute
}
'/_authenticated/organization/$organizationId/settings/labels-annotations': {
id: '/_authenticated/organization/$organizationId/settings/labels-annotations'
path: '/labels-annotations'
Expand Down Expand Up @@ -3274,6 +3295,7 @@ interface AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren {
AuthenticatedOrganizationOrganizationIdSettingsGitRepositoryAccessRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsGitRepositoryAccessRoute
AuthenticatedOrganizationOrganizationIdSettingsHelmRepositoriesRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsHelmRepositoriesRoute
AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRoute
AuthenticatedOrganizationOrganizationIdSettingsMcpServerRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsMcpServerRoute
AuthenticatedOrganizationOrganizationIdSettingsMembersRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsMembersRoute
AuthenticatedOrganizationOrganizationIdSettingsWebhookRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsWebhookRoute
AuthenticatedOrganizationOrganizationIdSettingsIndexRoute: typeof AuthenticatedOrganizationOrganizationIdSettingsIndexRoute
Expand Down Expand Up @@ -3305,6 +3327,8 @@ const AuthenticatedOrganizationOrganizationIdSettingsRouteRouteChildren: Authent
AuthenticatedOrganizationOrganizationIdSettingsHelmRepositoriesRoute,
AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRoute:
AuthenticatedOrganizationOrganizationIdSettingsLabelsAnnotationsRoute,
AuthenticatedOrganizationOrganizationIdSettingsMcpServerRoute:
AuthenticatedOrganizationOrganizationIdSettingsMcpServerRoute,
AuthenticatedOrganizationOrganizationIdSettingsMembersRoute:
AuthenticatedOrganizationOrganizationIdSettingsMembersRoute,
AuthenticatedOrganizationOrganizationIdSettingsWebhookRoute:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { SettingsMcpServer } from '@qovery/domains/organizations/feature'

export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/mcp-server')({
component: RouteComponent,
})

function RouteComponent() {
return <SettingsMcpServer />
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ function RouteComponent() {
icon: 'sparkles' as const,
}

const mcpServerLink = {
title: 'MCP server',
to: `${pathSettings}/mcp-server`,
icon: 'code' as const,
}

const dangerZoneLink = {
title: 'Danger zone',
to: `${pathSettings}/danger-zone`,
Expand All @@ -110,6 +116,7 @@ function RouteComponent() {
webhookLink,
apiTokenLink,
aiCopilotLink,
mcpServerLink,
...(isOrganizationAdmin ? [dangerZoneLink] : []),
]

Expand Down
1 change: 1 addition & 0 deletions libs/domains/organizations/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export * from './lib/settings-container-registries/settings-container-registries
export * from './lib/settings-billing-summary/settings-billing-summary'
export * from './lib/settings-webhook/settings-webhook'
export * from './lib/settings-api-token/settings-api-token'
export * from './lib/settings-mcp-server/settings-mcp-server'
export * from './lib/settings-danger-zone/settings-danger-zone'
export * from './lib/settings-billing-details/settings-billing-details'
export * from './lib/settings-roles/settings-roles'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { SettingsMcpServer } from './settings-mcp-server'

jest.mock('@tanstack/react-router', () => ({
...jest.requireActual('@tanstack/react-router'),
useParams: () => ({ organizationId: 'org-1' }),
}))

describe('SettingsMcpServer', () => {
it('should render heading and default Claude Code tab content', () => {
renderWithProviders(<SettingsMcpServer />)

expect(screen.getByRole('heading', { name: 'MCP server' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Configure via OAuth (recommended)' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Configure via API token' })).toBeInTheDocument()
expect(screen.getByText('Claude Code')).toBeInTheDocument()
expect(screen.getByText('Codex')).toBeInTheDocument()
expect(
screen.getByText('claude mcp add --transport http qovery https://mcp.qovery.com/mcp --callback-port 4242')
).toBeInTheDocument()
})

it('should switch to Codex instructions when clicking Codex tab', async () => {
const { userEvent } = renderWithProviders(<SettingsMcpServer />)

await userEvent.click(screen.getByText('Codex'))

expect(screen.getByText('1. Update your config.toml')).toBeInTheDocument()
expect(screen.getByText('mcp_oauth_callback_port = 4242')).toBeInTheDocument()
expect(screen.getByText("codex mcp add qovery --url 'https://mcp.qovery.com/mcp'")).toBeInTheDocument()
})

it('should render API token setup steps', () => {
renderWithProviders(<SettingsMcpServer />)

expect(screen.getByText('1. Generate token and copy it')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Generate API token' })).toBeInTheDocument()
expect(screen.getByText('2. Authenticate through your API token')).toBeInTheDocument()
expect(screen.getByText(/Authorization: Token your_qovery_token/)).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { useParams } from '@tanstack/react-router'
import { type ReactNode, useState } from 'react'
import { NeedHelp } from '@qovery/shared/assistant/feature'
import { Button, CopyButton, Heading, Icon, Navbar, Section, useModal } from '@qovery/shared/ui'
import { useDocumentTitle } from '@qovery/shared/util-hooks'
import CrudModalFeature from '../settings-api-token/crud-modal-feature/crud-modal-feature'

interface CommandBlockProps {
content: string
showPrompt?: boolean
}

function CommandBlock({ content, showPrompt = false }: CommandBlockProps) {
const isMultiline = content.includes('\n')

return (
<div
className={`flex gap-6 rounded border border-neutral bg-surface-neutral-subtle p-3 text-neutral retina:border-[0.5px] ${
isMultiline ? 'items-start' : 'items-center'
}`}
>
<div className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm text-neutral">
{showPrompt ? <span className="select-none">$ </span> : null}
{content}
</div>
<div className="shrink-0">
<CopyButton content={content} />
</div>
</div>
)
}

interface InstructionSectionProps {
number: number
title: string
description?: string
children: ReactNode
}

function InstructionSection({ number, title, description, children }: InstructionSectionProps) {
return (
<div className="flex flex-col gap-2">
<div className="space-y-1">
<p className="text-sm font-medium text-neutral">
{number}. {title}
</p>
{description ? <p className="text-sm text-neutral-subtle">{description}</p> : null}
</div>
{children}
</div>
)
}

export function SettingsMcpServer() {
const { organizationId = '' } = useParams({ strict: false })
const { openModal, closeModal } = useModal()
useDocumentTitle('MCP server - Organization settings')
const [activeClient, setActiveClient] = useState<'claude-code' | 'codex'>('claude-code')

return (
<div className="w-full">
<Section className="px-8 pb-8 pt-6">
<div className="mb-8 flex w-full justify-between gap-2 border-b border-neutral">
<div className="flex w-full items-start justify-between gap-4 pb-6">
<div className="flex flex-col gap-2">
<Heading>MCP server</Heading>
<p className="max-w-2xl text-sm text-neutral-subtle">
The Qovery MCP Server lets you interact with your Qovery infrastructure from any MCP-compatible client
(Claude, Claude Code, ChatGPT, etc.) using natural language.
</p>
<NeedHelp className="mt-2" />
</div>
</div>
</div>

<div className="max-w-content-with-navigation-left space-y-8">
<Section className="gap-4">
<div className="space-y-1">
<Heading level={2}>Configure via OAuth (recommended)</Heading>
</div>

<div className="flex flex-col gap-3">
<div className="overflow-hidden rounded-md border border-neutral bg-surface-neutral">
<div className="border-b border-neutral px-4">
<Navbar.Root
activeId={activeClient}
ariaLabel="MCP client selector"
className="relative top-[1px] -mt-[1px]"
>
<Navbar.Item
id="claude-code"
active={activeClient === 'claude-code'}
className="cursor-pointer"
onClick={() => setActiveClient('claude-code')}
>
<Icon iconName="claude" iconStyle="brands" />
<span>Claude Code</span>
</Navbar.Item>
<Navbar.Item
id="codex"
active={activeClient === 'codex'}
className="cursor-pointer"
onClick={() => setActiveClient('codex')}
>
<Icon iconName="openai" iconStyle="brands" />
<span>Codex</span>
</Navbar.Item>
</Navbar.Root>
</div>

<div className="p-4">
{activeClient === 'claude-code' ? (
<InstructionSection
number={1}
title="Run this command"
description="Authenticate through OAuth and add the Qovery MCP server to Claude Code."
>
<CommandBlock
content="claude mcp add --transport http qovery https://mcp.qovery.com/mcp --callback-port 4242"
showPrompt
/>
</InstructionSection>
) : (
<div className="flex flex-col gap-5">
<InstructionSection
number={1}
title="Update your config.toml"
description="Add this setting to your Codex configuration file to use the OAuth callback port."
>
<CommandBlock content="mcp_oauth_callback_port = 4242" />
</InstructionSection>
<InstructionSection
number={2}
title="Run this command"
description="Authenticate through OAuth and add the Qovery MCP server to Codex."
>
<CommandBlock content="codex mcp add qovery --url 'https://mcp.qovery.com/mcp'" showPrompt />
</InstructionSection>
</div>
)}
</div>
</div>
</div>
</Section>

<Section className="gap-4">
<div className="space-y-1">
<Heading level={2}>Configure via API token</Heading>
</div>

<div className="overflow-hidden rounded-md border border-neutral bg-surface-neutral p-4">
<div className="flex flex-col gap-5">
<InstructionSection
number={1}
title="Generate token and copy it"
description="Save this token securely. You won’t be able to see it again!"
>
<div>
<Button
color="brand"
size="md"
onClick={() =>
openModal({
content: <CrudModalFeature organizationId={organizationId} onClose={closeModal} />,
})
}
>
Generate API token
</Button>
</div>
</InstructionSection>

<InstructionSection
number={2}
title="Authenticate through your API token"
description="Pass your Qovery token via query parameter or Authorization header"
>
<CommandBlock
content={`# Query parameter
https://mcp.qovery.com/mcp?token=your_qovery_token

# Authorization header
Authorization: Token your_qovery_token`}
/>
</InstructionSection>
</div>
</div>
</Section>
</div>
</Section>
</div>
)
}
Loading