Skip to content
Open
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
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ You can deploy your own version of the coding agent template to Vercel with one
- **Persistent Storage**: Tasks stored in Neon Postgres database
- **Git Integration**: Automatically creates branches and commits changes
- **Modern UI**: Clean, responsive interface built with Next.js and Tailwind CSS
- **MCP Server Support**: Connect MCP servers to Claude Code for extended capabilities (Claude only)

## Setup

Expand All @@ -38,11 +39,7 @@ pnpm install

### 3. Set up environment variables

Copy the example environment file and fill in your values:

```bash
cp .env.example .env.local
```
Create a `.env.local` file with your values:

Required environment variables:

Expand All @@ -59,6 +56,7 @@ Optional environment variables:
- `CURSOR_API_KEY`: For Cursor agent support
- `GEMINI_API_KEY`: For Google Gemini agent support
- `NPM_TOKEN`: For private npm packages
- `ENCRYPTION_KEY`: 32-byte hex string for encrypting MCP OAuth secrets (required only when using MCP connectors). Generate with: `openssl rand -hex 32`

### 4. Set up the database

Expand Down Expand Up @@ -110,6 +108,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
- `CURSOR_API_KEY`: Cursor agent API key
- `GEMINI_API_KEY`: Google Gemini agent API key (get yours at [Google AI Studio](https://aistudio.google.com/apikey))
- `NPM_TOKEN`: NPM token for private packages
- `ENCRYPTION_KEY`: 32-byte hex string for encrypting MCP OAuth secrets (required only when using MCP connectors). Generate with: `openssl rand -hex 32`

## AI Branch Name Generation

Expand Down Expand Up @@ -138,6 +137,19 @@ The system automatically generates descriptive Git branch names using AI SDK 5 a
- **Sandbox**: [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox)
- **Git**: Automated branching and commits with AI-generated branch names

## MCP Server Support

Connect MCP Servers to extend Claude Code with additional tools and integrations. **Currently only works with Claude Code agent.**

### How to Add MCP Servers

1. Go to the "Connectors" tab and click "Add MCP Server"
2. Enter server details (name, base URL, optional OAuth credentials)
3. If using OAuth, generate encryption key: `openssl rand -hex 32`
4. Add to `.env.local`: `ENCRYPTION_KEY=your-32-byte-hex-key`

**Note**: `ENCRYPTION_KEY` is only required when using MCP servers with OAuth authentication.

## Development

### Database Operations
Expand Down
31 changes: 31 additions & 0 deletions app/api/connectors/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db/client'
import { connectors } from '@/lib/db/schema'
import { decrypt } from '@/lib/crypto'

export async function GET() {
try {
const allConnectors = await db.select().from(connectors)

const decryptedConnectors = allConnectors.map((connector) => ({
...connector,
oauthClientSecret: connector.oauthClientSecret ? decrypt(connector.oauthClientSecret) : null,
}))

return NextResponse.json({
success: true,
data: decryptedConnectors,
})
} catch (error) {
console.error('Error fetching connectors:', error)

return NextResponse.json(
{
success: false,
error: 'Failed to fetch connectors',
data: [],
},
{ status: 500 },
)
}
}
30 changes: 28 additions & 2 deletions app/api/tasks/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse, after } from 'next/server'
import { Sandbox } from '@vercel/sandbox'
import { db } from '@/lib/db/client'
import { tasks, insertTaskSchema } from '@/lib/db/schema'
import { tasks, insertTaskSchema, connectors } from '@/lib/db/schema'
import { generateId } from '@/lib/utils/id'
import { createSandbox } from '@/lib/sandbox/creation'
import { executeAgentInSandbox, AgentType } from '@/lib/sandbox/agents'
Expand All @@ -11,6 +11,7 @@ import { eq, desc, or } from 'drizzle-orm'
import { createInfoLog } from '@/lib/utils/logging'
import { createTaskLogger } from '@/lib/utils/task-logger'
import { generateBranchName, createFallbackBranchName } from '@/lib/utils/branch-name-generator'
import { decrypt } from '@/lib/crypto'

export async function GET() {
try {
Expand Down Expand Up @@ -358,8 +359,33 @@ async function processTask(
throw new Error('Sandbox is not available for agent execution')
}

type Connector = typeof connectors.$inferSelect

let mcpServers: Connector[] = []

try {
const allConnectors = await db.select().from(connectors)
mcpServers = allConnectors
.filter((connector: Connector) => connector.status === 'connected')
.map((connector: Connector) => {
return {
...connector,
oauthClientSecret: connector.oauthClientSecret ? decrypt(connector.oauthClientSecret) : null,
}
})

if (mcpServers.length > 0) {
await logger.info(
`Found ${mcpServers.length} connected MCP servers: ${mcpServers.map((s) => s.name).join(', ')}`,
)
}
} catch (mcpError) {
console.error('Failed to fetch MCP servers:', mcpError)
await logger.info('Warning: Could not fetch MCP servers, continuing without them')
}

const agentResult = await Promise.race([
executeAgentInSandbox(sandbox, prompt, selectedAgent as AgentType, logger, selectedModel),
executeAgentInSandbox(sandbox, prompt, selectedAgent as AgentType, logger, selectedModel, mcpServers),
agentTimeoutPromise,
])

Expand Down
101 changes: 52 additions & 49 deletions components/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import { useState, useEffect, createContext, useContext, useCallback } from 'react'
import { TaskSidebar } from '@/components/task-sidebar'
import { Task } from '@/lib/db/schema'
import { Task, Connector } from '@/lib/db/schema'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import Link from 'next/link'
import { getSidebarWidth, setSidebarWidth, getSidebarOpen, setSidebarOpen } from '@/lib/utils/cookies'
import { nanoid } from 'nanoid'
import { ConnectorsProvider } from '@/components/connectors-provider'

interface AppLayoutProps {
children: React.ReactNode
Expand Down Expand Up @@ -264,72 +265,74 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen }:

return (
<TasksContext.Provider value={{ refreshTasks: fetchTasks, toggleSidebar, isSidebarOpen, addTaskOptimistically }}>
<div
className="h-screen flex relative"
style={
{
'--sidebar-width': `${sidebarWidth}px`,
'--sidebar-open': isSidebarOpen ? '1' : '0',
} as React.CSSProperties
}
suppressHydrationWarning
>
{/* Backdrop - Mobile Only */}
{isSidebarOpen && <div className="lg:hidden fixed inset-0 bg-black/50 z-30" onClick={closeSidebar} />}

{/* Sidebar */}
<ConnectorsProvider>
<div
className={`
className="h-screen flex relative"
style={
{
'--sidebar-width': `${sidebarWidth}px`,
'--sidebar-open': isSidebarOpen ? '1' : '0',
} as React.CSSProperties
}
suppressHydrationWarning
>
{/* Backdrop - Mobile Only */}
{isSidebarOpen && <div className="lg:hidden fixed inset-0 bg-black/50 z-30" onClick={closeSidebar} />}

{/* Sidebar */}
<div
className={`
fixed inset-y-0 left-0 z-40
${isResizing ? '' : 'transition-all duration-300 ease-in-out'}
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}
${isSidebarOpen ? 'pointer-events-auto' : 'pointer-events-none'}
`}
style={{
width: `${sidebarWidth}px`,
}}
>
<div
className="h-full overflow-hidden"
style={{
width: `${sidebarWidth}px`,
}}
>
{isLoading ? (
<SidebarLoader width={sidebarWidth} />
) : (
<TaskSidebar tasks={tasks} onTaskSelect={handleTaskSelect} width={sidebarWidth} />
)}
<div
className="h-full overflow-hidden"
style={{
width: `${sidebarWidth}px`,
}}
>
{isLoading ? (
<SidebarLoader width={sidebarWidth} />
) : (
<TaskSidebar tasks={tasks} onTaskSelect={handleTaskSelect} width={sidebarWidth} />
)}
</div>
</div>
</div>

{/* Resize Handle - Desktop Only, when sidebar is open */}
<div
className={`
{/* Resize Handle - Desktop Only, when sidebar is open */}
<div
className={`
hidden lg:block fixed inset-y-0 cursor-col-resize group z-41 hover:bg-primary/20
${isResizing ? '' : 'transition-all duration-300 ease-in-out'}
${isSidebarOpen ? 'w-1 opacity-100' : 'w-0 opacity-0'}
`}
onMouseDown={isSidebarOpen ? handleMouseDown : undefined}
style={{
// Position it right after the sidebar
left: isSidebarOpen ? `${sidebarWidth}px` : '0px',
}}
>
<div className="absolute inset-0 w-2 -ml-0.5" />
<div className="absolute inset-y-0 left-0 w-0.5 bg-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
onMouseDown={isSidebarOpen ? handleMouseDown : undefined}
style={{
// Position it right after the sidebar
left: isSidebarOpen ? `${sidebarWidth}px` : '0px',
}}
>
<div className="absolute inset-0 w-2 -ml-0.5" />
<div className="absolute inset-y-0 left-0 w-0.5 bg-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>

{/* Main Content */}
<div
className={`flex-1 overflow-auto flex flex-col lg:ml-0 ${isResizing ? '' : 'transition-all duration-300 ease-in-out'}`}
style={{
marginLeft: isSidebarOpen ? `${sidebarWidth + 4}px` : '0px',
}}
>
{children}
{/* Main Content */}
<div
className={`flex-1 overflow-auto flex flex-col lg:ml-0 ${isResizing ? '' : 'transition-all duration-300 ease-in-out'}`}
style={{
marginLeft: isSidebarOpen ? `${sidebarWidth + 4}px` : '0px',
}}
>
{children}
</div>
</div>
</div>
</ConnectorsProvider>
</TasksContext.Provider>
)
}
63 changes: 63 additions & 0 deletions components/connectors-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use client'

import { useState, useEffect, createContext, useContext, useCallback } from 'react'
import { Connector } from '@/lib/db/schema'

interface ConnectorsContextType {
connectors: Connector[]
refreshConnectors: () => Promise<void>
isLoading: boolean
}

const ConnectorsContext = createContext<ConnectorsContextType | undefined>(undefined)

export const useConnectors = () => {
const context = useContext(ConnectorsContext)
if (!context) {
throw new Error('useConnectors must be used within ConnectorsProvider')
}
return context
}

interface ConnectorsProviderProps {
children: React.ReactNode
}

export function ConnectorsProvider({ children }: ConnectorsProviderProps) {
const [connectors, setConnectors] = useState<Connector[]>([])
const [isLoading, setIsLoading] = useState(true)

const fetchConnectors = useCallback(async () => {
try {
const response = await fetch('/api/connectors')
if (response.ok) {
const data = await response.json()
setConnectors(data.data || [])
}
} catch (error) {
console.error('Error fetching connectors:', error)
} finally {
setIsLoading(false)
}
}, [])

useEffect(() => {
fetchConnectors()
}, [fetchConnectors])

const refreshConnectors = useCallback(async () => {
await fetchConnectors()
}, [fetchConnectors])

return (
<ConnectorsContext.Provider
value={{
connectors,
refreshConnectors,
isLoading,
}}
>
{children}
</ConnectorsContext.Provider>
)
}
Loading