Skip to content
Merged
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
5 changes: 4 additions & 1 deletion backend/src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export function createHealthRoutes(db: Database) {
timestamp: new Date().toISOString(),
database: dbCheck ? 'connected' : 'disconnected',
opencode: opencodeHealthy ? 'healthy' : 'unhealthy',
opencodePort: opencodeServerManager.getPort()
opencodePort: opencodeServerManager.getPort(),
opencodeVersion: opencodeServerManager.getVersion(),
opencodeMinVersion: opencodeServerManager.getMinVersion(),
opencodeVersionSupported: opencodeServerManager.isVersionSupported()
})
} catch (error) {
return c.json({
Expand Down
25 changes: 1 addition & 24 deletions backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,7 @@ const UpdateOpenCodeConfigSchema = z.object({
isDefault: z.boolean().optional(),
})

function hasMcpChanged(oldContent: Record<string, unknown>, newContent: Record<string, unknown>): boolean {
const oldMcp = oldContent.mcp as Record<string, any> || {}
const newMcp = newContent.mcp as Record<string, any> || {}

const oldKeys = Object.keys(oldMcp).sort()
const newKeys = Object.keys(newMcp).sort()

if (JSON.stringify(oldKeys) !== JSON.stringify(newKeys)) {
return true
}

for (const key of oldKeys) {
if (JSON.stringify(oldMcp[key]) !== JSON.stringify(newMcp[key])) {
return true
}
}

return false
}


const CreateCustomCommandSchema = z.object({
name: z.string().min(1).max(255),
Expand Down Expand Up @@ -170,11 +152,6 @@ export function createSettingsRoutes(db: Database) {
logger.info(`Wrote default config to: ${configPath}`)

await patchOpenCodeConfig(config.content)

if (existingConfig && hasMcpChanged(existingConfig.content, config.content)) {
logger.info('MCP configuration changed, restarting OpenCode server')
await opencodeServerManager.restart()
}
}

return c.json(config)
Expand Down
55 changes: 53 additions & 2 deletions backend/src/services/opencode-single-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,28 @@ import type { Database } from 'bun:sqlite'
const OPENCODE_SERVER_PORT = ENV.OPENCODE.PORT
const OPENCODE_SERVER_DIRECTORY = getWorkspacePath()
const OPENCODE_CONFIG_PATH = getOpenCodeConfigFilePath()
const MIN_OPENCODE_VERSION = '1.0.137'

function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number)
const parts2 = v2.split('.').map(Number)

for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0
const p2 = parts2[i] || 0
if (p1 > p2) return 1
if (p1 < p2) return -1
}
return 0
}

class OpenCodeServerManager {
private static instance: OpenCodeServerManager
private serverProcess: any = null
private serverProcess: ReturnType<typeof spawn> | null = null
private serverPid: number | null = null
private isHealthy: boolean = false
private db: Database | null = null
private version: string | null = null

private constructor() {}

Expand Down Expand Up @@ -105,7 +120,7 @@ class OpenCodeServerManager {
}
)

this.serverPid = this.serverProcess.pid
this.serverPid = this.serverProcess.pid ?? null

logger.info(`OpenCode server started with PID ${this.serverPid}`)

Expand All @@ -116,6 +131,15 @@ class OpenCodeServerManager {

this.isHealthy = true
logger.info('OpenCode server is healthy')

await this.fetchVersion()
if (this.version) {
logger.info(`OpenCode version: ${this.version}`)
if (!this.isVersionSupported()) {
logger.warn(`OpenCode version ${this.version} is below minimum required version ${MIN_OPENCODE_VERSION}`)
logger.warn('Some features like MCP management may not work correctly')
}
}
}

async stop(): Promise<void> {
Expand Down Expand Up @@ -152,6 +176,19 @@ class OpenCodeServerManager {
return OPENCODE_SERVER_PORT
}

getVersion(): string | null {
return this.version
}

getMinVersion(): string {
return MIN_OPENCODE_VERSION
}

isVersionSupported(): boolean {
if (!this.version) return false
return compareVersions(this.version, MIN_OPENCODE_VERSION) >= 0
}

async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`http://127.0.0.1:${OPENCODE_SERVER_PORT}/doc`, {
Expand All @@ -163,6 +200,20 @@ class OpenCodeServerManager {
}
}

async fetchVersion(): Promise<string | null> {
try {
const result = execSync('opencode --version 2>&1', { encoding: 'utf8' })
const match = result.match(/(\d+\.\d+\.\d+)/)
if (match && match[1]) {
this.version = match[1]
return this.version
}
} catch (error) {
logger.warn('Failed to get OpenCode version:', error)
}
return null
}

private async waitForHealth(timeoutMs: number): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
Expand Down
127 changes: 127 additions & 0 deletions frontend/src/api/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { API_BASE_URL } from '@/config'

const API_BASE = API_BASE_URL

export type McpStatus =
| { status: 'connected' }
| { status: 'disabled' }
| { status: 'failed'; error: string }
| { status: 'needs_auth' }
| { status: 'needs_client_registration'; error: string }

export type McpStatusMap = Record<string, McpStatus>

export interface McpServerConfig {
type: 'local' | 'remote'
enabled?: boolean
command?: string[]
url?: string
environment?: Record<string, string>
headers?: Record<string, string>
timeout?: number
oauth?: boolean | {
clientId?: string
clientSecret?: string
scope?: string
}
}

export interface AddMcpServerRequest {
name: string
config: McpServerConfig
}

export interface McpAuthStartResponse {
authorizationUrl: string
}

export const mcpApi = {
async getStatus(): Promise<McpStatusMap> {
const response = await fetch(`${API_BASE}/api/opencode/mcp`)
if (!response.ok) {
throw new Error(`Failed to get MCP status: ${response.statusText}`)
}
return response.json()
},

async addServer(name: string, config: McpServerConfig): Promise<McpStatusMap> {
const response = await fetch(`${API_BASE}/api/opencode/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, config }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `Failed to add MCP server: ${response.statusText}`)
}
return response.json()
},

async connect(name: string): Promise<boolean> {
const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/connect`, {
method: 'POST',
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `Failed to connect MCP server: ${response.statusText}`)
}
return response.json()
},

async disconnect(name: string): Promise<boolean> {
const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/disconnect`, {
method: 'POST',
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `Failed to disconnect MCP server: ${response.statusText}`)
}
return response.json()
},

async startAuth(name: string): Promise<McpAuthStartResponse> {
const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/auth`, {
method: 'POST',
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `Failed to start MCP auth: ${response.statusText}`)
}
return response.json()
},

async completeAuth(name: string, code: string): Promise<McpStatus> {
const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/auth/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `Failed to complete MCP auth: ${response.statusText}`)
}
return response.json()
},

async authenticate(name: string): Promise<McpStatus> {
const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/auth/authenticate`, {
method: 'POST',
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `Failed to authenticate MCP server: ${response.statusText}`)
}
return response.json()
},

async removeAuth(name: string): Promise<{ success: true }> {
const response = await fetch(`${API_BASE}/api/opencode/mcp/${encodeURIComponent(name)}/auth`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `Failed to remove MCP auth: ${response.statusText}`)
}
return response.json()
},
}
15 changes: 9 additions & 6 deletions frontend/src/components/message/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { useModelSelection } from '@/hooks/useModelSelection'

import { useUserBash } from '@/stores/userBashStore'
import { useMobile } from '@/hooks/useMobile'
import { ChevronDown } from 'lucide-react'
import { useSessionStatusForSession } from '@/stores/sessionStatusStore'
import { ChevronDown, Square } from 'lucide-react'

import { CommandSuggestions } from '@/components/command/CommandSuggestions'
import { MentionSuggestions, type MentionItem } from './MentionSuggestions'
Expand Down Expand Up @@ -451,7 +452,9 @@ export function PromptInput({
const { model: selectedModel, modelString } = useModelSelection(opcodeUrl, directory)
const currentModel = modelString || ''
const isMobile = useMobile()
const hideSecondaryButtons = isMobile && (hasActiveStream || showScrollButton)
const sessionStatus = useSessionStatusForSession(sessionID)
const showStopButton = hasActiveStream && (sessionStatus.type === 'busy' || sessionStatus.type === 'retry')
const hideSecondaryButtons = isMobile && hasActiveStream

useEffect(() => {
const loadModelName = async () => {
Expand Down Expand Up @@ -485,7 +488,7 @@ export function PromptInput({
return (
<div className="relative backdrop-blur-md bg-background opacity-95 border border-border rounded-xl p-2 md:p-3 mx-2 md:mx-4 mb-2 md:mb-5 w-[90%] md:max-w-4xl">
{hasActiveStream && (
<div className="mb-2">
<div className="">
<SessionStatusIndicator sessionID={sessionID} />
</div>
)}
Expand Down Expand Up @@ -566,14 +569,14 @@ export function PromptInput({
<ChevronDown className="w-5 h-5" />
</button>
)}
{hasActiveStream && (
{showStopButton && (
<button
onClick={handleStop}
disabled={disabled}
className="px-3 md:px-4 py-1.5 rounded-lg text-sm font-medium transition-colors bg-destructive hover:bg-destructive/90 text-destructive-foreground"
className="p-1.5 px-4 md:p-2 rounded-lg transition-colors bg-destructive hover:bg-destructive/90 text-destructive-foreground"
title="Stop"
>
Stop
<Square className="w-4 h-4" />
</button>
)}
<button
Expand Down
Loading