MCP endpoint
Nervly exposes a Model Context Protocol endpoint so an AI agent can inspect the gateway the way an operator would — answering "is Nervly reaching the broker right now?" without a shell, a dashboard login or a metrics scrape.
Reference: Invoke a Model Context Protocol tool.
Every call is a JSON-RPC 2.0 POST /v1/mcp with an McpRequest body:
| Field | Type | Required | Meaning |
|---|---|---|---|
method | string | Yes | tools/list enumerates the tools; tools/call executes one. |
params | object | null | No | Method arguments. For tools/call, {"name": ..., "arguments": {...}}. |
id | integer | null | No | Correlation id. Echoed in the response; defaults to 1. |
The gateway answers with an McpResponse in which exactly one of result or error is present:
| Field | Type | Meaning |
|---|---|---|
jsonrpc | string | Protocol version. Always "2.0". |
result | object | Tool result on success. Shape depends on the tool. |
error | object | null | JSON-RPC error on failure, with code, message and optional data. |
id | integer | Echo of the request id. |
Protocol-level failures — an unknown method, an unknown tool, or invalid parameters — are still HTTP 200 with an error object, as JSON-RPC requires. Only a missing or invalid bearer token produces a non-2xx status.
Listing the tools
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
curl -X POST https://api.nervly.io/v1/mcp \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "method": "tools/list" }'
interface Tool {
name: string;
description: string;
}
interface McpResponse<T> {
jsonrpc: '2.0';
result: T;
id: number;
}
const response = await fetch('https://api.nervly.io/v1/mcp', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NERVLY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ method: 'tools/list' }),
});
if (!response.ok) {
throw new Error(`Nervly returned ${response.status}: ${await response.text()}`);
}
const { result } = (await response.json()) as McpResponse<{ tools: Tool[] }>;
for (const tool of result.tools) {
console.log(`${tool.name}: ${tool.description}`);
}
import os
from typing import Any
import requests
response = requests.post(
"https://api.nervly.io/v1/mcp",
headers={
"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}",
"Content-Type": "application/json",
},
json={"method": "tools/list"},
timeout=10,
)
response.raise_for_status()
body: dict[str, Any] = response.json()
# McpResponse: jsonrpc="2.0", result (object), id (int).
for tool in body["result"]["tools"]:
print(f"{tool['name']}: {tool['description']}")
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type tool struct {
Name string `json:"name"`
Description string `json:"description"`
}
type listResult struct {
Tools []tool `json:"tools"`
}
type mcpResponse struct {
JSONRPC string `json:"jsonrpc"`
Result json.RawMessage `json:"result"`
ID int64 `json:"id"`
}
func main() {
body, err := json.Marshal(map[string]string{"method": "tools/list"})
if err != nil {
panic(err)
}
req, err := http.NewRequest("POST", "https://api.nervly.io/v1/mcp", bytes.NewBuffer(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var envelope mcpResponse
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
panic(err)
}
var result listResult
if err := json.Unmarshal(envelope.Result, &result); err != nil {
panic(err)
}
for _, t := range result.Tools {
fmt.Printf("%s: %s\n", t.Name, t.Description)
}
}
<?php
$ch = curl_init('https://api.nervly.io/v1/mcp');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NERVLY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['method' => 'tools/list']),
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
curl_close($ch);
/** @var array{jsonrpc: string, result: array{tools: list<array{name: string, description: string}>}, id: int} $body */
$body = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
foreach ($body['result']['tools'] as $tool) {
echo "{$tool['name']}: {$tool['description']}\n";
}
use reqwest::Client;
use serde::Deserialize;
use serde_json::json;
use std::env;
use std::error::Error;
use std::time::Duration;
#[derive(Deserialize)]
struct Tool {
name: String,
description: String,
}
#[derive(Deserialize)]
struct ListResult {
tools: Vec<Tool>,
}
#[derive(Deserialize)]
struct McpResponse<T> {
jsonrpc: String,
result: T,
id: i64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.post("https://api.nervly.io/v1/mcp")
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.header("Content-Type", "application/json")
.json(&json!({ "method": "tools/list" }))
.send()
.await?;
let status = response.status();
let raw = response.text().await?;
if !status.is_success() {
return Err(format!("Nervly returned {status}: {raw}").into());
}
let envelope: McpResponse<ListResult> = serde_json::from_str(&raw)?;
for tool in envelope.result.tools {
println!("{}: {}", tool.name, tool.description);
}
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class McpListTools {
// Reads the flat string fields of McpResponse.result. This keeps the example
// dependency-free on a bare JDK; decode with Jackson or Gson in production.
private static final Pattern FIELD =
Pattern.compile("\"(name|description)\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"");
public static void main(String[] args) throws Exception {
String payload = "{\"method\":\"tools/list\"}";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/mcp"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new IllegalStateException("Nervly returned " + response.statusCode() + ": " + response.body());
}
Matcher matcher = FIELD.matcher(response.body());
while (matcher.find()) {
System.out.println(matcher.group(1) + ": " + matcher.group(2));
}
}
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
public sealed record Tool(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description);
public sealed record ToolsListResult(
[property: JsonPropertyName("tools")] Tool[] Tools);
public sealed record McpResponse<T>(
[property: JsonPropertyName("jsonrpc")] string Jsonrpc,
[property: JsonPropertyName("result")] T Result,
[property: JsonPropertyName("id")] long Id);
public class Program
{
public static async Task Main()
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.nervly.io/v1/mcp");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
request.Content = new StringContent("{\"method\":\"tools/list\"}", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Nervly returned {(int)response.StatusCode}: {body}");
}
var envelope = JsonSerializer.Deserialize<McpResponse<ToolsListResult>>(body)
?? throw new InvalidOperationException("empty MCP response");
foreach (var tool in envelope.Result.Tools)
{
Console.WriteLine($"{tool.Name}: {tool.Description}");
}
}
}
require 'json'
require 'net/http'
require 'uri'
uri = URI('https://api.nervly.io/v1/mcp')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
request['Content-Type'] = 'application/json'
request.body = { method: 'tools/list' }.to_json
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 10
response = http.request(request)
unless response.is_a?(Net::HTTPSuccess)
raise "Nervly returned #{response.code}: #{response.body}"
end
# McpResponse: jsonrpc="2.0", result (object), id (integer).
envelope = JSON.parse(response.body)
envelope.fetch('result').fetch('tools').each do |tool|
puts "#{tool.fetch('name')}: #{tool.fetch('description')}"
end
import Foundation
struct McpRequest: Codable {
let method: String
}
struct Tool: Codable {
let name: String
let description: String
}
struct ToolsListResult: Codable {
let tools: [Tool]
}
struct McpResponse<T: Codable>: Codable {
let jsonrpc: String
let result: T
let id: Int64
}
let body = try JSONEncoder().encode(McpRequest(method: "tools/list"))
var urlRequest = URLRequest(url: URL(string: "https://api.nervly.io/v1/mcp")!)
urlRequest.httpMethod = "POST"
urlRequest.timeoutInterval = 10
urlRequest.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = body
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("Request failed: \(error)")
return
}
guard let http = response as? HTTPURLResponse, let data = data,
(200..<300).contains(http.statusCode) else {
print("MCP call failed")
return
}
if let envelope = try? JSONDecoder().decode(McpResponse<ToolsListResult>.self, from: data) {
for tool in envelope.result.tools {
print("\(tool.name): \(tool.description)")
}
}
}.resume()
semaphore.wait()
{
"jsonrpc": "2.0",
"result": {
"tools": [
{
"name": "gateway_status",
"description": "Inspect real-time edge gateway status: process uptime and whether the NATS broker connection is live or buffering.",
"inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }
},
{
"name": "idempotency_inspect",
"description": "Check whether an Idempotency-Key is cached for this workspace, and how long its replay window has left.",
"inputSchema": {
"type": "object",
"properties": {
"idempotencyKey": { "type": "string", "minLength": 1, "description": "The Idempotency-Key value to inspect." }
},
"required": ["idempotencyKey"],
"additionalProperties": false
}
},
{
"name": "send_notification",
"description": "Trigger a multi-channel notification (SMS, voice, email, WhatsApp or push) for one subscriber. Sandboxed by default: the call validates routing and compliance and returns the dispatch it would have made without sending anything. Pass `live: true` to run the same validation, routing, rate limiting and broker publish path as POST /v1/events/trigger and contact the carrier.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "description": "Workflow/event name configured in the workspace." },
"to": {
"type": "object",
"properties": {
"subscriberId": { "type": "string", "minLength": 1 },
"email": { "type": ["string", "null"] },
"phone": { "type": ["string", "null"], "description": "E.164 number, e.g. +2348012345678." },
"deviceTokens": { "type": ["array", "null"], "items": { "type": "string" } }
},
"required": ["subscriberId"],
"additionalProperties": false
},
"payload": { "type": ["object", "null"], "description": "Template variables referenced as {{key}}." },
"overrides": { "type": ["object", "null"] },
"category": { "type": ["string", "null"] },
"priority": { "type": ["string", "null"], "enum": ["CRITICAL", "HIGH", "NORMAL", "LOW", null] },
"idempotencyKey": { "type": ["string", "null"] },
"live": { "type": "boolean", "description": "Explicit opt-in to send for real. Only a literal `true` dispatches; omit it (or send `false`) to get a sandbox preview. The live path is never reached by omission, a typo or a defaulted argument." }
},
"required": ["name", "to"],
"additionalProperties": false
}
},
{
"name": "check_delivery_status",
"description": "Look up one message's delivery state, provider dispatch details and normalized delivery code, scoped to this workspace.",
"inputSchema": {
"type": "object",
"properties": { "eventId": { "type": "string", "minLength": 1, "description": "evt_<hex> or UUID." } },
"required": ["eventId"],
"additionalProperties": false
}
},
{
"name": "list_templates",
"description": "List the workspace's message templates with their required {{variable}} placeholders, optionally narrowed to one event name or channel.",
"inputSchema": {
"type": "object",
"properties": {
"eventName": { "type": ["string", "null"] },
"channel": { "type": ["string", "null"], "enum": ["sms", "email", "push", "whatsapp", "voice", null] }
},
"additionalProperties": false
}
},
{
"name": "verify_subscriber_channel",
"description": "Check a subscriber's opt-in status, NCC DND state and channel prioritization, and report which channel would be selected.",
"inputSchema": {
"type": "object",
"properties": {
"subscriberId": { "type": "string", "minLength": 1 },
"channel": { "type": ["string", "null"], "enum": ["sms", "email", "push", "whatsapp", "voice", null] },
"category": { "type": ["string", "null"], "description": "Category override to evaluate instead of the channel default." },
"eventName": { "type": ["string", "null"], "description": "Event name the DND intent is classified from when category is omitted." }
},
"required": ["subscriberId"],
"additionalProperties": false
}
},
{
"name": "read_api_schema",
"description": "Read the gateway's published API contract. With no arguments, list every operation and component name; with operationId, return that operation with the component schemas it references; with component, return one named schema. The contract carries no tenant data.",
"inputSchema": {
"type": "object",
"properties": {
"operationId": { "type": ["string", "null"], "description": "Operation id from the index, e.g. trigger-notification." },
"component": { "type": ["string", "null"], "description": "Component schema name from the index, e.g. TriggerRequest." }
},
"additionalProperties": false
}
},
{
"name": "generate_integration_code",
"description": "Generate integration code that triggers a notification, using this workspace's own event names and template variables. Supported languages: curl, javascript, python, go and php.",
"inputSchema": {
"type": "object",
"properties": {
"language": { "type": "string", "enum": ["curl", "javascript", "python", "go", "php"] },
"eventName": { "type": ["string", "null"], "description": "Event name to generate for; must exist in this workspace's templates. Defaults to the workspace's first template." },
"subscriberId": { "type": ["string", "null"] },
"channel": { "type": ["string", "null"], "enum": ["sms", "email", "push", "whatsapp", "voice", null] }
},
"required": ["language"],
"additionalProperties": false
}
}
]
},
"id": 1
}
The catalog above is what a read-write key sees. A read-only key receives the
same list without send_notification.
Calling a tool
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
curl -X POST https://api.nervly.io/v1/mcp \
-H "Authorization: Bearer $NERVLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "method": "tools/call", "params": { "name": "gateway_status" } }'
interface GatewayStatus {
service: string;
uptime: number;
nats_status: string;
}
interface McpResponse<T> {
jsonrpc: '2.0';
result: T;
id: number;
}
const response = await fetch('https://api.nervly.io/v1/mcp', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NERVLY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
method: 'tools/call',
params: { name: 'gateway_status' },
}),
});
if (!response.ok) {
throw new Error(`Nervly returned ${response.status}: ${await response.text()}`);
}
const { result } = (await response.json()) as McpResponse<GatewayStatus>;
if ('error' in result) {
throw new Error(`MCP error: ${(result as { error: string }).error}`);
}
console.log(`${result.service} uptime=${result.uptime}s nats=${result.nats_status}`);
import os
from typing import Any
import requests
response = requests.post(
"https://api.nervly.io/v1/mcp",
headers={
"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}",
"Content-Type": "application/json",
},
json={"method": "tools/call", "params": {"name": "gateway_status"}},
timeout=10,
)
response.raise_for_status()
body: dict[str, Any] = response.json()
result = body["result"]
if "error" in result:
raise RuntimeError(f"MCP error: {result['error']}")
print(f"{result['service']} uptime={result['uptime']}s nats={result['nats_status']}")
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type statusResult struct {
Service string `json:"service"`
Uptime int64 `json:"uptime"`
NatsStatus string `json:"nats_status"`
Error string `json:"error"`
}
type mcpResponse struct {
JSONRPC string `json:"jsonrpc"`
Result json.RawMessage `json:"result"`
ID int64 `json:"id"`
}
func main() {
payload := map[string]any{
"method": "tools/call",
"params": map[string]string{"name": "gateway_status"},
}
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
req, err := http.NewRequest("POST", "https://api.nervly.io/v1/mcp", bytes.NewBuffer(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var envelope mcpResponse
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
panic(err)
}
var result statusResult
if err := json.Unmarshal(envelope.Result, &result); err != nil {
panic(err)
}
if result.Error != "" {
panic("MCP error: " + result.Error)
}
fmt.Printf("%s uptime=%ds nats=%s\n", result.Service, result.Uptime, result.NatsStatus)
}
<?php
$ch = curl_init('https://api.nervly.io/v1/mcp');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NERVLY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'method' => 'tools/call',
'params' => ['name' => 'gateway_status'],
]),
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
curl_close($ch);
$body = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
$result = $body['result'];
if (isset($result['error'])) {
throw new RuntimeException("MCP error: {$result['error']}");
}
printf("%s uptime=%ds nats=%s\n", $result['service'], $result['uptime'], $result['nats_status']);
use reqwest::Client;
use serde::Deserialize;
use serde_json::json;
use std::env;
use std::error::Error;
use std::time::Duration;
#[derive(Deserialize)]
struct StatusResult {
service: Option<String>,
uptime: Option<u64>,
nats_status: Option<String>,
error: Option<String>,
}
#[derive(Deserialize)]
struct McpResponse<T> {
jsonrpc: String,
result: T,
id: i64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.post("https://api.nervly.io/v1/mcp")
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.header("Content-Type", "application/json")
.json(&json!({
"method": "tools/call",
"params": { "name": "gateway_status" }
}))
.send()
.await?;
let status = response.status();
let raw = response.text().await?;
if !status.is_success() {
return Err(format!("Nervly returned {status}: {raw}").into());
}
let envelope: McpResponse<StatusResult> = serde_json::from_str(&raw)?;
if let Some(error) = envelope.result.error {
return Err(format!("MCP error: {error}").into());
}
println!(
"{} uptime={}s nats={}",
envelope.result.service.unwrap_or_default(),
envelope.result.uptime.unwrap_or_default(),
envelope.result.nats_status.unwrap_or_default()
);
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class McpCallTool {
public static void main(String[] args) throws Exception {
String payload = "{\"method\":\"tools/call\",\"params\":{\"name\":\"gateway_status\"}}";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/mcp"))
.timeout(Duration.ofSeconds(10))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new IllegalStateException("Nervly returned " + response.statusCode() + ": " + response.body());
}
String result = response.body();
System.out.println("service=" + value(result, "service")
+ " uptime=" + value(result, "uptime")
+ " nats_status=" + value(result, "nats_status"));
}
/** Reads one scalar (string or number) under `"key":` from the JSON body. */
private static String value(String json, String key) {
int at = json.indexOf('"' + key + "\":");
if (at < 0) return "";
int i = json.indexOf(':', at) + 1;
while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++;
if (i < json.length() && json.charAt(i) == '"') {
int start = i + 1;
int end = start;
while (end < json.length() && json.charAt(end) != '"') end++;
return json.substring(start, end);
}
int end = i;
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '.')) end++;
return json.substring(i, end);
}
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
public sealed record GatewayStatus(
[property: JsonPropertyName("service")] string Service,
[property: JsonPropertyName("uptime")] long Uptime,
[property: JsonPropertyName("nats_status")] string NatsStatus);
public sealed record McpResponse<T>(
[property: JsonPropertyName("jsonrpc")] string Jsonrpc,
[property: JsonPropertyName("result")] T Result,
[property: JsonPropertyName("id")] long Id);
public class Program
{
public static async Task Main()
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.nervly.io/v1/mcp");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
request.Content = new StringContent(
"{\"method\":\"tools/call\",\"params\":{\"name\":\"gateway_status\"}}",
Encoding.UTF8,
"application/json");
using var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Nervly returned {(int)response.StatusCode}: {body}");
}
var envelope = JsonSerializer.Deserialize<McpResponse<GatewayStatus>>(body)
?? throw new InvalidOperationException("empty MCP response");
Console.WriteLine(
$"{envelope.Result.Service} uptime={envelope.Result.Uptime}s nats={envelope.Result.NatsStatus}");
}
}
require 'json'
require 'net/http'
require 'uri'
uri = URI('https://api.nervly.io/v1/mcp')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
request['Content-Type'] = 'application/json'
request.body = { method: 'tools/call', params: { name: 'gateway_status' } }.to_json
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 10
response = http.request(request)
unless response.is_a?(Net::HTTPSuccess)
raise "Nervly returned #{response.code}: #{response.body}"
end
result = JSON.parse(response.body).fetch('result')
raise "MCP error: #{result['error']}" if result.key?('error')
puts "#{result['service']} uptime=#{result['uptime']}s nats=#{result['nats_status']}"
import Foundation
struct McpRequest: Codable {
let method: String
let params: [String: String]?
}
struct GatewayStatus: Codable {
let service: String
let uptime: Int64
let nats_status: String
}
struct McpResponse<T: Codable>: Codable {
let jsonrpc: String
let result: T
let id: Int64
}
let body = try JSONEncoder().encode(
McpRequest(method: "tools/call", params: ["name": "gateway_status"])
)
var urlRequest = URLRequest(url: URL(string: "https://api.nervly.io/v1/mcp")!)
urlRequest.httpMethod = "POST"
urlRequest.timeoutInterval = 10
urlRequest.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = body
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("Request failed: \(error)")
return
}
guard let http = response as? HTTPURLResponse, let data = data,
(200..<300).contains(http.statusCode) else {
print("MCP call failed")
return
}
if let envelope = try? JSONDecoder().decode(McpResponse<GatewayStatus>.self, from: data) {
print("\(envelope.result.service) uptime=\(envelope.result.uptime)s nats=\(envelope.result.nats_status)")
}
}.resume()
semaphore.wait()
{
"jsonrpc": "2.0",
"result": {
"service": "nervly-gateway",
"uptime": 86400,
"nats_status": "CONNECTED"
},
"id": 1
}
nats_status is CONNECTED while the gateway holds a broker connection and
FALLBACK_BUFFER when it is buffering instead — the value an operator would
look for when asking "is Nervly reaching the broker right now?".
The tool catalog
tools/list advertises eight tools to a read-write key and seven to a
read-only key: send_notification requires the write scope, so it is omitted
for a read-only key and rejected if called by name. Every tool validates its
arguments strictly: unknown parameters are rejected, so a typo or a smuggled
workspaceId fails rather than being ignored.
| Tool | Arguments | What it returns |
|---|---|---|
gateway_status | none | Process uptime and whether NATS is connected or buffering. |
idempotency_inspect | idempotencyKey | Whether that replay key is cached for this workspace, and its remaining TTL. |
send_notification | name, to, optional payload, overrides, category, priority, idempotencyKey, live | A sandbox preview by default; with live: true, the same TriggerResponse as POST /v1/events/trigger. |
check_delivery_status | eventId | Delivery status, provider dispatch details, timeline, and a normalized delivery code. |
list_templates | optional eventName, channel | Workspace templates with their required {{variable}} placeholders. |
verify_subscriber_channel | subscriberId, optional channel, category, eventName | Opt-in status, NCC DND state, channel prioritization, and the channel that would be selected. eventName is the fallback the DND intent is classified from when category is omitted. |
read_api_schema | optional operationId, component | The gateway's published API contract: an operation index, one operation with its referenced schemas, or one named component schema. |
generate_integration_code | language, optional eventName, subscriberId, channel | A ready-to-run snippet that triggers a notification, bound to this workspace's event names and template variables. |
Sending a notification
send_notification is sandboxed by default. A call without the live opt-in
validates the request and returns the dispatch it would have made without
sending anything; a real send requires the explicit live: true argument, at
which point the tool runs the same validation, rate limiting, persistence and
broker publish path as the REST trigger endpoint, so an agent cannot bypass
routing or tenancy. The call is tools/call with
arguments: { name, to, payload?, overrides?, category?, priority?, idempotencyKey?, live? },
and a live result is a TriggerResponse:
{
"jsonrpc": "2.0",
"result": {
"eventId": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8",
"status": "QUEUED",
"idempotencyKey": "order-123",
"priority": "NORMAL",
"channel": "sms",
"timestamp": "2026-08-28T09:15:04.221Z"
},
"id": 7
}
Checking a delivery
{
"jsonrpc": "2.0",
"result": {
"event_id": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8",
"status": "DELIVERED",
"normalized_code": "delivered",
"provider": "termii",
"provider_message_id": "msg_ext_778899",
"created_at": "2026-08-28T09:15:04.221Z",
"updated_at": "2026-08-28T09:15:09.004Z",
"events": [
{ "seq": 1, "status": "SENT", "normalized_code": "in_transit", "occurred_at": "2026-08-28T09:15:05.100Z" }
]
},
"id": 8
}
Sandbox test mode
Sandbox is the default. A send_notification call that omits the opt-in — or
passes "live": false — validates the request and returns the dispatch it
would have made, without sending anything. A real send requires the explicit
"live": true argument, so a call can never reach a real recipient through a
real carrier by omission, a typo or a defaulted argument.
In sandbox mode the gateway resolves routing and evaluates the subscriber's compliance state — opt-in, email suppression and cached NCC DND — then returns a preview instead of publishing:
{
"jsonrpc": "2.0",
"result": {
"sandbox": true,
"dispatched": false,
"would_dispatch": true,
"would_deliver": true,
"event_name": "otp",
"subscriber_id": "sub-1",
"priority": "NORMAL",
"channel": "sms",
"subject": "notify.bulk.sms",
"stream_class": "bulk",
"eligible_channels": ["sms"],
"compliance": { "selected_channel": "sms", "erased": false },
"reason": null
},
"id": 9
}
would_dispatch is the gateway's routing decision — it would publish an event.
would_deliver folds in the compliance view the worker applies before a carrier
call, so a subscriber who opted out, is erased, is suppressed or is on the NCC
DND register previews as non-deliverable with a reason. A sandbox call is
read-only: it does not consume the rate limit, does not cache an idempotency
key, writes no message row and publishes nothing to the broker, so no carrier is
ever contacted. When the recipient has no reachable channel, would_dispatch is
false and reason explains why — a validation result, not an
accepted-and-dropped event. A preview always labels itself "sandbox": true
and "dispatched": false; a live TriggerResponse carries neither label, so
the two can never be confused.
Reading the API schema
read_api_schema lets an agent read the same contract the reference site
publishes, derived from the server's own source. With no arguments it returns
the operation index and the component names:
{
"jsonrpc": "2.0",
"result": {
"operations": [
{ "operation_id": "trigger-notification", "method": "POST", "path": "/v1/events/trigger", "summary": "Trigger a notification", "tags": ["Events"] }
],
"components": ["RecipientDto", "TriggerRequest", "TriggerResponse"]
},
"id": 10
}
With operationId it returns that operation together with every component
schema it references, transitively — so one call yields the complete request
shape, nested DTOs included. With component it returns one named schema. An
unknown name is a -32004 resource error, not an empty answer. The contract is
platform-wide and carries no tenant data.
Generating integration code
generate_integration_code produces a snippet that calls
POST /v1/events/trigger, bound to the calling workspace's own templates. It
supports curl, javascript, python, go and php.
Pass a language and, optionally, an eventName. When eventName is omitted
the workspace's first template is used; when it is supplied it must exist in
this workspace, otherwise the call is a -32004 resource error. The response
carries the generated code, the variables the template expects, and the
event_name it used:
{
"jsonrpc": "2.0",
"result": {
"language": "python",
"operation_id": "trigger-notification",
"path": "/v1/events/trigger",
"event_name": "payment-received",
"variables": ["amount", "name", "order_id"],
"code": "import os\nimport requests\n..."
},
"id": 11
}
Because the event name comes from the workspace's own templates, the snippet can only ever name events the key already owns.
Errors
A protocol or parameter failure returns HTTP 200 with a JSON-RPC 2.0 error
object:
{
"jsonrpc": "2.0",
"error": {
"code": -32602,
"message": "Invalid arguments for check_delivery_status",
"data": { "tool": "check_delivery_status" }
},
"id": 1
}
| Code | Meaning |
|---|---|
-32601 | Method not found — an unrecognised method. |
-32602 | Invalid params — an unknown tool, a missing required argument, or a bad enum value. |
-32603 | Internal error — a dependency (Postgres, Redis, NATS) failed. |
-32003 | Permission denied — the API key lacks the read or write scope the tool requires. |
-32004 | Resource not found — the event or template is not visible to this workspace. |
-32000 | Tool failed — a rejected dispatch or an exceeded rate limit. |
id echoes the value you sent, or 1 when the request omitted one, so
concurrent calls can be correlated.
Security
This endpoint sits behind the same bearer API
key as the rest of /v1. The tools a key can
reach depend on its scopes: a key with write can call every tool, while a
read-only key (read) receives only the diagnostic tools — gateway_status,
idempotency_inspect, check_delivery_status, list_templates,
verify_subscriber_channel, read_api_schema and
generate_integration_code. send_notification is omitted from tools/list
for a read-only key and rejected if called by name.
Every tool is confined to the workspace that owns the key. No tool accepts a
workspace identifier as an argument, and every query is scoped to the
authenticated tenant, so an AI tool call cannot read or write across a
workspace boundary. read_api_schema reads the platform contract, which
carries no tenant data; generate_integration_code reads only the calling
workspace's templates.
send_notification is sandboxed by default, but live: true still sends. For
an agent that must never send at all, issue a key with the read scope: it
receives the seven diagnostic tools and cannot call send_notification even by
name. See Managing API keys.
Next
- Authentication
- Report gateway health — the unauthenticated alternative for liveness