Skip to content

[FEATURE]: Per-agent MCP tool filtering to stay within model tool limits #28662

Closed

Description

@CrystalMethod

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Related issues (not duplicates):


Problem

When using models with a hard tool limit (e.g. GPT-4o mini / github-copilot/gpt-5-mini with a 128-tool cap), agents that don't need all configured MCP servers still receive every tool definition in their context. This causes the request to exceed the model's tool limit and fail.

Example setup:

  • 3 MCP servers configured globally: gitlab (~40 tools), jira (~30 tools), sonarqube (~20 tools)
  • librarian agent uses gpt-5-mini (128-tool limit)
  • librarian only needs built-in tools + web search — it has no use for gitlab/jira/sonarqube
  • Total tools sent to librarian: 90+ MCP tools + built-in tools → exceeds 128

What doesn't work today

permission: deny (opencode native + oh-my-openagent)

{
  "agents": {
    "librarian": {
      "permission": { "gitlab_*": "deny", "jira_*": "deny" }
    }
  }
}

Blocks execution of those tools but does not remove them from the tool list sent to the model. The model still sees all tool definitions and they still count toward the limit.

tools: { "gitlab_*": false } (oh-my-openagent agent override)

oh-my-openagent migrates this to permission: deny internally — same problem, same result.

tools: { "gitlab_*": false } (opencode native agent config)

The tools key is not a recognized opencode agent field. It is silently routed into options and has no effect whatsoever.

tool.definition plugin hook

Only receives toolID — no agent context. Cannot conditionally filter per agent.

Requested feature

One or more of the following:

Option A: Per-agent disabled_mcps in opencode agent config

{
  "agent": {
    "librarian": {
      "disabled_mcps": ["gitlab", "jira", "sonarqube"]
    }
  }
}

Mirrors the top-level disabled_mcps that oh-my-openagent already supports, but scoped to a single agent.

Option B: Agent context + hidden field in the tool.definition plugin hook

"tool.definition"?: (input: {
  toolID: string;
  agent: string;   // add this — needed for per-agent filtering
}, output: {
  description: string;
  parameters: any;
  hidden?: boolean; // add this (as also requested in #23297) — removes tool from LLM tool list
}) => Promise<void>;

Would allow plugins to filter tools per agent without needing native config support. Note: hidden alone (#23297) is insufficient — the agent field in the input is also required.

Option C: Native tools filter in opencode agent config

{
  "agent": {
    "librarian": {
      "tools": { "gitlab_*": false, "jira_*": false }
    }
  }
}

A glob-based allowlist/denylist that removes matching tools from the tool list before it is sent to the model.

Why this matters

  • Models with small tool limits (GPT-4o mini, o1-mini, etc.) are unusable as sub-agents when many MCP servers are configured globally
  • Specialized agents (librarian, explore, reviewer) have well-defined tool needs — giving them 100+ irrelevant tools wastes context tokens and hits limits
  • The current workaround (disabling MCPs globally) defeats the purpose of having them configured

Environment

  • opencode (latest)
  • oh-my-openagent plugin
  • MCP servers: gitlab, jira, sonarqube
  • Affected agent: librarian (model: github-copilot/gpt-5-mini, 128-tool limit)

Activity

  1. github-actions commented on May 21, 2026

    @github-actions
    Contributor

    This issue doesn't fully meet our contributing guidelines.

    What needs to be fixed:

    • The title should start with [FEATURE]: for feature requests (e.g. [FEATURE]: Per-agent MCP tool filtering to stay within model tool limits)
    • The feature request template requires a verification checkbox confirming the feature hasn't been suggested before — this is missing

    Please edit this issue to address the above within 2 hours, or it will be automatically closed.


    This issue might be a duplicate of existing issues. Please check:

    If you believe this was flagged incorrectly, please let a maintainer know.

  2. changed the title [-]Feature: per-agent MCP tool filtering to stay within model tool limits[/-] [+][FEATURE]: Per-agent MCP tool filtering to stay within model tool limits[/+] on May 21, 2026
  3. github-actions commented on May 21, 2026

    @github-actions
    Contributor

    Thanks for updating the issue! It now meets our contributing guidelines — the feature request template is properly filled out with a clear problem description, concrete examples, and proposed solutions. The needs:compliance label has been removed.

  4. github-actions commented on May 21, 2026

    @github-actions
    Contributor

    Thanks for updating the issue! It now meets the contributing guidelines — the problem is clearly described, the verification checkbox is checked, environment details are included, and the requested options are well-explained. No further changes needed.

  5. abi-jey commented on May 26, 2026

    @abi-jey

    +1 for this

  6. rekram1-node commented on Jun 6, 2026

    @rekram1-node
    Collaborator

    Blocks execution of those tools but does not remove them from the tool list sent to the model. The model still sees all tool definitions and they still count toward the limit.

    This is not true, the permissions will omit them from context

  7. CrystalMethod commented on Jun 18, 2026

    @CrystalMethod
    Author

    Blocks execution of those tools but does not remove them from the tool list sent to the model. The model still sees all tool definitions and they still count toward the limit.

    This is not true, the permissions will omit them from context

    I actually dug into the opencode source to verify this, and the claim that "permissions omit them from context" doesn't match the current implementation.
    The smoking gun is in packages/opencode/src/session/llm.ts:

    const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
    workflowModel.sessionPreapprovedTools = Object.keys(prepared.tools).filter((name) => {
      const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
      return !match || match.action !== "ask"
    })

    What it actually does: prepared.tools contains every MCP and built-in tool before any filtering. The code only strips out "ask" tools — both "allow" and "deny" pass through unchanged. That full record then gets mapped in native-runtime.ts and sent directly to the model. There's a UI filter in registry.ts, but it only hides denied tools from the TUI sidebar; it doesn't touch the LLM payload.
    Right now the split is clear: the LLM receives all the definitions (and they count against the tool limit), but the runtime blocks the actual calls.

    @rekram1-node can you show me the exact file and lines where this logic is implemented?

  8. mocun33 commented on Aug 20, 2026

    @mocun33

    Reproducing on 1.18.19 (desktop, Windows) — the "permissions will omit them from context" claim doesn't hold here.

    Setup:

    • Project opencode.jsonc has "tools": { "paper-search_*": false, "openviking_*": false, "browsermcp_*": false, ... } (8 MCP servers)
    • opencode debug config confirms these are auto-migrated to permission: { "paper-search_*": "deny", ... }

    Observed:

    • The main session still receives all MCP tool definitions in its tool list (~40-50k tokens across 8 servers), consistent with @CrystalMethod's source analysis of llm.ts (only ask tools are stripped; allow/deny pass through unchanged).
    • permission: deny does block execution (calls are rejected), but the schema is still injected into the model context.

    Impact:

    • Plugins built on the legacy tools behavior (e.g. opencode-mcp-triage, which writes "servername_*": false and advertises "0 tokens in main session") silently fail to deliver the claimed token savings on current versions — the deny entries migrate to permission and only gate calls.

    Question: is this a known limitation or a regression? Is there any supported way to hide tool definitions from the model (not just block calls) — e.g. the hidden field on tool.definition (#23297) — or should we treat schema injection as permanent until a native per-agent tool filter lands?

  9. opencode-agent commented on Aug 20, 2026

    @opencode-agent
    Contributor

    The last two comments are inspecting the wrong code path. A full deny does remove matching tool definitions before the request is sent to the model in V1 dev / 1.18.19.

    The relevant path is:

    1. Request preparation calls resolveTools(input) and returns that filtered record as prepared.tools (lines 181–185).
    2. resolveTools merges agent and session permissions, computes disabled tools, and filters them out.
    3. Permission.disabled treats the effective final matching rule as tool-disabling when its argument pattern is "*" and its action is "deny".

    The sessionPreapprovedTools code cited above is inside the GitLabWorkflowLanguageModel integration. It controls workflow approval behavior and operates on the already-filtered prepared.tools; it is not the general LLM tool-list filter: llm.ts#L149-L153.

    I also ran this single-file Bun reproduction directly against commit b155b15694dbcc6768f11d2f25cc2bdd1f738ab4 (packages/opencode version 1.18.19). It calls the real LLMRequestPrep.prepare, rather than copying the filtering logic:

    repro-mcp-permission-filter.ts
    import { SessionV1 } from "@opencode-ai/core/v1/session"
    import { ModelV2 } from "@opencode-ai/core/model"
    import { ProviderV2 } from "@opencode-ai/core/provider"
    import { Effect } from "effect"
    import { tool } from "ai"
    import z from "zod"
    import type { Agent } from "../src/agent/agent"
    import type { Plugin } from "../src/plugin"
    import type { Provider } from "../src/provider/provider"
    import type { RuntimeFlags } from "../src/effect/runtime-flags"
    import { LLMRequestPrep } from "../src/session/llm/request"
    import { MessageID, SessionID } from "../src/session/schema"
    
    const providerID = ProviderV2.ID.make("repro")
    const modelID = ModelV2.ID.make("repro")
    const sessionID = SessionID.make("session-mcp-permission-repro")
    
    const model = {
      id: modelID,
      providerID,
      api: { id: modelID, url: "http://localhost", npm: "@ai-sdk/openai-compatible" },
      name: "repro",
      capabilities: {
        temperature: false,
        reasoning: false,
        attachment: false,
        toolcall: true,
        input: { text: true, audio: false, image: false, video: false, pdf: false },
        output: { text: true, audio: false, image: false, video: false, pdf: false },
        interleaved: false,
      },
      cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
      limit: { context: 128_000, output: 4_096 },
      status: "active",
      options: {},
      headers: {},
      release_date: "2026-01-01",
    } satisfies Provider.Model
    
    const provider = {
      id: providerID,
      name: "repro",
      source: "custom",
      env: [],
      options: {},
      models: { [modelID]: model },
    } satisfies Provider.Info
    
    const agent = {
      name: "repro",
      mode: "primary",
      options: {},
      permission: [
        { permission: "*", pattern: "*", action: "allow" },
        { permission: "paper-search_*", pattern: "*", action: "deny" },
        { permission: "browsermcp_*", pattern: "*", action: "deny" },
      ],
    } satisfies Agent.Info
    
    const user = {
      id: MessageID.make("msg_mcp-permission-repro"),
      sessionID,
      role: "user",
      time: { created: Date.now() },
      agent: agent.name,
      model: { providerID, modelID },
    } satisfies SessionV1.User
    
    const plugin: Plugin.Interface = {
      trigger: (_name, _input, output) => Effect.succeed(output),
      list: () => Effect.succeed([]),
      init: () => Effect.void,
    }
    
    const flags = {
      autoShare: false,
      pure: false,
      disableDefaultPlugins: false,
      disableEmbeddedWebUi: false,
      disableExternalSkills: false,
      disableLspDownload: false,
      disableClaudeCodePrompt: false,
      disableClaudeCodeSkills: false,
      enableExa: false,
      enableParallel: false,
      enableExperimentalModels: false,
      enableQuestionTool: false,
      experimentalReferences: false,
      experimentalBackgroundSubagents: false,
      experimentalLspTy: false,
      experimentalLspTool: false,
      experimentalOxfmt: false,
      experimentalPlanMode: false,
      experimentalCodeMode: false,
      experimentalEventSystem: false,
      experimentalWorkspaces: false,
      experimentalIconDiscovery: false,
      outputTokenMax: undefined,
      bashDefaultTimeoutMs: undefined,
      experimentalNativeLlm: false,
      experimentalWebSockets: false,
      client: "cli",
    } satisfies RuntimeFlags.Info
    
    const makeTool = () =>
      tool({
        description: "reproduction tool",
        inputSchema: z.object({}),
        execute: async () => ({ output: "" }),
      })
    
    const prepared = await Effect.runPromise(
      LLMRequestPrep.prepare({
        user,
        sessionID,
        model,
        agent,
        system: [],
        messages: [{ role: "user", content: "hello" }],
        tools: {
          "paper-search_lookup": makeTool(),
          browsermcp_open: makeTool(),
          keep_me: makeTool(),
        },
        provider,
        auth: undefined,
        plugin,
        flags,
        isWorkflow: false,
      }),
    )
    
    const actual = Object.keys(prepared.tools)
    const expected = ["keep_me"]
    
    console.log("tools before request preparation:", ["paper-search_lookup", "browsermcp_open", "keep_me"])
    console.log("tools after request preparation: ", actual)
    
    if (JSON.stringify(actual) !== JSON.stringify(expected)) {
      throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
    }
    
    console.log("PASS: hard-denied MCP definitions are absent from the LLM request")

    Run from packages/opencode after placing it under packages/opencode/script/:

    $ bun run script/repro-mcp-permission-filter.ts
    tools before request preparation: [ "paper-search_lookup", "browsermcp_open", "keep_me" ]
    tools after request preparation:  [ "keep_me" ]
    PASS: hard-denied MCP definitions are absent from the LLM request

    The important qualification is that only a full tool deny hides the definition. Argument-specific denies (for example, deny only bash commands matching rm *) and ask rules must leave the tool visible because other calls remain possible. For MCP tools, a rule such as "paper-search_*": "deny" becomes a full deny (pattern: "*") and matching MCP definitions are omitted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions