@vibeworks/barkme-mcp-server
Version:
A Model Context Protocol (MCP) server that provides iOS push notifications through the Bark service
615 lines (610 loc) • 23.3 kB
JavaScript
export const VERSION = "0.1.1";
const DEFAULT_BARK_SERVER = "https://api.day.app";
const DEFAULT_GROUP_NAME = "Bark MCP Server";
const DEFAULT_RETRY_COUNT = "1";
const DEFAULT_ASYNC_MODE = "true";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
import { zodToJsonSchema } from "zod-to-json-schema";
import { z } from "zod";
import got from "got";
import { logger } from "./logger.js";
function makeJsonSchema(schema) {
return zodToJsonSchema(schema);
}
function isValidUrl(url) {
try {
new URL(url);
return true;
}
catch {
return false;
}
}
function loadBarkConfig() {
const devices = new Map();
const devicesStr = process.env.BARK_DEVICES?.trim();
if (!devicesStr) {
throw new Error("BARK_DEVICES environment variable is required. Format: 'iPhone:key1,iPad:key2' or 'key1,key2'");
}
const pairs = devicesStr
.split(",")
.map((pair) => pair.trim())
.filter((pair) => pair.length > 0);
for (const pair of pairs) {
if (pair.includes(":")) {
const [alias, key] = pair.split(":").map((s) => s.trim());
if (alias && key) {
devices.set(alias, key);
}
}
else {
const key = pair.trim();
if (key) {
const autoAlias = `device${devices.size + 1}`;
devices.set(autoAlias, key);
}
}
}
if (devices.size === 0) {
throw new Error("No valid devices found in BARK_DEVICES. Format: 'iPhone:key1,iPad:key2' or 'key1,key2'");
}
const configuredDefault = process.env.BARK_DEFAULT_DEVICE?.trim();
let defaultDevice = Array.from(devices.keys())[0];
if (configuredDefault && devices.has(configuredDefault)) {
defaultDevice = configuredDefault;
}
else if (configuredDefault) {
logger.warn(`BARK_DEFAULT_DEVICE "${configuredDefault}" not found in devices, using first device: ${defaultDevice}`);
}
const server = process.env.BARK_SERVER?.trim() || DEFAULT_BARK_SERVER;
if (!isValidUrl(server)) {
throw new Error(`Invalid BARK_SERVER URL: ${server}`);
}
const icon = process.env.BARK_ICON?.trim();
if (icon && !isValidUrl(icon)) {
throw new Error(`Invalid BARK_ICON URL: ${icon}`);
}
const retryStr = process.env.BARK_RETRY?.trim() || DEFAULT_RETRY_COUNT;
const retry = parseInt(retryStr, 10);
if (isNaN(retry) || retry < 0 || retry > 10) {
throw new Error(`Invalid BARK_RETRY value: ${retryStr}. Must be a number between 0-10.`);
}
const level = process.env.BARK_LEVEL?.trim();
if (level &&
!["passive", "active", "timeSensitive", "critical"].includes(level)) {
throw new Error(`Invalid BARK_LEVEL value: ${level}. Must be one of: passive, active, timeSensitive, critical.`);
}
const volumeStr = process.env.BARK_VOLUME?.trim();
let volume;
if (volumeStr) {
volume = parseInt(volumeStr, 10);
if (isNaN(volume) || volume < 0 || volume > 10) {
throw new Error(`Invalid BARK_VOLUME value: ${volumeStr}. Must be a number between 0-10.`);
}
}
const asyncStr = process.env.BARK_ASYNC?.trim() || DEFAULT_ASYNC_MODE;
const async = asyncStr.toLowerCase() !== "false";
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
return {
server,
devices,
defaultDevice,
async,
proxyUrl,
group: process.env.BARK_GROUP?.trim() || DEFAULT_GROUP_NAME,
sound: process.env.BARK_SOUND?.trim(),
icon,
retry,
level,
volume,
};
}
let cachedConfig = null;
let configError = null;
function getBarkConfig() {
if (configError)
throw configError;
if (cachedConfig)
return cachedConfig;
try {
cachedConfig = loadBarkConfig();
return cachedConfig;
}
catch (error) {
configError = error;
throw configError;
}
}
function createNotificationSchema(config) {
const deviceAliases = Array.from(config.devices.keys());
return z
.object({
message: z
.string()
.min(1)
.optional()
.describe("Main notification text (required unless deleting existing notification)"),
title: z
.string()
.optional()
.describe("Bold title shown above the message"),
subtitle: z
.string()
.optional()
.describe("Secondary text shown below the title"),
level: z
.enum(["passive", "active", "timeSensitive", "critical"])
.optional()
.describe(config.level
? `Urgency level (default: "${config.level}"): passive=silent, active=normal, timeSensitive=breaks DND, critical=emergency`
: "Urgency level: passive=silent notification, active=normal alert (default), timeSensitive=breaks through Do Not Disturb, critical=emergency alert with sound"),
group: z
.string()
.optional()
.describe(config.group
? `Category name to group related notifications (default: "${config.group}")`
: "Category name to group related notifications together"),
icon: z
.string()
.url()
.optional()
.describe(config.icon
? `Custom icon image URL (default: "${config.icon}")`
: "Custom icon image URL to display instead of default Bark icon"),
url: z
.string()
.url()
.optional()
.describe("Web link or app URL to open when user taps the notification"),
sound: z
.string()
.optional()
.describe(config.sound
? `Custom notification sound (default: "${config.sound}")`
: "Custom notification sound name"),
badge: z
.number()
.int()
.min(0)
.optional()
.describe("App badge number (any integer)"),
copy: z
.string()
.optional()
.describe("Custom text to copy when notification is tapped (defaults to full notification content)"),
autocopy: z
.boolean()
.optional()
.describe("Auto-copy message content (iOS 14.5+ requires manual long press)"),
ring: z
.boolean()
.optional()
.describe("Repeat notification sound continuously"),
archive: z
.boolean()
.optional()
.describe("Save notification to history (overrides app settings)"),
volume: z
.number()
.int()
.min(0)
.max(10)
.optional()
.describe(config.volume !== undefined
? `Alert volume for critical notifications (0-10, default: ${config.volume})`
: "Alert volume for critical notifications (0-10, default 5)"),
action: z
.enum(["none"])
.optional()
.describe("Disable popup when notification is tapped (pass 'none')"),
ciphertext: z
.string()
.optional()
.describe("Encrypted message text for secure notifications"),
targets: z
.array(z.string())
.optional()
.describe(deviceAliases.length > 0
? `Device aliases to send notification to. Available devices: ${deviceAliases.join(", ")}. If not specified, sends to all configured devices.`
: "Device aliases to send notification to (if not specified, sends to all configured devices)"),
id: z
.string()
.optional()
.describe("Notification ID for update operations (same ID updates existing notification)"),
delete: z
.boolean()
.optional()
.describe("Delete notification from system notification center and app history (requires id and background app refresh enabled)"),
})
.refine((data) => {
if (!data.delete && !data.message) {
return false;
}
if (data.delete && !data.id) {
return false;
}
return true;
}, {
message: "Message required unless deleting. Delete operations require id parameter.",
});
}
let httpClient;
let currentProxyUrl;
function getHttpClient(proxyUrl, retryCount = 1) {
if (!httpClient || currentProxyUrl !== proxyUrl) {
httpClient = got.extend({
timeout: { request: 10_000, connect: 5_000 },
retry: {
limit: Math.max(0, retryCount - 1),
methods: ["POST"],
statusCodes: [408, 413, 429, 500, 502, 503, 504, 521, 522, 524],
errorCodes: [
"ETIMEDOUT",
"ECONNRESET",
"EADDRINUSE",
"ECONNREFUSED",
"EPIPE",
"ENOTFOUND",
"ENETUNREACH",
"EAI_AGAIN",
],
},
http2: false,
});
currentProxyUrl = proxyUrl;
}
return httpClient;
}
function resolveTargetDevices(config, params) {
let targetKeys = [];
let targetAliases = [];
let invalidTargets = [];
let usedFallback = false;
if (params.targets && params.targets.length > 0) {
for (const alias of params.targets) {
const key = config.devices.get(alias);
if (key) {
targetKeys.push(key);
targetAliases.push(alias);
}
else {
invalidTargets.push(alias);
}
}
if (targetKeys.length === 0) {
const defaultKey = config.devices.get(config.defaultDevice);
if (defaultKey) {
targetKeys = [defaultKey];
targetAliases = [config.defaultDevice];
usedFallback = true;
logger.warn(`No valid targets found, using default device: ${config.defaultDevice}`);
}
}
}
else {
targetKeys = Array.from(config.devices.values());
targetAliases = Array.from(config.devices.keys());
}
return {
keys: targetKeys,
aliases: targetAliases,
invalidTargets,
usedFallback,
};
}
function buildBarkPayload(config, params) {
const basePayload = Object.fromEntries(Object.entries({
body: params.message,
title: params.title,
subtitle: params.subtitle,
sound: params.sound || config.sound,
icon: params.icon || config.icon,
group: params.group || config.group,
url: params.url,
level: params.level || config.level,
badge: params.badge,
copy: params.copy,
autoCopy: params.autocopy ? "1" : undefined,
call: params.ring ? "1" : undefined,
isArchive: params.archive ? "1" : undefined,
volume: params.volume || config.volume,
ciphertext: params.ciphertext,
id: params.id,
delete: params.delete ? "1" : undefined,
action: params.action,
}).filter(([_, value]) => value !== undefined));
return basePayload;
}
async function sendNotification(config, params, deviceKey, deviceAlias) {
const payload = buildBarkPayload(config, params);
const client = getHttpClient(config.proxyUrl, config.retry);
const url = `${config.server}/${deviceKey}`;
const start = performance.now();
try {
logger.debug(`Sending notification to ${deviceAlias}`);
const response = await client
.post(url, {
json: payload,
})
.json();
const elapsed = ((performance.now() - start) / 1000).toFixed(3);
if (response?.code === 200) {
logger.info(`Notification delivered to ${deviceAlias} in ${elapsed}s`);
return {
success: true,
message: `Delivered to ${deviceAlias} in ${elapsed}s`,
elapsed,
response: response,
};
}
else {
logger.warn(`Bark API error for ${deviceAlias}: ${JSON.stringify(response)}`);
return {
success: false,
message: `Bark API error: ${JSON.stringify(response)} in ${elapsed}s`,
elapsed,
response: response,
};
}
}
catch (error) {
logger.error(`HTTP request failed for ${deviceAlias}: ${error instanceof Error ? error.message : "Unknown error"}`);
return {
success: false,
message: `Failed to deliver to ${deviceAlias}: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
}
function sendNotificationAsync(config, params) {
const { keys: targetKeys, aliases: targetAliases, invalidTargets, } = resolveTargetDevices(config, params);
if (invalidTargets.length > 0) {
logger.warn(`Invalid targets ignored: ${invalidTargets.join(", ")}`);
}
targetKeys.forEach((key, i) => {
setTimeout(() => {
sendNotification(config, params, key, targetAliases[i])
.then((result) => {
if (result.success) {
logger.info(`${result.message}`);
}
else {
logger.error(`${result.message}`);
}
})
.catch((error) => {
logger.error(`Unexpected error for ${targetAliases[i]}: ${error.message}`);
});
}, i * 100);
});
}
async function sendNotificationSync(config, params) {
const { keys: targetKeys, aliases: targetAliases, invalidTargets, } = resolveTargetDevices(config, params);
if (invalidTargets.length > 0) {
logger.warn(`Invalid targets ignored: ${invalidTargets.join(", ")}`);
}
if (targetKeys.length > 0) {
return await sendNotification(config, params, targetKeys[0], targetAliases[0]);
}
return {
success: false,
message: "No valid targets found",
};
}
function getOperationDescription(params) {
if (params.delete) {
return `Deleting notification "${params.id}"`;
}
else if (params.id) {
return `Updating notification "${params.id}"`;
}
else {
return params.title
? `Sending notification "${params.title}"`
: "Sending notification";
}
}
function formatAsyncResponse(config, params) {
const action = getOperationDescription(params);
const { aliases: targetAliases, invalidTargets, usedFallback, } = resolveTargetDevices(config, params);
let responseText = `${action} - queued for background delivery`;
responseText += `\nTo: ${targetAliases.map((alias) => `\`${alias}\``).join(", ")}`;
if (invalidTargets.length > 0) {
responseText += `\n⚠ Invalid targets ignored: ${invalidTargets.map((target) => `\`${target}\``).join(", ")}`;
const allDevices = Array.from(config.devices.keys());
responseText += `\nValid devices: ${allDevices.map((device) => `\`${device}\``).join(", ")}`;
}
if (usedFallback) {
responseText += `\n⚠ No valid targets found, used default device: \`${config.defaultDevice}\``;
}
if (params.level && params.level !== "active") {
responseText += `\nLevel: ${params.level}`;
}
return responseText;
}
function formatSyncResponse(config, params, result) {
if (result.success) {
const action = getOperationDescription(params);
const { aliases: targetAliases, invalidTargets, usedFallback, } = resolveTargetDevices(config, params);
let responseText = `${action.replace("Sending", "Sent")} - delivered immediately`;
responseText += `\nTo: \`${targetAliases[0]}\``;
if (result.elapsed)
responseText += ` (${result.elapsed}s)`;
if (invalidTargets.length > 0) {
responseText += `\n⚠ Invalid targets ignored: ${invalidTargets.map((target) => `\`${target}\``).join(", ")}`;
const allDevices = Array.from(config.devices.keys());
responseText += `\nValid devices: ${allDevices.map((device) => `\`${device}\``).join(", ")}`;
}
if (usedFallback) {
responseText += `\n⚠ No valid targets found, used default device: \`${config.defaultDevice}\``;
}
return responseText;
}
else {
return `Failed to send notification - immediate delivery\n${result.message}`;
}
}
function queueNotification(config, params) {
setTimeout(() => {
try {
sendNotificationAsync(config, params);
}
catch (syncError) {
logger.error(`Background notification setup failed (non-fatal): ${syncError instanceof Error ? syncError.message : "Unknown error"}`);
}
}, 100);
}
function createNotifyTool() {
const config = getBarkConfig();
const notificationSchema = createNotificationSchema(config);
let description = "Send push notifications to iOS devices via Bark. Use for alerts, updates, reminders, or any user communication.";
const defaults = [];
if (config.group)
defaults.push(`group="${config.group}"`);
if (config.sound)
defaults.push(`sound="${config.sound}"`);
if (config.icon)
defaults.push(`icon="${config.icon}"`);
if (config.level)
defaults.push(`level="${config.level}"`);
if (config.volume !== undefined)
defaults.push(`volume=${config.volume}`);
if (defaults.length > 0) {
description += ` Configured defaults: ${defaults.join(", ")}. These will be used if not overridden.`;
}
description += " Supports urgency levels, custom appearance, actions, and notification management.";
return {
name: "notify",
description,
inputSchema: makeJsonSchema(notificationSchema),
handler: async (args) => {
try {
const validParams = notificationSchema.parse(args);
logger.debug(`Processing notification request for ${validParams.targets?.length || config.devices.size} devices`);
if (config.async) {
logger.info("Queuing notification for async delivery");
queueNotification(config, validParams);
const responseText = formatAsyncResponse(config, validParams);
return {
content: [
{
type: "text",
text: responseText,
},
],
};
}
else {
logger.info("Sending notification synchronously");
const result = await sendNotificationSync(config, validParams);
const responseText = formatSyncResponse(config, validParams, result);
return {
content: [
{
type: "text",
text: responseText,
},
],
};
}
}
catch (error) {
logger.error(`Notification request failed: ${error instanceof Error ? error.message : "Unknown error"}`);
return {
content: [
{
type: "text",
text: `Notification failed: ${error instanceof Error ? error.message : "Unknown error"}\n\nEnsure BARK_DEVICES environment variable is set with format: 'iPhone:key1,iPad:key2' or 'key1,key2'`,
},
],
isError: true,
};
}
},
};
}
function getServerInstructions() {
const base = `Feel free to send push notifications to iOS devices using the \`notify\` tool.
KEY CAPABILITIES:
- Send instant notifications with custom titles, messages, and alerts
- Use different urgency levels (passive, active, timeSensitive, critical)
- Add custom icons, sounds, and action URLs
- Update or delete existing notifications
- Notifications are sent to all configured devices
COMMON USAGE PATTERNS:
- Quick alerts: notify(message="Your task is complete")
- Important alerts: notify(message="Server down", title="Alert", level="critical")
- With actions: notify(message="New deployment", url="https://dashboard.com")
- Updates: notify(message="Updated status", id="task-123")`;
try {
const cfg = getBarkConfig();
const deviceAliases = Array.from(cfg.devices.keys());
const defaultsLines = [
`Server: ${cfg.server}`,
`Async: ${cfg.async ? "true" : "false"} (retries: ${cfg.retry})`,
`Default device: ${cfg.defaultDevice}`,
`Devices: ${deviceAliases.map((d) => `\`${d}\``).join(", ")}`,
`Group: ${cfg.group}`,
`Sound: ${cfg.sound ?? "(none)"}`,
`Icon: ${cfg.icon ?? "(none)"}`,
`Level: ${cfg.level ?? "(default)"}`,
`Volume: ${cfg.volume ?? "(default)"}`,
];
return `${base}
CURRENT CONFIGURATION:
${defaultsLines.map((l) => `- ${l}`).join("\n")}`;
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
return `${base}
CONFIGURATION ISSUE:
- ${message}
- Ensure \`BARK_DEVICES\` and related env vars are set as documented.`;
}
}
const server = new Server({
name: "barkme-mcp-server",
version: VERSION,
description: "Send iOS push notifications to users via Bark service",
instructions: getServerInstructions(),
}, {
capabilities: {
tools: {},
},
});
const tools = [createNotifyTool()];
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map(({ handler, ...tool }) => tool),
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
const tool = tools.find((t) => t.name === name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
return tool.handler(args || {});
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info("Barkme MCP Server running on stdio");
}
runServer().catch((error) => {
logger.error(`Fatal error running server: ${error instanceof Error ? error.message : "Unknown error"}`);
process.exit(1);
});