🐛 fix(codex): reuse Linear inspector for MCP calls (#15738)

* 🐛 fix(codex): reuse Linear inspector for MCP calls

* 🐛 fix(codex): gate generic Linear MCP labels
This commit is contained in:
Arvin Xu
2026-06-13 11:46:16 +08:00
committed by GitHub
parent e5a27dc97c
commit f60d1fe8dd
3 changed files with 177 additions and 11 deletions
@@ -20,7 +20,7 @@ import {
getMcpToolName,
} from './mcpToolUtils';
const LINEAR_TOOL_NAME_SET = new Set<string>(LINEAR_TOOL_NAMES);
const LINEAR_TOOL_NAME_SET = new Set<string>([...LINEAR_TOOL_NAMES, 'fetch', 'search']);
const SharedLinearInspector = LinearInspector as ComponentType<
BuiltinInspectorProps<Record<string, unknown>>
>;
@@ -33,17 +33,23 @@ const McpToolInspector = memo<BuiltinInspectorProps<CodexMcpToolArgs, CodexMcpTo
});
const server = getMcpServer(args, pluginState) || getMcpServer(partialArgs);
const tool = getMcpToolName(args, pluginState) || getMcpToolName(partialArgs);
const linearApiName = getCodexLinearMcpApiName(tool);
const input = getMcpInputRecord(args, pluginState);
const partialInput = getMcpInputRecord(partialArgs);
const linearApiName = getCodexLinearMcpApiName({
input: input || partialInput,
server,
toolName: tool,
});
if (LINEAR_TOOL_NAME_SET.has(linearApiName)) {
return (
<SharedLinearInspector
apiName={linearApiName}
args={getMcpInputRecord(args, pluginState) || {}}
args={input || {}}
identifier={'codex'}
isArgumentsStreaming={isArgumentsStreaming}
isLoading={isLoading}
partialArgs={getMcpInputRecord(partialArgs) || {}}
partialArgs={partialInput || {}}
pluginState={pluginState}
/>
);
@@ -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',
});
});
});
@@ -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<string, string> = {
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<string, unknown> | 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<string, unknown>;
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;
};