@postsider/mcp 0.0.0-bootstrap.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,190 @@
1
+ # @postsider/mcp
2
+
3
+ MCP (Model Context Protocol) server that gives AI agents selected access to a
4
+ PostSider instance through its public API. Works with Claude Code, Claude
5
+ Desktop, Codex, Cursor, and any MCP-compatible client. Talks to cloud
6
+ (`api.postsider.com`) or a self-hosted instance, authenticated with an
7
+ organization API key.
8
+
9
+ PostSider is a shared operational calendar: the agent prepares and schedules
10
+ work, and a human reviews what actually goes live.
11
+
12
+ ## Install
13
+
14
+ ### 1. Create an API key
15
+
16
+ In PostSider, open **Settings -> API** and generate an organization API key.
17
+
18
+ ### 2. Connect your client
19
+
20
+ Claude Code:
21
+
22
+ ```bash
23
+ claude mcp add postsider -e POSTSIDER_API_KEY=your_api_key -- npx -y @postsider/mcp
24
+ ```
25
+
26
+ Claude Desktop or any generic MCP client:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "postsider": {
32
+ "command": "npx",
33
+ "args": ["-y", "@postsider/mcp"],
34
+ "env": {
35
+ "POSTSIDER_API_KEY": "your_api_key"
36
+ }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ ### 3. Verify with a read-only prompt
43
+
44
+ Ask your agent, and confirm it answers without changing anything:
45
+
46
+ ```text
47
+ List my connected PostSider channels. Do not create or modify anything.
48
+ ```
49
+
50
+ Then:
51
+
52
+ ```text
53
+ Show my PostSider calendar for the next 14 days. Do not create or modify anything.
54
+ ```
55
+
56
+ ### 4. Only then prepare a draft
57
+
58
+ ```text
59
+ Create one draft only for the selected channel. Show me the exact content first. Do not publish it.
60
+ ```
61
+
62
+ ### From source
63
+
64
+ ```bash
65
+ pnpm --filter @postsider/mcp build
66
+ ```
67
+
68
+ Then point your client at the built entrypoint:
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "postsider": {
74
+ "command": "node",
75
+ "args": ["/absolute/path/to/apps/mcp/dist/index.js"],
76
+ "env": {
77
+ "POSTSIDER_API_KEY": "your_api_key",
78
+ "POSTSIDER_API_URL": "https://api.postsider.com"
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ### Claude Code plugin
86
+
87
+ The package directory is also a Claude Code plugin: `.claude-plugin/plugin.json`
88
+ declares the API key as a secret user setting, `.mcp.json` starts this server at
89
+ the pinned released version, and `skills/postsider-workflow/SKILL.md` teaches the
90
+ read-first, draft-first workflow. The repository root is a plugin marketplace, so
91
+ the plugin installs from the repository directly:
92
+
93
+ ```bash
94
+ claude plugin marketplace add lumizone/postsider
95
+ claude plugin install postsider@postsider
96
+ ```
97
+
98
+ Installation stores the key outside the plugin: pass it at install time with
99
+ `--config api_key=your_api_key`, or set it afterwards with
100
+ `/plugin configure postsider@postsider` inside Claude Code. The key is never
101
+ written into the plugin directory.
102
+
103
+ ## What the agent can do
104
+
105
+ 19 tools, all prefixed `postsider_`.
106
+
107
+ ### Read
108
+
109
+ | Tool | Action |
110
+ |------|--------|
111
+ | `postsider_list_channels` | List connected social channels (ids, names, platforms). Call this first to get channel ids. |
112
+ | `postsider_get_agency_overview` | Org-wide overview: clients, channels, queued, drafts, published, errors, pending approvals. |
113
+ | `postsider_get_customer_report` | The same report scoped to one customer. |
114
+ | `postsider_list_groups` | List channel groups. |
115
+ | `postsider_find_slot` | Next free queue slot for a channel, in UTC. |
116
+ | `postsider_list_posts` | List posts in a date range. |
117
+ | `postsider_get_post` | Full post details, including the publish error if any. |
118
+ | `postsider_get_post_missing_fields` | Per-channel validation problems, so they can be fixed before publishing. |
119
+ | `postsider_get_post_analytics` | Analytics for a single post. |
120
+ | `postsider_get_channel_analytics` | Account-level analytics for a channel. |
121
+ | `postsider_get_notifications` | Recent notifications: publish failures, channels needing reconnection. |
122
+ | `postsider_get_publishing_state` | Whether publishing is active or paused for the organization. |
123
+ | `postsider_get_approval_status` | Approval status of a draft, including a reviewer note when rejected. |
124
+
125
+ ### Write
126
+
127
+ | Tool | Action | Risk |
128
+ |------|--------|------|
129
+ | `postsider_create_post` | Create a post as a draft, a scheduled post, or an immediate publish across channels. | Creates content and can publish |
130
+ | `postsider_update_post_status` | Move a post between `draft` and `schedule`. | Reversible |
131
+ | `postsider_request_approval` | Send a draft into the human approval queue. | Reversible |
132
+ | `postsider_upload_media_from_url` | Import media into the library from a public HTTPS URL. | Creates media |
133
+ | `postsider_delete_post` | Permanently delete a post **and every other channel version of it** (one group). Read the post first. | Destructive |
134
+ | `postsider_pause_publishing` | Emergency stop for the whole organization. Resuming is human-only. | Destructive |
135
+
136
+ Every tool declares `readOnlyHint`, `destructiveHint`, `idempotentHint` and
137
+ `openWorldHint` explicitly, so a client can tell a read from a kill switch.
138
+
139
+ ## Configuration
140
+
141
+ | Variable | Required | Default | Description |
142
+ |----------|----------|---------|-------------|
143
+ | `POSTSIDER_API_KEY` | yes | - | Organization API key (Settings -> API) |
144
+ | `POSTSIDER_API_URL` | no | `https://api.postsider.com` | Instance base URL |
145
+
146
+ `POSTSIDER_API_URL` must be HTTPS. Plain HTTP is accepted only on loopback
147
+ (`localhost`, `127.0.0.1`, `[::1]`) for local development.
148
+
149
+ For a self-hosted instance, point `POSTSIDER_API_URL` at where the public API is
150
+ served. Behind the bundled nginx the API lives under `/api`, so use your domain
151
+ plus `/api`, e.g. `https://social.example.com/api`. The server appends
152
+ `/public/v1` to whatever you set.
153
+
154
+ ## Safety
155
+
156
+ - The API key is read from the environment and sent only to the configured
157
+ PostSider API URL. It is never printed, logged, or included in an error.
158
+ - Credentials embedded in `POSTSIDER_API_URL` are rejected.
159
+ - Redirects are refused rather than followed: a redirect would move an
160
+ authenticated request off the configured origin, so the client fails with an
161
+ actionable message instead.
162
+ - Requests time out after 30 seconds, and a failing response body is read up to
163
+ 8 KB before being reported.
164
+ - Tool arguments are validated locally, before any network call: ids must be
165
+ non-empty, dates must be real ISO 8601 dates (`2026-02-30` is refused, not sent
166
+ to the API), post status `draft` or `schedule`, and media imports require a
167
+ public HTTPS URL.
168
+ - A missing API key exits 1 with an explanatory message on stderr. stdout carries
169
+ protocol frames only.
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ pnpm --filter @postsider/mcp typecheck
175
+ pnpm --filter @postsider/mcp typecheck:tests
176
+ pnpm --filter @postsider/mcp test
177
+ pnpm --filter @postsider/mcp build
178
+ pnpm --filter @postsider/mcp version:gate
179
+ pnpm --filter @postsider/mcp validate:registry
180
+ pnpm --filter @postsider/mcp smoke:tarball
181
+ claude plugin validate --strict apps/mcp
182
+ ```
183
+
184
+ ## Design
185
+
186
+ This server is a thin wrapper over selected `/public/v1` REST API endpoints,
187
+ authenticated with the raw API key. It has no dependency on the backend and
188
+ pulls in only `@modelcontextprotocol/sdk` and `zod`, so it stays lean and easy to
189
+ maintain. All tool registrations live in a single factory, so the stdio
190
+ transport today and any future transport share one agent-facing surface.
package/dist/client.js ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The PostSider public API client: base URL validation, bounded requests, and
3
+ * actionable errors. `server.ts` builds one per request from the organization
4
+ * API key.
5
+ */
6
+ /**
7
+ * Hosts allowed to use plain HTTP: loopback only, for local development and
8
+ * self-hosting. `URL.hostname` keeps the brackets on an IPv6 literal.
9
+ */
10
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
11
+ /**
12
+ * Validate `POSTSIDER_API_URL` and normalize it to the `<base>/public/v1` prefix.
13
+ *
14
+ * The public API is served over HTTPS. Plain HTTP is accepted only on loopback,
15
+ * and credentials embedded in the URL are always rejected. Every message names
16
+ * POSTSIDER_API_URL so the user knows which setting to fix, and none of them
17
+ * includes the rejected URL, so an embedded password can never reach a log.
18
+ */
19
+ function resolveBaseUrl(baseUrl) {
20
+ let parsed;
21
+ try {
22
+ parsed = new URL(baseUrl);
23
+ }
24
+ catch {
25
+ throw new Error('POSTSIDER_API_URL is not a valid absolute URL. Use e.g. https://api.postsider.com.');
26
+ }
27
+ if (parsed.username || parsed.password) {
28
+ throw new Error('POSTSIDER_API_URL must not embed credentials (username or password). ' +
29
+ 'Remove them and pass the key as POSTSIDER_API_KEY instead.');
30
+ }
31
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
32
+ throw new Error(`POSTSIDER_API_URL must use http or https, not "${parsed.protocol.replace(':', '')}". Use e.g. https://api.postsider.com.`);
33
+ }
34
+ if (parsed.protocol === 'http:' && !LOOPBACK_HOSTNAMES.has(parsed.hostname)) {
35
+ throw new Error(`POSTSIDER_API_URL must use https for non-local host "${parsed.hostname}". ` +
36
+ 'Plain http is allowed only for local development on localhost, 127.0.0.1 or [::1].');
37
+ }
38
+ return (parsed.origin + parsed.pathname.replace(/\/+$/, '') + '/public/v1');
39
+ }
40
+ /** Hard ceiling on how long a single API request may take. */
41
+ const REQUEST_TIMEOUT_MS = 30_000;
42
+ /** Hard ceiling on how much of a failing response body is read or echoed. */
43
+ const MAX_ERROR_BODY_BYTES = 8 * 1024;
44
+ /**
45
+ * Hard ceiling on a successful response body. Tool results are injected into the
46
+ * agent's context, so an unbounded payload is a problem regardless of who causes
47
+ * it. 8 MB is far beyond any sane calendar response.
48
+ */
49
+ const MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
50
+ /**
51
+ * Read `name` off a thrown value without relying on the Error prototype chain.
52
+ * A `DOMException` cross-realm value is not always `instanceof Error`, so
53
+ * classifying by prototype silently misroutes timeouts to the generic branch.
54
+ */
55
+ function thrownName(err) {
56
+ if (typeof err === 'object' && err !== null && 'name' in err) {
57
+ return String(err.name);
58
+ }
59
+ return '';
60
+ }
61
+ /**
62
+ * Read a message off a thrown value's `cause`, which is where the runtime puts
63
+ * the real reason for a failed `fetch` (for example "unexpected redirect").
64
+ *
65
+ * Read structurally, never with `instanceof`: inside a test runner's VM the
66
+ * cause can come from another realm, which is the same trap `thrownName` exists
67
+ * to avoid.
68
+ */
69
+ function thrownCauseMessage(err) {
70
+ if (typeof err === 'object' && err !== null && 'cause' in err) {
71
+ const cause = err.cause;
72
+ if (typeof cause === 'string') {
73
+ return cause;
74
+ }
75
+ if (typeof cause === 'object' && cause !== null && 'message' in cause) {
76
+ return String(cause.message);
77
+ }
78
+ }
79
+ return '';
80
+ }
81
+ /** Parse a JSON body, falling back to raw text when it is not JSON. */
82
+ function parseBody(text) {
83
+ if (!text) {
84
+ return null;
85
+ }
86
+ try {
87
+ return JSON.parse(text);
88
+ }
89
+ catch {
90
+ return text;
91
+ }
92
+ }
93
+ /**
94
+ * Read a response body without ever buffering more than `maxBytes`.
95
+ *
96
+ * The download is aborted as soon as the ceiling is passed, so a broken or
97
+ * hostile server cannot stream unbounded data into the agent's context. The loop
98
+ * reads one chunk past the ceiling before deciding, so a body of exactly
99
+ * `maxBytes` is not falsely reported as truncated, and the over-read is bounded
100
+ * to that single chunk.
101
+ *
102
+ * Bodies without a stream reader (test doubles, bodiless responses) cannot be
103
+ * streamed at all, so they are read and then cut with the same byte ceiling.
104
+ */
105
+ async function readBoundedBody(res, maxBytes) {
106
+ const encoder = new TextEncoder();
107
+ if (!res.body) {
108
+ const text = await res.text();
109
+ const bytes = encoder.encode(text);
110
+ if (bytes.byteLength <= maxBytes) {
111
+ return { text, truncated: false };
112
+ }
113
+ return {
114
+ text: new TextDecoder().decode(bytes.subarray(0, maxBytes)),
115
+ truncated: true,
116
+ };
117
+ }
118
+ const reader = res.body.getReader();
119
+ const chunks = [];
120
+ let seen = 0;
121
+ let truncated = false;
122
+ try {
123
+ for (;;) {
124
+ const { done, value } = await reader.read();
125
+ if (done) {
126
+ break;
127
+ }
128
+ if (!value) {
129
+ continue;
130
+ }
131
+ seen += value.byteLength;
132
+ chunks.push(value);
133
+ if (seen > maxBytes) {
134
+ truncated = true;
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ finally {
140
+ // Release the connection even when we stopped reading early.
141
+ await reader.cancel().catch(() => undefined);
142
+ }
143
+ const all = new Uint8Array(seen);
144
+ let offset = 0;
145
+ for (const chunk of chunks) {
146
+ all.set(chunk, offset);
147
+ offset += chunk.byteLength;
148
+ }
149
+ // Cut the BYTES, not the decoded string, so the ceiling stays exact.
150
+ const bytes = truncated ? all.subarray(0, maxBytes) : all;
151
+ return { text: new TextDecoder().decode(bytes), truncated };
152
+ }
153
+ /**
154
+ * Thin HTTP client for the PostSider public API (`/public/v1`).
155
+ *
156
+ * Auth is the RAW api key in the `Authorization` header (no `Bearer` prefix),
157
+ * matching the platform's public API contract. This client has no dependency on
158
+ * the backend; it talks to any PostSider instance (cloud or self-hosted) over
159
+ * HTTP, so the MCP server works the same everywhere.
160
+ */
161
+ export class PostsiderClient {
162
+ apiKey;
163
+ base;
164
+ constructor(apiKey, baseUrl) {
165
+ this.apiKey = apiKey;
166
+ this.base = resolveBaseUrl(baseUrl);
167
+ }
168
+ async request(method, path, opts = {}) {
169
+ if (!path.startsWith('/')) {
170
+ // Internal invariant: every path in server.ts is a literal starting with "/".
171
+ throw new Error(`Internal error: API paths must start with "/", received "${path}".`);
172
+ }
173
+ const url = new URL(this.base + path);
174
+ if (opts.query) {
175
+ for (const [k, v] of Object.entries(opts.query)) {
176
+ if (v !== undefined && v !== null && v !== '') {
177
+ url.searchParams.set(k, String(v));
178
+ }
179
+ }
180
+ }
181
+ let res;
182
+ try {
183
+ res = await fetch(url, {
184
+ method,
185
+ headers: {
186
+ Authorization: this.apiKey,
187
+ ...(opts.body ? { 'Content-Type': 'application/json' } : {}),
188
+ ...(opts.idempotencyKey ? { 'Idempotency-Key': opts.idempotencyKey } : {}),
189
+ },
190
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
191
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
192
+ // The public API never legitimately redirects, and a redirect would
193
+ // otherwise leave the validated origin: the HTTPS/loopback policy above
194
+ // only covers the URL the user configured, not wherever it points next.
195
+ redirect: 'error',
196
+ });
197
+ }
198
+ catch (err) {
199
+ const name = thrownName(err);
200
+ if (name === 'TimeoutError' || name === 'AbortError') {
201
+ throw new Error(`PostSider did not respond within ${REQUEST_TIMEOUT_MS / 1000}s (${method} ${path} at ${url.origin}). Check POSTSIDER_API_URL and that the instance is online.`);
202
+ }
203
+ // This client refuses redirects on purpose (see `redirect: 'error'`), so a
204
+ // redirected request would otherwise surface as an opaque "fetch failed".
205
+ if (thrownCauseMessage(err).toLowerCase().includes('redirect')) {
206
+ throw new Error(`PostSider answered ${method} ${path} with a redirect, which this client refuses to follow ` +
207
+ `because it would send the API key to another address. Point POSTSIDER_API_URL directly at the ` +
208
+ `PostSider API (self-hosted installs behind the bundled nginx need the /api suffix, e.g. https://social.example.com/api).`);
209
+ }
210
+ throw new Error(`Could not reach PostSider at ${url.origin}. Check POSTSIDER_API_URL and that the instance is online. (${err instanceof Error ? err.message : String(err)})`);
211
+ }
212
+ /**
213
+ * Read the body, turning an abort that happens mid-response into the same
214
+ * timeout message instead of leaking a raw DOMException to the agent.
215
+ */
216
+ const readBody = async (maxBytes) => {
217
+ try {
218
+ return await readBoundedBody(res, maxBytes);
219
+ }
220
+ catch (err) {
221
+ const name = thrownName(err);
222
+ if (name === 'TimeoutError' || name === 'AbortError') {
223
+ throw new Error(`PostSider did not finish sending its response within ${REQUEST_TIMEOUT_MS / 1000}s (${method} ${path} at ${url.origin}). Check POSTSIDER_API_URL and that the instance is online.`);
224
+ }
225
+ throw new Error(`PostSider API ${method} ${path} response could not be read: ${err instanceof Error ? err.message : String(err)}`);
226
+ }
227
+ };
228
+ if (!res.ok) {
229
+ const { text: raw, truncated } = await readBody(MAX_ERROR_BODY_BYTES);
230
+ const data = parseBody(truncated ? `${raw} [truncated]` : raw);
231
+ const apiMsg = data && typeof data === 'object' && 'msg' in data
232
+ ? String(data.msg)
233
+ : typeof data === 'string'
234
+ ? data
235
+ : res.statusText;
236
+ // 423 = Emergency Pause (kill switch): surface WHY the post did not go
237
+ // out so an agent understands a paused org instead of a generic failure.
238
+ if (res.status === 423) {
239
+ const d = (data && typeof data === 'object' ? data : {});
240
+ const reason = typeof d.reason === 'string' ? d.reason : undefined;
241
+ throw new Error(`Publishing is paused for this organization${reason ? ` (${reason})` : ''}. No posts can be scheduled or published until an owner resumes from the PostSider dashboard.`);
242
+ }
243
+ if (res.status === 401 || res.status === 403) {
244
+ throw new Error(`Unauthorized (${res.status}). The POSTSIDER_API_KEY is missing or invalid. Generate one in PostSider under Settings -> API.`);
245
+ }
246
+ throw new Error(`PostSider API ${method} ${path} failed (${res.status}): ${apiMsg}`);
247
+ }
248
+ // A successful body is agent-visible payload, so it is bounded too: without
249
+ // a ceiling a broken instance could exhaust memory on the user's machine.
250
+ const { text, truncated } = await readBody(MAX_RESPONSE_BODY_BYTES);
251
+ if (truncated) {
252
+ throw new Error(`PostSider API ${method} ${path} returned more than ${MAX_RESPONSE_BODY_BYTES / (1024 * 1024)} MB, which is more than this server will load. Narrow the request, for example with a shorter date range.`);
253
+ }
254
+ return parseBody(text);
255
+ }
256
+ get(path, query) {
257
+ return this.request('GET', path, { query });
258
+ }
259
+ post(path, body, idempotencyKey) {
260
+ return this.request('POST', path, { body, idempotencyKey });
261
+ }
262
+ put(path, body) {
263
+ return this.request('PUT', path, { body });
264
+ }
265
+ del(path) {
266
+ return this.request('DELETE', path);
267
+ }
268
+ }
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PostSider MCP server entrypoint (stdio transport).
4
+ *
5
+ * Reads configuration from the environment, builds the client, and connects the
6
+ * shared server factory to stdio. Every tool registration lives in `server.ts`
7
+ * so that no transport can drift away from another.
8
+ *
9
+ * Configuration (environment variables):
10
+ * POSTSIDER_API_KEY (required) - org API key from Settings -> API
11
+ * POSTSIDER_API_URL (optional) - instance base URL
12
+ * default: https://api.postsider.com
13
+ *
14
+ * Transport: stdio (the standard for local MCP clients).
15
+ */
16
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
+ import { PostsiderClient } from './client.js';
18
+ import { createPostSiderMcpServer } from './server.js';
19
+ const apiKey = process.env.POSTSIDER_API_KEY;
20
+ const baseUrl = process.env.POSTSIDER_API_URL || 'https://api.postsider.com';
21
+ async function main(key, url) {
22
+ const client = new PostsiderClient(key, url);
23
+ const server = createPostSiderMcpServer(client);
24
+ const transport = new StdioServerTransport();
25
+ await server.connect(transport);
26
+ // Log the origin only: it is what the client actually talks to, and it cannot
27
+ // carry a query-string secret that the client would have dropped anyway.
28
+ process.stderr.write(`PostSider MCP server running (stdio) against ${new URL(url).origin}\n`);
29
+ }
30
+ if (!apiKey) {
31
+ // Fail fast with an actionable message on stderr (stdout is the MCP channel).
32
+ process.stderr.write('POSTSIDER_API_KEY is not set. Add it to your MCP client config. ' +
33
+ 'Generate a key in PostSider under Settings -> API.\n');
34
+ process.exit(1);
35
+ }
36
+ main(apiKey, baseUrl).catch((err) => {
37
+ process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
38
+ process.exit(1);
39
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pure mapping from the MCP `postsider_create_post` tool arguments to the
3
+ * PostSider public API request body for `POST /public/v1/posts`.
4
+ *
5
+ * Kept as a standalone, dependency-free function so it can be unit-tested in
6
+ * isolation (importing `index.ts` would start the stdio server and require
7
+ * POSTSIDER_API_KEY). `index.ts` imports and uses this verbatim, so runtime
8
+ * behavior is unchanged.
9
+ */
10
+ /**
11
+ * Map the create-post tool arguments to the public API request body.
12
+ * Defaults: `image` -> [], `settings` -> {}, `tags` -> []. `firstComment` is
13
+ * only included when provided.
14
+ */
15
+ export function buildCreatePostBody(args) {
16
+ const { type, date, shortLink, posts, tags } = args;
17
+ return {
18
+ type,
19
+ date,
20
+ shortLink,
21
+ tags: tags ?? [],
22
+ posts: posts.map((p) => ({
23
+ integration: { id: p.channelId },
24
+ value: [{ content: p.content, image: p.images ?? [] }],
25
+ ...(p.firstComment ? { firstComment: p.firstComment } : {}),
26
+ settings: p.settings ?? {},
27
+ })),
28
+ };
29
+ }