byourside-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 +151 -0
- package/package.json +23 -0
- package/src/client.js +30 -0
- package/src/config.js +12 -0
- package/src/errors.js +28 -0
- package/src/server.js +29 -0
- package/src/tools.js +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 By Your Side
|
|
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,151 @@
|
|
|
1
|
+
# byourside-mcp
|
|
2
|
+
|
|
3
|
+
MCP server that gives any MCP client (Claude Desktop, Cursor, etc.) a phone -- place outbound AI calls, poll for results, and list call history via the By Your Side Agent API.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node 18 or newer
|
|
8
|
+
- A By Your Side agent API key (starts with `bys_ak_`)
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd mcp
|
|
14
|
+
npm install
|
|
15
|
+
BYOURSIDE_API_KEY=bys_ak_your_key_here npm start
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## MCP client configuration
|
|
19
|
+
|
|
20
|
+
### Claude Desktop
|
|
21
|
+
|
|
22
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or the equivalent on your OS:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"mcpServers": {
|
|
27
|
+
"byourside": {
|
|
28
|
+
"command": "node",
|
|
29
|
+
"args": ["/absolute/path/to/voip-agent/mcp/src/server.js"],
|
|
30
|
+
"env": { "BYOURSIDE_API_KEY": "bys_ak_your_key_here" }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Cursor
|
|
37
|
+
|
|
38
|
+
Add to `.cursor/mcp.json` in your project (or the global `~/.cursor/mcp.json`):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"mcpServers": {
|
|
43
|
+
"byourside": {
|
|
44
|
+
"command": "node",
|
|
45
|
+
"args": ["/absolute/path/to/voip-agent/mcp/src/server.js"],
|
|
46
|
+
"env": { "BYOURSIDE_API_KEY": "bys_ak_your_key_here" }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To point at a staging or self-hosted instance, add `"BYOURSIDE_API_BASE": "https://staging.example.com"` to the `env` block.
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
### `place_call`
|
|
57
|
+
|
|
58
|
+
Places an outbound AI phone call toward an objective. Returns a `callId` immediately; the call runs asynchronously. Poll `get_call` with that `callId` until status is terminal.
|
|
59
|
+
|
|
60
|
+
**Inputs:**
|
|
61
|
+
|
|
62
|
+
| Field | Type | Required | Description |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| `to` | string | yes | Destination number in E.164 (e.g. `+14155550123`) |
|
|
65
|
+
| `objective` | string | yes | What the call should accomplish |
|
|
66
|
+
| `context` | string | no | Background context for the assistant |
|
|
67
|
+
| `fields` | array | no | Structured fields to extract (up to 20 items: `{ name, type?, description? }`) |
|
|
68
|
+
| `webhookUrl` | string | no | HTTPS URL to receive the signed result when the call ends |
|
|
69
|
+
| `callerId` | string | no | Caller ID override; must be a number on your account |
|
|
70
|
+
|
|
71
|
+
**Returns:** `{ callId, status }` where `status` is `queued` on success.
|
|
72
|
+
|
|
73
|
+
### `get_call`
|
|
74
|
+
|
|
75
|
+
Fetches the current status and result of a call.
|
|
76
|
+
|
|
77
|
+
**Inputs:**
|
|
78
|
+
|
|
79
|
+
| Field | Type | Required | Description |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| `callId` | string | yes | The `callId` from `place_call` |
|
|
82
|
+
|
|
83
|
+
**Returns:** `{ id, status, summary, transcript, extracted, recordingUrl, to, objective, startedAt, endedAt, durationSec, error? }`
|
|
84
|
+
|
|
85
|
+
### `list_calls`
|
|
86
|
+
|
|
87
|
+
Lists recent calls placed by your account (most recent first).
|
|
88
|
+
|
|
89
|
+
**Inputs:**
|
|
90
|
+
|
|
91
|
+
| Field | Type | Required | Description |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| `limit` | number | no | Max calls to return (default 20, max 100) |
|
|
94
|
+
|
|
95
|
+
**Returns:** `{ calls: [ { id, to, objective, status, summary, createdAt, startedAt, endedAt, durationSec, error? } ] }`
|
|
96
|
+
|
|
97
|
+
## Call statuses
|
|
98
|
+
|
|
99
|
+
| Status | Terminal? | Meaning |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `queued` | no | Call accepted, not yet dialing |
|
|
102
|
+
| `ringing` | no | Dialing the destination |
|
|
103
|
+
| `in_progress` | no | Call is live |
|
|
104
|
+
| `completed` | yes | Call finished normally |
|
|
105
|
+
| `no_answer` | yes | Destination did not pick up |
|
|
106
|
+
| `voicemail` | yes | Reached voicemail |
|
|
107
|
+
| `declined` | yes | Call was rejected |
|
|
108
|
+
| `failed` | yes | Technical failure |
|
|
109
|
+
|
|
110
|
+
## Place your first call
|
|
111
|
+
|
|
112
|
+
1. Configure your MCP client as above.
|
|
113
|
+
2. Ask your assistant: "Place a call to +14155550123 with the objective: confirm the meeting time for tomorrow at 2pm."
|
|
114
|
+
3. The assistant calls `place_call` and gets back a `callId`.
|
|
115
|
+
4. Ask: "Check on call `<callId>` -- did it complete?"
|
|
116
|
+
5. The assistant calls `get_call` and returns the status, summary, and any extracted fields.
|
|
117
|
+
|
|
118
|
+
## Manual end-to-end test
|
|
119
|
+
|
|
120
|
+
1. Set `BYOURSIDE_API_KEY` and optionally `BYOURSIDE_API_BASE` in your MCP client config.
|
|
121
|
+
2. Configure the client using the JSON above.
|
|
122
|
+
3. Ask your agent to call a number you control with a specific objective and one extraction field (e.g. `{ name: "confirmed", type: "boolean" }`).
|
|
123
|
+
4. Confirm `place_call` returns a `callId`.
|
|
124
|
+
5. Call `get_call` repeatedly until `status` is terminal; verify `extracted.confirmed` is present.
|
|
125
|
+
6. Try a blocked destination (e.g. a premium-rate number) and confirm the mapped error message is returned.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
Run tests:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cd mcp && node --test
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Check server parses:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
node --check mcp/src/server.js
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Smoke test (missing key, should exit 1):
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
node mcp/src/server.js
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Smoke test (with dummy key, should print "ready on stdio" to stderr):
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
BYOURSIDE_API_KEY=bys_ak_test node mcp/src/server.js
|
|
151
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "byourside-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "MCP server giving AI agents a phone via By Your Side outbound AI calls.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "By Your Side",
|
|
8
|
+
"homepage": "https://byourside.ai/docs/agent-api",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/allexp1/voip-agent.git",
|
|
12
|
+
"directory": "mcp"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["voice", "ai", "phone", "outbound", "agent", "mcp", "voip", "calls"],
|
|
15
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
16
|
+
"bin": { "byourside-mcp": "src/server.js" },
|
|
17
|
+
"scripts": { "test": "node --test", "start": "node src/server.js" },
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
20
|
+
"zod": "^3.23.0"
|
|
21
|
+
},
|
|
22
|
+
"engines": { "node": ">=18" }
|
|
23
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Tiny authed REST client for the By Your Side Agent API. Never throws on HTTP or
|
|
2
|
+
// network errors; returns a structured result so tool handlers can surface a clean
|
|
3
|
+
// message. fetchImpl is injectable for tests (defaults to global fetch on Node 18+).
|
|
4
|
+
import { mapApiError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export function createClient({ apiKey, baseUrl, fetchImpl = globalThis.fetch } = {}) {
|
|
7
|
+
if (typeof fetchImpl !== 'function') throw new Error('no fetch available (Node 18+ required)');
|
|
8
|
+
async function request(method, path, body) {
|
|
9
|
+
const url = `${baseUrl}${path}`;
|
|
10
|
+
const opts = { method, headers: { Authorization: `Bearer ${apiKey}` } };
|
|
11
|
+
if (body !== undefined && method !== 'GET') {
|
|
12
|
+
opts.headers['content-type'] = 'application/json';
|
|
13
|
+
opts.body = JSON.stringify(body);
|
|
14
|
+
}
|
|
15
|
+
let res;
|
|
16
|
+
try {
|
|
17
|
+
res = await fetchImpl(url, opts);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return { ok: false, status: 0, error: 'network_error', message: mapApiError(0, null) };
|
|
20
|
+
}
|
|
21
|
+
let data = null;
|
|
22
|
+
try { data = await res.json(); } catch { data = null; }
|
|
23
|
+
if (res.ok) {
|
|
24
|
+
if (data == null) return { ok: false, status: res.status, error: 'empty_response', message: 'Server returned an empty or non-JSON response. Please retry shortly.' };
|
|
25
|
+
return { ok: true, status: res.status, data };
|
|
26
|
+
}
|
|
27
|
+
return { ok: false, status: res.status, error: (data && data.error) || 'error', message: mapApiError(res.status, data) };
|
|
28
|
+
}
|
|
29
|
+
return { request };
|
|
30
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Reads + validates the MCP server's runtime config from an env object.
|
|
2
|
+
// Pure (takes env in) so it is unit-testable without touching process.env.
|
|
3
|
+
const DEFAULT_BASE = 'https://api.byourside.ai';
|
|
4
|
+
|
|
5
|
+
export function loadConfig(env = process.env) {
|
|
6
|
+
const apiKey = String(env.BYOURSIDE_API_KEY || '').trim();
|
|
7
|
+
if (!apiKey) {
|
|
8
|
+
throw new Error('BYOURSIDE_API_KEY is required (your bys_ak_ agent key). Set it in your MCP client config.');
|
|
9
|
+
}
|
|
10
|
+
const baseUrl = String(env.BYOURSIDE_API_BASE || DEFAULT_BASE).trim().replace(/\/+$/, '');
|
|
11
|
+
return { apiKey, baseUrl };
|
|
12
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Map a By Your Side Agent API error (status + body token) to a clear, actionable
|
|
2
|
+
// message for the calling LLM/user. Never includes secrets. Pure.
|
|
3
|
+
const TOKEN_MESSAGES = {
|
|
4
|
+
destination_blocked: "That destination is not allowed (premium, IRSF, or unsupported country).",
|
|
5
|
+
invalid_number: 'The destination number is invalid (use full E.164, e.g. +14155550123).',
|
|
6
|
+
to_required: 'A destination number ("to") is required.',
|
|
7
|
+
objective_required: 'An "objective" for the call is required.',
|
|
8
|
+
invalid_context: 'The "context" value is invalid.',
|
|
9
|
+
invalid_fields: 'The "fields" value is invalid (use up to 20 items of { name, type?, description? }).',
|
|
10
|
+
invalid_webhook_url: 'The "webhookUrl" must be an https URL.',
|
|
11
|
+
invalid_caller_id: 'The "callerId" value is invalid.',
|
|
12
|
+
caller_id_not_owned: "That caller ID is not a number on your account.",
|
|
13
|
+
rate_limited: 'Rate limit reached. Try again shortly.',
|
|
14
|
+
over_minute_cap: 'Outbound usage limit reached for now.',
|
|
15
|
+
unauthorized: 'Invalid or missing API key (BYOURSIDE_API_KEY).',
|
|
16
|
+
not_found: 'No call found with that id (or it does not belong to your account).',
|
|
17
|
+
placement_failed: 'The call could not be placed (carrier/trunk issue). Try again shortly.',
|
|
18
|
+
store_error: 'Temporary service error. Please retry shortly.',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function mapApiError(status, body) {
|
|
22
|
+
if (!status) return 'Temporary service error. Please retry shortly.';
|
|
23
|
+
const token = body && typeof body === 'object' ? String(body.error || '') : '';
|
|
24
|
+
if (token && TOKEN_MESSAGES[token]) return TOKEN_MESSAGES[token];
|
|
25
|
+
if (status >= 500) return 'Temporary service error. Please retry shortly.';
|
|
26
|
+
if (token) return `Request failed (${token}).`;
|
|
27
|
+
return `Request failed with status ${status}.`;
|
|
28
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// By Your Side MCP server (stdio). Registers place_call / get_call / list_calls on an
|
|
3
|
+
// McpServer and serves over stdin/stdout. Config comes from env (BYOURSIDE_API_KEY,
|
|
4
|
+
// BYOURSIDE_API_BASE). Exits with a clear message if the key is missing.
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { createClient } from './client.js';
|
|
9
|
+
import { buildTools } from './tools.js';
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
let cfg;
|
|
13
|
+
try { cfg = loadConfig(process.env); }
|
|
14
|
+
catch (e) { console.error(`[byourside-mcp] ${e.message}`); process.exit(1); }
|
|
15
|
+
|
|
16
|
+
const client = createClient({ apiKey: cfg.apiKey, baseUrl: cfg.baseUrl });
|
|
17
|
+
const server = new McpServer({ name: 'byourside-mcp', version: '0.1.0' });
|
|
18
|
+
|
|
19
|
+
for (const t of buildTools(client)) {
|
|
20
|
+
// SDK high-level registration: (name, description, zodRawShape, handler).
|
|
21
|
+
// SDK version 1.29.0 uses this variadic overload.
|
|
22
|
+
server.tool(t.name, t.description, t.inputShape, t.handler);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await server.connect(new StdioServerTransport());
|
|
26
|
+
console.error('[byourside-mcp] ready on stdio');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
main().catch((e) => { console.error('[byourside-mcp] fatal:', e?.message || e); process.exit(1); });
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The three MCP tools, as plain definitions ({name, description, inputShape, handler})
|
|
2
|
+
// so they are unit-testable without the MCP SDK. server.js registers them on an
|
|
3
|
+
// McpServer. Handlers call the injected REST client and shape the MCP result; API
|
|
4
|
+
// errors are surfaced as { isError: true } with the mapped message (never thrown).
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
const okText = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
8
|
+
const errText = (msg) => ({ isError: true, content: [{ type: 'text', text: msg }] });
|
|
9
|
+
const result = (r) => (r.ok ? okText(r.data) : errText(r.message || `Request failed (${r.error || r.status}).`));
|
|
10
|
+
|
|
11
|
+
export function buildTools(client) {
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
name: 'place_call',
|
|
15
|
+
description:
|
|
16
|
+
'Place an outbound AI phone call toward an objective. Returns a callId immediately; ' +
|
|
17
|
+
'the call is conducted asynchronously. Poll get_call with that callId until status is ' +
|
|
18
|
+
'terminal (completed | no_answer | voicemail | declined | failed).',
|
|
19
|
+
inputShape: {
|
|
20
|
+
to: z.string().describe('Destination phone number in E.164, e.g. +14155550123.'),
|
|
21
|
+
objective: z.string().describe('What the call should accomplish.'),
|
|
22
|
+
context: z.string().optional().describe('Background context for the assistant.'),
|
|
23
|
+
fields: z.array(z.object({
|
|
24
|
+
name: z.string(),
|
|
25
|
+
type: z.enum(['string', 'boolean', 'number']).optional(),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
})).optional().describe('Structured fields to extract from the call.'),
|
|
28
|
+
webhookUrl: z.string().optional().describe('Optional https URL to receive the signed result.'),
|
|
29
|
+
callerId: z.string().optional().describe('Optional caller ID; must be a number on your account.'),
|
|
30
|
+
},
|
|
31
|
+
handler: async (args) => result(await client.request('POST', '/v1/agent/calls', {
|
|
32
|
+
to: args.to, objective: args.objective, context: args.context,
|
|
33
|
+
fields: args.fields, webhookUrl: args.webhookUrl, callerId: args.callerId,
|
|
34
|
+
})),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'get_call',
|
|
38
|
+
description:
|
|
39
|
+
'Get the status and result of a call by id. status is one of queued | ringing | ' +
|
|
40
|
+
'in_progress (still running, poll again) or completed | no_answer | voicemail | ' +
|
|
41
|
+
'declined | failed (terminal). Returns summary, transcript, and extracted fields.',
|
|
42
|
+
inputShape: { callId: z.string().describe('The callId returned by place_call.') },
|
|
43
|
+
handler: async (args) => result(await client.request('GET', `/v1/agent/calls/${encodeURIComponent(args.callId)}`)),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'list_calls',
|
|
47
|
+
description: 'List recent calls placed by your account (most recent first).',
|
|
48
|
+
inputShape: { limit: z.number().int().positive().max(100).optional().describe('Max calls to return (default 20, max 100).') },
|
|
49
|
+
handler: async (args) => {
|
|
50
|
+
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100); // backstop for direct callers bypassing zod
|
|
51
|
+
return result(await client.request('GET', `/v1/agent/calls?limit=${limit}`));
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|