From f60d1fe8dd173908f21336963bb946807f52afa3 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sat, 13 Jun 2026 11:46:16 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(codex):=20reuse=20Linear=20i?= =?UTF-8?q?nspector=20for=20MCP=20calls=20(#15738)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix(codex): reuse Linear inspector for MCP calls * 🐛 fix(codex): gate generic Linear MCP labels --- .../src/codex/McpToolInspector.tsx | 14 ++- .../src/codex/mcpToolUtils.test.ts | 99 +++++++++++++++++++ .../builtin-tools/src/codex/mcpToolUtils.ts | 75 ++++++++++++-- 3 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 packages/builtin-tools/src/codex/mcpToolUtils.test.ts diff --git a/packages/builtin-tools/src/codex/McpToolInspector.tsx b/packages/builtin-tools/src/codex/McpToolInspector.tsx index 8e233d378a..88e21c3f7e 100644 --- a/packages/builtin-tools/src/codex/McpToolInspector.tsx +++ b/packages/builtin-tools/src/codex/McpToolInspector.tsx @@ -20,7 +20,7 @@ import { getMcpToolName, } from './mcpToolUtils'; -const LINEAR_TOOL_NAME_SET = new Set(LINEAR_TOOL_NAMES); +const LINEAR_TOOL_NAME_SET = new Set([...LINEAR_TOOL_NAMES, 'fetch', 'search']); const SharedLinearInspector = LinearInspector as ComponentType< BuiltinInspectorProps> >; @@ -33,17 +33,23 @@ const McpToolInspector = memo ); diff --git a/packages/builtin-tools/src/codex/mcpToolUtils.test.ts b/packages/builtin-tools/src/codex/mcpToolUtils.test.ts new file mode 100644 index 0000000000..43d1d1c699 --- /dev/null +++ b/packages/builtin-tools/src/codex/mcpToolUtils.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { getCodexLinearMcpApiName, getMcpInputRecord } from './mcpToolUtils'; + +describe('getCodexLinearMcpApiName', () => { + it('maps Codex Apps fetch calls to entity-specific Linear APIs', () => { + expect( + getCodexLinearMcpApiName({ input: { id: 'issue:LOBE-10205' }, toolName: 'linear_fetch' }), + ).toBe('get_issue'); + expect( + getCodexLinearMcpApiName({ input: { id: 'project:Desktop' }, toolName: 'linear_fetch' }), + ).toBe('get_project'); + expect( + getCodexLinearMcpApiName({ input: { id: 'initiative:AI' }, toolName: 'linear_fetch' }), + ).toBe('get_initiative'); + expect( + getCodexLinearMcpApiName({ + input: { id: 'document:agent-runtime' }, + toolName: 'linear_fetch', + }), + ).toBe('get_document'); + }); + + it('keeps generic Codex Apps Linear search and unknown fetch names renderable', () => { + expect( + getCodexLinearMcpApiName({ + input: { query: 'agent runtime' }, + toolName: 'linear_search', + }), + ).toBe('search'); + expect( + getCodexLinearMcpApiName({ input: { id: 'unknown:123' }, toolName: 'linear_fetch' }), + ).toBe('fetch'); + }); + + it('normalizes MCP-prefixed and underscored Linear tool names', () => { + expect(getCodexLinearMcpApiName({ toolName: '_get_issue' })).toBe('get_issue'); + expect(getCodexLinearMcpApiName({ toolName: 'linear__get_issue' })).toBe('get_issue'); + expect(getCodexLinearMcpApiName({ toolName: 'server_linear_get_issue' })).toBe('get_issue'); + }); + + it('treats bare issue identifiers as issue fetch calls', () => { + expect( + getCodexLinearMcpApiName({ input: { id: 'LOBE-10205' }, toolName: 'linear_fetch' }), + ).toBe('get_issue'); + }); + + it('does not treat generic fetch or search from other MCP servers as Linear', () => { + expect( + getCodexLinearMcpApiName({ + input: { query: 'agent runtime' }, + server: 'node_repl', + toolName: 'search', + }), + ).toBe(''); + expect( + getCodexLinearMcpApiName({ + input: { id: 'LOBE-10205' }, + server: 'github', + toolName: 'fetch', + }), + ).toBe(''); + }); + + it('allows bare fetch only when the input has a Linear entity prefix', () => { + expect( + getCodexLinearMcpApiName({ + input: { id: 'issue:LOBE-10205' }, + server: 'node_repl', + toolName: 'fetch', + }), + ).toBe('get_issue'); + }); + + it('allows generic fetch or search from a Linear server', () => { + expect( + getCodexLinearMcpApiName({ + input: { query: 'agent runtime' }, + server: 'mcp__codex_apps__linear', + toolName: 'search', + }), + ).toBe('search'); + expect( + getCodexLinearMcpApiName({ + input: { id: 'unknown:123' }, + server: 'mcp__codex_apps__linear', + toolName: 'fetch', + }), + ).toBe('fetch'); + }); +}); + +describe('getMcpInputRecord', () => { + it('parses JSON string MCP arguments', () => { + expect(getMcpInputRecord({ arguments: '{"id":"issue:LOBE-10205"}' })).toEqual({ + id: 'issue:LOBE-10205', + }); + }); +}); diff --git a/packages/builtin-tools/src/codex/mcpToolUtils.ts b/packages/builtin-tools/src/codex/mcpToolUtils.ts index fae811e61f..c6c105d230 100644 --- a/packages/builtin-tools/src/codex/mcpToolUtils.ts +++ b/packages/builtin-tools/src/codex/mcpToolUtils.ts @@ -24,6 +24,12 @@ export interface FormattedMcpValue { const LINEAR_CODEX_PREFIX = 'linear_'; const LINEAR_CODEX_SERVER_PREFIX = 'server_'; +const CODEX_LINEAR_FETCH_API_BY_ENTITY: Record = { + document: 'get_document', + initiative: 'get_initiative', + issue: 'get_issue', + project: 'get_project', +}; const SERVER_KEYS = ['server', 'serverName', 'server_name', 'connector', 'connector_id']; const TOOL_KEYS = ['tool', 'toolName', 'tool_name', 'name']; const INPUT_KEYS = ['arguments', 'args', 'input', 'params', 'parameters']; @@ -83,17 +89,72 @@ export const getMcpInputRecord = ( } }; -export const getCodexLinearMcpApiName = (toolName: string) => { - if (!toolName) return ''; +const normalizeCodexLinearToolName = (toolName: string) => { + if (!toolName) return { apiName: '', hasLinearPrefix: false }; let apiName = toolName.trim(); - if (apiName.startsWith(LINEAR_CODEX_PREFIX)) { - apiName = apiName.slice(LINEAR_CODEX_PREFIX.length); - } - if (apiName.startsWith(LINEAR_CODEX_SERVER_PREFIX)) { - apiName = apiName.slice(LINEAR_CODEX_SERVER_PREFIX.length); + let hasLinearPrefix = false; + + let changed = true; + while (changed) { + changed = false; + + if (apiName.startsWith(LINEAR_CODEX_PREFIX)) { + apiName = apiName.slice(LINEAR_CODEX_PREFIX.length); + hasLinearPrefix = true; + changed = true; + } + + if (apiName.startsWith(LINEAR_CODEX_SERVER_PREFIX)) { + apiName = apiName.slice(LINEAR_CODEX_SERVER_PREFIX.length); + changed = true; + } + + while (apiName.startsWith('_')) { + apiName = apiName.slice(1); + changed = true; + } } + return { apiName, hasLinearPrefix }; +}; + +const isLinearServerName = (server?: string) => + normalizeString(server) + .split(/[^a-z0-9]+/iu) + .some((part) => part.toLowerCase() === 'linear'); + +const getCodexLinearFetchApiName = ( + input: Record | undefined, + isLinearContext: boolean, +) => { + const id = normalizeString(input?.id); + const entityPrefix = id.includes(':') ? id.slice(0, id.indexOf(':')).toLowerCase() : ''; + const prefixedApiName = CODEX_LINEAR_FETCH_API_BY_ENTITY[entityPrefix]; + if (prefixedApiName) return prefixedApiName; + + if (!isLinearContext) return ''; + + if (/^[A-Z][A-Z0-9]+-\d+$/u.test(id)) return 'get_issue'; + + return 'fetch'; +}; + +export const getCodexLinearMcpApiName = ({ + input, + server, + toolName, +}: { + input?: Record; + server?: string; + toolName: string; +}) => { + const { apiName, hasLinearPrefix } = normalizeCodexLinearToolName(toolName); + const isLinearContext = hasLinearPrefix || isLinearServerName(server); + + if (apiName === 'fetch') return getCodexLinearFetchApiName(input, isLinearContext); + if (apiName === 'search') return isLinearContext ? apiName : ''; + return apiName; };