@floomhq/signaldash 0.22.0 → 0.27.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/README.md +161 -9
- package/bin/sd.mjs +615 -13
- package/lib/mcp.js +12 -2
- package/package.json +3 -3
- package/skills/signaldash/SKILL.md +389 -42
- package/skills/signaldash-safe-usage/SKILL.md +1 -1
package/bin/sd.mjs
CHANGED
|
@@ -214,9 +214,52 @@ export async function cmdClaim(provider, accountId, dependencies = {}) {
|
|
|
214
214
|
|
|
215
215
|
// ---- MCP (stdio). Every tool proxies through the backend with the token. ----
|
|
216
216
|
const TOOLS = [
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
{
|
|
218
|
+
name: "li_list_chats",
|
|
219
|
+
ch: "li",
|
|
220
|
+
action: "list_chats",
|
|
221
|
+
description: "List or search your LinkedIn chats. `search` filters on identifiers, never on a person name (a 1:1 chat has no name); the response reports how far it scanned.",
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {
|
|
225
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
|
|
226
|
+
cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
|
|
227
|
+
search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats on name and counterpart identifiers. The provider cannot filter a chat list, so SignalDash filters over a bounded scan and the response reports `scanned_chats` and `exhaustive` so a miss is never mistaken for a proven absence. A 1:1 chat has no name, so search by identifier or phone number, not by person name." },
|
|
228
|
+
max_scan: { type: "integer", minimum: 1, maximum: 500, description: "How many chats the search may scan before it stops and reports exhaustive:false. Default 200, and never more than five provider pages: bulk reading is the top account-restriction trigger, so go deeper with the returned cursor rather than with a bigger scan." },
|
|
229
|
+
},
|
|
230
|
+
additionalProperties: false,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "li_read_messages",
|
|
235
|
+
ch: "li",
|
|
236
|
+
action: "read",
|
|
237
|
+
description: "Read messages in a LinkedIn chat. args: chat_id",
|
|
238
|
+
inputSchema: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
242
|
+
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
243
|
+
},
|
|
244
|
+
required: ["chat_id"],
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: "li_send_message",
|
|
250
|
+
ch: "li",
|
|
251
|
+
action: "send",
|
|
252
|
+
description: "Send a LinkedIn message (rate-safe). args: chat_id, text",
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: "object",
|
|
255
|
+
properties: {
|
|
256
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
257
|
+
text: { type: "string", minLength: 1, maxLength: 5000 },
|
|
258
|
+
},
|
|
259
|
+
required: ["chat_id", "text"],
|
|
260
|
+
additionalProperties: false,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
220
263
|
{
|
|
221
264
|
name: "li_send_invitation",
|
|
222
265
|
path: "/li/send_invitation",
|
|
@@ -411,6 +454,20 @@ const TOOLS = [
|
|
|
411
454
|
additionalProperties: false,
|
|
412
455
|
},
|
|
413
456
|
},
|
|
457
|
+
{
|
|
458
|
+
name: "sd_voice_profile",
|
|
459
|
+
path: "/sd/voice/profile",
|
|
460
|
+
description: "Build or fetch this user's personal writing-voice profile for one channel, mined ONLY from up to ~50 of their own SENT messages on that channel (never the other side of any conversation, never another user's data). Returns compact markdown: hard length stats (median/p75/p90 characters), 5-10 verbatim redacted exemplars, negative constraints, and language behavior. Computed once and cached; call with force_recompute:true only when explicitly asked to refresh. Call this before drafting on the user's behalf and match the returned stats and exemplars.",
|
|
461
|
+
inputSchema: {
|
|
462
|
+
type: "object",
|
|
463
|
+
properties: {
|
|
464
|
+
channel: { type: "string", enum: ["linkedin", "whatsapp", "email"] },
|
|
465
|
+
force_recompute: { type: "boolean", default: false },
|
|
466
|
+
},
|
|
467
|
+
required: ["channel"],
|
|
468
|
+
additionalProperties: false,
|
|
469
|
+
},
|
|
470
|
+
},
|
|
414
471
|
{
|
|
415
472
|
name: "li_search_connections",
|
|
416
473
|
path: "/li/connections/search",
|
|
@@ -556,9 +613,423 @@ const TOOLS = [
|
|
|
556
613
|
additionalProperties: false,
|
|
557
614
|
},
|
|
558
615
|
},
|
|
559
|
-
{
|
|
560
|
-
|
|
561
|
-
|
|
616
|
+
{
|
|
617
|
+
name: "sd_campaign_create",
|
|
618
|
+
path: "/sd/campaign/create",
|
|
619
|
+
description: "Create one campaign: a paced connection request to each exact person, then the exact approved message(s) once that person is PROVEN to have accepted, then an optional follow-up that stops the moment they reply. Nothing is sent until a human approves this exact recipient list and this exact message text on the approval page. Targets come from an explicit list you supply (for example one you built with li_search_connections or li_post_reactions) or from your own post engagers, which costs zero profile fetches. If the user already sent someone a connection request by hand and just wants the follow-up automated, set adopt_existing_invitation on that target instead of leaving them out. Draft the messages in the user's own voice and keep them short: the on-acceptance group is 2-3 separate short sends, never one block.",
|
|
620
|
+
inputSchema: {
|
|
621
|
+
type: "object",
|
|
622
|
+
properties: {
|
|
623
|
+
source_label: { type: "string", minLength: 1, maxLength: 120 },
|
|
624
|
+
time_zone: {
|
|
625
|
+
type: "string",
|
|
626
|
+
minLength: 1,
|
|
627
|
+
description: "Sender IANA timezone. Execution is Mon-Fri 09:00-17:00 sender-local.",
|
|
628
|
+
},
|
|
629
|
+
target_source: {
|
|
630
|
+
type: "string",
|
|
631
|
+
enum: ["explicit", "post_engagers"],
|
|
632
|
+
default: "explicit",
|
|
633
|
+
},
|
|
634
|
+
targets: {
|
|
635
|
+
type: "array",
|
|
636
|
+
minItems: 1,
|
|
637
|
+
maxItems: 150,
|
|
638
|
+
description: "Explicit targets. Each needs a canonical linkedin.com/in/ URL or an exact provider_id.",
|
|
639
|
+
items: {
|
|
640
|
+
type: "object",
|
|
641
|
+
properties: {
|
|
642
|
+
profile_url: { type: "string", format: "uri" },
|
|
643
|
+
provider_id: { type: "string", minLength: 5, maxLength: 500 },
|
|
644
|
+
inclusion_reason: { type: "string", minLength: 1, maxLength: 240 },
|
|
645
|
+
note: {
|
|
646
|
+
type: "string",
|
|
647
|
+
maxLength: 200,
|
|
648
|
+
description: "Optional invitation note.",
|
|
649
|
+
},
|
|
650
|
+
display_name: { type: "string", maxLength: 160 },
|
|
651
|
+
headline: { type: "string", maxLength: 300 },
|
|
652
|
+
adopt_existing_invitation: {
|
|
653
|
+
type: "boolean",
|
|
654
|
+
default: false,
|
|
655
|
+
description: "Set true ONLY when the user already sent this exact person a connection request by hand and now wants the follow-up automated. SignalDash then REQUIRES a still-pending sent invitation to that person and sends none of its own: it adopts the existing one, watches for acceptance, and runs the message sequence. If no pending invitation is found the person is dropped, never invited. Incompatible with note, because no invitation goes out.",
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
required: ["inclusion_reason"],
|
|
659
|
+
additionalProperties: false,
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
engagers: {
|
|
663
|
+
type: "object",
|
|
664
|
+
description: "Only with target_source post_engagers. The server reads your own recent posts and their reactions and comments, which return member id and network distance directly, so no profiles are fetched.",
|
|
665
|
+
properties: {
|
|
666
|
+
post_limit: { type: "integer", minimum: 1, maximum: 10, default: 5 },
|
|
667
|
+
max_targets: { type: "integer", minimum: 1, maximum: 150, default: 50 },
|
|
668
|
+
inclusion_reason: { type: "string", minLength: 1, maxLength: 240 },
|
|
669
|
+
note: { type: "string", maxLength: 200 },
|
|
670
|
+
},
|
|
671
|
+
additionalProperties: false,
|
|
672
|
+
},
|
|
673
|
+
messages: {
|
|
674
|
+
type: "array",
|
|
675
|
+
minItems: 1,
|
|
676
|
+
maxItems: 5,
|
|
677
|
+
description: "The exact frozen texts. after_days 0 means sent once the invitation is accepted (1-3 of these, sent as separate consecutive messages); a later step needs after_days 1-30 and only goes out if there has been no reply.",
|
|
678
|
+
items: {
|
|
679
|
+
type: "object",
|
|
680
|
+
properties: {
|
|
681
|
+
text: { type: "string", minLength: 1, maxLength: 1200 },
|
|
682
|
+
after_days: { type: "integer", minimum: 0, maximum: 30 },
|
|
683
|
+
},
|
|
684
|
+
required: ["text"],
|
|
685
|
+
additionalProperties: false,
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
invite_ttl_days: {
|
|
689
|
+
type: "integer",
|
|
690
|
+
minimum: 1,
|
|
691
|
+
maximum: 60,
|
|
692
|
+
default: 21,
|
|
693
|
+
description: "An invitation not accepted within this many days is dropped and never messaged.",
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
required: ["source_label", "time_zone", "messages"],
|
|
697
|
+
additionalProperties: false,
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
name: "sd_campaign_preview",
|
|
702
|
+
path: "/sd/campaign/preview",
|
|
703
|
+
description: "Inspect one campaign before approval: every exact recipient, every exclusion and its reason, the exact message text for each step, the timing, the shared daily budget, and the approval_url to send the human. Show the human this content. This read also authorizes a later exact cancel. An agent cannot approve a campaign; only the authenticated human page can, and it can hand you a one-time code for sd_campaign_approve.",
|
|
704
|
+
inputSchema: {
|
|
705
|
+
type: "object",
|
|
706
|
+
properties: {
|
|
707
|
+
campaign_id: { type: "string", minLength: 36, maxLength: 36 },
|
|
708
|
+
},
|
|
709
|
+
required: ["campaign_id"],
|
|
710
|
+
additionalProperties: false,
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
name: "sd_campaign_approve",
|
|
715
|
+
path: "/sd/campaign/approve",
|
|
716
|
+
description: "Record the human's approval of one campaign using the one-time code they generated on the authenticated approval page. You cannot create that code, guess it, or approve without it, and it only ever approves the exact payload and exact recipients they reviewed.",
|
|
717
|
+
inputSchema: {
|
|
718
|
+
type: "object",
|
|
719
|
+
properties: {
|
|
720
|
+
campaign_id: { type: "string", minLength: 36, maxLength: 36 },
|
|
721
|
+
confirm_token: {
|
|
722
|
+
type: "string",
|
|
723
|
+
pattern: "^sd-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$",
|
|
724
|
+
description: "The code the human read off the approval page.",
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
required: ["campaign_id", "confirm_token"],
|
|
728
|
+
additionalProperties: false,
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
name: "sd_campaign_status",
|
|
733
|
+
path: "/sd/campaign/status",
|
|
734
|
+
description: "Monitor one campaign: invited, acceptance proven, messaged, replied and halted, expired, or excluded per person, plus every message step and any provider response SignalDash could not parse.",
|
|
735
|
+
inputSchema: {
|
|
736
|
+
type: "object",
|
|
737
|
+
properties: {
|
|
738
|
+
campaign_id: { type: "string", minLength: 36, maxLength: 36 },
|
|
739
|
+
},
|
|
740
|
+
required: ["campaign_id"],
|
|
741
|
+
additionalProperties: false,
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
name: "sd_campaign_cancel",
|
|
746
|
+
path: "/sd/campaign/cancel",
|
|
747
|
+
description: "Revoke the human approval and cancel one freshly inspected campaign. Stops every unsent invitation and every unsent message. Cannot recall an executing write. Restarting requires a new preview and a new human approval.",
|
|
748
|
+
inputSchema: {
|
|
749
|
+
type: "object",
|
|
750
|
+
properties: {
|
|
751
|
+
campaign_id: { type: "string", minLength: 36, maxLength: 36 },
|
|
752
|
+
approval_view_hash: {
|
|
753
|
+
type: ["string", "null"],
|
|
754
|
+
pattern: "^[0-9a-f]{64}$",
|
|
755
|
+
},
|
|
756
|
+
confirm: { type: "boolean" },
|
|
757
|
+
},
|
|
758
|
+
required: ["campaign_id", "approval_view_hash", "confirm"],
|
|
759
|
+
additionalProperties: false,
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
name: "sd_withdrawal_batch_create",
|
|
764
|
+
path: "/li/withdrawal_batch/create",
|
|
765
|
+
description:
|
|
766
|
+
"Clear a backlog of old PENDING SENT LinkedIn invitations. Freezes the exact invitation ids matching an age rule, then one human approval withdraws them one at a time. Two things you must tell the user, because both are LinkedIn's own documented behaviour: (1) this does NOT free up sending capacity, withdrawing does not lift an active sending restriction, it only clears a stale backlog; (2) after withdrawing you cannot re-invite that person for UP TO THREE WEEKS. `exclude` is REQUIRED, not optional: ask the user who must NOT be withdrawn before you call this, because a blanket age rule will otherwise catch people they wanted to keep pending. Passing an empty array is allowed but it means nobody is protected. Age labels are buckets ('sent 4 months ago'), not dates, so the filter is bucket-accurate at best and deliberately holds back anyone whose bucket straddles the threshold.",
|
|
767
|
+
inputSchema: {
|
|
768
|
+
type: "object",
|
|
769
|
+
properties: {
|
|
770
|
+
account: {
|
|
771
|
+
type: "string",
|
|
772
|
+
enum: ["linkedin"],
|
|
773
|
+
description: "Only LinkedIn sent invitations can be swept.",
|
|
774
|
+
},
|
|
775
|
+
older_than_days: {
|
|
776
|
+
type: "integer",
|
|
777
|
+
minimum: 30,
|
|
778
|
+
maximum: 3650,
|
|
779
|
+
default: 90,
|
|
780
|
+
description:
|
|
781
|
+
"Withdraw invitations older than this. Bucket-accurate at best. Minimum 30.",
|
|
782
|
+
},
|
|
783
|
+
exclude: {
|
|
784
|
+
type: "array",
|
|
785
|
+
maxItems: 100,
|
|
786
|
+
description:
|
|
787
|
+
"REQUIRED. People to protect from the sweep, matched exactly, never fuzzily. Ask the user for these by name before calling. An empty array means nobody is protected.",
|
|
788
|
+
items: {
|
|
789
|
+
type: "object",
|
|
790
|
+
properties: {
|
|
791
|
+
kind: {
|
|
792
|
+
type: "string",
|
|
793
|
+
enum: [
|
|
794
|
+
"invitation_id",
|
|
795
|
+
"provider_id",
|
|
796
|
+
"public_identifier",
|
|
797
|
+
"profile_url",
|
|
798
|
+
"member_urn",
|
|
799
|
+
"display_name",
|
|
800
|
+
],
|
|
801
|
+
},
|
|
802
|
+
value: { type: "string", minLength: 1, maxLength: 1000 },
|
|
803
|
+
},
|
|
804
|
+
required: ["kind", "value"],
|
|
805
|
+
additionalProperties: false,
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
time_zone: {
|
|
809
|
+
type: "string",
|
|
810
|
+
minLength: 1,
|
|
811
|
+
maxLength: 64,
|
|
812
|
+
description:
|
|
813
|
+
"IANA sender timezone. Withdrawals only run Monday-Friday 09:00-17:00 in this zone.",
|
|
814
|
+
},
|
|
815
|
+
limit: {
|
|
816
|
+
type: "integer",
|
|
817
|
+
minimum: 1,
|
|
818
|
+
maximum: 1500,
|
|
819
|
+
description: "Optional ceiling on how many to withdraw.",
|
|
820
|
+
},
|
|
821
|
+
source_label: { type: "string", minLength: 1, maxLength: 120 },
|
|
822
|
+
allow_unmatched_exclusions: {
|
|
823
|
+
type: "boolean",
|
|
824
|
+
description:
|
|
825
|
+
"By default an exclusion that matches nobody fails the preview, because that is what a typo looks like and a typo means the protected person gets withdrawn. Set true only when the user confirms the mismatch is expected.",
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
required: ["account", "exclude", "time_zone"],
|
|
829
|
+
additionalProperties: false,
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: "sd_withdrawal_batch_status",
|
|
834
|
+
path: "/li/withdrawal_batch/status",
|
|
835
|
+
description:
|
|
836
|
+
"Inspect or monitor one withdrawal sweep: how many are done and remaining, the per-day pace and this sweep's own daily allowance (it never consumes your send budget), the stop reason if it stopped, any provider response SignalDash could not parse, the exact people your exclusions protected, and the approval_url to send the human. This read also authorizes a later exact cancel.",
|
|
837
|
+
inputSchema: {
|
|
838
|
+
type: "object",
|
|
839
|
+
properties: {
|
|
840
|
+
withdrawal_batch_id: { type: "string", minLength: 36, maxLength: 36 },
|
|
841
|
+
},
|
|
842
|
+
required: ["withdrawal_batch_id"],
|
|
843
|
+
additionalProperties: false,
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
name: "sd_withdrawal_batch_approve",
|
|
848
|
+
path: "/li/withdrawal_batch/approve",
|
|
849
|
+
description:
|
|
850
|
+
"Record the human's approval of one withdrawal sweep using the one-time code they generated on the authenticated approval page. You cannot create that code, guess it, or approve without it, and it only ever approves the exact invitations they left ticked.",
|
|
851
|
+
inputSchema: {
|
|
852
|
+
type: "object",
|
|
853
|
+
properties: {
|
|
854
|
+
withdrawal_batch_id: { type: "string", minLength: 36, maxLength: 36 },
|
|
855
|
+
confirm_token: {
|
|
856
|
+
type: "string",
|
|
857
|
+
pattern: "^sd-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$",
|
|
858
|
+
description: "The code the human read off the approval page.",
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
required: ["withdrawal_batch_id", "confirm_token"],
|
|
862
|
+
additionalProperties: false,
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
name: "sd_withdrawal_batch_cancel",
|
|
867
|
+
path: "/li/withdrawal_batch/cancel",
|
|
868
|
+
description:
|
|
869
|
+
"Revoke the human approval and stop one freshly inspected withdrawal sweep. Stops every withdrawal that has not started. Cannot recall one already submitted, and cannot restore the three-week re-invite block for people already withdrawn.",
|
|
870
|
+
inputSchema: {
|
|
871
|
+
type: "object",
|
|
872
|
+
properties: {
|
|
873
|
+
withdrawal_batch_id: { type: "string", minLength: 36, maxLength: 36 },
|
|
874
|
+
approval_view_hash: {
|
|
875
|
+
type: ["string", "null"],
|
|
876
|
+
pattern: "^[0-9a-f]{64}$",
|
|
877
|
+
},
|
|
878
|
+
confirm: { type: "boolean" },
|
|
879
|
+
},
|
|
880
|
+
required: ["withdrawal_batch_id", "approval_view_hash", "confirm"],
|
|
881
|
+
additionalProperties: false,
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
name: "wa_list_chats",
|
|
886
|
+
ch: "wa",
|
|
887
|
+
action: "list_chats",
|
|
888
|
+
description: "List or search your WhatsApp chats. `search` matches the phone number (chats carry it as attendee_public_identifier), not a person name; the response reports how far it scanned.",
|
|
889
|
+
inputSchema: {
|
|
890
|
+
type: "object",
|
|
891
|
+
properties: {
|
|
892
|
+
limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
|
|
893
|
+
cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
|
|
894
|
+
search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats on name and counterpart identifiers. The provider cannot filter a chat list, so SignalDash filters over a bounded scan and the response reports `scanned_chats` and `exhaustive` so a miss is never mistaken for a proven absence. A 1:1 chat has no name, so search by identifier or phone number, not by person name." },
|
|
895
|
+
max_scan: { type: "integer", minimum: 1, maximum: 500, description: "How many chats the search may scan before it stops and reports exhaustive:false. Default 200, and never more than five provider pages: bulk reading is the top account-restriction trigger, so go deeper with the returned cursor rather than with a bigger scan." },
|
|
896
|
+
},
|
|
897
|
+
additionalProperties: false,
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
name: "wa_read_messages",
|
|
902
|
+
ch: "wa",
|
|
903
|
+
action: "read",
|
|
904
|
+
description: "Read messages in a WhatsApp chat. args: chat_id",
|
|
905
|
+
inputSchema: {
|
|
906
|
+
type: "object",
|
|
907
|
+
properties: {
|
|
908
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
909
|
+
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
910
|
+
},
|
|
911
|
+
required: ["chat_id"],
|
|
912
|
+
additionalProperties: false,
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
name: "wa_get_attachment",
|
|
917
|
+
path: "/wa/get_attachment",
|
|
918
|
+
description: "Download one attachment of one WhatsApp message this account owns and store it on the SignalDash host. Read the chat first to obtain the exact message_id and attachment_id. Returns the stored path, mimetype, byte size and sha256.",
|
|
919
|
+
inputSchema: {
|
|
920
|
+
type: "object",
|
|
921
|
+
properties: {
|
|
922
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
923
|
+
message_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
924
|
+
attachment_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
925
|
+
},
|
|
926
|
+
required: ["chat_id", "message_id", "attachment_id"],
|
|
927
|
+
additionalProperties: false,
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
name: "wa_transcribe_voice",
|
|
932
|
+
path: "/wa/transcribe_voice",
|
|
933
|
+
description: "Transcribe one WhatsApp voice note or audio attachment this account owns. Fetches the audio through SignalDash, transcribes it on the SignalDash host, and returns the transcript text, the stored audio path, and the `backend` that produced the text. Optional `backend` picks the engine: `gemini` (default, accurate on German with English terms mixed in) or `whisper` (local small model, much weaker on code-switching). A `gemini` request that cannot reach Gemini falls back to the local model and says so in `backend` and `fallback_reason`.",
|
|
934
|
+
inputSchema: {
|
|
935
|
+
type: "object",
|
|
936
|
+
properties: {
|
|
937
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
938
|
+
message_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
939
|
+
attachment_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
940
|
+
backend: { type: "string", enum: ["gemini", "whisper"] },
|
|
941
|
+
},
|
|
942
|
+
required: ["chat_id", "message_id", "attachment_id"],
|
|
943
|
+
additionalProperties: false,
|
|
944
|
+
},
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
name: "wa_send_message",
|
|
948
|
+
ch: "wa",
|
|
949
|
+
action: "send",
|
|
950
|
+
description: "Send a WhatsApp message (rate-safe). args: chat_id, text, attachments. Read the chat first: a send into a thread this account has not read recently is refused. `attachments` optionally carries up to 4 base64 files as exact {filename, content_type, content_base64} objects, at most 16 MiB per file and 16 MiB per message, and accepts images, PDF, CSV, plain text, JSON, xlsx and zip. `text` is the caption and may be omitted when a file is attached, but a call carrying neither text nor an attachment is refused. An attachment send is rate-limited, deduplicated and recorded exactly like a text send, and spends the same daily budget. If a send times out or the provider never confirms it, the message may still have been delivered: SignalDash records it and refuses an identical retry with `409 send_outcome_unknown`. Read the chat, and only if the message is genuinely absent, resend the identical payload with `confirm_resend:true`.",
|
|
951
|
+
inputSchema: {
|
|
952
|
+
type: "object",
|
|
953
|
+
properties: {
|
|
954
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
955
|
+
text: { type: "string", minLength: 1, maxLength: 5000 },
|
|
956
|
+
attachments: {
|
|
957
|
+
type: "array", maxItems: 4,
|
|
958
|
+
items: {
|
|
959
|
+
type: "object",
|
|
960
|
+
properties: {
|
|
961
|
+
filename: { type: "string", minLength: 1, maxLength: 180 },
|
|
962
|
+
content_type: {
|
|
963
|
+
type: "string",
|
|
964
|
+
enum: [
|
|
965
|
+
"image/png",
|
|
966
|
+
"image/jpeg",
|
|
967
|
+
"image/webp",
|
|
968
|
+
"image/gif",
|
|
969
|
+
"application/pdf",
|
|
970
|
+
"text/csv",
|
|
971
|
+
"text/plain",
|
|
972
|
+
"application/json",
|
|
973
|
+
"application/zip",
|
|
974
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
975
|
+
],
|
|
976
|
+
},
|
|
977
|
+
content_base64: { type: "string", minLength: 1 },
|
|
978
|
+
},
|
|
979
|
+
required: ["filename", "content_type", "content_base64"],
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
confirm_resend: { type: "boolean", const: true },
|
|
983
|
+
},
|
|
984
|
+
// `text` alone is no longer required: a document with no caption is a
|
|
985
|
+
// legitimate message. `anyOf` states the real rule to any client that
|
|
986
|
+
// validates locally, and the backend refuses a call carrying neither
|
|
987
|
+
// regardless of whether the client checked.
|
|
988
|
+
required: ["chat_id"],
|
|
989
|
+
anyOf: [{ required: ["text"] }, { required: ["attachments"] }],
|
|
990
|
+
additionalProperties: false,
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
name: "wa_delete_message",
|
|
995
|
+
path: "/wa/delete_message",
|
|
996
|
+
description: "Delete one WhatsApp message this account SENT, in a chat this account owns. Read the chat first: the exact `message_id` comes from `wa_read_messages`. Only your own messages can be deleted; someone else's is refused with `403 message_not_own`. This is irreversible and is never retried: a delete already recorded for this exact chat and message is refused with `409 duplicate_delete` rather than replayed. WhatsApp applies its own time and role limits to deleting for everyone and can answer successfully without removing anything, so re-read the chat afterwards to confirm. Deletes spend their own daily budget and never consume your send budget.",
|
|
997
|
+
inputSchema: {
|
|
998
|
+
type: "object",
|
|
999
|
+
properties: {
|
|
1000
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
1001
|
+
message_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
1002
|
+
},
|
|
1003
|
+
required: ["chat_id", "message_id"],
|
|
1004
|
+
additionalProperties: false,
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
name: "wa_delete_messages",
|
|
1009
|
+
path: "/wa/delete_messages",
|
|
1010
|
+
description: "Delete several WhatsApp messages this account sent, at most 200 per call. Every entry runs the exact same ownership, budget and audit path as `wa_delete_message`, one at a time with a pause between them, never in parallel. Returns a per-entry `ok` with the refusal `code` and `error` for each one that did not go through, so a partial result is readable rather than all-or-nothing. Entries the batch never reached before its time limit come back with `skipped:true` and `code:batch_deadline`; resend exactly those to resume.",
|
|
1011
|
+
inputSchema: {
|
|
1012
|
+
type: "object",
|
|
1013
|
+
properties: {
|
|
1014
|
+
messages: {
|
|
1015
|
+
type: "array",
|
|
1016
|
+
minItems: 1,
|
|
1017
|
+
maxItems: 200,
|
|
1018
|
+
items: {
|
|
1019
|
+
type: "object",
|
|
1020
|
+
properties: {
|
|
1021
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
1022
|
+
message_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
1023
|
+
},
|
|
1024
|
+
required: ["chat_id", "message_id"],
|
|
1025
|
+
additionalProperties: false,
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
required: ["messages"],
|
|
1030
|
+
additionalProperties: false,
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
562
1033
|
{
|
|
563
1034
|
name: "email_list",
|
|
564
1035
|
path: "/email/list",
|
|
@@ -566,6 +1037,7 @@ const TOOLS = [
|
|
|
566
1037
|
inputSchema: {
|
|
567
1038
|
type: "object",
|
|
568
1039
|
properties: { limit: { type: "integer", minimum: 1, maximum: 100 } },
|
|
1040
|
+
additionalProperties: false,
|
|
569
1041
|
},
|
|
570
1042
|
},
|
|
571
1043
|
{
|
|
@@ -579,6 +1051,7 @@ const TOOLS = [
|
|
|
579
1051
|
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
580
1052
|
},
|
|
581
1053
|
required: ["thread_id"],
|
|
1054
|
+
additionalProperties: false,
|
|
582
1055
|
},
|
|
583
1056
|
},
|
|
584
1057
|
{
|
|
@@ -598,17 +1071,146 @@ const TOOLS = [
|
|
|
598
1071
|
body: { type: "string", minLength: 1, maxLength: 5000 },
|
|
599
1072
|
},
|
|
600
1073
|
required: ["to", "subject", "body"],
|
|
1074
|
+
additionalProperties: false,
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
name: "li_my_posts",
|
|
1079
|
+
path: "/li/posts",
|
|
1080
|
+
description: "List the user's own LinkedIn posts with engagement counts (reactions, comments, impressions). args: limit",
|
|
1081
|
+
inputSchema: {
|
|
1082
|
+
type: "object",
|
|
1083
|
+
properties: {
|
|
1084
|
+
limit: { type: "integer", minimum: 1, maximum: 50 },
|
|
1085
|
+
member_id: { type: "string", minLength: 1, maxLength: 500, description: "Whose posts to list. Defaults to your own." },
|
|
1086
|
+
},
|
|
1087
|
+
additionalProperties: false,
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
name: "li_post_reactions",
|
|
1092
|
+
path: "/li/post_reactions",
|
|
1093
|
+
description: "Who reacted to a post — name + headline. These are warm inbound signals. args: post_id, limit",
|
|
1094
|
+
inputSchema: {
|
|
1095
|
+
type: "object",
|
|
1096
|
+
properties: {
|
|
1097
|
+
post_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
1098
|
+
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
1099
|
+
},
|
|
1100
|
+
required: ["post_id"],
|
|
1101
|
+
additionalProperties: false,
|
|
1102
|
+
},
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
name: "li_post_comments",
|
|
1106
|
+
path: "/li/post_comments",
|
|
1107
|
+
description: "Comments on a post, with author. args: post_id, limit",
|
|
1108
|
+
inputSchema: {
|
|
1109
|
+
type: "object",
|
|
1110
|
+
properties: {
|
|
1111
|
+
post_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
1112
|
+
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
1113
|
+
},
|
|
1114
|
+
required: ["post_id"],
|
|
1115
|
+
additionalProperties: false,
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
{
|
|
1119
|
+
name: "li_draft_post",
|
|
1120
|
+
path: "/li/create_post",
|
|
1121
|
+
description: "Draft, publish, or schedule a LinkedIn post. Scheduling requires an offset-qualified scheduled_at plus publish:true after exact human approval. Optional mentions, base64 image attachments, and an account-owner-authored first_comment are preserved for the scheduled publish.",
|
|
1122
|
+
inputSchema: {
|
|
1123
|
+
type: "object",
|
|
1124
|
+
properties: {
|
|
1125
|
+
text: { type: "string", minLength: 1, maxLength: 3000 },
|
|
1126
|
+
publish: { type: "boolean" },
|
|
1127
|
+
scheduled_at: { type: "string", format: "date-time" },
|
|
1128
|
+
first_comment: { type: "string", minLength: 1, maxLength: 1250 },
|
|
1129
|
+
mentions: {
|
|
1130
|
+
type: "array", maxItems: 20,
|
|
1131
|
+
items: {
|
|
1132
|
+
type: "object",
|
|
1133
|
+
properties: {
|
|
1134
|
+
name: { type: "string", minLength: 1, maxLength: 120 },
|
|
1135
|
+
profile_id: { type: "string", minLength: 1, maxLength: 250 },
|
|
1136
|
+
},
|
|
1137
|
+
required: ["name", "profile_id"],
|
|
1138
|
+
},
|
|
1139
|
+
},
|
|
1140
|
+
attachments: {
|
|
1141
|
+
type: "array", maxItems: 4,
|
|
1142
|
+
items: {
|
|
1143
|
+
type: "object",
|
|
1144
|
+
properties: {
|
|
1145
|
+
filename: { type: "string", minLength: 1, maxLength: 180 },
|
|
1146
|
+
content_type: { type: "string", enum: ["image/png", "image/jpeg", "image/webp", "image/gif"] },
|
|
1147
|
+
content_base64: { type: "string", minLength: 1 },
|
|
1148
|
+
},
|
|
1149
|
+
required: ["filename", "content_type", "content_base64"],
|
|
1150
|
+
},
|
|
1151
|
+
},
|
|
1152
|
+
},
|
|
1153
|
+
required: ["text"],
|
|
1154
|
+
additionalProperties: false,
|
|
1155
|
+
},
|
|
1156
|
+
},
|
|
1157
|
+
{
|
|
1158
|
+
name: "li_set_scheduled_post_first_comment",
|
|
1159
|
+
path: "/li/set_scheduled_post_first_comment",
|
|
1160
|
+
description: "Attach one exact approved first comment to an existing scheduled LinkedIn post. SignalDash publishes it through the same connected account immediately after the post. Requires id, first_comment, and confirm:true.",
|
|
1161
|
+
inputSchema: {
|
|
1162
|
+
type: "object",
|
|
1163
|
+
properties: {
|
|
1164
|
+
id: { type: "string", format: "uuid" },
|
|
1165
|
+
first_comment: { type: "string", minLength: 1, maxLength: 1250 },
|
|
1166
|
+
confirm: { type: "boolean", const: true },
|
|
1167
|
+
},
|
|
1168
|
+
required: ["id", "first_comment", "confirm"],
|
|
1169
|
+
additionalProperties: false,
|
|
1170
|
+
},
|
|
1171
|
+
},
|
|
1172
|
+
{
|
|
1173
|
+
name: "li_scheduled_posts",
|
|
1174
|
+
path: "/li/scheduled_posts",
|
|
1175
|
+
description: "List this authenticated user's scheduled LinkedIn posts and their current states.",
|
|
1176
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
name: "li_cancel_scheduled_post",
|
|
1180
|
+
path: "/li/cancel_scheduled_post",
|
|
1181
|
+
description: "Cancel one exact scheduled LinkedIn post before execution. Requires id and confirm:true.",
|
|
1182
|
+
inputSchema: {
|
|
1183
|
+
type: "object",
|
|
1184
|
+
properties: {
|
|
1185
|
+
id: { type: "string", format: "uuid" },
|
|
1186
|
+
confirm: { type: "boolean", const: true },
|
|
1187
|
+
},
|
|
1188
|
+
required: ["id", "confirm"],
|
|
1189
|
+
additionalProperties: false,
|
|
601
1190
|
},
|
|
602
1191
|
},
|
|
603
|
-
{ name: "li_my_posts", path: "/li/posts", description: "List the user's own LinkedIn posts with engagement counts (reactions, comments, impressions). args: limit" },
|
|
604
|
-
{ name: "li_post_reactions", path: "/li/post_reactions", description: "Who reacted to a post — name + headline. These are warm inbound signals. args: post_id, limit" },
|
|
605
|
-
{ name: "li_post_comments", path: "/li/post_comments", description: "Comments on a post, with author. args: post_id, limit" },
|
|
606
|
-
{ name: "li_draft_post", path: "/li/create_post", description: "Draft a LinkedIn post. Returns the draft WITHOUT publishing. Publishing requires the human to approve and re-send with publish:true. args: text" },
|
|
607
1192
|
];
|
|
1193
|
+
// No catch-all fallback. It advertised one union of keys for every tool that
|
|
1194
|
+
// had no schema of its own -- which is how `li_list_chats` came to offer `text`
|
|
1195
|
+
// and `member_id` that the route never reads, and how a caller could not tell a
|
|
1196
|
+
// filtered search from an unfiltered list.
|
|
1197
|
+
//
|
|
1198
|
+
// A tool with no schema is dropped from the listing rather than shipped with a
|
|
1199
|
+
// promise the backend does not keep. Dropped, not thrown: tools/list maps every
|
|
1200
|
+
// tool through here, so throwing would take the whole LinkedIn AND WhatsApp
|
|
1201
|
+
// surface down over one bad entry. The test suite is what fails on a missing
|
|
1202
|
+
// schema; the live surface degrades by exactly one tool.
|
|
608
1203
|
function mcpTool(name) {
|
|
609
1204
|
const tool = TOOLS.find(t => t.name === name);
|
|
610
|
-
|
|
611
|
-
|
|
1205
|
+
if (!tool || !tool.inputSchema) {
|
|
1206
|
+
process.stderr.write(`[signaldash] tool ${name} has no inputSchema; omitted\n`);
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
return {
|
|
1210
|
+
name,
|
|
1211
|
+
description: tool.description,
|
|
1212
|
+
inputSchema: tool.inputSchema,
|
|
1213
|
+
};
|
|
612
1214
|
}
|
|
613
1215
|
export async function runMcp(dependencies = {}) {
|
|
614
1216
|
const input = dependencies.input || process.stdin;
|
|
@@ -620,7 +1222,7 @@ export async function runMcp(dependencies = {}) {
|
|
|
620
1222
|
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
621
1223
|
const { id, method, params } = msg;
|
|
622
1224
|
if (method === "initialize") reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "signaldash", version: PACKAGE_VERSION } });
|
|
623
|
-
else if (method === "tools/list") reply(id, { tools: TOOLS.map(t => mcpTool(t.name)) });
|
|
1225
|
+
else if (method === "tools/list") reply(id, { tools: TOOLS.map(t => mcpTool(t.name)).filter(Boolean) });
|
|
624
1226
|
else if (method === "tools/call") {
|
|
625
1227
|
const t = TOOLS.find(x => x.name === params.name);
|
|
626
1228
|
if (!t) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
|