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 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
+ }