DEV Community

Cover image for How many tokens does osFoundry's MCP tool list cost Codex, and how do you trim it per project?
BBT
BBT

Posted on

How many tokens does osFoundry's MCP tool list cost Codex, and how do you trim it per project?

This is the call an MCP client makes right after the handshake, sent with curl to osFoundry's server from a Mac in Tokyo on 2026-11-06. No token is needed for it:

$ curl -s -D - -X POST https://api.osfoundry.io/api/v1/external-mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

HTTP/2 200
content-type: application/json
content-length: 45368

{"jsonrpc":"2.0","id":2,"result":{"tools":[ …59 tools… ]}}
Enter fullscreen mode Exit fullscreen mode

One entry out of the 59:

{"name": "read_note_tab",
 "title": "Read one Note tab",
 "description": "Read a selected Note, Markdown, Slides, Sheet, Mermaid, Board, Whiteboard, Design, or Artifacts tab by instance ID. Use list_note_attachments for Files and Sources.",
 "inputSchema": {"type": "object",
                 "properties": {"note_id": {"type": "string"}, "tab_id": {"type": "string"}},
                 "required": ["note_id", "tab_id"], "additionalProperties": false},
 "annotations": {"title": "Read one Note tab", "readOnlyHint": true,
                 "openWorldHint": false, "destructiveHint": false},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:read"]}]}
Enter fullscreen mode Exit fullscreen mode

Codex CLI connects to this server with a [mcp_servers.osfoundry] table and a url. Unless the client defers some of them, every request Codex makes to the model then carries these 59 declarations in some form. The questions below are the ones you hit when you decide how many of them it should carry.

What does Codex fetch before the first turn?

Three POSTs to the same URL: initialize, the notifications/initialized notification, and tools/list. The live server answers them like this:

Request Status Body
initialize 200 308 bytes: protocol 2025-06-18, osfoundry 0.1.0, tools.listChanged: false, instructions
notifications/initialized 202 empty
tools/list 200 45,368 bytes, 59 tools
tools/call without a token 401 WWW-Authenticate with resource_metadata

listChanged: false means the server won't announce changes to the list mid-session. The list Codex gets at startup is the list for the session.

The instructions string is 125 characters: "Use list_granted_resources first. Every Note edit must use the exact tab ID. Local-only data is unavailable to cloud clients." OpenAI's Codex docs say Codex reads this field as server-wide guidance and that the first 512 characters should stand on their own. This one fits with room to spare.

The 401 is where OAuth starts. That part, and why osFoundry checks access on every call, is covered in a separate post on the same endpoint from Claude's side. Here only the catalogue matters.

How many tokens are 59 tools?

What reaches the model isn't the raw JSON. Codex turns each MCP tool into a function declaration. The name, the description and the input schema are what the model needs. title, annotations and securitySchemes are protocol metadata the client uses itself. OpenAI doesn't publish how tool declarations are counted on its side, so the numbers below count the payload, not the bill.

Measured with tiktoken 0.14.0 and the o200k_base encoding, on compact JSON:

What Tokens
Raw tools/list result, all fields 10,494
59 declarations (name with mcp__osfoundry__ prefix, description, schema) 7,609
of which descriptions 2,194
of which input schemas 4,118
of which the mcp__osfoundry__ prefixes 413
Metadata that stays in the client (title, annotations, securitySchemes) 3,590

The median declaration is 116 tokens. The largest is edit_note_tab at 336, then send_channel_message (237) and upload_linked_chat_artifact (233). The smallest is list_granted_resources at 57.

By scope: the 30 osfoundry:read tools come to 2,906 tokens, the 26 osfoundry:write tools to 4,378, and the 3 osfoundry:run tools to 329. Writes cost more per tool because they carry expected_version, idempotency_key and the content being written.

About 7,600 tokens sit in front of every request whether the model calls osFoundry or not. Cached prompt prefixes make that cheaper than it sounds. It still takes up context, and 59 names compete for the model's attention when it decides which tool fits.

Where does Codex apply enabled_tools?

The keys are documented on OpenAI's Codex MCP page: enabled_tools is an allow list, disabled_tools a deny list "applied after enabled_tools". The implementation is in codex-rs/codex-mcp/src/tools.rs in the openai/codex repository:

pub(crate) fn allows(&self, tool_name: &str) -> bool {
    if let Some(enabled) = &self.enabled
        && !enabled.contains(tool_name)
    {
        return false;
    }

    !self.disabled.contains(tool_name)
}

pub(crate) fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> {
    tools
        .into_iter()
        .filter(|tool| filter.allows(&tool.tool.name))
        .collect()
}
Enter fullscreen mode Exit fullscreen mode

Two consequences. The filter compares tool.tool.name, the raw MCP name, so you write read_note_tab in config.toml, not mcp__osfoundry__read_note_tab. The prefix is added later, in the same file, when the model-visible names are built. And a set lookup never fails. A name that doesn't exist in tools/list matches nothing and says nothing.

The filter runs in the client after tools/list has arrived. It shortens the prompt. It doesn't shorten the download, and the server knows nothing about it.

Rebuilding the client side

No codex binary is installed on the Mac this was measured on (Apple M1). catalog.py stands in for Codex's startup. It's 92 lines and runs with uv run --python 3.12 --with tiktoken. It reads [mcp_servers.*] tables from a TOML file, sends the three requests to the live endpoint over http.client, applies the same allow-then-deny rule to the raw names, builds mcp__osfoundry__<tool> declarations and counts them:

kept = [t for t in tools if (allow is None or t["name"] in allow) and t["name"] not in deny]
decls = [model_decl("osfoundry", t) for t in kept]
unknown = sorted(((allow or set()) | deny) - names)
Enter fullscreen mode Exit fullscreen mode

The profile file has one table per configuration. A real config.toml would have one [mcp_servers.osfoundry] table, global or per repository:

[mcp_servers.notes_kb_records]
url = "https://api.osfoundry.io/api/v1/external-mcp"
enabled_tools = [
  "list_granted_resources", "list_project_resources", "list_note_tabs",
  "read_note_tab", "read_sheet_range", "search_knowledge_base",
  "read_knowledge_article", "list_record_items", "read_record_item",
]

[mcp_servers.deny_list]
url = "https://api.osfoundry.io/api/v1/external-mcp"
disabled_tools = ["request_session_run", "run_graph_session", "cancel_graph_run",
                  "propose_gitnest_work", "…the other propose_ tools…",
                  "join_agent_network", "send_agent_message", "wait_for_messages",
                  "list_agents", "set_status", "claim_connected_profile"]

[mcp_servers.typo]
url = "https://api.osfoundry.io/api/v1/external-mcp"
enabled_tools = ["list_granted_resources", "list_project_resources",
                 "read_note", "search_knowledge_base"]
Enter fullscreen mode Exit fullscreen mode

Output:

$ uv run --python 3.12 --with tiktoken python catalog.py profiles.toml --runs 10
status (200, 202, 200)  initialize 308 B  tools/list 45368 B  59 tools  instructions 125 chars
raw tools/list result: 10494 tokens (o200k_base)

profile                  tools   bytes  tokens  unknown names
all_59                      59   33559    7609  -
read_only                   30   13181    2906  -
notes_kb_records             9    3937     881  -
coding_project              16    7245    1604  -
deny_list                   40   20943    4693  -
typo                         3    1449     309  read_note
Enter fullscreen mode Exit fullscreen mode

read_only is every tool with readOnlyHint: true. coding_project is the nine Note/KB/Record readers plus five GitNest readers, edit_record_item and post_session_note. deny_list removes the 19 tools a coding assistant with a local checkout has little use for: the three run tools, the ten propose_… tools and six agent-network and profile tools.

Going from 59 tools to the nine readers cuts the declarations from 7,609 to 881 tokens, 88% less. The deny list only gets to 4,693, because writes are the expensive tools and it keeps most of them. An allow list is the more predictable choice. It also stays short when the server adds tools, which is the reason to prefer it.

The typo row is the failure you'll actually see. read_note isn't a tool (read_note_tab is), so the profile ends up with three tools, and nothing complains. Codex will happily list the Note and then have no way to open it. The simulator prints unknown names. Codex doesn't, as far as the documentation and the filter code show.

What does the handshake cost in time?

catalog.py --runs 10 timed the three requests end to end, twice, once on one TLS connection and once with a new connection per request:

Batch (n=10 each) initialize median tools/list median whole startup median min–max
one connection, batch 1 482 ms 826 ms 1,515 ms 1,034–2,899 ms
new connection, batch 1 521 ms 704 ms 1,715 ms 1,122–3,107 ms
one connection, batch 2 454 ms 828 ms 1,441 ms 741–1,915 ms
new connection, batch 2 432 ms 478 ms 1,257 ms 1,088–1,557 ms

Connection reuse made no consistent difference. Variation between runs was larger than the effect. Filtering doesn't change this number at all, since Codex downloads the full list either way.

Three documented Codex settings meet this number. startup_timeout_sec defaults to 10 seconds, and the slowest run here took 3.1 s. mcp_optional_startup_grace_ms defaults to 1,000 ms and, per the docs, "controls how long Codex waits for optional MCP servers when building the initial tool catalog". Every startup measured here took longer than that. If osFoundry's tools must be there from the first turn, the documented levers are required = true, which makes startup wait for the server and fail if it isn't ready, and startup_readiness = "catalog", which lets a valid cached catalogue satisfy startup while the live connection comes up. Tool calls still need the live connection.

What do the annotations buy you?

default_tools_approval_mode takes auto, prompt, writes or approve, and "the writes mode prompts for tools that aren't marked read-only". osFoundry marks 30 of its 59 tools readOnlyHint: true. With writes, Codex runs those without asking and prompts for the other 29, which is the split you'd draw by hand. For a single tool, tools.<tool>.approval_mode overrides the default, and tools.<tool>.output_token_limit caps what one tool's result may add to the context.

The scope works the same way at the OAuth layer. Codex uses scopes from the config if it's set. Otherwise it takes the scopes from the server's challenge or its protected-resource metadata, and osFoundry's lists osfoundry:read, osfoundry:write, osfoundry:run and osfoundry:models. With scopes = ["osfoundry:read"] the token is issued for reading only, and every write tool declares osfoundry:write in its securitySchemes, whatever enabled_tools says.

Which filter is the real one?

There are three, and only one of them is a security boundary.

  • enabled_tools / disabled_tools in config.toml decide what the model is offered. Anyone who can edit the file can change it.
  • The Tools checklist in osFoundry's Connected tab is stored on the connection as allowed_tools. null means "every current and future supported tool". The owner sets it, and a local file can't widen it.
  • Project membership and the per-call access check decide what any tool can touch. A full tool list on a connection with no projects reads nothing.

The Tools checklist with only the read tools ticked, then Save tools

Example: The Tools checklist with only the read tools ticked, then Save tools

The client filter is prompt hygiene. Use it for tokens and for the model's choices, and use the other two for permissions.

If you wire this up yourself

  • Prefer enabled_tools over disabled_tools for a server with dozens of tools. It's shorter in tokens and stable when the server adds tools.
  • Write raw MCP names, and diff your list against a fresh tools/list when the server updates. Unknown names fail silently.
  • Set scopes explicitly, so the token carries no more than the config intends.
  • Pair default_tools_approval_mode = "writes" with a server that sets readOnlyHint honestly. The prompt split is only as good as the annotations.
  • If the server sits a second or more away, decide between required = true and startup_readiness = "catalog" rather than leaving it to the 1,000 ms grace default.
  • Keep the server's instructions under 512 characters and self-contained. It's sent with every session, like the tool list.

How the tool list relates to the context budget in general is on osFoundry's context window glossary page.

Top comments (1)

Collapse
 
aiapikey profile image
于侃 •

Worth measuring this per server rather than per project: the cost is the sum of every exposed tool's JSON schema, and it is paid on every request in the conversation, not once. A typical 20-30 tool server lands around 1.5-3k tokens of definitions; at 100 requests a day that is 45-90M input tokens a month, often more than the actual prompts.

Two trims that worked for me: split rarely used tool families into a second server you only attach for that project, and cut descriptions down to what the model needs to choose a tool rather than full docs - long descriptions are charged on every call. The 512-character instruction budget the post mentions is the same trade in a different place: anything sent per session should earn its tokens.