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

Small things that mattered

← All posts