feat: add change_title MCP tool support to OpenCode

This commit is contained in:
weishu
2026-01-29 10:53:15 +08:00
parent 70b5c22c8f
commit 7e0b139d24
5 changed files with 138 additions and 1 deletions
@@ -19,6 +19,7 @@ type AutoApprovalRuleSet = {
const AUTO_APPROVE_TOOL_NAME_HINTS = [
'change_title',
'happy__change_title',
'hapi_change_title', // OpenCode MCP tool pattern
'geminireasoning',
'codexreasoning',
'think',
+26
View File
@@ -5,6 +5,9 @@ import { Future } from '@/utils/future';
import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy';
import { ensureOpencodeHookPlugin } from './utils/hookPlugin';
import { buildOpencodeEnv } from './utils/config';
import { ensureOpencodeConfig } from './utils/opencodeConfig';
import { TITLE_INSTRUCTION } from './utils/systemPrompt';
import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
import type { OpencodeHookEvent } from './types';
import type { OpencodeHookServer } from './utils/startOpencodeHookServer';
import { createOpencodeStorageScanner, type OpencodeStorageScannerHandle } from './utils/opencodeStorageScanner';
@@ -263,6 +266,21 @@ export async function opencodeLocalLauncher(
const opencodeConfigDir = resolveOpencodeConfigDir(session);
ensureOpencodeHookPlugin(opencodeConfigDir, hookUrl, opts.hookServer.token);
// Start the hapi MCP server for change_title support (optional feature)
let happyServer: { url: string; stop: () => void } | null = null;
let opencodeConfigPath: string | null = null;
try {
const bridge = await buildHapiMcpBridge(session.client);
happyServer = bridge.server;
logger.debug(`[opencode-local]: Started hapi MCP server at ${happyServer.url}`);
// Generate opencode.json config with MCP server and instructions
const { configPath } = ensureOpencodeConfig(opencodeConfigDir, bridge.mcpServers.hapi, TITLE_INSTRUCTION);
opencodeConfigPath = configPath;
} catch (error) {
logger.debug('[opencode-local]: Failed to start hapi MCP server (change_title will be unavailable)', error);
}
let storageScanner: OpencodeStorageScannerHandle | null = null;
const messageRoles = new Map<string, string>();
const sentTextParts = new Set<string>();
@@ -617,6 +635,10 @@ export async function opencodeLocalLauncher(
if (!env.OPENCODE_CONFIG_DIR) {
env.OPENCODE_CONFIG_DIR = opencodeConfigDir;
}
// Set OPENCODE_CONFIG to point to our generated config file (if MCP server started)
if (!env.OPENCODE_CONFIG && opencodeConfigPath) {
env.OPENCODE_CONFIG = opencodeConfigPath;
}
await opencodeLocal({
path: session.path,
@@ -659,6 +681,10 @@ export async function opencodeLocalLauncher(
if (storageScanner) {
await storageScanner.cleanup();
}
if (happyServer) {
happyServer.stop();
logger.debug('[opencode-local]: Stopped hapi MCP server');
}
}
return exitReason || 'exit';
+10 -1
View File
@@ -9,6 +9,7 @@ import type { OpencodeSession } from './session';
import type { PermissionMode } from './types';
import { createOpencodeBackend } from './utils/opencodeBackend';
import { OpencodePermissionHandler } from './utils/permissionHandler';
import { TITLE_INSTRUCTION } from './utils/systemPrompt';
class OpencodeRemoteLauncher extends RemoteLauncherBase {
private readonly session: OpencodeSession;
@@ -17,6 +18,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
private happyServer: { stop: () => void } | null = null;
private abortController = new AbortController();
private displayPermissionMode: PermissionMode | null = null;
private instructionsSent = false;
constructor(session: OpencodeSession) {
super(process.env.DEBUG ? session.logPath : undefined);
@@ -112,9 +114,16 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
this.applyDisplayMode(batch.mode.permissionMode);
messageBuffer.addMessage(batch.message, 'user');
// Inject title instructions on first prompt
let messageText = batch.message;
if (!this.instructionsSent) {
messageText = `${TITLE_INSTRUCTION}\n\n${batch.message}`;
this.instructionsSent = true;
}
const promptContent: PromptContent[] = [{
type: 'text',
text: batch.message
text: messageText
}];
session.onThinkingChange(true);
+81
View File
@@ -0,0 +1,81 @@
/**
* OpenCode configuration file generator.
*
* Generates opencode.json with MCP server configuration and instructions
* for the hapi change_title tool.
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const CONFIG_FILENAME = 'opencode.json';
const INSTRUCTIONS_FILENAME = 'hapi-instructions.md';
interface McpServerEntry {
command: string;
args: string[];
}
interface OpencodeConfig {
$schema: string;
mcp: Record<string, {
type: string;
command: string[];
enabled: boolean;
}>;
instructions: string[];
}
/**
* Ensures the opencode.json config file exists with MCP server and instructions.
*
* @param rootPath - The OPENCODE_CONFIG_DIR path
* @param mcpServer - The hapi MCP server command configuration
* @param instructions - The instruction text to write to the instructions file
*/
export function ensureOpencodeConfig(
rootPath: string,
mcpServer: McpServerEntry,
instructions: string
): { configPath: string; instructionsPath: string } {
mkdirSync(rootPath, { recursive: true });
// Write instructions file
const instructionsPath = join(rootPath, INSTRUCTIONS_FILENAME);
writeFileSafe(instructionsPath, instructions);
// Build opencode.json config
// Use absolute path for instructions since OpenCode resolves paths relative to project root
const config: OpencodeConfig = {
$schema: 'https://opencode.ai/config.json',
mcp: {
hapi: {
type: 'local',
command: [mcpServer.command, ...mcpServer.args],
enabled: true
}
},
instructions: [instructionsPath]
};
const configPath = join(rootPath, CONFIG_FILENAME);
const configJson = JSON.stringify(config, null, 2);
writeFileSafe(configPath, configJson);
return { configPath, instructionsPath };
}
/**
* Write file only if content has changed.
*/
function writeFileSafe(filePath: string, content: string): void {
try {
const current = readFileSync(filePath, 'utf-8');
if (current === content) {
return;
}
} catch {
// Ignore missing or unreadable file
}
writeFileSync(filePath, content, 'utf-8');
}
+20
View File
@@ -0,0 +1,20 @@
/**
* OpenCode-specific system prompt for change_title tool.
*
* OpenCode exposes MCP tools with the naming pattern: <server-name>_<tool-name>
* The hapi MCP server exposes `change_title`, so it's called as `hapi_change_title`.
*/
import { trimIdent } from '@/utils/trimIdent';
/**
* Title instruction for OpenCode to call the hapi MCP tool.
*/
export const TITLE_INSTRUCTION = trimIdent(`
ALWAYS when you start a new chat - you must call the tool "hapi_change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a chance to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human.
`);
/**
* The system prompt to inject for OpenCode sessions.
*/
export const opencodeSystemPrompt = TITLE_INSTRUCTION;