Exposing Workflow Nodes as MCP Tools

BlockNext has dozens of integration nodes: send a Slack message, upload to Google Drive, generate an image, post to Telegram. Inside a workflow they run on a canvas. But the same operations are exactly what an AI agent wants as tools. So every provider in BlockNext is also an MCP server, and every node is one of its tools. Point Claude or any other MCP client at a BlockNext server with an API key, and it can use your connected services from a chat.

In the open-source edition the unit is the node: each node is a tool. A separate platform server lets an agent list and inspect workflows and turn their triggers on or off. Here's what it took.

One server per provider

Each provider registers an MCP server whose tools are the same node structs the workflow engine uses. The MCP module collects all of them and mounts one stateless, streamable HTTP endpoint per server, built on the official Go SDK, modelcontextprotocol/go-sdk:

GET  /servers                                          catalog of every MCP server
GET  /.well-known/oauth-protected-resource/:serverId/mcp  RFC 9728 metadata
POST /:serverId/mcp                                    streamable MCP endpoint (stateless)

Stateless matters here. There's no session to pin to an instance, so the mcp-api binary can be scaled like any other HTTP service.

Because every node is already described by a JSON Schema, the tool's name, description and input schema come for free. I explained that pattern in JSON Schema-First Executors for LLM Tool Calling and MCP. But the node schema isn't quite what an external client needs, so the adapter changes it in a few places.

Credentials as references, never as values

Inside a workflow, a node gets its credentials from the workflow's configuration. An MCP client has no workflow. It needs a way to say "use the Slack connection of my organization", without ever seeing the token.

The adapter adds a required credentials object to every tool's input schema. Its values are references to saved credentials, with a pattern the client can validate before it calls:

func augmentInputSchema(original *gjs.Schema, credentialKeys []string) *gjs.Schema {
	if len(credentialKeys) == 0 {
		return original
	}

	base := &gjs.Schema{Type: "object", Properties: map[string]*gjs.Schema{}}
	if original != nil {
		base = new(*original)
	}

	properties := make(map[string]*gjs.Schema, len(base.Properties)+1)
	maps.Copy(properties, base.Properties)

	credentialProps := make(map[string]*gjs.Schema, len(credentialKeys))
	for _, key := range credentialKeys {
		credentialProps[key] = &gjs.Schema{
			Type:        "string",
			Description: "Reference to a saved credential. Format: credential:organization:<uuid>.",
			Pattern:     "^credential:organization:[0-9a-fA-F-]+$",
		}
	}

	properties[credentialsKey] = &gjs.Schema{
		Type:        "object",
		Title:       "Credentials",
		Description: "References to saved credentials required by this tool.",
		Properties:  credentialProps,
		Required:    append([]string{}, credentialKeys...),
	}

	base.Properties = properties
	base.Required = append(append([]string{}, base.Required...), credentialsKey)
	return base
}

When a call comes in, a resolver parses the reference and checks three things before anything runs: the credential belongs to an organization, it's the organization the caller authenticated as, and its type is the one the tool asks for. Expired OAuth tokens are refreshed at that point too. The secret itself never leaves the server, and the agent only ever handles an ID.

An output shape MCP accepts

Every BlockNext node returns an array of items, because workflows process batches. MCP structured output, however, expects an object. The adapter wraps the node's output schema in one, under an items key:

func wrapOutputSchema(original *gjs.Schema) *gjs.Schema {
	if original == nil {
		return nil
	}
	return &gjs.Schema{
		Type:       "object",
		Properties: map[string]*gjs.Schema{itemsKey: original},
		Required:   []string{itemsKey},
	}
}

It's a small change, but without it the tools would declare an output schema that clients are entitled to reject.

Tell the client what a tool does to the world

MCP has tool annotations: hints that a tool is read-only, destructive, idempotent, or talks to the open world. Clients use them to decide when to ask the user for confirmation. Nodes carry the same hints, and the adapter maps them across:

func mcpAnnotations(a nodeengineContract.NodeAnnotations) *mcpsdk.ToolAnnotations {
	return &mcpsdk.ToolAnnotations{
		ReadOnlyHint:    a.ReadOnly,
		DestructiveHint: a.Destructive,
		IdempotentHint:  a.Idempotent,
		OpenWorldHint:   a.OpenWorld,
	}
}

The fields are *bool, not bool. "This tool is not destructive" is a statement. A missing value is the absence of one, and a client should treat the two differently.

Limits per organization

An agent in a loop can call a tool much faster than a person clicking a button. Each call takes a slot in a per-organization semaphore. A heartbeat keeps the slot alive while the call runs, and it's released even if the request's context was cancelled:

func (a *adapter) acquireSlot(ctx context.Context, organizationID uuid.UUID) (func(), error) {
	holderID := bnuuid.NewV7()

	semaphore, err := a.semaphoreManager.AcquireSemaphore(ctx, organizationID, holderID, a.maxConcurrentExecutions)
	if err != nil {
		return nil, err
	}

	heartbeatCtx, stopHeartbeat := context.WithCancel(ctx)
	go a.runSemaphoreHeartbeat(heartbeatCtx, organizationID, holderID)

	releaseCtx := context.WithoutCancel(ctx)

	return func() {
		stopHeartbeat()
		_ = a.semaphoreManager.ReleaseSemaphore(releaseCtx, organizationID, holderID, semaphore)
	}, nil
}

The release uses context.WithoutCancel on purpose. If the client disconnects mid-call, the request context is cancelled, but the slot must still be returned, or the organization slowly loses capacity. The semaphore uses the same in-process-or-Redis provider as the rest of the platform.

Every finished call is also recorded through the executions module: parameters without secrets, credential references and outputs. Tool calls from agents show up in the same history as workflow runs. If recording fails, that failure is logged and never returned to the caller.

Authentication: API keys and a real OAuth 2.1 server

The simplest way in is an organization API key with the mcp:invoke scope, sent as a header. That's fine for scripts and for clients you configure by hand.

For clients like Claude, the MCP authorization spec expects proper OAuth, and BlockNext ships its own authorization server for it. When a request arrives without valid credentials, the endpoint answers 401 with a WWW-Authenticate header pointing to the protected resource metadata (RFC 9728). From there the client discovers the authorization server (RFC 8414), registers itself (RFC 7591), and runs an authorization code flow. The consent screen asks the user to approve access for one of their organizations.

A few rules I kept strict:

Takeaway

Wrapping a function as an MCP tool takes an afternoon. Making it safe for agents you don't control is the actual work: credentials that agents reference but never see, limits that hold when an agent loops, an honest description of side effects, and authorization that follows the spec instead of a shared secret in a config file. If your platform already describes its operations as schemas, most of the tool definition is already done.

← All posts