Skip to content

Commit 69af800

Browse files
committed
feat: add MCP Catalog page and related components
1 parent 221bcf1 commit 69af800

16 files changed

Lines changed: 211 additions & 146 deletions

File tree

dist-types/plugins/dev-ai-hub/src/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ export { devAiHubPlugin } from './plugin';
22
export { DevAiHubPage } from './pluginLegacy';
33
export { devAiHubApiRef } from './api/DevAiHubClient';
44
export type { DevAiHubApi } from './api/DevAiHubClient';
5-
export { rootRouteRef, mcpConfigRouteRef } from './routes';
5+
export { rootRouteRef, mcpConfigRouteRef, mcpCatalogRouteRef } from './routes';

dist-types/plugins/dev-ai-hub/src/plugin.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export declare const devAiHubPlugin: import("@backstage/frontend-plugin-api").OverridableFrontendPlugin<{
22
root: import("@backstage/frontend-plugin-api").RouteRef<undefined>;
33
mcpConfig: import("@backstage/frontend-plugin-api").RouteRef<undefined>;
4+
mcpCatalog: import("@backstage/frontend-plugin-api").RouteRef<undefined>;
45
}, {}, {
56
"api:dev-ai-hub": import("@backstage/frontend-plugin-api").OverridableExtensionDefinition<{
67
kind: "api";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export declare const rootRouteRef: import("@backstage/frontend-plugin-api").RouteRef<undefined>;
22
export declare const mcpConfigRouteRef: import("@backstage/frontend-plugin-api").RouteRef<undefined>;
3+
export declare const mcpCatalogRouteRef: import("@backstage/frontend-plugin-api").RouteRef<undefined>;

plugins/dev-ai-hub/dev/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createDevApp } from '@backstage/dev-utils';
22
import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api';
33
import { DevAiHubPage } from '../src/components/DevAiHubPage';
44
import { McpConfigPage } from '../src/components/McpConfigPage';
5+
import { McpCatalogPage } from '../src/components/McpCatalogPage';
56
import { devAiHubApiRef, DevAiHubClient } from '../src/api/DevAiHubClient';
67

78
// Bypass Backstage guest auth without a running auth backend.
@@ -29,4 +30,9 @@ createDevApp()
2930
title: 'MCP Config',
3031
path: '/mcp-config',
3132
})
33+
.addPage({
34+
element: <McpCatalogPage />,
35+
title: 'MCP Catalog',
36+
path: '/mcp-catalog',
37+
})
3238
.render();

plugins/dev-ai-hub/src/components/DevAiHubPage/DevAiHubPage.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useMemo, type ElementType } from 'react';
22
import { useSearchParams } from 'react-router-dom';
33
import { Box, Flex, Text, Skeleton, TablePagination } from '@backstage/ui';
4-
import { RiArticleLine, RiRobot2Line, RiToolsLine, RiGitBranchLine, RiStackLine } from '@remixicon/react';
4+
import { RiArticleLine, RiRobot2Line, RiToolsLine, RiGitBranchLine } from '@remixicon/react';
55
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
66
import type { AssetType, AiTool } from '@julianpedro/plugin-dev-ai-hub-common';
77
import { AssetCard } from '../AssetCard';
@@ -33,8 +33,6 @@ const STATS_CONFIG: { key: AssetType; label: string; Icon: ElementType; gradient
3333
gradient: 'linear-gradient(135deg, #6AB04C 0%, #4A8F2E 100%)', shadow: '#6AB04C40' },
3434
{ key: 'workflow', label: 'Workflows', Icon: RiGitBranchLine,
3535
gradient: 'linear-gradient(135deg, #F9CA24 0%, #D4A800 100%)', shadow: '#F9CA2440' },
36-
{ key: 'bundle', label: 'Bundles', Icon: RiStackLine,
37-
gradient: 'linear-gradient(135deg, #A55EEA 0%, #8438D6 100%)', shadow: '#A55EEA40' },
3836
];
3937

4038
export function DevAiHubPage() {
@@ -154,7 +152,12 @@ export function DevAiHubPage() {
154152
{/* Results summary */}
155153
{result && !loading && (
156154
<Text variant="body-x-small" color="secondary" style={{ marginBottom: 'var(--bui-space-4)', display: 'block' }}>
157-
{t('devAiHubPage.resultsFound', { n: String(result.totalCount) })}
155+
{t(
156+
result.totalCount === 1
157+
? 'devAiHubPage.resultsFoundOne'
158+
: 'devAiHubPage.resultsFoundOther',
159+
{ n: String(result.totalCount) },
160+
)}
158161
</Text>
159162
)}
160163

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
@layer components {
2+
.pageRoot {
3+
padding: var(--bui-space-6);
4+
}
5+
6+
.subtitle {
7+
margin-top: var(--bui-space-1);
8+
display: block;
9+
margin-bottom: var(--bui-space-6);
10+
}
11+
12+
.catalogGrid {
13+
display: grid;
14+
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
15+
gap: var(--bui-space-3);
16+
max-width: 960px;
17+
}
18+
19+
.catalogEmpty {
20+
border: 1px dashed var(--bui-border-1);
21+
border-radius: var(--bui-radius-3);
22+
padding: var(--bui-space-6);
23+
text-align: center;
24+
gap: var(--bui-space-1);
25+
max-width: 720px;
26+
}
27+
28+
.catalogCard {
29+
border: 1px solid var(--bui-border-1);
30+
border-radius: var(--bui-radius-3);
31+
padding: var(--bui-space-3);
32+
display: flex;
33+
flex-direction: column;
34+
gap: var(--bui-space-2);
35+
min-height: 140px;
36+
}
37+
38+
.catalogIcon {
39+
width: 40px;
40+
height: 40px;
41+
border-radius: var(--bui-radius-2);
42+
background-color: var(--bui-bg-neutral-1-hover);
43+
display: flex;
44+
align-items: center;
45+
justify-content: center;
46+
flex-shrink: 0;
47+
}
48+
49+
.catalogIconImage {
50+
width: 26px;
51+
height: 26px;
52+
object-fit: contain;
53+
}
54+
55+
.catalogDescription {
56+
display: block;
57+
overflow: hidden;
58+
display: -webkit-box;
59+
-webkit-line-clamp: 2;
60+
-webkit-box-orient: vertical;
61+
}
62+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Box, Flex, Text, Button } from '@backstage/ui';
2+
import { RiServerLine } from '@remixicon/react';
3+
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
4+
import { useMcpCatalog } from '../../hooks';
5+
import type { McpCatalogEntry } from '@julianpedro/plugin-dev-ai-hub-common';
6+
import { devAiHubTranslationRef } from '../../translation';
7+
import styles from './McpCatalogPage.module.css';
8+
9+
function CatalogEntryCard({ entry }: { entry: McpCatalogEntry }) {
10+
const { t } = useTranslationRef(devAiHubTranslationRef);
11+
const handleInstallVscode = () => {
12+
const config: Record<string, unknown> = { name: entry.id, type: entry.type };
13+
if (entry.type === 'http') config.url = entry.url;
14+
if (entry.type === 'stdio') {
15+
config.command = entry.command;
16+
if (entry.args?.length) config.args = entry.args;
17+
if (entry.env && Object.keys(entry.env).length) config.env = entry.env;
18+
}
19+
window.location.href = `vscode:mcp/install?${encodeURIComponent(JSON.stringify(config))}`;
20+
};
21+
22+
const handleInstallCursor = () => {
23+
const config: Record<string, unknown> = { type: entry.type };
24+
if (entry.type === 'http') config.url = entry.url;
25+
if (entry.type === 'stdio') {
26+
config.command = entry.command;
27+
if (entry.args?.length) config.args = entry.args;
28+
if (entry.env && Object.keys(entry.env).length) config.env = entry.env;
29+
}
30+
window.location.href = `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(entry.name)}&config=${btoa(JSON.stringify(config))}`;
31+
};
32+
33+
const canInstall =
34+
(entry.type === 'http' && !!entry.url) ||
35+
(entry.type === 'stdio' && !!entry.command);
36+
37+
return (
38+
<Box className={styles.catalogCard}>
39+
<Flex align="center" style={{ gap: 'var(--bui-space-2)' }}>
40+
<Box className={styles.catalogIcon}>
41+
{entry.icon ? (
42+
<img
43+
src={entry.icon}
44+
alt={entry.name}
45+
className={styles.catalogIconImage}
46+
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
47+
/>
48+
) : (
49+
<RiServerLine size={20} style={{ color: 'var(--bui-fg-secondary)' }} />
50+
)}
51+
</Box>
52+
<Box style={{ minWidth: 0, flex: 1 }}>
53+
<Text variant="body-small" weight="bold">{entry.name}</Text>
54+
<Text variant="body-x-small" color="secondary">{entry.type.toUpperCase()}</Text>
55+
</Box>
56+
</Flex>
57+
{entry.description && (
58+
<Text variant="body-x-small" color="secondary" className={styles.catalogDescription}>
59+
{entry.description}
60+
</Text>
61+
)}
62+
<Flex style={{ gap: 'var(--bui-space-2)', marginTop: 'auto' }}>
63+
<Button size="small" variant="secondary" isDisabled={!canInstall} onPress={handleInstallVscode}>
64+
{t('mcpConfigDialog.catalogVscode')}
65+
</Button>
66+
<Button size="small" variant="secondary" isDisabled={!canInstall} onPress={handleInstallCursor}>
67+
{t('mcpConfigDialog.catalogCursor')}
68+
</Button>
69+
</Flex>
70+
</Box>
71+
);
72+
}
73+
74+
export function McpCatalogPage() {
75+
const { t } = useTranslationRef(devAiHubTranslationRef);
76+
const { catalog } = useMcpCatalog();
77+
78+
return (
79+
<div className={styles.pageRoot}>
80+
<Flex align="center" style={{ gap: 'var(--bui-space-2)' }}>
81+
<RiServerLine size={20} style={{ color: 'var(--bui-fg-secondary)' }} />
82+
<Text variant="title-small" weight="bold">{t('mcpConfigDialog.catalogTab')}</Text>
83+
{catalog.length > 0 && (
84+
<Text variant="body-x-small" color="secondary">
85+
{t('mcpConfigDialog.catalogAvailable', { n: String(catalog.length) })}
86+
</Text>
87+
)}
88+
</Flex>
89+
<Text variant="body-small" color="secondary" className={styles.subtitle}>
90+
{t('mcpConfigDialog.catalogDescription')}
91+
</Text>
92+
93+
{catalog.length === 0 ? (
94+
<Flex direction="column" align="center" className={styles.catalogEmpty}>
95+
<RiServerLine size={32} style={{ color: 'var(--bui-fg-secondary)' }} />
96+
<Text variant="body-small" weight="bold">{t('mcpConfigDialog.catalogEmpty')}</Text>
97+
<Text variant="body-x-small" color="secondary">{t('mcpConfigDialog.catalogAddHint')}</Text>
98+
</Flex>
99+
) : (
100+
<div className={styles.catalogGrid}>
101+
{catalog.map(entry => (
102+
<CatalogEntryCard key={entry.id} entry={entry} />
103+
))}
104+
</div>
105+
)}
106+
</div>
107+
);
108+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { McpCatalogPage } from './McpCatalogPage';

plugins/dev-ai-hub/src/components/McpConfigPage/McpConfigPage.module.css

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,52 +13,6 @@
1313
max-width: 720px;
1414
}
1515

16-
.catalogSection {
17-
margin-top: var(--bui-space-6);
18-
max-width: 720px;
19-
}
20-
21-
.catalogGrid {
22-
display: grid;
23-
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
24-
gap: var(--bui-space-3);
25-
}
26-
27-
.catalogCard {
28-
border: 1px solid var(--bui-border-1);
29-
border-radius: var(--bui-radius-3);
30-
padding: var(--bui-space-3);
31-
display: flex;
32-
flex-direction: column;
33-
gap: var(--bui-space-2);
34-
min-height: 140px;
35-
}
36-
37-
.catalogIcon {
38-
width: 40px;
39-
height: 40px;
40-
border-radius: var(--bui-radius-2);
41-
background-color: var(--bui-bg-neutral-1-hover);
42-
display: flex;
43-
align-items: center;
44-
justify-content: center;
45-
flex-shrink: 0;
46-
}
47-
48-
.catalogIconImage {
49-
width: 26px;
50-
height: 26px;
51-
object-fit: contain;
52-
}
53-
54-
.catalogDescription {
55-
display: block;
56-
overflow: hidden;
57-
display: -webkit-box;
58-
-webkit-line-clamp: 2;
59-
-webkit-box-orient: vertical;
60-
}
61-
6216
.tabBar {
6317
margin-bottom: var(--bui-space-4);
6418
border-bottom: 1px solid var(--bui-border-1);

0 commit comments

Comments
 (0)