feat(prompts): add works best with feature for prompts

This commit is contained in:
Fatih Kadir Akın
2026-01-09 10:17:44 +03:00
parent ad3b472e8a
commit e4b18a0fac
12 changed files with 486 additions and 5 deletions

1
.gitignore vendored
View File

@@ -12,7 +12,6 @@
# testing
/coverage
/src/app/sponsors
# next.js
/.next/

View File

@@ -128,6 +128,16 @@
"noTagsFound": "No tags found",
"promptContributors": "Contributors",
"contributorsDescription": "Other users who helped write this prompt. Users whose change requests are approved are added automatically.",
"worksBestWithModels": "Works Best With",
"worksBestWithModelsDescription": "AI models this prompt works best with (max 3)",
"selectModel": "Select model...",
"worksBestWithMCP": "MCP Servers",
"worksBestWithMCPDescription": "MCP servers and tools this prompt works with",
"mcpCommandPlaceholder": "npx -y @mcp/server-name",
"mcpToolsPlaceholder": "tool1, tool2",
"add": "Add",
"advancedOptions": "Advanced Options",
"worksBestWith": "Works best with",
"searchContributors": "Search by username...",
"noUsersFound": "No users found",
"promptPrivate": "Private",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "prompts" ADD COLUMN "bestWithMCP" JSONB,
ADD COLUMN "bestWithModels" TEXT[];

View File

@@ -124,6 +124,8 @@ model Prompt {
outgoingConnections PromptConnection[] @relation("ConnectionSource")
incomingConnections PromptConnection[] @relation("ConnectionTarget")
collectedBy Collection[]
bestWithModels String[] // Model slugs this prompt works best with (max 3), e.g. ["gpt-4o", "claude-3-5-sonnet"]
bestWithMCP Json? // MCP configs array, e.g. [{command: "npx -y @mcp/server", tools: ["tool1"]}]
@@index([authorId])
@@index([categoryId])

View File

@@ -21,6 +21,11 @@ const updatePromptSchema = z.object({
requiresMediaUpload: z.boolean().optional(),
requiredMediaType: z.enum(["IMAGE", "VIDEO", "DOCUMENT"]).optional().nullable(),
requiredMediaCount: z.number().int().min(1).max(10).optional().nullable(),
bestWithModels: z.array(z.string()).max(3).optional(),
bestWithMCP: z.array(z.object({
command: z.string(),
tools: z.array(z.string()).optional(),
})).optional(),
});
// Get single prompt
@@ -156,7 +161,7 @@ export async function PATCH(
);
}
const { tagIds, contributorIds, categoryId, mediaUrl, title, ...data } = parsed.data;
const { tagIds, contributorIds, categoryId, mediaUrl, title, bestWithModels, bestWithMCP, ...data } = parsed.data;
// Regenerate slug if title changed
let newSlug: string | undefined;
@@ -171,6 +176,8 @@ export async function PATCH(
...(newSlug && { slug: newSlug }),
...(categoryId !== undefined && { categoryId: categoryId || null }),
...(mediaUrl !== undefined && { mediaUrl: mediaUrl || null }),
...(bestWithModels !== undefined && { bestWithModels }),
...(bestWithMCP !== undefined && { bestWithMCP }),
};
// Update prompt

View File

@@ -23,6 +23,11 @@ const promptSchema = z.object({
requiresMediaUpload: z.boolean().optional(),
requiredMediaType: z.enum(["IMAGE", "VIDEO", "DOCUMENT"]).optional(),
requiredMediaCount: z.number().int().min(1).max(10).optional(),
bestWithModels: z.array(z.string()).max(3).optional(),
bestWithMCP: z.array(z.object({
command: z.string(),
tools: z.array(z.string()).optional(),
})).optional(),
});
// Create prompt
@@ -46,7 +51,7 @@ export async function POST(request: Request) {
);
}
const { title, description, content, type, structuredFormat, categoryId, tagIds, contributorIds, isPrivate, mediaUrl, requiresMediaUpload, requiredMediaType, requiredMediaCount } = parsed.data;
const { title, description, content, type, structuredFormat, categoryId, tagIds, contributorIds, isPrivate, mediaUrl, requiresMediaUpload, requiredMediaType, requiredMediaCount, bestWithModels, bestWithMCP } = parsed.data;
// Check if user is flagged (for auto-delisting and daily limit)
const currentUser = await db.user.findUnique({
@@ -176,6 +181,8 @@ export async function POST(request: Request) {
requiresMediaUpload: requiresMediaUpload || false,
requiredMediaType: requiresMediaUpload ? requiredMediaType : null,
requiredMediaCount: requiresMediaUpload ? requiredMediaCount : null,
bestWithModels: bestWithModels || [],
bestWithMCP: bestWithMCP || [],
authorId: session.user.id,
categoryId: categoryId || null,
// Auto-delist prompts from flagged users

View File

@@ -96,6 +96,8 @@ export default async function EditPromptPage({ params }: EditPromptPageProps) {
requiresMediaUpload: prompt.requiresMediaUpload,
requiredMediaType: (prompt.requiredMediaType as "IMAGE" | "VIDEO" | "DOCUMENT") || "IMAGE",
requiredMediaCount: prompt.requiredMediaCount || 1,
bestWithModels: (prompt as unknown as { bestWithModels?: string[] }).bestWithModels || [],
bestWithMCP: (prompt as unknown as { bestWithMCP?: { command: string; tools?: string[] }[] }).bestWithMCP || [],
};
// Check if AI generation is enabled

View File

@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import { getTranslations, getLocale } from "next-intl/server";
import { formatDistanceToNow } from "@/lib/date";
import { Clock, Edit, History, GitPullRequest, Check, X, Users, ImageIcon, Video, FileText, Shield, Trash2 } from "lucide-react";
import { Clock, Edit, History, GitPullRequest, Check, X, Users, ImageIcon, Video, FileText, Shield, Trash2, Cpu, Terminal, Wrench } from "lucide-react";
import { AnimatedDate } from "@/components/ui/animated-date";
import { ShareDropdown } from "@/components/prompts/share-dropdown";
import { auth } from "@/lib/auth";
@@ -30,6 +30,7 @@ import { RelatedPrompts } from "@/components/prompts/related-prompts";
import { AddToCollectionButton } from "@/components/prompts/add-to-collection-button";
import { getConfig } from "@/lib/config";
import { StructuredData } from "@/components/seo/structured-data";
import { AI_MODELS } from "@/lib/works-best-with";
interface PromptPageProps {
params: Promise<{ id: string }>;
@@ -258,6 +259,10 @@ export default async function PromptPage({ params }: PromptPageProps) {
const delistReason = (prompt as { delistReason?: string | null }).delistReason as
| "TOO_SHORT" | "NOT_ENGLISH" | "LOW_QUALITY" | "NOT_LLM_INSTRUCTION" | "MANUAL" | null;
// Get works best with fields (cast until Prisma types are regenerated)
const bestWithModels = (prompt as unknown as { bestWithModels?: string[] }).bestWithModels || [];
const bestWithMCP = (prompt as unknown as { bestWithMCP?: { command: string; tools?: string[] }[] }).bestWithMCP || [];
return (
<>
{/* Structured Data for Rich Results */}
@@ -575,6 +580,65 @@ export default async function PromptPage({ params }: PromptPageProps) {
/>
)}
</div>
{/* Works Best With */}
{(bestWithModels.length > 0 || bestWithMCP.length > 0) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
{bestWithModels.length > 0 && (
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">{t("worksBestWith")}:</span>
<div className="flex flex-wrap gap-1.5">
{bestWithModels.map((slug) => {
const model = AI_MODELS[slug as keyof typeof AI_MODELS];
return (
<Badge key={slug} variant="secondary" className="text-xs">
{model?.name || slug}
</Badge>
);
})}
</div>
</div>
)}
{bestWithMCP.length > 0 && (
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">MCP:</span>
<div className="flex flex-wrap gap-1.5">
{bestWithMCP.flatMap((mcp, mcpIndex) =>
mcp.tools && mcp.tools.length > 0
? mcp.tools.map((tool, toolIndex) => (
<Tooltip key={`${mcpIndex}-${toolIndex}`}>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs font-mono cursor-help gap-1">
<Wrench className="h-3 w-3" />
{tool}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<code className="text-xs break-all">{mcp.command}</code>
</TooltipContent>
</Tooltip>
))
: [(
<Tooltip key={mcpIndex}>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs font-mono cursor-help">
{mcp.command.split("/").pop()?.replace("server-", "") || mcp.command}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<code className="text-xs break-all">{mcp.command}</code>
</TooltipContent>
</Tooltip>
)]
)}
</div>
</div>
)}
</div>
)}
{/* Report & Prompt Flow */}
<PromptFlowSection
promptId={prompt.id}

View File

@@ -0,0 +1,105 @@
import { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { Users, Star, TrendingUp } from "lucide-react";
export const metadata: Metadata = {
title: "CodeRabbit joins prompts.chat as a sponsor",
description: "We're thrilled to announce CodeRabbit as our newest sponsor!",
};
export default function CodeRabbitAnnouncementPage() {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-16 bg-white">
{/* Twitter-sized container (1200x675 aspect ratio) */}
<div className="w-full max-w-[1200px] aspect-[1200/675] flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg bg-white p-8">
<div className="text-center space-y-8">
{/* Logo */}
<div className="flex items-center justify-center gap-2">
<Image
src="/logo.svg"
alt="prompts.chat"
width={32}
height={32}
className="h-8 w-auto"
/>
<span className="text-xl font-medium text-gray-600">prompts.chat</span>
</div>
{/* Announcement */}
<div className="space-y-2">
<h1 className="text-3xl md:text-4xl font-bold tracking-tight text-gray-900">
We&apos;re Thrilled to Announce
</h1>
<p className="text-gray-500 text-lg">
Our newest sponsor is here!
</p>
</div>
{/* Sponsor Logo */}
<div className="flex justify-center">
<Link
href="https://coderabbit.link/fatih"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center px-12 py-8 border border-gray-200 rounded-2xl hover:border-gray-300 transition-colors bg-white shadow-lg"
>
<Image
src="/sponsors/coderabbit.svg"
alt="CodeRabbit"
width={200}
height={50}
className="h-12 w-auto"
/>
</Link>
</div>
{/* Stats */}
<div className="flex items-center justify-center gap-8 md:gap-12 pt-4">
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2 text-gray-400">
<Users className="h-5 w-5" />
<span className="text-2xl font-bold text-gray-900">5000+</span>
</div>
<span className="text-sm text-gray-500">Contributors</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2 text-amber-500">
<Star className="h-5 w-5 fill-current" />
<span className="text-2xl font-bold text-gray-900">142K+</span>
</div>
<span className="text-sm text-gray-500">GitHub Stars</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2 text-emerald-500">
<TrendingUp className="h-5 w-5" />
<span className="text-2xl font-bold text-gray-900">Growing</span>
</div>
<span className="text-sm text-gray-500">Every Day</span>
</div>
</div>
{/* CTA */}
<div className="pt-8">
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-3 border border-gray-200 rounded-full text-gray-500 hover:text-gray-900 hover:border-gray-400 transition-colors"
>
<Image
src="/logo.svg"
alt="prompts.chat"
width={16}
height={16}
className="h-4 w-auto"
/>
<span className="font-medium">prompts.chat</span>
<span className="hidden sm:inline"> The social platform for AI prompts</span>
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2, Upload, X, ArrowDown, Play, Image as ImageIcon, Video, Volume2, Paperclip, Search, Sparkles, BookOpen, ExternalLink, ChevronDown } from "lucide-react";
import { Loader2, Upload, X, ArrowDown, Play, Image as ImageIcon, Video, Volume2, Paperclip, Search, Sparkles, BookOpen, ExternalLink, ChevronDown, Settings2 } from "lucide-react";
import Link from "next/link";
import { VariableToolbar } from "./variable-toolbar";
import { VariableWarning } from "./variable-warning";
@@ -47,6 +47,7 @@ import { toast } from "sonner";
import { prettifyJson } from "@/lib/format";
import { analyticsPrompt } from "@/lib/analytics";
import { getPromptUrl } from "@/lib/urls";
import { AI_MODELS, getModelsByProvider, type PromptMCPConfig } from "@/lib/works-best-with";
interface MediaFieldProps {
form: ReturnType<typeof useForm<PromptFormValues>>;
@@ -345,6 +346,11 @@ const createPromptSchema = (t: (key: string) => string) => z.object({
requiresMediaUpload: z.boolean(),
requiredMediaType: z.enum(["IMAGE", "VIDEO", "DOCUMENT"]).optional(),
requiredMediaCount: z.coerce.number().int().min(1).max(10).optional(),
bestWithModels: z.array(z.string()).max(3).optional(),
bestWithMCP: z.array(z.object({
command: z.string(),
tools: z.array(z.string()).optional(),
})).optional(),
});
type PromptFormValues = z.infer<ReturnType<typeof createPromptSchema>>;
@@ -426,9 +432,19 @@ export function PromptForm({ categories, tags, initialData, initialContributors
requiresMediaUpload: initialData?.requiresMediaUpload || false,
requiredMediaType: initialData?.requiredMediaType || "IMAGE",
requiredMediaCount: initialData?.requiredMediaCount || 1,
bestWithModels: initialData?.bestWithModels || [],
bestWithMCP: initialData?.bestWithMCP || [],
},
});
// State for MCP input and advanced section
const [newMcpCommand, setNewMcpCommand] = useState("");
const [newMcpTools, setNewMcpTools] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const bestWithMCP = form.watch("bestWithMCP") || [];
const bestWithModels = form.watch("bestWithModels") || [];
const modelsByProvider = getModelsByProvider();
const selectedTags = form.watch("tagIds");
const promptType = form.watch("type");
const structuredFormat = form.watch("structuredFormat");
@@ -889,6 +905,136 @@ export function PromptForm({ categories, tags, initialData, initialContributors
onRemove={(userId) => setContributors((prev) => prev.filter((u) => u.id !== userId))}
/>
</div>
{/* Advanced Section */}
<div className="border rounded-lg">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center justify-between w-full p-3 text-sm font-medium text-left hover:bg-muted/50 transition-colors"
>
<span className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
{t("advancedOptions")}
{(bestWithModels.length > 0 || bestWithMCP.length > 0) && (
<Badge variant="secondary" className="text-[10px] h-5">{bestWithModels.length + bestWithMCP.length}</Badge>
)}
</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</button>
{showAdvanced && (
<div className="p-3 space-y-4 border-t">
{/* Works Best With Models */}
<div className="space-y-2">
<label className="text-sm font-medium block">{t("worksBestWithModels")}</label>
<p className="text-xs text-muted-foreground">{t("worksBestWithModelsDescription")}</p>
{bestWithModels.length > 0 && (
<div className="flex flex-wrap gap-2">
{bestWithModels.map((slug) => {
const model = AI_MODELS[slug as keyof typeof AI_MODELS];
return (
<Badge key={slug} variant="secondary" className="pr-1 flex items-center gap-1">
{model?.name || slug}
<button
type="button"
onClick={() => form.setValue("bestWithModels", bestWithModels.filter((s) => s !== slug))}
className="ml-1 rounded-full hover:bg-muted p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
)}
{bestWithModels.length < 3 && (
<Select
value=""
onValueChange={(slug) => {
if (slug && !bestWithModels.includes(slug)) {
form.setValue("bestWithModels", [...bestWithModels, slug]);
}
}}
>
<SelectTrigger className="w-full sm:w-64 h-8 text-xs">
<SelectValue placeholder={t("selectModel")} />
</SelectTrigger>
<SelectContent>
{Object.entries(modelsByProvider).map(([provider, models]) => (
<div key={provider}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">{provider}</div>
{models
.filter((m) => !bestWithModels.includes(m.slug))
.map((model) => (
<SelectItem key={model.slug} value={model.slug}>
{model.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
)}
</div>
{/* Works Best With MCP */}
<div className="space-y-2">
<label className="text-sm font-medium block">{t("worksBestWithMCP")}</label>
<p className="text-xs text-muted-foreground">{t("worksBestWithMCPDescription")}</p>
{bestWithMCP.length > 0 && (
<div className="space-y-1.5">
{bestWithMCP.map((mcp, index) => (
<div key={index} className="flex items-center gap-2 p-2 rounded border bg-muted/30 text-xs">
<code className="flex-1 break-all">{mcp.command}</code>
{mcp.tools && mcp.tools.length > 0 && (
<span className="text-muted-foreground">({mcp.tools.join(", ")})</span>
)}
<button
type="button"
onClick={() => form.setValue("bestWithMCP", bestWithMCP.filter((_, i) => i !== index))}
className="p-1 hover:bg-muted rounded"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Input
placeholder={t("mcpCommandPlaceholder")}
value={newMcpCommand}
onChange={(e) => setNewMcpCommand(e.target.value)}
className="flex-1 text-xs h-8"
/>
<Input
placeholder={t("mcpToolsPlaceholder")}
value={newMcpTools}
onChange={(e) => setNewMcpTools(e.target.value)}
className="w-28 text-xs h-8"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2 text-xs"
disabled={!newMcpCommand.trim()}
onClick={() => {
if (newMcpCommand.trim()) {
const tools = newMcpTools.trim() ? newMcpTools.split(",").map(t => t.trim()).filter(Boolean) : undefined;
form.setValue("bestWithMCP", [...bestWithMCP, { command: newMcpCommand.trim(), tools }]);
setNewMcpCommand("");
setNewMcpTools("");
}
}}
>
{t("add")}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
{/* ===== INPUT SECTION ===== */}

136
src/lib/works-best-with.ts Normal file
View File

@@ -0,0 +1,136 @@
// Types and constants for "Works Best With" feature
// MCP Server configuration for a prompt
export interface PromptMCPConfig {
command: string;
tools?: string[];
}
// Well-known AI models (slug -> display info)
export const AI_MODELS = {
// OpenAI
"gpt-4o": { name: "GPT-4o", provider: "OpenAI" },
"gpt-4o-mini": { name: "GPT-4o Mini", provider: "OpenAI" },
"gpt-4-turbo": { name: "GPT-4 Turbo", provider: "OpenAI" },
"gpt-4": { name: "GPT-4", provider: "OpenAI" },
"o1": { name: "o1", provider: "OpenAI" },
"o1-mini": { name: "o1 Mini", provider: "OpenAI" },
"o1-pro": { name: "o1 Pro", provider: "OpenAI" },
"o3": { name: "o3", provider: "OpenAI" },
"o3-mini": { name: "o3 Mini", provider: "OpenAI" },
"gpt-4-5": { name: "GPT-4.5", provider: "OpenAI" },
// Anthropic
"claude-3-5-sonnet": { name: "Claude 3.5 Sonnet", provider: "Anthropic" },
"claude-3-5-haiku": { name: "Claude 3.5 Haiku", provider: "Anthropic" },
"claude-3-opus": { name: "Claude 3 Opus", provider: "Anthropic" },
"claude-4-sonnet": { name: "Claude 4 Sonnet", provider: "Anthropic" },
"claude-4-opus": { name: "Claude 4 Opus", provider: "Anthropic" },
// Google
"gemini-2-0-flash": { name: "Gemini 2.0 Flash", provider: "Google" },
"gemini-2-5-pro": { name: "Gemini 2.5 Pro", provider: "Google" },
"gemini-2-5-flash": { name: "Gemini 2.5 Flash", provider: "Google" },
"gemma-3": { name: "Gemma 3", provider: "Google" },
// Meta
"llama-4": { name: "Llama 4", provider: "Meta" },
"llama-4-scout": { name: "Llama 4 Scout", provider: "Meta" },
"llama-4-maverick": { name: "Llama 4 Maverick", provider: "Meta" },
"llama-3-3": { name: "Llama 3.3", provider: "Meta" },
// xAI
"grok-3": { name: "Grok 3", provider: "xAI" },
"grok-2": { name: "Grok 2", provider: "xAI" },
// DeepSeek
"deepseek-r1": { name: "DeepSeek R1", provider: "DeepSeek" },
"deepseek-v3": { name: "DeepSeek V3", provider: "DeepSeek" },
// Mistral
"mistral-large": { name: "Mistral Large", provider: "Mistral" },
"mixtral-8x22b": { name: "Mixtral 8x22B", provider: "Mistral" },
"codestral": { name: "Codestral", provider: "Mistral" },
// Alibaba
"qwen-2-5": { name: "Qwen 2.5", provider: "Alibaba" },
"qwen-3": { name: "Qwen 3", provider: "Alibaba" },
// Microsoft
"phi-4": { name: "Phi-4", provider: "Microsoft" },
// Amazon
"nova-pro": { name: "Nova Pro", provider: "Amazon" },
"nova-lite": { name: "Nova Lite", provider: "Amazon" },
} as const;
export type AIModelSlug = keyof typeof AI_MODELS;
export function getModelInfo(slug: string): { name: string; provider: string } | null {
return AI_MODELS[slug as AIModelSlug] ?? null;
}
export function isValidModelSlug(slug: string): slug is AIModelSlug {
return slug in AI_MODELS;
}
// Get models grouped by provider
export function getModelsByProvider(): Record<string, { slug: string; name: string }[]> {
const grouped: Record<string, { slug: string; name: string }[]> = {};
for (const [slug, info] of Object.entries(AI_MODELS)) {
if (!grouped[info.provider]) {
grouped[info.provider] = [];
}
grouped[info.provider].push({ slug, name: info.name });
}
return grouped;
}
// Validate bestWithModels (max 3, valid slugs)
export function validateBestWithModels(models: string[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (models.length > 3) {
errors.push("Maximum 3 models allowed");
}
for (const slug of models) {
if (!isValidModelSlug(slug)) {
errors.push(`Unknown model: ${slug}`);
}
}
return { valid: errors.length === 0, errors };
}
// Validate bestWithMCP
export function validateBestWithMCP(mcp: unknown): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (mcp === null || mcp === undefined) {
return { valid: true, errors: [] };
}
if (typeof mcp !== "object") {
errors.push("MCP config must be an object");
return { valid: false, errors };
}
const config = mcp as Record<string, unknown>;
if (!("command" in config) || typeof config.command !== "string") {
errors.push("MCP config.command is required and must be a string");
}
if ("tools" in config && config.tools !== undefined) {
if (!Array.isArray(config.tools)) {
errors.push("MCP config.tools must be an array");
} else if (!config.tools.every((t: unknown) => typeof t === "string")) {
errors.push("MCP config.tools must be an array of strings");
}
}
return { valid: errors.length === 0, errors };
}