@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 +90 -0
- package/dist/chunk-GXJ2ADGM.js +182 -0
- package/dist/http.js +85 -0
- package/dist/index.js +28 -0
- package/package.json +25 -0
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
|
+
}
|