mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
🐛 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:
@@ -20,7 +20,7 @@ import {
|
|||||||
getMcpToolName,
|
getMcpToolName,
|
||||||
} from './mcpToolUtils';
|
} 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<
|
const SharedLinearInspector = LinearInspector as ComponentType<
|
||||||
BuiltinInspectorProps<Record<string, unknown>>
|
BuiltinInspectorProps<Record<string, unknown>>
|
||||||
>;
|
>;
|
||||||
@@ -33,17 +33,23 @@ const McpToolInspector = memo<BuiltinInspectorProps<CodexMcpToolArgs, CodexMcpTo
|
|||||||
});
|
});
|
||||||
const server = getMcpServer(args, pluginState) || getMcpServer(partialArgs);
|
const server = getMcpServer(args, pluginState) || getMcpServer(partialArgs);
|
||||||
const tool = getMcpToolName(args, pluginState) || getMcpToolName(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)) {
|
if (LINEAR_TOOL_NAME_SET.has(linearApiName)) {
|
||||||
return (
|
return (
|
||||||
<SharedLinearInspector
|
<SharedLinearInspector
|
||||||
apiName={linearApiName}
|
apiName={linearApiName}
|
||||||
args={getMcpInputRecord(args, pluginState) || {}}
|
args={input || {}}
|
||||||
identifier={'codex'}
|
identifier={'codex'}
|
||||||
isArgumentsStreaming={isArgumentsStreaming}
|
isArgumentsStreaming={isArgumentsStreaming}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
partialArgs={getMcpInputRecord(partialArgs) || {}}
|
partialArgs={partialInput || {}}
|
||||||
pluginState={pluginState}
|
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_PREFIX = 'linear_';
|
||||||
const LINEAR_CODEX_SERVER_PREFIX = 'server_';
|
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 SERVER_KEYS = ['server', 'serverName', 'server_name', 'connector', 'connector_id'];
|
||||||
const TOOL_KEYS = ['tool', 'toolName', 'tool_name', 'name'];
|
const TOOL_KEYS = ['tool', 'toolName', 'tool_name', 'name'];
|
||||||
const INPUT_KEYS = ['arguments', 'args', 'input', 'params', 'parameters'];
|
const INPUT_KEYS = ['arguments', 'args', 'input', 'params', 'parameters'];
|
||||||
@@ -83,17 +89,72 @@ export const getMcpInputRecord = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCodexLinearMcpApiName = (toolName: string) => {
|
const normalizeCodexLinearToolName = (toolName: string) => {
|
||||||
if (!toolName) return '';
|
if (!toolName) return { apiName: '', hasLinearPrefix: false };
|
||||||
|
|
||||||
let apiName = toolName.trim();
|
let apiName = toolName.trim();
|
||||||
if (apiName.startsWith(LINEAR_CODEX_PREFIX)) {
|
let hasLinearPrefix = false;
|
||||||
apiName = apiName.slice(LINEAR_CODEX_PREFIX.length);
|
|
||||||
}
|
let changed = true;
|
||||||
if (apiName.startsWith(LINEAR_CODEX_SERVER_PREFIX)) {
|
while (changed) {
|
||||||
apiName = apiName.slice(LINEAR_CODEX_SERVER_PREFIX.length);
|
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;
|
return apiName;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user