routy-mcp-server 1.0.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 +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +300 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Routy MCP Server
|
|
2
|
+
|
|
3
|
+
Routy 여행 플래너의 MCP (Model Context Protocol) 서버입니다.
|
|
4
|
+
Claude Desktop, Cursor 등 MCP 클라이언트에서 **본인 Routy 계정**으로 여행을 조회/생성/관리할 수 있습니다.
|
|
5
|
+
|
|
6
|
+
## 제공 도구 (Tools)
|
|
7
|
+
|
|
8
|
+
| 도구 | 설명 |
|
|
9
|
+
|------|------|
|
|
10
|
+
| `list_trips` | 내 여행 목록 / 공개 여행 조회 |
|
|
11
|
+
| `get_trip` | 여행 상세 조회 (일정+장소 포함) |
|
|
12
|
+
| `create_trip` | 새 여행 생성 |
|
|
13
|
+
| `add_place` | 일정에 장소 추가 |
|
|
14
|
+
| `search_popular_places` | 인기 장소 검색 |
|
|
15
|
+
| `get_rankings` | 인기 여행 순위 (조회수/좋아요) |
|
|
16
|
+
| `delete_trip` | 여행 삭제 |
|
|
17
|
+
|
|
18
|
+
## 설치
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd mcp-server
|
|
22
|
+
npm install
|
|
23
|
+
npm run build
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 사용 방법
|
|
27
|
+
|
|
28
|
+
### 1. API 키 발급
|
|
29
|
+
|
|
30
|
+
[routy.co.kr](https://routy.co.kr) → 프로필 → API Keys 섹션에서 키를 생성하세요.
|
|
31
|
+
|
|
32
|
+
### 2. Claude Desktop 설정
|
|
33
|
+
|
|
34
|
+
`claude_desktop_config.json`에 추가:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"mcpServers": {
|
|
39
|
+
"routy": {
|
|
40
|
+
"command": "node",
|
|
41
|
+
"args": ["/path/to/routy/mcp-server/dist/index.js"],
|
|
42
|
+
"env": {
|
|
43
|
+
"ROUTY_API_KEY": "routy_sk_xxxxxxxxxxxxxxxx"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 3. 사용
|
|
51
|
+
|
|
52
|
+
Claude Desktop에서 자연어로:
|
|
53
|
+
- "내 여행 목록 보여줘" → `list_trips`
|
|
54
|
+
- "도쿄 3박 4일 여행 만들어줘" → `create_trip` + `add_place`
|
|
55
|
+
- "인기 여행 순위 알려줘" → `get_rankings`
|
|
56
|
+
- "신주쿠 인기 장소 검색해줘" → `search_popular_places`
|
|
57
|
+
|
|
58
|
+
## 보안
|
|
59
|
+
|
|
60
|
+
- API 키는 SHA-256 해싱되어 저장됩니다
|
|
61
|
+
- 키가 유출되면 프로필에서 즉시 폐기하고 새로 발급하세요
|
|
62
|
+
- 서버는 Supabase anon key만 사용하며, 모든 인증은 서버사이드 RPC 함수에서 처리됩니다
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createClient } from "@supabase/supabase-js";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
// ── Config ───────────────────────────────────────────────────────
|
|
8
|
+
const SUPABASE_URL = "https://cntscjgvayswahyadvxk.supabase.co";
|
|
9
|
+
const SUPABASE_ANON_KEY = "sb_publishable_QpjCt3rQYues0CT2Q31fWw_g1y1y_Cb";
|
|
10
|
+
let client;
|
|
11
|
+
let keyHash;
|
|
12
|
+
function initClient() {
|
|
13
|
+
const apiKey = process.env.ROUTY_API_KEY;
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new Error("ROUTY_API_KEY is required.\n" +
|
|
16
|
+
"Generate one at https://routy.co.kr/profile → API Keys section.");
|
|
17
|
+
}
|
|
18
|
+
keyHash = createHash("sha256").update(apiKey).digest("hex");
|
|
19
|
+
client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
20
|
+
}
|
|
21
|
+
// Helper: call RPC with key_hash
|
|
22
|
+
async function rpc(fn, params = {}) {
|
|
23
|
+
const { data, error } = await client.rpc(fn, {
|
|
24
|
+
p_key_hash: keyHash,
|
|
25
|
+
...params,
|
|
26
|
+
});
|
|
27
|
+
if (error)
|
|
28
|
+
throw new Error(error.message);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
// ── MCP Server ───────────────────────────────────────────────────
|
|
32
|
+
const server = new McpServer({
|
|
33
|
+
name: "routy",
|
|
34
|
+
version: "1.0.0",
|
|
35
|
+
});
|
|
36
|
+
// ── Tool: list_trips ─────────────────────────────────────────────
|
|
37
|
+
server.tool("list_trips", "List my trips, or browse public trips.", {
|
|
38
|
+
public_only: z
|
|
39
|
+
.boolean()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Only return public trips (default false = my trips)"),
|
|
42
|
+
limit: z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.min(1)
|
|
46
|
+
.max(100)
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Max results (default 20)"),
|
|
49
|
+
}, async ({ public_only, limit }) => {
|
|
50
|
+
try {
|
|
51
|
+
const data = await rpc("mcp_list_trips", {
|
|
52
|
+
p_public_only: public_only ?? false,
|
|
53
|
+
p_limit: limit ?? 20,
|
|
54
|
+
});
|
|
55
|
+
const trips = data ?? [];
|
|
56
|
+
if (trips.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: "text",
|
|
61
|
+
text: public_only
|
|
62
|
+
? "No public trips found."
|
|
63
|
+
: "No trips yet. Use create_trip to make one!",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: "text", text: JSON.stringify(trips, null, 2) }],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// ── Tool: get_trip ───────────────────────────────────────────────
|
|
77
|
+
server.tool("get_trip", "Get full trip details including all days and places.", {
|
|
78
|
+
trip_id: z.string().uuid().describe("Trip ID"),
|
|
79
|
+
}, async ({ trip_id }) => {
|
|
80
|
+
try {
|
|
81
|
+
const data = await rpc("mcp_get_trip", { p_trip_id: trip_id });
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// ── Tool: create_trip ────────────────────────────────────────────
|
|
91
|
+
server.tool("create_trip", "Create a new trip with days. Returns the created trip ID.", {
|
|
92
|
+
name: z.string().min(1).describe("Trip name"),
|
|
93
|
+
start_date: z.string().describe("Start date (YYYY-MM-DD)"),
|
|
94
|
+
end_date: z.string().describe("End date (YYYY-MM-DD)"),
|
|
95
|
+
region: z
|
|
96
|
+
.enum(["DOMESTIC", "INTERNATIONAL"])
|
|
97
|
+
.optional()
|
|
98
|
+
.describe("Region type (default INTERNATIONAL)"),
|
|
99
|
+
memo: z.string().optional().describe("Trip description"),
|
|
100
|
+
}, async ({ name, start_date, end_date, region, memo }) => {
|
|
101
|
+
try {
|
|
102
|
+
const data = await rpc("mcp_create_trip", {
|
|
103
|
+
p_name: name,
|
|
104
|
+
p_start_date: start_date,
|
|
105
|
+
p_end_date: end_date,
|
|
106
|
+
p_region: region ?? "INTERNATIONAL",
|
|
107
|
+
p_memo: memo ?? "",
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: `Trip created!\n ID: ${data.trip_id}\n Name: ${data.name}\n Days: ${data.days}`,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
// ── Tool: add_place ──────────────────────────────────────────────
|
|
123
|
+
server.tool("add_place", "Add a place/POI to a specific day in a trip. Include google_place_id and place_url when available for rich marker info.", {
|
|
124
|
+
trip_id: z.string().uuid().describe("Trip ID"),
|
|
125
|
+
day_number: z.number().int().min(1).describe("Day number (1-based)"),
|
|
126
|
+
name: z.string().min(1).describe("Place name"),
|
|
127
|
+
lat: z.number().describe("Latitude"),
|
|
128
|
+
lng: z.number().describe("Longitude"),
|
|
129
|
+
address: z.string().optional().describe("Address"),
|
|
130
|
+
arrival_time: z
|
|
131
|
+
.string()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe("Arrival time (HH:MM, default 09:00)"),
|
|
134
|
+
duration_minutes: z
|
|
135
|
+
.number()
|
|
136
|
+
.int()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Stay duration in minutes (default 60)"),
|
|
139
|
+
memo: z.string().optional().describe("Note about this place"),
|
|
140
|
+
google_place_id: z
|
|
141
|
+
.string()
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("Google Place ID (e.g. ChIJ...) — use for international places"),
|
|
144
|
+
kakao_place_id: z
|
|
145
|
+
.string()
|
|
146
|
+
.optional()
|
|
147
|
+
.describe("Kakao Place ID — use for domestic (Korean) places"),
|
|
148
|
+
place_url: z
|
|
149
|
+
.string()
|
|
150
|
+
.optional()
|
|
151
|
+
.describe("Google Maps URL for this place"),
|
|
152
|
+
category_name: z
|
|
153
|
+
.string()
|
|
154
|
+
.optional()
|
|
155
|
+
.describe("Place category (e.g. restaurant, temple)"),
|
|
156
|
+
phone: z.string().optional().describe("Phone number"),
|
|
157
|
+
}, async ({ trip_id, day_number, name, lat, lng, address, arrival_time, duration_minutes, memo, google_place_id, kakao_place_id, place_url, category_name, phone }) => {
|
|
158
|
+
try {
|
|
159
|
+
const data = await rpc("mcp_add_place", {
|
|
160
|
+
p_trip_id: trip_id,
|
|
161
|
+
p_day_number: day_number,
|
|
162
|
+
p_name: name,
|
|
163
|
+
p_lat: lat,
|
|
164
|
+
p_lng: lng,
|
|
165
|
+
p_address: address ?? "",
|
|
166
|
+
p_arrival_time: arrival_time ?? "09:00",
|
|
167
|
+
p_duration_minutes: duration_minutes ?? 60,
|
|
168
|
+
p_memo: memo ?? "",
|
|
169
|
+
p_google_place_id: google_place_id ?? null,
|
|
170
|
+
p_kakao_place_id: kakao_place_id ?? null,
|
|
171
|
+
p_place_url: place_url ?? "",
|
|
172
|
+
p_category_name: category_name ?? "",
|
|
173
|
+
p_phone: phone ?? "",
|
|
174
|
+
p_is_in_route: true,
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
type: "text",
|
|
180
|
+
text: `Place added to Day ${day_number}!\n ID: ${data.place_id}\n Name: ${data.name}\n Order: ${data.order_index}`,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// ── Tool: update_trip ────────────────────────────────────────────
|
|
190
|
+
server.tool("update_trip", "Update a trip's name, memo, or visibility (public/private).", {
|
|
191
|
+
trip_id: z.string().uuid().describe("Trip ID"),
|
|
192
|
+
name: z.string().optional().describe("New trip name"),
|
|
193
|
+
memo: z.string().optional().describe("New trip description"),
|
|
194
|
+
is_public: z
|
|
195
|
+
.boolean()
|
|
196
|
+
.optional()
|
|
197
|
+
.describe("Set true to make public, false for private"),
|
|
198
|
+
}, async ({ trip_id, name, memo, is_public }) => {
|
|
199
|
+
try {
|
|
200
|
+
const data = await rpc("mcp_update_trip", {
|
|
201
|
+
p_trip_id: trip_id,
|
|
202
|
+
p_name: name ?? null,
|
|
203
|
+
p_memo: memo ?? null,
|
|
204
|
+
p_is_public: is_public ?? null,
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
content: [
|
|
208
|
+
{
|
|
209
|
+
type: "text",
|
|
210
|
+
text: `Trip updated!\n${JSON.stringify(data, null, 2)}`,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
// ── Tool: search_popular_places ──────────────────────────────────
|
|
220
|
+
server.tool("search_popular_places", "Search globally popular places tracked across all Routy users.", {
|
|
221
|
+
query: z.string().min(1).describe("Search keyword (place name)"),
|
|
222
|
+
limit: z
|
|
223
|
+
.number()
|
|
224
|
+
.int()
|
|
225
|
+
.min(1)
|
|
226
|
+
.max(50)
|
|
227
|
+
.optional()
|
|
228
|
+
.describe("Max results (default 10)"),
|
|
229
|
+
}, async ({ query, limit }) => {
|
|
230
|
+
try {
|
|
231
|
+
const data = await rpc("mcp_search_places", {
|
|
232
|
+
p_query: query,
|
|
233
|
+
p_limit: limit ?? 10,
|
|
234
|
+
});
|
|
235
|
+
if (!data || data.length === 0) {
|
|
236
|
+
return {
|
|
237
|
+
content: [
|
|
238
|
+
{ type: "text", text: `No places found matching "${query}"` },
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
// ── Tool: get_rankings ───────────────────────────────────────────
|
|
251
|
+
server.tool("get_rankings", "Get top-ranked public trips by views or likes.", {
|
|
252
|
+
sort_by: z
|
|
253
|
+
.enum(["views", "likes"])
|
|
254
|
+
.optional()
|
|
255
|
+
.describe("Sort by views or likes (default views)"),
|
|
256
|
+
limit: z
|
|
257
|
+
.number()
|
|
258
|
+
.int()
|
|
259
|
+
.min(1)
|
|
260
|
+
.max(50)
|
|
261
|
+
.optional()
|
|
262
|
+
.describe("Max results (default 10)"),
|
|
263
|
+
}, async ({ sort_by, limit }) => {
|
|
264
|
+
try {
|
|
265
|
+
const data = await rpc("mcp_get_rankings", {
|
|
266
|
+
p_sort_by: sort_by ?? "views",
|
|
267
|
+
p_limit: limit ?? 10,
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
// ── Tool: delete_trip ────────────────────────────────────────────
|
|
278
|
+
server.tool("delete_trip", "Delete a trip and all its days/places (cascade).", {
|
|
279
|
+
trip_id: z.string().uuid().describe("Trip ID to delete"),
|
|
280
|
+
}, async ({ trip_id }) => {
|
|
281
|
+
try {
|
|
282
|
+
const data = await rpc("mcp_delete_trip", { p_trip_id: trip_id });
|
|
283
|
+
return {
|
|
284
|
+
content: [{ type: "text", text: `Deleted trip: ${data.deleted}` }],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
// ── Start ────────────────────────────────────────────────────────
|
|
292
|
+
async function main() {
|
|
293
|
+
initClient();
|
|
294
|
+
const transport = new StdioServerTransport();
|
|
295
|
+
await server.connect(transport);
|
|
296
|
+
}
|
|
297
|
+
main().catch((err) => {
|
|
298
|
+
console.error("Fatal:", err);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "routy-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Routy Travel Planner MCP Server — manage trips from any MCP client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"routy-mcp-server": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"dev": "tsc --watch"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
|
+
"@supabase/supabase-js": "^2.49.4",
|
|
21
|
+
"zod": "^3.24.2"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.15.3",
|
|
25
|
+
"typescript": "^5.8.3"
|
|
26
|
+
}
|
|
27
|
+
}
|