n2n-post2site 0.1.2 → 0.1.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to N2N Post2Site are documented in this file.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## [0.1.4] - 2026-06-24
8
+
9
+ ### Changed
10
+
11
+ - **Breaking:** renamed the `n2n_get_product_context` tool to `n2n_get_scope_context`, and the backend endpoint from `GET /products/{content_scope}` to `GET /scopes/{content_scope}`.
12
+ - Treat `content_scope` as generic `kind:key` metadata. Client-side validation now only checks the `kind:key` format; whether a scope is required or prohibited for a content type is decided by the backend (`capabilities.content.content_scope.required_for_types`).
13
+ - Relaxed `type` and `locale` tool inputs from fixed enums to free strings; the backend's `capabilities` is the source of truth for supported values. `status` remains an enum.
14
+ - Genericized tool descriptions and documentation to remove product-only assumptions.
15
+
16
+ ### Removed
17
+
18
+ - Removed the client-side `type`/`content_scope` coupling (`assertContentPostShape`), replaced by a format-only `assertContentScopeFormat`.
19
+ - Removed the internal `docs/REFACTOR_PLAN.md` working document.
20
+
21
+ ## [0.1.3] - 2026-06-17
22
+
23
+ ### Added
24
+
25
+ - Added `n2n_list_drafts` for listing unpublished drafts through the existing posts endpoint with `status=draft`.
26
+ - Added `n2n_update_draft`, which reads the target post first and refuses to patch unless the backend reports `status: "draft"`.
27
+ - Added `docs/ARCHITECTURE.md` to document runtime layers, module boundaries, and request flow.
28
+
29
+ ### Changed
30
+
31
+ - Added `docs/ARCHITECTURE.md` to the README related docs list.
32
+ - Bumped package version to `0.1.3`.
33
+
5
34
  ## [0.1.2] - 2026-06-15
6
35
 
7
36
  ### Added
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Datafrog LLC
3
+ Copyright (c) 2026 N2NS Lab
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,103 +1,101 @@
1
- # N2N Post2Site
1
+ <p align="center">
2
+ <img src="./assets/n2n-post2site-logo.png" width="128" alt="n2n-post2site logo">
3
+ </p>
2
4
 
3
- AI-assisted content publishing MCP server for blogs and product guides.
5
+ # n2n-post2site
6
+
7
+ AI-assisted content publishing MCP server for blogs, guides, and other categorized content.
4
8
 
5
9
  [![npm version](https://img.shields.io/npm/v/n2n-post2site)](https://www.npmjs.com/package/n2n-post2site)
6
10
  [![npm total downloads](https://img.shields.io/npm/dt/n2n-post2site)](https://www.npmjs.com/package/n2n-post2site)
7
- [![license](https://img.shields.io/github/license/n2ns/n2n-post2site)](https://github.com/n2ns/n2n-post2site/blob/main/LICENSE)
11
+ [![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
8
12
  [![MCP Protocol](https://img.shields.io/badge/MCP-Protocol-blue)](https://modelcontextprotocol.io)
9
13
  [![node version](https://img.shields.io/node/v/n2n-post2site)](https://nodejs.org)
10
- [![DataFrog.io](https://datafrog.io/badges/datafrog.svg)](https://datafrog.io)
11
14
 
12
- N2N Post2Site lets an AI assistant draft, edit, review, and publish website content through a narrow Content Publishing API Contract. It is built for teams that want AI-assisted blog posts, technical notes, changelogs, and product guides without giving the assistant database access, shell access, deployment access, payment access, or user administration access.
15
+ ---
16
+
17
+ > **Draft with AI. Publish with intent.**
18
+
19
+ n2n-post2site is an open-source Model Context Protocol (MCP) server that lets an AI assistant draft, edit, review, and publish website content through a narrow Content Publishing API Contract. It is a local MCP bridge between your IDE assistant and your website content API — intentionally small, with no database access, shell access, deployment access, payment access, or user administration access.
13
20
 
14
- It is intentionally small: a local MCP bridge between your IDE assistant and your website content API. Your website remains responsible for authentication, validation, storage, preview, publishing rules, and audit behavior.
21
+ ## 📚 Contents
15
22
 
16
- ## Quick summary
23
+ - [What is n2n-post2site?](#-what-is-n2n-post2site)
24
+ - [Architecture](#️-architecture)
25
+ - [Publishing model](#-publishing-model)
26
+ - [Quick start](#-quick-start)
27
+ - [Backend API contract](#-backend-api-contract)
28
+ - [MCP tools](#️-mcp-tools)
29
+ - [Content format](#-content-format)
30
+ - [Security and governance notes](#-security-and-governance-notes)
31
+ - [Related docs](#-related-docs)
17
32
 
18
- - Runs locally as an MCP server.
19
- - Connects to one website content API through `CONTENT_API_BASE_URL` and `CONTENT_API_KEY`.
20
- - Creates drafts by default.
21
- - Publishes only through an explicit publish tool.
22
- - Submits one locale per create or update call.
23
- - Uses Markdown-first content, with optional inline HTML when your site supports it.
24
- - Reads product context before drafting product guides.
25
- - Does not expose database, shell, server, payment, user, pricing, or delete operations.
33
+ ## 💡 What is n2n-post2site?
26
34
 
27
- ## When to use it
35
+ n2n-post2site gives AI coding assistants a structured path to draft and publish website content without exposing the database, file system, or deployment layer. Your backend implements the Content Publishing API Contract; the MCP server forwards AI tool calls to it.
28
36
 
29
- Use N2N Post2Site when you want an AI assistant to help with:
37
+ Use it for blog posts, product guides, technical notes, release notes, and localized article drafts. It is not a CMS — no admin panel, no storage backend, no image uploads (v1), no deployment workflow.
38
+
39
+ ## 🏗️ Architecture
40
+
41
+ ```text
42
+ ┌─────────────────────────────┐
43
+ │ AI coding assistant │
44
+ │ (Claude, Cursor, VS Code) │
45
+ └──────────────┬──────────────┘
46
+ │ MCP tool calls (stdio)
47
+
48
+ ┌─────────────────────────────┐
49
+ │ n2n-post2site │
50
+ │ MCP server · 9 tools │
51
+ │ validate → map → HTTP call │
52
+ └──────────────┬──────────────┘
53
+ │ HTTPS + site-scoped API key
54
+
55
+ ┌─────────────────────────────┐
56
+ │ Your backend content API │
57
+ │ (Content Publishing API │
58
+ │ Contract) │
59
+ │ owns storage, auth, policy │
60
+ └─────────────────────────────┘
61
+ ```
30
62
 
31
- - Company blog posts.
32
- - Product guides.
33
- - Technical field notes.
34
- - Release notes and changelogs.
35
- - Localized article drafts.
36
- - Safe updates to existing website content.
63
+ - **The MCP server is a thin bridge**: it validates tool input, maps it to the contract, and forwards HTTP calls. It owns no persistence, authorization, or review policy.
64
+ - **The backend owns the truth**: storage, publishing state, and access control all live behind the contract.
65
+ - **One server, one site**: each MCP instance is bound to a single website through `CONTENT_API_BASE_URL` and `CONTENT_API_KEY`.
37
66
 
38
- Do not use it as a CMS. It does not provide an admin panel, database schema, storage backend, preview system, deployment workflow, or image upload service.
67
+ See [Architecture](./docs/ARCHITECTURE.md) for the full layer breakdown.
39
68
 
40
- ## Publishing model
69
+ ## 📰 Publishing model
41
70
 
42
- N2N Post2Site works with two publishing spaces:
71
+ n2n-post2site classifies content with an optional `content_scope`:
43
72
 
44
- | Space | `content_scope` | Example |
73
+ | Content | `content_scope` | Example |
45
74
  |---|---|---|
46
- | Company blog | omitted or empty | technical notes, announcements, changelogs |
47
- | Product guide | `kind:key` | `product:evisa-helper` |
75
+ | Unscoped | omitted or empty | technical notes, announcements, changelogs |
76
+ | Scoped | `kind:key` | `product:example-product` |
48
77
 
49
- The backend defines which scopes are valid. A product guide should only be written after the assistant reads controlled product context with `n2n_get_product_context`.
78
+ The backend defines which `content_scope` kinds are valid and which content types require one. Scoped content should only be written after the assistant reads controlled context with `n2n_get_scope_context`.
50
79
 
51
80
  The assistant should follow this workflow:
52
81
 
53
82
  1. Call `n2n_get_capabilities`.
54
83
  2. Search existing content with `n2n_list_posts`.
55
- 3. For product guides, call `n2n_get_product_context`.
84
+ 3. For scoped content, call `n2n_get_scope_context`.
56
85
  4. Create or update one locale at a time.
57
- 5. Review the draft.
58
- 6. Publish only through `n2n_publish_post`.
86
+ 5. Resume unfinished work with `n2n_list_drafts`, `n2n_get_post`, and `n2n_update_draft`.
87
+ 6. Review the draft.
88
+ 7. Publish only through `n2n_publish_post`.
59
89
 
60
- ## Requirements
90
+ ## 🚀 Quick start
61
91
 
62
- - Node.js 22+
63
- - An MCP-capable client or IDE
64
- - A site-scoped content API token
65
- - A backend that implements the Content Publishing API Contract
92
+ **Requirements**: Node.js 22+, an MCP-capable client, a site-scoped API key, and a backend that implements the [Content Publishing API Contract](./docs/BACKEND_API.md).
66
93
 
67
- ## Install
68
-
69
- For local development:
70
-
71
- ```bash
72
- npm install
73
- npm run build
74
- npm run check
75
- ```
76
-
77
- For MCP clients, use the published package after it is available on npm:
78
-
79
- ```bash
80
- npx -y n2n-post2site
81
- ```
94
+ > **Just trying it out?** Run the [mock backend](./examples/mock-backend/) for a zero-dependency local server that implements the full contract — no website required.
82
95
 
83
- ## Configuration
96
+ ### 1. Configure your MCP client
84
97
 
85
- Set these environment variables in your MCP client configuration:
86
-
87
- ```env
88
- CONTENT_API_BASE_URL=https://example.com/api/v1/mcp
89
- CONTENT_API_KEY=change-me
90
- ```
91
-
92
- `CONTENT_API_BASE_URL` is the base URL of your protected content API. The backend should expose `/capabilities`, `/products/{content_scope}`, and `/posts` relative to this base URL.
93
-
94
- Keep path mapping and field mapping in the backend adapter, not in MCP client configuration. The MCP config should only need a base URL and an API key.
95
-
96
- Do not put API keys in prompts, article content, README examples, or screenshots.
97
-
98
- ## MCP client examples
99
-
100
- ### Using the npm package
98
+ Using the npm package:
101
99
 
102
100
  ```json
103
101
  {
@@ -106,15 +104,15 @@ Do not put API keys in prompts, article content, README examples, or screenshots
106
104
  "command": "npx",
107
105
  "args": ["-y", "n2n-post2site"],
108
106
  "env": {
109
- "CONTENT_API_BASE_URL": "https://example.com/api/v1/mcp",
110
- "CONTENT_API_KEY": "change-me"
107
+ "CONTENT_API_BASE_URL": "https://your-site.com/api/v1/mcp",
108
+ "CONTENT_API_KEY": "your-api-key"
111
109
  }
112
110
  }
113
111
  }
114
112
  }
115
113
  ```
116
114
 
117
- ### Using a local checkout
115
+ Using a local checkout:
118
116
 
119
117
  ```json
120
118
  {
@@ -123,185 +121,34 @@ Do not put API keys in prompts, article content, README examples, or screenshots
123
121
  "command": "node",
124
122
  "args": ["/path/to/n2n-post2site/dist/index.js"],
125
123
  "env": {
126
- "CONTENT_API_BASE_URL": "https://example.com/api/v1/mcp",
127
- "CONTENT_API_KEY": "change-me"
124
+ "CONTENT_API_BASE_URL": "https://your-site.com/api/v1/mcp",
125
+ "CONTENT_API_KEY": "your-api-key"
128
126
  }
129
127
  }
130
128
  }
131
129
  }
132
130
  ```
133
131
 
134
- Bind one MCP server configuration to exactly one website. Do not ask the AI assistant to choose a `site_id` in tool arguments.
135
-
136
- ## Backend API contract
137
-
138
- The backend should support these endpoints relative to `CONTENT_API_BASE_URL`:
139
-
140
- ```http
141
- GET /capabilities
142
- GET /products/{content_scope}
143
- GET /posts
144
- POST /posts
145
- GET /posts/{id_or_slug}
146
- PATCH /posts/{id_or_slug}
147
- POST /posts/{id_or_slug}/publish
148
- ```
132
+ - `CONTENT_API_BASE_URL`: base URL of your protected content API. Keep path and field mapping in the backend adapter.
133
+ - `CONTENT_API_KEY`: site-scoped API key. Do not put it in prompts, article content, or screenshots.
149
134
 
150
- ### `GET /capabilities`
135
+ Bind one server instance to exactly one website.
151
136
 
152
- Returns the publishing contract for AI clients, including:
137
+ ## 🔗 Backend API contract
153
138
 
154
- - Supported content types.
155
- - Supported statuses.
156
- - Supported locales.
157
- - Single-locale input fields.
158
- - `content_scope` rules.
159
- - Available product guide scopes.
160
- - Safety boundaries.
139
+ n2n-post2site connects to any backend that implements the Content Publishing API Contract. The contract defines the required endpoints, payload rules, and security requirements.
161
140
 
162
- ### `GET /products/{content_scope}`
141
+ See **[Backend API Contract](./docs/BACKEND_API.md)** for the full specification.
163
142
 
164
- Returns controlled product context before drafting product guides.
143
+ ## 🛠️ MCP tools
165
144
 
166
- Expected fields:
145
+ - **Discovery** (`n2n_get_capabilities`, `n2n_list_posts`, `n2n_get_scope_context`): read backend capabilities, list existing posts, and load scope context before drafting.
146
+ - **Drafting** (`n2n_create_post`, `n2n_update_post`, `n2n_update_draft`, `n2n_list_drafts`, `n2n_get_post`): create posts, update posts one locale at a time, and resume unpublished drafts.
147
+ - **Publishing** (`n2n_publish_post`): publish an approved draft through an explicit publish step.
167
148
 
168
- - `content_scope`: confirms the valid product guide scope.
169
- - `canonical_url`: product page for deeper reading, links, and citations.
170
- - `docs_url`: docs or guide index to prefer for tutorials.
171
- - `summary`: controlled product summary.
172
- - `key_points`: controlled facts the assistant may rely on.
173
- - `do_not_claim`: claims the assistant must not make.
174
-
175
- ### Create and update payloads
176
-
177
- Create and update calls use one locale per request:
178
-
179
- ```json
180
- {
181
- "slug": "example-product-guide",
182
- "type": "guide",
183
- "content_scope": "product:example-product",
184
- "locale": "en",
185
- "title": "How to Use Example Product",
186
- "excerpt": "A short guide to using the example product.",
187
- "content": "## Overview\n\nMarkdown content..."
188
- }
189
- ```
190
-
191
- Rules:
192
-
193
- - `title` is plain text.
194
- - `excerpt` is plain text.
195
- - `content` is Markdown.
196
- - Inline HTML is allowed when useful.
197
- - Full HTML documents with `<html>`, `<head>`, or `<body>` are not allowed.
198
- - Create/update must not accept `status`, `published_at`, `user_id`, or `author`.
199
- - Publishing state changes only through `/posts/{id_or_slug}/publish`.
200
-
201
- Backends may return `missing_locales` and `next_actions` after create, update, or publish. The assistant should add missing language versions with additional `n2n_update_post` calls instead of asking the backend to auto-translate.
202
-
203
- ## MCP tools
204
-
205
- ### `n2n_get_capabilities`
206
-
207
- Read backend capabilities before creating or updating content.
208
-
209
- ```json
210
- {}
211
- ```
212
-
213
- ### `n2n_list_posts`
214
-
215
- Search existing posts before drafting new content.
216
-
217
- ```json
218
- {
219
- "status": "draft",
220
- "type": "guide",
221
- "content_scope": "product:example-product",
222
- "q": "setup guide",
223
- "per_page": 20
224
- }
225
- ```
226
-
227
- `status` is only a filter here. Do not send `status` in create or update calls.
228
-
229
- ### `n2n_get_post`
230
-
231
- Read an existing post before updating it, completing missing locales, or writing a follow-up.
232
-
233
- ```json
234
- {
235
- "id_or_slug": "example-product-guide"
236
- }
237
- ```
238
-
239
- ### `n2n_get_product_context`
240
-
241
- Read controlled product facts before drafting a product guide.
242
-
243
- ```json
244
- {
245
- "content_scope": "product:example-product"
246
- }
247
- ```
248
-
249
- ### `n2n_create_post`
250
-
251
- Create a draft. Publishing is separate.
252
-
253
- Company blog example:
254
-
255
- ```json
256
- {
257
- "slug": "content-workflow-notes",
258
- "type": "technical",
259
- "locale": "en",
260
- "title": "Content Workflow Notes",
261
- "excerpt": "Practical notes from shipping a content workflow.",
262
- "content": "## Notes\n\nMarkdown content..."
263
- }
264
- ```
265
-
266
- Product guide example:
267
-
268
- ```json
269
- {
270
- "slug": "example-product-guide",
271
- "type": "guide",
272
- "content_scope": "product:example-product",
273
- "locale": "en",
274
- "title": "How to Use Example Product",
275
- "excerpt": "A short guide to using the example product.",
276
- "content": "## Overview\n\nMarkdown content..."
277
- }
278
- ```
279
-
280
- ### `n2n_update_post`
281
-
282
- Update one locale of an existing post. Call `n2n_get_post` first.
283
-
284
- ```json
285
- {
286
- "id_or_slug": "example-product-guide",
287
- "locale": "de",
288
- "title": "So verwenden Sie Example Product",
289
- "excerpt": "A localized summary for the selected locale.",
290
- "content": "## Ueberblick\n\nLocalized Markdown content..."
291
- }
292
- ```
293
-
294
- ### `n2n_publish_post`
295
-
296
- Publish an existing draft.
297
-
298
- ```json
299
- {
300
- "id_or_slug": "example-product-guide"
301
- }
302
- ```
149
+ See [Tools reference](./docs/TOOLS_REFERENCE.md) for parameter schemas and call examples.
303
150
 
304
- ## Content format
151
+ ## 📝 Content format
305
152
 
306
153
  - Submit one locale per create or update call.
307
154
  - Use Markdown for `content`.
@@ -317,9 +164,9 @@ Image example:
317
164
  ![Product dashboard showing content status](/images/guides/content-status.png)
318
165
  ```
319
166
 
320
- ## Safety boundaries
167
+ ## 🔐 Security and governance notes
321
168
 
322
- N2N Post2Site should not expose:
169
+ n2n-post2site should not expose:
323
170
 
324
171
  - Delete operations.
325
172
  - Product configuration writes.
@@ -339,14 +186,20 @@ Recommended backend behavior:
339
186
  - Keep create/update and publish separate.
340
187
  - Set `published_at` on the backend, not from MCP input.
341
188
 
342
- ## Laravel backend package direction
189
+ ## 📖 Related docs
343
190
 
344
- A Laravel package can implement the HTTP side of this contract for multiple Laravel sites without copying controllers between projects. N2N Post2Site should stay a generic MCP client of that contract.
191
+ - **[Backend API Contract](./docs/BACKEND_API.md)**: Endpoints, payload rules, and security requirements for backend implementors.
192
+ - **[Architecture](./docs/ARCHITECTURE.md)**: Runtime layers, module boundaries, and request flow in the MCP server.
193
+ - **[Tools reference](./docs/TOOLS_REFERENCE.md)**: MCP tool parameter schemas and call examples.
194
+ - **[Roadmap](./ROADMAP.md)**: Planned features and what's coming next.
195
+ - **[Changelog](./CHANGELOG.md)**: Version history and release notes.
196
+ - **[Contributing](./CONTRIBUTING.md)**: How to report issues and contribute.
197
+ - **[Security](./SECURITY.md)**: How to report vulnerabilities.
345
198
 
346
- ## About N2NS Lab
199
+ ## 📄 License
347
200
 
348
- Built by N2NS Lab, short for Next-to-Native Systems Lab, Datafrog's open-source lab for AI-native developer tools.
201
+ This project is licensed under the [MIT License](./LICENSE).
349
202
 
350
- Learn more: https://n2ns.com
203
+ ---
351
204
 
352
- Source repository: git@github.com:n2ns/n2n-post2site.git
205
+ Built by [N2NS Lab](https://n2ns.com), an open-source lab for practical AI developer tools.
Binary file
Binary file
Binary file
@@ -1,10 +1,13 @@
1
+ import { FetchHttpTransport } from './transport/http.js';
1
2
  const POSTS_PATH = '/posts';
2
3
  const CAPABILITIES_PATH = '/capabilities';
3
- const PRODUCTS_PATH = '/products';
4
+ const SCOPES_PATH = '/scopes';
4
5
  export class ContentClient {
5
6
  config;
6
- constructor(config) {
7
+ transport;
8
+ constructor(config, transport = new FetchHttpTransport(config)) {
7
9
  this.config = config;
10
+ this.transport = transport;
8
11
  }
9
12
  async getCapabilities() {
10
13
  return this.request(CAPABILITIES_PATH, { method: 'GET' });
@@ -12,18 +15,22 @@ export class ContentClient {
12
15
  async listPosts(input) {
13
16
  const params = new URLSearchParams();
14
17
  for (const [key, value] of Object.entries(input)) {
15
- if (value === undefined)
18
+ if (value === undefined) {
16
19
  continue;
20
+ }
17
21
  params.set(key, String(value));
18
22
  }
19
23
  const suffix = params.toString() ? `?${params.toString()}` : '';
20
24
  return this.request(`${POSTS_PATH}${suffix}`, { method: 'GET' });
21
25
  }
26
+ async listDrafts(input) {
27
+ return this.listPosts({ ...input, status: 'draft' });
28
+ }
22
29
  async getPost(idOrSlug) {
23
30
  return this.request(`${POSTS_PATH}/${encodeURIComponent(idOrSlug)}`, { method: 'GET' });
24
31
  }
25
- async getProductContext(input) {
26
- return this.request(`${PRODUCTS_PATH}/${encodeURIComponent(input.content_scope)}`, { method: 'GET' });
32
+ async getScopeContext(input) {
33
+ return this.request(`${SCOPES_PATH}/${encodeURIComponent(input.content_scope)}`, { method: 'GET' });
27
34
  }
28
35
  async createPost(input) {
29
36
  return this.request(POSTS_PATH, {
@@ -38,13 +45,18 @@ export class ContentClient {
38
45
  body: JSON.stringify(payload),
39
46
  });
40
47
  }
48
+ async updateDraft(input) {
49
+ const existingPost = await this.getPost(input.id_or_slug);
50
+ assertDraftStatus(existingPost);
51
+ return this.updatePost(input);
52
+ }
41
53
  async publishPost(idOrSlug) {
42
54
  return this.request(`${POSTS_PATH}/${encodeURIComponent(idOrSlug)}/publish`, {
43
55
  method: 'POST',
44
56
  });
45
57
  }
46
58
  async request(path, init) {
47
- const response = await fetch(`${this.config.apiBaseUrl}${path}`, {
59
+ const response = await this.transport.request(path, {
48
60
  ...init,
49
61
  headers: {
50
62
  Accept: 'application/json',
@@ -54,24 +66,31 @@ export class ContentClient {
54
66
  ...(init.headers ?? {}),
55
67
  },
56
68
  });
57
- const text = await response.text();
58
- const body = parseJsonOrText(text);
59
69
  if (!response.ok) {
60
- throw new Error(`Content API request failed: ${response.status} ${response.statusText}: ${formatBody(body)}`);
70
+ throw new Error(`Content API request failed: ${response.status} ${response.statusText}: ${formatBody(response.body)}`);
61
71
  }
62
- return body;
72
+ return response.body;
63
73
  }
64
74
  }
65
- function parseJsonOrText(text) {
66
- if (!text)
67
- return null;
68
- try {
69
- return JSON.parse(text);
75
+ function formatBody(body) {
76
+ return typeof body === 'string' ? body : JSON.stringify(body, null, 2);
77
+ }
78
+ function assertDraftStatus(body) {
79
+ const status = extractPostStatus(body);
80
+ if (status === 'draft') {
81
+ return;
70
82
  }
71
- catch {
72
- return text;
83
+ if (status) {
84
+ throw new Error(`n2n_update_draft requires a draft post. Current status: ${status}.`);
73
85
  }
86
+ throw new Error('n2n_update_draft could not verify draft status. The backend GET /posts/{id_or_slug} response must include a status field.');
74
87
  }
75
- function formatBody(body) {
76
- return typeof body === 'string' ? body : JSON.stringify(body, null, 2);
88
+ function extractPostStatus(body) {
89
+ const record = asRecord(body);
90
+ const post = asRecord(record?.blog_post) ?? asRecord(record?.data) ?? record;
91
+ const status = post?.status;
92
+ return typeof status === 'string' ? status : undefined;
93
+ }
94
+ function asRecord(value) {
95
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
77
96
  }
package/dist/index.js CHANGED
@@ -1,62 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
3
  import { loadConfig } from './config.js';
5
- import { ContentClient } from './content-client.js';
6
- import { assertContentPostShape, capabilitiesSchema, createPostSchema, getPostSchema, listPostsSchema, productContextSchema, publishPostSchema, updatePostSchema, } from './schemas/blog-post.js';
7
- const server = new McpServer({
8
- name: 'n2n-post2site',
9
- version: '0.1.2',
10
- });
11
- const client = new ContentClient(loadConfig());
12
- server.tool('n2n_get_capabilities', 'Read the backend Content Publishing API Contract before creating or updating content. Use this to discover supported content types, locales, content_scope rules, and product guide scopes.', capabilitiesSchema.shape, async (input) => {
13
- capabilitiesSchema.parse(input);
14
- const result = await client.getCapabilities();
15
- return textResult(result);
16
- });
17
- server.tool('n2n_list_posts', 'Search and list existing website articles or product guides before drafting new content. Use this to avoid duplicate topics and conflicting guidance. For product guides, filter by content_scope. Use content_scope="" for unscoped company blog posts when the backend supports it.', listPostsSchema.shape, async (input) => {
18
- const parsed = listPostsSchema.parse(input);
19
- const result = await client.listPosts(parsed);
20
- return textResult(result);
21
- });
22
- server.tool('n2n_get_post', 'Read one existing article or guide by numeric ID or slug. Use this before updating a post, completing missing locales, or writing a follow-up article that depends on previous content.', getPostSchema.shape, async (input) => {
23
- const parsed = getPostSchema.parse(input);
24
- const result = await client.getPost(parsed.id_or_slug);
25
- return textResult(result);
26
- });
27
- server.tool('n2n_get_product_context', 'Read the controlled product fact sheet before writing a product guide. The backend returns content_scope, canonical_url, docs_url, summary, key_points, and do_not_claim so the article does not invent product facts.', productContextSchema.shape, async (input) => {
28
- const parsed = productContextSchema.parse(input);
29
- assertContentPostShape({ type: 'guide', content_scope: parsed.content_scope });
30
- const result = await client.getProductContext(parsed);
31
- return textResult(result);
32
- });
33
- server.tool('n2n_create_post', 'Create an article draft or product guide draft with one locale per call. Before creating new content, search existing posts with n2n_list_posts to avoid duplicates. Before creating a product guide, also call n2n_get_product_context for the target content_scope and follow its canonical_url, docs_url, summary, key_points, and do_not_claim. content must be a Markdown document string; inline HTML is allowed when useful. title and excerpt must be plain text. The backend returns missing_locales when more language versions should be added. Use n2n_publish_post to publish.', createPostSchema.shape, async (input) => {
34
- const parsed = createPostSchema.parse(input);
35
- assertContentPostShape(parsed);
36
- const result = await client.createPost(parsed);
37
- return textResult(result);
38
- });
39
- server.tool('n2n_update_post', 'Update one locale of an existing article or guide by ID or slug. Always call n2n_get_post first so edits preserve existing content and metadata. content must be a Markdown document string; inline HTML is allowed when useful. title and excerpt must be plain text. Use repeated calls with different locale values to add missing language versions.', updatePostSchema.shape, async (input) => {
40
- const parsed = updatePostSchema.parse(input);
41
- assertContentPostShape(parsed);
42
- const result = await client.updatePost(parsed);
43
- return textResult(result);
44
- });
45
- server.tool('n2n_publish_post', 'Publish an existing article or guide by ID or slug. Publishing is intentionally separate from create/update to avoid accidental publication.', publishPostSchema.shape, async (input) => {
46
- const parsed = publishPostSchema.parse(input);
47
- const result = await client.publishPost(parsed.id_or_slug);
48
- return textResult(result);
49
- });
50
- function textResult(value) {
51
- return {
52
- content: [
53
- {
54
- type: 'text',
55
- text: JSON.stringify(value, null, 2),
56
- },
57
- ],
58
- };
59
- }
4
+ import { createServer } from './server.js';
5
+ const server = createServer(loadConfig());
60
6
  async function main() {
61
7
  const transport = new StdioServerTransport();
62
8
  await server.connect(transport);
package/dist/result.js ADDED
@@ -0,0 +1,10 @@
1
+ export function createTextResult(value) {
2
+ return {
3
+ content: [
4
+ {
5
+ type: 'text',
6
+ text: JSON.stringify(value, null, 2),
7
+ },
8
+ ],
9
+ };
10
+ }
@@ -1,26 +1,26 @@
1
1
  import { z } from 'zod';
2
- export const contentTypeSchema = z.enum(['technical', 'announcement', 'changelog', 'guide']);
3
2
  export const statusSchema = z.enum(['draft', 'published']);
4
- export const localeSchema = z.enum(['en', 'zh_CN', 'es', 'de']);
5
3
  export const capabilitiesSchema = z.object({});
4
+ const CONTENT_SCOPE_FORMAT = /^[a-z][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
6
5
  export const listPostsSchema = z.object({
7
6
  status: statusSchema.optional().describe('Filter by publication status.'),
8
- type: contentTypeSchema.optional().describe('Filter by content type. Use guide for product or collection guide articles.'),
9
- content_scope: z.string().optional().describe('Optional site-defined canonical publishing scope for guides, products, or collections. Use kind:key, for example product:example-product. Use an empty string to list unscoped company blog posts when the backend supports it.'),
10
- q: z.string().optional().describe('Search query across title, content, and translations.'),
7
+ type: z.string().optional().describe('Filter by content type. Call n2n_get_capabilities for supported types.'),
8
+ content_scope: z.string().optional().describe('Filter by content_scope (kind:key). Use an empty string to list unscoped posts when the backend supports it.'),
9
+ q: z.string().optional().describe('Search query across title and content.'),
11
10
  per_page: z.number().int().min(1).max(100).optional().describe('Page size. Defaults to the server value.'),
12
11
  });
12
+ export const listDraftsSchema = listPostsSchema.omit({ status: true });
13
13
  export const getPostSchema = z.object({
14
14
  id_or_slug: z.string().min(1).describe('Numeric ID or slug of the article.'),
15
15
  });
16
- export const productContextSchema = z.object({
17
- content_scope: z.string().min(1).describe('Product guide scope in kind:key format, for example product:evisa-helper. Read this before writing a product guide.'),
16
+ export const scopeContextSchema = z.object({
17
+ content_scope: z.string().min(1).describe('Content scope in kind:key format, for example product:example. Read this before writing scoped content. Call n2n_get_capabilities for supported kinds.'),
18
18
  });
19
19
  export const createPostSchema = z.object({
20
20
  slug: z.string().min(1).describe('Globally unique URL slug.'),
21
- type: contentTypeSchema.default('technical').describe('Use guide for product or collection guide articles.'),
22
- content_scope: z.string().optional().describe('Required when type is guide. Use kind:key, for example product:example-product, project:example-project, or collection:example-collection.'),
23
- locale: localeSchema.default('en').describe('Locale for this single-language submission. Submit one locale per tool call.'),
21
+ type: z.string().optional().describe('Content type. Call n2n_get_capabilities for supported types. The backend applies its default when omitted.'),
22
+ content_scope: z.string().optional().describe('Optional kind:key categorization, for example product:example. The backend requires it for certain content types; call n2n_get_capabilities for supported kinds, examples, and which types require it.'),
23
+ locale: z.string().optional().describe('Single-locale code for this submission. Submit one locale per call. Call n2n_get_capabilities for supported locales. The backend applies its default when omitted.'),
24
24
  title: z.string().min(1).describe('Plain text title for the selected locale. Do not use Markdown here.'),
25
25
  excerpt: z.string().optional().describe('Plain text summary for the selected locale. No Markdown headings, tables, or images.'),
26
26
  content: z.string().min(1).describe('Markdown article body for the selected locale. Markdown image syntax is allowed. Inline HTML is allowed when useful.'),
@@ -29,17 +29,15 @@ export const createPostSchema = z.object({
29
29
  export const updatePostSchema = createPostSchema.partial().extend({
30
30
  id_or_slug: z.string().min(1).describe('Numeric ID or slug of the article to update.'),
31
31
  });
32
+ export const updateDraftSchema = updatePostSchema;
32
33
  export const publishPostSchema = getPostSchema;
33
- export function assertContentPostShape(input) {
34
- const type = input.type ?? 'technical';
35
- const content_scope = input.content_scope;
36
- if (type === 'guide' && !content_scope) {
37
- throw new Error('content_scope is required when type is guide. Use a kind:key value such as product:example-product, project:example-project, or collection:example-collection.');
38
- }
39
- if (type !== 'guide' && content_scope) {
40
- throw new Error('content_scope is only allowed when type is guide. Omit content_scope for unscoped company blog posts.');
41
- }
42
- if (content_scope && !/^[a-z][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/.test(content_scope)) {
43
- throw new Error('content_scope must use kind:key format, for example product:example-product.');
34
+ /**
35
+ * Contract-level format check only. Whether a content_scope is required or
36
+ * prohibited for a given type is decided by the backend (capabilities
37
+ * .content.content_scope.required_for_types) and enforced server-side.
38
+ */
39
+ export function assertContentScopeFormat(contentScope) {
40
+ if (contentScope && !CONTENT_SCOPE_FORMAT.test(contentScope)) {
41
+ throw new Error('content_scope must use kind:key format, for example product:example.');
44
42
  }
45
43
  }
package/dist/server.js ADDED
@@ -0,0 +1,28 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { ContentClient } from './content-client.js';
3
+ import { registerCreatePostTool } from './tools/create-post.js';
4
+ import { registerGetCapabilitiesTool } from './tools/get-capabilities.js';
5
+ import { registerGetPostTool } from './tools/get-post.js';
6
+ import { registerGetScopeContextTool } from './tools/get-scope-context.js';
7
+ import { registerListDraftsTool } from './tools/list-drafts.js';
8
+ import { registerListPostsTool } from './tools/list-posts.js';
9
+ import { registerPublishPostTool } from './tools/publish-post.js';
10
+ import { registerUpdateDraftTool } from './tools/update-draft.js';
11
+ import { registerUpdatePostTool } from './tools/update-post.js';
12
+ export function createServer(config) {
13
+ const client = new ContentClient(config);
14
+ const server = new McpServer({
15
+ name: 'n2n-post2site',
16
+ version: '0.1.2',
17
+ });
18
+ registerGetCapabilitiesTool(server, client);
19
+ registerListPostsTool(server, client);
20
+ registerListDraftsTool(server, client);
21
+ registerGetPostTool(server, client);
22
+ registerGetScopeContextTool(server, client);
23
+ registerCreatePostTool(server, client);
24
+ registerUpdatePostTool(server, client);
25
+ registerUpdateDraftTool(server, client);
26
+ registerPublishPostTool(server, client);
27
+ return server;
28
+ }
@@ -0,0 +1,10 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { assertContentScopeFormat, createPostSchema } from '../schemas/blog-post.js';
3
+ export function registerCreatePostTool(server, client) {
4
+ server.tool('n2n_create_post', 'Create a content draft with one locale per call. Before creating new content, search existing posts with n2n_list_posts to avoid duplicates. Call n2n_get_capabilities to learn supported types and which require a content_scope; for scoped content also call n2n_get_scope_context and follow the returned facts. content must be a Markdown document string; inline HTML is allowed when useful. title and excerpt must be plain text. The backend returns missing_locales when more language versions should be added. Use n2n_publish_post to publish.', createPostSchema.shape, async (input) => {
5
+ const parsed = createPostSchema.parse(input);
6
+ assertContentScopeFormat(parsed.content_scope);
7
+ const result = await client.createPost(parsed);
8
+ return createTextResult(result);
9
+ });
10
+ }
@@ -0,0 +1,9 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { capabilitiesSchema } from '../schemas/blog-post.js';
3
+ export function registerGetCapabilitiesTool(server, client) {
4
+ server.tool('n2n_get_capabilities', 'Read the backend Content Publishing API Contract before creating or updating content. Use this to discover supported content types, locales, content_scope rules (kinds, examples, which types require a scope), and available scopes.', capabilitiesSchema.shape, async (input) => {
5
+ capabilitiesSchema.parse(input);
6
+ const result = await client.getCapabilities();
7
+ return createTextResult(result);
8
+ });
9
+ }
@@ -0,0 +1,9 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { getPostSchema } from '../schemas/blog-post.js';
3
+ export function registerGetPostTool(server, client) {
4
+ server.tool('n2n_get_post', 'Read one existing article or guide by numeric ID or slug. Use this before updating a post, completing missing locales, or writing a follow-up article that depends on previous content.', getPostSchema.shape, async (input) => {
5
+ const parsed = getPostSchema.parse(input);
6
+ const result = await client.getPost(parsed.id_or_slug);
7
+ return createTextResult(result);
8
+ });
9
+ }
@@ -0,0 +1,10 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { assertContentScopeFormat, scopeContextSchema } from '../schemas/blog-post.js';
3
+ export function registerGetScopeContextTool(server, client) {
4
+ server.tool('n2n_get_scope_context', 'Read the controlled context for a content_scope before writing scoped content. The backend returns content_scope plus host-defined facts (for example canonical_url, docs_url, summary, key_points, do_not_claim) so the article does not invent facts.', scopeContextSchema.shape, async (input) => {
5
+ const parsed = scopeContextSchema.parse(input);
6
+ assertContentScopeFormat(parsed.content_scope);
7
+ const result = await client.getScopeContext(parsed);
8
+ return createTextResult(result);
9
+ });
10
+ }
@@ -0,0 +1,9 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { listDraftsSchema } from '../schemas/blog-post.js';
3
+ export function registerListDraftsTool(server, client) {
4
+ server.tool('n2n_list_drafts', 'List unpublished draft posts. Use this before resuming previous AI-written drafts. This tool always filters status=draft; use type, content_scope, q, and per_page to narrow the results.', listDraftsSchema.shape, async (input) => {
5
+ const parsed = listDraftsSchema.parse(input);
6
+ const result = await client.listDrafts(parsed);
7
+ return createTextResult(result);
8
+ });
9
+ }
@@ -0,0 +1,9 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { listPostsSchema } from '../schemas/blog-post.js';
3
+ export function registerListPostsTool(server, client) {
4
+ server.tool('n2n_list_posts', 'Search and list existing posts before drafting new content, to avoid duplicate topics and conflicting guidance. Filter by content_scope for scoped content, or content_scope="" for unscoped posts when the backend supports it.', listPostsSchema.shape, async (input) => {
5
+ const parsed = listPostsSchema.parse(input);
6
+ const result = await client.listPosts(parsed);
7
+ return createTextResult(result);
8
+ });
9
+ }
@@ -0,0 +1,9 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { publishPostSchema } from '../schemas/blog-post.js';
3
+ export function registerPublishPostTool(server, client) {
4
+ server.tool('n2n_publish_post', 'Publish an existing article or guide by ID or slug. Publishing is intentionally separate from create/update to avoid accidental publication.', publishPostSchema.shape, async (input) => {
5
+ const parsed = publishPostSchema.parse(input);
6
+ const result = await client.publishPost(parsed.id_or_slug);
7
+ return createTextResult(result);
8
+ });
9
+ }
@@ -0,0 +1,10 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { assertContentScopeFormat, updateDraftSchema } from '../schemas/blog-post.js';
3
+ export function registerUpdateDraftTool(server, client) {
4
+ server.tool('n2n_update_draft', 'Update one locale of an unpublished draft by ID or slug. The client reads the post first and refuses to patch unless the backend reports status=draft. Use n2n_list_drafts and n2n_get_post before calling this tool.', updateDraftSchema.shape, async (input) => {
5
+ const parsed = updateDraftSchema.parse(input);
6
+ assertContentScopeFormat(parsed.content_scope);
7
+ const result = await client.updateDraft(parsed);
8
+ return createTextResult(result);
9
+ });
10
+ }
@@ -0,0 +1,10 @@
1
+ import { createTextResult } from '../result.js';
2
+ import { assertContentScopeFormat, updatePostSchema } from '../schemas/blog-post.js';
3
+ export function registerUpdatePostTool(server, client) {
4
+ server.tool('n2n_update_post', 'Update one locale of an existing article or guide by ID or slug. Always call n2n_get_post first so edits preserve existing content and metadata. content must be a Markdown document string; inline HTML is allowed when useful. title and excerpt must be plain text. Use repeated calls with different locale values to add missing language versions.', updatePostSchema.shape, async (input) => {
5
+ const parsed = updatePostSchema.parse(input);
6
+ assertContentScopeFormat(parsed.content_scope);
7
+ const result = await client.updatePost(parsed);
8
+ return createTextResult(result);
9
+ });
10
+ }
@@ -0,0 +1,28 @@
1
+ export class FetchHttpTransport {
2
+ baseUrl;
3
+ constructor(config) {
4
+ this.baseUrl = config.apiBaseUrl;
5
+ }
6
+ async request(path, init) {
7
+ const response = await fetch(`${this.baseUrl}${path}`, init);
8
+ const text = await response.text();
9
+ const body = parseJsonOrText(text);
10
+ return {
11
+ status: response.status,
12
+ statusText: response.statusText,
13
+ ok: response.ok,
14
+ body,
15
+ };
16
+ }
17
+ }
18
+ function parseJsonOrText(text) {
19
+ if (!text) {
20
+ return null;
21
+ }
22
+ try {
23
+ return JSON.parse(text);
24
+ }
25
+ catch {
26
+ return text;
27
+ }
28
+ }
@@ -0,0 +1,113 @@
1
+ # N2N Post2Site Architecture
2
+
3
+ This document describes the current architecture of `n2n-post2site`. The goal is to keep boundaries clear, preserve interface compatibility, and give future changes stable extension points.
4
+
5
+ ## 1. What it is
6
+
7
+ `n2n-post2site` is an MCP server. It:
8
+
9
+ - Loads environment configuration and creates an MCP server instance.
10
+ - Exposes nine content publishing tools to MCP clients.
11
+ - Translates tool arguments into HTTP calls against a backend content API.
12
+ - Wraps backend responses into MCP-displayable text.
13
+
14
+ It does not own persistence, authorization, review policy, or content-ranking decisions. Those belong to the backend API provider.
15
+
16
+ ## 2. Layers
17
+
18
+ ### 2.1 Entry (`src/index.ts`)
19
+
20
+ - Creates the server via `createServer(loadConfig())`.
21
+ - Connects to the MCP client over `StdioServerTransport`.
22
+ - Owns only process lifecycle and failure output.
23
+
24
+ ### 2.2 Configuration (`src/config.ts`)
25
+
26
+ - Reads `CONTENT_API_BASE_URL` and `CONTENT_API_KEY`.
27
+ - Fails fast on missing values.
28
+ - Normalizes trailing slashes on `CONTENT_API_BASE_URL`.
29
+
30
+ ### 2.3 Assembly (`src/server.ts`)
31
+
32
+ Build-and-register only:
33
+
34
+ 1. Construct `ContentClient`.
35
+ 2. Create `McpServer`.
36
+ 3. Register the nine tools, in order:
37
+
38
+ - `n2n_get_capabilities`
39
+ - `n2n_list_posts`
40
+ - `n2n_list_drafts`
41
+ - `n2n_get_post`
42
+ - `n2n_get_scope_context`
43
+ - `n2n_create_post`
44
+ - `n2n_update_post`
45
+ - `n2n_update_draft`
46
+ - `n2n_publish_post`
47
+
48
+ ### 2.4 Tools (`src/tools/*.ts`)
49
+
50
+ Each file is one MCP tool:
51
+
52
+ - Validate input (Zod schema + lightweight assertions).
53
+ - Call the matching `ContentClient` method.
54
+ - Return a uniform `text` result.
55
+
56
+ Tools do not deal with low-level HTTP, headers, JSON parsing, or path building.
57
+
58
+ ### 2.5 Transport and client
59
+
60
+ - `src/transport/http.ts` — the `HttpTransport` interface and the default `FetchHttpTransport` wrapping `fetch`; handles response parsing (JSON/text) and status passthrough.
61
+ - `src/content-client.ts` — builds paths and HTTP methods, injects auth headers (`X-API-KEY` / `Authorization`), turns non-2xx responses into errors, assembles list and route parameters, and implements the `updateDraft` flow:
62
+ 1. `GET /posts/{id_or_slug}`
63
+ 2. require `status === 'draft'`
64
+ 3. then `PATCH /posts/{id_or_slug}`
65
+
66
+ ### 2.6 Schemas and validation (`src/schemas/blog-post.ts`)
67
+
68
+ - Zod schemas define every tool's input.
69
+ - `type` and `locale` are free strings; the backend is the source of truth for supported values (discoverable via `n2n_get_capabilities`).
70
+ - `assertContentScopeFormat` performs a contract-level format check only: when present, `content_scope` must be `kind:key`. Whether a scope is *required* or *prohibited* for a given type is decided and enforced by the backend (`capabilities.content.content_scope.required_for_types`).
71
+
72
+ ### 2.7 Output (`src/result.ts`)
73
+
74
+ - `createTextResult` wraps any backend payload into MCP `content: [{ type: 'text', text: ... }]`, keeping all tools' return shape consistent.
75
+
76
+ ## 3. Request flow
77
+
78
+ Typical call (`n2n_create_post`):
79
+
80
+ 1. The tool receives arguments.
81
+ 2. Zod schema validation + assertions.
82
+ 3. `ContentClient.createPost` is called.
83
+ 4. `ContentClient` builds the request and sends it via `HttpTransport.request`.
84
+ 5. The response body is parsed and returned.
85
+ 6. The tool wraps it with `createTextResult` for the MCP client.
86
+
87
+ ## 4. Key invariants
88
+
89
+ - Tool contract is stable (nine tools, names, argument semantics).
90
+ - Backend contract is stable (see `docs/BACKEND_API.md`).
91
+ - CLI/deploy entry is stable (`bin` points to `dist/index.js`).
92
+ - Fail-fast error behavior: missing config or invalid arguments error immediately.
93
+
94
+ ## 5. Test boundaries
95
+
96
+ - `tests/server.test.ts` — all nine tools register.
97
+ - `tests/transport.test.ts` / `tests/client.test.ts` — request-layer behavior of `FetchHttpTransport` and `ContentClient`.
98
+ - `tests/schema.test.ts` — input schemas and `content_scope` format checks.
99
+
100
+ ## 6. Limits and future direction
101
+
102
+ The design is layered but intentionally a lightweight monolith:
103
+
104
+ - No runtime retry, idempotency, rate limiting, or backoff.
105
+ - A single error mode (errors are thrown uniformly).
106
+ - Tool capabilities are documented statically.
107
+
108
+ Possible extensions:
109
+
110
+ - An `errors.ts` with a unified API error model.
111
+ - Strategic retry / idempotency in the client layer.
112
+ - A service layer (e.g. `PostService`) for richer flows.
113
+ - End-to-end contract tests covering error response shapes.
@@ -0,0 +1,112 @@
1
+ # Backend API Contract
2
+
3
+ This document defines the HTTP contract that a backend must implement to work with N2N Post2Site. The MCP server connects to this contract through `CONTENT_API_BASE_URL` and `CONTENT_API_KEY`.
4
+
5
+ ## Required endpoints
6
+
7
+ All endpoints are relative to `CONTENT_API_BASE_URL`.
8
+
9
+ ```http
10
+ GET /capabilities
11
+ GET /scopes/{content_scope}
12
+ GET /posts
13
+ POST /posts
14
+ GET /posts/{id_or_slug}
15
+ PATCH /posts/{id_or_slug}
16
+ POST /posts/{id_or_slug}/publish
17
+ ```
18
+
19
+ ## `GET /capabilities`
20
+
21
+ Returns the publishing contract for AI clients, including:
22
+
23
+ - Supported content types.
24
+ - Supported statuses.
25
+ - Supported locales.
26
+ - Single-locale input fields.
27
+ - `content_scope` rules (`content.content_scope`: format, `kinds`, `examples`, `required_for_types`).
28
+ - Available scopes (`scopes`).
29
+ - Safety boundaries.
30
+
31
+ The MCP server calls this endpoint before every create or update operation.
32
+
33
+ ## `GET /scopes/{content_scope}`
34
+
35
+ Returns controlled context for a `content_scope` before drafting scoped content.
36
+
37
+ The body is `content_scope` plus host-defined controlled fields. Common fields:
38
+
39
+ | Field | Description |
40
+ | --- | --- |
41
+ | `content_scope` | Confirms the valid scope. |
42
+ | `canonical_url` | Page for deeper reading, links, and citations. |
43
+ | `docs_url` | Docs or guide index to prefer for tutorials. |
44
+ | `summary` | Controlled summary. |
45
+ | `key_points` | Controlled facts the assistant may rely on. |
46
+ | `do_not_claim` | Claims the assistant must not make. |
47
+
48
+ ## `GET /posts`
49
+
50
+ Returns a list of existing posts. Supports filtering by `status`, `type`, `content_scope`, and a search query `q`.
51
+
52
+ For automated internal linking and search, each post in the list should include:
53
+
54
+ | Field | Description |
55
+ | --- | --- |
56
+ | `id` | Unique identifier. |
57
+ | `title` | Post title. |
58
+ | `slug` | Unique URL slug. |
59
+ | `link` | Required for AI auto-linking. Absolute public URL of the published post on the live website, so AI clients do not guess paths. |
60
+
61
+ ## `GET /posts/{id_or_slug}`
62
+
63
+ Returns a single post by ID or slug. The MCP server calls this before updating a post or completing missing locales.
64
+
65
+ ## `POST /posts` and `PATCH /posts/{id_or_slug}`
66
+
67
+ Create and update calls use one locale per request.
68
+
69
+ ### Payload
70
+
71
+ ```json
72
+ {
73
+ "slug": "example-product-guide",
74
+ "type": "guide",
75
+ "content_scope": "product:example-product",
76
+ "locale": "en",
77
+ "title": "How to Use Example Product",
78
+ "excerpt": "A short guide to using the example product.",
79
+ "content": "## Overview\n\nMarkdown content..."
80
+ }
81
+ ```
82
+
83
+ ### Field rules
84
+
85
+ - `title` is plain text.
86
+ - `excerpt` is plain text.
87
+ - `content` is Markdown. Inline HTML is allowed when useful. Full HTML documents with `<html>`, `<head>`, or `<body>` are not allowed.
88
+ - `content_scope` is an optional `kind:key` value. The backend requires it for the content types listed in `capabilities.content.content_scope.required_for_types` and prohibits it for all other types.
89
+ - Create and update payloads must not accept `status`, `published_at`, `user_id`, or `author`.
90
+ - Publishing state changes only through `POST /posts/{id_or_slug}/publish`.
91
+
92
+ ### Response conventions
93
+
94
+ Backends may return `missing_locales` and `next_actions` after create, update, or publish. The MCP server will add missing language versions with additional update calls instead of asking the backend to auto-translate.
95
+
96
+ ## `POST /posts/{id_or_slug}/publish`
97
+
98
+ Publishes an existing draft. Publishing state is managed exclusively through this endpoint, not through create or update payloads.
99
+
100
+ ## Draft-specific MCP tools
101
+
102
+ `n2n_list_drafts` and `n2n_update_draft` do not require extra backend endpoints:
103
+
104
+ - `n2n_list_drafts` calls `GET /posts` with `status=draft`.
105
+ - `n2n_update_draft` calls `GET /posts/{id_or_slug}` first and refuses to continue unless the returned post has `status: "draft"`, then calls `PATCH /posts/{id_or_slug}`.
106
+
107
+ ## Security requirements
108
+
109
+ - Use a site-scoped API key with the minimum permissions needed.
110
+ - Sanitize backend errors before returning them to the MCP client.
111
+ - Do not expose `user_id`, `author`, `published_at`, or other server-managed fields in create or update payloads.
112
+ - Keep path mapping and field mapping in the backend adapter, not in MCP client configuration.
@@ -0,0 +1,142 @@
1
+ # n2n-post2site tools reference
2
+
3
+ This document describes the MCP tools provided by n2n-post2site. All tools connect to the backend at `CONTENT_API_BASE_URL` and authenticate with `CONTENT_API_KEY`.
4
+
5
+ See [Backend API Contract](./BACKEND_API.md) for the endpoint specification the backend must implement.
6
+
7
+ ## `n2n_get_capabilities`
8
+
9
+ Read backend capabilities before creating or updating content.
10
+
11
+ ```json
12
+ {}
13
+ ```
14
+
15
+ The backend returns supported content types, statuses, locales, input fields, `content_scope` rules, and safety boundaries. The MCP server calls this endpoint before every create or update operation.
16
+
17
+ ## `n2n_list_posts`
18
+
19
+ Search existing posts before drafting new content.
20
+
21
+ ```json
22
+ {
23
+ "status": "draft",
24
+ "type": "guide",
25
+ "content_scope": "product:example-product",
26
+ "q": "setup guide",
27
+ "per_page": 20
28
+ }
29
+ ```
30
+
31
+ `status` is a filter only. Do not send `status` in create or update calls.
32
+
33
+ ## `n2n_list_drafts`
34
+
35
+ List unpublished drafts. This is a draft-only convenience tool over `GET /posts?status=draft`.
36
+
37
+ ```json
38
+ {
39
+ "type": "guide",
40
+ "content_scope": "product:example-product",
41
+ "q": "setup guide",
42
+ "per_page": 20
43
+ }
44
+ ```
45
+
46
+ ## `n2n_get_post`
47
+
48
+ Read an existing post before updating it, completing missing locales, or writing a follow-up.
49
+
50
+ ```json
51
+ {
52
+ "id_or_slug": "example-product-guide"
53
+ }
54
+ ```
55
+
56
+ ## `n2n_get_scope_context`
57
+
58
+ Read controlled facts for a `content_scope` before drafting scoped content.
59
+
60
+ ```json
61
+ {
62
+ "content_scope": "product:example-product"
63
+ }
64
+ ```
65
+
66
+ The backend returns `content_scope` plus host-defined controlled fields, commonly `canonical_url`, `docs_url`, `summary`, `key_points`, and `do_not_claim`.
67
+
68
+ ## `n2n_create_post`
69
+
70
+ Create a draft. Publishing is a separate step.
71
+
72
+ Blog post example:
73
+
74
+ ```json
75
+ {
76
+ "slug": "content-workflow-notes",
77
+ "type": "technical",
78
+ "locale": "en",
79
+ "title": "Content Workflow Notes",
80
+ "excerpt": "Practical notes from shipping a content workflow.",
81
+ "content": "## Notes\n\nMarkdown content..."
82
+ }
83
+ ```
84
+
85
+ Scoped content example (a type that requires a content_scope):
86
+
87
+ ```json
88
+ {
89
+ "slug": "example-product-guide",
90
+ "type": "guide",
91
+ "content_scope": "product:example-product",
92
+ "locale": "en",
93
+ "title": "How to Use Example Product",
94
+ "excerpt": "A short guide to using the example product.",
95
+ "content": "## Overview\n\nMarkdown content..."
96
+ }
97
+ ```
98
+
99
+ ## `n2n_update_post`
100
+
101
+ Update one locale of an existing post. Call `n2n_get_post` first.
102
+
103
+ ```json
104
+ {
105
+ "id_or_slug": "example-product-guide",
106
+ "locale": "de",
107
+ "title": "So verwenden Sie Example Product",
108
+ "excerpt": "A localized summary for the selected locale.",
109
+ "content": "## Ueberblick\n\nLocalized Markdown content..."
110
+ }
111
+ ```
112
+
113
+ ## `n2n_update_draft`
114
+
115
+ Update one locale of an unpublished draft. The client reads the post first and refuses to patch unless the backend reports `status: "draft"`.
116
+
117
+ ```json
118
+ {
119
+ "id_or_slug": "example-product-guide",
120
+ "locale": "en",
121
+ "title": "How to Use Example Product",
122
+ "excerpt": "A refined summary for the draft.",
123
+ "content": "## Overview\n\nUpdated draft Markdown content..."
124
+ }
125
+ ```
126
+
127
+ ## `n2n_publish_post`
128
+
129
+ Publish an existing draft.
130
+
131
+ ```json
132
+ {
133
+ "id_or_slug": "example-product-guide"
134
+ }
135
+ ```
136
+
137
+ Publishing state is managed exclusively through this tool, not through create or update payloads.
138
+
139
+ ## Client method mapping
140
+
141
+ - `ContentClient.listDrafts(input)` calls `GET /posts` with `status=draft` plus the provided list filters.
142
+ - `ContentClient.updateDraft(input)` calls `GET /posts/{id_or_slug}` first, checks `status === "draft"`, then calls `PATCH /posts/{id_or_slug}`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n2n-post2site",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "AI-assisted content publishing MCP server for blogs and product guides.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,9 +31,16 @@
31
31
  },
32
32
  "files": [
33
33
  "dist",
34
+ "assets",
35
+ "docs",
34
36
  "README.md",
35
37
  "CHANGELOG.md",
36
38
  ".env.example"
37
39
  ],
40
+ "author": "N2NS Lab",
41
+ "funding": {
42
+ "type": "github",
43
+ "url": "https://github.com/sponsors/n2ns"
44
+ },
38
45
  "license": "MIT"
39
46
  }