@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
247 lines • 13.8 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { typedError } from "./error-envelope.js";
import { formatAsText, formatSuccess, formatValidationError, } from "@withpica/mcp-utils";
export class TelegramTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
{
definition: {
name: "pica_telegram_status",
scope: "read:analytics",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Check if the user has Telegram connected. Use this before promising to send Telegram notifications. " +
"Returns connection status, username, and notification preferences. " +
"If not connected, suggest the user visit /connect to set up Telegram. " +
"Pair from here with pica_telegram_connect; change which events notify with pica_telegram_config.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {},
},
},
executor: this.getStatus.bind(this),
},
{
definition: {
name: "pica_notify_user",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Send a notification to the user's connected Telegram. Use for long-running tasks: " +
"enrichment, exports, duplicate merges, bulk operations. The message should be concise " +
"and actionable — tell them what finished and what to do next. " +
"Fails gracefully if Telegram is not connected (returns error, does not throw). " +
"This reaches the ACCOUNT OWNER, now. Use pica_notify_person instead to reach " +
"someone in the catalog (a writer, a producer), and pica_notify_user_at to " +
"schedule this same message for later.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "Notification message (markdown supported). Keep it short — 1-3 sentences. " +
"Include what completed and any action needed.",
},
},
required: ["message"],
},
},
executor: this.notifyUser.bind(this),
},
{
definition: {
name: "pica_telegram_connect",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Pair the user's Telegram account with withPICA so the agent can send " +
"notifications via pica_notify_user. Returns a short magic code the " +
"user sends to @withpica_bot, then call pica_telegram_status on the " +
"next turn to confirm the connection landed. If the user is already " +
"paired, short-circuits and returns { already_connected: true }. " +
"Idempotent — repeated calls within 10 minutes return the same code. " +
"Rate-limited to 3 new codes per user per rolling hour. " +
"Use when the user asks the agent to send them anything via Telegram " +
"but pica_telegram_status reports not connected.",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {},
},
},
executor: this.connect.bind(this),
},
{
definition: {
name: "pica_telegram_config",
// Scoped admin like its siblings pica_notify_user /
// pica_telegram_connect: the backing routes live under
// /api/admin/settings/telegram/* and every /api/admin/settings/**
// bearer WRITE floors to "admin" (ADMIN_SEGMENT_WRITE_FLOOR
// shares the "settings" segment across the whole settings
// surface, not just telegram) — no scope below admin can satisfy
// that floor, so this can't be loosened per-tool without
// widening every other /settings write at the same time.
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Get or set the user's Telegram notification preferences (per-event-type on/off), " +
"grouped by category (catalog, payments, agreements, audio, invoices, expenses, tasks, " +
"team, documents, performances, royalties, system). " +
"Omit event_type/is_enabled/bulk_config to just read current preferences — the response " +
"lists every event type with its enabled state and category. " +
"Set event_type + is_enabled to toggle one event type, or bulk_config to set several at " +
"once. Requires Telegram to already be connected (pica_telegram_connect). " +
"→ then: pica_telegram_status (confirm the connection is still active)",
workflows: "infrastructure",
vernacular_kind: "specialist",
vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
inputSchema: {
type: "object",
properties: {
event_type: {
type: "string",
description: "Single event type to toggle (pairs with is_enabled). Read the current preferences first (call with no arguments) to see the full list of event types.",
},
is_enabled: {
type: "boolean",
description: "Enable or disable event_type. Required if event_type is set.",
},
bulk_config: {
type: "array",
items: {
type: "object",
properties: {
event_type: { type: "string" },
is_enabled: { type: "boolean" },
},
required: ["event_type", "is_enabled"],
},
description: "Set multiple event types at once.",
},
},
},
},
executor: this.config.bind(this),
},
];
}
async getStatus(_args) {
const result = await this.pica.telegram.getStatus();
const data = result.data || result;
if (!data.connected) {
return formatAsText({
connected: false,
message: "Telegram is not connected. The user can set it up at /connect.",
bot_username: data.botUsername,
});
}
return formatSuccess(`Telegram connected as @${data.telegramUsername || data.telegramFirstName}. You can send notifications via pica_notify_user.`, {
connected: true,
telegram_username: data.telegramUsername,
telegram_first_name: data.telegramFirstName,
preferences: data.preferences,
});
}
async notifyUser(args) {
if (!args.message?.trim()) {
return formatValidationError({ message: "message is required" });
}
try {
const result = await this.pica.telegram.sendNotification(args.message.trim());
const data = result.data || result;
return formatSuccess(`Notification sent to @${data.telegramUsername || "user"} on Telegram.`, { sent: true, telegram_username: data.telegramUsername });
}
catch (err) {
// Graceful failure — don't throw, return a typed error envelope. It
// still carries isError so the client can't read a failed send as a
// delivered one (A6, 2026-08-24).
const message = err?.message || String(err);
if (message.includes("not connected") || message.includes("400")) {
return typedError("TELEGRAM_NOT_CONNECTED", "Telegram is not connected. The user can set it up at /connect.", { sent: false });
}
return typedError("TELEGRAM_SEND_FAILED", `Failed to send notification: ${message}`, { sent: false });
}
}
async connect(_args) {
try {
const result = await this.pica.telegram.connect();
const data = result.data || result;
if (data.already_connected) {
return formatSuccess(`Telegram is already paired as @${data.telegram_username || "user"}. You can send notifications via pica_notify_user.`, {
already_connected: true,
telegram_username: data.telegram_username,
telegram_first_name: data.telegram_first_name,
});
}
return formatSuccess(data.instructions ||
`Open ${data.bot_url} and send the code ${data.code}. Call pica_telegram_status on the next turn to confirm.`, {
already_connected: false,
code: data.code,
bot_url: data.bot_url,
expires_at: data.expires_at,
instructions: data.instructions,
completion_hints: data.completion_hints,
});
}
catch (err) {
const message = err?.message || String(err);
if (message.includes("rate_limited") || message.includes("429")) {
return typedError("RATE_LIMITED", "Too many Telegram pairing attempts. Try again in an hour.");
}
return typedError("TELEGRAM_PAIRING_FAILED", `Failed to generate pairing code: ${message}`);
}
}
/**
* pica_telegram_config — read the current notification preferences when
* no write fields are given, otherwise apply the single-toggle or
* bulk_config write. Mirrors the retired bespoke assistant's
* handleGetTelegramNotificationConfig / handleConfigureTelegramNotifications
* pair as one action-inferred tool (read-by-default, opt-in write).
*/
async config(args) {
// A write is attempted whenever event_type or bulk_config is present at
// all — event_type without is_enabled is still a write ATTEMPT (just an
// invalid one), not a fall-through to the read path.
const isWrite = args.bulk_config !== undefined || args.event_type !== undefined;
if (!isWrite) {
const result = await this.pica.telegram.getPreferences();
const data = result.data || result;
return formatSuccess("Telegram notification preferences", data);
}
if (args.event_type !== undefined && args.is_enabled === undefined) {
return formatValidationError({
is_enabled: "is_enabled is required when event_type is set",
});
}
try {
const result = await this.pica.telegram.setPreferences({
event_type: args.event_type,
is_enabled: args.is_enabled,
bulk_config: args.bulk_config,
});
const data = result.data || result;
return formatSuccess((data && data.message) || "Telegram notification preferences updated", data);
}
catch (err) {
const message = err?.message || String(err);
if (message.includes("not connected") || message.includes("400")) {
return typedError("TELEGRAM_NOT_CONNECTED", "Telegram is not connected. Use pica_telegram_connect first.");
}
return typedError("TELEGRAM_CONFIG_FAILED", `Failed to update Telegram notification preferences: ${message}`);
}
}
}
//# sourceMappingURL=telegram.js.map