@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
460 lines • 24.2 kB
JavaScript
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { typedError } from "./error-envelope.js";
import { formatAsText, formatSuccess, formatStructuredList, formatValidationError, } from "@withpica/mcp-utils";
export class NotificationsTools {
pica;
constructor(pica) {
this.pica = pica;
}
getTools() {
return [
{
definition: {
name: "pica_notifications_list",
scope: "read:catalog",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "Check for notifications from PICA — new collaborator invites, " +
"enrichment results, verification requests, credit disputes, " +
"and other flagged items that need attention. " +
"For the cross-surface feed shared with Telegram, web and email, use pica_notifications_recent.",
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.listNotifications.bind(this),
},
{
definition: {
name: "pica_notify_person",
scope: "write:people",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Send a notification to a person in the catalog. If the person has a PICA account, " +
"they get an in-app notification + Telegram message (if connected). " +
"If they're not a PICA user yet, returns a hint to use pica_collaborators_invite instead. " +
"Use for credit verification requests, song updates, or any message to a collaborator. " +
"The recipient is a CATALOG PERSON, identified by person_id — use pica_notify_user " +
"instead when the recipient is the account owner you are working for.",
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: {
person_id: {
type: "string",
description: "The person's ID in your catalog",
},
message: {
type: "string",
description: "What to tell them (e.g. 'please verify your writing credit on Midnight Run')",
},
work_id: {
type: "string",
description: "Optional work (composition) ID for context — deep-links the notification to that work's detail page",
},
recording_id: {
type: "string",
description: "Optional recording (master) ID for context — deep-links to the recording's detail page. Use this when the credit is a master/recording credit; takes precedence over work_id. Prevents the notification dead-ending on the catalog.",
},
},
required: ["person_id", "message"],
},
},
executor: this.notifyPerson.bind(this),
},
{
definition: {
name: "pica_notifications_mark_read",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Mark one or more notifications as read. " +
"Get the ids from pica_notifications_list first.",
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: {
notification_ids: {
type: "array",
items: { type: "string" },
description: "IDs of notifications to mark as read",
},
},
required: ["notification_ids"],
},
},
executor: this.markRead.bind(this),
},
{
definition: {
name: "pica_notify_user_at",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Schedule a one-shot Telegram reminder for the future. The user can ask for reminders in any natural phrasing — 'remind me tomorrow at 9', 'ping me in 2 hours', 'check on this next monday'. Convert their phrasing to an ISO 8601 UTC timestamp before calling. Ask the user for their timezone once if you don't already know it. The message body IS the receipt — make it self-contained so the user reading it cold knows what to do. Delivery goes via Telegram, so pica_telegram_connect must be paired first. Per-user cap: 100 pending reminders. Same recipient as pica_notify_user — use that one instead when the message should go out now rather than at a deliver_at.",
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: "The reminder body text. Self-contained — include enough context that the user reading it cold knows what to do. 1-4000 characters.",
},
deliver_at: {
type: "string",
description: "ISO 8601 UTC timestamp. Must be in the future and within 1 year. Convert from natural language using the user's timezone — ask first if you don't know it.",
},
},
required: ["message", "deliver_at"],
},
},
executor: this.notifyUserAt.bind(this),
},
{
definition: {
name: "pica_notify_user_list_pending",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "read",
description: "List all pending scheduled Telegram reminders for the current user, ordered by deliver_at ascending. Use to show the user what reminders are queued or to find an id for pica_notify_user_cancel.",
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.listPending.bind(this),
},
{
definition: {
name: "pica_notify_user_cancel",
scope: "admin",
audiences: ["stdio", "http", "public"],
tier: "write",
description: "Cancel a pending scheduled reminder by id. Idempotent — if the reminder was already delivered or already cancelled, this surfaces that state gracefully instead of erroring. Use cancel + pica_notify_user_at together to reschedule a reminder.",
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: {
id: {
type: "string",
description: "The scheduled_notifications row id — get it from pica_notify_user_list_pending or from the response of pica_notify_user_at.",
},
},
required: ["id"],
},
},
executor: this.cancelPending.bind(this),
},
// --- ADR-218 cross-transport awareness ---
{
definition: {
name: "pica_notifications_recent",
scope: "read:catalog",
audiences: ["stdio", "http"],
tier: "read",
description: "Use when the user asks: 'what notifications do I have?', 'anything from the team?', 'recent alerts'. " +
"Read the recent cross-transport notification feed for the org — events that PICA has surfaced about your catalog (credit disputes, enrichment matches, " +
"audio analysis outcomes, etc.). Distinct from `pica_notifications_list`: this reads the `discovery_events` ledger that " +
"ALL surfaces (MCP, Telegram, web, email) share, so it stays in sync with what was sent to the user's Telegram channel. " +
"Pass `unread_only: true` to see only events the agent has not yet acknowledged via " +
"`pica_notification_acknowledge`. Default limit 20, max 100. The agent's ambient envelope at " +
"_meta.session_state.notifications.unread_count tells you whether there's anything new before you call this.",
workflows: "infrastructure",
vernacular_kind: "creator-entry",
inputSchema: {
type: "object",
properties: {
limit: {
type: "number",
minimum: 1,
maximum: 100,
description: "Optional. Default 20, max 100. Number of recent events to return.",
},
unread_only: {
type: "boolean",
description: "Optional. When true, filters to events the MCP surface has not acknowledged.",
},
},
},
},
executor: this.discoveriesRecent.bind(this),
},
{
definition: {
name: "pica_notification_acknowledge",
scope: "write:catalog",
audiences: ["stdio", "http"],
tier: "write",
description: "Acknowledge one or more cross-transport notifications. Appends 'mcp' to the event's `seen_in` array — " +
"idempotent, so re-acknowledging is a safe no-op. Once acknowledged, the same event will NOT be re-fired to " +
"the user's Telegram channel on subsequent emits of the same dedup key (until_resolved-strategy alerts like " +
"credits_low_balance, system_warning, cascade_proposal_created). Use after summarising the events to the user " +
"or after taking the action they describe — keeps the user from being re-pinged about the same thing on " +
"another surface.",
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_ids: {
type: "array",
items: { type: "string" },
description: "Discovery event UUIDs returned from `pica_notifications_recent`.",
},
},
required: ["event_ids"],
},
},
executor: this.discoveriesAcknowledge.bind(this),
},
];
}
/**
* ADR-218 Primitive B — read the cross-transport notification feed.
*/
async discoveriesRecent(args) {
const limit = typeof args.limit === "number" ? args.limit : undefined;
const unreadOnly = typeof args.unread_only === "boolean" ? args.unread_only : false;
try {
const events = await this.pica.discoveries.recent({
limit,
unreadOnly,
});
if (events.length === 0) {
return formatAsText({
count: 0,
unread_only: unreadOnly,
message: unreadOnly
? "No unacknowledged notifications. The agent's inbox is clear."
: "No recent notifications in the cross-transport feed.",
});
}
const summary = events.slice(0, 10).map((e) => {
const ackTag = e.seen_in.includes("mcp") ? " (acknowledged)" : "";
return `[${e.severity}] ${e.title}${ackTag} — ${e.event_type} @ ${e.created_at}`;
});
const more = events.length > 10
? `\n\n_+${events.length - 10} more — pass a higher limit to see them._`
: "";
return {
content: [
{
type: "text",
text: `${events.length} notification${events.length === 1 ? "" : "s"}${unreadOnly ? " (unacknowledged)" : ""}:\n\n${summary.join("\n")}${more}`,
},
],
structuredContent: {
kind: "pica.card.notifications_recent",
version: 1,
data: {
count: events.length,
unread_only: unreadOnly,
events: events.map((e) => ({
id: e.id,
event_type: e.event_type,
category: e.category,
title: e.title,
description: e.description,
entity_type: e.entity_type,
entity_id: e.entity_id,
entity_name: e.entity_name,
severity: e.severity,
source: e.source,
link: e.link,
happened_at: e.created_at,
seen_in: e.seen_in,
acknowledged_by_mcp: e.seen_in.includes("mcp"),
})),
},
},
};
}
catch (err) {
return typedError("NOTIFICATIONS_RECENT_FAILED", `Failed to fetch recent notifications: ${err?.message || String(err)}`);
}
}
/**
* ADR-218 Primitive C — acknowledge cross-transport notifications.
*/
async discoveriesAcknowledge(args) {
const ids = Array.isArray(args.event_ids)
? args.event_ids.filter((v) => typeof v === "string" && v.length > 0)
: [];
if (ids.length === 0) {
return formatValidationError({
event_ids: "event_ids must be a non-empty array of UUID strings",
});
}
try {
const result = await this.pica.discoveries.acknowledge(ids);
return formatSuccess(result.acknowledged === 0
? "Already acknowledged — no rows changed."
: `Acknowledged ${result.acknowledged} of ${ids.length} notification${ids.length === 1 ? "" : "s"} for the MCP surface.`, {
acknowledged: result.acknowledged,
surface: result.surface,
requested: ids.length,
});
}
catch (err) {
return typedError("NOTIFICATION_ACKNOWLEDGE_FAILED", `Failed to acknowledge notifications: ${err?.message || String(err)}`);
}
}
async listPending(_args) {
try {
const result = await this.pica.notifications.listScheduled();
const data = result?.data || result || [];
const items = Array.isArray(data) ? data : [];
return formatStructuredList(items, "scheduled reminder", {
count: items.length,
});
}
catch (err) {
return typedError("REMINDERS_LIST_FAILED", `Failed to list pending reminders: ${err?.message || String(err)}`);
}
}
async cancelPending(args) {
if (!args.id || typeof args.id !== "string") {
return formatValidationError({ id: "id is required" });
}
try {
const result = await this.pica.notifications.cancelScheduled(args.id);
const data = result?.data || result;
if (data?.status === "sent") {
return formatAsText({
cancelled: false,
status: "sent",
message: `Already delivered on ${data.sent_at} — can't cancel.`,
data,
});
}
if (data?.status === "cancelled" && data?.deliver_at) {
return formatSuccess(`Cancelled — was scheduled for ${data.deliver_at}.`, { cancelled: true, id: data.id, deliver_at: data.deliver_at });
}
if (data?.status === "failed") {
return formatAsText({
cancelled: false,
status: "failed",
message: `Reminder already marked failed: ${data.error_text || "unknown"}`,
data,
});
}
return formatSuccess("Cancelled.", { cancelled: true, data });
}
catch (err) {
const message = err?.message || String(err);
return typedError("REMINDER_CANCEL_FAILED", `Failed to cancel reminder: ${message}`, { cancelled: false });
}
}
async notifyUserAt(args) {
if (!args.message ||
typeof args.message !== "string" ||
!args.message.trim()) {
return formatValidationError({ message: "message is required" });
}
if (!args.deliver_at || typeof args.deliver_at !== "string") {
return formatValidationError({
deliver_at: "deliver_at is required (ISO 8601 UTC)",
});
}
try {
const result = await this.pica.notifications.schedule({
message: args.message.trim(),
deliverAt: args.deliver_at,
source: typeof args.source === "string" ? args.source : undefined,
});
const data = result?.data || result;
return formatSuccess(`Reminder scheduled for ${data.deliver_at}. I'll ping you on Telegram when it fires.`, {
id: data.id,
deliver_at: data.deliver_at,
message: data.message,
status: data.status,
completion_hints: {
list: "pica_notify_user_list_pending",
cancel: "pica_notify_user_cancel",
},
});
}
catch (err) {
const message = err?.message || String(err);
return typedError("REMINDER_SCHEDULE_FAILED", `Failed to schedule reminder: ${message}`, { scheduled: false });
}
}
async listNotifications(_args) {
try {
const result = await this.pica.notifications.list();
const notifications = result?.notifications || result || [];
const items = Array.isArray(notifications) ? notifications : [];
// Notifications carry `read: boolean` (lib/services/notifications/
// types.ts); there is no `read_at`, so the old `!n.read_at` counted
// every row as unread.
const unread = items.filter((n) => n.read !== true && !n.read_at);
// The list is the newest few from each of ~20 sources (each capped),
// so its length is not a total of anything — say so instead of
// calling it one (row-cap T3).
return formatStructuredList(items, "notification", {
total: null,
unread_shown: unread.length,
note: "The newest notifications from each source (each source is capped), not a complete count.",
});
}
catch (err) {
return typedError("NOTIFICATIONS_LIST_FAILED", `Failed to list notifications: ${err?.message || String(err)}`);
}
}
async notifyPerson(args) {
if (!args.person_id?.trim()) {
return formatValidationError({ person_id: "person_id is required" });
}
if (!args.message?.trim()) {
return formatValidationError({ message: "message is required" });
}
try {
const result = await this.pica.notifications.sendToPerson({
person_id: args.person_id,
message: args.message,
work_id: args.work_id,
recording_id: args.recording_id,
});
const data = result.data || result;
if (!data.delivered) {
return formatAsText({
delivered: false,
person_name: data.person_name,
reason: data.reason,
hint: data.hint,
});
}
const channels = [];
if (data.channels?.in_app)
channels.push("in-app");
if (data.channels?.telegram)
channels.push("Telegram");
if (data.channels?.email)
channels.push("email");
return formatSuccess(`Notified ${data.person_name} via ${channels.join(" + ")}`, data);
}
catch (err) {
return typedError("NOTIFY_PERSON_FAILED", `Failed to notify person: ${err?.message || String(err)}`);
}
}
async markRead(args) {
const ids = args.notification_ids;
await this.pica.notifications.markRead(ids);
return formatSuccess(`Marked ${ids.length} notification${ids.length !== 1 ? "s" : ""} as read`);
}
}
//# sourceMappingURL=notifications.js.map