@followr/mcp 0.1.0 → 0.2.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.
@@ -220,9 +220,13 @@ var FollowrClient = class {
220
220
  // Tags (CRUD complete, sessions 5 + 6 verified empirically)
221
221
  // ──────────────────────────────────────────────────────────
222
222
  async listTags(companyId, options) {
223
- const result = await this.request("GET", "/api/tags", {
224
- query: { "filter[company_id]": companyId, "page[size]": options?.pageSize ?? 100 }
225
- });
223
+ const query = {
224
+ "filter[company_id]": companyId,
225
+ "page[size]": options?.pageSize ?? 100
226
+ };
227
+ if (options?.name !== void 0)
228
+ query["filter[name]"] = options.name;
229
+ const result = await this.request("GET", "/api/tags", { query });
226
230
  return result.data;
227
231
  }
228
232
  async createTag(body) {
@@ -1664,9 +1668,890 @@ function registerPostGroupTools(server2, client2, options) {
1664
1668
  });
1665
1669
  }
1666
1670
 
1667
- // ../mcp-core/dist/tools/prompts.js
1671
+ // ../mcp-core/dist/tools/posts.js
1668
1672
  import { z as z9 } from "zod";
1669
- var SOCIAL_NETWORK_TYPE = z9.enum([
1673
+
1674
+ // ../mcp-core/dist/specs/runtime-context.js
1675
+ var CACHE_TTL_MS = 10 * 60 * 1e3;
1676
+ var cache = /* @__PURE__ */ new Map();
1677
+ async function gatherRuntimeContext(companyId, network, client2) {
1678
+ if (network !== "twitter" && network !== "tiktok")
1679
+ return {};
1680
+ const key = `${companyId}:${network}`;
1681
+ const cached = cache.get(key);
1682
+ if (cached && cached.expiresAt > Date.now())
1683
+ return cached.ctx;
1684
+ let ctx = {};
1685
+ try {
1686
+ const accounts = await client2.listSocialNetworks(companyId);
1687
+ const account = accounts.find((a) => a.type === network);
1688
+ if (account)
1689
+ ctx = extractContext(network, account);
1690
+ } catch {
1691
+ ctx = {};
1692
+ }
1693
+ cache.set(key, { ctx, expiresAt: Date.now() + CACHE_TTL_MS });
1694
+ return ctx;
1695
+ }
1696
+ function extractContext(network, account) {
1697
+ const p = account.preferences ?? {};
1698
+ const ctx = {};
1699
+ if (network === "twitter") {
1700
+ if (typeof p["verified"] === "boolean")
1701
+ ctx.twitter_verified = p["verified"];
1702
+ return ctx;
1703
+ }
1704
+ if (network === "tiktok") {
1705
+ if (typeof p["max_video_post_duration_sec"] === "number") {
1706
+ ctx.tiktok_max_duration_seconds = p["max_video_post_duration_sec"];
1707
+ }
1708
+ if (Array.isArray(p["privacy_level_options"])) {
1709
+ ctx.tiktok_privacy_level_options = p["privacy_level_options"].filter((v) => typeof v === "string");
1710
+ }
1711
+ if (typeof p["duet_disabled"] === "boolean")
1712
+ ctx.tiktok_duet_disabled = p["duet_disabled"];
1713
+ if (typeof p["stitch_disabled"] === "boolean")
1714
+ ctx.tiktok_stitch_disabled = p["stitch_disabled"];
1715
+ if (typeof p["comment_disabled"] === "boolean") {
1716
+ ctx.tiktok_comment_disabled = p["comment_disabled"];
1717
+ }
1718
+ return ctx;
1719
+ }
1720
+ return ctx;
1721
+ }
1722
+
1723
+ // ../mcp-core/dist/data/social-network-specs.json
1724
+ var social_network_specs_default = {
1725
+ _meta: {
1726
+ source: "Extracted from app.followr.ai frontend bundle, position 1553329-1559200",
1727
+ verified_at: "2026-05-16",
1728
+ extraction_method: "Chrome inspection of webpackChunkfollow main bundle",
1729
+ structure_note: "Top-level keys are `<network>_<product_type>`. Booleans normalized from !0/!1. void 0 \u2192 null. Conditionals noted as both branches where present."
1730
+ },
1731
+ medium_feed: {
1732
+ connection: {
1733
+ required: true
1734
+ },
1735
+ title: {
1736
+ max_length: 100,
1737
+ required: true
1738
+ },
1739
+ description: {
1740
+ max_length: 1024e4,
1741
+ required: true
1742
+ }
1743
+ },
1744
+ pinterest_feed: {
1745
+ connection: {
1746
+ required: true
1747
+ },
1748
+ title: {
1749
+ max_length: 100,
1750
+ required: true
1751
+ },
1752
+ board_id: {
1753
+ required: true
1754
+ },
1755
+ description: {
1756
+ max_length: 500
1757
+ },
1758
+ media_urls: {
1759
+ allow_multiple_types: false,
1760
+ max_images_length: 5,
1761
+ max_videos_length: 1,
1762
+ max_gifs_length: 0,
1763
+ max_length: 5,
1764
+ required: true,
1765
+ image_max_size_bytes: 33554432,
1766
+ video_max_size_bytes: 2097152e3,
1767
+ video_min_duration_seconds: 3,
1768
+ video_max_duration_seconds: 900,
1769
+ same_ratio: true,
1770
+ allow_file_cover: true,
1771
+ allow_frame_cover: true,
1772
+ required_cover: true
1773
+ }
1774
+ },
1775
+ twitter_feed: {
1776
+ connection: {
1777
+ required: true
1778
+ },
1779
+ description: {
1780
+ max_length: 280,
1781
+ max_length_if_verified: 25e3,
1782
+ _note: "Followr resolves this at runtime: 25000 if the connected Twitter account.preferences.verified is true, else 280"
1783
+ },
1784
+ media_urls: {
1785
+ allow_multiple_types: false,
1786
+ max_images_length: 4,
1787
+ max_videos_length: 1,
1788
+ max_gifs_length: 1,
1789
+ max_length: 4,
1790
+ required: false,
1791
+ image_max_size_bytes: 5242880,
1792
+ video_max_size_bytes: 536870912,
1793
+ video_min_duration_seconds: 0.5,
1794
+ video_max_duration_seconds: 140
1795
+ }
1796
+ },
1797
+ facebook_feed: {
1798
+ connection: {
1799
+ required: true
1800
+ },
1801
+ description: {
1802
+ max_length: 6e4
1803
+ },
1804
+ media_urls: {
1805
+ allow_multiple_types: false,
1806
+ max_images_length: 10,
1807
+ max_videos_length: 1,
1808
+ max_gifs_length: 0,
1809
+ max_length: 10,
1810
+ required: false,
1811
+ image_max_size_bytes: 8388608,
1812
+ video_max_size_bytes: 10737418240,
1813
+ video_min_duration_seconds: 3,
1814
+ video_max_duration_seconds: 1200,
1815
+ allow_file_cover: false,
1816
+ allow_frame_cover: false
1817
+ }
1818
+ },
1819
+ facebook_reel: {
1820
+ connection: {
1821
+ required: true
1822
+ },
1823
+ description: {
1824
+ max_length: 6e4
1825
+ },
1826
+ media_urls: {
1827
+ allow_multiple_types: false,
1828
+ max_images_length: 0,
1829
+ max_videos_length: 1,
1830
+ max_gifs_length: 0,
1831
+ max_length: 1,
1832
+ required: true,
1833
+ image_max_size_bytes: 8388608,
1834
+ video_max_size_bytes: 1073741824,
1835
+ video_min_duration_seconds: 3,
1836
+ video_max_duration_seconds: 90,
1837
+ video_max_width: 1920,
1838
+ video_min_width: 540,
1839
+ allowed_aspect_ratios: [
1840
+ 0.5,
1841
+ 0.6
1842
+ ],
1843
+ allow_file_cover: false,
1844
+ allow_frame_cover: false
1845
+ }
1846
+ },
1847
+ facebook_story: {
1848
+ connection: {
1849
+ required: true
1850
+ },
1851
+ description: {
1852
+ max_length: 0
1853
+ },
1854
+ media_urls: {
1855
+ allow_multiple_types: true,
1856
+ max_images_length: 1,
1857
+ max_videos_length: 1,
1858
+ max_gifs_length: 0,
1859
+ max_length: 1,
1860
+ required: true,
1861
+ image_max_size_bytes: 8388608,
1862
+ video_max_size_bytes: 104857600,
1863
+ video_min_duration_seconds: 3,
1864
+ video_max_duration_seconds: 60,
1865
+ video_max_width: 1920,
1866
+ video_min_width: 540,
1867
+ allowed_aspect_ratios: [
1868
+ 0.5,
1869
+ 0.6
1870
+ ]
1871
+ }
1872
+ },
1873
+ instagram_feed: {
1874
+ connection: {
1875
+ required: true
1876
+ },
1877
+ description: {
1878
+ max_length: 2200
1879
+ },
1880
+ media_urls: {
1881
+ allow_multiple_types: false,
1882
+ max_images_length: 10,
1883
+ max_videos_length: 1,
1884
+ max_gifs_length: 0,
1885
+ max_length: 10,
1886
+ required: true,
1887
+ image_max_size_bytes: 8388608,
1888
+ video_max_size_bytes: 10737418240,
1889
+ video_min_duration_seconds: 3,
1890
+ video_max_duration_seconds: 900,
1891
+ video_max_width: 1920,
1892
+ allowed_aspect_ratios: [
1893
+ 0.5,
1894
+ 1.91
1895
+ ],
1896
+ allow_file_cover: true,
1897
+ allow_frame_cover: true
1898
+ }
1899
+ },
1900
+ instagram_reel: {
1901
+ connection: {
1902
+ required: true
1903
+ },
1904
+ description: {
1905
+ max_length: 2200
1906
+ },
1907
+ media_urls: {
1908
+ allow_multiple_types: false,
1909
+ max_images_length: 0,
1910
+ max_videos_length: 1,
1911
+ max_gifs_length: 0,
1912
+ max_length: 1,
1913
+ required: true,
1914
+ image_max_size_bytes: null,
1915
+ video_max_size_bytes: 1073741824,
1916
+ video_min_duration_seconds: 3,
1917
+ video_max_duration_seconds: 900,
1918
+ video_max_width: 1920,
1919
+ video_min_width: 720,
1920
+ allowed_aspect_ratios: [
1921
+ 0.5,
1922
+ 1.91
1923
+ ],
1924
+ allow_file_cover: true,
1925
+ allow_frame_cover: true
1926
+ }
1927
+ },
1928
+ instagram_story: {
1929
+ connection: {
1930
+ required: true
1931
+ },
1932
+ description: {
1933
+ max_length: 0
1934
+ },
1935
+ media_urls: {
1936
+ allow_multiple_types: true,
1937
+ max_images_length: 1,
1938
+ max_videos_length: 1,
1939
+ max_gifs_length: 0,
1940
+ max_length: 1,
1941
+ required: true,
1942
+ image_max_size_bytes: 8388608,
1943
+ video_max_size_bytes: 104857600,
1944
+ video_min_duration_seconds: 3,
1945
+ video_max_duration_seconds: 60,
1946
+ video_max_width: 1920,
1947
+ video_min_width: 540,
1948
+ allowed_aspect_ratios: [
1949
+ 0.5,
1950
+ 0.6
1951
+ ],
1952
+ allow_file_cover: false,
1953
+ allow_frame_cover: false
1954
+ }
1955
+ },
1956
+ tiktok_feed: {
1957
+ connection: {
1958
+ required: true
1959
+ },
1960
+ description: {
1961
+ max_length: 2200,
1962
+ required: false
1963
+ },
1964
+ media_urls: {
1965
+ allow_multiple_types: false,
1966
+ max_images_length: 0,
1967
+ max_videos_length: 1,
1968
+ max_gifs_length: 0,
1969
+ max_length: 1,
1970
+ required: true,
1971
+ image_max_size_bytes: null,
1972
+ video_max_size_bytes: 4294967296,
1973
+ video_min_duration_seconds: 3,
1974
+ video_max_duration_seconds: 600,
1975
+ allow_file_cover: false,
1976
+ allow_frame_cover: true,
1977
+ _video_max_duration_note: "Followr resolves this at runtime: if connected TikTok account.video_duration is set, uses that; otherwise falls back to 600s. Max varies per TikTok account (1-3-10-60 min tiers)"
1978
+ },
1979
+ privacy_level: {
1980
+ required: true
1981
+ }
1982
+ },
1983
+ linkedin_feed: {
1984
+ connection: {
1985
+ required: true
1986
+ },
1987
+ description: {
1988
+ max_length: 3e3,
1989
+ required: true
1990
+ },
1991
+ media_urls: {
1992
+ allow_multiple_types: false,
1993
+ max_images_length: 9,
1994
+ max_videos_length: 1,
1995
+ max_gifs_length: 0,
1996
+ max_length: 9,
1997
+ required: false,
1998
+ image_max_size_bytes: 8388608,
1999
+ video_max_size_bytes: 209715200,
2000
+ video_min_duration_seconds: 3,
2001
+ video_max_duration_seconds: 600
2002
+ }
2003
+ },
2004
+ youtube_feed: {
2005
+ connection: {
2006
+ required: true
2007
+ },
2008
+ title: {
2009
+ max_length: 100,
2010
+ required: true
2011
+ },
2012
+ description: {
2013
+ max_length: 5e3,
2014
+ required: false
2015
+ },
2016
+ media_urls: {
2017
+ allow_multiple_types: false,
2018
+ max_images_length: 0,
2019
+ max_videos_length: 1,
2020
+ max_gifs_length: 0,
2021
+ max_length: 1,
2022
+ required: true,
2023
+ image_max_size_bytes: null,
2024
+ video_max_size_bytes: 137438953472,
2025
+ video_min_duration_seconds: 1,
2026
+ video_max_duration_seconds: 43200,
2027
+ video_max_width: 7680,
2028
+ video_min_width: 426,
2029
+ allowed_aspect_ratios: [
2030
+ 0.3333,
2031
+ 2.3922
2032
+ ],
2033
+ allow_file_cover: true,
2034
+ allow_frame_cover: true
2035
+ }
2036
+ },
2037
+ youtube_short: {
2038
+ connection: {
2039
+ required: true
2040
+ },
2041
+ title: {
2042
+ max_length: 100,
2043
+ required: false
2044
+ },
2045
+ description: {
2046
+ max_length: 5e3,
2047
+ required: false
2048
+ },
2049
+ media_urls: {
2050
+ allow_multiple_types: false,
2051
+ max_images_length: 0,
2052
+ max_videos_length: 1,
2053
+ max_gifs_length: 0,
2054
+ max_length: 1,
2055
+ required: true,
2056
+ video_max_size_bytes: 268435456,
2057
+ video_min_duration_seconds: 1,
2058
+ video_max_duration_seconds: 60,
2059
+ video_max_width: 1920,
2060
+ video_min_width: 600,
2061
+ allowed_aspect_ratios: [
2062
+ 0.5,
2063
+ 0.625
2064
+ ],
2065
+ allow_file_cover: true,
2066
+ allow_frame_cover: true
2067
+ }
2068
+ },
2069
+ threads_feed: {
2070
+ connection: {
2071
+ required: true
2072
+ },
2073
+ description: {
2074
+ max_length: 500
2075
+ },
2076
+ media_urls: {
2077
+ allow_multiple_types: true,
2078
+ max_images_length: 20,
2079
+ max_videos_length: 20,
2080
+ max_gifs_length: 20,
2081
+ max_length: 20,
2082
+ required: false,
2083
+ image_max_size_bytes: 5242880,
2084
+ video_max_size_bytes: 536870912,
2085
+ video_min_duration_seconds: 0.5,
2086
+ video_max_duration_seconds: 140
2087
+ }
2088
+ },
2089
+ bluesky_feed: {
2090
+ connection: {
2091
+ required: true
2092
+ },
2093
+ description: {
2094
+ max_length: 300
2095
+ },
2096
+ media_urls: {
2097
+ allow_multiple_types: false,
2098
+ max_images_length: 4,
2099
+ max_videos_length: 1,
2100
+ max_gifs_length: 0,
2101
+ max_length: 4,
2102
+ required: false,
2103
+ image_max_size_bytes: 1048576,
2104
+ video_max_size_bytes: 52428800,
2105
+ video_min_duration_seconds: 1,
2106
+ video_max_duration_seconds: 180
2107
+ }
2108
+ }
2109
+ };
2110
+
2111
+ // ../mcp-core/dist/specs/loader.js
2112
+ var REGISTRY = social_network_specs_default;
2113
+ function getSpec(network, productType) {
2114
+ const key = `${network}_${productType}`;
2115
+ return REGISTRY[key] ?? null;
2116
+ }
2117
+ function getSpecsMeta() {
2118
+ return REGISTRY._meta;
2119
+ }
2120
+
2121
+ // ../mcp-core/dist/specs/validate.js
2122
+ function validateAgainstSpec(payload, context = {}) {
2123
+ const spec = getSpec(payload.network, payload.product_type);
2124
+ if (!spec)
2125
+ return [];
2126
+ const specKey = `${payload.network}_${payload.product_type}`;
2127
+ const warnings = [];
2128
+ warnings.push(...validateTitle(spec, payload, specKey));
2129
+ warnings.push(...validateDescription(spec, payload, specKey, context));
2130
+ warnings.push(...validateBoardId(spec, payload, specKey));
2131
+ warnings.push(...validatePrivacyLevel(spec, payload, specKey, context));
2132
+ warnings.push(...validateMedia(spec, payload, specKey, context));
2133
+ return warnings;
2134
+ }
2135
+ function validateTitle(spec, payload, specKey) {
2136
+ const rule = spec.title;
2137
+ if (!rule)
2138
+ return [];
2139
+ const warnings = [];
2140
+ const title = payload.title?.trim() ?? "";
2141
+ if (rule.required && title.length === 0) {
2142
+ warnings.push({
2143
+ spec_key: specKey,
2144
+ field: "title",
2145
+ rule: "required",
2146
+ current_value: payload.title ?? null,
2147
+ expected: "non-empty string",
2148
+ severity: "hard_fail",
2149
+ suggestion: `Title is required for ${payload.network} ${payload.product_type}.`
2150
+ });
2151
+ }
2152
+ if (rule.max_length != null && title.length > rule.max_length) {
2153
+ warnings.push({
2154
+ spec_key: specKey,
2155
+ field: "title.max_length",
2156
+ rule: "max_length_exceeded",
2157
+ current_value: title.length,
2158
+ expected: rule.max_length,
2159
+ severity: "hard_fail",
2160
+ suggestion: `Trim title to ${rule.max_length} characters or less. Currently ${title.length}.`
2161
+ });
2162
+ }
2163
+ return warnings;
2164
+ }
2165
+ function validateDescription(spec, payload, specKey, context) {
2166
+ const rule = spec.description;
2167
+ if (!rule)
2168
+ return [];
2169
+ const warnings = [];
2170
+ const description = payload.description ?? "";
2171
+ const descTrimmed = description.trim();
2172
+ if (rule.required && descTrimmed.length === 0) {
2173
+ warnings.push({
2174
+ spec_key: specKey,
2175
+ field: "description",
2176
+ rule: "required",
2177
+ current_value: payload.description ?? null,
2178
+ expected: "non-empty string",
2179
+ severity: "hard_fail",
2180
+ suggestion: `Description is required for ${payload.network} ${payload.product_type}.`
2181
+ });
2182
+ }
2183
+ let effectiveMax = rule.max_length;
2184
+ let bumpedByVerified = false;
2185
+ if (payload.network === "twitter" && context.twitter_verified === true && rule.max_length_if_verified != null) {
2186
+ effectiveMax = rule.max_length_if_verified;
2187
+ bumpedByVerified = true;
2188
+ }
2189
+ if (effectiveMax == null)
2190
+ return warnings;
2191
+ if (effectiveMax === 0 && description.length > 0) {
2192
+ warnings.push({
2193
+ spec_key: specKey,
2194
+ field: "description",
2195
+ rule: "not_supported",
2196
+ current_value: description.length,
2197
+ expected: 0,
2198
+ severity: "hard_fail",
2199
+ suggestion: `${payload.network} ${payload.product_type} doesn't support a caption. The description field is ignored.`
2200
+ });
2201
+ return warnings;
2202
+ }
2203
+ if (description.length > effectiveMax) {
2204
+ let verifiedNote = "";
2205
+ if (payload.network === "twitter" && !context.twitter_verified && rule.max_length_if_verified != null) {
2206
+ verifiedNote = ` (Twitter Premium subscribers get up to ${rule.max_length_if_verified}.)`;
2207
+ }
2208
+ warnings.push({
2209
+ spec_key: specKey,
2210
+ field: "description.max_length",
2211
+ rule: "max_length_exceeded",
2212
+ current_value: description.length,
2213
+ expected: effectiveMax,
2214
+ severity: "hard_fail",
2215
+ suggestion: `Trim caption to ${effectiveMax} characters or less. Currently ${description.length}.${verifiedNote}`
2216
+ });
2217
+ }
2218
+ if (!bumpedByVerified && payload.network === "twitter" && rule.max_length_if_verified != null && description.length > rule.max_length && description.length <= rule.max_length_if_verified) {
2219
+ }
2220
+ return warnings;
2221
+ }
2222
+ function validateBoardId(spec, payload, specKey) {
2223
+ if (!spec.board_id?.required)
2224
+ return [];
2225
+ const boardId = payload.preferences?.["board_id"];
2226
+ if (boardId != null && boardId !== "")
2227
+ return [];
2228
+ return [
2229
+ {
2230
+ spec_key: specKey,
2231
+ field: "preferences.board_id",
2232
+ rule: "required",
2233
+ current_value: boardId ?? null,
2234
+ expected: "Pinterest board id (number)",
2235
+ severity: "hard_fail",
2236
+ suggestion: "Pinterest requires a destination board. Set preferences.board_id to a valid board id."
2237
+ }
2238
+ ];
2239
+ }
2240
+ function validatePrivacyLevel(spec, payload, specKey, context) {
2241
+ if (!spec.privacy_level?.required)
2242
+ return [];
2243
+ const warnings = [];
2244
+ const prefs = payload.preferences;
2245
+ const level = prefs?.["privacy_level"];
2246
+ if (level == null || typeof level !== "string" || level === "") {
2247
+ warnings.push({
2248
+ spec_key: specKey,
2249
+ field: "preferences.privacy_level",
2250
+ rule: "required",
2251
+ current_value: level ?? null,
2252
+ expected: context.tiktok_privacy_level_options ?? "one of TikTok's privacy levels",
2253
+ severity: "hard_fail",
2254
+ suggestion: `TikTok requires preferences.privacy_level. ${context.tiktok_privacy_level_options ? `Allowed for this account: ${context.tiktok_privacy_level_options.join(", ")}.` : "Check the connected TikTok account for allowed values."}`
2255
+ });
2256
+ return warnings;
2257
+ }
2258
+ if (context.tiktok_privacy_level_options && !context.tiktok_privacy_level_options.includes(level)) {
2259
+ warnings.push({
2260
+ spec_key: specKey,
2261
+ field: "preferences.privacy_level",
2262
+ rule: "value_not_allowed",
2263
+ current_value: level,
2264
+ expected: context.tiktok_privacy_level_options,
2265
+ severity: "hard_fail",
2266
+ suggestion: `Privacy level "${level}" is not enabled for this TikTok account. Allowed: ${context.tiktok_privacy_level_options.join(", ")}.`
2267
+ });
2268
+ }
2269
+ return warnings;
2270
+ }
2271
+ function validateMedia(spec, payload, specKey, context) {
2272
+ const m = spec.media_urls;
2273
+ if (!m)
2274
+ return [];
2275
+ const warnings = [];
2276
+ const assets = payload.assets ?? [];
2277
+ if (m.required && assets.length === 0) {
2278
+ warnings.push({
2279
+ spec_key: specKey,
2280
+ field: "assets",
2281
+ rule: "required",
2282
+ current_value: 0,
2283
+ expected: ">=1 asset",
2284
+ severity: "hard_fail",
2285
+ suggestion: `${payload.network} ${payload.product_type} requires at least one media asset.`
2286
+ });
2287
+ return warnings;
2288
+ }
2289
+ if (assets.length === 0)
2290
+ return warnings;
2291
+ if (m.allow_multiple_types === false) {
2292
+ const types = new Set(assets.map((a) => a.type));
2293
+ if (types.size > 1) {
2294
+ warnings.push({
2295
+ spec_key: specKey,
2296
+ field: "assets.types",
2297
+ rule: "no_mixed_media",
2298
+ current_value: Array.from(types),
2299
+ expected: "single media type",
2300
+ severity: "hard_fail",
2301
+ suggestion: `${payload.network} ${payload.product_type} doesn't allow mixed media types in one post. Use either all images, all videos, or all GIFs.`
2302
+ });
2303
+ }
2304
+ }
2305
+ const counts = {
2306
+ image: assets.filter((a) => a.type === "image").length,
2307
+ video: assets.filter((a) => a.type === "video").length,
2308
+ gif: assets.filter((a) => a.type === "gif").length
2309
+ };
2310
+ if (m.max_images_length != null && counts.image > m.max_images_length) {
2311
+ warnings.push(countWarning(specKey, payload, "images", counts.image, m.max_images_length));
2312
+ }
2313
+ if (m.max_videos_length != null && counts.video > m.max_videos_length) {
2314
+ warnings.push(countWarning(specKey, payload, "videos", counts.video, m.max_videos_length));
2315
+ }
2316
+ if (m.max_gifs_length != null && counts.gif > m.max_gifs_length) {
2317
+ warnings.push(countWarning(specKey, payload, "gifs", counts.gif, m.max_gifs_length));
2318
+ }
2319
+ if (m.max_length != null && assets.length > m.max_length) {
2320
+ warnings.push({
2321
+ spec_key: specKey,
2322
+ field: "assets",
2323
+ rule: "max_total_exceeded",
2324
+ current_value: assets.length,
2325
+ expected: m.max_length,
2326
+ severity: "hard_fail",
2327
+ suggestion: `${payload.network} ${payload.product_type} accepts up to ${m.max_length} total assets. You attached ${assets.length}.`
2328
+ });
2329
+ }
2330
+ const effectiveVideoMaxDuration = resolveVideoMaxDuration(payload, context, m);
2331
+ for (let i = 0; i < assets.length; i++) {
2332
+ warnings.push(...validateAsset(assets[i], i, specKey, payload, m, effectiveVideoMaxDuration));
2333
+ }
2334
+ return warnings;
2335
+ }
2336
+ function resolveVideoMaxDuration(payload, context, m) {
2337
+ if (payload.network === "tiktok" && context.tiktok_max_duration_seconds != null) {
2338
+ return context.tiktok_max_duration_seconds;
2339
+ }
2340
+ return m.video_max_duration_seconds;
2341
+ }
2342
+ function validateAsset(asset, index, specKey, payload, m, effectiveVideoMaxDuration) {
2343
+ const warnings = [];
2344
+ const prefix = `assets[${index}]`;
2345
+ if (asset.type === "image" && m.image_max_size_bytes != null && asset.size_bytes != null && asset.size_bytes > m.image_max_size_bytes) {
2346
+ warnings.push({
2347
+ spec_key: specKey,
2348
+ field: `${prefix}.size_bytes`,
2349
+ rule: "max_size_exceeded",
2350
+ current_value: asset.size_bytes,
2351
+ expected: m.image_max_size_bytes,
2352
+ severity: "hard_fail",
2353
+ suggestion: `Image at index ${index} is ${formatBytes(asset.size_bytes)}, max is ${formatBytes(m.image_max_size_bytes)} for ${payload.network} ${payload.product_type}.`
2354
+ });
2355
+ }
2356
+ if (asset.type === "video" && m.video_max_size_bytes != null && asset.size_bytes != null && asset.size_bytes > m.video_max_size_bytes) {
2357
+ warnings.push({
2358
+ spec_key: specKey,
2359
+ field: `${prefix}.size_bytes`,
2360
+ rule: "max_size_exceeded",
2361
+ current_value: asset.size_bytes,
2362
+ expected: m.video_max_size_bytes,
2363
+ severity: "hard_fail",
2364
+ suggestion: `Video at index ${index} is ${formatBytes(asset.size_bytes)}, max is ${formatBytes(m.video_max_size_bytes)} for ${payload.network} ${payload.product_type}.`
2365
+ });
2366
+ }
2367
+ if (asset.type === "video" && asset.duration_seconds != null) {
2368
+ if (effectiveVideoMaxDuration != null && asset.duration_seconds > effectiveVideoMaxDuration) {
2369
+ warnings.push({
2370
+ spec_key: specKey,
2371
+ field: `${prefix}.duration_seconds`,
2372
+ rule: "video_too_long",
2373
+ current_value: asset.duration_seconds,
2374
+ expected: effectiveVideoMaxDuration,
2375
+ severity: "hard_fail",
2376
+ suggestion: `Video at index ${index} is ${asset.duration_seconds}s, max is ${effectiveVideoMaxDuration}s for ${payload.network} ${payload.product_type}.`
2377
+ });
2378
+ }
2379
+ if (m.video_min_duration_seconds != null && asset.duration_seconds < m.video_min_duration_seconds) {
2380
+ warnings.push({
2381
+ spec_key: specKey,
2382
+ field: `${prefix}.duration_seconds`,
2383
+ rule: "video_too_short",
2384
+ current_value: asset.duration_seconds,
2385
+ expected: m.video_min_duration_seconds,
2386
+ severity: "hard_fail",
2387
+ suggestion: `Video at index ${index} is ${asset.duration_seconds}s, min is ${m.video_min_duration_seconds}s for ${payload.network} ${payload.product_type}.`
2388
+ });
2389
+ }
2390
+ }
2391
+ if (asset.type === "video" && asset.width != null) {
2392
+ if (m.video_max_width != null && asset.width > m.video_max_width) {
2393
+ warnings.push({
2394
+ spec_key: specKey,
2395
+ field: `${prefix}.width`,
2396
+ rule: "video_max_width_exceeded",
2397
+ current_value: asset.width,
2398
+ expected: m.video_max_width,
2399
+ severity: "hard_fail",
2400
+ suggestion: `Video at index ${index} is ${asset.width}px wide, max is ${m.video_max_width}px.`
2401
+ });
2402
+ }
2403
+ if (m.video_min_width != null && asset.width < m.video_min_width) {
2404
+ warnings.push({
2405
+ spec_key: specKey,
2406
+ field: `${prefix}.width`,
2407
+ rule: "video_min_width_below",
2408
+ current_value: asset.width,
2409
+ expected: m.video_min_width,
2410
+ severity: "hard_fail",
2411
+ suggestion: `Video at index ${index} is ${asset.width}px wide, min is ${m.video_min_width}px.`
2412
+ });
2413
+ }
2414
+ }
2415
+ if (m.allowed_aspect_ratios && asset.width != null && asset.height != null && asset.height > 0) {
2416
+ const ratio = asset.width / asset.height;
2417
+ const [min, max] = m.allowed_aspect_ratios;
2418
+ if (ratio < min || ratio > max) {
2419
+ warnings.push({
2420
+ spec_key: specKey,
2421
+ field: `${prefix}.aspect_ratio`,
2422
+ rule: "aspect_ratio_out_of_range",
2423
+ current_value: round(ratio, 3),
2424
+ expected: m.allowed_aspect_ratios,
2425
+ severity: "hard_fail",
2426
+ suggestion: `Asset at index ${index} has aspect ratio ${round(ratio, 3)} (${asset.width}\xD7${asset.height}). ${payload.network} ${payload.product_type} accepts [${min}, ${max}].`
2427
+ });
2428
+ }
2429
+ }
2430
+ return warnings;
2431
+ }
2432
+ function countWarning(specKey, payload, kind, actual, max) {
2433
+ return {
2434
+ spec_key: specKey,
2435
+ field: `assets.${kind}`,
2436
+ rule: "max_count_exceeded",
2437
+ current_value: actual,
2438
+ expected: max,
2439
+ severity: "hard_fail",
2440
+ suggestion: `${payload.network} ${payload.product_type} accepts up to ${max} ${kind === "videos" && max === 1 ? "video" : kind === "images" && max === 1 ? "image" : kind === "gifs" && max === 1 ? "GIF" : kind}. You attached ${actual}.`
2441
+ };
2442
+ }
2443
+ function formatBytes(bytes) {
2444
+ if (bytes >= 1024 ** 3)
2445
+ return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
2446
+ if (bytes >= 1024 ** 2)
2447
+ return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
2448
+ if (bytes >= 1024)
2449
+ return `${(bytes / 1024).toFixed(1)} KB`;
2450
+ return `${bytes} B`;
2451
+ }
2452
+ function round(n, decimals) {
2453
+ const factor = 10 ** decimals;
2454
+ return Math.round(n * factor) / factor;
2455
+ }
2456
+
2457
+ // ../mcp-core/dist/tools/posts.js
2458
+ var NETWORK_ENUM = [
2459
+ "medium",
2460
+ "pinterest",
2461
+ "twitter",
2462
+ "facebook",
2463
+ "instagram",
2464
+ "tiktok",
2465
+ "linkedin",
2466
+ "youtube",
2467
+ "threads",
2468
+ "bluesky"
2469
+ ];
2470
+ var PRODUCT_TYPE_ENUM = ["feed", "reel", "story", "short"];
2471
+ var NETWORKS_NEEDING_MEDIA_PRODUCT_TYPE = /* @__PURE__ */ new Set([
2472
+ "instagram",
2473
+ "facebook",
2474
+ "youtube"
2475
+ ]);
2476
+ var PRODUCT_TYPE_TO_FOLLOWR = {
2477
+ feed: "FEED",
2478
+ reel: "REEL",
2479
+ story: "STORY",
2480
+ short: "SHORT"
2481
+ };
2482
+ var AssetInputSchema = z9.object({
2483
+ id: z9.number().int().positive().describe("Followr asset id (returned by upload_image_from_url / upload_video_from_url / list_assets)."),
2484
+ type: z9.enum(["image", "video", "gif"]).describe("Asset type. Required for spec validation."),
2485
+ width: z9.number().positive().optional().describe("Optional. If provided, used for aspect ratio and width checks."),
2486
+ height: z9.number().positive().optional(),
2487
+ size_bytes: z9.number().nonnegative().optional().describe("Optional. If provided, used for file size checks."),
2488
+ duration_seconds: z9.number().positive().optional().describe("Optional, video assets only. Used for duration checks.")
2489
+ });
2490
+ function registerPostTools(server2, client2, _options) {
2491
+ server2.registerTool("create_post", {
2492
+ title: "Create a Post inside a PostGroup, attaching assets and copy",
2493
+ description: "Create a Post (per-network entry) within an existing PostGroup. This is the second step of the manual PostGroup \u2192 Post \u2192 Schedule workflow: after create_post_group, call create_post once per target social network, then update_post_group to set publish_at. Pre-validates the payload against per-network specs (caption length, asset count/type/size/aspect ratio, etc.) and returns advisory warnings alongside the created post. Warnings are informational \u2014 the post is always created. The caller (LLM or user) decides whether to act on warnings before scheduling.",
2494
+ inputSchema: {
2495
+ post_group_id: z9.number().int().positive().describe("Parent PostGroup id from create_post_group."),
2496
+ company_id: z9.number().int().positive().describe("Followr workspace id. Required to resolve account-specific limits (Twitter verified, TikTok tier) for validation."),
2497
+ social_network_type: z9.enum(NETWORK_ENUM).describe("Target social network."),
2498
+ product_type: z9.enum(PRODUCT_TYPE_ENUM).describe("Post format. 'feed' = standard feed post. 'reel' = vertical short-form video (IG Reels, FB Reels). 'story' = ephemeral 9:16 (IG/FB Stories). 'short' = YouTube Shorts. For IG/FB/YouTube, this is auto-injected as preferences.media_product_type in the Followr API body."),
2499
+ description: z9.string().optional().describe("Caption / body text."),
2500
+ title: z9.string().optional().describe("Title (medium / pinterest / youtube only)."),
2501
+ link: z9.string().optional().describe("Optional outbound link."),
2502
+ assets: z9.array(AssetInputSchema).optional().describe("Media to attach. Asset ids are extracted into the API's assets_ids array. The full metadata (type, width, height, size, duration) is used for validation only \u2014 not sent to the API."),
2503
+ preferences: z9.record(z9.string(), z9.unknown()).optional().describe("Network-specific extras passed through to Followr. e.g. board_id (Pinterest), privacy_level + duet_disabled (TikTok), category_id (YouTube), notify_followers (IG). media_product_type is auto-injected from product_type for IG/FB/YouTube if not already set."),
2504
+ comments_to_create: z9.array(z9.unknown()).optional().describe("First-comment payloads (e.g., for IG hashtag dumping in the first comment).")
2505
+ }
2506
+ }, async (input) => {
2507
+ const context = await gatherRuntimeContext(input.company_id, input.social_network_type, client2);
2508
+ const mergedPreferences = { ...input.preferences ?? {} };
2509
+ if (NETWORKS_NEEDING_MEDIA_PRODUCT_TYPE.has(input.social_network_type) && !("media_product_type" in mergedPreferences)) {
2510
+ mergedPreferences["media_product_type"] = PRODUCT_TYPE_TO_FOLLOWR[input.product_type];
2511
+ }
2512
+ const warnings = validateAgainstSpec({
2513
+ network: input.social_network_type,
2514
+ product_type: input.product_type,
2515
+ description: input.description,
2516
+ title: input.title,
2517
+ link: input.link,
2518
+ assets: input.assets,
2519
+ preferences: mergedPreferences
2520
+ }, context);
2521
+ const body = {
2522
+ social_network_type: input.social_network_type
2523
+ };
2524
+ if (input.description !== void 0)
2525
+ body.description = input.description;
2526
+ if (input.title !== void 0)
2527
+ body.title = input.title;
2528
+ if (input.link !== void 0)
2529
+ body.link = input.link;
2530
+ if (input.assets && input.assets.length > 0) {
2531
+ body.assets_ids = input.assets.map((a) => a.id);
2532
+ }
2533
+ if (Object.keys(mergedPreferences).length > 0)
2534
+ body.preferences = mergedPreferences;
2535
+ if (input.comments_to_create !== void 0)
2536
+ body.comments_to_create = input.comments_to_create;
2537
+ const post = await client2.createPost(input.post_group_id, body);
2538
+ const response = {
2539
+ post,
2540
+ validation: {
2541
+ warning_count: warnings.length,
2542
+ warnings,
2543
+ runtime_context: context
2544
+ }
2545
+ };
2546
+ return {
2547
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2548
+ };
2549
+ });
2550
+ }
2551
+
2552
+ // ../mcp-core/dist/tools/prompts.js
2553
+ import { z as z10 } from "zod";
2554
+ var SOCIAL_NETWORK_TYPE = z10.enum([
1670
2555
  "facebook",
1671
2556
  "twitter",
1672
2557
  "instagram",
@@ -1683,10 +2568,10 @@ function registerPromptTools(server2, client2, _options) {
1683
2568
  title: "List brand-voice prompts for a workspace",
1684
2569
  description: "List the brand-voice prompts of a workspace. These are the per-network prompt templates that Followr picks among when generating content (Post Generator, etc.). Optional filter by social network type and by `default=true`. Pass company_id=0 to see Followr's built-in defaults (where company_id is null in the API).",
1685
2570
  inputSchema: {
1686
- company_id: z9.number().int().nonnegative().describe("Workspace id. Pass 0 to query Followr's built-in defaults (the API maps 0 \u2192 null)."),
2571
+ company_id: z10.number().int().nonnegative().describe("Workspace id. Pass 0 to query Followr's built-in defaults (the API maps 0 \u2192 null)."),
1687
2572
  social_network_type: SOCIAL_NETWORK_TYPE.optional().describe("Restrict to one network."),
1688
- only_default: z9.boolean().optional().describe("If true, only return prompts marked default."),
1689
- page_size: z9.number().int().positive().max(100).optional()
2573
+ only_default: z10.boolean().optional().describe("If true, only return prompts marked default."),
2574
+ page_size: z10.number().int().positive().max(100).optional()
1690
2575
  }
1691
2576
  }, async ({ company_id, social_network_type, only_default, page_size }) => {
1692
2577
  const prompts = await client2.listPrompts({
@@ -1716,7 +2601,7 @@ function registerPromptTools(server2, client2, _options) {
1716
2601
  title: "Get a single brand-voice prompt by id",
1717
2602
  description: "Fetch one brand-voice prompt by id. Useful to inspect its current text, default flag, and network assignment.",
1718
2603
  inputSchema: {
1719
- prompt_id: z9.number().int().positive()
2604
+ prompt_id: z10.number().int().positive()
1720
2605
  }
1721
2606
  }, async ({ prompt_id }) => {
1722
2607
  const prompt = await client2.getPrompt(prompt_id);
@@ -1726,12 +2611,12 @@ function registerPromptTools(server2, client2, _options) {
1726
2611
  title: "Create a brand-voice prompt for a workspace",
1727
2612
  description: "Create a custom brand-voice prompt attached to a workspace and a specific social network. Followr will consider this prompt (alongside any built-in defaults and other workspace prompts) when generating content. Mark `default=true` to make it eligible for automatic selection. Multiple prompts with default=true per network are allowed; Followr picks one at generate time.",
1728
2613
  inputSchema: {
1729
- company_id: z9.number().int().positive(),
2614
+ company_id: z10.number().int().positive(),
1730
2615
  social_network_type: SOCIAL_NETWORK_TYPE,
1731
- name: z9.string().min(1).max(80).describe("Short human-readable name shown in the Followr UI."),
1732
- prompt: z9.string().min(1).describe("The actual prompt text used as system instructions when generating."),
1733
- default: z9.boolean().optional().describe("If true, marks this prompt as eligible for automatic selection. Default false."),
1734
- type: z9.string().optional().describe("Resource type. Default `text` (the only verified value). Reserved for future image/video prompt types.")
2616
+ name: z10.string().min(1).max(80).describe("Short human-readable name shown in the Followr UI."),
2617
+ prompt: z10.string().min(1).describe("The actual prompt text used as system instructions when generating."),
2618
+ default: z10.boolean().optional().describe("If true, marks this prompt as eligible for automatic selection. Default false."),
2619
+ type: z10.string().optional().describe("Resource type. Default `text` (the only verified value). Reserved for future image/video prompt types.")
1735
2620
  }
1736
2621
  }, async ({ company_id, social_network_type, name, prompt, default: isDefault, type }) => {
1737
2622
  const created = await client2.createPrompt({
@@ -1748,11 +2633,11 @@ function registerPromptTools(server2, client2, _options) {
1748
2633
  title: "Update a brand-voice prompt",
1749
2634
  description: "Patch an existing brand-voice prompt. Use to rename, edit the prompt text, change the network assignment, or toggle the `default` flag.",
1750
2635
  inputSchema: {
1751
- prompt_id: z9.number().int().positive(),
1752
- name: z9.string().min(1).max(80).optional(),
1753
- prompt: z9.string().min(1).optional(),
2636
+ prompt_id: z10.number().int().positive(),
2637
+ name: z10.string().min(1).max(80).optional(),
2638
+ prompt: z10.string().min(1).optional(),
1754
2639
  social_network_type: SOCIAL_NETWORK_TYPE.optional(),
1755
- default: z9.boolean().optional()
2640
+ default: z10.boolean().optional()
1756
2641
  }
1757
2642
  }, async ({ prompt_id, ...patch }) => {
1758
2643
  const updated = await client2.updatePrompt(prompt_id, patch);
@@ -1762,7 +2647,7 @@ function registerPromptTools(server2, client2, _options) {
1762
2647
  title: "Delete a brand-voice prompt (destructive)",
1763
2648
  description: "Permanently delete a brand-voice prompt. Cannot be undone. Followr's built-in defaults (where company_id is null) cannot be deleted; only workspace-scoped prompts can.",
1764
2649
  inputSchema: {
1765
- prompt_id: z9.number().int().positive()
2650
+ prompt_id: z10.number().int().positive()
1766
2651
  }
1767
2652
  }, async ({ prompt_id }) => {
1768
2653
  await client2.deletePrompt(prompt_id);
@@ -1771,14 +2656,14 @@ function registerPromptTools(server2, client2, _options) {
1771
2656
  }
1772
2657
 
1773
2658
  // ../mcp-core/dist/tools/rule-groups.js
1774
- import { z as z10 } from "zod";
2659
+ import { z as z11 } from "zod";
1775
2660
  function registerRuleGroupTools(server2, client2, _options) {
1776
2661
  server2.registerTool("list_rule_groups", {
1777
2662
  title: "List Autopilot rule groups in a workspace",
1778
2663
  description: "List rule groups (Autopilot scheduling rules) in a workspace. A rule group bundles rules that auto-fill empty calendar slots from a pool of tagged PostGroups. Includes the underlying rules array by default.",
1779
2664
  inputSchema: {
1780
- company_id: z10.number().int().positive(),
1781
- include: z10.string().optional().describe("Override include chain. Default: rules.")
2665
+ company_id: z11.number().int().positive(),
2666
+ include: z11.string().optional().describe("Override include chain. Default: rules.")
1782
2667
  }
1783
2668
  }, async ({ company_id, include }) => {
1784
2669
  const groups = await client2.listRuleGroups(company_id, {
@@ -1805,7 +2690,7 @@ function registerRuleGroupTools(server2, client2, _options) {
1805
2690
  title: "Get a single rule group by id",
1806
2691
  description: "Fetch one Autopilot rule group's details. Path is flat (/api/ruleGroups/{id}).",
1807
2692
  inputSchema: {
1808
- rule_group_id: z10.number().int().positive()
2693
+ rule_group_id: z11.number().int().positive()
1809
2694
  }
1810
2695
  }, async ({ rule_group_id }) => {
1811
2696
  const group = await client2.getRuleGroup(rule_group_id);
@@ -1815,11 +2700,11 @@ function registerRuleGroupTools(server2, client2, _options) {
1815
2700
  title: "Create an Autopilot rule group",
1816
2701
  description: "Create a new Autopilot rule group in a workspace. After creation, individual rules (days_of_week, time_slots, social_network_types, tag filters) must be added separately. random_minutes adds jitter to scheduled times to avoid bot-like patterns.",
1817
2702
  inputSchema: {
1818
- company_id: z10.number().int().positive(),
1819
- name: z10.string().min(1),
1820
- description: z10.string().optional(),
1821
- is_active: z10.boolean().optional().describe("If true, the rule group is active immediately. Default false."),
1822
- random_minutes: z10.number().int().min(0).max(120).optional().describe("Random jitter in minutes applied to scheduled times. Default 0.")
2703
+ company_id: z11.number().int().positive(),
2704
+ name: z11.string().min(1),
2705
+ description: z11.string().optional(),
2706
+ is_active: z11.boolean().optional().describe("If true, the rule group is active immediately. Default false."),
2707
+ random_minutes: z11.number().int().min(0).max(120).optional().describe("Random jitter in minutes applied to scheduled times. Default 0.")
1823
2708
  }
1824
2709
  }, async ({ company_id, name, description, is_active, random_minutes }) => {
1825
2710
  const group = await client2.createRuleGroup({
@@ -1835,11 +2720,11 @@ function registerRuleGroupTools(server2, client2, _options) {
1835
2720
  title: "Update an Autopilot rule group",
1836
2721
  description: "Patch a rule group's name, description, active flag, or random jitter.",
1837
2722
  inputSchema: {
1838
- rule_group_id: z10.number().int().positive(),
1839
- name: z10.string().min(1).optional(),
1840
- description: z10.string().optional(),
1841
- active: z10.boolean().optional(),
1842
- random_minutes: z10.number().int().min(0).max(120).optional()
2723
+ rule_group_id: z11.number().int().positive(),
2724
+ name: z11.string().min(1).optional(),
2725
+ description: z11.string().optional(),
2726
+ active: z11.boolean().optional(),
2727
+ random_minutes: z11.number().int().min(0).max(120).optional()
1843
2728
  }
1844
2729
  }, async ({ rule_group_id, ...patch }) => {
1845
2730
  const group = await client2.updateRuleGroup(rule_group_id, patch);
@@ -1849,7 +2734,7 @@ function registerRuleGroupTools(server2, client2, _options) {
1849
2734
  title: "Delete an Autopilot rule group (destructive)",
1850
2735
  description: "Permanently delete an Autopilot rule group. Already-scheduled posts that were filled by this group will remain on the calendar (deletion only affects future autopilot fills). Cannot be undone.",
1851
2736
  inputSchema: {
1852
- rule_group_id: z10.number().int().positive()
2737
+ rule_group_id: z11.number().int().positive()
1853
2738
  }
1854
2739
  }, async ({ rule_group_id }) => {
1855
2740
  await client2.deleteRuleGroup(rule_group_id);
@@ -1858,16 +2743,16 @@ function registerRuleGroupTools(server2, client2, _options) {
1858
2743
  }
1859
2744
 
1860
2745
  // ../mcp-core/dist/tools/social-hub.js
1861
- import { z as z11 } from "zod";
2746
+ import { z as z12 } from "zod";
1862
2747
  function registerSocialHubTools(server2, client2, _options) {
1863
2748
  server2.registerTool("list_conversations", {
1864
2749
  title: "List inbox conversations (DMs) for a workspace",
1865
2750
  description: "List Social Hub conversations across all connected accounts in a workspace, newest activity first. Each entry includes the external user (DM sender), last message preview, and unread count. Use this to triage the inbox or auto-reply.",
1866
2751
  inputSchema: {
1867
- company_id: z11.number().int().positive(),
1868
- social_network_id: z11.number().int().positive().optional().describe("Optional: limit to a specific connected account."),
1869
- only_unread: z11.boolean().optional().describe("If true, only return conversations with unread messages."),
1870
- page_size: z11.number().int().positive().max(100).optional()
2752
+ company_id: z12.number().int().positive(),
2753
+ social_network_id: z12.number().int().positive().optional().describe("Optional: limit to a specific connected account."),
2754
+ only_unread: z12.boolean().optional().describe("If true, only return conversations with unread messages."),
2755
+ page_size: z12.number().int().positive().max(100).optional()
1871
2756
  }
1872
2757
  }, async ({ company_id, social_network_id, only_unread, page_size }) => {
1873
2758
  const conversations = await client2.listConversations(company_id, {
@@ -1901,8 +2786,8 @@ function registerSocialHubTools(server2, client2, _options) {
1901
2786
  title: "Get messages in a conversation",
1902
2787
  description: "Return the messages within a single conversation, newest first. Use this after list_conversations to read full thread content before deciding on a reply.",
1903
2788
  inputSchema: {
1904
- conversation_id: z11.number().int().positive(),
1905
- page_size: z11.number().int().positive().max(100).optional()
2789
+ conversation_id: z12.number().int().positive(),
2790
+ page_size: z12.number().int().positive().max(100).optional()
1906
2791
  }
1907
2792
  }, async ({ conversation_id, page_size }) => {
1908
2793
  const messages = await client2.listMessages(conversation_id, {
@@ -1921,7 +2806,7 @@ function registerSocialHubTools(server2, client2, _options) {
1921
2806
  title: "Mark a conversation as read",
1922
2807
  description: "Mark a Social Hub conversation as read (clears the unread badge). Use this after processing messages from get_conversation_messages.",
1923
2808
  inputSchema: {
1924
- conversation_id: z11.number().int().positive()
2809
+ conversation_id: z12.number().int().positive()
1925
2810
  }
1926
2811
  }, async ({ conversation_id }) => {
1927
2812
  const updated = await client2.markConversationRead(conversation_id);
@@ -1931,8 +2816,8 @@ function registerSocialHubTools(server2, client2, _options) {
1931
2816
  title: "List messages via the platform-native endpoint (Facebook or Instagram only)",
1932
2817
  description: "Read messages using Followr's platform-specific proxy to the Meta Graph API. Only supported for Facebook and Instagram conversations (other networks like LinkedIn, TikTok, X, Threads, Bluesky return 404 from this proxy and should use get_conversation_messages instead). Returns the raw Graph API shape with created_time, from, id, message, to fields.",
1933
2818
  inputSchema: {
1934
- platform: z11.enum(["facebook", "instagram"]).describe("Only facebook and instagram are supported."),
1935
- conversation_id: z11.number().int().positive()
2819
+ platform: z12.enum(["facebook", "instagram"]).describe("Only facebook and instagram are supported."),
2820
+ conversation_id: z12.number().int().positive()
1936
2821
  }
1937
2822
  }, async ({ platform, conversation_id }) => {
1938
2823
  const messages = await client2.listPlatformMessages(platform, conversation_id);
@@ -1942,9 +2827,9 @@ function registerSocialHubTools(server2, client2, _options) {
1942
2827
  title: "List external users (contacts) in a workspace",
1943
2828
  description: "List external users associated with a workspace. External users are DM senders, followers, or comment authors collected across all connected accounts. Use this to see who has interacted with the brand recently. Internally calls /api/externalUsers (which is the same resource the Followr UI's Contacts page reads from).",
1944
2829
  inputSchema: {
1945
- company_id: z11.number().int().positive(),
1946
- type: z11.string().optional().describe("Optional filter by external user type (e.g. follower, message_sender, comment_author)."),
1947
- page_size: z11.number().int().positive().max(100).optional()
2830
+ company_id: z12.number().int().positive(),
2831
+ type: z12.string().optional().describe("Optional filter by external user type (e.g. follower, message_sender, comment_author)."),
2832
+ page_size: z12.number().int().positive().max(100).optional()
1948
2833
  }
1949
2834
  }, async ({ company_id, type, page_size }) => {
1950
2835
  const contacts = await client2.listExternalUsers(company_id, {
@@ -1972,10 +2857,10 @@ function registerSocialHubTools(server2, client2, _options) {
1972
2857
  title: "List comments on published posts in a workspace",
1973
2858
  description: "Return comments left on the workspace's published posts. Use this for community-moderation workflows: surface new comments, draft replies, escalate negative sentiment. Optionally narrow to a single post via post_id.",
1974
2859
  inputSchema: {
1975
- company_id: z11.number().int().positive(),
1976
- post_id: z11.number().int().positive().optional().describe("Optional: limit to a single post."),
1977
- page_size: z11.number().int().positive().max(100).optional(),
1978
- include: z11.string().optional().describe("Optional include chain (e.g. 'externalUser,post').")
2860
+ company_id: z12.number().int().positive(),
2861
+ post_id: z12.number().int().positive().optional().describe("Optional: limit to a single post."),
2862
+ page_size: z12.number().int().positive().max(100).optional(),
2863
+ include: z12.string().optional().describe("Optional include chain (e.g. 'externalUser,post').")
1979
2864
  }
1980
2865
  }, async ({ company_id, post_id, page_size, include }) => {
1981
2866
  const comments = await client2.listComments(company_id, {
@@ -2000,13 +2885,13 @@ function registerSubscriptionTools(server2, client2, _options) {
2000
2885
  }
2001
2886
 
2002
2887
  // ../mcp-core/dist/tools/tags.js
2003
- import { z as z12 } from "zod";
2888
+ import { z as z13 } from "zod";
2004
2889
  function registerTagTools(server2, client2, _options) {
2005
2890
  server2.registerTool("list_tags", {
2006
2891
  title: "List tags in a workspace",
2007
2892
  description: "List all tags for a Followr workspace. Tags are scoped to a company and used for categorizing PostGroups (and as a workaround for approval status via the 'Approved' / 'Rejected' convention).",
2008
2893
  inputSchema: {
2009
- company_id: z12.number().int().positive()
2894
+ company_id: z13.number().int().positive()
2010
2895
  }
2011
2896
  }, async ({ company_id }) => {
2012
2897
  const tags = await client2.listTags(company_id);
@@ -2023,9 +2908,9 @@ function registerTagTools(server2, client2, _options) {
2023
2908
  title: "Create a tag in a workspace",
2024
2909
  description: "Create a new tag in the specified workspace. Color is optional hex (e.g. #22c55e). Tags are idempotent by convention: caller should list existing first to avoid duplicates.",
2025
2910
  inputSchema: {
2026
- company_id: z12.number().int().positive(),
2027
- name: z12.string().min(1),
2028
- color: z12.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe("Hex color, e.g. #22c55e")
2911
+ company_id: z13.number().int().positive(),
2912
+ name: z13.string().min(1),
2913
+ color: z13.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe("Hex color, e.g. #22c55e")
2029
2914
  }
2030
2915
  }, async ({ company_id, name, color }) => {
2031
2916
  const tag = await client2.createTag({ company_id, name, ...color ? { color } : {} });
@@ -2035,10 +2920,10 @@ function registerTagTools(server2, client2, _options) {
2035
2920
  title: "Update a tag (rename, change color or active state)",
2036
2921
  description: "Patch a tag's name, color, or active flag.",
2037
2922
  inputSchema: {
2038
- tag_id: z12.number().int().positive(),
2039
- name: z12.string().optional(),
2040
- color: z12.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
2041
- active: z12.boolean().optional()
2923
+ tag_id: z13.number().int().positive(),
2924
+ name: z13.string().optional(),
2925
+ color: z13.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
2926
+ active: z13.boolean().optional()
2042
2927
  }
2043
2928
  }, async ({ tag_id, ...patch }) => {
2044
2929
  const tag = await client2.updateTag(tag_id, patch);
@@ -2048,7 +2933,7 @@ function registerTagTools(server2, client2, _options) {
2048
2933
  title: "Delete a tag (destructive)",
2049
2934
  description: "Permanently delete a tag. PostGroups that referenced this tag will keep their tags_ids list with a now-broken reference. Cannot be undone.",
2050
2935
  inputSchema: {
2051
- tag_id: z12.number().int().positive()
2936
+ tag_id: z13.number().int().positive()
2052
2937
  }
2053
2938
  }, async ({ tag_id }) => {
2054
2939
  await client2.deleteTag(tag_id);
@@ -2058,13 +2943,13 @@ function registerTagTools(server2, client2, _options) {
2058
2943
  title: "Find a tag by name or create it if it doesn't exist",
2059
2944
  description: "Idempotent helper. Looks up tags in the workspace by case-insensitive name match. If found, returns its id. If not, creates a new tag with the given name and color.",
2060
2945
  inputSchema: {
2061
- company_id: z12.number().int().positive(),
2062
- name: z12.string().min(1),
2063
- color: z12.string().regex(/^#[0-9a-fA-F]{6}$/).optional()
2946
+ company_id: z13.number().int().positive(),
2947
+ name: z13.string().min(1),
2948
+ color: z13.string().regex(/^#[0-9a-fA-F]{6}$/).optional()
2064
2949
  }
2065
2950
  }, async ({ company_id, name, color }) => {
2066
- const tags = await client2.listTags(company_id);
2067
- const existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase());
2951
+ const matches = await client2.listTags(company_id, { name });
2952
+ const existing = matches.find((t) => t.name.toLowerCase() === name.toLowerCase());
2068
2953
  if (existing) {
2069
2954
  return {
2070
2955
  content: [{ type: "text", text: JSON.stringify({ found: true, tag: existing }, null, 2) }]
@@ -2078,7 +2963,7 @@ function registerTagTools(server2, client2, _options) {
2078
2963
  }
2079
2964
 
2080
2965
  // ../mcp-core/dist/tools/users.js
2081
- import { z as z13 } from "zod";
2966
+ import { z as z14 } from "zod";
2082
2967
  function registerUserTools(server2, client2, _options) {
2083
2968
  server2.registerTool("get_current_user", {
2084
2969
  title: "Get the user that owns the current API token",
@@ -2102,8 +2987,8 @@ function registerUserTools(server2, client2, _options) {
2102
2987
  title: "List users with access to a workspace",
2103
2988
  description: "List Followr users that have access to a specific workspace (team members). Uses the relation filter `companies.id`. Use to map ownership, find collaborators, or check who created a draft.",
2104
2989
  inputSchema: {
2105
- company_id: z13.number().int().positive(),
2106
- page_size: z13.number().int().positive().max(100).optional()
2990
+ company_id: z14.number().int().positive(),
2991
+ page_size: z14.number().int().positive().max(100).optional()
2107
2992
  }
2108
2993
  }, async ({ company_id, page_size }) => {
2109
2994
  const users = await client2.listUsersInCompany(company_id, {
@@ -2126,8 +3011,72 @@ function registerUserTools(server2, client2, _options) {
2126
3011
  });
2127
3012
  }
2128
3013
 
3014
+ // ../mcp-core/dist/tools/validate.js
3015
+ import { z as z15 } from "zod";
3016
+ var NETWORK_ENUM2 = [
3017
+ "medium",
3018
+ "pinterest",
3019
+ "twitter",
3020
+ "facebook",
3021
+ "instagram",
3022
+ "tiktok",
3023
+ "linkedin",
3024
+ "youtube",
3025
+ "threads",
3026
+ "bluesky"
3027
+ ];
3028
+ var PRODUCT_TYPE_ENUM2 = ["feed", "reel", "story", "short"];
3029
+ var AssetSchema = z15.object({
3030
+ id: z15.number().int().positive().optional(),
3031
+ type: z15.enum(["image", "video", "gif"]),
3032
+ width: z15.number().positive().optional(),
3033
+ height: z15.number().positive().optional(),
3034
+ size_bytes: z15.number().nonnegative().optional(),
3035
+ duration_seconds: z15.number().positive().optional()
3036
+ });
3037
+ function registerValidateTools(server2, client2, _options) {
3038
+ server2.registerTool("validate_against_specs", {
3039
+ title: "Validate a Post payload against social network specs (advisory)",
3040
+ description: "Check if a post (caption, assets, preferences) violates any social network rules before publishing. Returns advisory warnings; does NOT block. Use BEFORE expensive operations (generate_avatar_video, image generation, etc.) to avoid wasting credits on outputs the platform will reject. Asset metadata (size_bytes, width, height, duration_seconds) is optional \u2014 provide what you have, the validator skips checks for missing fields.",
3041
+ inputSchema: {
3042
+ company_id: z15.number().int().positive().describe("Followr workspace id. Required to resolve account-specific limits (Twitter verified, TikTok tier)."),
3043
+ network: z15.enum(NETWORK_ENUM2).describe("Target social network."),
3044
+ product_type: z15.enum(PRODUCT_TYPE_ENUM2).describe("Post format. e.g. 'feed' for IG Feed, 'reel' for IG Reels, 'story' for IG Stories, 'short' for YouTube Shorts."),
3045
+ description: z15.string().optional().describe("Post caption / body text."),
3046
+ title: z15.string().optional().describe("Title (medium, pinterest, youtube only)."),
3047
+ link: z15.string().optional().describe("Optional outbound link."),
3048
+ assets: z15.array(AssetSchema).optional().describe("Media to attach. Each asset's metadata is used for size/duration/dimensions/aspect ratio checks."),
3049
+ preferences: z15.record(z15.string(), z15.unknown()).optional().describe("Network-specific extras: board_id (Pinterest), privacy_level (TikTok), category_id (YouTube), media_product_type (FEED/REELS/STORY for IG), etc.")
3050
+ }
3051
+ }, async (input) => {
3052
+ const context = await gatherRuntimeContext(input.company_id, input.network, client2);
3053
+ const spec = getSpec(input.network, input.product_type);
3054
+ const specKey = `${input.network}_${input.product_type}`;
3055
+ const warnings = validateAgainstSpec({
3056
+ network: input.network,
3057
+ product_type: input.product_type,
3058
+ description: input.description,
3059
+ title: input.title,
3060
+ link: input.link,
3061
+ assets: input.assets,
3062
+ preferences: input.preferences
3063
+ }, context);
3064
+ const response = {
3065
+ spec_key: specKey,
3066
+ spec_exists: spec !== null,
3067
+ runtime_context: context,
3068
+ warning_count: warnings.length,
3069
+ warnings,
3070
+ specs_verified_at: getSpecsMeta()?.verified_at ?? null
3071
+ };
3072
+ return {
3073
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
3074
+ };
3075
+ });
3076
+ }
3077
+
2129
3078
  // ../mcp-core/dist/tools/voices.js
2130
- import { z as z14 } from "zod";
3079
+ import { z as z16 } from "zod";
2131
3080
  function sanitizeVoice(voice) {
2132
3081
  const v = voice;
2133
3082
  if (v.company) {
@@ -2141,8 +3090,8 @@ function registerVoiceTools(server2, client2, _options) {
2141
3090
  title: "List voices in a workspace",
2142
3091
  description: "List voice profiles already created in a Followr workspace. Each voice is linked to a TTS provider (typically ElevenLabs) and can be assigned to an avatar or used directly for audio generation. Use this before generate_audio or create_avatar_full_flow to discover available voices.",
2143
3092
  inputSchema: {
2144
- company_id: z14.number().int().positive(),
2145
- page_size: z14.number().int().positive().max(100).optional()
3093
+ company_id: z16.number().int().positive(),
3094
+ page_size: z16.number().int().positive().max(100).optional()
2146
3095
  }
2147
3096
  }, async ({ company_id, page_size }) => {
2148
3097
  const voices = await client2.listVoices(company_id, {
@@ -2169,7 +3118,7 @@ function registerVoiceTools(server2, client2, _options) {
2169
3118
  title: "Get a single voice with its audio sample",
2170
3119
  description: "Fetch one voice by id, with the audio sample hydrated. Use this to confirm a freshly created voice has its sample uploaded, or to retrieve the audio preview URL.",
2171
3120
  inputSchema: {
2172
- voice_id: z14.number().int().positive()
3121
+ voice_id: z16.number().int().positive()
2173
3122
  }
2174
3123
  }, async ({ voice_id }) => {
2175
3124
  const voice = await client2.getVoice(voice_id);
@@ -2179,12 +3128,12 @@ function registerVoiceTools(server2, client2, _options) {
2179
3128
  title: "Browse the ElevenLabs voice catalog with optional filters",
2180
3129
  description: "Returns a page of voices from the ElevenLabs catalog with rich metadata (language, gender, age, accent, use_case, preview_url, social handles). Supports client-side filtering by language, gender, category, and free-text query against name and description. Use this to pick a voice_id before calling create_voice_from_elevenlabs.",
2181
3130
  inputSchema: {
2182
- page: z14.number().int().positive().optional().describe("API page number. 30 voices per page. Default 1."),
2183
- language: z14.string().optional().describe("Filter by ISO 639-1 code (en, es, pt, fr, etc.)."),
2184
- gender: z14.enum(["male", "female", "non-binary"]).optional(),
2185
- category: z14.string().optional().describe("e.g. professional, casual."),
2186
- query: z14.string().optional().describe("Substring match against name or description (case-insensitive)."),
2187
- featured_only: z14.boolean().optional().describe("If true, only return voices marked featured.")
3131
+ page: z16.number().int().positive().optional().describe("API page number. 30 voices per page. Default 1."),
3132
+ language: z16.string().optional().describe("Filter by ISO 639-1 code (en, es, pt, fr, etc.)."),
3133
+ gender: z16.enum(["male", "female", "non-binary"]).optional(),
3134
+ category: z16.string().optional().describe("e.g. professional, casual."),
3135
+ query: z16.string().optional().describe("Substring match against name or description (case-insensitive)."),
3136
+ featured_only: z16.boolean().optional().describe("If true, only return voices marked featured.")
2188
3137
  }
2189
3138
  }, async ({ page, language, gender, category, query, featured_only }) => {
2190
3139
  const all = await client2.listElevenlabsVoices({ ...page ? { page } : {} });
@@ -2225,12 +3174,12 @@ function registerVoiceTools(server2, client2, _options) {
2225
3174
  title: "Create a Followr voice linked to an ElevenLabs voice",
2226
3175
  description: "Create a Voice resource in the workspace that wraps an ElevenLabs voice_id. Required: name, language_code (ISO 639-1), elevenlabs_voice_id (from list_elevenlabs_voices). The voice is usable immediately for generate_audio and create_avatar_full_flow even though the optional audio sample upload is not part of this tool (voice.audio will be null until manually uploaded). Cannot be undone via the MCP (voice resource persists).",
2227
3176
  inputSchema: {
2228
- company_id: z14.number().int().positive(),
2229
- name: z14.string().min(1).max(50).describe("Human-readable voice name."),
2230
- language_code: z14.string().min(2).max(5).describe("ISO 639-1 code, e.g. en, es, pt, fr."),
2231
- elevenlabs_voice_id: z14.string().min(1).describe("ElevenLabs voice_id from list_elevenlabs_voices."),
2232
- accent: z14.string().optional(),
2233
- description: z14.string().optional()
3177
+ company_id: z16.number().int().positive(),
3178
+ name: z16.string().min(1).max(50).describe("Human-readable voice name."),
3179
+ language_code: z16.string().min(2).max(5).describe("ISO 639-1 code, e.g. en, es, pt, fr."),
3180
+ elevenlabs_voice_id: z16.string().min(1).describe("ElevenLabs voice_id from list_elevenlabs_voices."),
3181
+ accent: z16.string().optional(),
3182
+ description: z16.string().optional()
2234
3183
  }
2235
3184
  }, async ({ company_id, name, language_code, elevenlabs_voice_id, accent, description }) => {
2236
3185
  const voice = await client2.createVoice(company_id, {
@@ -2246,15 +3195,15 @@ function registerVoiceTools(server2, client2, _options) {
2246
3195
  }
2247
3196
 
2248
3197
  // ../mcp-core/dist/tools/workspace-settings.js
2249
- import { z as z15 } from "zod";
3198
+ import { z as z17 } from "zod";
2250
3199
  function registerWorkspaceSettingsTools(server2, client2, _options) {
2251
3200
  server2.registerTool("update_webhook_url", {
2252
3201
  title: "Update the workspace webhook URL and secret",
2253
3202
  description: "Set or rotate the workspace's outbound webhook (used when a Post is published or fails). Both fields can be cleared with empty string. The secret never round-trips: stored and signed against, never echoed back; the response only confirms the URL.",
2254
3203
  inputSchema: {
2255
- company_id: z15.number().int().positive(),
2256
- webhook_posts_url: z15.string().describe("Destination URL for outbound events. Empty string clears."),
2257
- webhook_secret: z15.string().optional().describe("Shared secret used to sign payloads. Optional. Empty string clears.")
3204
+ company_id: z17.number().int().positive(),
3205
+ webhook_posts_url: z17.string().describe("Destination URL for outbound events. Empty string clears."),
3206
+ webhook_secret: z17.string().optional().describe("Shared secret used to sign payloads. Optional. Empty string clears.")
2258
3207
  }
2259
3208
  }, async ({ company_id, webhook_posts_url, webhook_secret }) => {
2260
3209
  const updated = await client2.updateCompany(company_id, {
@@ -2278,8 +3227,8 @@ function registerWorkspaceSettingsTools(server2, client2, _options) {
2278
3227
  title: "Set the workspace's left-menu visibility flags",
2279
3228
  description: "Update which menu sections are visible in the Followr SPA for a workspace. The field `menu_visibility` is a map of section name to boolean. REPLACE semantics: the map passed becomes the new value. Useful for whitelabel installs that want to hide irrelevant sections.",
2280
3229
  inputSchema: {
2281
- company_id: z15.number().int().positive(),
2282
- menu_visibility: z15.record(z15.boolean()).describe("Full map of section_name -> visible. REPLACE, not merge.")
3230
+ company_id: z17.number().int().positive(),
3231
+ menu_visibility: z17.record(z17.boolean()).describe("Full map of section_name -> visible. REPLACE, not merge.")
2283
3232
  }
2284
3233
  }, async ({ company_id, menu_visibility }) => {
2285
3234
  const updated = await client2.updateCompany(company_id, {
@@ -2535,17 +3484,17 @@ function registerFollowrResources(server2, client2, _options) {
2535
3484
  }
2536
3485
 
2537
3486
  // ../mcp-core/dist/prompts/index.js
2538
- import { z as z16 } from "zod";
3487
+ import { z as z18 } from "zod";
2539
3488
  function registerFollowrPrompts(server2, _client, _options) {
2540
3489
  server2.registerPrompt("followr.weekly-brief", {
2541
3490
  title: "Generate a week of scheduled posts from a brief",
2542
3491
  description: "Take a free-form weekly brief and produce a full week of scheduled posts in the workspace. Anchors to the workspace's brand voice, picks suitable networks, drafts copy + images, and schedules across the week.",
2543
3492
  argsSchema: {
2544
- company_id: z16.string().describe("Followr company id."),
2545
- brief: z16.string().describe("Free-form brief for the week. Topics, hooks, must-mentions, banned terms, target audience notes."),
2546
- networks: z16.string().optional().describe("Comma-separated network types (instagram, facebook, etc). Defaults to all connected accounts."),
2547
- posts_per_day: z16.string().optional().describe("Integer; default 1."),
2548
- starting_iso_date: z16.string().optional().describe("ISO 8601 date for day 1 of the week. Defaults to tomorrow at 10:00 in the workspace timezone.")
3493
+ company_id: z18.string().describe("Followr company id."),
3494
+ brief: z18.string().describe("Free-form brief for the week. Topics, hooks, must-mentions, banned terms, target audience notes."),
3495
+ networks: z18.string().optional().describe("Comma-separated network types (instagram, facebook, etc). Defaults to all connected accounts."),
3496
+ posts_per_day: z18.string().optional().describe("Integer; default 1."),
3497
+ starting_iso_date: z18.string().optional().describe("ISO 8601 date for day 1 of the week. Defaults to tomorrow at 10:00 in the workspace timezone.")
2549
3498
  }
2550
3499
  }, ({ company_id, brief, networks, posts_per_day, starting_iso_date }) => ({
2551
3500
  messages: [
@@ -2578,14 +3527,14 @@ Procedure:
2578
3527
  title: "Launch a multi-network campaign end-to-end",
2579
3528
  description: "Spin up a full campaign in one shot: hashtag/tag taxonomy, brand-aligned hero asset, teaser + launch + follow-up posts across selected networks, scheduled around the launch date.",
2580
3529
  argsSchema: {
2581
- company_id: z16.string().describe("Followr company id."),
2582
- campaign_name: z16.string(),
2583
- launch_iso_date: z16.string().describe("ISO 8601 launch datetime in UTC."),
2584
- networks: z16.string().describe("Comma-separated network types."),
2585
- product_or_offer: z16.string().describe("What the campaign is selling or announcing."),
2586
- primary_cta: z16.string().describe("The single CTA (e.g. 'Sign up at acme.com/launch')."),
2587
- teaser_days_before: z16.string().optional().describe("How many days before launch to start teasers. Default 3."),
2588
- followup_days_after: z16.string().optional().describe("How many days after launch to keep follow-up posts. Default 7.")
3530
+ company_id: z18.string().describe("Followr company id."),
3531
+ campaign_name: z18.string(),
3532
+ launch_iso_date: z18.string().describe("ISO 8601 launch datetime in UTC."),
3533
+ networks: z18.string().describe("Comma-separated network types."),
3534
+ product_or_offer: z18.string().describe("What the campaign is selling or announcing."),
3535
+ primary_cta: z18.string().describe("The single CTA (e.g. 'Sign up at acme.com/launch')."),
3536
+ teaser_days_before: z18.string().optional().describe("How many days before launch to start teasers. Default 3."),
3537
+ followup_days_after: z18.string().optional().describe("How many days after launch to keep follow-up posts. Default 7.")
2589
3538
  }
2590
3539
  }, ({ company_id, campaign_name, launch_iso_date, networks, product_or_offer, primary_cta, teaser_days_before, followup_days_after }) => ({
2591
3540
  messages: [
@@ -2619,13 +3568,13 @@ Procedure:
2619
3568
  title: "Generate an avatar video series on a single topic",
2620
3569
  description: "Produce N short avatar videos on a topic. One script per episode, one lipsync render per episode, all scheduled across N consecutive days.",
2621
3570
  argsSchema: {
2622
- company_id: z16.string(),
2623
- avatar_id: z16.string().describe("Avatar to use (from list_avatars or create_avatar_full_flow)."),
2624
- topic: z16.string().describe("The topic / theme of the series."),
2625
- episode_count: z16.string().describe("Number of episodes (1-30 reasonable)."),
2626
- networks: z16.string().describe("Comma-separated network types. Vertical 9:16 will be used for Reels / Shorts."),
2627
- starting_iso_date: z16.string().describe("ISO 8601 datetime for episode 1."),
2628
- cadence: z16.string().optional().describe("daily | every-other-day | weekly. Default daily.")
3571
+ company_id: z18.string(),
3572
+ avatar_id: z18.string().describe("Avatar to use (from list_avatars or create_avatar_full_flow)."),
3573
+ topic: z18.string().describe("The topic / theme of the series."),
3574
+ episode_count: z18.string().describe("Number of episodes (1-30 reasonable)."),
3575
+ networks: z18.string().describe("Comma-separated network types. Vertical 9:16 will be used for Reels / Shorts."),
3576
+ starting_iso_date: z18.string().describe("ISO 8601 datetime for episode 1."),
3577
+ cadence: z18.string().optional().describe("daily | every-other-day | weekly. Default daily.")
2629
3578
  }
2630
3579
  }, ({ company_id, avatar_id, topic, episode_count, networks, starting_iso_date, cadence }) => ({
2631
3580
  messages: [
@@ -2656,10 +3605,10 @@ Procedure:
2656
3605
  title: "Draft three crisis-response variants for review",
2657
3606
  description: "Quickly produce three differently-toned crisis-response post drafts (apology, clarification, deflection) staged as drafts (not auto-publish) so a human can approve one.",
2658
3607
  argsSchema: {
2659
- company_id: z16.string(),
2660
- situation: z16.string().describe("What happened, key facts, sensitivity notes."),
2661
- networks: z16.string().describe("Comma-separated network types."),
2662
- urgency_window_hours: z16.string().optional().describe("How many hours until the post should ideally go live. Default 4.")
3608
+ company_id: z18.string(),
3609
+ situation: z18.string().describe("What happened, key facts, sensitivity notes."),
3610
+ networks: z18.string().describe("Comma-separated network types."),
3611
+ urgency_window_hours: z18.string().optional().describe("How many hours until the post should ideally go live. Default 4.")
2663
3612
  }
2664
3613
  }, ({ company_id, situation, networks, urgency_window_hours }) => ({
2665
3614
  messages: [
@@ -2693,11 +3642,11 @@ Procedure:
2693
3642
  title: "Repurpose a single URL into multi-network posts",
2694
3643
  description: "Given a URL (blog post, news article, video), produce network-tailored versions across the requested networks, with the right format (carousel, single image, video, blog post) per network.",
2695
3644
  argsSchema: {
2696
- company_id: z16.string(),
2697
- source_url: z16.string().describe("The URL to repurpose (article, blog post, video)."),
2698
- networks: z16.string().describe("Comma-separated networks."),
2699
- publish_at: z16.string().optional().describe("ISO 8601 datetime. If omitted, leave as draft."),
2700
- include_visual: z16.string().optional().describe("If 'true', generate a fresh image per network format. Default 'true'.")
3645
+ company_id: z18.string(),
3646
+ source_url: z18.string().describe("The URL to repurpose (article, blog post, video)."),
3647
+ networks: z18.string().describe("Comma-separated networks."),
3648
+ publish_at: z18.string().optional().describe("ISO 8601 datetime. If omitted, leave as draft."),
3649
+ include_visual: z18.string().optional().describe("If 'true', generate a fresh image per network format. Default 'true'.")
2701
3650
  }
2702
3651
  }, ({ company_id, source_url, networks, publish_at, include_visual }) => ({
2703
3652
  messages: [
@@ -2735,6 +3684,8 @@ Procedure:
2735
3684
  function registerFollowrTools(server2, client2, options = {}) {
2736
3685
  registerCompanyTools(server2, client2, options);
2737
3686
  registerPostGroupTools(server2, client2, options);
3687
+ registerPostTools(server2, client2, options);
3688
+ registerValidateTools(server2, client2, options);
2738
3689
  registerTagTools(server2, client2, options);
2739
3690
  registerAiResultsTools(server2, client2, options);
2740
3691
  registerAvatarTools(server2, client2, options);
@@ -2782,7 +3733,7 @@ var client = new FollowrClient({
2782
3733
  });
2783
3734
  var server = new McpServer({
2784
3735
  name: "followr",
2785
- version: "0.1.0"
3736
+ version: "0.2.0"
2786
3737
  });
2787
3738
  registerFollowrTools(server, client);
2788
3739
  var transport = new StdioServerTransport();