Skip to main content
Glama
README.md
# vybeflow MCP

Model Context Protocol servers for [vybeflow](https://vybeflow.vyber.id) — a multi-channel
messaging platform (WhatsApp, Telegram, Instagram, email, web chat) with an inbox, campaigns,
contacts, a product catalogue and billing.

Point Claude, Cursor, or any MCP-capable assistant at your vybeflow tenant and let it read
your inbox, look up a contact, check a campaign's progress, or send a message — inside
permissions you choose, with a key that expires.

> **Not on npm yet.** The packages run, but they are unpublished, so `npx -y @vybeflow/mcp-inbox`
> will not resolve. Clone this repo, `npm install`, and point your client at the bin directly.
> Every `@vybeflow/…` name below is the published name it *will* have.

```jsonc
// claude_desktop_config.json
{
  "mcpServers": {
    "vybeflow-inbox": {
      "command": "node",
      "args": ["/path/to/vybeflow-mcp/packages/inbox/bin/cli.js"],
      "env": { "VYBEFLOW_API_KEY": "vf_live_..." }
    }
  }
}
```

---

## Getting a key

In the vybeflow console: **Pengaturan → API Key → Buat API Key**.

You choose two things, and both matter:

- **What it may do.** Scopes come from the same permission catalogue that governs staff
  accounts, so a key can never do something a role could not. You cannot grant a scope you do
  not hold yourself, and `iam.*` — the permissions that mint roles and other keys — can never
  be given to a key at all.
- **When it stops.** An expiry is required. 90 days by default, 365 maximum. A bearer
  credential with no end date outlives the integration it was made for.

The key is shown **once**. The server stores only a salted SHA-256 digest of it; nobody,
including us, can recover it afterwards. Revoking is instant in the console and takes effect
across all services within about a minute.

---

## The design: two tools, not two hundred

This is the part worth reading before you write your own MCP server.

vybeflow registers **249 routes**. The obvious design — one MCP tool per endpoint — costs
roughly 225 tokens per serialised tool definition, so arming all of them would burn about
**56,000 tokens of context on every single turn**, including the turn where you say "hi". It
is resident cost: it is paid before the system prompt, before your message, and again on the
next message, forever.

So these servers expose **two tools, always**:

| Tool | What it does |
|---|---|
| `list_api_endpoints({ domain?, q? })` | Returns compact rows — `endpointId`, summary, risk, whether it writes. Search with `q`. |
| `call_api({ endpointId, path?, query?, body?, headers?, fields?, where?, slice?, confirm?, describe? })` | The one door. |

**Discovery is a tool *result*, not a tool *definition*.** A result can be dropped from the
conversation once it has been used; a tool definition sits in every request forever. Two tools
cost the same whether the registry holds thirty endpoints or five hundred.

`call_api` with `describe: true` returns the endpoint's specification — schema, headers, the
permission it needs — without executing anything. That used to be a third tool; folding it in
saved a definition on every turn for something a model needs once per endpoint and never
again.

### The flow: domain → endpoint → call

```
1. domain      which server you installed, e.g. @vybeflow/mcp-campaign
2. discover    list_api_endpoints()          -> 7 rows: endpointId, summary, risk
3. inspect     call_api({ endpointId, describe: true })
                                             -> schema, path params, required scope
4. call        call_api({ endpointId, ... })
               risk: safe        -> executes
               risk: confirm     -> awaiting_confirmation + actionId
               risk: destructive -> awaiting_confirmation + a warning, then
                                    call_api({ ..., confirm: actionId })
```

The domain is chosen **before** anything else, and it is a real boundary rather than a
convention: a campaign server asked for `inbox.ticket.list` answers `wrong_server` and names
the package to install instead. Step 3 is optional for an endpoint that needs no arguments,
and unavoidable for one that does — a bare `endpointId` on a parameterised endpoint returns
the specification rather than a broken request.

Run it yourself: `node scripts/demo-flow.mjs`.

### One server per domain

Seven packages, each carrying only its own slice of the registry:

| Package | Domain | Endpoints |
|---|---|---|
| `@vybeflow/mcp-inbox` | tickets and conversations | 7 |
| `@vybeflow/mcp-campaign` | blast campaigns | 7 |
| `@vybeflow/mcp-contact` | customer records | 6 |
| `@vybeflow/mcp-channel` | WhatsApp, Telegram, Instagram | 5 |
| `@vybeflow/mcp-product` | catalogue | 5 |
| `@vybeflow/mcp-billing` | invoices, quota, usage | 5 |
| `@vybeflow/mcp-tenant` | company profile | 2 |

Install only what you need. An assistant that manages campaigns has no business listing your
invoices, and the cheapest way to enforce that is not to load the tool at all.

---

## The guardrails, and why each exists

Most of this file is about refusing well. A refusal that carries a plan is worth more than an
error.

**Risk is declared, never inferred from the HTTP verb.** The verb lies. `POST /tickets/:id/close`
is a tidy-up; `POST /notification/send/whatsapp` messages a real customer and spends quota.
Both are POSTs. Anything not marked `safe` returns `awaiting_confirmation` with an `actionId`
and executes only on a second call carrying `confirm: actionId`. **That is not a failure** —
it is the confirmation step, and the tool description says so, because a model that reads it
as an error will retry instead of confirming.

**Oversized answers are refused, not truncated.** A model handed 600KB of rows will chunk it,
read one chunk, and confidently invent the rest — we have watched it happen with a product
list, complete with invented prices. So `call_api` refuses, and the refusal names the row
count, the filters that exist, and a histogram of the values actually present, so the next
call can narrow instead of guess.

**`where` has ten typed operators and no regex.** Two reasons. A model-supplied regular
expression is uncancellable once V8 starts matching, so one catastrophically backtracking
pattern hangs the process. And a regex invites matching the wrong thing: `match: "terlambat"`
against a field whose value is the English `"late"` returns zero rows, and zero rows reads as
*"there are none"* — a confident, specific, wrong answer.

**Fields can only narrow.** Every endpoint declares a `project` allow-list of the fields it
returns, and `fields` can subset that but never widen it. On some endpoints the allow-list is
a secret guard: the company-profile record also holds a Midtrans server key, and the allow-list
is what keeps it out of a conversation.

**Loop breakers.** Two identical calls is a retry; three is a loop. The third gets
`status: "repeating"` — but the refusal still carries the endpointIds, so it is not a dead end.

**A guessed domain is answered, not rejected.** Ask for `sales` and you get a search across
every domain rather than a rejection, because a model that guessed a domain name still wants
something specific.

**403 is terminal.** These servers hold exactly one credential and have no second identity to
retry as, so a 403 means the key lacks that scope. The refusal names the missing permission
and says to widen the key — explicitly *not* to look for another endpoint with the same
effect.

---

## Security posture

What this server does and does not defend against, stated plainly so you can decide whether
it is safe to point at your tenant.

**The credential is never argument-controlled.** Headers are an allow-list drawn from the
registry, and the API key is applied last and unconditionally. An earlier version spread
caller-supplied headers over the defaults, which let a tool argument replace the server's own
key and inject `x-active-tenant` — the request went out carrying the wrong credential. Undeclared
query parameters are refused the same way: the registry is the contract.

**Confirmation handles are unguessable and expire.** `actionId` is 16 bytes of CSPRNG, valid
for five minutes, and the pending set is bounded. Possession of a handle is never treated as
authentication. This matters most for the planned hosted transport, where one process would
serve many users.

**Returned data is labelled as data.** Rows include customer-written text — an inbox ticket is
literally a message from a member of the public — and it reaches the model through the same
channel as your instructions. Every data-bearing result carries a `provenance` note saying so.
**This is mitigation, not immunity.** Prompt injection through third-party content is an
unsolved problem; keep tool approval on, and do not let a model act unattended on what a row
says.

**The key only goes where you point it.** `VYBEFLOW_BASE_URL` must be HTTPS (loopback excepted),
and a non-default host is refused unless you set `VYBEFLOW_ALLOW_CUSTOM_HOST=true`. An MCP
config is a snippet people paste from the internet; one altered line should not silently
forward a live key somewhere else.

**Scope is the real boundary.** The server enforces nothing itself — vybeflow does. A key
carries a permission list, checked by the same middleware that checks a logged-in employee,
so the worst a compromised MCP server can do is what its key already allowed. Mint narrow
keys with short expiries.

**Requests are rate limited, per key and per tenant.** A token bucket: bursts are allowed up
to the bucket size, then the rate settles. Writes cost three tokens to a read's one. Every
response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`; a refusal is
`429` with `Retry-After`, and `call_api` surfaces that as `rate_limited` with the server's own
number rather than a generic error — so the model waits and repeats the same call instead of
casting around for another endpoint.

The tenant ceiling is the part that matters. A per-key limit alone is bypassed by minting a
second key; since a tenant may hold twenty live keys, a key-only limit is a 20× limit wearing
a disguise.

**Not covered.** Limits are per service process, so a key doing 120/min gets 120/min *at each
service*, not across the platform — the goal is that no one service can be hammered, and a
shared counter would put a network round-trip on every request in the estate. No audit of
*which* MCP client used a key; `lastUsedAt` is accurate to about a minute. Revoking takes
effect within roughly a minute, not instantly. stdio only, so the server runs with your user's
privileges like any other local MCP server.

## Authentication

Everything goes over HTTPS to `https://vybeflow.vyber.id` with your key in `x-api-key`
(`Authorization: Bearer vf_live_…` also works).

A key resolves, server-side, to a tenant and a permission list, and is then checked by the
same middleware that checks a logged-in employee. The enforcement layer never learns it is
talking to a machine — which is exactly why a key cannot exceed its scopes.

```bash
curl -H "x-api-key: vf_live_..." \
  https://vybeflow.vyber.id/api/campaign/campaigns
```

## Machine-readable documentation

| URL | What |
|---|---|
| [`/openapi.json`](https://vybeflow.vyber.id/openapi.json) | OpenAPI 3.1, every public endpoint |
| `/openapi/<domain>.json` | one domain at a time |
| [`/llms.txt`](https://vybeflow.vyber.id/llms.txt) | short index, [llmstxt.org](https://llmstxt.org) convention |
| [`/llms-full.txt`](https://vybeflow.vyber.id/llms-full.txt) | every endpoint with its permission and risk |
| [`/.well-known/mcp.json`](https://vybeflow.vyber.id/.well-known/mcp.json) | server discovery |

Every operation carries `x-vybeflow-scope` (the permission your key needs) and
`x-vybeflow-risk`. All of it is generated from `packages/core/src/registry/` — the same file
the servers run on, so the documentation cannot drift from the behaviour.

---

## Repository layout

```
packages/core/src/registry/     the API described as data, one file per domain
packages/core/src/RegistryExecutor.js   the runtime and every guardrail above
packages/core/src/ServiceBases.js       service id -> public URL
packages/<domain>/bin/cli.js    ~10 lines: pick a domain, speak MCP over stdio
scripts/check-registry-contract.mjs     proves the registry matches the real routes
scripts/export-docs.mjs                 renders OpenAPI, llms.txt and the docs page
scripts/test-executor.mjs               guardrails, incl. the hardening above
scripts/test-mcp-handshake.mjs          drives a real client over stdio
scripts/demo-flow.mjs                   the four-step flow, end to end
```

A registry entry is **data about somebody else's code**, in another repository, with no import
between them — nothing stops a route being renamed and this file staying confidently wrong. So
`check:registry` walks the real route tables and compares method, path and the literal
permission code, and fails when they disagree. It is what makes the published documentation a
claim rather than a hope.

```bash
npm test              # executor guardrails + registry contract
npm run check:registry
npm run export:docs   # regenerate the published documentation
```

## Status

Early. stdio transport today; a hosted endpoint at `vybeflow.vyber.id/mcp/<domain>` is
planned, so no local install is needed. Not yet published to npm — run from source. Instagram
has no package — the service is not directly reachable and most of its routes are Meta
webhooks or media bytes.

## Licence

MIT.