@rynger/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/config.js +31 -0
- package/dist/guards.js +101 -0
- package/dist/http.js +95 -0
- package/dist/index.js +79 -0
- package/dist/result.js +84 -0
- package/dist/tools.js +595 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rynger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# @rynger/mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for **Rynger**, your personal AI
|
|
4
|
+
outbound-call agent. It lets any MCP host (Claude Desktop, Claude Code, Cursor,
|
|
5
|
+
Codex, …) place and schedule one-off phone calls, read their transcripts and
|
|
6
|
+
outcomes, manage your phone numbers, and handle billing.
|
|
7
|
+
|
|
8
|
+
This server is a **thin client over Rynger's HTTP API**. It shapes inputs,
|
|
9
|
+
applies a few client-side guards that mirror the Rynger web portal, and forwards
|
|
10
|
+
to the API. It deliberately contains **no business logic** — credits, AI judging
|
|
11
|
+
of whether the goal was met, number provisioning, and caller-ID selection all
|
|
12
|
+
live server-side.
|
|
13
|
+
|
|
14
|
+
## Guardrails
|
|
15
|
+
|
|
16
|
+
- **Personal one-off calls only.** No marketing, mass, sequential, list-based, or
|
|
17
|
+
robocalling (TCPA). There is **no bulk / auto-loop / "call this list"**
|
|
18
|
+
capability, and the tools must not be looped to simulate one.
|
|
19
|
+
- **The AI self-discloses that it is an AI** on every call.
|
|
20
|
+
- **1 credit per call** (your first call is free). Each call is **hard-capped at
|
|
21
|
+
10 minutes**.
|
|
22
|
+
- **`callee` must be E.164** (e.g. `+14155550123`). A number bought for a country
|
|
23
|
+
can only dial that country; with the shared US number, non-US destinations show
|
|
24
|
+
a US caller ID (the tool says so in its result).
|
|
25
|
+
- **Pay actions return a URL** the human must open — `rynger_buy_number`,
|
|
26
|
+
`rynger_checkout`, and `rynger_billing_portal` never charge money directly.
|
|
27
|
+
- Your API key (`rk_...`) is **redacted** from all logs and error messages.
|
|
28
|
+
|
|
29
|
+
## Get your API key
|
|
30
|
+
|
|
31
|
+
Open Rynger → **Power Users** tab and create an API key. It looks like
|
|
32
|
+
`rk_live_...`. Set it as `RYNGER_API_KEY` (see configs below).
|
|
33
|
+
|
|
34
|
+
## Configuration (environment)
|
|
35
|
+
|
|
36
|
+
| Variable | Required | Default | Purpose |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| `RYNGER_API_KEY` | **yes** | — | Bearer token. The server exits on startup if unset. |
|
|
39
|
+
| `RYNGER_API_BASE` | no | `https://rynger-func-g4d4caf7c0c9c0ak.eastus-01.azurewebsites.net/api` | Override the API base URL. |
|
|
40
|
+
| `RYNGER_REQUEST_TIMEOUT_MS` | no | `30000` | Per-request timeout in milliseconds. |
|
|
41
|
+
|
|
42
|
+
Runs with no install via `npx -y @rynger/mcp`. Requires **Node 18+** (uses the
|
|
43
|
+
built-in `fetch`).
|
|
44
|
+
|
|
45
|
+
## Install per client
|
|
46
|
+
|
|
47
|
+
### Claude Desktop
|
|
48
|
+
|
|
49
|
+
Edit `claude_desktop_config.json`
|
|
50
|
+
(macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`,
|
|
51
|
+
Windows: `%APPDATA%\Claude\claude_desktop_config.json`):
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"rynger": {
|
|
57
|
+
"command": "npx",
|
|
58
|
+
"args": ["-y", "@rynger/mcp"],
|
|
59
|
+
"env": {
|
|
60
|
+
"RYNGER_API_KEY": "rk_live_xxxxxxxxxxxxxxxx"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
To point at a non-default API base, add it to `env`:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
"env": {
|
|
71
|
+
"RYNGER_API_KEY": "rk_live_xxxxxxxxxxxxxxxx",
|
|
72
|
+
"RYNGER_API_BASE": "https://your-host/api"
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Claude Code
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
claude mcp add rynger --env RYNGER_API_KEY=rk_live_xxxxxxxxxxxxxxxx -- npx -y @rynger/mcp
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Cursor
|
|
83
|
+
|
|
84
|
+
Create `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for global):
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"mcpServers": {
|
|
89
|
+
"rynger": {
|
|
90
|
+
"command": "npx",
|
|
91
|
+
"args": ["-y", "@rynger/mcp"],
|
|
92
|
+
"env": {
|
|
93
|
+
"RYNGER_API_KEY": "rk_live_xxxxxxxxxxxxxxxx"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Codex
|
|
101
|
+
|
|
102
|
+
Add to your Codex MCP config (`~/.codex/config.toml`):
|
|
103
|
+
|
|
104
|
+
```toml
|
|
105
|
+
[mcp_servers.rynger]
|
|
106
|
+
command = "npx"
|
|
107
|
+
args = ["-y", "@rynger/mcp"]
|
|
108
|
+
env = { RYNGER_API_KEY = "rk_live_xxxxxxxxxxxxxxxx" }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Tools
|
|
112
|
+
|
|
113
|
+
All tools are prefixed `rynger_` and map 1:1 to the Rynger HTTP API.
|
|
114
|
+
|
|
115
|
+
| Tool | What it does |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `rynger_place_call` | Place a single outbound AI call now toward one person to accomplish a goal. |
|
|
118
|
+
| `rynger_get_call` | Fetch one call's full record, including transcript and the goal-met verdict. |
|
|
119
|
+
| `rynger_list_calls` | List recent calls with status, goal, callee, and outcome. |
|
|
120
|
+
| `rynger_wait_for_call` | Poll a call until it leaves `pending` (or a timeout, max 7 min); return the final record. |
|
|
121
|
+
| `rynger_refine_goal` | Tighten a call goal and list any missing details before you place the call. |
|
|
122
|
+
| `rynger_schedule_call` | Schedule a single call for a future time, optionally recurring. |
|
|
123
|
+
| `rynger_list_schedules` | List scheduled/recurring calls with next run time and repeat rule. |
|
|
124
|
+
| `rynger_cancel_schedule` | Cancel a scheduled call by id. |
|
|
125
|
+
| `rynger_list_numbers` | List the shared number, your owned numbers, inbound config, and buyable countries. |
|
|
126
|
+
| `rynger_quote_number` | Get the price and regulatory fields needed to buy a number in a country. |
|
|
127
|
+
| `rynger_buy_number` | Start buying a number — returns a Stripe Checkout URL for the human to open. |
|
|
128
|
+
| `rynger_release_number` | Permanently release one of your owned numbers (destructive). |
|
|
129
|
+
| `rynger_set_inbound` | Configure how the AI answers inbound calls to one of your numbers. |
|
|
130
|
+
| `rynger_whoami` | Your account identity plus current credit balance and plan. |
|
|
131
|
+
| `rynger_get_balance` | Your current credit balance and plan. |
|
|
132
|
+
| `rynger_checkout` | Start a plan checkout (`starter`/`plus`/`pro`) — returns a URL to open. |
|
|
133
|
+
| `rynger_billing_portal` | Get a Stripe billing-portal URL the human can open. |
|
|
134
|
+
| `rynger_redeem` | Redeem a promo/credit code. |
|
|
135
|
+
|
|
136
|
+
Read-only tools (`get_call`, `list_calls`, `wait_for_call`, `refine_goal`,
|
|
137
|
+
`list_schedules`, `list_numbers`, `quote_number`, `whoami`, `get_balance`) are
|
|
138
|
+
marked `readOnlyHint`. Mutating, money/call-spending, or irreversible tools
|
|
139
|
+
(`place_call`, `schedule_call`, `buy_number`, `release_number`) are marked
|
|
140
|
+
`destructiveHint` so hosts can prompt for confirmation.
|
|
141
|
+
|
|
142
|
+
## Languages
|
|
143
|
+
|
|
144
|
+
`en, es, de, fr, it, pt, nl, pl, uk`. Languages other than `en`/`pl` may not yet
|
|
145
|
+
have a base assistant configured server-side; if so, the backend's error is
|
|
146
|
+
surfaced verbatim.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm install
|
|
152
|
+
npm run build # tsc -> dist/
|
|
153
|
+
npm test # builds, runs guard tests, runs --list-tools smoke
|
|
154
|
+
node dist/index.js --list-tools # prints tools, no API key or network needed
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment / configuration handling for the Rynger MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Reads RYNGER_API_KEY (required), RYNGER_API_BASE (optional) and
|
|
5
|
+
* RYNGER_REQUEST_TIMEOUT_MS (optional). Kept separate from the server so the
|
|
6
|
+
* --list-tools smoke path can avoid touching it entirely.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_API_BASE = "https://rynger-func-g4d4caf7c0c9c0ak.eastus-01.azurewebsites.net/api";
|
|
9
|
+
export const DEFAULT_TIMEOUT_MS = 30000;
|
|
10
|
+
/**
|
|
11
|
+
* Build config from process.env, or exit the process with a clear stderr message
|
|
12
|
+
* when RYNGER_API_KEY is missing. Only call this on a code path that actually
|
|
13
|
+
* needs to talk to the API (never on --list-tools).
|
|
14
|
+
*/
|
|
15
|
+
export function loadConfigOrExit() {
|
|
16
|
+
const apiKey = process.env.RYNGER_API_KEY?.trim();
|
|
17
|
+
if (!apiKey) {
|
|
18
|
+
process.stderr.write("Set RYNGER_API_KEY — create one in Rynger → Power Users\n");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const apiBase = (process.env.RYNGER_API_BASE?.trim() || DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
22
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
23
|
+
const rawTimeout = process.env.RYNGER_REQUEST_TIMEOUT_MS?.trim();
|
|
24
|
+
if (rawTimeout) {
|
|
25
|
+
const parsed = Number(rawTimeout);
|
|
26
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
27
|
+
timeoutMs = Math.floor(parsed);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { apiKey, apiBase, timeoutMs };
|
|
31
|
+
}
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-free client-side guards for the Rynger MCP server.
|
|
3
|
+
*
|
|
4
|
+
* These mirror the guardrails the Rynger web portal applies before it calls the
|
|
5
|
+
* HTTP API. They do NOT re-implement any server-side business logic (credits,
|
|
6
|
+
* judging, provisioning, caller-ID selection all stay on the backend). They only
|
|
7
|
+
* shape/validate input so obviously-bad requests fail fast with a clear message.
|
|
8
|
+
*
|
|
9
|
+
* Exported separately so tests can import them without booting the MCP server.
|
|
10
|
+
*/
|
|
11
|
+
/** E.164 phone numbers: leading +, first digit 1-9, total 7-15 digits. */
|
|
12
|
+
export const E164_REGEX = /^\+[1-9]\d{6,14}$/;
|
|
13
|
+
/**
|
|
14
|
+
* Validate a phone number against E.164.
|
|
15
|
+
* @returns true if the string is a syntactically valid E.164 number.
|
|
16
|
+
*/
|
|
17
|
+
export function validateE164(value) {
|
|
18
|
+
return typeof value === "string" && E164_REGEX.test(value);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Map of owned-number country code -> allowed callee dial prefixes (the digits
|
|
22
|
+
* that follow the leading "+"). US and CA share the "NA" group (+1).
|
|
23
|
+
*
|
|
24
|
+
* This mirrors the portal's caller/destination matching: a number bought for a
|
|
25
|
+
* given country may only dial destinations in that country.
|
|
26
|
+
*/
|
|
27
|
+
export const DIAL_CODE_MAP = {
|
|
28
|
+
NA: ["1"],
|
|
29
|
+
GB: ["44"],
|
|
30
|
+
PL: ["48"],
|
|
31
|
+
DE: ["49"],
|
|
32
|
+
FR: ["33"],
|
|
33
|
+
ES: ["34"],
|
|
34
|
+
NL: ["31"],
|
|
35
|
+
IT: ["39"],
|
|
36
|
+
IE: ["353"],
|
|
37
|
+
};
|
|
38
|
+
/** Country codes that the backend reports but that all belong to the "NA" group. */
|
|
39
|
+
const NA_ALIASES = new Set(["NA", "US", "CA"]);
|
|
40
|
+
/** Normalize a backend-reported country code to the key used in DIAL_CODE_MAP. */
|
|
41
|
+
function normalizeCountry(country) {
|
|
42
|
+
const upper = (country || "").toUpperCase();
|
|
43
|
+
return NA_ALIASES.has(upper) ? "NA" : upper;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Decide whether a call from a given owned number may dial a given destination,
|
|
47
|
+
* and surface the shared-number caller-ID note when no owned number is used.
|
|
48
|
+
*
|
|
49
|
+
* @param callee destination number in E.164 (already validated upstream).
|
|
50
|
+
* @param fromCountry the `country` of the chosen owned number, or null/undefined
|
|
51
|
+
* when the caller did not pass `from_number_id` (i.e. the
|
|
52
|
+
* shared US number will be used).
|
|
53
|
+
*
|
|
54
|
+
* Behaviour (mirrors the portal):
|
|
55
|
+
* - No owned number (fromCountry null) + callee not +1 -> allowed, but attach a
|
|
56
|
+
* note that caller ID will display as a US number.
|
|
57
|
+
* - No owned number + callee +1 -> allowed, no note.
|
|
58
|
+
* - Owned number whose country we don't recognize -> allowed (let the
|
|
59
|
+
* backend be the source of truth), no note.
|
|
60
|
+
* - Owned number + callee dial code matches its country -> allowed.
|
|
61
|
+
* - Owned number + callee dial code does NOT match -> blocked with a clear
|
|
62
|
+
* error.
|
|
63
|
+
*/
|
|
64
|
+
export function callerDestCheck(callee, fromCountry) {
|
|
65
|
+
// Shared US number path: caller did not select an owned number.
|
|
66
|
+
if (fromCountry === null || fromCountry === undefined || fromCountry === "") {
|
|
67
|
+
if (!callee.startsWith("+1")) {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
note: "caller ID will display as a US number",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
const country = normalizeCountry(fromCountry);
|
|
76
|
+
const allowedPrefixes = DIAL_CODE_MAP[country];
|
|
77
|
+
// Unknown country: defer to the backend rather than blocking incorrectly.
|
|
78
|
+
if (!allowedPrefixes) {
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|
|
81
|
+
const digits = callee.replace(/^\+/, "");
|
|
82
|
+
const matches = allowedPrefixes.some((prefix) => digits.startsWith(prefix));
|
|
83
|
+
if (!matches) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: `Caller/destination mismatch: your ${country} number can only call ${country} ` +
|
|
87
|
+
`destinations (dial code +${allowedPrefixes.join("/+")}), but ${callee} is not in that range. ` +
|
|
88
|
+
`Use a matching owned number, or omit from_number_id to call from the shared US number.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Redact any Rynger API-key-shaped substring (rk_...) from arbitrary text so the
|
|
95
|
+
* key never leaks into logs or error messages.
|
|
96
|
+
*/
|
|
97
|
+
export function redact(text) {
|
|
98
|
+
if (typeof text !== "string")
|
|
99
|
+
return text;
|
|
100
|
+
return text.replace(/rk_[A-Za-z0-9_\-]+/g, "rk_***");
|
|
101
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin HTTP client over the Rynger API.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONLY place that performs network I/O. Every tool calls one of the
|
|
5
|
+
* endpoints in the documented contract via this client. No business logic lives
|
|
6
|
+
* here — it just attaches auth, enforces a timeout, and normalizes errors into a
|
|
7
|
+
* shape the tool layer can turn into an MCP result.
|
|
8
|
+
*/
|
|
9
|
+
import { redact } from "./guards.js";
|
|
10
|
+
/** Error thrown for non-2xx responses or transport failures, carrying status + body. */
|
|
11
|
+
export class ApiError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
body;
|
|
14
|
+
constructor(message, status, body) {
|
|
15
|
+
super(redact(message));
|
|
16
|
+
this.name = "ApiError";
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.body = body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class RyngerHttpClient {
|
|
22
|
+
config;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
}
|
|
26
|
+
/** GET helper. */
|
|
27
|
+
get(path) {
|
|
28
|
+
return this.request("GET", path);
|
|
29
|
+
}
|
|
30
|
+
/** POST helper. Body is JSON-encoded; omit for empty-body POSTs. */
|
|
31
|
+
post(path, body) {
|
|
32
|
+
return this.request("POST", path, body);
|
|
33
|
+
}
|
|
34
|
+
async request(method, path, body) {
|
|
35
|
+
const url = `${this.config.apiBase}${path.startsWith("/") ? path : `/${path}`}`;
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
38
|
+
const headers = {
|
|
39
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
40
|
+
Accept: "application/json",
|
|
41
|
+
};
|
|
42
|
+
let payload;
|
|
43
|
+
if (body !== undefined && method !== "GET") {
|
|
44
|
+
headers["Content-Type"] = "application/json";
|
|
45
|
+
payload = JSON.stringify(body);
|
|
46
|
+
}
|
|
47
|
+
let res;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetch(url, {
|
|
50
|
+
method,
|
|
51
|
+
headers,
|
|
52
|
+
body: payload,
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
59
|
+
throw new ApiError(`Request timed out after ${this.config.timeoutMs}ms (${method} ${path}).`, 0, null);
|
|
60
|
+
}
|
|
61
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
62
|
+
throw new ApiError(`Network error calling Rynger API (${method} ${path}): ${detail}`, 0, null);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
const parsed = await parseBody(res);
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new ApiError(`Rynger API returned HTTP ${res.status}`, res.status, parsed);
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, status: res.status, body: parsed };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Parse a Response body as JSON when possible, falling back to text. */
|
|
75
|
+
async function parseBody(res) {
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
if (!text)
|
|
78
|
+
return null;
|
|
79
|
+
const contentType = res.headers.get("content-type") || "";
|
|
80
|
+
if (contentType.includes("application/json")) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Some backends omit the header; attempt JSON anyway, fall back to raw text.
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(text);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return text;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Rynger MCP server — entry point.
|
|
4
|
+
*
|
|
5
|
+
* Rynger is a personal AI outbound-call agent. This server is a THIN client over
|
|
6
|
+
* Rynger's existing HTTP API: it shapes inputs, applies a few client-side guards
|
|
7
|
+
* that mirror the web portal, and forwards to the API. It does NOT implement any
|
|
8
|
+
* business logic (credits, judging, provisioning, caller-ID all stay server-side).
|
|
9
|
+
*
|
|
10
|
+
* Transport: stdio.
|
|
11
|
+
*
|
|
12
|
+
* The --list-tools path is handled before any env read or transport setup, so it
|
|
13
|
+
* works without RYNGER_API_KEY and performs no network calls.
|
|
14
|
+
*/
|
|
15
|
+
import { listToolSummaries, buildTools } from "./tools.js";
|
|
16
|
+
const SERVER_NAME = "rynger";
|
|
17
|
+
const SERVER_VERSION = "0.1.0";
|
|
18
|
+
const SERVER_INSTRUCTIONS = [
|
|
19
|
+
"Rynger is your personal AI outbound-call agent. These tools let you place and schedule one-off phone calls, " +
|
|
20
|
+
"check their transcripts and outcomes, manage your phone numbers, and handle billing.",
|
|
21
|
+
"",
|
|
22
|
+
"Guardrails (enforced or required):",
|
|
23
|
+
"- Personal one-off calls only. Never use this for marketing, mass, sequential, list-based, or robocalling (TCPA). " +
|
|
24
|
+
"There is no bulk / auto-loop / 'call this list' capability and you must not simulate one by calling tools in a loop.",
|
|
25
|
+
"- The AI self-discloses that it is an AI on every call.",
|
|
26
|
+
"- Calls cost 1 credit each (the first call is free) and are hard-capped at 10 minutes.",
|
|
27
|
+
"- callee numbers must be E.164 (e.g. +14155550123). A number bought for one country can only dial that country; " +
|
|
28
|
+
"with the shared US number, non-US destinations will show a US caller ID.",
|
|
29
|
+
"- Pay actions (rynger_buy_number, rynger_checkout, rynger_billing_portal) return a URL that the human must open — " +
|
|
30
|
+
"agents cannot complete payment.",
|
|
31
|
+
].join("\n");
|
|
32
|
+
/** Print tool names + one-line descriptions and exit. No env, no network. */
|
|
33
|
+
function printToolList() {
|
|
34
|
+
const tools = listToolSummaries();
|
|
35
|
+
for (const t of tools) {
|
|
36
|
+
process.stdout.write(`${t.name} — ${t.description}\n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function main() {
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
41
|
+
if (argv.includes("--list-tools")) {
|
|
42
|
+
printToolList();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
// Defer all env + SDK + network setup until we know we are actually serving.
|
|
46
|
+
const { loadConfigOrExit } = await import("./config.js");
|
|
47
|
+
const { RyngerHttpClient } = await import("./http.js");
|
|
48
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
49
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
50
|
+
const config = loadConfigOrExit();
|
|
51
|
+
const client = new RyngerHttpClient(config);
|
|
52
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
|
|
53
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
54
|
+
capabilities: { tools: {} },
|
|
55
|
+
});
|
|
56
|
+
for (const tool of buildTools(client)) {
|
|
57
|
+
server.registerTool(tool.name, {
|
|
58
|
+
title: tool.title,
|
|
59
|
+
description: tool.description,
|
|
60
|
+
inputSchema: tool.inputSchema,
|
|
61
|
+
annotations: tool.annotations,
|
|
62
|
+
},
|
|
63
|
+
// The SDK passes validated args (and an extra) — we only use args.
|
|
64
|
+
async (args) => {
|
|
65
|
+
const result = await tool.handler(args ?? {});
|
|
66
|
+
return result;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const transport = new StdioServerTransport();
|
|
70
|
+
await server.connect(transport);
|
|
71
|
+
// Stay alive; the transport keeps the process running on stdin.
|
|
72
|
+
}
|
|
73
|
+
main().catch((err) => {
|
|
74
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
75
|
+
// Redact any key-shaped substring defensively (avoid importing for one call).
|
|
76
|
+
const safe = msg.replace(/rk_[A-Za-z0-9_\-]+/g, "rk_***");
|
|
77
|
+
process.stderr.write(`rynger-mcp fatal: ${safe}\n`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
package/dist/result.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for turning API results / errors into MCP tool results.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes the HTTP-status -> actionable-message mapping and the redaction of
|
|
5
|
+
* the API key from every outgoing string.
|
|
6
|
+
*/
|
|
7
|
+
import { redact } from "./guards.js";
|
|
8
|
+
import { ApiError } from "./http.js";
|
|
9
|
+
/** Wrap arbitrary JSON-able data as a successful text tool result. */
|
|
10
|
+
export function ok(data) {
|
|
11
|
+
return {
|
|
12
|
+
content: [{ type: "text", text: redact(pretty(data)) }],
|
|
13
|
+
isError: false,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Wrap a successful API result's body as a text tool result. */
|
|
17
|
+
export function okBody(res) {
|
|
18
|
+
return ok(res.body);
|
|
19
|
+
}
|
|
20
|
+
/** Build an error tool result with a redacted, actionable message. */
|
|
21
|
+
export function fail(message) {
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: "text", text: redact(message) }],
|
|
24
|
+
isError: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Pretty-print JSON; fall back to String() for non-serializable input. */
|
|
28
|
+
function pretty(data) {
|
|
29
|
+
if (typeof data === "string")
|
|
30
|
+
return data;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.stringify(data, null, 2);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return String(data);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Extract the backend `error` field from a parsed body, if present. */
|
|
39
|
+
function backendError(body) {
|
|
40
|
+
if (body && typeof body === "object" && "error" in body) {
|
|
41
|
+
const e = body.error;
|
|
42
|
+
if (typeof e === "string")
|
|
43
|
+
return e;
|
|
44
|
+
if (e != null)
|
|
45
|
+
return JSON.stringify(e);
|
|
46
|
+
}
|
|
47
|
+
if (typeof body === "string" && body.trim())
|
|
48
|
+
return body.trim();
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Map any thrown error (ApiError or otherwise) to an actionable MCP error result.
|
|
53
|
+
* Always includes the HTTP status. Mirrors the contract's error mapping:
|
|
54
|
+
* 401 -> revoked key, 402 -> out of calls, 404 -> not found,
|
|
55
|
+
* 400/403/409/503 -> pass through backend `error` verbatim.
|
|
56
|
+
*/
|
|
57
|
+
export function mapError(err) {
|
|
58
|
+
if (err instanceof ApiError) {
|
|
59
|
+
const status = err.status;
|
|
60
|
+
const beError = backendError(err.body);
|
|
61
|
+
switch (status) {
|
|
62
|
+
case 401:
|
|
63
|
+
return fail("HTTP 401: Invalid or revoked API key — create a new one in Rynger → Power Users.");
|
|
64
|
+
case 402:
|
|
65
|
+
return fail("HTTP 402: Out of calls — top up with rynger_checkout or redeem a code with rynger_redeem." +
|
|
66
|
+
(beError ? ` (${beError})` : ""));
|
|
67
|
+
case 404:
|
|
68
|
+
return fail("HTTP 404: Not found." + (beError ? ` ${beError}` : ""));
|
|
69
|
+
case 400:
|
|
70
|
+
case 403:
|
|
71
|
+
case 409:
|
|
72
|
+
case 503:
|
|
73
|
+
return fail(`HTTP ${status}: ${beError ?? err.message}`);
|
|
74
|
+
default:
|
|
75
|
+
if (status === 0) {
|
|
76
|
+
// Transport-level failure (timeout / DNS / network).
|
|
77
|
+
return fail(err.message);
|
|
78
|
+
}
|
|
79
|
+
return fail(`HTTP ${status}: ${beError ?? err.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
83
|
+
return fail(detail);
|
|
84
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool definitions for the Rynger MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Every tool is a thin shim over exactly one documented HTTP endpoint (plus the
|
|
5
|
+
* client-side guards that mirror the portal). No business logic — credits,
|
|
6
|
+
* judging, provisioning, caller-ID selection all stay server-side.
|
|
7
|
+
*
|
|
8
|
+
* `buildTools(client)` returns the full list. `listToolSummaries()` returns just
|
|
9
|
+
* names + descriptions and constructs NO client, so it is safe for the
|
|
10
|
+
* --list-tools smoke path (no env, no network).
|
|
11
|
+
*/
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { callerDestCheck, validateE164, } from "./guards.js";
|
|
14
|
+
import { mapError, ok, okBody, fail } from "./result.js";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Shared enums / field schemas (mirror the contract)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const LANGUAGES = ["en", "es", "de", "fr", "it", "pt", "nl", "pl", "uk"];
|
|
19
|
+
const REPEATS = ["none", "daily", "weekdays", "weekly", "monthly"];
|
|
20
|
+
const PLANS = ["starter", "plus", "pro"];
|
|
21
|
+
const languageField = z
|
|
22
|
+
.enum(LANGUAGES)
|
|
23
|
+
.describe("Language the AI speaks on the call. One of en,es,de,fr,it,pt,nl,pl,uk. " +
|
|
24
|
+
"Non-en/pl may fail server-side until a base assistant is configured; the server error is surfaced verbatim.");
|
|
25
|
+
const calleeField = z
|
|
26
|
+
.string()
|
|
27
|
+
.describe("Destination phone number in E.164 format, e.g. +14155550123.");
|
|
28
|
+
const contextField = z
|
|
29
|
+
.object({
|
|
30
|
+
name: z.string().describe("Who the AI should say it is calling on behalf of (the user's name)."),
|
|
31
|
+
reason: z.string().describe("Short reason/background for the call the AI can reference."),
|
|
32
|
+
})
|
|
33
|
+
.describe("Context the AI uses to introduce itself and frame the call.");
|
|
34
|
+
const fromNumberIdField = z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Optional id of one of your owned numbers to call from (see rynger_list_numbers). " +
|
|
38
|
+
"Omit to use the shared US number. Must match the destination country.");
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Guard helpers shared by place_call / schedule_call
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/**
|
|
43
|
+
* Run the E.164 + caller/destination guards for an outbound request.
|
|
44
|
+
* Returns either a blocking ToolResult (caller should return it directly) or an
|
|
45
|
+
* optional advisory note to append after a successful API call.
|
|
46
|
+
*/
|
|
47
|
+
async function runOutboundGuards(client, callee, fromNumberId) {
|
|
48
|
+
if (!validateE164(callee)) {
|
|
49
|
+
return {
|
|
50
|
+
block: fail(`Invalid callee "${callee}": must be E.164, e.g. +14155550123 (leading +, 7–15 digits).`),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// No owned number selected -> shared US number path; only need the note check.
|
|
54
|
+
if (!fromNumberId) {
|
|
55
|
+
const check = callerDestCheck(callee, null);
|
|
56
|
+
return { note: check.note };
|
|
57
|
+
}
|
|
58
|
+
// Owned number selected -> look up its country to enforce the match.
|
|
59
|
+
let fromCountry = null;
|
|
60
|
+
try {
|
|
61
|
+
const res = await client.get("/numbers");
|
|
62
|
+
const owned = extractOwned(res.body);
|
|
63
|
+
const match = owned.find((n) => n.id === fromNumberId);
|
|
64
|
+
if (!match) {
|
|
65
|
+
return {
|
|
66
|
+
block: fail(`from_number_id "${fromNumberId}" is not one of your owned numbers. ` +
|
|
67
|
+
`Call rynger_list_numbers to see available ids.`),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
fromCountry = match.country ?? null;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
// If we cannot verify, fail closed with an actionable message rather than
|
|
74
|
+
// silently calling from the wrong country.
|
|
75
|
+
return { block: mapError(err) };
|
|
76
|
+
}
|
|
77
|
+
const check = callerDestCheck(callee, fromCountry);
|
|
78
|
+
if (!check.ok) {
|
|
79
|
+
return { block: fail(check.error ?? "Caller/destination mismatch.") };
|
|
80
|
+
}
|
|
81
|
+
return { note: check.note };
|
|
82
|
+
}
|
|
83
|
+
/** Pull the owned[] array out of a /numbers response defensively. */
|
|
84
|
+
function extractOwned(body) {
|
|
85
|
+
if (body && typeof body === "object" && "owned" in body) {
|
|
86
|
+
const owned = body.owned;
|
|
87
|
+
if (Array.isArray(owned)) {
|
|
88
|
+
return owned.filter((n) => !!n && typeof n === "object" && typeof n.id === "string");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
/** Append an advisory note to a successful tool result's text. */
|
|
94
|
+
function withNote(result, note) {
|
|
95
|
+
if (!note)
|
|
96
|
+
return result;
|
|
97
|
+
return {
|
|
98
|
+
...result,
|
|
99
|
+
content: [
|
|
100
|
+
...result.content,
|
|
101
|
+
{ type: "text", text: `Note: ${note}.` },
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Tool builder
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
const PERSONAL_USE_NOTE = "Personal one-off calls only — no marketing, mass, sequential, list-based, or robocalling (TCPA). " +
|
|
109
|
+
"The AI self-discloses that it is an AI on every call. Costs 1 credit per call (first call free). " +
|
|
110
|
+
"Each call is capped at 10 minutes.";
|
|
111
|
+
export function buildTools(client) {
|
|
112
|
+
return [
|
|
113
|
+
// ----------------------------------------------------------------- Calls
|
|
114
|
+
{
|
|
115
|
+
name: "rynger_place_call",
|
|
116
|
+
title: "Place a call",
|
|
117
|
+
description: "Place a single outbound AI phone call now toward one person to accomplish a goal. " +
|
|
118
|
+
PERSONAL_USE_NOTE,
|
|
119
|
+
inputSchema: {
|
|
120
|
+
goal: z
|
|
121
|
+
.string()
|
|
122
|
+
.describe("What the call should accomplish, e.g. 'Book a table for 2 at 7pm tonight'."),
|
|
123
|
+
callee: calleeField,
|
|
124
|
+
language: languageField,
|
|
125
|
+
context: contextField,
|
|
126
|
+
from_number_id: fromNumberIdField,
|
|
127
|
+
},
|
|
128
|
+
annotations: {
|
|
129
|
+
title: "Place a call",
|
|
130
|
+
readOnlyHint: false,
|
|
131
|
+
destructiveHint: true,
|
|
132
|
+
openWorldHint: true,
|
|
133
|
+
},
|
|
134
|
+
handler: async (args) => {
|
|
135
|
+
const callee = String(args.callee ?? "");
|
|
136
|
+
const fromNumberId = args.from_number_id;
|
|
137
|
+
const guard = await runOutboundGuards(client, callee, fromNumberId);
|
|
138
|
+
if (guard.block)
|
|
139
|
+
return guard.block;
|
|
140
|
+
try {
|
|
141
|
+
const res = await client.post("/start_call", {
|
|
142
|
+
goal: args.goal,
|
|
143
|
+
callee,
|
|
144
|
+
language: args.language,
|
|
145
|
+
context: args.context,
|
|
146
|
+
...(fromNumberId ? { from_number_id: fromNumberId } : {}),
|
|
147
|
+
});
|
|
148
|
+
return withNote(okBody(res), guard.note);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
return mapError(err);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "rynger_get_call",
|
|
157
|
+
title: "Get call detail",
|
|
158
|
+
description: "Fetch the full record for one call by id, including transcript and the verdict on whether the goal was met.",
|
|
159
|
+
inputSchema: {
|
|
160
|
+
call_id: z.string().describe("The call_id returned by rynger_place_call or rynger_list_calls."),
|
|
161
|
+
},
|
|
162
|
+
annotations: { title: "Get call detail", readOnlyHint: true },
|
|
163
|
+
handler: async (args) => {
|
|
164
|
+
try {
|
|
165
|
+
const res = await client.get(`/calls/${encodeURIComponent(String(args.call_id))}`);
|
|
166
|
+
return okBody(res);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return mapError(err);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "rynger_list_calls",
|
|
175
|
+
title: "List calls",
|
|
176
|
+
description: "List your recent calls with their status, goal, callee, and whether the goal was met.",
|
|
177
|
+
inputSchema: {},
|
|
178
|
+
annotations: { title: "List calls", readOnlyHint: true },
|
|
179
|
+
handler: async () => {
|
|
180
|
+
try {
|
|
181
|
+
return okBody(await client.get("/calls"));
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
return mapError(err);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "rynger_wait_for_call",
|
|
190
|
+
title: "Wait for a call to finish",
|
|
191
|
+
description: "Poll a call until it is no longer pending (completed/failed) or a timeout is reached, then return the final record. " +
|
|
192
|
+
"Use after rynger_place_call to wait for the outcome. Default and maximum wait is 7 minutes.",
|
|
193
|
+
inputSchema: {
|
|
194
|
+
call_id: z.string().describe("The call_id to wait on."),
|
|
195
|
+
timeout_seconds: z
|
|
196
|
+
.number()
|
|
197
|
+
.int()
|
|
198
|
+
.positive()
|
|
199
|
+
.max(420)
|
|
200
|
+
.optional()
|
|
201
|
+
.describe("Max seconds to wait (default 420 = 7 min, capped at 420)."),
|
|
202
|
+
},
|
|
203
|
+
annotations: { title: "Wait for a call to finish", readOnlyHint: true },
|
|
204
|
+
handler: async (args) => {
|
|
205
|
+
const callId = String(args.call_id);
|
|
206
|
+
const requested = typeof args.timeout_seconds === "number" ? args.timeout_seconds : 420;
|
|
207
|
+
const timeoutSec = Math.min(Math.max(1, Math.floor(requested)), 420);
|
|
208
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
209
|
+
const intervalMs = 5000;
|
|
210
|
+
let last = null;
|
|
211
|
+
try {
|
|
212
|
+
// First immediate check, then poll every ~5s.
|
|
213
|
+
// eslint-disable-next-line no-constant-condition
|
|
214
|
+
while (true) {
|
|
215
|
+
const res = await client.get(`/calls/${encodeURIComponent(callId)}`);
|
|
216
|
+
last = res.body;
|
|
217
|
+
const status = readStatus(res.body);
|
|
218
|
+
if (status !== "pending") {
|
|
219
|
+
return okBody(res);
|
|
220
|
+
}
|
|
221
|
+
if (Date.now() + intervalMs >= deadline) {
|
|
222
|
+
return ok({
|
|
223
|
+
timed_out: true,
|
|
224
|
+
waited_seconds: timeoutSec,
|
|
225
|
+
message: `Call ${callId} is still pending after ${timeoutSec}s; returning latest snapshot.`,
|
|
226
|
+
latest: last,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
await sleep(intervalMs);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
return mapError(err);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
// -------------------------------------------------------------- Optimize
|
|
238
|
+
{
|
|
239
|
+
name: "rynger_refine_goal",
|
|
240
|
+
title: "Refine a call goal",
|
|
241
|
+
description: "Improve a call goal before placing the call: returns a tightened goal and a list of any missing details to ask the user for.",
|
|
242
|
+
inputSchema: {
|
|
243
|
+
goal: z.string().describe("The draft goal to improve."),
|
|
244
|
+
context: contextField.optional().describe("Optional context (name, reason) to ground the rewrite."),
|
|
245
|
+
language: languageField.optional(),
|
|
246
|
+
},
|
|
247
|
+
annotations: { title: "Refine a call goal", readOnlyHint: true },
|
|
248
|
+
handler: async (args) => {
|
|
249
|
+
try {
|
|
250
|
+
const res = await client.post("/refine_goal", {
|
|
251
|
+
goal: args.goal,
|
|
252
|
+
...(args.context ? { context: args.context } : {}),
|
|
253
|
+
...(args.language ? { language: args.language } : {}),
|
|
254
|
+
});
|
|
255
|
+
return okBody(res);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
return mapError(err);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
// ------------------------------------------------------------ Scheduling
|
|
263
|
+
{
|
|
264
|
+
name: "rynger_schedule_call",
|
|
265
|
+
title: "Schedule a call",
|
|
266
|
+
description: "Schedule a single outbound AI call for a future time (optionally recurring). " +
|
|
267
|
+
PERSONAL_USE_NOTE,
|
|
268
|
+
inputSchema: {
|
|
269
|
+
goal: z.string().describe("What the call should accomplish."),
|
|
270
|
+
callee: calleeField,
|
|
271
|
+
language: languageField,
|
|
272
|
+
context: contextField,
|
|
273
|
+
from_number_id: fromNumberIdField,
|
|
274
|
+
scheduled_for: z
|
|
275
|
+
.string()
|
|
276
|
+
.describe("When to place the call, as an ISO 8601 timestamp, e.g. 2026-07-01T15:00:00Z."),
|
|
277
|
+
timezone: z
|
|
278
|
+
.string()
|
|
279
|
+
.optional()
|
|
280
|
+
.describe("Optional IANA timezone for the schedule, e.g. America/New_York."),
|
|
281
|
+
repeat: z
|
|
282
|
+
.enum(REPEATS)
|
|
283
|
+
.optional()
|
|
284
|
+
.describe("Optional recurrence: none, daily, weekdays, weekly, or monthly."),
|
|
285
|
+
repeat_until: z
|
|
286
|
+
.string()
|
|
287
|
+
.optional()
|
|
288
|
+
.describe("Optional ISO 8601 date/time after which recurrence stops."),
|
|
289
|
+
},
|
|
290
|
+
annotations: {
|
|
291
|
+
title: "Schedule a call",
|
|
292
|
+
readOnlyHint: false,
|
|
293
|
+
destructiveHint: true,
|
|
294
|
+
openWorldHint: true,
|
|
295
|
+
},
|
|
296
|
+
handler: async (args) => {
|
|
297
|
+
const callee = String(args.callee ?? "");
|
|
298
|
+
const fromNumberId = args.from_number_id;
|
|
299
|
+
const guard = await runOutboundGuards(client, callee, fromNumberId);
|
|
300
|
+
if (guard.block)
|
|
301
|
+
return guard.block;
|
|
302
|
+
try {
|
|
303
|
+
const res = await client.post("/schedule_call", {
|
|
304
|
+
goal: args.goal,
|
|
305
|
+
callee,
|
|
306
|
+
language: args.language,
|
|
307
|
+
context: args.context,
|
|
308
|
+
scheduled_for: args.scheduled_for,
|
|
309
|
+
...(fromNumberId ? { from_number_id: fromNumberId } : {}),
|
|
310
|
+
...(args.timezone ? { timezone: args.timezone } : {}),
|
|
311
|
+
...(args.repeat ? { repeat: args.repeat } : {}),
|
|
312
|
+
...(args.repeat_until ? { repeat_until: args.repeat_until } : {}),
|
|
313
|
+
});
|
|
314
|
+
return withNote(okBody(res), guard.note);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
return mapError(err);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "rynger_list_schedules",
|
|
323
|
+
title: "List scheduled calls",
|
|
324
|
+
description: "List your scheduled (upcoming/recurring) calls with their next run time, repeat rule, goal, and callee.",
|
|
325
|
+
inputSchema: {},
|
|
326
|
+
annotations: { title: "List scheduled calls", readOnlyHint: true },
|
|
327
|
+
handler: async () => {
|
|
328
|
+
try {
|
|
329
|
+
return okBody(await client.get("/schedules"));
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
return mapError(err);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: "rynger_cancel_schedule",
|
|
338
|
+
title: "Cancel a scheduled call",
|
|
339
|
+
description: "Cancel a scheduled call by its schedule id.",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
schedule_id: z.string().describe("The schedule id (from rynger_list_schedules) to cancel."),
|
|
342
|
+
},
|
|
343
|
+
annotations: { title: "Cancel a scheduled call", readOnlyHint: false },
|
|
344
|
+
handler: async (args) => {
|
|
345
|
+
try {
|
|
346
|
+
const res = await client.post(`/schedules/${encodeURIComponent(String(args.schedule_id))}/cancel`);
|
|
347
|
+
return okBody(res);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
return mapError(err);
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
// --------------------------------------------------------------- Numbers
|
|
355
|
+
{
|
|
356
|
+
name: "rynger_list_numbers",
|
|
357
|
+
title: "List phone numbers",
|
|
358
|
+
description: "List the shared number, your owned numbers (with country/status), inbound config, and which countries are available to buy.",
|
|
359
|
+
inputSchema: {},
|
|
360
|
+
annotations: { title: "List phone numbers", readOnlyHint: true },
|
|
361
|
+
handler: async () => {
|
|
362
|
+
try {
|
|
363
|
+
return okBody(await client.get("/numbers"));
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
return mapError(err);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: "rynger_quote_number",
|
|
372
|
+
title: "Quote a phone number",
|
|
373
|
+
description: "Get the price and the regulatory fields needed to buy a number in a given country before purchasing.",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
country: z.string().describe("ISO country code to quote, e.g. US, GB, DE."),
|
|
376
|
+
},
|
|
377
|
+
annotations: { title: "Quote a phone number", readOnlyHint: true },
|
|
378
|
+
handler: async (args) => {
|
|
379
|
+
try {
|
|
380
|
+
const res = await client.post("/numbers/quote", { country: args.country });
|
|
381
|
+
return okBody(res);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
return mapError(err);
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "rynger_buy_number",
|
|
390
|
+
title: "Buy a phone number",
|
|
391
|
+
description: "Start buying a phone number for a country. Returns a Stripe Checkout URL the human must open to pay — " +
|
|
392
|
+
"agents cannot complete payment. Use rynger_quote_number first to learn the required regulatory fields.",
|
|
393
|
+
inputSchema: {
|
|
394
|
+
country: z.string().describe("ISO country code to buy in, e.g. US, GB, DE."),
|
|
395
|
+
area_code: z.string().optional().describe("Optional preferred area code."),
|
|
396
|
+
language: languageField.optional().describe("Optional default language for the number."),
|
|
397
|
+
regulatory: z
|
|
398
|
+
.record(z.string(), z.unknown())
|
|
399
|
+
.optional()
|
|
400
|
+
.describe("Regulatory bundle fields required for the country (see rynger_quote_number fields_needed). " +
|
|
401
|
+
"Shape varies by country (none/address/address_id)."),
|
|
402
|
+
},
|
|
403
|
+
annotations: {
|
|
404
|
+
title: "Buy a phone number",
|
|
405
|
+
readOnlyHint: false,
|
|
406
|
+
destructiveHint: true,
|
|
407
|
+
openWorldHint: true,
|
|
408
|
+
},
|
|
409
|
+
handler: async (args) => {
|
|
410
|
+
try {
|
|
411
|
+
const res = await client.post("/numbers/buy", {
|
|
412
|
+
country: args.country,
|
|
413
|
+
...(args.area_code ? { area_code: args.area_code } : {}),
|
|
414
|
+
...(args.language ? { language: args.language } : {}),
|
|
415
|
+
...(args.regulatory ? { regulatory: args.regulatory } : {}),
|
|
416
|
+
});
|
|
417
|
+
return okBody(res);
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
return mapError(err);
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: "rynger_release_number",
|
|
426
|
+
title: "Release a phone number",
|
|
427
|
+
description: "Permanently release (give up) one of your owned numbers by id. This is destructive and cannot be undone.",
|
|
428
|
+
inputSchema: {
|
|
429
|
+
number_id: z.string().describe("The owned number id (from rynger_list_numbers) to release."),
|
|
430
|
+
},
|
|
431
|
+
annotations: {
|
|
432
|
+
title: "Release a phone number",
|
|
433
|
+
readOnlyHint: false,
|
|
434
|
+
destructiveHint: true,
|
|
435
|
+
openWorldHint: true,
|
|
436
|
+
},
|
|
437
|
+
handler: async (args) => {
|
|
438
|
+
try {
|
|
439
|
+
const res = await client.post(`/numbers/${encodeURIComponent(String(args.number_id))}/release`);
|
|
440
|
+
return okBody(res);
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
return mapError(err);
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: "rynger_set_inbound",
|
|
449
|
+
title: "Configure inbound for a number",
|
|
450
|
+
description: "Configure how the AI answers inbound calls to one of your owned numbers: owner name, greeting, instructions, questions to ask, and language.",
|
|
451
|
+
inputSchema: {
|
|
452
|
+
number_id: z.string().describe("The owned number id to configure."),
|
|
453
|
+
owner_name: z.string().describe("Name the AI uses to identify whose line this is."),
|
|
454
|
+
greeting: z.string().describe("Opening line the AI says when answering."),
|
|
455
|
+
instructions: z.string().describe("How the AI should handle inbound callers."),
|
|
456
|
+
questions: z
|
|
457
|
+
.array(z.string())
|
|
458
|
+
.describe("Questions the AI should ask inbound callers (may be empty)."),
|
|
459
|
+
language: languageField,
|
|
460
|
+
},
|
|
461
|
+
annotations: { title: "Configure inbound for a number", readOnlyHint: false },
|
|
462
|
+
handler: async (args) => {
|
|
463
|
+
try {
|
|
464
|
+
const res = await client.post(`/numbers/${encodeURIComponent(String(args.number_id))}/inbound`, {
|
|
465
|
+
owner_name: args.owner_name,
|
|
466
|
+
greeting: args.greeting,
|
|
467
|
+
instructions: args.instructions,
|
|
468
|
+
questions: args.questions,
|
|
469
|
+
language: args.language,
|
|
470
|
+
});
|
|
471
|
+
return okBody(res);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
return mapError(err);
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
// ------------------------------------------------------- Billing/Account
|
|
479
|
+
{
|
|
480
|
+
name: "rynger_whoami",
|
|
481
|
+
title: "Who am I + balance",
|
|
482
|
+
description: "Return your account identity together with your current call-credit balance and plan (combines /me and /credits).",
|
|
483
|
+
inputSchema: {},
|
|
484
|
+
annotations: { title: "Who am I + balance", readOnlyHint: true },
|
|
485
|
+
handler: async () => {
|
|
486
|
+
try {
|
|
487
|
+
const [meRes, creditsRes] = await Promise.all([
|
|
488
|
+
client.get("/me"),
|
|
489
|
+
client.get("/credits"),
|
|
490
|
+
]);
|
|
491
|
+
return ok({ me: meRes.body, credits: creditsRes.body });
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
return mapError(err);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: "rynger_get_balance",
|
|
500
|
+
title: "Get credit balance",
|
|
501
|
+
description: "Return your current call-credit balance and plan.",
|
|
502
|
+
inputSchema: {},
|
|
503
|
+
annotations: { title: "Get credit balance", readOnlyHint: true },
|
|
504
|
+
handler: async () => {
|
|
505
|
+
try {
|
|
506
|
+
return okBody(await client.get("/credits"));
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
return mapError(err);
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: "rynger_checkout",
|
|
515
|
+
title: "Start a credit/plan checkout",
|
|
516
|
+
description: "Start a checkout for a plan (starter, plus, or pro) to add call credits. Returns a URL the human must open to pay.",
|
|
517
|
+
inputSchema: {
|
|
518
|
+
plan: z.enum(PLANS).describe("Which plan to buy: starter, plus, or pro."),
|
|
519
|
+
},
|
|
520
|
+
annotations: { title: "Start a credit/plan checkout", readOnlyHint: false },
|
|
521
|
+
handler: async (args) => {
|
|
522
|
+
try {
|
|
523
|
+
const res = await client.post("/checkout", { plan: args.plan });
|
|
524
|
+
return okBody(res);
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
return mapError(err);
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: "rynger_billing_portal",
|
|
533
|
+
title: "Open billing portal",
|
|
534
|
+
description: "Get a Stripe billing-portal URL the human can open to manage their subscription, payment method, and invoices.",
|
|
535
|
+
inputSchema: {},
|
|
536
|
+
annotations: { title: "Open billing portal", readOnlyHint: false },
|
|
537
|
+
handler: async () => {
|
|
538
|
+
try {
|
|
539
|
+
return okBody(await client.post("/portal"));
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
return mapError(err);
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
name: "rynger_redeem",
|
|
548
|
+
title: "Redeem a code",
|
|
549
|
+
description: "Redeem a promo/credit code and return the granted status and updated balance.",
|
|
550
|
+
inputSchema: {
|
|
551
|
+
code: z.string().describe("The code to redeem."),
|
|
552
|
+
},
|
|
553
|
+
annotations: { title: "Redeem a code", readOnlyHint: false },
|
|
554
|
+
handler: async (args) => {
|
|
555
|
+
try {
|
|
556
|
+
const res = await client.post("/redeem", { code: args.code });
|
|
557
|
+
return okBody(res);
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
return mapError(err);
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
];
|
|
565
|
+
}
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
// --list-tools support (no client, no env, no network)
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
/** Names + one-line descriptions for the --list-tools smoke path. */
|
|
570
|
+
export function listToolSummaries() {
|
|
571
|
+
// Build against a throwaway client stub; handlers are never invoked here.
|
|
572
|
+
const stub = {};
|
|
573
|
+
return buildTools(stub).map((t) => ({
|
|
574
|
+
name: t.name,
|
|
575
|
+
description: firstSentence(t.description),
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
function firstSentence(text) {
|
|
579
|
+
const idx = text.indexOf(". ");
|
|
580
|
+
return idx === -1 ? text : text.slice(0, idx + 1);
|
|
581
|
+
}
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// small utils
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
function sleep(ms) {
|
|
586
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
587
|
+
}
|
|
588
|
+
function readStatus(body) {
|
|
589
|
+
if (body && typeof body === "object" && "status" in body) {
|
|
590
|
+
const s = body.status;
|
|
591
|
+
if (typeof s === "string")
|
|
592
|
+
return s;
|
|
593
|
+
}
|
|
594
|
+
return "pending";
|
|
595
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rynger/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Rynger — your personal AI outbound-call agent. A thin client over Rynger's HTTP API: place one-off calls, schedule them, manage phone numbers, and handle billing from any MCP host.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"rynger-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"start": "node dist/index.js",
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
23
|
+
"test": "npm run build && node test/guards.test.js && node dist/index.js --list-tools"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"model-context-protocol",
|
|
28
|
+
"rynger",
|
|
29
|
+
"ai-calls",
|
|
30
|
+
"outbound",
|
|
31
|
+
"voice-agent"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
35
|
+
"zod": "^3.23.8"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^20.11.0",
|
|
39
|
+
"typescript": "^5.5.0"
|
|
40
|
+
}
|
|
41
|
+
}
|