Unified Tools
Declare a capability once and expose it through REST, MCP, and the CLI.
A tool is a named, schema-validated capability of an organization. It is declared once in Convex and served by three adapters, so REST clients, MCP clients, and the CLI never drift apart.
Layout
| File | Role |
|---|---|
convex/tools/types.ts | defineTool and the ToolDefinition contract |
convex/tools/schema.ts | Zod to JSON Schema conversion used by discovery |
convex/tools/registry.ts | The single list of tools, with duplicate-name detection |
convex/tools/routes.ts | REST aliases: path matching and collision detection |
convex/tools/definitions/*.tools.ts | The tools themselves, grouped by domain |
convex/tools/actions.ts | listAvailableTools, getToolSchema, executeTool |
convex/http.ts | REST routes under /api/v1 |
src/lib/mcp/server.ts | MCP adapter reading the same registry |
scripts/tools-cli.mjs | CLI adapter (pnpm tools) |
Add a tool
Declare it with defineTool. The input schema is a Zod object; defineTool validates the input before the handler runs, so no adapter can skip validation.
// convex/tools/definitions/project.tools.ts
import { z } from "zod";
import { internal } from "@convex/_generated/api";
import { defineTool } from "@convex/tools/types";
export const projectTools = [
defineTool({
name: "get_project",
description: "Get a single project of the organization by id.",
category: "organization",
access: "read",
inputSchema: z.object({
projectId: z.string().min(1).describe("The project id"),
}),
handler: async (input, { ctx, organizationId }) => {
const project = await ctx.runQuery(
internal.projects.queries.getForOrgApi,
{ organizationId, projectId: input.projectId },
);
if (!project) {
return {
success: false,
code: "not_found",
error: `Project "${input.projectId}" not found in this organization`,
};
}
return { success: true, data: { project } };
},
}),
];Then register it in convex/tools/registry.ts:
import { projectTools } from "@convex/tools/definitions/project.tools";
const definitions: RegisteredTool[] = [
...organizationTools,
...memberTools,
...billingTools,
...projectTools,
];That is the whole change. The tool now appears in GET /api/v1/tools, in pnpm tools list, and in the MCP tool list.
Contract
| Field | Purpose |
|---|---|
name | Snake case, unique. Duplicates throw when the registry loads |
description | Written for an LLM: say what it returns and when to use it |
category | Groups tools for pnpm tools list --category |
access | read or write. Drives the MCP readOnlyHint / destructiveHint hints |
inputSchema | A z.object(...). Use .describe() on each field, it reaches the client |
route | Optional REST alias, see below. Omit it to stay on /api/v1/tools/<name> |
handler | Receives the parsed input and { ctx, organizationId, source } |
The handler returns a discriminated result:
type ToolResult<TData> =
| { success: true; data: TData }
| { success: false; error: string; code?: string };code: "not_found" maps to HTTP 404; every other failure maps to 400.
REST aliases
Every tool is reachable at POST /api/v1/tools/<name>. Add a route and it also gets a readable resource path:
defineTool({
name: "get_project",
// ...
route: { method: "GET", path: "/projects/:projectId" },
handler: async (input, { ctx, organizationId }) => {
/* ... */
},
});GET /api/v1/projects/prj_123 now runs the same tool, through the same executeTool action. There is no second handler to keep in sync, and the route shows up in GET /api/v1/tools next to the tool.
pathis relative to/api/v1and may contain:paramsegments./tools/*is reserved for discovery.- One tool is one operation, so
routetakes a singlemethod. Creating and updating a project are two tools with two schemas. GETandDELETEbuild the input from the path params plus the query string;POST,PATCH, andPUTuse the path params plus the JSON body. Path params always win, so?projectId=othercannot override the URL.- Query and path values are always strings. A schema behind a
GETroute needsz.coerce.number()orz.coerce.boolean()rather thanz.number(). - Two tools claiming the same method and path shape throw when the registry loads, the same way duplicate names do.
REST aliases return the tool payload directly - { "project": { … } }. The /api/v1/tools/* endpoints keep the { "data": … } envelope that MCP and the CLI expect.
Authorization
Tools never check auth themselves. Every surface goes through executeTool, an orgApiAction that verifies the credential and resolves organizationId before the handler runs. The credential is either an organization API key or an OAuth access token issued to an MCP client; both are verified in Convex, and an OAuth token must carry the nowstack.write scope to run a write tool. Handlers use that organizationId and call internal Convex queries with it.
The MCP adapter reads tool metadata from the registry but executes through POST /api/v1/tools/:name with an x-tool-source: mcp header, so authorization, validation, and business logic live in exactly one place.
Source
source tells a handler which surface invoked it (api, mcp, or cli). Use it for analytics or rate limiting, never for authorization.