fix(codex): bridge MCP elicitation through user input (#1008)

* fix(codex): bridge MCP elicitation through user input

* fix(codex): allow MCP elicitation in yolo mode

* fix(codex): preserve MCP form semantics

* fix(codex): accept implicit MCP form mode

* fix(codex): harden MCP elicitation prompts

* fix(codex): require valid MCP choice answers

* fix(codex): round-trip MCP array elicitation

* fix(web): require explicit MCP URL confirmation

* fix(codex): preserve MCP array item types

* fix(codex): support multi-select MCP elicitation

* fix(codex): allow MCP elicitation in read-only mode

* fix(codex): route MCP tool approvals through permissions
This commit is contained in:
SSU-WEI HUANG
2026-07-12 18:42:55 +08:00
committed by GitHub
parent 474db94136
commit d97b270ba8
15 changed files with 1006 additions and 91 deletions
+13 -1
View File
@@ -1,4 +1,16 @@
export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never';
export type ApprovalPolicyPreset = 'untrusted' | 'on-failure' | 'on-request' | 'never';
export type ApprovalPolicy =
| ApprovalPolicyPreset
| {
granular: {
sandbox_approval: boolean;
rules: boolean;
skill_approval?: boolean;
request_permissions?: boolean;
mcp_elicitations: boolean;
};
};
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
export interface InitializeCapabilities {
+18 -14
View File
@@ -1129,8 +1129,8 @@ describe('codexRemoteLauncher', () => {
expect(session.thinking).toBe(false);
});
it('uses live permission mode for app-server MCP elicitation handlers', async () => {
const { session, setPermissionMode } = createSessionStub();
it('routes app-server MCP elicitation through the existing user-input transport', async () => {
const { session, codexMessages, rpcHandlers, setPermissionMode } = createSessionStub();
const exitReason = await codexRemoteLauncher(session as never);
@@ -1156,14 +1156,24 @@ describe('codexRemoteLauncher', () => {
}
};
await expect(handler?.(request)).resolves.toEqual({
action: 'cancel',
content: null,
_meta: null
setPermissionMode('yolo');
const response = handler?.(request);
await vi.waitFor(() => {
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'tool-call',
name: 'request_user_input'
}));
});
const requestMessage = codexMessages.find((message) => (
typeof message === 'object' && message !== null && 'name' in message && message.name === 'request_user_input'
)) as { callId: string };
await rpcHandlers.get('permission')?.({
id: requestMessage.callId,
approved: true,
answers: { approval: { answers: ['allow'] } }
});
setPermissionMode('yolo');
await expect(handler?.(request)).resolves.toEqual({
await expect(response).resolves.toEqual({
action: 'accept',
content: {
approval: 'allow'
@@ -1171,12 +1181,6 @@ describe('codexRemoteLauncher', () => {
_meta: null
});
setPermissionMode('default');
await expect(handler?.(request)).resolves.toEqual({
action: 'cancel',
content: null,
_meta: null
});
});
it('sends Codex plan collaboration mode when the app-server advertises it', async () => {
+37 -2
View File
@@ -87,7 +87,15 @@ describe('appServerConfig', () => {
});
expect(params.sandbox).toBe('danger-full-access');
expect(params.approvalPolicy).toBe('never');
expect(params.approvalPolicy).toEqual({
granular: {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: false,
mcp_elicitations: true
}
});
});
it('keeps on-failure approvals for safe-yolo threads', () => {
@@ -101,6 +109,25 @@ describe('appServerConfig', () => {
expect(params.approvalPolicy).toBe('on-failure');
});
it('allows MCP elicitation without enabling sandbox prompts for read-only threads', () => {
const params = buildThreadStartParams({
cwd: '/workspace/project',
mode: { permissionMode: 'read-only', collaborationMode: 'default' },
mcpServers
});
expect(params.sandbox).toBe('read-only');
expect(params.approvalPolicy).toEqual({
granular: {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: false,
mcp_elicitations: true
}
});
});
it('concatenates custom developer instructions after base instructions', () => {
const params = buildThreadStartParams({
cwd: '/workspace/project',
@@ -229,7 +256,15 @@ describe('appServerConfig', () => {
expect(params.threadId).toBe('thread-1');
expect(params.cwd).toBe('/workspace/project');
expect(params.input).toEqual([{ type: 'text', text: 'hello' }]);
expect(params.approvalPolicy).toBe('never');
expect(params.approvalPolicy).toEqual({
granular: {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: false,
mcp_elicitations: true
}
});
expect(params.sandboxPolicy).toEqual({ type: 'readOnly' });
expect(params.effort).toBe('high');
expect(params.summary).toBeUndefined();
+16
View File
@@ -23,7 +23,23 @@ const MODELS_WITHOUT_REASONING_SUMMARY = new Set([
'gpt-5.3-codex-spark'
]);
const MCP_ELICITATION_ONLY_APPROVAL_POLICY = {
granular: {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: false,
mcp_elicitations: true
}
} as const satisfies ApprovalPolicy;
function resolveApprovalPolicy(mode: EnhancedMode): ApprovalPolicy {
if (mode.permissionMode === 'yolo' || mode.permissionMode === 'read-only') {
// Codex's `never` policy auto-declines MCP elicitations before app-server
// can forward them. Keep command/sandbox prompts disabled for Yolo and
// read-only while allowing auth and structured input to reach HAPI's UI.
return MCP_ELICITATION_ONLY_APPROVAL_POLICY;
}
return resolveCodexPermissionModeConfig(mode.permissionMode).approvalPolicy;
}
@@ -189,17 +189,21 @@ describe('registerAppServerPermissionHandlers', () => {
});
});
it('accepts non-HAPI MCP elicitation requests when live permission mode is yolo', async () => {
it('keeps structured MCP tool approval forms interactive even in yolo mode', async () => {
const { client, handlers } = createClient();
let permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo' = 'default';
const permissionHandler = {
handleToolCall: vi.fn()
};
const onUserInputRequest = vi.fn(async () => ({
decision: 'accept' as const,
answers: { approval: { answers: ['allow'] } }
}));
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: permissionHandler as never,
getPermissionMode: () => permissionMode
getPermissionMode: () => 'yolo',
onUserInputRequest
});
const handler = handlers.get('mcpServer/elicitation/request');
@@ -211,26 +215,26 @@ describe('registerAppServerPermissionHandlers', () => {
serverName: 'qmd',
mode: 'form',
message: 'Allow the qmd MCP server to run tool "status"?',
_meta: null,
_meta: {
codex_approval_kind: 'mcp_tool_call',
tool_name: 'status'
},
requestedSchema: {
type: 'object',
properties: {
approval: {
type: 'string',
enum: ['allow', 'deny']
},
comment: {
type: 'string',
title: 'Optional comment'
}
},
required: ['approval']
}
};
await expect(handler?.(request)).resolves.toEqual({
action: 'cancel',
content: null,
_meta: null
});
permissionMode = 'yolo';
await expect(handler?.(request)).resolves.toEqual({
action: 'accept',
content: {
@@ -239,15 +243,369 @@ describe('registerAppServerPermissionHandlers', () => {
_meta: null
});
permissionMode = 'default';
await expect(handler?.(request)).resolves.toEqual({
action: 'cancel',
expect(onUserInputRequest).toHaveBeenCalledWith({
id: expect.any(String),
input: {
questions: [{
id: 'approval',
header: 'approval',
question: 'Allow the qmd MCP server to run tool "status"?\n\napproval',
required: true,
options: [{ label: 'allow', description: '' }, { label: 'deny', description: '' }]
}, {
id: 'comment',
header: 'Optional comment',
question: 'Allow the qmd MCP server to run tool "status"?\n\nOptional comment',
required: false,
options: []
}]
}
});
});
it('routes message-only MCP tool approvals through the permission handler in yolo mode', async () => {
const { client, handlers } = createClient();
const permissionHandler = {
handleToolCall: vi.fn(async () => ({ decision: 'approved_for_session' as const }))
};
const onUserInputRequest = vi.fn();
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: permissionHandler as never,
getPermissionMode: () => 'yolo',
onUserInputRequest
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'github',
request: {
elicitationId: 'approval-1',
mode: 'form',
message: 'Allow GitHub search?',
requestedSchema: {
type: 'object',
properties: {}
},
_meta: {
codex_approval_kind: 'mcp_tool_call',
tool_name: 'search_issues',
tool_title: 'Search issues',
tool_description: 'Search GitHub issues',
tool_params: { query: 'is:open bug' },
tool_params_display: { query: 'is:open bug' },
persist: ['session', 'always']
}
}
})).resolves.toEqual({
action: 'accept',
content: null,
_meta: { persist: 'session' }
});
expect(permissionHandler.handleToolCall).toHaveBeenCalledWith(
'approval-1',
'search_issues',
{
message: 'Allow GitHub search?',
serverName: 'github',
toolTitle: 'Search issues',
toolDescription: 'Search GitHub issues',
toolParams: { query: 'is:open bug' },
toolParamsDisplay: { query: 'is:open bug' }
}
);
expect(onUserInputRequest).not.toHaveBeenCalled();
});
it('does not persist a yolo MCP tool approval when session persistence is unavailable', async () => {
const { client, handlers } = createClient();
const permissionHandler = {
handleToolCall: vi.fn(async () => ({ decision: 'approved_for_session' as const }))
};
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: permissionHandler as never,
getPermissionMode: () => 'yolo'
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'external',
mode: 'form',
message: 'Allow tool?',
elicitationId: 'approval-2',
requestedSchema: null,
_meta: {
codex_approval_kind: 'mcp_tool_call',
tool_name: 'external_tool',
persist: 'always'
}
})).resolves.toEqual({
action: 'accept',
content: null,
_meta: null
});
});
it('does not auto-accept non-HAPI MCP elicitation requests in safe-yolo mode', async () => {
it.each([
['approved', { action: 'accept', content: null, _meta: null }],
['denied', { action: 'decline', content: null, _meta: null }],
['abort', { action: 'cancel', content: null, _meta: null }]
] as const)('maps %s MCP tool approval decisions without request_user_input', async (decision, expected) => {
const { client, handlers } = createClient();
const permissionHandler = {
handleToolCall: vi.fn(async () => ({ decision }))
};
const onUserInputRequest = vi.fn();
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: permissionHandler as never,
onUserInputRequest
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'external',
message: 'Allow tool?',
elicitationId: 'approval-3',
_meta: {
codex_approval_kind: 'mcp_tool_call',
tool_name: 'external_tool',
persist: ['session']
}
})).resolves.toEqual(expected);
expect(onUserInputRequest).not.toHaveBeenCalled();
});
it('keeps a selected MCP answer when the user also adds a note', async () => {
const { client, handlers } = createClient();
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: { handleToolCall: vi.fn() } as never,
onUserInputRequest: vi.fn(async () => ({
decision: 'accept' as const,
answers: { approval: { answers: ['allow', 'user_note: approved for this task'] } }
}))
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'external',
mode: 'form',
message: 'Approve?',
requestedSchema: {
type: 'object',
properties: {
approval: { type: 'string', enum: ['allow', 'deny'] }
},
required: ['approval']
}
})).resolves.toEqual({
action: 'accept',
content: { approval: 'allow' },
_meta: null
});
});
it('does not coerce a note-only MCP choice into a schema value', async () => {
const { client, handlers } = createClient();
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: { handleToolCall: vi.fn() } as never,
onUserInputRequest: vi.fn(async () => ({
decision: 'accept' as const,
answers: { approved: { answers: ['user_note: please approve'] } }
}))
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'external',
mode: 'form',
message: 'Approve?',
requestedSchema: {
type: 'object',
properties: { approved: { type: 'boolean' } },
required: ['approved']
}
})).resolves.toEqual({
action: 'accept',
content: {},
_meta: null
});
});
it('round-trips array choices and free-text array input', async () => {
const { client, handlers } = createClient();
const onUserInputRequest = vi.fn(async () => ({
decision: 'accept' as const,
answers: {
tags: { answers: ['bug'] },
paths: { answers: ['user_note: src/index.ts'] }
}
}));
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: { handleToolCall: vi.fn() } as never,
onUserInputRequest
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'external',
mode: 'form',
message: 'Choose metadata',
requestedSchema: {
type: 'object',
properties: {
tags: { type: 'array', items: { type: 'string', enum: ['bug', 'feature'] } },
paths: { type: 'array', items: { type: 'string' } }
},
required: ['tags', 'paths']
}
})).resolves.toEqual({
action: 'accept',
content: {
tags: ['bug'],
paths: ['src/index.ts']
},
_meta: null
});
expect(onUserInputRequest).toHaveBeenCalledWith({
id: expect.any(String),
input: {
questions: [{
id: 'tags',
header: 'tags',
question: 'Choose metadata\n\ntags',
required: true,
multiple: true,
options: [{ label: 'bug', description: '' }, { label: 'feature', description: '' }]
}, {
id: 'paths',
header: 'paths',
question: 'Choose metadata\n\npaths',
required: true,
multiple: true,
options: []
}]
}
});
});
it('preserves number, integer, and boolean array item types', async () => {
const { client, handlers } = createClient();
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: { handleToolCall: vi.fn() } as never,
onUserInputRequest: vi.fn(async () => ({
decision: 'accept' as const,
answers: {
scores: { answers: ['2.5'] },
indices: { answers: ['3'] },
flags: { answers: ['true'] }
}
}))
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'external',
mode: 'form',
message: 'Choose typed arrays',
requestedSchema: {
type: 'object',
properties: {
scores: { type: 'array', items: { type: 'number', enum: [1.5, 2.5] } },
indices: { type: 'array', items: { type: 'integer', enum: [2, 3] } },
flags: { type: 'array', items: { type: 'boolean' } }
},
required: ['scores', 'indices', 'flags']
}
})).resolves.toEqual({
action: 'accept',
content: {
scores: [2.5],
indices: [3],
flags: [true]
},
_meta: null
});
});
it('treats an omitted MCP elicitation mode as a form request', async () => {
const { client, handlers } = createClient();
const onUserInputRequest = vi.fn(async () => ({
decision: 'accept' as const,
answers: { nickname: { answers: ['user_note: Codex'] } }
}));
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: { handleToolCall: vi.fn() } as never,
onUserInputRequest
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'external',
message: 'Choose a nickname',
requestedSchema: {
type: 'object',
properties: { nickname: { type: 'string' } },
required: ['nickname']
}
})).resolves.toEqual({
action: 'accept',
content: { nickname: 'Codex' },
_meta: null
});
expect(onUserInputRequest).toHaveBeenCalledOnce();
});
it('gives generic message-only MCP forms a display header instead of exposing the internal id', async () => {
const { client, handlers } = createClient();
const onUserInputRequest = vi.fn(async () => ({
decision: 'accept' as const,
answers: { __mcp_form_confirmation: { answers: ['Continue'] } }
}));
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: { handleToolCall: vi.fn() } as never,
onUserInputRequest
});
const handler = handlers.get('mcpServer/elicitation/request');
await expect(handler?.({
serverName: 'external',
mode: 'form',
message: 'Continue with connector setup?',
requestedSchema: {
type: 'object',
properties: {}
}
})).resolves.toEqual({
action: 'accept',
content: {},
_meta: null
});
expect(onUserInputRequest).toHaveBeenCalledWith({
id: expect.any(String),
input: {
questions: [{
id: '__mcp_form_confirmation',
header: 'Confirmation',
question: 'Continue with connector setup?',
options: [{ label: 'Continue', description: '' }]
}]
}
});
});
it('forwards URL MCP elicitation and preserves a declined response', async () => {
const { client, handlers } = createClient();
const permissionHandler = {
handleToolCall: vi.fn()
@@ -256,7 +614,22 @@ describe('registerAppServerPermissionHandlers', () => {
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: permissionHandler as never,
getPermissionMode: () => 'safe-yolo'
getPermissionMode: () => 'yolo',
onUserInputRequest: vi.fn(async ({ input }) => {
expect(input).toEqual({
url: 'https://example.com/login',
questions: [{
id: '__mcp_url_confirmation',
header: 'Sign in',
question: 'Sign in to continue',
options: [{
label: 'Open sign-in page and continue',
description: 'https://example.com/login'
}]
}]
});
return { decision: 'decline' as const };
})
});
const handler = handlers.get('mcpServer/elicitation/request');
@@ -266,15 +639,14 @@ describe('registerAppServerPermissionHandlers', () => {
threadId: 'thread-1',
turnId: 'turn-1',
serverName: 'external',
mode: 'form',
message: 'Collect data',
_meta: null,
requestedSchema: {
type: 'object',
properties: {},
request: {
mode: 'url',
message: 'Sign in to continue',
url: 'https://example.com/login',
elicitationId: 'auth-1'
}
})).resolves.toEqual({
action: 'cancel',
action: 'decline',
content: null,
_meta: null
});
+253 -14
View File
@@ -12,6 +12,8 @@ type PermissionResult = {
};
type ElicitationSchemaProperty = {
title?: unknown;
description?: unknown;
type?: unknown;
default?: unknown;
enum?: unknown;
@@ -19,6 +21,8 @@ type ElicitationSchemaProperty = {
items?: unknown;
};
type UserInputAnswer = Record<string, string[]> | Record<string, { answers: string[] }>;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
@@ -30,6 +34,17 @@ function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function asWebUrl(value: unknown): string | undefined {
const raw = asString(value);
if (!raw) return undefined;
try {
const url = new URL(raw);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : undefined;
} catch {
return undefined;
}
}
function pickToolName(record: Record<string, unknown>): string {
return asString(record.toolName)
?? asString(record.tool_name)
@@ -150,6 +165,211 @@ function buildAcceptedElicitationContent(params: unknown): Record<string, unknow
return content;
}
function unwrapElicitationRequest(params: unknown): Record<string, unknown> {
const record = asRecord(params) ?? {};
return asRecord(record.request) ?? record;
}
function getMcpToolApprovalMeta(params: unknown): Record<string, unknown> | null {
const record = asRecord(params) ?? {};
const request = unwrapElicitationRequest(params);
const meta = asRecord(request._meta) ?? asRecord(record._meta);
if (meta?.codex_approval_kind !== 'mcp_tool_call') return null;
const mode = asString(request.mode) ?? 'form';
if (mode !== 'form') return null;
const schema = asRecord(request.requestedSchema);
const properties = asRecord(schema?.properties);
if (properties && Object.keys(properties).length > 0) return null;
return meta;
}
function mcpApprovalSupportsSessionPersistence(meta: Record<string, unknown>): boolean {
if (meta.persist === 'session') return true;
return Array.isArray(meta.persist) && meta.persist.includes('session');
}
function buildMcpToolApprovalInput(
params: unknown,
meta: Record<string, unknown>
): { toolName: string; input: Record<string, unknown> } {
const record = asRecord(params) ?? {};
const request = unwrapElicitationRequest(params);
const serverName = asString(record.serverName) ?? asString(request.serverName);
const toolTitle = asString(meta.tool_title);
const toolName = asString(meta.tool_name) ?? toolTitle ?? serverName ?? 'MCP tool';
const input: Record<string, unknown> = {
message: asString(request.message) ?? 'Allow MCP tool call?'
};
if (serverName) input.serverName = serverName;
if (toolTitle) input.toolTitle = toolTitle;
const toolDescription = asString(meta.tool_description);
if (toolDescription) input.toolDescription = toolDescription;
if (meta.tool_params !== undefined) input.toolParams = meta.tool_params;
if (meta.tool_params_display !== undefined) input.toolParamsDisplay = meta.tool_params_display;
return { toolName, input };
}
function mapMcpToolApprovalDecision(
decision: PermissionDecision,
meta: Record<string, unknown>
): { action: 'accept' | 'decline' | 'cancel'; content: null; _meta: { persist: 'session' } | null } {
if (decision === 'denied') {
return { action: 'decline', content: null, _meta: null };
}
if (decision === 'abort') {
return { action: 'cancel', content: null, _meta: null };
}
return {
action: 'accept',
content: null,
_meta: decision === 'approved_for_session' && mcpApprovalSupportsSessionPersistence(meta)
? { persist: 'session' }
: null
};
}
function elicitationChoiceValues(property: Record<string, unknown>): unknown[] {
if (Array.isArray(property.enum)) return property.enum;
if (Array.isArray(property.oneOf)) {
return property.oneOf.map((item) => asRecord(item)?.const).filter((item) => item !== undefined);
}
if (property.type === 'array') {
const items = asRecord(property.items);
if (Array.isArray(items?.enum)) return items.enum;
if (Array.isArray(items?.oneOf)) {
return items.oneOf.map((item) => asRecord(item)?.const).filter((item) => item !== undefined);
}
if (items?.type === 'boolean') return [true, false];
}
if (property.type === 'boolean') return [true, false];
return [];
}
function elicitationOptions(property: Record<string, unknown>): Array<{ label: string; description: string }> {
return elicitationChoiceValues(property).map((value) => ({
label: String(value),
description: ''
}));
}
function buildElicitationUserInput(params: unknown): { questions: unknown[]; url?: string } | null {
const request = unwrapElicitationRequest(params);
const mode = asString(request.mode) ?? 'form';
const message = asString(request.message) ?? 'MCP server requires input';
if (mode === 'url') {
const url = asWebUrl(request.url);
if (!url) return null;
return {
url,
questions: [{
id: '__mcp_url_confirmation',
header: 'Sign in',
question: message,
options: [{ label: 'Open sign-in page and continue', description: url }]
}]
};
}
if (mode !== 'form') return null;
const schema = asRecord(request.requestedSchema);
const properties = asRecord(schema?.properties);
if (!properties) return null;
const required = new Set(
Array.isArray(schema?.required)
? schema.required.filter((value): value is string => typeof value === 'string')
: []
);
const questions = Object.entries(properties).map(([id, rawProperty]) => {
const property = asRecord(rawProperty) ?? {};
const fieldQuestion = asString(property.title) ?? asString(property.description) ?? id;
return {
id,
header: fieldQuestion,
question: `${message}\n\n${fieldQuestion}`,
required: required.has(id),
...(property.type === 'array' ? { multiple: true } : {}),
options: elicitationOptions(property)
};
});
return {
questions: questions.length > 0 ? questions : [{
id: '__mcp_form_confirmation',
header: 'Confirmation',
question: message,
options: [{ label: 'Continue', description: '' }]
}]
};
}
function answerValues(answers: UserInputAnswer, id: string): string[] {
const value = answers[id];
if (Array.isArray(value)) return value;
return asRecord(value)?.answers instanceof Array
? (asRecord(value)?.answers as unknown[]).filter((item): item is string => typeof item === 'string')
: [];
}
function coerceSchemaValue(value: string, schema: Record<string, unknown>): unknown {
if (schema.type === 'boolean') {
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}
if (schema.type === 'number') {
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
if (schema.type === 'integer') {
const number = Number(value);
return Number.isInteger(number) ? number : undefined;
}
return value;
}
function buildElicitationContent(params: unknown, answers: UserInputAnswer): Record<string, unknown> {
const request = unwrapElicitationRequest(params);
if (request.mode === 'url') return {};
const properties = asRecord(asRecord(request.requestedSchema)?.properties) ?? {};
const content: Record<string, unknown> = {};
for (const [id, rawProperty] of Object.entries(properties)) {
const property = asRecord(rawProperty) ?? {};
const values = answerValues(answers, id);
const selectedValues = values.filter((value) => !value.startsWith('user_note: '));
const selected = selectedValues[0];
const note = values.find((value) => value.startsWith('user_note: '))?.slice('user_note: '.length);
const hasSchemaChoices = elicitationChoiceValues(property).length > 0;
if (hasSchemaChoices && selected === undefined) continue;
const value = selected ?? note;
if (value === undefined) continue;
if (property.type === 'array') {
const itemSchema = asRecord(property.items) ?? {};
const rawValues = selectedValues.length > 0
? selectedValues
: note !== undefined
? [note]
: [];
content[id] = rawValues
.map((item) => coerceSchemaValue(item, itemSchema))
.filter((item) => item !== undefined);
} else {
const coerced = coerceSchemaValue(value, property);
if (coerced !== undefined) content[id] = coerced;
}
}
return content;
}
function isHapiBridgeElicitation(params: unknown): boolean {
const record = asRecord(params);
return record?.serverName === 'hapi';
@@ -160,7 +380,7 @@ export function registerAppServerPermissionHandlers(args: {
permissionHandler: CodexPermissionHandler;
getPermissionMode?: () => CodexPermissionMode | undefined;
onUserInputRequest?: (request: { id: string; input: unknown }) => Promise<
| { decision: 'accept'; answers: Record<string, string[]> | Record<string, { answers: string[] }> }
| { decision: 'accept'; answers: UserInputAnswer }
| { decision: 'decline' | 'cancel' }
>;
}): void {
@@ -259,16 +479,36 @@ export function registerAppServerPermissionHandlers(args: {
client.registerRequestHandler('mcpServer/elicitation/request', async (params) => {
const record = asRecord(params) ?? {};
const request = unwrapElicitationRequest(params);
const currentPermissionMode = getPermissionMode?.();
const shouldAccept = isHapiBridgeElicitation(params) || currentPermissionMode === 'yolo';
// HAPI's own bridge only asks for values whose safe defaults are defined by HAPI.
if (isHapiBridgeElicitation(params)) {
return {
action: 'accept',
content: buildAcceptedElicitationContent(request),
_meta: null
};
}
if (!shouldAccept) {
const approvalMeta = getMcpToolApprovalMeta(params);
if (approvalMeta) {
const requestId = asString(request.elicitationId) ?? randomUUID();
const approval = buildMcpToolApprovalInput(params, approvalMeta);
const result = await permissionHandler.handleToolCall(
requestId,
approval.toolName,
approval.input
) as PermissionResult;
return mapMcpToolApprovalDecision(result.decision, approvalMeta);
}
const input = buildElicitationUserInput(params);
if (!onUserInputRequest || !input) {
logger.debug('[CodexAppServer] Cancelling unsupported MCP elicitation request', {
serverName: record.serverName,
mode: record.mode,
message: record.message,
permissionMode: currentPermissionMode ?? 'unknown'
mode: request.mode,
message: request.message,
permissionMode: getPermissionMode?.() ?? 'unknown'
});
return {
@@ -278,16 +518,15 @@ export function registerAppServerPermissionHandlers(args: {
};
}
logger.debug('[CodexAppServer] Accepting MCP elicitation request', {
serverName: record.serverName,
mode: record.mode,
message: record.message,
permissionMode: currentPermissionMode ?? 'unknown'
});
const requestId = asString(request.elicitationId) ?? randomUUID();
const result = await onUserInputRequest({ id: requestId, input });
if (result.decision !== 'accept') {
return { action: result.decision === 'decline' ? 'decline' : 'cancel', content: null, _meta: null };
}
return {
action: 'accept',
content: buildAcceptedElicitationContent(params),
content: buildElicitationContent(params, result.answers),
_meta: null
};
});
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CodexPermissionMode } from '@hapi/protocol/types';
import type { ApprovalPolicy, SandboxMode, SandboxPolicy } from '../appServerTypes';
import type { ApprovalPolicyPreset, SandboxMode, SandboxPolicy } from '../appServerTypes';
export type CodexPermissionModeConfig = {
approvalPolicy: ApprovalPolicy;
approvalPolicy: ApprovalPolicyPreset;
sandbox: SandboxMode;
sandboxPolicy: SandboxPolicy;
};