coupon-moa-mcp 0.0.1

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,61 @@
1
+ # coupon-moa-mcp
2
+
3
+ Coupon Moa 어드민 MCP Server. Claude Desktop 또는 Claude Code에서 어드민 데이터를 조회하고, 지점을 일괄 등록할 수 있습니다.
4
+
5
+ ## 설치 및 사용
6
+
7
+ ### Claude Desktop
8
+
9
+ `claude_desktop_config.json`에 추가:
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "coupon-moa": {
15
+ "command": "npx",
16
+ "args": ["-y", "coupon-moa-mcp"],
17
+ "env": {
18
+ "ADMIN_API_URL": "https://your-api-url.com"
19
+ }
20
+ }
21
+ }
22
+ }
23
+ ```
24
+
25
+ ### Claude Code
26
+
27
+ `.mcp.json`에 추가:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "coupon-moa": {
33
+ "command": "npx",
34
+ "args": ["-y", "coupon-moa-mcp"],
35
+ "env": {
36
+ "ADMIN_API_URL": "https://your-api-url.com"
37
+ }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ ## 인증
44
+
45
+ 처음 실행 시 브라우저가 열리면서 Google 로그인을 요청합니다. 로그인 완료 후 토큰이 `~/.coupon-moa-mcp/token.json`에 저장되며, 이후 자동으로 사용됩니다.
46
+
47
+ ## Tools
48
+
49
+ | Tool | 설명 |
50
+ |------|------|
51
+ | `admin:push-stores` | 지점 데이터를 어드민 웹에 미리보기로 push |
52
+ | `admin:list-stores` | 지점 목록 조회 |
53
+ | `admin:list-brands` | 브랜드 목록 조회 |
54
+ | `admin:list-cities` | 도시 목록 조회 |
55
+ | `admin:get-review-status` | 검토 진행 상태 조회 |
56
+
57
+ ## 환경변수
58
+
59
+ | 변수 | 설명 | 기본값 |
60
+ |------|------|--------|
61
+ | `ADMIN_API_URL` | 백엔드 API URL | `http://localhost:8080` |
@@ -0,0 +1,7 @@
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 { default: _ } = await import('../src/index.ts');
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "coupon-moa-mcp",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for Coupon Moa admin — push store data, query brands/cities/stores via Claude",
5
+ "private": false,
6
+ "type": "module",
7
+ "bin": {
8
+ "coupon-moa-mcp": "./bin/coupon-moa-mcp.mjs"
9
+ },
10
+ "scripts": {
11
+ "dev": "tsx watch src/index.ts",
12
+ "start": "tsx src/index.ts",
13
+ "publish:npm": "bash ../../scripts/publish-mcp.sh"
14
+ },
15
+ "files": [
16
+ "src/**/*.ts",
17
+ "bin/**/*.mjs",
18
+ "package.json",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "coupon-moa",
25
+ "claude"
26
+ ],
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.12.1",
30
+ "tsx": "^4.19.0",
31
+ "zod": "^3.24.0"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.9.3"
35
+ }
36
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,133 @@
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 getAccessToken(apiUrl: string): Promise<string> {
18
+ // 저장된 토큰 확인
19
+ const stored = await loadTokens();
20
+ if (stored) {
21
+ // accessToken 유효성은 API 호출 시 확인 — 여기서는 일단 반환
22
+ return stored.accessToken;
23
+ }
24
+
25
+ // 토큰 없으면 OAuth 로그인
26
+ console.error('[MCP] 로그인이 필요합니다. 브라우저가 열립니다...');
27
+ const tokens = await startOAuthFlow(apiUrl);
28
+ await saveTokens(tokens);
29
+ return tokens.accessToken;
30
+ }
31
+
32
+ export async function refreshAccessToken(apiUrl: string): Promise<string | null> {
33
+ const stored = await loadTokens();
34
+ if (!stored?.refreshToken) {
35
+ return null;
36
+ }
37
+
38
+ try {
39
+ const response = await fetch(`${apiUrl}/api/auth/refresh`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json', 'Cookie': `refreshToken=${stored.refreshToken}` },
42
+ });
43
+
44
+ if (!response.ok) {
45
+ return null;
46
+ }
47
+
48
+ const data = await response.json() as { data?: { accessToken: string } };
49
+ const accessToken = data.data?.accessToken;
50
+ if (!accessToken) {
51
+ return null;
52
+ }
53
+
54
+ await saveTokens({ ...stored, accessToken, savedAt: Date.now() });
55
+ return accessToken;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export async function clearTokens(): Promise<void> {
62
+ if (existsSync(TOKEN_FILE)) {
63
+ const { unlink } = await import('node:fs/promises');
64
+ await unlink(TOKEN_FILE);
65
+ }
66
+ }
67
+
68
+ async function loadTokens(): Promise<StoredTokens | null> {
69
+ try {
70
+ const content = await readFile(TOKEN_FILE, 'utf-8');
71
+ return JSON.parse(content) as StoredTokens;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ async function saveTokens(tokens: StoredTokens): Promise<void> {
78
+ if (!existsSync(TOKEN_DIR)) {
79
+ await mkdir(TOKEN_DIR, { recursive: true });
80
+ }
81
+ await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2));
82
+ }
83
+
84
+ function startOAuthFlow(apiUrl: string): Promise<StoredTokens> {
85
+ return new Promise((resolve, reject) => {
86
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
87
+ const url = new URL(req.url ?? '', `http://localhost:${AUTH_PORT}`);
88
+
89
+ if (url.pathname === '/callback') {
90
+ const accessToken = url.searchParams.get('accessToken');
91
+
92
+ if (!accessToken) {
93
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
94
+ res.end('<h1>인증 실패</h1><p>토큰을 받지 못했습니다. 다시 시도해주세요.</p>');
95
+ server.close();
96
+ reject(new Error('No access token received'));
97
+ return;
98
+ }
99
+
100
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
101
+ res.end('<h1>인증 완료!</h1><p>이 창을 닫아도 됩니다.</p><script>setTimeout(()=>window.close(),1500)</script>');
102
+
103
+ server.close();
104
+ resolve({ accessToken, savedAt: Date.now() });
105
+ return;
106
+ }
107
+
108
+ res.writeHead(404);
109
+ res.end('Not found');
110
+ });
111
+
112
+ server.listen(AUTH_PORT, () => {
113
+ const loginUrl = `${apiUrl}/api/auth/google?redirect=http://localhost:${AUTH_PORT}`;
114
+ console.error(`[MCP] 브라우저에서 로그인해주세요: ${loginUrl}`);
115
+
116
+ // 브라우저 자동 열기
117
+ import('node:child_process').then(({ exec }) => {
118
+ const command = process.platform === 'darwin'
119
+ ? `open "${loginUrl}"`
120
+ : process.platform === 'win32'
121
+ ? `start "${loginUrl}"`
122
+ : `xdg-open "${loginUrl}"`;
123
+ exec(command);
124
+ });
125
+ });
126
+
127
+ // 60초 타임아웃
128
+ setTimeout(() => {
129
+ server.close();
130
+ reject(new Error('로그인 타임아웃 (60초). 다시 시도해주세요.'));
131
+ }, 60000);
132
+ });
133
+ }
package/src/index.ts ADDED
@@ -0,0 +1,108 @@
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
+ await api.initialize();
12
+
13
+ const server = new McpServer({
14
+ name: 'coupon-moa-admin',
15
+ version: '0.0.1',
16
+ });
17
+
18
+ // --- Tool: push-stores ---
19
+ server.tool(
20
+ 'admin:push-stores',
21
+ '지점 데이터를 어드민 웹에 미리보기로 push합니다. DB에 저장하지 않습니다. 사용자가 검토 후 등록합니다.',
22
+ pushStoresSchema.shape,
23
+ async (params) => {
24
+ try {
25
+ const result = await pushStores(api, params as any);
26
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
27
+ } catch (error) {
28
+ const message = error instanceof Error ? error.message : 'Unknown error';
29
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
30
+ }
31
+ },
32
+ );
33
+
34
+ // --- Tool: list-stores ---
35
+ server.tool(
36
+ 'admin:list-stores',
37
+ '어드민에 등록된 지점 목록을 조회합니다.',
38
+ listStoresSchema.shape,
39
+ async (params) => {
40
+ try {
41
+ const result = await listStores(api, params as any);
42
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
43
+ } catch (error) {
44
+ const message = error instanceof Error ? error.message : 'Unknown error';
45
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
46
+ }
47
+ },
48
+ );
49
+
50
+ // --- Tool: list-brands ---
51
+ server.tool(
52
+ 'admin:list-brands',
53
+ '어드민에 등록된 브랜드 목록을 조회합니다.',
54
+ listBrandsSchema.shape,
55
+ async (params) => {
56
+ try {
57
+ const result = await listBrands(api, params as any);
58
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
59
+ } catch (error) {
60
+ const message = error instanceof Error ? error.message : 'Unknown error';
61
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
62
+ }
63
+ },
64
+ );
65
+
66
+ // --- Tool: list-cities ---
67
+ server.tool(
68
+ 'admin:list-cities',
69
+ '어드민에 등록된 도시 목록을 조회합니다.',
70
+ listCitiesSchema.shape,
71
+ async (params) => {
72
+ try {
73
+ const result = await listCities(api, params as any);
74
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
75
+ } catch (error) {
76
+ const message = error instanceof Error ? error.message : 'Unknown error';
77
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
78
+ }
79
+ },
80
+ );
81
+
82
+ // --- Tool: get-review-status ---
83
+ server.tool(
84
+ 'admin:get-review-status',
85
+ '현재 진행 중인 일괄 등록 검토 상태를 조회합니다.',
86
+ getReviewStatusSchema.shape,
87
+ async () => {
88
+ try {
89
+ const result = await getReviewStatus(api);
90
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
91
+ } catch (error) {
92
+ const message = error instanceof Error ? error.message : 'Unknown error';
93
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
94
+ }
95
+ },
96
+ );
97
+
98
+ // --- Start ---
99
+ async function main() {
100
+ const transport = new StdioServerTransport();
101
+ await server.connect(transport);
102
+ console.error('[MCP] Server started (API mode)');
103
+ }
104
+
105
+ main().catch((error) => {
106
+ console.error('[MCP] Fatal error:', error);
107
+ process.exit(1);
108
+ });
@@ -0,0 +1,37 @@
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
+ }
@@ -0,0 +1,47 @@
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
+ }
@@ -0,0 +1,62 @@
1
+ import { getAccessToken, 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
+ this.token = await getAccessToken(this.apiUrl);
15
+ }
16
+
17
+ async send(method: string, params: unknown): Promise<unknown> {
18
+ if (!this.token) {
19
+ await this.initialize();
20
+ }
21
+
22
+ let response = await this.fetchApi(method, params);
23
+
24
+ // 401이면 토큰 refresh 시도
25
+ if (response.status === 401) {
26
+ const newToken = await refreshAccessToken(this.apiUrl);
27
+ if (newToken) {
28
+ this.token = newToken;
29
+ response = await this.fetchApi(method, params);
30
+ } else {
31
+ // refresh 실패 → 재로그인
32
+ await clearTokens();
33
+ this.token = await getAccessToken(this.apiUrl);
34
+ response = await this.fetchApi(method, params);
35
+ }
36
+ }
37
+
38
+ if (!response.ok) {
39
+ const text = await response.text();
40
+ throw new Error(`API 호출 실패 (${response.status}): ${text}`);
41
+ }
42
+
43
+ const json = await response.json();
44
+ return json.data ?? json;
45
+ }
46
+
47
+ private async fetchApi(method: string, params: unknown): Promise<Response> {
48
+ const url = `${this.apiUrl}/api/admin/mcp/${method}`;
49
+ return fetch(url, {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ 'Authorization': `Bearer ${this.token}`,
54
+ },
55
+ body: JSON.stringify(params),
56
+ });
57
+ }
58
+
59
+ isConnected(): boolean {
60
+ return this.token !== null;
61
+ }
62
+ }