JSON Schema-First Executors for LLM Tool Calling and MCP
BlockNext has dozens of integration nodes: Slack, Telegram, Google Drive, LLM providers, image and video generators, and more. Each node shows up in several places. There's a form in the workflow editor, validation before it runs, a function declaration when an LLM fills in its parameters, and a tool definition when an AI agent calls it over MCP.
If each of those had its own description of the node's inputs, they would drift apart. A field renamed in the form but not in the validator, or a new required field the LLM never hears about. So every node is described exactly once, as a JSON Schema, and everything else is derived from it.
One descriptor per node
Here is the Slack "send message" node, slightly trimmed. It's a plain Go struct with metadata, the credential type it needs, and an input and output schema built with google/jsonschema-go:
func NewSlackSendMessageNode(nodeID string) *SlackSendMessageNode {
return &SlackSendMessageNode{
ID: nodeID,
Kind: nodes.NodeKindAction,
Name: "Slack Send Message",
Description: "Send a text message to a Slack channel.",
SupportedCredentials: []string{"slack_oauth2"},
InputSchema: &gjs.Schema{
Type: "object",
Properties: map[string]*gjs.Schema{
"channel": {
Type: "string",
Title: "Channel",
Description: "Slack channel ID where the message will be posted.",
},
"text": {
Type: "string",
Title: "Text",
Description: "Message text to post to the Slack channel.",
},
},
Required: []string{"channel", "text"},
},
OutputSchema: &gjs.Schema{ /* array of { status, messageId } */ },
HasNaturalLanguage: true,
Annotations: nodes.NodeAnnotations{
Destructive: new(false),
},
}
}The Title and Description on each property aren't decoration. The title is the form label in the editor. The description is what the LLM and the MCP client read to understand the field. Writing them well is how you get good tool calls.
The executor stays typed
A schema-first design can easily turn into map[string]any everywhere. To avoid that, the executor works with a typed input struct, and a generic validator stands between the raw data and that struct:
type SlackSendMessageExecutorInput struct {
Channel string `schema:"channel"`
Text string `schema:"text"`
}
func (e *SlackSendMessageExecutor) ExecuteWithContext(
ctx context.Context,
credentials map[string]any,
data []map[string]any,
) ([]map[string]any, error) {
// ...build a Slack client from the resolved OAuth credential...
results := make([]map[string]any, 0)
for _, item := range data {
input, err := e.validator.Parse(item) // defaults, validation, typed struct
if err != nil {
return nil, err
}
// ...POST /chat.postMessage with input.Channel and input.Text...
}
return results, nil
}Parse does three things in order. It copies schema defaults into empty fields, validates the record against the schema with santhosh-tekuri/jsonschema, and binds it onto the struct. By the time the executor sees input.Channel, it is guaranteed to be there.
Every executor has the same signature: a context, the resolved credentials, an array of items in, an array of items out. The array is deliberate. Every node in BlockNext processes a batch, so "send a message for each row" needs no loop construct in the workflow.
Registration is where everything gets derived
Each provider has one register.go. It builds the node, creates the validator from the node's own input schema, and registers the same node in each place that needs it:
func Register(fileGateway filegateway.FileGateway) {
nodeID := "slack"
sendMessageNodeID := nodeID + "_send_message"
sendMessageNode := sendmessage.NewSlackSendMessageNode(sendMessageNodeID)
sendMessageValidator := jsonschema.New[sendmessage.SlackSendMessageExecutorInput](sendMessageNode.GetInputSchema())
sendMessageExecutor := sendmessage.NewSlackSendMessageExecutor(sendMessageNodeID, sendMessageValidator)
nodes.RegisterNode(sendMessageNode)
executors.RegisterExecutor(sendMessageExecutor)
functioncalling.RegisterFunctionCalling(functioncalling.Generate(sendMessageNode))
// ...send media node, webhook adapter, MCP server...
}The validator's type parameter is the executor's input struct, and its schema is the node's InputSchema. There's no second copy of the field list anywhere.
From schema to function declaration
LLM function calling expects a name, a description and a JSON Schema for the parameters, which is exactly what the node already has. The conversion is little more than a re-encode:
func fromNode(node nodes.NodeManager) map[string]any {
name := strings.ReplaceAll(node.GetID(), ".", "_")
parameters := map[string]any{
"type": "object",
"properties": map[string]any{},
}
if input := node.GetInputSchema(); input != nil {
if data, err := json.Marshal(input); err == nil {
var asMap map[string]any
if err := json.Unmarshal(data, &asMap); err == nil {
parameters = asMap
}
}
}
return map[string]any{
"name": name,
"description": node.GetDescription(),
"parameters": parameters,
}
}Only nodes that set HasNaturalLanguage get a declaration. In BlockNext the LLM doesn't decide which node to run. The workflow defines that. It fills in a node's parameters from plain-language instructions, like "post a short summary of this to the team channel". Anything the user set explicitly is merged on top of what the model inferred, so a configured value always wins.
And the same schema becomes an MCP tool
The provider's register.go also registers an MCP server whose tools are those same node structs. The MCP adapter takes the node's input schema, adds what an external agent needs (a reference to a saved credential), and publishes it as the tool's schema. The Annotations on the node map directly onto MCP's tool hints, such as read-only, destructive or idempotent. I wrote about the MCP side in detail in Exposing Workflow Nodes as MCP Tools.
What this buys you
- Adding a node is one place to change. Describe the inputs once, write the executor against a typed struct, and the form, validation, function calling and MCP tool all follow.
- The UI, the LLM and external agents can't disagree. They all read the same schema, so a required field is required everywhere.
- Descriptions get real attention. When the same sentence is a form hint, a prompt for the model and documentation for an agent, people write it properly.
Small things that mattered
- Function names have rules. Node IDs can contain dots, but function names for the LLM providers don't allow them. The conversion replaces them with underscores.
- Tell "false" apart from "not set". Tool annotations like
Destructiveare*bool, because "this tool is not destructive" and "nobody said" mean different things to an MCP client. Go 1.26'snew(false)makes that pointer a one-liner. - Long-running jobs stay one node. Video and music generation can take minutes. Instead of exposing "start", "check status" and "fetch result" as separate nodes, the executor starts the job and polls the provider itself. The schema stays honest: one input, one finished result.