Give an agent a custom tool without an MCP server
Verified August 11, 2026
Hosted MCP servers cover the popular SaaS. They do not cover your billing tables, your internal admin API, or the service only your team runs. That is usually the tool you actually wanted.
You do not need to build an MCP server for it. You do not need an SDK, a client library, or a framework. You need one HTTP endpoint that takes JSON and returns JSON.
This walks through that endpoint. The finished worker is about thirty lines and has no dependencies.
What you need
- Node 20 or newer, or any language that can serve an HTTP POST
- An OpenRouter account
- A substructure project
Install the CLI
npm i -g @substructure.ai/cliWrite the worker
The engine proposes every step of a turn and asks your endpoint what to do with it. Return the proposal and you accept it.
import { createServer } from "node:http";
function decide({ proposed }) {
return proposed;
}
const server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(decide(JSON.parse(body))));
});
});
server.listen(4444);That is a complete, working agent. It behaves exactly like an agent with no worker at all, because it agrees with every proposal. Everything from here is you disagreeing on purpose.
Note what is absent. No import from us. No SDK, no client, no base class. The contract is the request body.
Point an agent at it
name = "support-bot"
[llm.openrouter]
type = "openrouter"
[agent.support]
llm = "openrouter"
model = "z-ai/glm-5.2"
system = "You are the support assistant. Look customers up before you answer."
worker = "http://localhost:4444"[agent.support] declares the agent: its model, its prompt, and now the endpoint that decides for it. worker is the whole switch. Agents that name one use it, and the rest stay with the engine in the same file.
No [remote] section yet, so the engine runs on this machine and reaches localhost the way any other program on it does. Nothing is deployed until the file names a deployment.
Declare the tool
The engine sends session.start when a session opens. Answer it with the agent config you want, including the tools the model should see.
const tools = [
{
name: "lookup_customer",
description: "Look up a customer account by email address.",
input: {
type: "object",
properties: { email: { type: "string" } },
required: ["email"],
},
},
];
function decide({ trigger, proposed }) {
if (trigger.type === "session.start") {
return { agent: { ...proposed.agent, tools } };
}
return proposed;
}Spread proposed.agent rather than replacing it. The proposal already carries the model and the prompt from your config file, and this adds to it instead of throwing it away.
The model sees name, description, and input. The description is the part that decides whether it gets called at the right moment, so write it for a reader who cannot see your code.
Run the tool
When the model calls the tool, the engine sends tool.execute. Do the work and answer with a result.
async function lookupCustomer({ email }) {
// Your database, your API, your network. The engine reaches none of it.
const row = await db.customer.findByEmail(email);
if (!row) throw new Error(`No customer with email ${email}.`);
return `${row.name} is on the ${row.plan} plan since ${row.since}.`;
}
async function decide({ trigger, proposed }) {
if (trigger.type === "session.start") {
return { agent: { ...proposed.agent, tools } };
}
if (trigger.type === "tool.execute" && trigger.name === "lookup_customer") {
try {
const result = await lookupCustomer(trigger.input.value);
return { actions: [{ type: "tool.result", result }] };
} catch (err) {
return { actions: [{ type: "tool.error", error: err.message }] };
}
}
return proposed;
}This is the whole idea. Your credentials, your database connection, and your network stay on your side. The engine sends you the arguments the model chose and takes back whatever you return.
A tool.error is an answer, not a crash. The model reads it and can correct itself, which is why the message should say what went wrong in words the model can act on. "No customer with email x" beats "lookup failed".
Try it
Run the worker in one terminal and a turn in another.
node server.mjsexport OPENROUTER_API_KEY=sk-or-...
subs run --agent support -o pretty "what plan is priya@acme.com on?"The model calls lookup_customer, your endpoint answers, and the model writes the reply around it.
The engine ran here, so it read the key from your environment and POSTed each decision to http://localhost:4444. Change the worker, run it again, and the next turn takes the change. See Local development.
Take it live
http://localhost:4444 works while the engine runs on your machine. A deployment needs a URL it can reach, so deploy the worker anywhere that serves HTTP and name a [remote] in the same edit.
[agent.support]
worker = "https://tools.internal.example.com/agent"
[remote]
url = "https://api.substructure.ai"subs apply
subs llm set-key openrouter[remote] is the deployment the CLI talks to. subs apply writes the org and the project back under it, and subs llm set-key puts the provider key there, so the deployment no longer needs anything from your environment.
That section also decides where a turn runs. With one in the file, subs run sends the turn to the deployment — which cannot see localhost. Keep a second file without a [remote] for the local loop.
subs run -c substructure.dev.toml --agent support -o pretty "..."Verify the signature
A deployment signs every decision it sends. Check it before you act on one.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.SUBS_SIGNING_SECRET;
function verify(body, header) {
const expected = `sha256=${createHmac("sha256", SECRET).update(body).digest("hex")}`;
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Refuse anything that does not match.
if (!verify(body, req.headers["x-substructure-signature"])) {
res.writeHead(401).end();
return;
}Each agent with a worker gets its own signing secret. The deployment creates it on the first subs apply that gives the agent a worker. Read it with subs agents secret support.
An engine on your machine signs only when the agent names signing_secret_env, so a local turn arrives unsigned and this check refuses it. Leave the check off while you develop, or name the variable and export it.
[agent.support]
signing_secret_env = "SUBS_SIGNING_SECRET"Put it in Slack
The tool belongs to the agent, so any frontend the agent answers on can reach it.
[slack]
dm = "support"
mentions = "support"subs apply
subs slack connectNow anyone in the channel can ask about a customer and the answer comes from your own database.
Mix custom tools with hosted ones
An agent can hold both. Declare the MCP connection for the SaaS you do not own and keep your own tools in the worker.
[mcp.stripe]
url = "https://mcp.stripe.com"
auth = "token"
[agent.support]
worker = "https://tools.internal.example.com/agent"
mcp = [{ id = "stripe", tools = { read_only = true } }]The model sees one tool list. Stripe's tools arrive prefixed with the connection id and yours arrive with the names you gave them.
When an MCP server is worth it
Build one when the same tools have to serve several agents, several products, or people outside your team. That is what the protocol is for, and a server pays for itself once there is more than one consumer.
For one agent reaching one internal system, the webhook is the shorter path and there is nothing extra to run.
If it does not work
- The model never calls the tool. Read the description as if you were the model. Vague descriptions get skipped.
session.startreplaced the config. Spreadproposed.agentinstead of returning a baretoolsarray, or you drop the model and the prompt.- Arguments arrive somewhere unexpected. They are on
trigger.input.value, not ontriggerdirectly. - The engine cannot reach the worker. A deployment cannot see
localhost. Deploy the worker, or run turns from a file with no[remote]while you iterate. - The turn ran somewhere unexpected.
[remote]decides. A file that names one sendssubs runto the deployment; a file that names none runs the engine here.subs runalso looks forsubstructure.tomlin the working directory only, so a turn started from a subdirectory finds no file and goes to the deployment. - 401 from your own endpoint. The signing secret belongs to the agent. Read it with
subs agents secret <id>. A local engine sends no signature unless the agent namessigning_secret_env. - Anything else. Run
subs doctor.
Next steps
- Workers: every trigger the engine sends, and what you can return.
- Tool calls: schemas, output validation, and where a tool runs.
- Async tools when the work takes longer than a request.
- Local development: the fast loop before any of this is deployed.
- Connect Sentry and Linear to Slack for the hosted-connector side of the same agent.