diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx
index 1805d24..17848a9 100644
--- a/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx
+++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx
@@ -1,32 +1,20 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
+import { Button } from '@tpmjs/ui/Button/Button';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
+import { useCallback, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
-import { InstallationSection } from '~/components/collections/InstallationSection';
+import { ForkButton } from '~/components/ForkButton';
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
import { LikeButton } from '~/components/LikeButton';
import { ScenariosSection } from '~/components/ScenariosSection';
import { ShareButton } from '~/components/ShareButton';
import { SkillsSection } from '~/components/skills/SkillsSection';
-
-export interface CollectionTool {
- id: string;
- toolId: string;
- position: number;
- note: string | null;
- tool: {
- id: string;
- name: string;
- description: string;
- likeCount: number;
- package: {
- npmPackageName: string;
- category: string;
- };
- };
-}
+import { UseCasesSection } from '~/components/UseCasesSection';
+import { useSession } from '~/lib/auth-client';
/**
* Locked state for private collections viewed by non-owners
@@ -46,13 +34,46 @@ export function PrivateCollectionLocked({ name }: { name: string }) {
{name}
Private
- This collection is private.
+
+ This collection is private and can only be viewed by its owner.
+
);
}
+export interface CollectionTool {
+ id: string;
+ toolId: string;
+ position: number;
+ note: string | null;
+ tool: {
+ id: string;
+ name: string;
+ description: string;
+ likeCount: number;
+ package: {
+ npmPackageName: string;
+ category: string;
+ };
+ };
+}
+
+export interface UseCaseToolStep {
+ toolName: string;
+ packageName: string;
+ purpose: string;
+ order: number;
+}
+
+export interface UseCase {
+ id: string;
+ userPrompt: string;
+ description: string;
+ toolSequence: UseCaseToolStep[];
+}
+
export interface PublicCollection {
id: string;
slug: string; // Already coerced to empty string if null in server component
@@ -78,6 +99,184 @@ export interface PublicCollection {
username: string;
};
} | null;
+ useCases?: UseCase[] | null;
+ useCasesGeneratedAt?: string | null;
+}
+
+function McpUrlSection({
+ username,
+ slug,
+ isOwner,
+}: {
+ username: string;
+ slug: string;
+ isOwner: boolean;
+}) {
+ const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
+ const [showConfig, setShowConfig] = useState(false);
+ const [showApiExample, setShowApiExample] = useState(false);
+
+ const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
+ const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`;
+ const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`;
+
+ const copyToClipboard = async (url: string, type: 'http' | 'sse') => {
+ await navigator.clipboard.writeText(url);
+ setCopiedUrl(type);
+ setTimeout(() => setCopiedUrl(null), 2000);
+ };
+
+ const configSnippet = `{
+ "mcpServers": {
+ "tpmjs-${slug}": {
+ "command": "npx",
+ "args": [
+ "mcp-remote",
+ "${httpUrl}"
+ ]
+ }
+ }
+}`;
+
+ const apiExampleSnippet = `// Call a tool with your own credentials
+const response = await fetch("${httpUrl}", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer YOUR_TPMJS_API_KEY"
+ },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ method: "tools/call",
+ params: {
+ name: "tool-name",
+ arguments: { /* tool args */ },
+ env: {
+ // Your env vars for the tools
+ "API_KEY": "your-key-here"
+ }
+ },
+ id: 1
+ })
+});`;
+
+ return (
+
+
+
+
+
+
MCP Server URLs
+
+
+
+ {/* HTTP Transport */}
+
+
+
+ HTTP Transport
+
+ (recommended)
+
+
+
+ {httpUrl}
+
+
+
+
+
+ {/* SSE Transport */}
+
+
+
+ SSE Transport
+
+ (streaming)
+
+
+
+ {sseUrl}
+
+
+
+
+
+
+ {/* Note for non-owners */}
+ {!isOwner && (
+
+
+
+ You'll need to provide your own API keys for any tools that require them. Pass
+ credentials via the{' '}
+ env parameter in your
+ API calls.
+
+
+ )}
+
+ {/* Config snippet toggle */}
+
+
+
+ {showConfig && (
+
+
+
+ )}
+
+ {!isOwner && (
+ <>
+
+
+ {showApiExample && (
+
+
+
+ )}
+ >
+ )}
+
+
+
+ Use these URLs with{' '}
+
+ Claude Desktop, Cursor, or any MCP client
+
+
+
+ );
}
interface CollectionDetailClientProps {
@@ -85,7 +284,28 @@ interface CollectionDetailClientProps {
username: string;
}
-export function CollectionDetailClient({ collection, username }: CollectionDetailClientProps) {
+export function CollectionDetailClient({
+ collection: initialCollection,
+ username,
+}: CollectionDetailClientProps) {
+ const { data: session } = useSession();
+ const [collection, setCollection] = useState(initialCollection);
+
+ // Check if current user is the owner
+ const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id;
+
+ // Handler for when use cases are generated
+ const handleUseCasesGenerated = useCallback(
+ (useCases: UseCase[], generatedAt: string) => {
+ setCollection({
+ ...collection,
+ useCases,
+ useCasesGeneratedAt: generatedAt,
+ });
+ },
+ [collection]
+ );
+
// Generate tweet text
const tweetText = collection.description
? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}`
@@ -129,6 +349,7 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
entityId={collection.id}
initialCount={collection.likeCount}
/>
+
@@ -150,19 +371,8 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
)}
- {/* Installation Section */}
-
+ {/* MCP Server URLs - Available to everyone (non-owners must provide their own credentials) */}
+
{/* Tools */}
{collection.tools.length > 0 ? (
@@ -211,6 +421,16 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
)}
+ {/* Use Cases Section */}
+ {collection.tools.length > 0 && (
+
+ )}
+
{/* Scenarios Section */}
{collection.tools.length > 0 && (
;
+interface CollectionTool {
+ id: string;
+ toolId: string;
+ position: number;
+ note: string | null;
+ addedAt: string;
+ tool: {
+ id: string;
+ name: string;
+ description: string;
+ likeCount: number;
+ package: {
+ id: string;
+ npmPackageName: string;
+ category: string;
+ };
+ };
}
-/**
- * DEPRECATED: This route is deprecated in favor of /@username/collections/[slug]
- * All requests are 301 redirected to the new canonical URL.
- */
-export default async function CollectionRedirectPage({ params }: CollectionRedirectPageProps) {
- const { id } = await params;
-
- // Look up the collection by ID
- const collection = await prisma.collection.findUnique({
- where: { id },
- select: {
- slug: true,
- isPublic: true,
- user: {
- select: { username: true },
- },
- },
- });
-
- // If collection doesn't exist, return 404
- if (!collection) {
- notFound();
- }
-
- // If collection is private, return 404 (don't reveal existence)
- if (!collection.isPublic) {
- notFound();
- }
-
- // If user has no username or collection has no slug, can't redirect to pretty URL
- if (!collection.user.username || !collection.slug) {
- notFound();
- }
-
- // 301 permanent redirect to the canonical URL
- redirect(`/@${collection.user.username}/collections/${collection.slug}`);
+interface PublicCollection {
+ id: string;
+ slug: string | null;
+ name: string;
+ description: string | null;
+ likeCount: number;
+ toolCount: number;
+ createdAt: string;
+ updatedAt: string;
+ createdBy: {
+ id: string;
+ username: string | null;
+ name: string;
+ image: string | null;
+ };
+ tools: CollectionTool[];
+}
+
+function McpUrlSection({ username, slug }: { username: string; slug: string }) {
+ const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
+ const [showConfig, setShowConfig] = useState(false);
+
+ const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
+ const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`;
+ const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`;
+
+ const copyToClipboard = async (url: string, type: 'http' | 'sse') => {
+ await navigator.clipboard.writeText(url);
+ setCopiedUrl(type);
+ setTimeout(() => setCopiedUrl(null), 2000);
+ };
+
+ const configSnippet = `{
+ "mcpServers": {
+ "tpmjs-collection": {
+ "command": "npx",
+ "args": [
+ "mcp-remote",
+ "${httpUrl}",
+ "--header",
+ "Authorization: Bearer YOUR_TPMJS_API_KEY"
+ ]
+ }
+ }
+}`;
+
+ return (
+
+
+
+
+
+
MCP Server URLs
+
+
+
+ {/* HTTP Transport */}
+
+
+
+ HTTP Transport
+
+ (recommended)
+
+
+
+ {httpUrl}
+
+
+
+
+
+ {/* SSE Transport */}
+
+
+
+ SSE Transport
+
+ (streaming)
+
+
+
+ {sseUrl}
+
+
+
+
+
+
+ {/* Config snippet toggle */}
+
+
+
+ {showConfig && (
+
+
+ {configSnippet}
+
+
+
+ )}
+
+
+
+ Use these URLs with{' '}
+
+ Claude Desktop, Cursor, or any MCP client
+
+
+
+ );
+}
+
+export default function PublicCollectionDetailPage(): React.ReactElement {
+ const params = useParams();
+ const router = useRouter();
+ const collectionId = params.id as string;
+
+ const [collection, setCollection] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchCollection = useCallback(async () => {
+ try {
+ const response = await fetch(`/api/public/collections/${collectionId}`);
+ const data = await response.json();
+
+ if (data.success) {
+ // Redirect to pretty URL if username and slug are available
+ if (data.data.createdBy?.username && data.data.slug) {
+ router.replace(`/${data.data.createdBy.username}/collections/${data.data.slug}`);
+ return;
+ }
+ setCollection(data.data);
+ } else {
+ if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') {
+ setError('This collection is not available or is private');
+ } else {
+ setError(data.error?.message || 'Failed to fetch collection');
+ }
+ }
+ } catch (err) {
+ console.error('Failed to fetch collection:', err);
+ setError('Failed to fetch collection');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [collectionId, router]);
+
+ useEffect(() => {
+ fetchCollection();
+ }, [fetchCollection]);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+ );
+ }
+
+ if (error || !collection) {
+ return (
+
+
+
+
+
+
+ {error || 'Collection not found'}
+
+
+ This collection may be private or no longer available.
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {/* Back link */}
+
+
+ Back to Collections
+
+
+ {/* Header */}
+
+
+
{collection.name}
+ {collection.description && (
+
{collection.description}
+ )}
+
+
+
+
+ {/* Meta info */}
+
+
+ {collection.createdBy.image ? (
+

+ ) : (
+
+
+
+ )}
+
Created by {collection.createdBy.name}
+
+
•
+
+ {collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''}
+
+
+
+ {/* MCP URLs */}
+ {collection.createdBy?.username && collection.slug && (
+
+ )}
+
+ {/* Tools */}
+
+
Tools in this Collection
+
+ {collection.tools.length === 0 ? (
+
+
+
No tools in this collection yet
+
+ ) : (
+
+ {collection.tools.map((ct) => (
+
+
+
+
+ {ct.tool.name}
+
+
+ from {ct.tool.package.npmPackageName}
+
+
+
+
+
+ {ct.tool.description}
+
+
+ {ct.tool.package.category}
+
+ {ct.note && (
+
Note: {ct.note}
+ )}
+
+ ))}
+
+ )}
+
+
+
+ );
}
diff --git a/apps/web/src/app/dashboard/collections/[id]/page.tsx b/apps/web/src/app/dashboard/collections/[id]/page.tsx
index 6fe7916..f4abfc7 100644
--- a/apps/web/src/app/dashboard/collections/[id]/page.tsx
+++ b/apps/web/src/app/dashboard/collections/[id]/page.tsx
@@ -19,11 +19,41 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { AddToolSearch } from '~/components/collections/AddToolSearch';
import { CollectionForm } from '~/components/collections/CollectionForm';
-import { InstallationSection } from '~/components/collections/InstallationSection';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
import { EnvVarsEditor } from '~/components/EnvVarsEditor';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
+// MCP URL display component
+function McpUrlDisplay({ url, label, sublabel }: { url: string; label: string; sublabel: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const copyToClipboard = async () => {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+ {label}
+
+ ({sublabel})
+
+
+
+ {url}
+
+
+
+
+ );
+}
+
interface CollectionTool {
id: string;
toolId: string;
@@ -61,9 +91,9 @@ interface Collection {
tools: CollectionTool[];
}
-type TabId = 'tools' | 'installation' | 'env-vars' | 'settings';
+type TabId = 'tools' | 'connect' | 'env-vars' | 'settings';
-const VALID_TABS: TabId[] = ['tools', 'installation', 'env-vars', 'settings'];
+const VALID_TABS: TabId[] = ['tools', 'connect', 'env-vars', 'settings'];
export default function CollectionDetailPage(): React.ReactElement {
const params = useParams();
@@ -84,6 +114,7 @@ export default function CollectionDetailPage(): React.ReactElement {
const [isDeleting, setIsDeleting] = useState(false);
const [executorConfig, setExecutorConfig] = useState(null);
const [envVars, setEnvVars] = useState | null>(null);
+ const [showClaudeConfig, setShowClaudeConfig] = useState(false);
// Update URL when tab changes
const handleTabChange = (tabId: string) => {
@@ -334,11 +365,29 @@ export default function CollectionDetailPage(): React.ReactElement {
}
const existingToolIds = collection.tools.map((t) => t.toolId);
+ const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
+ const httpUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/http`;
+ const sseUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/sse`;
+
+ const configSnippet = `{
+ "mcpServers": {
+ "${collection.slug}": {
+ "command": "npx",
+ "args": [
+ "mcp-remote",
+ "${httpUrl}",
+ "--header",
+ "Authorization: Bearer YOUR_TPMJS_API_KEY"
+ ]
+ }
+ }
+}`;
+
const envVarsCount = envVars ? Object.keys(envVars).length : 0;
const tabs = [
{ id: 'tools' as const, label: 'Tools', count: collection.toolCount },
- { id: 'installation' as const, label: 'Installation' },
+ { id: 'connect' as const, label: 'Connect' },
{
id: 'env-vars' as const,
label: 'Env Vars',
@@ -469,11 +518,11 @@ export default function CollectionDetailPage(): React.ReactElement {
)}
- {/* Installation Tab */}
- {activeTab === 'installation' && (
+ {/* Connect Tab */}
+ {activeTab === 'connect' && (
{/* Username warning */}
- {!collection.user.username && (
+ {collection.isPublic && !collection.user.username && (
@@ -509,20 +558,63 @@ export default function CollectionDetailPage(): React.ReactElement {
)}
- {/* Installation Section */}
- {collection.user.username && (
-
+ {/* MCP URLs */}
+ {collection.isPublic && collection.user.username && (
+
+
+
+
+
+
MCP Server URLs
+
+
+
+
+
+
+
+
+
+
+ {showClaudeConfig && (
+
+
+ {configSnippet}
+
+
+
+ )}
+
+
+
+ Use these URLs with{' '}
+
+ Claude Desktop, Cursor, or any MCP client
+
+ . Requires your{' '}
+
+ TPMJS API key
+ {' '}
+ for authentication.
+
+
)}
)}
diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx
index 9f24911..fc89189 100644
--- a/apps/web/src/components/AppHeader.tsx
+++ b/apps/web/src/components/AppHeader.tsx
@@ -213,6 +213,11 @@ export function AppHeader(): React.ReactElement {
Tools
+
+
+