mcptest docs GitHub

Tool-selection F1 via equal-function sets

This gate scores whether an agent picked the right tool in a multi-server scenario, with no model in the loop. It works by grouping tools that do the same job into named classes, then reading precision, recall, and F1 straight off the recorded tool-call trace. The same trace always yields the same numbers, so the gate is deterministic, free to run, and safe to put in continuous integration.

Equal-function sets

An equal-function set is a named group of tools that accomplish the same job. Any member of the set is an acceptable choice. If a scenario can be solved by calling a web-search tool, and three different servers each expose a web-search tool, those three tools form one equal-function set. Selecting any one of them counts as a correct selection for that capability.

This is the idea from MSC-Bench (arXiv:2510.19423). Grouping interchangeable tools into a class lets a tool choice be judged correct when it lands in the right class, rather than when it matches one exact tool id. That matters in a multi-server setup where several servers offer the same capability under different names.

Because the check is "did an observed call land in the right class", it can be computed by reading the trace alone. There is no judge model, no token cost, and no cross-provider drift. Contrast this with a judged metric, where a model reads the agent's free-text answer and returns a verdict. A judged metric is not byte-stable (the same answer can score differently across runs or providers) and it costs tokens on every evaluation. The equal-function-set gate trades the ability to grade free-text answers for a number you can gate on in continuous integration.

Precision, recall, and F1

The three metrics are defined as follows. A true positive is a class the agent satisfied. A false positive is a tool call that satisfied no class. A false negative is a class the agent never satisfied.

The exact counting rule

The implementation walks the observed tool calls against the declared classes and applies one rule. Each class is consumed at most once.

A member id with a server. prefix matches the full qualified id, so two servers that expose an identically named tool stay distinct. A bare tool id (no server. prefix) matches that tool on any server, which is convenient for a single-server scenario.

Percentages and edge cases

All three metrics are reported as integers from 0 to 100. Integer percents keep the output identical on every platform (no floating-point formatting drift) and match the floor convention the other agent gates use. The edge cases are pinned so the result is byte-stable:

A worked example

Declare the gate inside an agent test with an equal_function_sets: block. The block has a required classes: list and an optional expect: list. Each class has a name: and a members: list of server.tool ids.

tests:
  - name: research-agent picks search then fetch
    type: agent
    agent: researcher
    runs: 1
    equal_function_sets:
      classes:
        - name: search
          members:
            - brave.web_search
            - google.search
        - name: fetch
          members:
            - http.get
      expect:
        - tool_selection.f1: { ">=": 80 }

This scenario has two capabilities. The search class is satisfied by either brave.web_search or google.search. The fetch class is satisfied by http.get.

Suppose the run produces this tool-call trace. The scorer reads the same tool_calls: [{name, server}] shape the agent driver records, and builds a server.tool id from each entry.

{
  "tool_calls": [
    { "name": "web_search", "server": "brave" },
    { "name": "get", "server": "http" }
  ]
}

The two observed ids are brave.web_search and http.get. Scoring them against the two classes gives:

ClassMatched byOutcome
searchbrave.web_searchtrue positive
fetchhttp.gettrue positive

There are 2 true positives, 0 false positives, and 0 false negatives. Precision is 2 divided by 2, which is 100. Recall is 2 divided by 2, which is 100. F1 is

  1. The tool_selection.f1: { ">=": 80 } floor passes.

Now suppose a second run misses the fetch capability and instead calls a tool that belongs to no class.

{
  "tool_calls": [
    { "name": "search", "server": "google" },
    { "name": "exec", "server": "shell" }
  ]
}

The observed ids are google.search and shell.exec. The search class is matched by google.search. The fetch class is never satisfied, so it is a false negative. The shell.exec call matches no class, so it is a false positive.

ClassMatched byOutcome
searchgoogle.searchtrue positive
fetch(none)false negative

Unexpected calls: shell.exec (false positive).

That gives 1 true positive, 1 false positive, and 1 false negative. Precision is 1 divided by 2, which is 50. Recall is 1 divided by 2, which is 50. F1 is 50. The report names fetch as the missed class and shell.exec as the unexpected tool, so the author sees exactly which capability the agent skipped and which extra tool it reached for.

The default gate

When the expect: list is omitted (or left empty), the block applies one default assertion: tool_selection.f1 >= 50. The reasoning is that a selection F1 under half is more wrong than right. The smallest useful form of the block is therefore just the classes: list:

equal_function_sets:
  classes:
    - name: search
      members: [brave.web_search, google.search]

That gates F1 at 50 with no further configuration.

Setting a precision, recall, or F1 floor

To gate a different metric, or to set a stricter floor, write an explicit expect: list. The three assertable targets are tool_selection.f1, tool_selection.precision, and tool_selection.recall. Each is an integer percent from 0 to 100, and each is gated with the standard matchers. To require high precision (few wrong tool calls) and a moderate F1:

equal_function_sets:
  classes:
    - name: search
      members: [brave.web_search, google.search]
    - name: fetch
      members: [http.get]
  expect:
    - tool_selection.precision: { ">=": 90 }
    - tool_selection.f1: { ">=": 70 }

When a test runs more than once (a runs: value above 1), the gate micro-averages across runs. It sums the true positives, false positives, and false negatives over every run, then derives the three percentages from those totals. The sums are order-independent, so a fixed set of run traces always produces the same aggregate.

When to use this gate

Reach for the objective F1 gate when you want a deterministic, free, gateable number for tool selection in a multi-server scenario. It is the right tool for continuous integration, where the same trace must score the same way every time and you cannot afford a token cost on every run.

Reach for the judged path instead when correctness lives in the agent's free-text answer, which no trace can confirm. That path uses a model as a judge to grade the final answer against a rubric, and is documented in LLM-as-judge evaluations. It costs a model call per scored answer and is not perfectly repeatable, so it does not belong in a gate that must be deterministic. The two checks are complementary: the objective F1 gate asserts that the agent reached the right tools, and the judged matcher asserts that the prose it produced is good.