MCP servers
Expose tools the assistant can use
If your system already speaks the Model Context Protocol — or you would rather write tools than implement a fixed contract — connect it as an MCP server. Seler discovers the tools; the workspace's owner switches on the ones a customer may trigger from a chat, and decides which ask the customer first.
Requirements
- Streamable HTTP transport at one
https://URL on a public host. Seler POSTs JSON-RPC to it and accepts either a JSON body or atext/event-streamanswer. The older SSE transport (a separate GET stream) is not supported. - Protocol version
2025-06-18or later. Seler sendsMCP-Protocol-Versionon every request and honoursMcp-Session-Idif you issue one. - Authorization: the owner can store one header value —
Bearer …,Basic …, or your own scheme — which Seler sends verbatim asAuthorization. It is encrypted at rest and never shown again. OAuth flows are not supported; issue a long-lived token for Seler. - Only
tools/listandtools/callare used. Resources, prompts and sampling are ignored.
What happens on connect
- Seler calls
initialize, sendsnotifications/initialized, then pages throughtools/list(up to 40 tools are kept). - Every tool is stored off. The owner sees the name and description you gave and turns on what they want. Nothing is offered to the model until then.
- Pressing إعادة قراءة in the dashboard lists the tools again: new ones arrive off, removed ones disappear, settings on the rest are kept.
- A
401/403from your server marks the connection as needing a new token; anything else marks it as needing attention with the reason shown.
How a tool is called
Each enabled tool becomes one function the model may call, named <server>_<tool> from the name the owner gave the server and your tool's name, so two servers offering lookup do not collide. The model sees your description and inputSchema exactly as you published them, and nothing about where the server is or how it is authorised.
- Seler opens a fresh session per call (
initialize, thentools/call). Keepinitializecheap. - 8 seconds to answer a call, 10 to list tools. A slow answer is reported to the customer as “the system is slow right now”.
- The model is shown the
textparts of your answer (joined, at most 8,000 characters) orstructuredContentwhen you return it — with a note that it is data, not instructions. isError: trueis passed to the model as a failure with your text, so say what went wrong in words a customer could be told.- Every call is written to the workspace's log: tool, arguments (truncated), duration, success or the error.
Read-only tools, and asking first
A tool that changes something is gated: the model must pass confirmed: true, and Seler refuses until the customer has been shown what will happen and agreed. This is code on Seler's side, not a prompt; a model that tries without asking simply gets the refusal back. The owner can relax it per tool.
Declare a tool that only reads with the standard annotation, and it is never gated:
{
"name": "get_balance",
"description": "The customer's account balance and loyalty points, by phone number.",
"inputSchema": {
"type": "object",
"properties": { "phone": { "type": "string", "description": "E.164" } },
"required": ["phone"]
},
"annotations": { "readOnlyHint": true }
}A minimal server
With the official TypeScript SDK, one tool, on Express. Run it on a public HTTPS host and enter its URL in the dashboard.
import express from "express";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const TOKEN = process.env.SELER_MCP_TOKEN;
function build() {
const server = new McpServer({ name: "erp-bridge", version: "1.0.0" });
server.registerTool(
"get_balance",
{
description: "The customer's account balance and loyalty points, by phone number.",
inputSchema: { phone: z.string() },
annotations: { readOnlyHint: true },
},
async ({ phone }) => {
const account = await erp.accountByPhone(phone);
if (!account) return { content: [{ type: "text", text: "No account for this number." }] };
return {
content: [{ type: "text", text: `Balance ${account.balance} IQD, ${account.points} points.` }],
structuredContent: { balance: account.balance, points: account.points },
};
},
);
server.registerTool(
"create_ticket",
{
description: "Open a support ticket for the customer. Changes data.",
inputSchema: { phone: z.string(), subject: z.string(), details: z.string() },
},
async (input) => {
const ticket = await erp.openTicket(input);
return { content: [{ type: "text", text: `Ticket ${ticket.number} opened.` }] };
},
);
return server;
}
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
if (req.get("authorization") !== `Bearer ${TOKEN}`) return res.status(401).end();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const server = build();
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3333);create_ticket has no readOnlyHint, so the owner sees it as “يغيّر بيانات” and, by default, the assistant asks the customer before calling it.
Limits
| Servers per workspace | 5 | |
| Tools kept per server | 40 | The first forty listed. |
| Tool name | ≤ 64 chars | Letters, digits and underscore survive; anything else becomes _. |
| Description | ≤ 1,000 chars | Shown to the owner and to the model. |
| Answer size | 256 KB | Larger answers are dropped. |
| Text shown to the model | 8,000 chars | The text parts, joined. |
| Timeouts | 8 s call · 10 s list |
Checklist
- Streamable HTTP at one https:// URL on a public host.
- The
Authorizationheader checked on every request. - Every read-only tool carries
annotations.readOnlyHint: true. - Descriptions say what the tool does in one sentence a shop owner can read.
- Errors come back as
isError: truewith a sentence, not a stack trace. - A tool that reveals personal data trusts nothing about who is asking.