coupon-moa-mcp 0.2.1 → 0.4.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 CHANGED
@@ -64,10 +64,10 @@ ADMIN_API_URL = "https://your-api-url.com"
64
64
  MCP를 사용하기 전에 먼저 로그인이 필요합니다:
65
65
 
66
66
  ```bash
67
- ADMIN_API_URL=https://your-api-url.com npx coupon-moa-mcp login
67
+ npx coupon-moa-mcp login --api-url https://your-api-url.com
68
68
  ```
69
69
 
70
- 브라우저가 열리면 Google 로그인 후 토큰이 `~/.coupon-moa-mcp/token.json`에 저장됩니다. 이후 MCP 실행 시 자동으로 사용됩니다.
70
+ 브라우저가 열리면 Google 로그인 후 토큰과 API URL이 `~/.coupon-moa-mcp/config.json`에 저장됩니다. 이후 MCP 실행 시 `ADMIN_API_URL` 환경변수 없이도 자동으로 사용됩니다.
71
71
 
72
72
  로그아웃:
73
73
 
@@ -1,15 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import { register } from 'node:module';
3
- import { pathToFileURL } from 'node:url';
4
-
5
- register('tsx/esm', pathToFileURL('./'));
6
-
7
- const command = process.argv[2];
8
-
9
- if (command === 'login') {
10
- await import('../src/cli-login.ts');
11
- } else if (command === 'logout') {
12
- await import('../src/cli-logout.ts');
13
- } else {
14
- await import('../src/index.ts');
15
- }
2
+ import '../dist/bin.js';
package/dist/bin.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/bin.ts
4
+ var command = process.argv[2];
5
+ if (command === "login") {
6
+ await import("./cli-login.js");
7
+ } else if (command === "logout") {
8
+ await import("./cli-logout.js");
9
+ } else {
10
+ await import("./index.js");
11
+ }
@@ -0,0 +1,115 @@
1
+ // src/auth.ts
2
+ import { createServer } from "http";
3
+ import { readFile, writeFile, mkdir } from "fs/promises";
4
+ import { existsSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ var TOKEN_DIR = join(homedir(), ".coupon-moa-mcp");
8
+ var TOKEN_FILE = join(TOKEN_DIR, "config.json");
9
+ var AUTH_PORT = 9401;
10
+ async function loadStoredConfig() {
11
+ try {
12
+ const content = await readFile(TOKEN_FILE, "utf-8");
13
+ return JSON.parse(content);
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ async function loadStoredToken() {
19
+ const stored = await loadStoredConfig();
20
+ return stored?.accessToken ?? null;
21
+ }
22
+ async function loadStoredApiUrl() {
23
+ const stored = await loadStoredConfig();
24
+ return stored?.apiUrl ?? null;
25
+ }
26
+ async function getAccessToken(apiUrl) {
27
+ const stored = await loadStoredConfig();
28
+ if (stored) {
29
+ return stored.accessToken;
30
+ }
31
+ console.log("\uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uB85C\uADF8\uC778\uD574\uC8FC\uC138\uC694...");
32
+ const tokens = await startOAuthFlow(apiUrl);
33
+ await saveConfig({ ...tokens, apiUrl });
34
+ return tokens.accessToken;
35
+ }
36
+ async function refreshAccessToken(apiUrl) {
37
+ const stored = await loadStoredConfig();
38
+ if (!stored?.refreshToken) {
39
+ return null;
40
+ }
41
+ try {
42
+ const response = await fetch(`${apiUrl}/api/auth/refresh`, {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json", "Cookie": `refreshToken=${stored.refreshToken}` }
45
+ });
46
+ if (!response.ok) {
47
+ return null;
48
+ }
49
+ const data = await response.json();
50
+ const accessToken = data.data?.accessToken;
51
+ if (!accessToken) {
52
+ return null;
53
+ }
54
+ await saveConfig({ ...stored, accessToken, savedAt: Date.now() });
55
+ return accessToken;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ async function clearTokens() {
61
+ if (existsSync(TOKEN_FILE)) {
62
+ const { unlink } = await import("fs/promises");
63
+ await unlink(TOKEN_FILE);
64
+ }
65
+ }
66
+ async function saveConfig(config) {
67
+ if (!existsSync(TOKEN_DIR)) {
68
+ await mkdir(TOKEN_DIR, { recursive: true });
69
+ }
70
+ await writeFile(TOKEN_FILE, JSON.stringify(config, null, 2));
71
+ }
72
+ function startOAuthFlow(apiUrl) {
73
+ return new Promise((resolve, reject) => {
74
+ const server = createServer((req, res) => {
75
+ const url = new URL(req.url ?? "", `http://localhost:${AUTH_PORT}`);
76
+ if (url.pathname === "/auth/callback" || url.pathname === "/callback") {
77
+ const accessToken = url.searchParams.get("accessToken");
78
+ if (!accessToken) {
79
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
80
+ res.end("<h1>\uC778\uC99D \uC2E4\uD328</h1><p>\uD1A0\uD070\uC744 \uBC1B\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uC8FC\uC138\uC694.</p>");
81
+ server.close();
82
+ reject(new Error("No access token received"));
83
+ return;
84
+ }
85
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
86
+ res.end("<h1>\uC778\uC99D \uC644\uB8CC!</h1><p>\uC774 \uCC3D\uC744 \uB2EB\uC544\uB3C4 \uB429\uB2C8\uB2E4.</p><script>setTimeout(()=>window.close(),1500)</script>");
87
+ server.close();
88
+ resolve({ accessToken, savedAt: Date.now() });
89
+ return;
90
+ }
91
+ res.writeHead(404);
92
+ res.end("Not found");
93
+ });
94
+ server.listen(AUTH_PORT, () => {
95
+ const loginUrl = `${apiUrl}/api/auth/google?redirect=http://localhost:${AUTH_PORT}`;
96
+ console.log(`\uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uB85C\uADF8\uC778\uD574\uC8FC\uC138\uC694: ${loginUrl}`);
97
+ import("child_process").then(({ exec }) => {
98
+ const command = process.platform === "darwin" ? `open "${loginUrl}"` : process.platform === "win32" ? `start "${loginUrl}"` : `xdg-open "${loginUrl}"`;
99
+ exec(command);
100
+ });
101
+ });
102
+ setTimeout(() => {
103
+ server.close();
104
+ reject(new Error("\uB85C\uADF8\uC778 \uD0C0\uC784\uC544\uC6C3 (60\uCD08). \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uC8FC\uC138\uC694."));
105
+ }, 6e4);
106
+ });
107
+ }
108
+
109
+ export {
110
+ loadStoredToken,
111
+ loadStoredApiUrl,
112
+ getAccessToken,
113
+ refreshAccessToken,
114
+ clearTokens
115
+ };
@@ -0,0 +1,34 @@
1
+ import {
2
+ clearTokens,
3
+ getAccessToken
4
+ } from "./chunk-S6Y6GLDK.js";
5
+
6
+ // src/cli-login.ts
7
+ function parseApiUrl() {
8
+ const args = process.argv.slice(3);
9
+ for (let i = 0; i < args.length; i++) {
10
+ if (args[i] === "--api-url" && args[i + 1]) {
11
+ return args[i + 1];
12
+ }
13
+ }
14
+ return process.env.ADMIN_API_URL || "http://localhost:8080";
15
+ }
16
+ var apiUrl = parseApiUrl();
17
+ console.log("=== coupon-moa-mcp \uB85C\uADF8\uC778 ===");
18
+ console.log(`API URL: ${apiUrl}`);
19
+ console.log("");
20
+ try {
21
+ await clearTokens();
22
+ const token = await getAccessToken(apiUrl);
23
+ console.log("");
24
+ console.log("\uB85C\uADF8\uC778 \uC131\uACF5! \uD1A0\uD070\uACFC API URL\uC774 \uC800\uC7A5\uB418\uC5C8\uC2B5\uB2C8\uB2E4.");
25
+ console.log(`\uD1A0\uD070 \uBBF8\uB9AC\uBCF4\uAE30: ${token.slice(0, 20)}...`);
26
+ console.log("");
27
+ console.log("\uC774\uC81C Claude Desktop/Code/Codex\uC5D0\uC11C coupon-moa MCP\uB97C \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.");
28
+ console.log("(ADMIN_API_URL \uD658\uACBD\uBCC0\uC218 \uC5C6\uC774\uB3C4 \uB3D9\uC791\uD569\uB2C8\uB2E4)");
29
+ } catch (error) {
30
+ const message = error instanceof Error ? error.message : "Unknown error";
31
+ console.error(`\uB85C\uADF8\uC778 \uC2E4\uD328: ${message}`);
32
+ process.exit(1);
33
+ }
34
+ process.exit(0);
@@ -0,0 +1,14 @@
1
+ import {
2
+ clearTokens
3
+ } from "./chunk-S6Y6GLDK.js";
4
+
5
+ // src/cli-logout.ts
6
+ try {
7
+ await clearTokens();
8
+ console.log("\uB85C\uADF8\uC544\uC6C3 \uC644\uB8CC. \uC800\uC7A5\uB41C \uD1A0\uD070\uC774 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4.");
9
+ } catch (error) {
10
+ const message = error instanceof Error ? error.message : "Unknown error";
11
+ console.error(`\uB85C\uADF8\uC544\uC6C3 \uC2E4\uD328: ${message}`);
12
+ process.exit(1);
13
+ }
14
+ process.exit(0);
package/dist/index.js ADDED
@@ -0,0 +1,236 @@
1
+ import {
2
+ clearTokens,
3
+ loadStoredApiUrl,
4
+ loadStoredToken,
5
+ refreshAccessToken
6
+ } from "./chunk-S6Y6GLDK.js";
7
+
8
+ // src/index.ts
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+
12
+ // src/ws/ws-server.ts
13
+ var DEFAULT_API_URL = "http://localhost:8080";
14
+ var AdminApiClient = class {
15
+ apiUrl = null;
16
+ token = null;
17
+ async initialize() {
18
+ this.token = await loadStoredToken();
19
+ this.apiUrl = await loadStoredApiUrl() || process.env.ADMIN_API_URL || DEFAULT_API_URL;
20
+ if (!this.token) {
21
+ throw new Error("\uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4. `npx coupon-moa-mcp login --api-url <URL>` \uC744 \uC2E4\uD589\uD574\uC8FC\uC138\uC694.");
22
+ }
23
+ }
24
+ async send(method, params) {
25
+ if (!this.token || !this.apiUrl) {
26
+ throw new Error("\uB85C\uADF8\uC778\uC774 \uD544\uC694\uD569\uB2C8\uB2E4. `npx coupon-moa-mcp login --api-url <URL>` \uC744 \uC2E4\uD589\uD574\uC8FC\uC138\uC694.");
27
+ }
28
+ let response = await this.fetchApi(method, params);
29
+ if (response.status === 401) {
30
+ const newToken = await refreshAccessToken(this.apiUrl);
31
+ if (newToken) {
32
+ this.token = newToken;
33
+ response = await this.fetchApi(method, params);
34
+ } else {
35
+ await clearTokens();
36
+ throw new Error("\uD1A0\uD070\uC774 \uB9CC\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. `npx coupon-moa-mcp login --api-url <URL>` \uC73C\uB85C \uB2E4\uC2DC \uB85C\uADF8\uC778\uD574\uC8FC\uC138\uC694.");
37
+ }
38
+ }
39
+ if (!response.ok) {
40
+ const text = await response.text();
41
+ throw new Error(`API \uD638\uCD9C \uC2E4\uD328 (${response.status}): ${text}`);
42
+ }
43
+ const json = await response.json();
44
+ return json.data ?? json;
45
+ }
46
+ async fetchApi(method, params) {
47
+ const url = `${this.apiUrl}/api/admin/mcp/${method}`;
48
+ return fetch(url, {
49
+ method: "POST",
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ "Authorization": `Bearer ${this.token}`
53
+ },
54
+ body: JSON.stringify(params)
55
+ });
56
+ }
57
+ isConnected() {
58
+ return this.token !== null;
59
+ }
60
+ };
61
+
62
+ // src/tools/push-stores.ts
63
+ import { z } from "zod";
64
+ var pushStoresSchema = z.object({
65
+ items: z.array(z.object({
66
+ brandId: z.string().describe("\uBE0C\uB79C\uB4DC ID"),
67
+ branchName: z.string().describe("\uD55C\uAD6D\uC5B4 \uC9C0\uC810\uBA85"),
68
+ nameJa: z.string().describe("\uC77C\uBCF8\uC5B4 \uC9C0\uC810\uBA85 (Google Places \uAC80\uC0C9\uC6A9)"),
69
+ cityId: z.string().describe("\uB3C4\uC2DC ID"),
70
+ address: z.string().optional().default("").describe("\uC8FC\uC18C"),
71
+ location: z.tuple([z.number(), z.number()]).optional().default([0, 0]).describe("[lng, lat]"),
72
+ operatingHours: z.array(z.object({
73
+ day: z.number(),
74
+ open: z.string(),
75
+ close: z.string(),
76
+ isNextDay: z.boolean()
77
+ })).optional().default([]).describe("\uC601\uC5C5\uC2DC\uAC04"),
78
+ phone: z.string().optional().default("").describe("\uC804\uD654\uBC88\uD638"),
79
+ note: z.string().optional().default("").describe("\uBA54\uBAA8"),
80
+ warning: z.string().optional().default("").describe("\uC8FC\uC758\uC0AC\uD56D")
81
+ })).describe("\uB4F1\uB85D\uD560 \uC9C0\uC810 \uBAA9\uB85D")
82
+ });
83
+ async function pushStores(api2, params) {
84
+ const batchId = `batch-${crypto.randomUUID().slice(0, 8)}`;
85
+ const items = params.items.map((item) => {
86
+ const operatingHours = item.operatingHours.length > 0 ? item.operatingHours : Array.from({ length: 7 }, (_, day) => ({
87
+ day,
88
+ open: "10:00",
89
+ close: "22:00",
90
+ isNextDay: false
91
+ }));
92
+ return { ...item, operatingHours };
93
+ });
94
+ const result = await api2.send("push-stores", { batchId, items });
95
+ return {
96
+ batchId,
97
+ itemCount: items.length,
98
+ reviewPath: `/review/${batchId}`,
99
+ ...result
100
+ };
101
+ }
102
+
103
+ // src/tools/list-queries.ts
104
+ import { z as z2 } from "zod";
105
+ var listStoresSchema = z2.object({
106
+ brandId: z2.string().optional().describe("\uBE0C\uB79C\uB4DC ID \uD544\uD130"),
107
+ cityId: z2.string().optional().describe("\uB3C4\uC2DC ID \uD544\uD130"),
108
+ state: z2.string().optional().default("ENABLED").describe("\uC0C1\uD0DC \uD544\uD130 (ENABLED, DISABLED, PENDING)"),
109
+ query: z2.string().optional().describe("\uC9C0\uC810\uBA85/\uC8FC\uC18C \uAC80\uC0C9\uC5B4"),
110
+ limit: z2.number().optional().default(50).describe("\uCD5C\uB300 \uACB0\uACFC \uC218 (\uAE30\uBCF8 50)")
111
+ });
112
+ var listBrandsSchema = z2.object({
113
+ state: z2.string().optional().default("ENABLED").describe("\uC0C1\uD0DC \uD544\uD130"),
114
+ query: z2.string().optional().describe("\uBE0C\uB79C\uB4DC\uBA85 \uAC80\uC0C9\uC5B4")
115
+ });
116
+ var listCitiesSchema = z2.object({
117
+ state: z2.string().optional().default("ENABLED").describe("\uC0C1\uD0DC \uD544\uD130")
118
+ });
119
+ var getReviewStatusSchema = z2.object({});
120
+ async function listStores(api2, params) {
121
+ return api2.send("list-stores", params);
122
+ }
123
+ async function listBrands(api2, params) {
124
+ return api2.send("list-brands", params);
125
+ }
126
+ async function listCities(api2, params) {
127
+ return api2.send("list-cities", params);
128
+ }
129
+ async function getReviewStatus(api2) {
130
+ return api2.send("get-review-status", {});
131
+ }
132
+
133
+ // src/index.ts
134
+ var server = new McpServer({
135
+ name: "coupon-moa-admin",
136
+ version: "0.0.1",
137
+ instructions: `\uCFE0\uD3F0 \uBAA8\uC544 \uC5B4\uB4DC\uBBFC MCP \u2014 \uC5B4\uB4DC\uBBFC \uC6F9\uC744 \uD1B5\uD574 \uC9C0\uC810/\uBE0C\uB79C\uB4DC/\uB3C4\uC2DC \uB370\uC774\uD130\uB97C \uC870\uD68C\uD558\uACE0 \uC77C\uAD04 \uB4F1\uB85D\uD569\uB2C8\uB2E4.
138
+
139
+ ## \uC0AC\uC6A9 \uD750\uB984 (\uC9C0\uC810 \uC77C\uAD04 \uB4F1\uB85D)
140
+
141
+ 1. admin:list-brands\uB85C \uBE0C\uB79C\uB4DC \uBAA9\uB85D \uC870\uD68C \u2192 \uBE0C\uB79C\uB4DC\uBA85\uC73C\uB85C brandId \uB9E4\uCE6D
142
+ 2. admin:list-cities\uB85C \uB3C4\uC2DC \uBAA9\uB85D \uC870\uD68C \u2192 \uC9C0\uC5ED\uBA85\uC73C\uB85C cityId \uB9E4\uCE6D
143
+ 3. admin:push-stores\uB85C \uB370\uC774\uD130\uB97C \uC5B4\uB4DC\uBBFC \uC6F9\uC5D0 push
144
+ 4. \uC0AC\uC6A9\uC790\uC5D0\uAC8C "\uC5B4\uB4DC\uBBFC\uC5D0\uC11C \uAC80\uD1A0\uD574\uC8FC\uC138\uC694"\uB77C\uACE0 \uC548\uB0B4
145
+ 5. \uC0AC\uC6A9\uC790\uAC00 \uC5B4\uB4DC\uBBFC\uC5D0\uC11C \uAC80\uD1A0 \uD6C4 \uB4F1\uB85D (admin:get-review-status\uB85C \uC9C4\uD589 \uC0C1\uD0DC \uD655\uC778 \uAC00\uB2A5)
146
+
147
+ ## \uC8FC\uC758\uC0AC\uD56D
148
+
149
+ - push-stores\uB294 DB\uC5D0 \uC800\uC7A5\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC5B4\uB4DC\uBBFC \uC6F9 \uBE0C\uB77C\uC6B0\uC800 \uBA54\uBAA8\uB9AC\uC5D0\uB9CC \uBCF4\uAD00\uB429\uB2C8\uB2E4.
150
+ - brandId, cityId\uB294 \uBC18\uB4DC\uC2DC list-brands, list-cities\uB85C \uC870\uD68C\uD55C \uC2E4\uC81C ID\uB97C \uC0AC\uC6A9\uD558\uC138\uC694.
151
+ - branchName\uC740 \uD55C\uAD6D\uC5B4 \uC9C0\uC810\uBA85, nameJa\uB294 \uC77C\uBCF8\uC5B4 \uC9C0\uC810\uBA85(Google Places \uAC80\uC0C9\uC6A9)\uC785\uB2C8\uB2E4.
152
+ - address, location, phone, operatingHours\uB294 \uBE44\uC6CC\uC11C \uBCF4\uB0B4\uB3C4 \uB429\uB2C8\uB2E4. \uC0AC\uC6A9\uC790\uAC00 \uAC80\uD1A0 \uB2E8\uACC4\uC5D0\uC11C Google Places \uAC80\uC0C9\uC73C\uB85C \uCC44\uC6C1\uB2C8\uB2E4.
153
+ - \uC5B4\uB4DC\uBBFC \uC6F9\uC774 \uC5F4\uB824\uC788\uC5B4\uC57C push\uAC00 \uC131\uACF5\uD569\uB2C8\uB2E4. \uC5F0\uACB0 \uC548 \uB418\uBA74 \uC0AC\uC6A9\uC790\uC5D0\uAC8C \uC5B4\uB4DC\uBBFC\uC744 \uC5F4\uB77C\uACE0 \uC548\uB0B4\uD558\uC138\uC694.`
154
+ });
155
+ var api = new AdminApiClient();
156
+ server.tool(
157
+ "admin:push-stores",
158
+ "\uC9C0\uC810 \uB370\uC774\uD130\uB97C \uC5B4\uB4DC\uBBFC \uC6F9\uC5D0 \uBBF8\uB9AC\uBCF4\uAE30\uB85C push\uD569\uB2C8\uB2E4. DB\uC5D0 \uC800\uC7A5\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC0AC\uC6A9\uC790\uAC00 \uAC80\uD1A0 \uD6C4 \uB4F1\uB85D\uD569\uB2C8\uB2E4.",
159
+ pushStoresSchema.shape,
160
+ async (params) => {
161
+ try {
162
+ const result = await pushStores(api, params);
163
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
164
+ } catch (error) {
165
+ const message = error instanceof Error ? error.message : "Unknown error";
166
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
167
+ }
168
+ }
169
+ );
170
+ server.tool(
171
+ "admin:list-stores",
172
+ "\uC5B4\uB4DC\uBBFC\uC5D0 \uB4F1\uB85D\uB41C \uC9C0\uC810 \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4.",
173
+ listStoresSchema.shape,
174
+ async (params) => {
175
+ try {
176
+ const result = await listStores(api, params);
177
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
178
+ } catch (error) {
179
+ const message = error instanceof Error ? error.message : "Unknown error";
180
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
181
+ }
182
+ }
183
+ );
184
+ server.tool(
185
+ "admin:list-brands",
186
+ "\uC5B4\uB4DC\uBBFC\uC5D0 \uB4F1\uB85D\uB41C \uBE0C\uB79C\uB4DC \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4.",
187
+ listBrandsSchema.shape,
188
+ async (params) => {
189
+ try {
190
+ const result = await listBrands(api, params);
191
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
192
+ } catch (error) {
193
+ const message = error instanceof Error ? error.message : "Unknown error";
194
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
195
+ }
196
+ }
197
+ );
198
+ server.tool(
199
+ "admin:list-cities",
200
+ "\uC5B4\uB4DC\uBBFC\uC5D0 \uB4F1\uB85D\uB41C \uB3C4\uC2DC \uBAA9\uB85D\uC744 \uC870\uD68C\uD569\uB2C8\uB2E4.",
201
+ listCitiesSchema.shape,
202
+ async (params) => {
203
+ try {
204
+ const result = await listCities(api, params);
205
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
206
+ } catch (error) {
207
+ const message = error instanceof Error ? error.message : "Unknown error";
208
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
209
+ }
210
+ }
211
+ );
212
+ server.tool(
213
+ "admin:get-review-status",
214
+ "\uD604\uC7AC \uC9C4\uD589 \uC911\uC778 \uC77C\uAD04 \uB4F1\uB85D \uAC80\uD1A0 \uC0C1\uD0DC\uB97C \uC870\uD68C\uD569\uB2C8\uB2E4.",
215
+ getReviewStatusSchema.shape,
216
+ async () => {
217
+ try {
218
+ const result = await getReviewStatus(api);
219
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
220
+ } catch (error) {
221
+ const message = error instanceof Error ? error.message : "Unknown error";
222
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
223
+ }
224
+ }
225
+ );
226
+ async function main() {
227
+ try {
228
+ await api.initialize();
229
+ } catch {
230
+ }
231
+ const transport = new StdioServerTransport();
232
+ await server.connect(transport);
233
+ }
234
+ main().catch(() => {
235
+ process.exit(1);
236
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coupon-moa-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for Coupon Moa admin — push store data, query brands/cities/stores via Claude",
5
5
  "private": false,
6
6
  "type": "module",
@@ -8,13 +8,14 @@
8
8
  "coupon-moa-mcp": "./bin/coupon-moa-mcp.mjs"
9
9
  },
10
10
  "scripts": {
11
+ "build": "tsup",
11
12
  "dev": "tsx watch src/index.ts",
12
- "start": "tsx src/index.ts",
13
+ "prepublishOnly": "pnpm build",
13
14
  "publish:npm": "bash ../../scripts/publish-mcp.sh"
14
15
  },
15
16
  "files": [
16
- "src/**/*.ts",
17
- "bin/**/*.mjs",
17
+ "dist",
18
+ "bin",
18
19
  "package.json",
19
20
  "README.md"
20
21
  ],
@@ -27,10 +28,11 @@
27
28
  "license": "MIT",
28
29
  "dependencies": {
29
30
  "@modelcontextprotocol/sdk": "^1.12.1",
30
- "tsx": "^4.19.0",
31
31
  "zod": "^3.24.0"
32
32
  },
33
33
  "devDependencies": {
34
+ "tsup": "^8.4.0",
35
+ "tsx": "^4.19.0",
34
36
  "typescript": "^5.9.3"
35
37
  }
36
38
  }
package/src/auth.ts DELETED
@@ -1,137 +0,0 @@
1
- import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
2
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
- import { existsSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
6
-
7
- const TOKEN_DIR = join(homedir(), '.coupon-moa-mcp');
8
- const TOKEN_FILE = join(TOKEN_DIR, 'token.json');
9
- const AUTH_PORT = 9401;
10
-
11
- interface StoredTokens {
12
- accessToken: string;
13
- refreshToken?: string;
14
- savedAt: number;
15
- }
16
-
17
- export async function loadStoredToken(): Promise<string | null> {
18
- const stored = await loadTokens();
19
- return stored?.accessToken ?? null;
20
- }
21
-
22
- export async function getAccessToken(apiUrl: string): Promise<string> {
23
- // 저장된 토큰 확인
24
- const stored = await loadTokens();
25
- if (stored) {
26
- return stored.accessToken;
27
- }
28
-
29
- // 토큰 없으면 OAuth 로그인 (CLI login 명령에서만 호출)
30
- console.log('브라우저에서 로그인해주세요...');
31
- const tokens = await startOAuthFlow(apiUrl);
32
- await saveTokens(tokens);
33
- return tokens.accessToken;
34
- }
35
-
36
- export async function refreshAccessToken(apiUrl: string): Promise<string | null> {
37
- const stored = await loadTokens();
38
- if (!stored?.refreshToken) {
39
- return null;
40
- }
41
-
42
- try {
43
- const response = await fetch(`${apiUrl}/api/auth/refresh`, {
44
- method: 'POST',
45
- headers: { 'Content-Type': 'application/json', 'Cookie': `refreshToken=${stored.refreshToken}` },
46
- });
47
-
48
- if (!response.ok) {
49
- return null;
50
- }
51
-
52
- const data = await response.json() as { data?: { accessToken: string } };
53
- const accessToken = data.data?.accessToken;
54
- if (!accessToken) {
55
- return null;
56
- }
57
-
58
- await saveTokens({ ...stored, accessToken, savedAt: Date.now() });
59
- return accessToken;
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
- export async function clearTokens(): Promise<void> {
66
- if (existsSync(TOKEN_FILE)) {
67
- const { unlink } = await import('node:fs/promises');
68
- await unlink(TOKEN_FILE);
69
- }
70
- }
71
-
72
- async function loadTokens(): Promise<StoredTokens | null> {
73
- try {
74
- const content = await readFile(TOKEN_FILE, 'utf-8');
75
- return JSON.parse(content) as StoredTokens;
76
- } catch {
77
- return null;
78
- }
79
- }
80
-
81
- async function saveTokens(tokens: StoredTokens): Promise<void> {
82
- if (!existsSync(TOKEN_DIR)) {
83
- await mkdir(TOKEN_DIR, { recursive: true });
84
- }
85
- await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2));
86
- }
87
-
88
- function startOAuthFlow(apiUrl: string): Promise<StoredTokens> {
89
- return new Promise((resolve, reject) => {
90
- const server = createServer((req: IncomingMessage, res: ServerResponse) => {
91
- const url = new URL(req.url ?? '', `http://localhost:${AUTH_PORT}`);
92
-
93
- if (url.pathname === '/auth/callback' || url.pathname === '/callback') {
94
- const accessToken = url.searchParams.get('accessToken');
95
-
96
- if (!accessToken) {
97
- res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
98
- res.end('<h1>인증 실패</h1><p>토큰을 받지 못했습니다. 다시 시도해주세요.</p>');
99
- server.close();
100
- reject(new Error('No access token received'));
101
- return;
102
- }
103
-
104
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
105
- res.end('<h1>인증 완료!</h1><p>이 창을 닫아도 됩니다.</p><script>setTimeout(()=>window.close(),1500)</script>');
106
-
107
- server.close();
108
- resolve({ accessToken, savedAt: Date.now() });
109
- return;
110
- }
111
-
112
- res.writeHead(404);
113
- res.end('Not found');
114
- });
115
-
116
- server.listen(AUTH_PORT, () => {
117
- const loginUrl = `${apiUrl}/api/auth/google?redirect=http://localhost:${AUTH_PORT}`;
118
- console.error(`[MCP] 브라우저에서 로그인해주세요: ${loginUrl}`);
119
-
120
- // 브라우저 자동 열기
121
- import('node:child_process').then(({ exec }) => {
122
- const command = process.platform === 'darwin'
123
- ? `open "${loginUrl}"`
124
- : process.platform === 'win32'
125
- ? `start "${loginUrl}"`
126
- : `xdg-open "${loginUrl}"`;
127
- exec(command);
128
- });
129
- });
130
-
131
- // 60초 타임아웃
132
- setTimeout(() => {
133
- server.close();
134
- reject(new Error('로그인 타임아웃 (60초). 다시 시도해주세요.'));
135
- }, 60000);
136
- });
137
- }
package/src/cli-login.ts DELETED
@@ -1,24 +0,0 @@
1
- import { getAccessToken, clearTokens } from './auth.js';
2
-
3
- const apiUrl = process.env.ADMIN_API_URL || 'http://localhost:8080';
4
-
5
- console.log('=== coupon-moa-mcp 로그인 ===');
6
- console.log(`API URL: ${apiUrl}`);
7
- console.log('');
8
-
9
- try {
10
- // 기존 토큰 삭제 후 새로 로그인
11
- await clearTokens();
12
- const token = await getAccessToken(apiUrl);
13
- console.log('');
14
- console.log('로그인 성공! 토큰이 저장되었습니다.');
15
- console.log(`토큰 미리보기: ${token.slice(0, 20)}...`);
16
- console.log('');
17
- console.log('이제 Claude Desktop/Code/Codex에서 coupon-moa MCP를 사용할 수 있습니다.');
18
- } catch (error) {
19
- const message = error instanceof Error ? error.message : 'Unknown error';
20
- console.error(`로그인 실패: ${message}`);
21
- process.exit(1);
22
- }
23
-
24
- process.exit(0);
package/src/cli-logout.ts DELETED
@@ -1,12 +0,0 @@
1
- import { clearTokens } from './auth.js';
2
-
3
- try {
4
- await clearTokens();
5
- console.log('로그아웃 완료. 저장된 토큰이 삭제되었습니다.');
6
- } catch (error) {
7
- const message = error instanceof Error ? error.message : 'Unknown error';
8
- console.error(`로그아웃 실패: ${message}`);
9
- process.exit(1);
10
- }
11
-
12
- process.exit(0);
package/src/index.ts DELETED
@@ -1,129 +0,0 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { AdminApiClient } from './ws/ws-server.js';
4
- import { pushStoresSchema, pushStores } from './tools/push-stores.js';
5
- import {
6
- listStoresSchema, listBrandsSchema, listCitiesSchema, getReviewStatusSchema,
7
- listStores, listBrands, listCities, getReviewStatus,
8
- } from './tools/list-queries.js';
9
-
10
- const api = new AdminApiClient();
11
- try {
12
- await api.initialize();
13
- } catch (error) {
14
- console.error(`[MCP] ${error instanceof Error ? error.message : error}`);
15
- }
16
-
17
- const server = new McpServer({
18
- name: 'coupon-moa-admin',
19
- version: '0.0.1',
20
- instructions: `쿠폰 모아 어드민 MCP — 어드민 웹을 통해 지점/브랜드/도시 데이터를 조회하고 일괄 등록합니다.
21
-
22
- ## 사용 흐름 (지점 일괄 등록)
23
-
24
- 1. admin:list-brands로 브랜드 목록 조회 → 브랜드명으로 brandId 매칭
25
- 2. admin:list-cities로 도시 목록 조회 → 지역명으로 cityId 매칭
26
- 3. admin:push-stores로 데이터를 어드민 웹에 push
27
- 4. 사용자에게 "어드민에서 검토해주세요"라고 안내
28
- 5. 사용자가 어드민에서 검토 후 등록 (admin:get-review-status로 진행 상태 확인 가능)
29
-
30
- ## 주의사항
31
-
32
- - push-stores는 DB에 저장하지 않습니다. 어드민 웹 브라우저 메모리에만 보관됩니다.
33
- - brandId, cityId는 반드시 list-brands, list-cities로 조회한 실제 ID를 사용하세요.
34
- - branchName은 한국어 지점명, nameJa는 일본어 지점명(Google Places 검색용)입니다.
35
- - address, location, phone, operatingHours는 비워서 보내도 됩니다. 사용자가 검토 단계에서 Google Places 검색으로 채웁니다.
36
- - 어드민 웹이 열려있어야 push가 성공합니다. 연결 안 되면 사용자에게 어드민을 열라고 안내하세요.`,
37
- });
38
-
39
- // --- Tool: push-stores ---
40
- server.tool(
41
- 'admin:push-stores',
42
- '지점 데이터를 어드민 웹에 미리보기로 push합니다. DB에 저장하지 않습니다. 사용자가 검토 후 등록합니다.',
43
- pushStoresSchema.shape,
44
- async (params) => {
45
- try {
46
- const result = await pushStores(api, params as any);
47
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
48
- } catch (error) {
49
- const message = error instanceof Error ? error.message : 'Unknown error';
50
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
51
- }
52
- },
53
- );
54
-
55
- // --- Tool: list-stores ---
56
- server.tool(
57
- 'admin:list-stores',
58
- '어드민에 등록된 지점 목록을 조회합니다.',
59
- listStoresSchema.shape,
60
- async (params) => {
61
- try {
62
- const result = await listStores(api, params as any);
63
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
64
- } catch (error) {
65
- const message = error instanceof Error ? error.message : 'Unknown error';
66
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
67
- }
68
- },
69
- );
70
-
71
- // --- Tool: list-brands ---
72
- server.tool(
73
- 'admin:list-brands',
74
- '어드민에 등록된 브랜드 목록을 조회합니다.',
75
- listBrandsSchema.shape,
76
- async (params) => {
77
- try {
78
- const result = await listBrands(api, params as any);
79
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
80
- } catch (error) {
81
- const message = error instanceof Error ? error.message : 'Unknown error';
82
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
83
- }
84
- },
85
- );
86
-
87
- // --- Tool: list-cities ---
88
- server.tool(
89
- 'admin:list-cities',
90
- '어드민에 등록된 도시 목록을 조회합니다.',
91
- listCitiesSchema.shape,
92
- async (params) => {
93
- try {
94
- const result = await listCities(api, params as any);
95
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
96
- } catch (error) {
97
- const message = error instanceof Error ? error.message : 'Unknown error';
98
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
99
- }
100
- },
101
- );
102
-
103
- // --- Tool: get-review-status ---
104
- server.tool(
105
- 'admin:get-review-status',
106
- '현재 진행 중인 일괄 등록 검토 상태를 조회합니다.',
107
- getReviewStatusSchema.shape,
108
- async () => {
109
- try {
110
- const result = await getReviewStatus(api);
111
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
112
- } catch (error) {
113
- const message = error instanceof Error ? error.message : 'Unknown error';
114
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
115
- }
116
- },
117
- );
118
-
119
- // --- Start ---
120
- async function main() {
121
- const transport = new StdioServerTransport();
122
- await server.connect(transport);
123
- console.error('[MCP] Server started (API mode)');
124
- }
125
-
126
- main().catch((error) => {
127
- console.error('[MCP] Fatal error:', error);
128
- process.exit(1);
129
- });
@@ -1,37 +0,0 @@
1
- import { z } from 'zod';
2
- import type { AdminApiClient } from '../ws/ws-server.js';
3
-
4
- export const listStoresSchema = z.object({
5
- brandId: z.string().optional().describe('브랜드 ID 필터'),
6
- cityId: z.string().optional().describe('도시 ID 필터'),
7
- state: z.string().optional().default('ENABLED').describe('상태 필터 (ENABLED, DISABLED, PENDING)'),
8
- query: z.string().optional().describe('지점명/주소 검색어'),
9
- limit: z.number().optional().default(50).describe('최대 결과 수 (기본 50)'),
10
- });
11
-
12
- export const listBrandsSchema = z.object({
13
- state: z.string().optional().default('ENABLED').describe('상태 필터'),
14
- query: z.string().optional().describe('브랜드명 검색어'),
15
- });
16
-
17
- export const listCitiesSchema = z.object({
18
- state: z.string().optional().default('ENABLED').describe('상태 필터'),
19
- });
20
-
21
- export const getReviewStatusSchema = z.object({});
22
-
23
- export async function listStores(api: AdminApiClient, params: z.infer<typeof listStoresSchema>) {
24
- return api.send('list-stores', params);
25
- }
26
-
27
- export async function listBrands(api: AdminApiClient, params: z.infer<typeof listBrandsSchema>) {
28
- return api.send('list-brands', params);
29
- }
30
-
31
- export async function listCities(api: AdminApiClient, params: z.infer<typeof listCitiesSchema>) {
32
- return api.send('list-cities', params);
33
- }
34
-
35
- export async function getReviewStatus(api: AdminApiClient) {
36
- return api.send('get-review-status', {});
37
- }
@@ -1,47 +0,0 @@
1
- import { z } from 'zod';
2
- import type { AdminApiClient } from '../ws/ws-server.js';
3
-
4
- export const pushStoresSchema = z.object({
5
- items: z.array(z.object({
6
- brandId: z.string().describe('브랜드 ID'),
7
- branchName: z.string().describe('한국어 지점명'),
8
- nameJa: z.string().describe('일본어 지점명 (Google Places 검색용)'),
9
- cityId: z.string().describe('도시 ID'),
10
- address: z.string().optional().default('').describe('주소'),
11
- location: z.tuple([z.number(), z.number()]).optional().default([0, 0]).describe('[lng, lat]'),
12
- operatingHours: z.array(z.object({
13
- day: z.number(),
14
- open: z.string(),
15
- close: z.string(),
16
- isNextDay: z.boolean(),
17
- })).optional().default([]).describe('영업시간'),
18
- phone: z.string().optional().default('').describe('전화번호'),
19
- note: z.string().optional().default('').describe('메모'),
20
- warning: z.string().optional().default('').describe('주의사항'),
21
- })).describe('등록할 지점 목록'),
22
- });
23
-
24
- export async function pushStores(api: AdminApiClient, params: z.infer<typeof pushStoresSchema>) {
25
- const batchId = `batch-${crypto.randomUUID().slice(0, 8)}`;
26
-
27
- const items = params.items.map((item) => {
28
- const operatingHours = item.operatingHours.length > 0
29
- ? item.operatingHours
30
- : Array.from({ length: 7 }, (_, day) => ({
31
- day,
32
- open: '10:00',
33
- close: '22:00',
34
- isNextDay: false,
35
- }));
36
- return { ...item, operatingHours };
37
- });
38
-
39
- const result = await api.send('push-stores', { batchId, items });
40
-
41
- return {
42
- batchId,
43
- itemCount: items.length,
44
- reviewPath: `/review/${batchId}`,
45
- ...(result as object),
46
- };
47
- }
@@ -1,68 +0,0 @@
1
- import { loadStoredToken, refreshAccessToken, clearTokens } from '../auth.js';
2
-
3
- const DEFAULT_API_URL = 'http://localhost:8080';
4
-
5
- export class AdminApiClient {
6
- private apiUrl: string;
7
- private token: string | null = null;
8
-
9
- constructor() {
10
- this.apiUrl = process.env.ADMIN_API_URL || DEFAULT_API_URL;
11
- }
12
-
13
- async initialize(): Promise<void> {
14
- const stored = await loadStoredToken();
15
- if (stored) {
16
- this.token = stored;
17
- return;
18
- }
19
-
20
- throw new Error(
21
- '로그인이 필요합니다. 먼저 `npx coupon-moa-mcp login` 을 실행해주세요.'
22
- );
23
- }
24
-
25
- async send(method: string, params: unknown): Promise<unknown> {
26
- if (!this.token) {
27
- throw new Error('로그인이 필요합니다. `npx coupon-moa-mcp login` 을 실행해주세요.');
28
- }
29
-
30
- let response = await this.fetchApi(method, params);
31
-
32
- // 401이면 토큰 refresh 시도
33
- if (response.status === 401) {
34
- const newToken = await refreshAccessToken(this.apiUrl);
35
- if (newToken) {
36
- this.token = newToken;
37
- response = await this.fetchApi(method, params);
38
- } else {
39
- await clearTokens();
40
- throw new Error('토큰이 만료되었습니다. `npx coupon-moa-mcp login` 으로 다시 로그인해주세요.');
41
- }
42
- }
43
-
44
- if (!response.ok) {
45
- const text = await response.text();
46
- throw new Error(`API 호출 실패 (${response.status}): ${text}`);
47
- }
48
-
49
- const json = await response.json();
50
- return json.data ?? json;
51
- }
52
-
53
- private async fetchApi(method: string, params: unknown): Promise<Response> {
54
- const url = `${this.apiUrl}/api/admin/mcp/${method}`;
55
- return fetch(url, {
56
- method: 'POST',
57
- headers: {
58
- 'Content-Type': 'application/json',
59
- 'Authorization': `Bearer ${this.token}`,
60
- },
61
- body: JSON.stringify(params),
62
- });
63
- }
64
-
65
- isConnected(): boolean {
66
- return this.token !== null;
67
- }
68
- }