Developer/Unified Tools

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

FileRole
convex/tools/types.tsdefineTool and the ToolDefinition contract
convex/tools/schema.tsZod to JSON Schema conversion used by discovery
convex/tools/registry.tsThe single list of tools, with duplicate-name detection
convex/tools/routes.tsREST aliases: path matching and collision detection
convex/tools/definitions/*.tools.tsThe tools themselves, grouped by domain
convex/tools/actions.tslistAvailableTools, getToolSchema, executeTool
convex/http.tsREST routes under /api/v1
src/lib/mcp/server.tsMCP adapter reading the same registry
scripts/tools-cli.mjsCLI 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

FieldPurpose
nameSnake case, unique. Duplicates throw when the registry loads
descriptionWritten for an LLM: say what it returns and when to use it
categoryGroups tools for pnpm tools list --category
accessread or write. Drives the MCP readOnlyHint / destructiveHint hints
inputSchemaA z.object(...). Use .describe() on each field, it reaches the client
routeOptional REST alias, see below. Omit it to stay on /api/v1/tools/<name>
handlerReceives 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.

  • path is relative to /api/v1 and may contain :param segments. /tools/* is reserved for discovery.
  • One tool is one operation, so route takes a single method. Creating and updating a project are two tools with two schemas.
  • GET and DELETE build the input from the path params plus the query string; POST, PATCH, and PUT use the path params plus the JSON body. Path params always win, so ?projectId=other cannot override the URL.
  • Query and path values are always strings. A schema behind a GET route needs z.coerce.number() or z.coerce.boolean() rather than z.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.