Skip to main content

Tools

Internal developers only

The Capture MCP SDK is currently only available to Capture's internal developers. We are working hard on the cybersecurity part, so that we can also offer it to everyone who builds on top of Capture.

A tool is an ordinary function. The SDK derives the input schema from its signature, uses its description as the description of the tool, and registers the tool with the Capture MCP server.

@app.tool(read_only=True)
async def get_report(report_id: str) -> dict:
"""Fetch a report by ID."""
return {"id": report_id, "status": "ok"}
Write the description for a model

The description is the only thing that tells an agent when to use your tool. Say what the tool is for, when to reach for it and what to watch out for, for example that a name is not unique. A good description matters more than anything else you add to a tool.


Options of a tool​

Python (@app.tool()).NETWhat it does
description, or the docstring[Description("...")]The description of the tool. In Python, pass description explicitly to take the text from a constant that a test also checks.
read_only[McpServerTool(ReadOnly = true)]Marks the tool as one that does not change anything.
destructive[McpServerTool(Destructive = true)]Marks the tool as one that can delete or overwrite data.
idempotent[McpServerTool(Idempotent = true)]Marks the tool as one that gives the same result when you call it again with the same input.
open_world[McpServerTool(OpenWorld = true)]Marks the tool as one that reaches outside your own system.
data_intensive[DataIntensive]Marks the tool as one that can return a lot of data. The SDK adds a note to the description once, when the tool is registered, so that agents prefer to call it from a code execution script instead of directly.
requires_permission[RequiresPermission("...")]The permission a caller needs. See permission-aware tools.
output_schemaUseStructuredContent, OutputSchemaTypeThe schema of the result. See output schemas.
structured_outputPython only. Set it to False when the return type includes CallToolResult.

The four hints (read_only, destructive, idempotent and open_world) are published to the agent as MCP tool annotations. They help an agent and its user decide how careful to be with a tool. Set the ones you are sure of and leave out the others: a tool that sets none registers with no annotations at all, which is not the same as every hint being false.

A parameter of type CaptureMcpCallContext is filled in when the tool is called and does not appear in the schema that agents see.

The call context gives you the caller, the permissions of the caller and the delegated token. See Authentication.


Output schemas​

An output schema tells clients what shape the result has. The MCP server hands the result of a tool with a schema to clients as structuredContent. The result of a tool without a schema reaches them as a raw content block instead.

The schema is derived from the return annotation of the function. You get one by upgrading the SDK, without writing a schema by hand:

class Reading(BaseModel):
sensor_id: str
value: float


@app.tool()
def read_sensor(sensor_id: str) -> Reading: # output_schema published automatically
...

Two return shapes publish nothing, on purpose:

  • A return that is not an object, such as list[str], int or a bare dict. The derived schema for those is {"result": ...}, and adopting it would change the shape of the result that existing callers already read. Return a model or a dict[str, ...], or pass output_schema= explicitly, to publish a schema.
  • structured_output=False, the flag for a return type that includes CallToolResult.

Pass output_schema= to override what the annotation says. The conversion above only runs for a schema that is derived from the annotation. An overridden schema is not matched against the return type, so your function has to return a result that already has that shape.

Registration without an output schema

Registration warns today about a tool with no output schema, and names it in GET /mcp/diagnostics/output-schema-coverage. From the date that report states, registration is refused. In .NET, that means setting UseStructuredContent on every tool.


Permission-aware tools​

Declare the permission a caller must hold to call a tool:

@app.tool(requires_permission="events:view")
async def get_logbook_events(ctx: CaptureMcpCallContext) -> dict: ...


@app.tool(requires_permission=["events:view", "events:export"])
async def export_logbook_events(ctx: CaptureMcpCallContext) -> dict: ...
  • Declare the bare permission. Use {resource}:{action}, for example events:view. Do not prefix it with the app key, as in app-901:events:view. Capture assigns that opaque key when your custom app is registered, so you cannot know it when you write the code. The SDK looks it up at runtime and adds it to your permissions during registration. A permission that is not exactly two parts is refused: in .NET your host fails at startup with a clear message, in Python the decorator raises.
  • A permission makes the tool company-scoped. The SDK adds a required companyId to the schema of the tool, and a call without a company that it can resolve fails closed. In Python, if your function wants that value as a parameter, name it company_id: the SDK keeps it out of the schema and fills it in from the companyId argument. A company_id parameter on a tool without requires_permission is refused.
  • A tool without a permission is open. It is callable by anyone who can reach it. The SDK logs this once for each such tool, so look for that line in review.
  • The attribute never shows up in the schema. It is information about the tool, not an argument of it.

Until at least one role that names a permission exists for your app, the app key cannot be resolved. Registration then retries, with the same backoff as an unreachable MCP server, instead of registering with a gap.

Check a permission in your own code​

The call context tells you whether the caller holds a permission in one company. It needs an explicit company. It never flattens the permissions of a caller across companies, so holding a permission in one company does not pass the check for another. A tool gets the companyId its call concerns from the model, typically as a parameter of its own, because there is no ambient current company.

@app.tool(requires_permission="events:view")
async def list_events(company_id: int, ctx: CaptureMcpCallContext) -> dict:
"""Lists events for a company."""
return {"company_id": company_id, "is_owner": ctx.is_owner(company_id)}

Use ctx.has_permission(permission, company_id) for the check and ctx.is_owner(company_id) for ownership.

Owners do not bypass this check

Capture's own permission evaluation treats a company owner as someone who holds every permission. These checks do not: they only look at explicit role grants, and IsOwner is informational. A tool that wants the owner to pass has to check ownership itself.

Declare your permissions to Capture​

A permission on a tool gates the calls, but it does not make the permission assignable to anyone. A permission that is not in the catalog of your app cannot appear in a role, so no administrator can ever grant it.

At startup the SDK therefore collects every distinct bare permission across your tools and declares each one to Capture, one at a time, with the same app credentials that authenticate your registration. This is the same in Python and in .NET.

  • This is strictly additive. The SDK never replaces the full manifest of your app. A full replace removes every permission and role that is stored for the app but missing from what you submit, and removing a role removes every assignment of it. An app that also declares permissions for its own HTTP surface would lose those, together with the role assignments of its users.
  • Pick one style per app. If your app also pushes a full manifest, directly or from another tool, that push does not know about the permissions the SDK added one at a time, and the other way around. Do not mix the two styles within one app.
  • Declaring the same permission again is safe. Capture answers that it is already declared, and the SDK treats that as success, so you can restart as often as you like.
  • It never blocks startup. If Capture cannot be reached, the SDK logs a warning that names the permission and the app, and retries with a backoff from 1 to 30 seconds. This runs independently of the registration of your tools.
  • The SDK never creates roles. An administrator bundles the declared permissions into roles and assigns them. If your app wants to ship preset roles, create them yourself with POST /api/apps/me/roles.

Declaring a permission is not granting it. After your first start, ask an administrator to put the permission in a role.