Skip to main content

Consolidating MCP Entity Retrieval into One Batch Tool

· 6 min read

Following our prior post on wiring up an MCP server for our Django app — see How We Integrated Model Context Protocol (MCP) into Our Django App — we went back and revisited the architecture. "Too many tools" is still a huge problem for LLM productivity, which has continued into GPT5 and the latest Claude models so probably won't be solved toon. Cursor and Claude both work better when they have fewer tools to choose from, and our original setup exposed too many single-purpose GET tools. So we consolidated everything into a single, strongly-typed batch tool.

The result: one get tool, clearer schema, faster concurrent fetches, and less model confusion.

Why consolidate?

As we used our MCP server in real projects, it became obvious that an abundance of single GET tools encouraged the model to take longer “tooling paths” and occasionally pick the wrong tool. Industry guidance also points in the same direction: fewer tools generally perform better for LLMs. See Speakeasy’s write-up on MCP tool best practices: Best practices for using MCP tools.

What we changed

  • Added a unified batch GET tool: agentinterviews_batch_get that fetches entities concurrently by type and uuids.
  • Made types visible to clients: Declared EntityType as a Literal enum and mapped it to API paths so allowed values are shown in the tool schema.
  • Included reports and artifacts: Supports all report types plus interview_transcripts and interview_recording.
  • Removed single GET tools: Individual GET endpoints are disabled in favor of the batch tool.

The schema: explicit types clients can see

We want clients (and LLMs) to see exactly which entity types are supported. Using a Literal enum makes the contract explicit and surfaces allowed values directly in tooltips and schemas.


EntityType = Literal[
"project",
"interview",
"interviewer",
"audience",
"provider",
"knowledge",
# Reports
"interview_stage_report",
"interview_level_report",
"interviewer_stage_report",
"interviewer_level_report",
# Interview artifacts
"interview_transcripts",
"interview_recording",
]

ENTITY_TYPE_TO_PATH: Dict[str, str] = {
"project": "projects",
"interview": "interviews",
"interviewer": "interviewers",
"audience": "audiences",
"provider": "audience-providers",
"knowledge": "knowledge",
# Reports
"interview_stage_report": "interview-stage-reports",
"interview_level_report": "interview-level-reports",
"interviewer_stage_report": "interviewer-stage-reports",
"interviewer_level_report": "interviewer-level-reports",
}

# Special-case URL templates that don't follow "/<path>/<id>/" pattern
ENTITY_TYPE_TO_DETAIL_TEMPLATE: Dict[str, str] = {
# Interview artifacts
"interview_transcripts": "interviews/{id}/transcripts/",
"interview_recording": "interviews/{id}/recording/",
}

ALLOWED_ENTITY_TYPES_DESC = ", ".join(
list(ENTITY_TYPE_TO_PATH.keys()) + list(ENTITY_TYPE_TO_DETAIL_TEMPLATE.keys())
)


The batch tool

The tool takes a single type, a list of uuids, and an optional fields selector. It fetches each item concurrently and returns a concise summary of successes, misses, and errors.


@mcp.tool(description="Agent Interviews: Batch get entities by type and UUIDs")
async def agentinterviews_batch_get(
type: Annotated[EntityType, Field(description=f"One of: {ALLOWED_ENTITY_TYPES_DESC}")],
uuids: Annotated[List[str], Field(min_items=1, description="UUIDs to fetch")],
fields: Annotated[Literal["basic","standard","full"], Field(description="Field set detail")] = "standard",
) -> Dict[str, Any]:
# ... AUTH

async with httpx.AsyncClient(timeout=30.0) as client:
async def fetch_one(one_uuid: str):
if type in ENTITY_TYPE_TO_DETAIL_TEMPLATE:
suffix = ENTITY_TYPE_TO_DETAIL_TEMPLATE[type].format(id=one_uuid)
url = f"{API_BASE_URL}/{suffix}"
else:
path = ENTITY_TYPE_TO_PATH[type]
url = f"{API_BASE_URL}/{path}/{one_uuid}/"
params: Dict[str, Any] = {}
if fields != "full":
params["fields"] = fields
try:
resp = await client.get(url, headers=headers, params=params)
if resp.status_code == 404:
return {"uuid": one_uuid, "status": 404, "data": None}
if resp.status_code != 200:
return {"uuid": one_uuid, "status": resp.status_code, "error": resp.text[:200]}
return {"uuid": one_uuid, "status": 200, "data": resp.json()}
except httpx.TimeoutException:
return {"uuid": one_uuid, "status": "timeout", "error": "Request timed out"}
except httpx.RequestError as e:
return {"uuid": one_uuid, "status": "network_error", "error": str(e)}

results = await asyncio.gather(*(fetch_one(x) for x in uuids))

items = [r["data"] for r in results if r.get("status") == 200 and r.get("data") is not None]
missing = [r["uuid"] for r in results if r.get("status") == 404]
errors = [{k: r[k] for k in ("uuid","status","error") if k in r} for r in results if r.get("status") not in (200, 404)]
return {"items": items, "missing": missing, "errors": errors}

Why this shape works well

  • Explicit type contract: The enum ensures only supported entity types are accepted. Clients see allowed values up-front.
  • Concurrent fetching: asyncio.gather means faster response times when fetching multiple items.
  • Minimal shape, maximal utility: items, missing, and errors make it easy for agents to branch logic.
  • Artifacts and reports included: Detail routes for transcripts/recordings live alongside standard REST paths.

What we disabled

We removed our single GET tools (project, interviewer, interview, transcripts, audience, provider, knowledge, interview-level report, interviewer-level report).

The plan is for agentinterviews_batch_get to be the central entry point for retrieving objects, reports, and interview artifacts.

Why list endpoints remain

We intentionally did not remove the model-specific list endpoints. They serve a different purpose than the batch getter:

  • Dynamic, model-specific filtering: Different models expose different filter fields (e.g., status, stage, tags, date ranges). List endpoints preserve these rich filters, server-side pagination, and sorting semantics.
  • Discovery vs. retrieval: Use list endpoints to discover the right records via filters. Use agentinterviews_batch_get to retrieve known records quickly by uuids.
  • Works alongside multi‑modal search: We also provide a multi‑modal search endpoint that spans multiple models for cross-entity discovery when the type is unknown or mixed. Use search for broad discovery; use model lists for precise, model-specific filtering; use the batch getter for fast retrieval by uuids.

Why fewer tools? A reference

If you’re tempted to expose lots of narrowly-scoped tools, consider the model’s perspective. Many tools increase confusion, not capability. We’ve found that consolidating reads into a single, well-designed getter dramatically reduces misfires and shortens agent traces.

Independent guidance echoes this: Docker recommends managing an intentional tool budget and avoiding 1:1 endpoint-to-tool mappings Top 5 MCP Server Best Practices. Apollo GraphQL shows how tool definitions and repeated invocations consume precious context tokens, so minimizing both cuts cost and latency Every Token Counts. Catch Metrics highlights the context overhead of verbose schemas and suggests schema optimization and dynamic tool loading MCP Server Performance Optimization. TheNextGenTechInsider argues for consolidating related functions to reduce overhead and improve agent reliability Mastering MCP Servers.

Future plans

We’re exploring a search-style tool that traverses related objects in a single call, and richer fields presets for targeted payloads (e.g., minimal, standard, expanded). We’ll also likely add per-type rate limits and cached fan-out for hot paths.

Key takeaways

  • One batch getter beats many singles: Clearer schema, faster performance, less confusion.
  • Enum-typed inputs help the model: Visible allowed values reduce invalid tool calls.
  • Artifacts and reports are first-class: Non-standard detail routes live alongside standard REST paths.
  • Follow the “fewer tools” principle.