postmd-mcp-server 2.3.0 → 2.5.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/.dockerignore ADDED
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ .git
3
+ .github
4
+ .env
5
+ .env.*
6
+ *.log
7
+ scripts
8
+ README.md
9
+ LICENSE
@@ -0,0 +1,107 @@
1
+ # 태그를 밀면 npm 과 MCP 레지스트리에 올린다.
2
+ #
3
+ # 두 곳 모두 토큰을 두지 않는다. GitHub 이 발급하는 단기 OIDC 토큰으로 인증한다.
4
+ #
5
+ # npm 트러스티드 퍼블리셔. npmjs.com 의 패키지 설정에 이 저장소와 이 파일 이름을
6
+ # 한 번 등록해 두면 된다. 등록한 워크플로에서 온 요청만 받는다.
7
+ # MCP 레지스트리 io.github.<소유자> 네임스페이스는 그 소유자의 저장소에서 온 OIDC 토큰으로
8
+ # 증명된다. 별도 설정이 없다.
9
+ #
10
+ # 사람이 브라우저에서 승인하는 단계는 없다. 손으로 올리던 때는 npm 은 보안 키로 2차 인증을,
11
+ # 레지스트리는 GitHub 기기 흐름 승인을 매번 요구했다.
12
+ name: publish
13
+
14
+ on:
15
+ push:
16
+ tags: ['v*']
17
+
18
+ jobs:
19
+ publish:
20
+ runs-on: ubuntu-latest
21
+ permissions:
22
+ # 두 곳의 인증이 모두 이 토큰으로 이루어진다.
23
+ id-token: write
24
+ contents: read
25
+
26
+ steps:
27
+ - uses: actions/checkout@v5
28
+
29
+ - uses: actions/setup-node@v5
30
+ with:
31
+ node-version: '22'
32
+ registry-url: 'https://registry.npmjs.org'
33
+
34
+ # 트러스티드 퍼블리싱은 npm 11.5.1 이상에서만 된다. 러너에 실린 판을 믿지 않는다.
35
+ - name: Use a recent npm
36
+ run: npm install -g npm@latest
37
+
38
+ # 세 곳의 버전이 어긋나면 레지스트리가 거절한다. 올리기 전에 여기서 잡는다.
39
+ - name: Versions must match the tag
40
+ run: |
41
+ TAG="${GITHUB_REF#refs/tags/v}"
42
+ PKG=$(node -p "require('./package.json').version")
43
+ SRV=$(node -p "require('./server.json').version")
44
+ SRVPKG=$(node -p "require('./server.json').packages[0].version")
45
+ echo "tag=$TAG package.json=$PKG server.json=$SRV server.json/packages=$SRVPKG"
46
+ for v in "$PKG" "$SRV" "$SRVPKG"; do
47
+ if [ "$v" != "$TAG" ]; then
48
+ echo "::error::version mismatch — tag is $TAG"
49
+ exit 1
50
+ fi
51
+ done
52
+
53
+ - run: npm ci
54
+
55
+ - name: Syntax check
56
+ run: |
57
+ node --check src/index.js
58
+ node --check src/env.js
59
+ node --check scripts/smoke-test.mjs
60
+
61
+ # 이미 올라간 버전을 다시 올리면 npm 이 403 을 준다. 태그를 다시 밀거나 레지스트리
62
+ # 쪽만 다시 올려야 할 때 이 단계에서 멈추지 않도록 한다.
63
+ - name: Publish to npm
64
+ run: |
65
+ VERSION=$(node -p "require('./package.json').version")
66
+ NAME=$(node -p "require('./package.json').name")
67
+ if npm view "$NAME@$VERSION" version > /dev/null 2>&1; then
68
+ echo "$NAME@$VERSION is already on npm — skipping"
69
+ else
70
+ npm publish --access public
71
+ fi
72
+
73
+ - name: Install mcp-publisher
74
+ run: |
75
+ curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
76
+
77
+ # 레지스트리는 소유권을 npm 으로 확인한다. 방금 올린 판의 메타데이터를 받아 mcpName 이
78
+ # 서버 이름과 같은지 본다. 그런데 npm 에 올린 직후에는 그 버전이 아직 보이지 않아
79
+ # 곧바로 부르면 404 로 떨어진다. 2.4.0 이 실제로 이렇게 실패했다 - npm 에는 올라갔는데
80
+ # 레지스트리만 빠져 두 곳이 어긋났다. 보일 때까지 기다린 다음 넘어간다.
81
+ - name: Wait until npm serves this version
82
+ run: |
83
+ NAME=$(node -p "require('./package.json').name")
84
+ VERSION=$(node -p "require('./package.json').version")
85
+ for i in $(seq 1 30); do
86
+ MCP_NAME=$(curl -fsS "https://registry.npmjs.org/$NAME/$VERSION" 2>/dev/null \
87
+ | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{process.stdout.write(JSON.parse(s).mcpName||'')}catch(e){}})")
88
+ if [ -n "$MCP_NAME" ]; then
89
+ echo "npm serves $NAME@$VERSION with mcpName $MCP_NAME"
90
+ exit 0
91
+ fi
92
+ echo "attempt $i/30: not visible yet"
93
+ sleep 10
94
+ done
95
+ echo "::error::npm did not serve $NAME@$VERSION within 5 minutes"
96
+ exit 1
97
+
98
+ # 기다린 뒤에도 순간적으로 실패할 수 있다. 같은 버전을 다시 올리는 것은 문제가 없다.
99
+ - name: Publish to the MCP registry
100
+ run: |
101
+ ./mcp-publisher login github-oidc
102
+ for i in 1 2 3; do
103
+ if ./mcp-publisher publish; then exit 0; fi
104
+ echo "publish failed ($i/3), retrying in 20s"
105
+ sleep 20
106
+ done
107
+ exit 1
@@ -0,0 +1,47 @@
1
+ # 레지스트리에 올라간 버전의 상태를 바꾼다. 손으로 돌린다.
2
+ #
3
+ # 잘못 올린 버전을 정리하는 데 쓴다. 레지스트리는 semver 가 가장 높은 것을 최신으로 보므로,
4
+ # 실수로 올린 높은 번호를 그냥 두면 그것이 계속 최신으로 남는다.
5
+ #
6
+ # 인증은 발행과 같다. GitHub 이 발급하는 단기 OIDC 토큰이라 사람이 승인할 것이 없다.
7
+ name: registry-status
8
+
9
+ on:
10
+ workflow_dispatch:
11
+ inputs:
12
+ version:
13
+ description: '상태를 바꿀 버전 (예: 3.0.0)'
14
+ required: true
15
+ status:
16
+ description: '바꿀 상태'
17
+ required: true
18
+ default: deleted
19
+ type: choice
20
+ options: [active, deprecated, deleted]
21
+ message:
22
+ description: '이유. 레지스트리에 함께 남는다'
23
+ required: false
24
+ default: ''
25
+
26
+ jobs:
27
+ status:
28
+ runs-on: ubuntu-latest
29
+ permissions:
30
+ id-token: write
31
+ contents: read
32
+
33
+ steps:
34
+ - uses: actions/checkout@v5
35
+
36
+ - name: Install mcp-publisher
37
+ run: |
38
+ curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
39
+
40
+ - name: Set the version status
41
+ run: |
42
+ NAME=$(node -p "require('./server.json').name")
43
+ ./mcp-publisher login github-oidc
44
+ ./mcp-publisher status \
45
+ --status "${{ inputs.status }}" \
46
+ --message "${{ inputs.message }}" \
47
+ "$NAME" "${{ inputs.version }}"
package/Dockerfile ADDED
@@ -0,0 +1,20 @@
1
+ # 원격(streamable HTTP) 서버 이미지. stdio 로 쓰는 사람은 이 이미지가 필요 없다 —
2
+ # npx 로 바로 돌아간다.
3
+ FROM node:22-alpine
4
+
5
+ WORKDIR /app
6
+
7
+ # 의존성 먼저. 소스만 바뀔 때 이 층을 다시 받지 않는다.
8
+ COPY package.json package-lock.json ./
9
+ RUN npm ci --omit=dev
10
+
11
+ COPY src ./src
12
+
13
+ # 루트로 돌릴 이유가 없다. node 사용자는 베이스 이미지에 이미 있다.
14
+ USER node
15
+
16
+ ENV NODE_ENV=production
17
+ ENV PORT=8080
18
+ EXPOSE 8080
19
+
20
+ CMD ["node", "src/http.js"]
package/README.md CHANGED
@@ -1,21 +1,29 @@
1
1
  # PostMD MCP Server
2
2
 
3
- stdio [Model Context Protocol](https://modelcontextprotocol.io) server for **[PostMD](https://postmd.turink.com)** — publish a Markdown document, get a web page you share by link. Optional groups, document passwords, share expiry and viewer themes. This server wraps PostMD's public API (`/api/v1`) so assistants can publish, read, update and organize documents.
3
+ [Model Context Protocol](https://modelcontextprotocol.io) server for **[PostMD](https://postmd.turink.com)** — publish a Markdown document, get a web page you share by link, and add a document graph that the viewer draws beside the text: the reading order, the bodies and rules a document names, a procedure spread over its chapters. Optional groups, document passwords and viewer themes. This server wraps PostMD's public API (`/api/v1`) so assistants can publish, read, update and organize documents.
4
4
 
5
- **Publishing needs no account and no key.** With zero configuration this server can already turn Markdown into a shareable page. An API key adds management: updating and deleting your documents, attachments, and groups.
5
+ **Publishing needs no account and no key.** With zero configuration this server can already turn Markdown into a shareable page, and the hosted server at `https://postmd.turink.com/mcp` needs no install either. An API key adds management: organizing documents in groups, and notes.
6
6
 
7
- **Anonymous documents come with a control token.** Publishing without a key returns `data.controlToken` and `data.retainedUntil`: the document is deleted at that instant, and the token is the only way to update or delete it before then. It is shown once and cannot be reissued, so keep it with the `docCode`. Pass it as `controlToken` to the update and delete tools and they work without an API key.
7
+ **30-day retention.** Documents have a 30-day retention period (`data.retainedUntil`) that extends by 30 days whenever the document is read (at most once per day). Documents without a password can be updated or deleted by anyone; password-protected documents require the password or the owner's API key.
8
8
 
9
- **HTTP reference:** [postmd.turink.com/docs/api](https://postmd.turink.com/docs/api) · machine-readable spec at [/api-docs](https://postmd.turink.com/api-docs)
9
+ **Where things are written down:** [/llms.txt](https://postmd.turink.com/llms.txt) lists what the service can do and which page answers each thing; it is the place to start. [/docs/api](https://postmd.turink.com/docs/api) is the HTTP reference and [/api-docs](https://postmd.turink.com/api-docs) the machine-readable spec.
10
10
 
11
- ## Requirements
11
+ ## Hosted or local
12
12
 
13
- - **Node.js** 20 or later
14
- - Nothing else. An **API key** (`pmk_…`) only for the management tools.
13
+ | | Hosted | Local |
14
+ |---|---|---|
15
+ | Address | `https://postmd.turink.com/mcp` | `npx -y postmd-mcp-server` |
16
+ | Needs | nothing | Node.js 20 or later |
17
+ | Tools | 5 | all 20 |
18
+ | API key | not accepted | optional, for member-scoped tools |
19
+ | Clients | any, including web-only ones such as ChatGPT and claude.ai | any that can run a local process |
20
+
21
+ The hosted server has no way to receive an API key, so it carries only the tools that need
22
+ none. Groups, notes and the tools that read a file from your disk are local-only.
15
23
 
16
24
  ## Configuration
17
25
 
18
- All variables are optional.
26
+ Local only — the hosted server reads none of these. All are optional.
19
27
 
20
28
  | Variable | Description |
21
29
  |----------|-------------|
@@ -27,6 +35,9 @@ Load order: this repo's `.env` (if present) is applied via `dotenv` without over
27
35
 
28
36
  ## Tools
29
37
 
38
+ Every tool below works on the local server. The hosted server carries five of them:
39
+ `postmd_create_document`, `postmd_get_document`, `postmd_get_document_raw`, `postmd_update_document`, and `postmd_delete_document`.
40
+
30
41
  Publishing and reading — no key needed:
31
42
 
32
43
  | Tool | Purpose |
@@ -36,18 +47,29 @@ Publishing and reading — no key needed:
36
47
  | `postmd_get_document` | Metadata by `docCode` |
37
48
  | `postmd_get_document_raw` | Stored Markdown body (optional `password`) |
38
49
 
39
- Managing documents — key with `documents:write`:
40
-
41
- Each of the first three also accepts `controlToken` instead of a key, for a document published anonymously.
50
+ Managing documents — key with `documents:write` (or password / no credential for unowned documents without password):
42
51
 
43
52
  | Tool | Purpose |
44
53
  |------|---------|
45
- | `postmd_update_document` | Replace content and/or metadata; can clear password / end date |
54
+ | `postmd_update_document` | Replace content and/or metadata; can clear password |
46
55
  | `postmd_update_document_from_file` | Same, body read from a local `filePath` |
47
56
  | `postmd_delete_document` | Delete a document (no undo) |
48
- | `postmd_upload_attachment` | Upload an image/PDF, get a URL to embed in Markdown |
49
57
  | `postmd_create_documents_from_files` | Bulk-publish several `.md` files in one call |
50
- | `postmd_move_document_to_group` | Move a document into a group / folder |
58
+ | `postmd_move_document_to_group` | Move a document into a group |
59
+
60
+ ### Graphs
61
+
62
+ A PostMD document can carry graph data that the viewer draws in a panel beside the text, showing
63
+ how the parts of the document relate. No tool here creates or edits a graph, because there is no
64
+ endpoint for one: the data sits in the Markdown as an HTML comment and travels with the body.
65
+
66
+ Adding a graph to an existing document therefore means reading it with `postmd_get_document_raw`,
67
+ inserting the comment, and sending the whole body back with `postmd_update_document`. Publishing a
68
+ new document with a graph is an ordinary `postmd_create_document` call.
69
+
70
+ The format is at <https://postmd.turink.com/docs/graph>. Working out what the nodes are and how
71
+ they connect requires reading the document, which is the calling agent's part; PostMD only draws
72
+ what it finds.
51
73
 
52
74
  ### Replacing content on a document that has notes
53
75
 
@@ -87,23 +109,40 @@ Groups — key with `groups:read` / `groups:write`:
87
109
  | `postmd_list_groups` | Groups visible to the key (paged) |
88
110
  | `postmd_list_group_documents` | Documents in a group (paged, searchable, sortable) |
89
111
  | `postmd_create_group` | New group |
90
- | `postmd_update_group` | Rename / change expiry |
112
+ | `postmd_update_group` | Rename group |
91
113
  | `postmd_delete_group` | Delete a group (documents survive) |
92
114
 
93
115
  For uploads: either pass the full Markdown as the `markdown` argument, or pass a local `filePath` only so this server reads the file. The path must exist on the machine running the MCP server.
94
116
 
95
- ## Quickstart
117
+ ## Client configuration
118
+
119
+ ### Hosted
96
120
 
97
- Nothing to install. `npx` fetches the package and the MCP client spawns it.
121
+ Claude Code:
98
122
 
99
123
  ```bash
100
- npx -y postmd-mcp-server
124
+ claude mcp add --transport http postmd https://postmd.turink.com/mcp
101
125
  ```
102
126
 
103
- Run it by hand only to check that it starts — it speaks MCP over stdin and stdout, so it
104
- will sit there waiting for a client.
127
+ Clients that take a JSON config:
105
128
 
106
- ## Client configuration
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "PostMD": {
133
+ "type": "http",
134
+ "url": "https://postmd.turink.com/mcp"
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ In ChatGPT, add it under **Settings → Apps**; in claude.ai, under **Settings → Connectors**.
141
+ There is nothing to authorize.
142
+
143
+ ### Local
144
+
145
+ There is nothing to install: `npx` fetches the package and the client spawns it.
107
146
 
108
147
  Claude Code:
109
148
 
@@ -137,6 +176,9 @@ claude mcp add postmd-dev -- node "$PWD/src/index.js"
137
176
 
138
177
  Leave `env` out entirely for publish/read-only use. `cp .env.example .env` works too — the server loads its own `.env`.
139
178
 
179
+ Running `npx -y postmd-mcp-server` by hand only checks that it starts. It speaks MCP over
180
+ stdin and stdout, so it will sit there waiting for a client.
181
+
140
182
  ## Smoke test
141
183
 
142
184
  Runs the full write path against a live server and cleans up after itself. Needs a key with all four scopes.
@@ -146,7 +188,7 @@ export POSTMD_API_KEY=pmk_…
146
188
  npm run smoke
147
189
  ```
148
190
 
149
- Creates a group and a passworded document, reads it back, updates it, clears the password, then deletes both. It also publishes one document with no credential and removes it with the control token.
191
+ Creates a group and a passworded document, reads it back, updates it, clears the password, then deletes both. It also publishes one document with no credential and removes it without a token.
150
192
 
151
193
  ## Stack
152
194
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "postmd-mcp-server",
3
- "version": "2.3.0",
4
- "description": "MCP server for PostMD — publish Markdown, get a shareable web page",
3
+ "version": "2.5.0",
4
+ "description": "MCP server for PostMD — publish Markdown as a shareable web page, with document graphs beside the text",
5
5
  "mcpName": "io.github.reinlainer/postmd-mcp-server",
6
6
  "type": "module",
7
7
  "main": "src/index.js",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "node src/index.js",
13
+ "start:http": "node src/http.js",
13
14
  "smoke": "node scripts/smoke-test.mjs"
14
15
  },
15
16
  "engines": {
@@ -24,7 +25,8 @@
24
25
  "mcp",
25
26
  "postmd",
26
27
  "markdown",
27
- "publishing"
28
+ "publishing",
29
+ "document-graph"
28
30
  ],
29
31
  "license": "MIT",
30
32
  "dependencies": {
@@ -125,10 +125,9 @@ if (groupId != null) {
125
125
  }
126
126
 
127
127
  /*
128
- 9. 익명 발행과 제어 토큰.
128
+ 9. 익명 발행 (비밀번호 없는 문서).
129
129
 
130
- 자격 증명을 붙이지 않고 부른다. 키를 실으면 그 회원 소유가 되어 토큰이 나오지 않으므로,
131
- 여기서만 Authorization 헤더를 뺀다.
130
+ 자격 증명을 붙이지 않고 부른다.
132
131
  */
133
132
  const anonForm = new FormData();
134
133
  anonForm.append(
@@ -141,32 +140,22 @@ const anon = await anonRes.json().catch(() => null);
141
140
  check("publish without a credential", anon?.resultCode === "200", JSON.stringify(anon));
142
141
 
143
142
  const anonCode = anon?.data?.docCode;
144
- const controlToken = anon?.data?.controlToken;
145
- check("answer carries a control token", typeof controlToken === "string" && controlToken.startsWith("pmt_"));
146
- check("answer carries a deletion date", typeof anon?.data?.retainedUntil === "string");
143
+ check("answer carries a retention date", typeof anon?.data?.retainedUntil === "string");
147
144
  check("answer explains the terms", typeof anon?.message === "string" && anon.message.length > 0);
148
145
 
149
- if (anonCode && controlToken) {
146
+ if (anonCode) {
150
147
  const titled = new FormData();
151
148
  titled.append("title", `mcp smoke anon ${stamp}`);
152
149
  const changed = await fetch(`${base}/api/v1/documents/${anonCode}/update`, {
153
150
  method: "POST",
154
- headers: { "X-Document-Token": controlToken },
155
151
  body: titled,
156
152
  });
157
- check("token updates the document", (await changed.json().catch(() => null))?.resultCode === "200");
158
-
159
- const refused = await fetch(`${base}/api/v1/documents/${anonCode}/delete`, {
160
- method: "POST",
161
- headers: { "X-Document-Token": "pmt_wrong" },
162
- });
163
- check("a wrong token is refused", (await refused.json().catch(() => null))?.resultCode === "E_DOC_0008");
153
+ check("anyone can update a document without password", (await changed.json().catch(() => null))?.resultCode === "200");
164
154
 
165
155
  const removed = await fetch(`${base}/api/v1/documents/${anonCode}/delete`, {
166
156
  method: "POST",
167
- headers: { "X-Document-Token": controlToken },
168
157
  });
169
- check("token deletes the document", (await removed.json().catch(() => null))?.resultCode === "200");
158
+ check("anyone can delete a document without password", (await removed.json().catch(() => null))?.resultCode === "200");
170
159
  }
171
160
 
172
161
  console.log(failures ? `\n${failures} failure(s)` : "\nall good");
package/server.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.reinlainer/postmd-mcp-server",
4
- "description": "Publish Markdown to PostMD and get a shareable web page",
5
- "version": "2.3.0",
4
+ "description": "Publish Markdown as a shareable web page, with document graphs drawn beside the text",
5
+ "version": "2.5.0",
6
6
  "repository": {
7
7
  "url": "https://github.com/reinlainer/postmd-mcp-server",
8
8
  "source": "github"
@@ -11,7 +11,7 @@
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "postmd-mcp-server",
14
- "version": "2.3.0",
14
+ "version": "2.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -24,7 +24,7 @@
24
24
  },
25
25
  {
26
26
  "name": "POSTMD_API_KEY",
27
- "description": "Needed only for managing documents, attachments and groups. Publishing and reading work without it.",
27
+ "description": "Needed only for managing documents and groups. Publishing and reading work without it.",
28
28
  "isRequired": false,
29
29
  "isSecret": true
30
30
  },
@@ -36,5 +36,11 @@
36
36
  }
37
37
  ]
38
38
  }
39
+ ],
40
+ "remotes": [
41
+ {
42
+ "type": "streamable-http",
43
+ "url": "https://postmd.turink.com/mcp"
44
+ }
39
45
  ]
40
46
  }
package/src/http.js ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 원격 진입점. streamable HTTP 로 받는다.
4
+ *
5
+ * 웹에서 도는 클라이언트(ChatGPT, claude.ai)는 로컬 프로세스를 띄우지 못해 stdio 서버에
6
+ * 붙을 수 없다. 그쪽이 요구하는 것은 고정 HTTPS 주소 하나다.
7
+ *
8
+ * 도구는 `server.js` 가 갖고 있고 여기서 여는 것은 그중 자격 증명 없이 되는 다섯 개다
9
+ * (`REMOTE_TOOLS`). 그래서 이 서버에는 인증이 없다.
10
+ *
11
+ * 상태를 두지 않는다(stateless). 요청마다 서버와 전송을 새로 만들고 끝나면 버린다.
12
+ * 도구가 모두 API 한 번 부르고 끝나는 것이라 요청 사이에 이어 둘 것이 없고, 세션을 들면
13
+ * 그때부터 메모리에 남는 것이 생겨 컨테이너를 늘릴 때 붙는 자리가 된다.
14
+ */
15
+ import http from "node:http";
16
+ import process from "node:process";
17
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
+ import { createMcpServer, VERSION } from "./server.js";
19
+
20
+ const PORT = Number(process.env.PORT || process.env.POSTMD_MCP_PORT || 8080);
21
+ const MCP_PATH = "/mcp";
22
+
23
+ /**
24
+ * 본문 상한. 문서 본문이 JSON 안에 실려 오므로 업로드 상한보다 넉넉해야 하지만, 열어
25
+ * 두면 아무나 부르는 자리에서 메모리를 밀어 넣을 수 있다. 실제 문서 크기 판정은 API 가
26
+ * 한다 - 여기는 그 앞의 거친 그물이다.
27
+ */
28
+ const MAX_BODY = 8 * 1024 * 1024;
29
+
30
+ function json(res, status, body) {
31
+ const payload = JSON.stringify(body);
32
+ res.writeHead(status, { "content-type": "application/json" });
33
+ res.end(payload);
34
+ }
35
+
36
+ const httpServer = http.createServer(async (req, res) => {
37
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
38
+
39
+ // 컨테이너와 프록시가 살아 있는지 보는 자리. MCP 와 무관하다.
40
+ if (url.pathname === "/health") {
41
+ return json(res, 200, { status: "ok", version: VERSION });
42
+ }
43
+
44
+ if (url.pathname !== MCP_PATH) {
45
+ return json(res, 404, {
46
+ error: `Not found. This server speaks MCP over streamable HTTP at ${MCP_PATH}.`,
47
+ });
48
+ }
49
+
50
+ const length = Number(req.headers["content-length"] || 0);
51
+ if (length > MAX_BODY) {
52
+ return json(res, 413, { error: "Request body too large." });
53
+ }
54
+
55
+ // 요청 하나에 서버 하나. 끝나면 둘 다 닫아 아무것도 남기지 않는다.
56
+ const server = createMcpServer({ remote: true });
57
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
58
+
59
+ res.on("close", () => {
60
+ transport.close().catch(() => {});
61
+ server.close().catch(() => {});
62
+ });
63
+
64
+ try {
65
+ await server.connect(transport);
66
+ await transport.handleRequest(req, res);
67
+ } catch (e) {
68
+ process.stderr.write(`mcp request failed: ${e instanceof Error ? e.stack : e}\n`);
69
+ if (!res.headersSent) json(res, 500, { error: "Internal error." });
70
+ }
71
+ });
72
+
73
+ httpServer.listen(PORT, () => {
74
+ process.stdout.write(`postmd-mcp-server ${VERSION} — streamable HTTP on :${PORT}${MCP_PATH}\n`);
75
+ });