Skip to content
Closed
91 changes: 88 additions & 3 deletions apps/mesh/src/shared/utils/group-connections.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,92 @@
import type { ConnectionEntity } from "@decocms/mesh-sdk";
import { getConnectionSlug } from "./connection-slug";
import { slugify } from "./slugify";

/**
* Strip auto-generated instance suffixes like "(2)" or "(a1b2)" from a title.
*/
const INSTANCE_SUFFIX_RE = /\s*\([^)]{1,6}\)\s*$/;

/**
* Convert an app_name slug to a display title as a last resort.
* "google-gmail" → "Google Gmail", "@scope/tool" → "Tool"
*/
function slugToTitle(appName: string): string {
const slug = appName.replace(/^@[^/]+\//, "");
return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

/**
* Check whether a stripped title looks like the original (not user-renamed)
* by comparing its slug against app_name. Allows partial matches at word
* boundaries so that "Vercel" matches "vercel-mcp" and "Vercel MCP Server"
* matches "vercel-mcp".
*/
function isOriginalTitle(titleSlug: string, appName: string): boolean {
return (
titleSlug === appName ||
appName.startsWith(titleSlug + "-") ||
titleSlug.startsWith(appName + "-")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
);
}

/**
* Returns the canonical display title for a connection in catalog/card/header contexts.
*
* Strategy:
* 1. Strip auto-generated instance suffixes from the title ("Vercel MCP (2)" → "Vercel MCP")
* 2. If the stripped title still matches the app_name slug (exact or word-boundary prefix),
* use it — this preserves the original casing from the registry (e.g., "Vercel MCP")
* 3. If it doesn't match (user renamed the instance), fall back to slug → title conversion
*
* Use the raw connection.title only when showing the specific instance matters
* (e.g., the instance list inside a connection detail, or the binding selector).
*/
export function getConnectionDisplayTitle(
connection: ConnectionEntity,
): string {
const stripped = connection.title.replace(INSTANCE_SUFFIX_RE, "");
if (!connection.app_name) return stripped;

@cubic-dev-ai cubic-dev-ai Bot Apr 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom connections now lose user-provided title suffixes because the function strips trailing (…) even when app_name is missing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/shared/utils/group-connections.ts, line 49:

<comment>Custom connections now lose user-provided title suffixes because the function strips trailing `(…)` even when `app_name` is missing.</comment>

<file context>
@@ -1,25 +1,91 @@
-    const slug = connection.app_name.replace(/^@[^/]+\//, "");
-    return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+  const stripped = connection.title.replace(INSTANCE_SUFFIX_RE, "");
+  if (!connection.app_name) return stripped;
+
+  if (isOriginalTitle(slugify(stripped), connection.app_name)) {
</file context>
Suggested change
if (!connection.app_name) return stripped;
if (!connection.app_name) return connection.title;
Fix with Cubic


if (isOriginalTitle(slugify(stripped), connection.app_name)) {
return stripped;
}

// Title was renamed — fall back to slug conversion
return slugToTitle(connection.app_name);
}

/**
* For a group of connections sharing the same app, pick the best canonical title.
* Prefers the original (non-renamed) title from any instance to preserve correct
* casing. Falls back to the shortest stripped title.
*/
export function getGroupDisplayTitle(connections: ConnectionEntity[]): string {
const appName = connections[0]!.app_name;

// First pass: look for an instance whose title still matches the app_name
// (i.e. hasn't been renamed). This preserves original casing like "Vercel MCP".
if (appName) {
for (const c of connections) {
const stripped = c.title.replace(INSTANCE_SUFFIX_RE, "");
if (isOriginalTitle(slugify(stripped), appName)) {
return stripped;
}
}
// All instances were renamed — fall back to slug conversion
return slugToTitle(appName);
}

// No app_name — pick the shortest stripped title
let best = getConnectionDisplayTitle(connections[0]!);
for (let i = 1; i < connections.length; i++) {
const candidate = getConnectionDisplayTitle(connections[i]!);
if (candidate.length < best.length) {
best = candidate;
}
}
return best;
}

export interface ConnectionGroup {
type: "group";
Expand Down Expand Up @@ -47,9 +134,7 @@ export function groupConnections(
type: "group",
key,
icon: first.icon,
title: first.app_name
? first.title.replace(/\s*\(\d+\)\s*$/, "")
: first.title,
title: getGroupDisplayTitle(bucket),
connections: bucket,
});
}
Expand Down
35 changes: 28 additions & 7 deletions apps/mesh/src/web/components/chat/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { cn } from "@deco/ui/lib/utils.ts";
import {
getWellKnownDecopilotVirtualMCP,
isDecopilot,
useConnections,
useProjectContext,
} from "@decocms/mesh-sdk";

import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent";
import {
ArrowUp,
Expand Down Expand Up @@ -56,6 +58,26 @@ import { question004Sound } from "@deco/ui/lib/question-004.ts";
import { AddConnectionDialog } from "@/web/views/virtual-mcp/add-connection-dialog";
import { ConnectionsBanner } from "./connections-banner";

function HomeConnectionsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const existingConnections = useConnections();
const existingConnectionIds = new Set(existingConnections.map((c) => c.id));
return (
<AddConnectionDialog
open={open}
onOpenChange={onOpenChange}
addedConnectionIds={existingConnectionIds}
onAdd={() => onOpenChange(false)}
defaultTab="all"
/>
);
}

// ============================================================================
// VirtualMCPBadge - Internal component for displaying selected virtual MCP
// ============================================================================
Expand Down Expand Up @@ -648,13 +670,12 @@ export function ChatInput({
</div>
</div>

<AddConnectionDialog
open={connectionsOpen}
onOpenChange={setConnectionsOpen}
addedConnectionIds={new Set()}
onAdd={() => {}}
defaultTab="all"
/>
{showConnectionsBanner && (
<HomeConnectionsDialog
open={connectionsOpen}
onOpenChange={setConnectionsOpen}
/>
)}
</>
);
}
29 changes: 16 additions & 13 deletions apps/mesh/src/web/components/details/connection/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { generatePrefixedId } from "@/shared/utils/generate-id";
import {
getConnectionDisplayTitle,
getGroupDisplayTitle,
} from "@/shared/utils/group-connections";
import { EmptyState } from "@/web/components/empty-state.tsx";
import { ErrorBoundary } from "@/web/components/error-boundary";
import { recordToEnvVars } from "@/web/components/env-vars-editor";
Expand Down Expand Up @@ -424,12 +428,9 @@ function ConnectionInspectorViewWithConnection({
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
{(() => {
const first = siblings[0] ?? connection;
return first.app_name
? first.title.replace(/\s*\(\d+\)\s*$/, "")
: first.title;
})()}
{siblings.length > 1
? getGroupDisplayTitle(siblings)
: getConnectionDisplayTitle(siblings[0] ?? connection)}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
Expand Down Expand Up @@ -562,12 +563,11 @@ function ConnectionInspectorViewWithConnection({
<div className="flex flex-col h-full overflow-hidden">
<ConnectionDetailHeader
connection={connection}
displayTitle={(() => {
const first = siblings[0] ?? connection;
return first.app_name
? first.title.replace(/\s*\(\d+\)\s*$/, "")
: first.title;
})()}
displayTitle={
siblings.length > 1
? getGroupDisplayTitle(siblings)
: getConnectionDisplayTitle(siblings[0] ?? connection)
}
/>
<div className="flex-1 overflow-auto @container">
<div className="grid grid-cols-1 @3xl:grid-cols-2 gap-5 p-6">
Expand All @@ -585,7 +585,10 @@ function ConnectionInspectorViewWithConnection({
setIsAddingInstance(true);
try {
const base = siblings[0] ?? connection;
const baseName = base.title.replace(/\s*\(\d+\)\s*$/, "");
const baseName =
siblings.length > 1
? getGroupDisplayTitle(siblings)
: getConnectionDisplayTitle(base);
const nextNumber = siblings.length + 1;
const newTitle = `${baseName} (${nextNumber})`;
const newId = generatePrefixedId("conn");
Expand Down
9 changes: 8 additions & 1 deletion apps/mesh/src/web/routes/orgs/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import {

import {
groupConnections,
getConnectionDisplayTitle,
type ConnectionGroup,
} from "@/shared/utils/group-connections";

Expand Down Expand Up @@ -552,6 +553,9 @@ function CatalogItemCard({
const icon =
item.server?.icons?.[0]?.src ||
getGitHubAvatarUrl(item.server?.repository) ||
item.icon ||
item.image ||
item.logo ||
null;
const appInstances = allConnections.filter(
(c) => c.connection_type !== "VIRTUAL" && c.app_name === appName,
Expand Down Expand Up @@ -1054,7 +1058,10 @@ function ConnectionResults({
return (
<ConnectionCard
key={connection.id}
connection={connection}
connection={{
...connection,
title: getConnectionDisplayTitle(connection),
}}
fallbackIcon={<Container />}
onClick={() =>
selectionMode
Expand Down
12 changes: 9 additions & 3 deletions apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { groupConnections } from "@/shared/utils/group-connections";
import {
groupConnections,
getConnectionDisplayTitle,
} from "@/shared/utils/group-connections";
import { CollectionSearch } from "@/web/components/collections/collection-search.tsx";
import { CollectionTabs } from "@/web/components/collections/collection-tabs.tsx";
import { CreateConnectionDialog } from "@/web/components/connections/create-connection-dialog.tsx";
Expand Down Expand Up @@ -296,6 +299,9 @@ function AddConnectionDialogContent({
const icon =
item.server?.icons?.[0]?.src ||
getGitHubAvatarUrl(item.server?.repository) ||
item.icon ||
item.image ||
item.logo ||
null;

return (
Expand Down Expand Up @@ -373,7 +379,7 @@ function AddConnectionDialogContent({
const c = item.connection;
return renderConnectedApp(
c.id,
c.title,
getConnectionDisplayTitle(c),
c.icon,
c.description ?? null,
[c],
Expand Down Expand Up @@ -471,7 +477,7 @@ export function AddConnectionDialog({
const handleCloneAndAdd = async (base: ConnectionEntity) => {
setConnectingItemId(base.app_name ?? base.id);
try {
const baseName = base.title.replace(/\s*\(\d+\)\s*$/, "");
const baseName = getConnectionDisplayTitle(base);
const newTitle = `${baseName} (${Date.now().toString(36).slice(-4)})`;

const created = await connectionActions.create.mutateAsync({
Expand Down
3 changes: 2 additions & 1 deletion apps/mesh/src/web/views/virtual-mcp/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generatePrefixedId } from "@/shared/utils/generate-id";
import { getConnectionDisplayTitle } from "@/shared/utils/group-connections";
import type { VirtualMCPEntity } from "@/tools/virtual/schema";
import { getUIResourceUri } from "@/mcp-apps/types.ts";
import { useChatTask } from "@/web/components/chat/context";
Expand Down Expand Up @@ -1185,7 +1186,7 @@ function VirtualMcpDetailViewWithData({
};
if (!base) return;

const baseName = base.title.replace(/\s*\(.*?\)\s*$/, "");
const baseName = getConnectionDisplayTitle(base);
const newId = generatePrefixedId("conn");
// Temporary title — will be updated with email suffix after OAuth if available
const tempTitle = `${baseName} (${Date.now().toString(36).slice(-4)})`;
Expand Down
Loading