DEV Community

Sattyam Jain
Sattyam Jain

Posted on

Profile your MCP server's tool shape with one stdlib script

A benchmark posted this week, MCP-GRANITE, took the same functionality and exposed it to nine local models at four granularities: fine-grained primitive tools, four composite tools, two tools, or one dispatch tool. Four composite tools won. Task completion rose 16.4% over primitives and 33.6% over the single tool, argument accuracy nearly doubled, and a 3.2B model at the right granularity beat a 20.9B model at the wrong one.

I wrote up why that matters for anyone routing agent work to small models on Medium. This post is the practical half: a script that tells you what shape your own MCP server is, before you decide whether to recut it.

What it measures

Three numbers per tool, from the tools/list response:

  • params: how many input properties the tool declares.
  • ops: how many operations hide behind the tool. A tool with an action (or operation, op, command, method, mode) property that has an enum counts as one operation per enum value. Everything else counts as one.
  • loose: string parameters with no enum, pattern, format or const. These are where a small model fills in something plausible and wrong.

Then a heuristic bucket for the whole server. The buckets are mine, not the paper's levels: the paper controls granularity directly, while this script can only infer it from a schema.

Get your tools/list

The MCP Inspector has a CLI mode that prints JSON:

npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list > tools.json
Enter fullscreen mode Exit fullscreen mode

Swap node build/index.js for however you start your server. Any file that contains a tools array, or a raw list of tools, works.

The script

Stdlib only, Python 3.8+.

#!/usr/bin/env python3
"""tool_shape.py: profile the shape of an MCP server's tool surface.

Input: the JSON from a tools/list call, e.g.
  npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list > tools.json
Usage: python3 tool_shape.py tools.json
Stdlib only. The buckets are my heuristic, not the MCP-GRANITE levels.
"""
import json
import sys

DISPATCH_NAMES = {"action", "operation", "op", "command", "cmd", "method", "verb", "mode"}


def load_tools(path):
    with open(path) as f:
        data = json.load(f)
    if isinstance(data, dict):
        data = data.get("tools", data.get("result", {}).get("tools", []))
    return data


def dispatch_param(schema):
    """Return (name, n_values) if a property looks like an operation switch."""
    for name, prop in (schema.get("properties") or {}).items():
        if name.lower() in DISPATCH_NAMES and isinstance(prop.get("enum"), list):
            return name, len(prop["enum"])
    return None, 0


def loose_strings(schema):
    """String params with no enum, pattern or format: where arguments get invented."""
    out = []
    for name, prop in (schema.get("properties") or {}).items():
        if prop.get("type") == "string" and not any(k in prop for k in ("enum", "pattern", "format", "const")):
            out.append(name)
    return out


def main(path):
    tools = load_tools(path)
    ops_total, loose_total, params_total = 0, 0, 0
    print(f"{'tool':32} {'params':>6} {'ops':>4} {'loose':>5}  dispatch")
    for t in tools:
        schema = t.get("inputSchema") or {}
        n_params = len(schema.get("properties") or {})
        d_name, d_values = dispatch_param(schema)
        ops = d_values if d_values else 1
        loose = loose_strings(schema)
        ops_total += ops
        loose_total += len(loose)
        params_total += n_params
        print(f"{t['name'][:32]:32} {n_params:>6} {ops:>4} {len(loose):>5}  {d_name or '-'}")

    n = len(tools)
    if n == 0:
        sys.exit("no tools found in input")
    ops_per_tool = ops_total / n
    print()
    print(f"tools: {n}   operations: {ops_total}   ops per tool: {ops_per_tool:.1f}")
    print(f"loose string params: {loose_total} of {params_total}")
    if ops_per_tool <= 1.2 and n > 8:
        shape = "primitive-leaning: one tool per operation, many tools"
    elif n <= 2 and ops_per_tool >= 4:
        shape = "dispatch-leaning: few tools, operation chosen by an argument"
    else:
        shape = "composite: a handful of tools, a few operations each"
    print(f"shape (heuristic): {shape}")


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "tools.json")
Enter fullscreen mode Exit fullscreen mode

Two runs

A smart-home server with one tool and a seven-value action enum:

tool                             params  ops loose  dispatch
home_control                          4    7     1  action

tools: 1   operations: 7   ops per tool: 7.0
loose string params: 1 of 4
shape (heuristic): dispatch-leaning: few tools, operation chosen by an argument
Enter fullscreen mode Exit fullscreen mode

The same idea split into twelve single-purpose tools, each taking a free-text room:

tools: 12   operations: 12   ops per tool: 1.0
loose string params: 12 of 12
shape (heuristic): primitive-leaning: one tool per operation, many tools
Enter fullscreen mode Exit fullscreen mode

Both are the extremes the benchmark found worst. The first makes the model pick an action and then guess which of the optional fields that action needs. The second makes it pick one tool out of twelve near-identical schemas, and every room is free text.

What I do with the output

  • Dispatch-leaning: split the enum into a handful of tools by what the operations share. Read operations in one tool, state changes in another is a reasonable first cut.
  • Primitive-leaning: merge tools that share most of their parameters.
  • High loose count: add an enum for anything with a known set of values, and a pattern or format for IDs and timestamps. This one is cheap and helps every model size.

Then measure. The MCP-GRANITE code runs locally through Ollama, and its default config runs one scenario across all four granularities with granite4:3b. One caveat from its README: task completion is scored by whether expected strings appear in the final answer, not by the end state of the simulated system, so check state yourself when you compare two cuts of your own tools.

The script is a starting point, not a verdict. If your server uses a different name for its operation switch, add it to DISPATCH_NAMES.

Paper: https://arxiv.org/abs/2609.24161
Benchmark code: https://github.com/dpasch01/mcp-granite

Top comments (0)