Skip to content

feat(cli): Model Context Protocol (MCP) support — client, TUI overlay, config & basic test #824

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
],
"dependencies": {
"@inkjs/ui": "^2.0.0",
"@modelcontextprotocol/sdk": "^1.11.0",
"chalk": "^5.2.0",
"diff": "^7.0.0",
"dotenv": "^16.1.4",
Expand Down
19 changes: 11 additions & 8 deletions codex-cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ResponseItem } from "openai/resources/responses/responses";
import TerminalChat from "./components/chat/terminal-chat";
import TerminalChatPastRollout from "./components/chat/terminal-chat-past-rollout";
import { checkInGit } from "./utils/check-in-git";
import { MCPProvider } from "./utils/mcp";
import { CLI_VERSION, type TerminalChatSession } from "./utils/session.js";
import { onExit } from "./utils/terminal";
import { ConfirmInput } from "@inkjs/ui";
Expand Down Expand Up @@ -94,13 +95,15 @@ export default function App({
}

return (
<TerminalChat
config={config}
prompt={prompt}
imagePaths={imagePaths}
approvalPolicy={approvalPolicy}
additionalWritableRoots={additionalWritableRoots}
fullStdout={fullStdout}
/>
<MCPProvider>
<TerminalChat
config={config}
prompt={prompt}
imagePaths={imagePaths}
approvalPolicy={approvalPolicy}
additionalWritableRoots={additionalWritableRoots}
fullStdout={fullStdout}
/>
</MCPProvider>
);
}
24 changes: 23 additions & 1 deletion codex-cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { AppRollout } from "./app";
import type { ApprovalPolicy } from "./approvals";
import type { CommandConfirmation } from "./utils/agent/agent-loop";
import type { AppConfig } from "./utils/config";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { ResponseItem } from "openai/resources/responses/responses";
import type { ReasoningEffort } from "openai/resources.mjs";

Expand All @@ -25,7 +26,8 @@ import {
INSTRUCTIONS_FILEPATH,
} from "./utils/config";
import { createInputItem } from "./utils/input-utils";
import { initLogger } from "./utils/logger/log";
import { initLogger, log } from "./utils/logger/log";
import { MCPManager } from "./utils/mcp";
import { isModelSupportedForResponses } from "./utils/model-utils.js";
import { parseToolCall } from "./utils/parsers";
import { onExit, setInkRenderer } from "./utils/terminal";
Expand Down Expand Up @@ -509,13 +511,30 @@ async function runQuietMode({
additionalWritableRoots: ReadonlyArray<string>;
config: AppConfig;
}): Promise<void> {
// Initialize MCP Manager for quiet mode
const mcpManager = new MCPManager();
let mcpTools: Array<Tool> = [];

try {
// Initialize MCP connections
await mcpManager.initialize();
mcpTools = await mcpManager.getFlattendTools();
// eslint-disable-next-line no-console
console.log(`Initialized MCP Manager with ${mcpTools.length} tools`);
} catch (error) {
// Log error but continue execution
log(`Failed to initialize MCP Manager: ${error}`);
}

const agent = new AgentLoop({
model: config.model,
config: config,
instructions: config.instructions,
provider: config.provider,
approvalPolicy,
additionalWritableRoots,
mcpTools,
mcpManager,
disableResponseStorage: config.disableResponseStorage,
onItem: (item: ResponseItem) => {
// eslint-disable-next-line no-console
Expand All @@ -541,6 +560,9 @@ async function runQuietMode({

const inputItem = await createInputItem(prompt, imagePaths);
await agent.run([inputItem]);

// Cleanup MCP resources
mcpManager.disconnectAll();
}

const exit = () => {
Expand Down
112 changes: 68 additions & 44 deletions codex-cli/src/components/chat/terminal-chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { MultilineTextEditorHandle } from "./multiline-editor";
import type { ReviewDecision } from "../../utils/agent/review.js";
import type { FileSystemSuggestion } from "../../utils/file-system-suggestions.js";
import type { HistoryEntry } from "../../utils/storage/command-history.js";
import type { ColorName } from "chalk";
import type {
ResponseInputItem,
ResponseItem,
Expand Down Expand Up @@ -30,6 +31,7 @@ import React, {
Fragment,
useEffect,
useRef,
useMemo,
} from "react";
import { useInterval } from "use-interval";

Expand All @@ -39,6 +41,32 @@ const suggestions = [
"are there any bugs in my code?",
];

type Props = {
loading: boolean;
isNew: boolean;
setItems: React.Dispatch<React.SetStateAction<Array<ResponseItem>>>;
setLastResponseId: React.Dispatch<React.SetStateAction<string | null>>;
submitInput: (input: Array<ResponseInputItem>) => void;
confirmationPrompt: React.ReactNode | null;
explanation?: string;
openOverlay: () => void;
openModelOverlay: () => void;
openApprovalOverlay: () => void;
openHelpOverlay: () => void;
openDiffOverlay: () => void;
openMcpOverlay: () => void;
active: boolean;
onCompact: () => void;
interruptAgent: () => void;
submitConfirmation: (
decision: ReviewDecision,
customDenyMessage?: string,
) => void;
contextLeftPercent: number;
items?: Array<ResponseItem>;
thinkingSeconds: number;
};

export default function TerminalChatInput({
isNew,
loading,
Expand All @@ -54,36 +82,13 @@ export default function TerminalChatInput({
openApprovalOverlay,
openHelpOverlay,
openDiffOverlay,
openMcpOverlay,
onCompact,
interruptAgent,
active,
thinkingSeconds,
items = [],
}: {
isNew: boolean;
loading: boolean;
submitInput: (input: Array<ResponseInputItem>) => void;
confirmationPrompt: React.ReactNode | null;
explanation?: string;
submitConfirmation: (
decision: ReviewDecision,
customDenyMessage?: string,
) => void;
setLastResponseId: (lastResponseId: string) => void;
setItems: React.Dispatch<React.SetStateAction<Array<ResponseItem>>>;
contextLeftPercent: number;
openOverlay: () => void;
openModelOverlay: () => void;
openApprovalOverlay: () => void;
openHelpOverlay: () => void;
openDiffOverlay: () => void;
onCompact: () => void;
interruptAgent: () => void;
active: boolean;
thinkingSeconds: number;
// New: current conversation items so we can include them in bug reports
items?: Array<ResponseItem>;
}): React.ReactElement {
}: Props): React.ReactElement {
// Slash command suggestion index
const [selectedSlashSuggestion, setSelectedSlashSuggestion] =
useState<number>(0);
Expand Down Expand Up @@ -304,6 +309,9 @@ export default function TerminalChatInput({
case "/clearhistory":
onSubmit(cmd);
break;
case "/mcp":
openMcpOverlay();
break;
default:
break;
}
Expand Down Expand Up @@ -515,7 +523,7 @@ export default function TerminalChatInput({
} else if (inputValue === "/clear" || inputValue === "clear") {
setInput("");
setSessionId("");
setLastResponseId("");
setLastResponseId(null);

// Clear the terminal screen (including scrollback) before resetting context.
clearTerminal();
Expand Down Expand Up @@ -735,6 +743,37 @@ export default function TerminalChatInput({
],
);

// Handle help text that shows context usage
const contextInfo = useMemo(() => {
if (contextLeftPercent === undefined) {
return null;
}

// Show remaining context as percentage
let contextColor: ColorName = "green";

if (contextLeftPercent <= 25) {
return (
<Text>
<Text color="red">
{Math.round(contextLeftPercent)}% context left — send "/compact" to
condense context
</Text>
</Text>
);
} else if (contextLeftPercent <= 40) {
contextColor = "yellow";
}

return (
<Text>
<Text color={contextColor}>
{Math.round(contextLeftPercent)}% context left
</Text>
</Text>
);
}, [contextLeftPercent]);

if (confirmationPrompt) {
return (
<TerminalChatCommandReview
Expand All @@ -756,7 +795,7 @@ export default function TerminalChatInput({
<TerminalChatInputThinking
onInterrupt={interruptAgent}
active={active}
thinkingSeconds={thinkingSeconds}
thinkingSeconds={thinkingSeconds ?? 0}
/>
) : (
<Box paddingX={1}>
Expand Down Expand Up @@ -845,23 +884,8 @@ export default function TerminalChatInput({
) : (
<Text dimColor>
ctrl+c to exit | "/" to see commands | enter to send
{contextLeftPercent > 25 && (
<>
{" — "}
<Text color={contextLeftPercent > 40 ? "green" : "yellow"}>
{Math.round(contextLeftPercent)}% context left
</Text>
</>
)}
{contextLeftPercent <= 25 && (
<>
{" — "}
<Text color="red">
{Math.round(contextLeftPercent)}% context left — send
"/compact" to condense context
</Text>
</>
)}
{" — "}
{contextInfo}
</Text>
)}
</Box>
Expand Down
87 changes: 80 additions & 7 deletions codex-cli/src/components/chat/terminal-chat-response-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { parse, setOptions } from "marked";
import TerminalRenderer from "marked-terminal";
import React, { useEffect, useMemo } from "react";

const MAX_LINES = 6;
const MAX_CHARS_PER_LINE = 50;

export default function TerminalChatResponseItem({
item,
fullStdout = false,
Expand Down Expand Up @@ -169,6 +172,77 @@ function TerminalChatResponseToolCall({
);
}

/**
* Truncates a single line if it exceeds the maximum length
* @param line The line to potentially truncate
* @param maxCharsPerLine Maximum characters allowed per line
* @returns Truncated line with ellipsis if needed
*/
function truncateLine(line: string, maxCharsPerLine: number): string {
return line.length > maxCharsPerLine
? `${line.slice(0, maxCharsPerLine)}...`
: line;
}

/**
* Truncates an array of lines if it exceeds the maximum number of lines
* @param lines Array of content lines
* @param maxLines Maximum number of lines to show
* @param maxCharsPerLine Maximum characters allowed per line
* @returns Truncated content with line count message
*/
function truncateByLineCount(
lines: Array<string>,
maxLines: number,
maxCharsPerLine: number,
): string {
const head = lines.slice(0, maxLines);
const truncatedHead = head.map((line) => truncateLine(line, maxCharsPerLine));
const remaining = lines.length - maxLines;
return [...truncatedHead, `... (${remaining} more lines)`].join("\n");
}

/**
* Truncates an array of lines based on total character length
* @param lines Array of content lines
* @param maxCharsPerLine Maximum characters allowed per line
* @returns Truncated content with line count message
*/
function truncateByCharCount(
lines: Array<string>,
maxCharsPerLine: number,
): string {
const totalLength = lines.reduce((acc, line) => acc + line.length, 0);
if (totalLength > maxCharsPerLine) {
const truncatedLines = lines.map((line) =>
truncateLine(line, maxCharsPerLine),
);
return [...truncatedLines, `... (${lines.length} more lines)`].join("\n");
}
return lines.join("\n");
}

/**
* Truncates output content based on line count and character count
* @param content Original content to truncate
* @param maxLines Maximum number of lines to show
* @param maxCharsPerLine Maximum characters allowed per line
* @returns Truncated content
*/
function truncateOutput(
content: string,
maxLines: number,
maxCharsPerLine: number,
): string {
const lines = content.split("\n");

if (lines.length > maxLines) {
return truncateByLineCount(lines, maxLines, maxCharsPerLine);
}

return truncateByCharCount(lines, maxCharsPerLine);
}

function TerminalChatResponseToolCallOutput({
message,
fullStdout,
Expand All @@ -177,7 +251,7 @@ function TerminalChatResponseToolCallOutput({
fullStdout: boolean;
}) {
const { output, metadata } = parseToolCallOutput(message.output);
const { exit_code, duration_seconds } = metadata;
const { exit_code, duration_seconds } = metadata || {};
const metadataInfo = useMemo(
() =>
[
Expand All @@ -192,12 +266,11 @@ function TerminalChatResponseToolCallOutput({
);
let displayedContent = output;
if (message.type === "function_call_output" && !fullStdout) {
const lines = displayedContent.split("\n");
if (lines.length > 4) {
const head = lines.slice(0, 4);
const remaining = lines.length - 4;
displayedContent = [...head, `... (${remaining} more lines)`].join("\n");
}
displayedContent = truncateOutput(
displayedContent,
MAX_LINES,
MAX_CHARS_PER_LINE,
);
}

// -------------------------------------------------------------------------
Expand Down
Loading