@anaella/mcp 0.1.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 ADDED
@@ -0,0 +1,90 @@
1
+ # @anaella/mcp
2
+
3
+ An MCP (Model Context Protocol) server for [anaella.com](https://anaella.com) — schedule and
4
+ publish social posts to your connected channels via the anaella `/v1` API, from any MCP client
5
+ (Claude, Claude Code, Cursor, etc.).
6
+
7
+ It's hosted at `https://api.anaella.com/mcp` (Streamable HTTP), or you can run it locally over
8
+ stdio.
9
+
10
+ ## Client configuration
11
+
12
+ Remote (recommended):
13
+ ```json
14
+ { "mcpServers": { "anaella": {
15
+ "url": "https://api.anaella.com/mcp",
16
+ "headers": { "Authorization": "Bearer ana_..." }
17
+ }}}
18
+ ```
19
+
20
+ Local (stdio):
21
+ ```json
22
+ { "mcpServers": { "anaella": {
23
+ "command": "npx", "args": ["-y", "@anaella/mcp"],
24
+ "env": { "ANAELLA_API_KEY": "ana_..." }
25
+ }}}
26
+ ```
27
+
28
+ Get an API key (`ana_...`) from your anaella organization settings.
29
+
30
+ ## Tools
31
+
32
+ - **list_channels** — List the connected social channels for your organization. Call this
33
+ first to get the channel ids you pass to `create_post`.
34
+ - **create_post** — Create and (optionally) publish a post to one or more channels.
35
+ - `channels: string[]` (required) — channel ids from `list_channels`
36
+ - `text?: string` — post text / caption
37
+ - `when?: string` — `"now"` publishes immediately, `"draft"` (default) saves without
38
+ publishing, or an ISO-8601 timestamp schedules it
39
+ - `media?: { url: string }[]` — public file URLs the API downloads
40
+ - `type?: "post" | "reel" | "thread" | "story"` — defaults to `"post"`
41
+ - `firstComment?: string`
42
+ - Publishing is asynchronous — poll `get_post` for the final per-channel status.
43
+ - **list_posts** — List recent posts for your organization with their per-channel status.
44
+ - `limit?: number` (1-100), `cursor?: number`
45
+ - **get_post** — Get one post by id, including its per-channel publish status.
46
+ - `id: string`
47
+ - **delete_post** — Delete a draft or cancel a scheduled post by id.
48
+ - `id: string`
49
+
50
+ ## Environment variables
51
+
52
+ - `ANAELLA_API_KEY` — required for stdio mode; your `ana_...` API key.
53
+ - `ANAELLA_API_URL` — base URL for the anaella API. Defaults to `https://api.anaella.com`.
54
+
55
+ In remote (Streamable HTTP) mode the API key is instead passed per-request as an
56
+ `Authorization: Bearer ana_...` header.
57
+
58
+ ## Local development
59
+
60
+ ```bash
61
+ bun install
62
+ bun run build # tsup -> dist/index.js (stdio) + dist/http.js (Streamable HTTP)
63
+ bun run dev # tsup --watch
64
+ bun test
65
+ ```
66
+
67
+ ## Smoke test
68
+
69
+ `bun run smoke` runs an opt-in, live smoke test against a real anaella API: it lists your
70
+ channels, creates a **draft** post (never publishes), then deletes it. It's a no-op unless
71
+ `ANAELLA_SMOKE_KEY` is set to a real `ana_...` key.
72
+
73
+ ```bash
74
+ ANAELLA_SMOKE_KEY=ana_... ANAELLA_API_URL=https://api.anaella.com bun run smoke
75
+ ```
76
+
77
+ ## Docker
78
+
79
+ Build context is this package directory (`packages/mcp`) — the image is self-contained and has
80
+ no `@anaella/*` workspace dependencies to resolve.
81
+
82
+ ```bash
83
+ cd packages/mcp
84
+ docker build -t anaella-mcp:local .
85
+ docker run -d -p 3000:3000 -e ANAELLA_API_URL=https://api.anaella.com anaella-mcp:local
86
+ curl http://localhost:3000/health # -> ok
87
+ ```
88
+
89
+ The container runs the Streamable HTTP server (`node dist/http.js`), exposing `GET /health`
90
+ and `POST /mcp` (requires `Authorization: Bearer ana_...`).
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/client.ts
4
+ var AnaellaApiError = class extends Error {
5
+ constructor(status, code, message, details) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ this.details = details;
10
+ this.name = "AnaellaApiError";
11
+ }
12
+ status;
13
+ code;
14
+ details;
15
+ };
16
+ var AnaellaClient = class {
17
+ apiKey;
18
+ baseUrl;
19
+ constructor(opts) {
20
+ this.apiKey = opts.apiKey;
21
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
22
+ }
23
+ async request(method, path, body) {
24
+ let res;
25
+ try {
26
+ res = await fetch(this.baseUrl + path, {
27
+ method,
28
+ headers: {
29
+ authorization: `Bearer ${this.apiKey}`,
30
+ ...body === void 0 ? {} : { "content-type": "application/json" }
31
+ },
32
+ body: body === void 0 ? void 0 : JSON.stringify(body)
33
+ });
34
+ } catch (e) {
35
+ throw new AnaellaApiError(
36
+ 0,
37
+ "network_error",
38
+ `Could not reach anaella API: ${e.message}`
39
+ );
40
+ }
41
+ const raw = await res.text();
42
+ let json;
43
+ try {
44
+ json = raw ? JSON.parse(raw) : void 0;
45
+ } catch {
46
+ json = void 0;
47
+ }
48
+ if (!res.ok) {
49
+ const env = json?.error;
50
+ throw new AnaellaApiError(
51
+ res.status,
52
+ env?.code ?? "error",
53
+ env?.message ?? `HTTP ${res.status}${raw ? `: ${raw.slice(0, 200)}` : ""}`,
54
+ env?.details
55
+ );
56
+ }
57
+ return json;
58
+ }
59
+ listChannels() {
60
+ return this.request("GET", "/v1/channels");
61
+ }
62
+ createPost(body) {
63
+ return this.request("POST", "/v1/posts", body);
64
+ }
65
+ listPosts(opts = {}) {
66
+ const q = new URLSearchParams();
67
+ if (opts.limit !== void 0) q.set("limit", String(opts.limit));
68
+ if (opts.cursor !== void 0) q.set("cursor", String(opts.cursor));
69
+ const qs = q.toString();
70
+ return this.request("GET", `/v1/posts${qs ? `?${qs}` : ""}`);
71
+ }
72
+ getPost(id) {
73
+ return this.request("GET", `/v1/posts/${encodeURIComponent(id)}`);
74
+ }
75
+ deletePost(id) {
76
+ return this.request("DELETE", `/v1/posts/${encodeURIComponent(id)}`);
77
+ }
78
+ };
79
+
80
+ // src/config.ts
81
+ function loadConfig(env) {
82
+ return {
83
+ apiKey: env.ANAELLA_API_KEY,
84
+ apiUrl: env.ANAELLA_API_URL?.replace(/\/+$/, "") || "https://api.anaella.com"
85
+ };
86
+ }
87
+
88
+ // src/server.ts
89
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
90
+
91
+ // src/tools.ts
92
+ import { z } from "zod";
93
+ var ok = (data) => ({
94
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
95
+ });
96
+ var fail = (err) => {
97
+ if (err instanceof AnaellaApiError) {
98
+ const detail = err.details?.length ? `
99
+ Details: ${JSON.stringify(err.details)}` : "";
100
+ return {
101
+ content: [{ type: "text", text: `Error [${err.code}] (HTTP ${err.status}): ${err.message}${detail}` }],
102
+ isError: true
103
+ };
104
+ }
105
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
106
+ };
107
+ var wrap = (fn) => async (args) => {
108
+ try {
109
+ return ok(await fn(args));
110
+ } catch (err) {
111
+ return fail(err);
112
+ }
113
+ };
114
+ function buildTools(client) {
115
+ return [
116
+ {
117
+ name: "list_channels",
118
+ config: {
119
+ description: "List the connected social channels for your organization. Call this FIRST to get the channel ids you pass to create_post.",
120
+ inputSchema: {}
121
+ },
122
+ handler: wrap(() => client.listChannels())
123
+ },
124
+ {
125
+ name: "create_post",
126
+ config: {
127
+ description: 'Create and (optionally) publish a post to one or more channels. Get channel ids from list_channels first. `when`: "now" publishes immediately, "draft" (default) saves without publishing, or an ISO-8601 timestamp schedules it. `media` are PUBLIC file URLs the API downloads. Publishing is asynchronous \u2014 poll get_post for the final per-channel status.',
128
+ inputSchema: {
129
+ channels: z.array(z.string()).min(1).describe("Channel ids from list_channels"),
130
+ text: z.string().optional().describe("Post text / caption"),
131
+ when: z.string().optional().describe('"now" | "draft" | ISO-8601 timestamp'),
132
+ media: z.array(z.object({ url: z.string().url() })).optional().describe("Public media URLs"),
133
+ type: z.enum(["post", "reel", "thread", "story"]).optional().describe('Defaults to "post"'),
134
+ firstComment: z.string().max(2e3).optional()
135
+ }
136
+ },
137
+ handler: wrap((args) => client.createPost(args))
138
+ },
139
+ {
140
+ name: "list_posts",
141
+ config: {
142
+ description: "List recent posts for your organization with their per-channel status.",
143
+ inputSchema: {
144
+ limit: z.number().int().min(1).max(100).optional(),
145
+ cursor: z.number().int().min(0).optional()
146
+ }
147
+ },
148
+ handler: wrap((args) => client.listPosts(args))
149
+ },
150
+ {
151
+ name: "get_post",
152
+ config: {
153
+ description: "Get one post by id, including its per-channel publish status. Use to poll after create_post.",
154
+ inputSchema: { id: z.string() }
155
+ },
156
+ handler: wrap((args) => client.getPost(args.id))
157
+ },
158
+ {
159
+ name: "delete_post",
160
+ config: {
161
+ description: "Delete a draft or cancel a scheduled post by id.",
162
+ inputSchema: { id: z.string() }
163
+ },
164
+ handler: wrap((args) => client.deletePost(args.id))
165
+ }
166
+ ];
167
+ }
168
+
169
+ // src/server.ts
170
+ function buildServer(client) {
171
+ const server = new McpServer({ name: "anaella", version: "0.1.0" });
172
+ for (const tool of buildTools(client)) {
173
+ server.registerTool(tool.name, tool.config, tool.handler);
174
+ }
175
+ return server;
176
+ }
177
+
178
+ export {
179
+ AnaellaClient,
180
+ loadConfig,
181
+ buildServer
182
+ };
package/dist/http.js ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ AnaellaClient,
4
+ buildServer,
5
+ loadConfig
6
+ } from "./chunk-GXJ2ADGM.js";
7
+
8
+ // src/http.ts
9
+ import http from "http";
10
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11
+ function readBody(req) {
12
+ return new Promise((resolve) => {
13
+ const chunks = [];
14
+ req.on("data", (c) => chunks.push(c));
15
+ req.on("end", () => {
16
+ const raw = Buffer.concat(chunks).toString("utf8");
17
+ if (!raw) return resolve(void 0);
18
+ try {
19
+ resolve(JSON.parse(raw));
20
+ } catch {
21
+ resolve(void 0);
22
+ }
23
+ });
24
+ req.on("error", () => resolve(void 0));
25
+ });
26
+ }
27
+ function sendJson(res, status, body) {
28
+ res.writeHead(status, { "content-type": "application/json" });
29
+ res.end(JSON.stringify(body));
30
+ }
31
+ function createHttpServer(apiUrl) {
32
+ return http.createServer(async (req, res) => {
33
+ const url = req.url ?? "";
34
+ if (req.method === "GET" && url === "/health") {
35
+ res.writeHead(200, { "content-type": "text/plain" });
36
+ res.end("ok");
37
+ return;
38
+ }
39
+ if (!url.startsWith("/mcp")) {
40
+ sendJson(res, 404, { error: { code: "not_found", message: "Not found" } });
41
+ return;
42
+ }
43
+ const authz = req.headers.authorization ?? "";
44
+ if (!/^Bearer\s+ana_/i.test(authz)) {
45
+ sendJson(res, 401, {
46
+ error: { code: "unauthorized", message: "Authorization: Bearer ana_\u2026 is required" }
47
+ });
48
+ return;
49
+ }
50
+ const apiKey = authz.replace(/^Bearer\s+/i, "");
51
+ const body = req.method === "POST" ? await readBody(req) : void 0;
52
+ const client = new AnaellaClient({ apiKey, baseUrl: apiUrl });
53
+ const server = buildServer(client);
54
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
55
+ res.on("close", () => {
56
+ transport.close();
57
+ server.close();
58
+ });
59
+ try {
60
+ await server.connect(transport);
61
+ await transport.handleRequest(req, res, body);
62
+ } catch (err) {
63
+ process.stderr.write(`[anaella-mcp] request error: ${err.message}
64
+ `);
65
+ if (!res.headersSent) {
66
+ sendJson(res, 500, { error: { code: "internal", message: "Internal error" } });
67
+ }
68
+ }
69
+ });
70
+ }
71
+ function main() {
72
+ const { apiUrl } = loadConfig(process.env);
73
+ const port = Number(process.env.PORT) || 3e3;
74
+ const server = createHttpServer(apiUrl);
75
+ server.listen(port, () => {
76
+ process.stderr.write(`[anaella-mcp] HTTP server listening on :${port} \u2192 ${apiUrl}
77
+ `);
78
+ });
79
+ }
80
+ if (process.argv[1] && (process.argv[1].endsWith("http.ts") || process.argv[1].endsWith("http.js"))) {
81
+ main();
82
+ }
83
+ export {
84
+ createHttpServer
85
+ };
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ AnaellaClient,
4
+ buildServer,
5
+ loadConfig
6
+ } from "./chunk-GXJ2ADGM.js";
7
+
8
+ // src/index.ts
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ async function main() {
11
+ const { apiKey, apiUrl } = loadConfig(process.env);
12
+ if (!apiKey) {
13
+ process.stderr.write(
14
+ "[anaella-mcp] ANAELLA_API_KEY is required (a developer key like ana_\u2026).\n"
15
+ );
16
+ process.exit(1);
17
+ }
18
+ const client = new AnaellaClient({ apiKey, baseUrl: apiUrl });
19
+ const server = buildServer(client);
20
+ await server.connect(new StdioServerTransport());
21
+ process.stderr.write(`[anaella-mcp] stdio server ready \u2192 ${apiUrl}
22
+ `);
23
+ }
24
+ main().catch((err) => {
25
+ process.stderr.write(`[anaella-mcp] fatal: ${err.message}
26
+ `);
27
+ process.exit(1);
28
+ });
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@anaella/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for anaella.com — schedule and publish social posts via the /v1 API.",
5
+ "type": "module",
6
+ "bin": { "anaella-mcp": "dist/index.js" },
7
+ "files": ["dist"],
8
+ "publishConfig": { "access": "public" },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "prepublishOnly": "npm run build",
12
+ "dev": "tsup --watch",
13
+ "test": "bun test",
14
+ "smoke": "bun run src/smoke.ts"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.29.0",
18
+ "zod": "^3.25.76"
19
+ },
20
+ "devDependencies": {
21
+ "tsup": "^8.0.0",
22
+ "typescript": "^5",
23
+ "@types/node": "^22"
24
+ }
25
+ }