Skip to content
K

Tool Registration

Every plugin provides one or more MCP tools via the tools array. Each tool is defined with a PluginToolDefinition that specifies its name, description, input schema, and handler function.

interface PluginToolDefinition {
/** Tool name, e.g., "mm_migrate_obsidian" */
name: string;
/** Human-readable description of what the tool does */
description: string;
/** Zod schema for validating tool input */
inputSchema: z.ZodType;
/** Handler function that executes the tool logic */
handler: (args: unknown) => Promise<unknown>;
}

All Monday Morning tools use the mm_ prefix:

PatternExamplePurpose
mm_<verb>_<noun>mm_sync_githubAction on a resource
mm_<plugin>_<action>mm_link_github_prPlugin-specific action
mm_<noun>mm_hello_worldSimple single-purpose tools

Input schemas use Zod for runtime validation. The schema is used both for type inference and for generating the JSON Schema that AI agents see.

import { z } from "zod";
export const MyToolInputSchema = z.object({
project_path: z.string().describe("Absolute path to the project root"),
query: z.string().describe("Search query string"),
limit: z.number().optional().default(10).describe("Maximum results to return"),
});
export type MyToolInput = z.infer<typeof MyToolInputSchema>;

Most tools should include project_path as the first field:

// Required project path — almost every tool needs this
project_path: z.string().describe("Absolute path to the project root"),

Enum fields for constrained choices:

direction: z
.enum(["pull", "push", "both"])
.optional()
.describe("Sync direction (default: both)"),

Optional fields with defaults:

format: z
.enum(["markdown", "json"])
.optional()
.default("markdown")
.describe("Output format"),

Boolean flags:

dry_run: z
.boolean()
.optional()
.default(false)
.describe("Preview changes without applying them"),
  1. Always add .describe() — The description string is shown to AI agents. Be specific about what the field does and what values are valid.

  2. Use z.enum() for fixed choices — This gives agents a clear set of options instead of a free-text field.

  3. Provide defaults for optional fields — Agents can then call your tool with minimal arguments.

  4. Include project_path — The MCP server passes the active project path to tool calls. Most tools need it to locate the .mm/ directory.

The handler receives the raw arguments from the MCP server, validates them against the schema, and returns a result:

{
name: "mm_my_tool",
description: "Does something useful",
inputSchema: MyToolInputSchema,
handler: async (args: unknown) => {
// 1. Parse and validate input
const input = MyToolInputSchema.parse(args);
// 2. Execute business logic
const result = await doSomething(input);
// 3. Return structured result
return result;
},
}

Keep handler functions thin. Put business logic in separate files under lib/:

src/index.ts
import { doSomething, DoSomethingInputSchema } from "./lib/do-something.js";
const myPlugin: MondayMorningPlugin = {
// ...
tools: [
{
name: "mm_do_something",
description: "Does something useful",
inputSchema: DoSomethingInputSchema,
handler: async (args: unknown) => {
const input = DoSomethingInputSchema.parse(args);
return doSomething(input);
},
},
],
};
src/lib/do-something.ts
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
export const DoSomethingInputSchema = z.object({
project_path: z.string().describe("Absolute path to the project root"),
target: z.string().describe("What to operate on"),
});
export type DoSomethingInput = z.infer<typeof DoSomethingInputSchema>;
export interface DoSomethingOutput {
success: boolean;
message: string;
}
export async function doSomething(
input: DoSomethingInput
): Promise<DoSomethingOutput> {
const mmDir = path.join(input.project_path, ".mm");
// ... implementation
return { success: true, message: "Done" };
}

If your handler needs the logger or path utilities from registration, capture the context:

let pluginContext: PluginContext | undefined;
const myPlugin: MondayMorningPlugin = {
// ...
register: async (context: PluginContext): Promise<void> => {
pluginContext = context;
},
tools: [
{
name: "mm_my_tool",
description: "Tool that uses plugin context",
inputSchema: MyToolInputSchema,
handler: async (args: unknown) => {
const input = MyToolInputSchema.parse(args);
pluginContext?.logger.info(`Running tool with: ${input.target}`);
// ... logic
return { success: true };
},
},
],
};
export default myPlugin;
// Re-export for use by lib/ modules
export function getPluginContext(): PluginContext | undefined {
return pluginContext;
}

Tool handlers should return structured objects. Common patterns:

// Success with data
return {
success: true,
data: { items: [...], count: 42 },
};
// Success with message
return {
success: true,
message: "Synced 5 issues from GitHub",
pulled: 5,
pushed: 0,
};
// Error
return {
success: false,
error: "GitHub token not configured. Set it in Settings > Plugins > GitHub.",
};

Zod’s .parse() throws a ZodError if validation fails. The MCP server catches this and returns a structured error to the agent. For business logic errors, return an error object instead of throwing:

handler: async (args: unknown) => {
const input = MyToolInputSchema.parse(args);
try {
const result = await riskyOperation(input);
return { success: true, data: result };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
},

A plugin can register any number of tools. Group related functionality into a single plugin:

const myPlugin: MondayMorningPlugin = {
id: "jira",
name: "Jira Integration",
// ...
tools: [
{
name: "mm_sync_jira",
description: "Sync issues between Jira and Monday Morning",
inputSchema: SyncJiraInputSchema,
handler: async (args) => { /* ... */ },
},
{
name: "mm_link_jira_ticket",
description: "Link a Jira ticket to a Monday Morning spec",
inputSchema: LinkJiraTicketInputSchema,
handler: async (args) => { /* ... */ },
},
{
name: "mm_jira_status",
description: "Check the sync status of a Jira project",
inputSchema: JiraStatusInputSchema,
handler: async (args) => { /* ... */ },
},
],
};

Community plugins don’t have access to the internal paths module. Use path.join() with project_path to build paths to the .mm/ directory:

import * as path from "path";
import * as fs from "fs/promises";
// Get the .mm/ directory for a project
const mmDir = path.join(projectPath, ".mm");
// Build paths to specific directories
const specsDir = path.join(projectPath, ".mm", "specs");
const issuesDir = path.join(projectPath, ".mm", "issues");
// Check if a directory exists
async function exists(p: string): Promise<boolean> {
try { await fs.access(p); return true; } catch { return false; }
}