@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/.claude-plugin/plugin.json +29 -0
- package/.mcp.json +11 -0
- package/CHANGELOG.md +62 -0
- package/LICENSE +673 -0
- package/README.md +190 -0
- package/dist/client.js +268 -0
- package/dist/index.js +39 -0
- package/dist/post-body.js +29 -0
- package/dist/server.js +550 -0
- package/package.json +73 -0
- package/server.json +45 -0
- package/skills/postsider-workflow/SKILL.md +76 -0
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
|
+
}
|