saju-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/dist/actor.js +268 -0
- package/dist/index.js +224 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Saju MCP Server
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server that
|
|
4
|
+
wraps the **Saju API** — Korean Four Pillars of Destiny (사주팔자 / Bazi) — so
|
|
5
|
+
MCP-capable clients (Claude Desktop, Cursor, and other MCP hosts) can compute
|
|
6
|
+
and interpret Saju directly in a conversation.
|
|
7
|
+
|
|
8
|
+
Backed by the live API at **https://saju-api.pages.dev** (10 languages:
|
|
9
|
+
ko, en, ja, zh, es, pt-br, vi, id, hi, th).
|
|
10
|
+
|
|
11
|
+
## Tools
|
|
12
|
+
|
|
13
|
+
| Tool | Upstream endpoint | What it does |
|
|
14
|
+
|------|-------------------|--------------|
|
|
15
|
+
| `saju_calculate` | `POST /api/v1/calculate` | Four Pillars (stem+branch+hanja), five-element distribution, Day Master, zodiac, from a solar birthdate. |
|
|
16
|
+
| `saju_interpret` | `POST /api/v1/interpret` | Full reading: Ten Gods (십신), hidden stems, Yongshin (용신), Daeun (대운), localized summaries. |
|
|
17
|
+
| `saju_compatibility` | `POST /api/v1/compatibility` | Two-person 궁합 score (0–100) with breakdown (element balance, Day Master relation, branch harmony/clash). |
|
|
18
|
+
| `saju_daily` | `GET /api/v1/daily` | Daily fortune snapshot (score + advice) for a Day Master and date. |
|
|
19
|
+
|
|
20
|
+
## Prerequisites
|
|
21
|
+
|
|
22
|
+
- Node.js **18+** (uses the built-in global `fetch`).
|
|
23
|
+
- A **Saju API key**. The free tier is **100 requests/day, no credit card**.
|
|
24
|
+
|
|
25
|
+
### Get a free API key
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
curl -X POST https://saju-api.pages.dev/api/v1/keys/create \
|
|
29
|
+
-H "Content-Type: application/json" \
|
|
30
|
+
-d '{"email":"you@example.com"}'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The response contains an `api_key` of the form `sajuapi_free_...`. Keep it
|
|
34
|
+
secret — it is passed to the server via the `SAJU_API_KEY` environment variable,
|
|
35
|
+
never hardcoded.
|
|
36
|
+
|
|
37
|
+
## Install & build
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install
|
|
41
|
+
npm run build # compiles src/index.ts -> dist/index.js
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Quick local check (lists the 4 tools, then exits):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
SAJU_API_KEY="sajuapi_free_xxx" npm start
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Register in Claude Desktop
|
|
51
|
+
|
|
52
|
+
Edit your `claude_desktop_config.json`:
|
|
53
|
+
|
|
54
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
55
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
56
|
+
|
|
57
|
+
Add (use the **absolute path** to the built `dist/index.js`):
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"saju": {
|
|
63
|
+
"command": "node",
|
|
64
|
+
"args": ["D:\\kunstudio-apps\\saju-mcp\\dist\\index.js"],
|
|
65
|
+
"env": {
|
|
66
|
+
"SAJU_API_KEY": "sajuapi_free_your_key_here"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Restart Claude Desktop. The four `saju_*` tools appear in the tools menu.
|
|
74
|
+
|
|
75
|
+
> Other MCP hosts (Cursor, Windsurf, custom clients) use the same shape:
|
|
76
|
+
> `command: "node"`, `args: ["<abs path>/dist/index.js"]`, and a
|
|
77
|
+
> `SAJU_API_KEY` env var.
|
|
78
|
+
|
|
79
|
+
## Environment variables
|
|
80
|
+
|
|
81
|
+
| Variable | Required | Default | Notes |
|
|
82
|
+
|----------|----------|---------|-------|
|
|
83
|
+
| `SAJU_API_KEY` | yes (for real calls) | _(empty)_ | Your `sajuapi_*` key, sent as the `X-API-Key` header. Without it, every call returns `401 invalid_api_key`. |
|
|
84
|
+
| `SAJU_API_BASE` | no | `https://saju-api.pages.dev` | Override the upstream base URL (e.g. for a staging deploy). |
|
|
85
|
+
|
|
86
|
+
## Example tool inputs
|
|
87
|
+
|
|
88
|
+
`saju_calculate` / `saju_interpret`:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{ "year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "M", "lang": "en" }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
(`hour: -1` if the birth hour is unknown.)
|
|
95
|
+
|
|
96
|
+
`saju_compatibility`:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"person_a": { "year": 1990, "month": 5, "day": 15, "hour": 14, "gender": "M" },
|
|
101
|
+
"person_b": { "year": 1992, "month": 8, "day": 3, "hour": 9, "gender": "F" },
|
|
102
|
+
"lang": "en"
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`saju_daily` (Day Master from a prior calculate/interpret call):
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{ "day_master": "갑", "date": "2026-06-17", "lang": "en" }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
Proprietary — KunStudio. Wraps the Saju API; subject to that API's terms.
|
package/dist/actor.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saju MCP Server — Apify Actor entrypoint (Streamable HTTP transport).
|
|
3
|
+
* -------------------------------------------------------------------
|
|
4
|
+
* This is the *cloud / Apify Store* entrypoint. It exposes the SAME four
|
|
5
|
+
* Saju tools as the stdio server (src/index.ts) but over Streamable HTTP,
|
|
6
|
+
* wired into Apify's Standby web server, and charges per tool call using
|
|
7
|
+
* pay-per-event (PPE) billing.
|
|
8
|
+
*
|
|
9
|
+
* The original stdio MCP server (src/index.ts) is left completely untouched
|
|
10
|
+
* so the free npm / Claude Desktop path keeps working. This file is only
|
|
11
|
+
* used inside an Apify Actor container.
|
|
12
|
+
*
|
|
13
|
+
* Pattern is taken verbatim from Apify's official TypeScript MCP template
|
|
14
|
+
* (apify/actor-templates → templates/ts-mcp-empty/src/main.ts):
|
|
15
|
+
* - Standby mode, web server listens on process.env.APIFY_CONTAINER_PORT
|
|
16
|
+
* - Stateless Streamable HTTP transport (new server per POST /mcp)
|
|
17
|
+
* - webServerMcpPath "/mcp" (see .actor/actor.json)
|
|
18
|
+
* - Readiness probe on GET / with the x-apify-container-server-readiness-probe header
|
|
19
|
+
* - Actor.charge({ eventName }) per tool call (events in .actor/pay_per_event.json)
|
|
20
|
+
*
|
|
21
|
+
* Auth to the upstream Saju API still comes from the SAJU_API_KEY env var,
|
|
22
|
+
* never hardcoded. On Apify it is set as an Actor environment variable / secret.
|
|
23
|
+
*/
|
|
24
|
+
import express from 'express';
|
|
25
|
+
import cors from 'cors';
|
|
26
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
27
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
import { Actor, log } from 'apify';
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Apify init (configures the Actor for its environment; must run at startup).
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
await Actor.init();
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Configuration (identical semantics to src/index.ts).
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const API_BASE = process.env.SAJU_API_BASE ?? 'https://saju-api.pages.dev';
|
|
38
|
+
const API_KEY = process.env.SAJU_API_KEY ?? '';
|
|
39
|
+
// Single PPE event: charged once per successful tool call (price in
|
|
40
|
+
// .actor/pay_per_event.json — the file is the source of truth, finalized
|
|
41
|
+
// in the Apify Console Monetization step before publishing).
|
|
42
|
+
const CHARGE_EVENT = 'tool-call';
|
|
43
|
+
const SUPPORTED_LANGS = [
|
|
44
|
+
'ko', 'en', 'ja', 'zh', 'es', 'pt-br', 'vi', 'id', 'hi', 'th',
|
|
45
|
+
];
|
|
46
|
+
if (!API_KEY) {
|
|
47
|
+
log.warning('[saju-mcp] SAJU_API_KEY is not set. Every tool call will return 401 ' +
|
|
48
|
+
'invalid_api_key until you set it as an Actor environment variable. ' +
|
|
49
|
+
'Get a free key (100 req/day, no card) at ' +
|
|
50
|
+
'POST https://saju-api.pages.dev/api/v1/keys/create');
|
|
51
|
+
}
|
|
52
|
+
async function callApi(path, init = { method: 'GET' }) {
|
|
53
|
+
const headers = {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
// Header name MUST be X-API-Key (see openapi.yaml securitySchemes).
|
|
56
|
+
'X-API-Key': API_KEY,
|
|
57
|
+
};
|
|
58
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
59
|
+
method: init.method,
|
|
60
|
+
headers,
|
|
61
|
+
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
|
62
|
+
});
|
|
63
|
+
let body;
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
try {
|
|
66
|
+
body = text ? JSON.parse(text) : {};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
body = { error: 'non_json_response', raw: text.slice(0, 500) };
|
|
70
|
+
}
|
|
71
|
+
return { ok: res.ok, status: res.status, body };
|
|
72
|
+
}
|
|
73
|
+
function toToolResponse(result) {
|
|
74
|
+
if (!result.ok) {
|
|
75
|
+
const errObj = result.body;
|
|
76
|
+
const hint = result.status === 401
|
|
77
|
+
? ' (set the SAJU_API_KEY Actor environment variable to a valid key — get a free one at POST /api/v1/keys/create)'
|
|
78
|
+
: result.status === 429
|
|
79
|
+
? ' (daily quota for your tier exceeded)'
|
|
80
|
+
: '';
|
|
81
|
+
return {
|
|
82
|
+
isError: true,
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: 'text',
|
|
86
|
+
text: `Saju API error ${result.status}: ${JSON.stringify(errObj)}${hint}`,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
content: [
|
|
93
|
+
{ type: 'text', text: JSON.stringify(result.body, null, 2) },
|
|
94
|
+
],
|
|
95
|
+
structuredContent: result.body,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Shared Zod shapes (mirrors openapi.yaml BirthRequest exactly).
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
const langField = z
|
|
102
|
+
.enum(SUPPORTED_LANGS)
|
|
103
|
+
.optional()
|
|
104
|
+
.describe('Output language (default ko). One of: ' + SUPPORTED_LANGS.join(', '));
|
|
105
|
+
const birthShape = {
|
|
106
|
+
year: z.number().int().min(1920).max(2050)
|
|
107
|
+
.describe('Solar (Gregorian) birth year, 1920–2050'),
|
|
108
|
+
month: z.number().int().min(1).max(12).describe('Birth month, 1–12'),
|
|
109
|
+
day: z.number().int().min(1).max(31).describe('Birth day, 1–31'),
|
|
110
|
+
hour: z.number().int().min(-1).max(23)
|
|
111
|
+
.describe('Birth hour in 24h time (0–23). Use -1 if the hour is unknown.'),
|
|
112
|
+
gender: z.enum(['M', 'F'])
|
|
113
|
+
.describe("Gender: 'M' or 'F' (affects Daeun / luck-pillar direction)."),
|
|
114
|
+
};
|
|
115
|
+
const personSchema = z.object({
|
|
116
|
+
...birthShape,
|
|
117
|
+
lang: langField,
|
|
118
|
+
});
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Build a fresh McpServer with the four Saju tools registered.
|
|
121
|
+
// A new instance is created per request (stateless Streamable HTTP).
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
function getServer() {
|
|
124
|
+
const server = new McpServer({ name: 'saju-mcp', version: '0.1.0' }, { capabilities: { logging: {} } });
|
|
125
|
+
// 1) saju_calculate — POST /api/v1/calculate
|
|
126
|
+
server.registerTool('saju_calculate', {
|
|
127
|
+
title: 'Calculate Saju (Four Pillars)',
|
|
128
|
+
description: 'Compute the Korean Four Pillars of Destiny (사주팔자 / Bazi) from a ' +
|
|
129
|
+
'solar birthdate. Returns the year/month/day/hour pillars (heavenly ' +
|
|
130
|
+
'stem + earthly branch, with hanja), the five-element distribution ' +
|
|
131
|
+
'(wood/fire/earth/metal/water), the Day Master, and the zodiac animal.',
|
|
132
|
+
inputSchema: { ...birthShape, lang: langField },
|
|
133
|
+
}, async (args) => {
|
|
134
|
+
await Actor.charge({ eventName: CHARGE_EVENT });
|
|
135
|
+
const result = await callApi('/api/v1/calculate', { method: 'POST', body: args });
|
|
136
|
+
return toToolResponse(result);
|
|
137
|
+
});
|
|
138
|
+
// 2) saju_interpret — POST /api/v1/interpret
|
|
139
|
+
server.registerTool('saju_interpret', {
|
|
140
|
+
title: 'Interpret Saju (Ten Gods, Yongshin, Daeun)',
|
|
141
|
+
description: 'Full Saju interpretation from a solar birthdate: the Four Pillars plus ' +
|
|
142
|
+
'Ten Gods (십신), hidden stems, life stages, interactions, Yongshin ' +
|
|
143
|
+
'(용신 / useful god), Daeun (대운 / luck pillars), and human-readable ' +
|
|
144
|
+
'summaries in the requested language.',
|
|
145
|
+
inputSchema: { ...birthShape, lang: langField },
|
|
146
|
+
}, async (args) => {
|
|
147
|
+
await Actor.charge({ eventName: CHARGE_EVENT });
|
|
148
|
+
const result = await callApi('/api/v1/interpret', { method: 'POST', body: args });
|
|
149
|
+
return toToolResponse(result);
|
|
150
|
+
});
|
|
151
|
+
// 3) saju_compatibility — POST /api/v1/compatibility
|
|
152
|
+
server.registerTool('saju_compatibility', {
|
|
153
|
+
title: 'Saju Compatibility (궁합)',
|
|
154
|
+
description: 'Score two-person compatibility (궁합) from 0–100 based on both ' +
|
|
155
|
+
"people's Saju. Returns the overall score and a breakdown " +
|
|
156
|
+
'(element balance, Day Master relation, branch harmony, branch clash) ' +
|
|
157
|
+
"plus each person's Day Master and zodiac.",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
person_a: personSchema.describe('First person (birth details).'),
|
|
160
|
+
person_b: personSchema.describe('Second person (birth details).'),
|
|
161
|
+
lang: langField,
|
|
162
|
+
},
|
|
163
|
+
}, async (args) => {
|
|
164
|
+
await Actor.charge({ eventName: CHARGE_EVENT });
|
|
165
|
+
const result = await callApi('/api/v1/compatibility', { method: 'POST', body: args });
|
|
166
|
+
return toToolResponse(result);
|
|
167
|
+
});
|
|
168
|
+
// 4) saju_daily — GET /api/v1/daily
|
|
169
|
+
server.registerTool('saju_daily', {
|
|
170
|
+
title: 'Daily Fortune Snapshot',
|
|
171
|
+
description: 'Daily fortune snapshot (0–100 score + advice) for a given Day Master ' +
|
|
172
|
+
'and date. Provide the Day Master either as a Korean stem character ' +
|
|
173
|
+
'(갑, 을, 병, 정, 무, 기, 경, 신, 임, 계) or an English alias ' +
|
|
174
|
+
'(e.g. wood_yang, water_yin). The Day Master comes from a prior ' +
|
|
175
|
+
'saju_calculate / saju_interpret call (day_master.stem).',
|
|
176
|
+
inputSchema: {
|
|
177
|
+
day_master: z.string().min(1).describe('Day Master: Korean stem (갑..계) or English alias ' +
|
|
178
|
+
'(wood_yang, wood_yin, fire_yang, ... water_yin).'),
|
|
179
|
+
date: z.string()
|
|
180
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD')
|
|
181
|
+
.optional()
|
|
182
|
+
.describe('Target date in YYYY-MM-DD. Defaults to today (UTC).'),
|
|
183
|
+
lang: langField,
|
|
184
|
+
},
|
|
185
|
+
}, async (args) => {
|
|
186
|
+
await Actor.charge({ eventName: CHARGE_EVENT });
|
|
187
|
+
const params = new URLSearchParams();
|
|
188
|
+
params.set('day_master', args.day_master);
|
|
189
|
+
if (args.date)
|
|
190
|
+
params.set('date', args.date);
|
|
191
|
+
if (args.lang)
|
|
192
|
+
params.set('lang', args.lang);
|
|
193
|
+
const result = await callApi(`/api/v1/daily?${params.toString()}`, { method: 'GET' });
|
|
194
|
+
return toToolResponse(result);
|
|
195
|
+
});
|
|
196
|
+
return server;
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Express app wired into the Apify Standby web server.
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
const app = express();
|
|
202
|
+
app.use(express.json());
|
|
203
|
+
app.use(cors({
|
|
204
|
+
origin: '*',
|
|
205
|
+
exposedHeaders: ['Mcp-Session-Id'],
|
|
206
|
+
}));
|
|
207
|
+
// Readiness probe (Apify Standby health check).
|
|
208
|
+
app.get('/', (req, res) => {
|
|
209
|
+
if (req.headers['x-apify-container-server-readiness-probe']) {
|
|
210
|
+
log.info('Readiness probe');
|
|
211
|
+
res.end('ok\n');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
res.status(404).end();
|
|
215
|
+
});
|
|
216
|
+
// MCP Streamable HTTP endpoint (matches webServerMcpPath in actor.json).
|
|
217
|
+
app.post('/mcp', async (req, res) => {
|
|
218
|
+
const server = getServer();
|
|
219
|
+
try {
|
|
220
|
+
const transport = new StreamableHTTPServerTransport({
|
|
221
|
+
sessionIdGenerator: undefined,
|
|
222
|
+
});
|
|
223
|
+
await server.connect(transport);
|
|
224
|
+
await transport.handleRequest(req, res, req.body);
|
|
225
|
+
res.on('close', () => {
|
|
226
|
+
transport.close();
|
|
227
|
+
server.close();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
log.error('Error handling MCP request', { error });
|
|
232
|
+
if (!res.headersSent) {
|
|
233
|
+
res.status(500).json({
|
|
234
|
+
jsonrpc: '2.0',
|
|
235
|
+
error: { code: -32603, message: 'Internal server error' },
|
|
236
|
+
id: null,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
app.get('/mcp', (_req, res) => {
|
|
242
|
+
res.writeHead(405).end(JSON.stringify({
|
|
243
|
+
jsonrpc: '2.0',
|
|
244
|
+
error: { code: -32000, message: 'Method not allowed.' },
|
|
245
|
+
id: null,
|
|
246
|
+
}));
|
|
247
|
+
});
|
|
248
|
+
app.delete('/mcp', (_req, res) => {
|
|
249
|
+
res.writeHead(405).end(JSON.stringify({
|
|
250
|
+
jsonrpc: '2.0',
|
|
251
|
+
error: { code: -32000, message: 'Method not allowed.' },
|
|
252
|
+
id: null,
|
|
253
|
+
}));
|
|
254
|
+
});
|
|
255
|
+
const PORT = process.env.APIFY_CONTAINER_PORT
|
|
256
|
+
? parseInt(process.env.APIFY_CONTAINER_PORT, 10)
|
|
257
|
+
: 3000;
|
|
258
|
+
app.listen(PORT, (error) => {
|
|
259
|
+
if (error) {
|
|
260
|
+
log.error('Failed to start server', { error });
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
log.info(`Saju MCP Server (Apify) listening on port ${PORT} at path /mcp`);
|
|
264
|
+
});
|
|
265
|
+
process.on('SIGINT', () => {
|
|
266
|
+
log.info('Shutting down Saju MCP Actor...');
|
|
267
|
+
process.exit(0);
|
|
268
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Saju MCP Server
|
|
4
|
+
* ---------------
|
|
5
|
+
* Exposes the live Saju API (https://saju-api.pages.dev) as Model Context
|
|
6
|
+
* Protocol tools over stdio, so MCP-capable clients (Claude Desktop, Cursor,
|
|
7
|
+
* etc.) can compute Korean Four Pillars (Saju / Bazi), daily fortune,
|
|
8
|
+
* compatibility, and full interpretation.
|
|
9
|
+
*
|
|
10
|
+
* SDK: @modelcontextprotocol/sdk v1.x (registerTool + StdioServerTransport).
|
|
11
|
+
* inputSchema is a Zod RawShape (object of validators) per v1.x docs:
|
|
12
|
+
* https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/docs/server.md
|
|
13
|
+
*
|
|
14
|
+
* Auth: the upstream API requires an `X-API-Key` header. We read it from the
|
|
15
|
+
* SAJU_API_KEY environment variable. NEVER hardcode a key here.
|
|
16
|
+
* Get a free key (100 req/day, no card) at POST /api/v1/keys/create.
|
|
17
|
+
*
|
|
18
|
+
* Endpoint specs are taken verbatim from the live backend:
|
|
19
|
+
* D:\saju-api\public\docs\openapi.yaml and D:\saju-api\functions\api\v1\*.js
|
|
20
|
+
*/
|
|
21
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
22
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Configuration
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const API_BASE = process.env.SAJU_API_BASE ?? 'https://saju-api.pages.dev';
|
|
28
|
+
const API_KEY = process.env.SAJU_API_KEY ?? '';
|
|
29
|
+
const SUPPORTED_LANGS = [
|
|
30
|
+
'ko', 'en', 'ja', 'zh', 'es', 'pt-br', 'vi', 'id', 'hi', 'th',
|
|
31
|
+
];
|
|
32
|
+
async function callApi(path, init = { method: 'GET' }) {
|
|
33
|
+
const headers = {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
// Header name MUST be X-API-Key (see openapi.yaml securitySchemes).
|
|
36
|
+
'X-API-Key': API_KEY,
|
|
37
|
+
};
|
|
38
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
39
|
+
method: init.method,
|
|
40
|
+
headers,
|
|
41
|
+
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
|
42
|
+
});
|
|
43
|
+
let body;
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
try {
|
|
46
|
+
body = text ? JSON.parse(text) : {};
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
body = { error: 'non_json_response', raw: text.slice(0, 500) };
|
|
50
|
+
}
|
|
51
|
+
return { ok: res.ok, status: res.status, body };
|
|
52
|
+
}
|
|
53
|
+
/** Format an ApiResult as an MCP tool response (text + structured content). */
|
|
54
|
+
function toToolResponse(result) {
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
const errObj = result.body;
|
|
57
|
+
const hint = result.status === 401
|
|
58
|
+
? ' (set the SAJU_API_KEY environment variable to a valid key — get a free one at POST /api/v1/keys/create)'
|
|
59
|
+
: result.status === 429
|
|
60
|
+
? ' (daily quota for your tier exceeded)'
|
|
61
|
+
: '';
|
|
62
|
+
return {
|
|
63
|
+
isError: true,
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: 'text',
|
|
67
|
+
text: `Saju API error ${result.status}: ` +
|
|
68
|
+
`${JSON.stringify(errObj)}${hint}`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
content: [
|
|
75
|
+
{ type: 'text', text: JSON.stringify(result.body, null, 2) },
|
|
76
|
+
],
|
|
77
|
+
structuredContent: result.body,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Shared Zod shapes (mirrors openapi.yaml BirthRequest exactly).
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
const langField = z
|
|
84
|
+
.enum(SUPPORTED_LANGS)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe('Output language (default ko). One of: ' + SUPPORTED_LANGS.join(', '));
|
|
87
|
+
// BirthRequest fields, matching the API validators in functions/api/v1/*.js.
|
|
88
|
+
const birthShape = {
|
|
89
|
+
year: z
|
|
90
|
+
.number()
|
|
91
|
+
.int()
|
|
92
|
+
.min(1920)
|
|
93
|
+
.max(2050)
|
|
94
|
+
.describe('Solar (Gregorian) birth year, 1920–2050'),
|
|
95
|
+
month: z.number().int().min(1).max(12).describe('Birth month, 1–12'),
|
|
96
|
+
day: z.number().int().min(1).max(31).describe('Birth day, 1–31'),
|
|
97
|
+
hour: z
|
|
98
|
+
.number()
|
|
99
|
+
.int()
|
|
100
|
+
.min(-1)
|
|
101
|
+
.max(23)
|
|
102
|
+
.describe('Birth hour in 24h time (0–23). Use -1 if the hour is unknown.'),
|
|
103
|
+
gender: z
|
|
104
|
+
.enum(['M', 'F'])
|
|
105
|
+
.describe("Gender: 'M' or 'F' (affects Daeun / luck-pillar direction)."),
|
|
106
|
+
};
|
|
107
|
+
// A nested person object (used by compatibility), built from the same shape.
|
|
108
|
+
const personSchema = z.object({
|
|
109
|
+
...birthShape,
|
|
110
|
+
lang: langField,
|
|
111
|
+
});
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Server + tools
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
const server = new McpServer({
|
|
116
|
+
name: 'saju-mcp',
|
|
117
|
+
version: '0.1.0',
|
|
118
|
+
});
|
|
119
|
+
// 1) saju_calculate — POST /api/v1/calculate
|
|
120
|
+
server.registerTool('saju_calculate', {
|
|
121
|
+
title: 'Calculate Saju (Four Pillars)',
|
|
122
|
+
description: 'Compute the Korean Four Pillars of Destiny (사주팔자 / Bazi) from a ' +
|
|
123
|
+
'solar birthdate. Returns the year/month/day/hour pillars (heavenly ' +
|
|
124
|
+
'stem + earthly branch, with hanja), the five-element distribution ' +
|
|
125
|
+
'(wood/fire/earth/metal/water), the Day Master, and the zodiac animal.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
...birthShape,
|
|
128
|
+
lang: langField,
|
|
129
|
+
},
|
|
130
|
+
}, async (args) => {
|
|
131
|
+
const result = await callApi('/api/v1/calculate', {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
body: args,
|
|
134
|
+
});
|
|
135
|
+
return toToolResponse(result);
|
|
136
|
+
});
|
|
137
|
+
// 2) saju_interpret — POST /api/v1/interpret
|
|
138
|
+
server.registerTool('saju_interpret', {
|
|
139
|
+
title: 'Interpret Saju (Ten Gods, Yongshin, Daeun)',
|
|
140
|
+
description: 'Full Saju interpretation from a solar birthdate: the Four Pillars plus ' +
|
|
141
|
+
'Ten Gods (십신), hidden stems, life stages, interactions, Yongshin ' +
|
|
142
|
+
'(용신 / useful god), Daeun (대운 / luck pillars), and human-readable ' +
|
|
143
|
+
'summaries in the requested language.',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
...birthShape,
|
|
146
|
+
lang: langField,
|
|
147
|
+
},
|
|
148
|
+
}, async (args) => {
|
|
149
|
+
const result = await callApi('/api/v1/interpret', {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
body: args,
|
|
152
|
+
});
|
|
153
|
+
return toToolResponse(result);
|
|
154
|
+
});
|
|
155
|
+
// 3) saju_compatibility — POST /api/v1/compatibility
|
|
156
|
+
server.registerTool('saju_compatibility', {
|
|
157
|
+
title: 'Saju Compatibility (궁합)',
|
|
158
|
+
description: 'Score two-person compatibility (궁합) from 0–100 based on both ' +
|
|
159
|
+
"people's Saju. Returns the overall score and a breakdown " +
|
|
160
|
+
'(element balance, Day Master relation, branch harmony, branch clash) ' +
|
|
161
|
+
'plus each person\'s Day Master and zodiac.',
|
|
162
|
+
inputSchema: {
|
|
163
|
+
person_a: personSchema.describe('First person (birth details).'),
|
|
164
|
+
person_b: personSchema.describe('Second person (birth details).'),
|
|
165
|
+
lang: langField,
|
|
166
|
+
},
|
|
167
|
+
}, async (args) => {
|
|
168
|
+
const result = await callApi('/api/v1/compatibility', {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
body: args,
|
|
171
|
+
});
|
|
172
|
+
return toToolResponse(result);
|
|
173
|
+
});
|
|
174
|
+
// 4) saju_daily — GET /api/v1/daily
|
|
175
|
+
server.registerTool('saju_daily', {
|
|
176
|
+
title: "Daily Fortune Snapshot",
|
|
177
|
+
description: 'Daily fortune snapshot (0–100 score + advice) for a given Day Master ' +
|
|
178
|
+
'and date. Provide the Day Master either as a Korean stem character ' +
|
|
179
|
+
'(갑, 을, 병, 정, 무, 기, 경, 신, 임, 계) or an English alias ' +
|
|
180
|
+
'(e.g. wood_yang, water_yin). The Day Master comes from a prior ' +
|
|
181
|
+
'saju_calculate / saju_interpret call (day_master.stem).',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
day_master: z
|
|
184
|
+
.string()
|
|
185
|
+
.min(1)
|
|
186
|
+
.describe('Day Master: Korean stem (갑..계) or English alias ' +
|
|
187
|
+
'(wood_yang, wood_yin, fire_yang, ... water_yin).'),
|
|
188
|
+
date: z
|
|
189
|
+
.string()
|
|
190
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD')
|
|
191
|
+
.optional()
|
|
192
|
+
.describe('Target date in YYYY-MM-DD. Defaults to today (UTC).'),
|
|
193
|
+
lang: langField,
|
|
194
|
+
},
|
|
195
|
+
}, async (args) => {
|
|
196
|
+
const params = new URLSearchParams();
|
|
197
|
+
params.set('day_master', args.day_master);
|
|
198
|
+
if (args.date)
|
|
199
|
+
params.set('date', args.date);
|
|
200
|
+
if (args.lang)
|
|
201
|
+
params.set('lang', args.lang);
|
|
202
|
+
const result = await callApi(`/api/v1/daily?${params.toString()}`, {
|
|
203
|
+
method: 'GET',
|
|
204
|
+
});
|
|
205
|
+
return toToolResponse(result);
|
|
206
|
+
});
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Boot
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
async function main() {
|
|
211
|
+
if (!API_KEY) {
|
|
212
|
+
// Warn on stderr (stdout is reserved for the JSON-RPC stream).
|
|
213
|
+
process.stderr.write('[saju-mcp] WARNING: SAJU_API_KEY is not set. Every tool call will ' +
|
|
214
|
+
'return 401 invalid_api_key until you set it. Get a free key at ' +
|
|
215
|
+
'POST https://saju-api.pages.dev/api/v1/keys/create\n');
|
|
216
|
+
}
|
|
217
|
+
const transport = new StdioServerTransport();
|
|
218
|
+
await server.connect(transport);
|
|
219
|
+
process.stderr.write('[saju-mcp] server connected over stdio\n');
|
|
220
|
+
}
|
|
221
|
+
main().catch((err) => {
|
|
222
|
+
process.stderr.write(`[saju-mcp] fatal: ${String(err)}\n`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "saju-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server wrapping the Saju API (Korean Four Pillars / Bazi) — calculate, interpret, compatibility, daily fortune in 10 languages.",
|
|
5
|
+
"license": "LicenseRef-Proprietary",
|
|
6
|
+
"author": "KunStudio",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"mcpName": "io.github.ghdejr11-beep/saju-mcp",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ghdejr11-beep/saju-mcp.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://saju-api.pages.dev",
|
|
14
|
+
"bin": {
|
|
15
|
+
"saju-mcp": "dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"main": "dist/index.js",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"start": "node dist/index.js",
|
|
28
|
+
"start:actor": "node dist/actor.js",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"dev": "tsc --watch"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
34
|
+
"apify": "^3.7.0",
|
|
35
|
+
"cors": "^2.8.5",
|
|
36
|
+
"express": "^5.1.0",
|
|
37
|
+
"zod": "^3.23.8"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/cors": "^2.8.13",
|
|
41
|
+
"@types/express": "^5.0.2",
|
|
42
|
+
"@types/node": "^22.10.0",
|
|
43
|
+
"typescript": "^5.7.0"
|
|
44
|
+
}
|
|
45
|
+
}
|