ynosis-agent-mcp 0.2.1 → 0.2.3
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/package.json +2 -2
- package/src/client.mjs +71 -7
- package/src/facility_match.mjs +38 -11
- package/src/server.mjs +59 -15
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ynosis-agent-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"test": "node --test test/*.test.mjs"
|
|
6
6
|
},
|
|
7
7
|
"description": "MCP stdio server for the Ynosis Agent API (facilities and spring analyses)",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|
|
10
|
-
"ynosis-agent-mcp": "
|
|
10
|
+
"ynosis-agent-mcp": "src/server.mjs"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
package/src/client.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { File } from "node:buffer";
|
|
2
|
+
|
|
1
3
|
const DEFAULT_BASE = "https://ynosis.jp";
|
|
4
|
+
const DEFAULT_REGISTER_MIN_INTERVAL_MS = 20_000;
|
|
2
5
|
|
|
3
6
|
function resolveKey() {
|
|
4
7
|
return process.env.YNOSIS_AGENT_API_KEY || process.env.AGENT_API_KEY || "";
|
|
@@ -13,6 +16,26 @@ function resolveBase() {
|
|
|
13
16
|
return raw.replace(/\/$/, "");
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @type {number | null} */
|
|
24
|
+
let lastRegisterRelatedAt = null;
|
|
25
|
+
|
|
26
|
+
export async function paceRegisterRelated(minIntervalMs = DEFAULT_REGISTER_MIN_INTERVAL_MS) {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
if (lastRegisterRelatedAt != null && minIntervalMs > 0) {
|
|
29
|
+
const wait = minIntervalMs - (now - lastRegisterRelatedAt);
|
|
30
|
+
if (wait > 0) await sleep(wait);
|
|
31
|
+
}
|
|
32
|
+
lastRegisterRelatedAt = Date.now();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function appendUploadFile(form, fieldName, buf, filename, contentType) {
|
|
36
|
+
form.append(fieldName, new File([buf], filename, { type: contentType }));
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
function imageContentType(filePath) {
|
|
17
40
|
const suffix = filePath.toLowerCase().split(".").pop();
|
|
18
41
|
if (suffix === "png") return "image/png";
|
|
@@ -22,6 +45,14 @@ function imageContentType(filePath) {
|
|
|
22
45
|
return "image/jpeg";
|
|
23
46
|
}
|
|
24
47
|
|
|
48
|
+
function preparedImageHeaders(filePath) {
|
|
49
|
+
const suffix = filePath.toLowerCase().split(".").pop();
|
|
50
|
+
if (suffix === "jpg" || suffix === "jpeg") {
|
|
51
|
+
return { "X-Ynosis-Image-Prepared": "1" };
|
|
52
|
+
}
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
|
|
25
56
|
export class AgentApiError extends Error {
|
|
26
57
|
constructor(statusCode, detail) {
|
|
27
58
|
super(`HTTP ${statusCode}: ${detail}`);
|
|
@@ -52,12 +83,17 @@ export class AgentApiClient {
|
|
|
52
83
|
return headers;
|
|
53
84
|
}
|
|
54
85
|
|
|
55
|
-
async request(method, path, { json, form } = {}) {
|
|
86
|
+
async request(method, path, { json, form, extraHeaders } = {}) {
|
|
56
87
|
const url = `${this.baseUrl}${path}`;
|
|
57
88
|
const ctrl = new AbortController();
|
|
58
89
|
const timer = setTimeout(() => ctrl.abort(), form ? Math.max(this.timeoutMs, 180_000) : this.timeoutMs);
|
|
59
90
|
try {
|
|
60
|
-
const
|
|
91
|
+
const formHeaders = { "X-API-Key": this.apiKey, ...(extraHeaders || {}) };
|
|
92
|
+
const init = {
|
|
93
|
+
method,
|
|
94
|
+
headers: form ? formHeaders : this.headers(json != null),
|
|
95
|
+
signal: ctrl.signal,
|
|
96
|
+
};
|
|
61
97
|
if (form) init.body = form;
|
|
62
98
|
else if (json != null) init.body = JSON.stringify(json);
|
|
63
99
|
const res = await fetch(url, init);
|
|
@@ -111,6 +147,20 @@ export class AgentApiClient {
|
|
|
111
147
|
return res.json;
|
|
112
148
|
}
|
|
113
149
|
|
|
150
|
+
async requestFacilityDeletion(facilityId, { reason, sourceLink }) {
|
|
151
|
+
const res = await this.request("POST", `/api/agent/facilities/${facilityId}/deletion-requests`, {
|
|
152
|
+
json: { reason, source_link: sourceLink },
|
|
153
|
+
});
|
|
154
|
+
return res.json;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async requestSourceDeletion(sourceId, { reason, sourceLink }) {
|
|
158
|
+
const res = await this.request("POST", `/api/agent/sources/${sourceId}/deletion-requests`, {
|
|
159
|
+
json: { reason, source_link: sourceLink },
|
|
160
|
+
});
|
|
161
|
+
return res.json;
|
|
162
|
+
}
|
|
163
|
+
|
|
114
164
|
async remediateDuplicateFacility(duplicateId, { canonicalId = null, dryRun = false } = {}) {
|
|
115
165
|
const { remediateDuplicateFacility: remediate } = await import("./facility_remediate.mjs");
|
|
116
166
|
return remediate(this, duplicateId, { canonicalId, dryRun });
|
|
@@ -177,21 +227,31 @@ export class AgentApiClient {
|
|
|
177
227
|
const res = await this.request("POST", "/api/agent/register", { json: payload });
|
|
178
228
|
return res.json;
|
|
179
229
|
}
|
|
230
|
+
await paceRegisterRelated();
|
|
180
231
|
const buf = await readFile(imagePath);
|
|
181
232
|
const form = new FormData();
|
|
182
233
|
form.append("payload", JSON.stringify(payload));
|
|
183
|
-
|
|
184
|
-
|
|
234
|
+
const name = basename(imagePath);
|
|
235
|
+
appendUploadFile(form, "file", buf, name, imageContentType(imagePath));
|
|
236
|
+
const res = await this.request("POST", "/api/agent/register", {
|
|
237
|
+
form,
|
|
238
|
+
extraHeaders: preparedImageHeaders(imagePath),
|
|
239
|
+
});
|
|
185
240
|
return res.json;
|
|
186
241
|
}
|
|
187
242
|
|
|
188
243
|
async analyzeImage(imagePath) {
|
|
189
244
|
const { readFile } = await import("node:fs/promises");
|
|
190
245
|
const { basename } = await import("node:path");
|
|
246
|
+
await paceRegisterRelated();
|
|
191
247
|
const buf = await readFile(imagePath);
|
|
192
248
|
const form = new FormData();
|
|
193
|
-
|
|
194
|
-
|
|
249
|
+
const name = basename(imagePath);
|
|
250
|
+
appendUploadFile(form, "file", buf, name, imageContentType(imagePath));
|
|
251
|
+
const res = await this.request("POST", "/api/agent/analyze", {
|
|
252
|
+
form,
|
|
253
|
+
extraHeaders: preparedImageHeaders(imagePath),
|
|
254
|
+
});
|
|
195
255
|
return res.json;
|
|
196
256
|
}
|
|
197
257
|
|
|
@@ -201,6 +261,7 @@ export class AgentApiClient {
|
|
|
201
261
|
}
|
|
202
262
|
|
|
203
263
|
async parseTranscript(rawText, llmJson) {
|
|
264
|
+
await paceRegisterRelated();
|
|
204
265
|
const body = { raw_text: rawText };
|
|
205
266
|
if (llmJson != null) body.llm_json = llmJson;
|
|
206
267
|
const res = await this.request("POST", "/api/agent/parse", { json: body });
|
|
@@ -215,9 +276,11 @@ export class AgentApiClient {
|
|
|
215
276
|
async uploadSourceImage(sourceId, imagePath) {
|
|
216
277
|
const { readFile } = await import("node:fs/promises");
|
|
217
278
|
const { basename } = await import("node:path");
|
|
279
|
+
await paceRegisterRelated();
|
|
218
280
|
const buf = await readFile(imagePath);
|
|
219
281
|
const form = new FormData();
|
|
220
|
-
|
|
282
|
+
const name = basename(imagePath);
|
|
283
|
+
appendUploadFile(form, "file", buf, name, imageContentType(imagePath));
|
|
221
284
|
const res = await this.request("POST", `/api/sources/${sourceId}/image`, { form });
|
|
222
285
|
return res.json;
|
|
223
286
|
}
|
|
@@ -255,6 +318,7 @@ export const FACILITY_TAG_OPTIONS = [
|
|
|
255
318
|
"露天あり",
|
|
256
319
|
"貸切湯あり",
|
|
257
320
|
"日帰り入浴可",
|
|
321
|
+
"飲泉可",
|
|
258
322
|
"共同浴場",
|
|
259
323
|
"造成泉",
|
|
260
324
|
"モール泉",
|
package/src/facility_match.mjs
CHANGED
|
@@ -45,16 +45,28 @@ export function facilitySearchQueries(name) {
|
|
|
45
45
|
return queries;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
async function coreNameInFacilityName(query, facilityName) {
|
|
49
|
+
const core = await normalizeFacilityName(query);
|
|
50
|
+
const normDb = await normalizeFacilityName(facilityName);
|
|
51
|
+
if (core.length < 2) return false;
|
|
52
|
+
return normDb.includes(core);
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
export async function scoreFacilityItem(candidateName, item) {
|
|
49
56
|
const dbName = item?.name || "";
|
|
50
57
|
let score = await nameSimilarity(candidateName, dbName);
|
|
58
|
+
let contains = await coreNameInFacilityName(candidateName, dbName);
|
|
51
59
|
for (const alias of item?.aliases || []) {
|
|
52
60
|
const aliasName = typeof alias === "object" ? alias?.name : alias;
|
|
53
|
-
if (aliasName)
|
|
61
|
+
if (aliasName) {
|
|
62
|
+
const aliasStr = String(aliasName);
|
|
63
|
+
score = Math.max(score, await nameSimilarity(candidateName, aliasStr));
|
|
64
|
+
if (await coreNameInFacilityName(candidateName, aliasStr)) contains = true;
|
|
65
|
+
}
|
|
54
66
|
}
|
|
67
|
+
if (contains) return score >= 0.95 ? 100 : 95;
|
|
55
68
|
if (score >= 0.95) return 100;
|
|
56
69
|
if (score >= NAME_SIMILARITY_HIGH) return 90;
|
|
57
|
-
if (score >= NAME_SIMILARITY_LOW_WITH_LOCATION_HINT) return 85;
|
|
58
70
|
return 0;
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -88,6 +100,7 @@ export async function findExistingFacility(
|
|
|
88
100
|
lat = null,
|
|
89
101
|
lng = null,
|
|
90
102
|
searchLimit = 30,
|
|
103
|
+
expectedAreas = null,
|
|
91
104
|
} = {}
|
|
92
105
|
) {
|
|
93
106
|
const primary = String(name || "").trim();
|
|
@@ -109,9 +122,8 @@ export async function findExistingFacility(
|
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
124
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
let bestSim = 0;
|
|
125
|
+
/** @type {Array<[number, number, object]>} */
|
|
126
|
+
const candidates = [];
|
|
115
127
|
|
|
116
128
|
for (const q of searchQs) {
|
|
117
129
|
const params = { q, limit: searchLimit };
|
|
@@ -124,19 +136,34 @@ export async function findExistingFacility(
|
|
|
124
136
|
}
|
|
125
137
|
for (const item of res.items || []) {
|
|
126
138
|
if (prefecture && (item.prefecture || "") !== prefecture) continue;
|
|
139
|
+
if (expectedAreas) {
|
|
140
|
+
const area = String(item.area || "").trim();
|
|
141
|
+
if (area && !expectedAreas.has(area)) continue;
|
|
142
|
+
}
|
|
127
143
|
const sc = await scoreWithLocation(names, item, lat, lng);
|
|
144
|
+
if (sc < MATCH_SCORE_MIN) continue;
|
|
128
145
|
let sim = 0;
|
|
129
146
|
for (const cn of names) {
|
|
130
147
|
sim = Math.max(sim, await nameSimilarity(cn, item.name || ""));
|
|
131
148
|
}
|
|
132
|
-
|
|
133
|
-
bestScore = sc;
|
|
134
|
-
bestItem = item;
|
|
135
|
-
bestSim = sim;
|
|
136
|
-
}
|
|
149
|
+
candidates.push([sc, sim, item]);
|
|
137
150
|
}
|
|
138
151
|
}
|
|
139
152
|
|
|
140
|
-
if (
|
|
153
|
+
if (!candidates.length) return null;
|
|
154
|
+
|
|
155
|
+
const rank = ([sc, sim, item]) => {
|
|
156
|
+
const unapprovedAgent = item.is_agent_registered && !item.is_agent_approved;
|
|
157
|
+
return [sc - (unapprovedAgent ? 15 : 0), sim, unapprovedAgent ? 0 : 1];
|
|
158
|
+
};
|
|
159
|
+
candidates.sort((a, b) => {
|
|
160
|
+
const ra = rank(a);
|
|
161
|
+
const rb = rank(b);
|
|
162
|
+
for (let i = 0; i < 3; i += 1) {
|
|
163
|
+
if (ra[i] !== rb[i]) return rb[i] - ra[i];
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
});
|
|
167
|
+
const [bestScore, bestSim, bestItem] = candidates[0];
|
|
141
168
|
return { facility: bestItem, match_score: bestScore, name_similarity: bestSim };
|
|
142
169
|
}
|
package/src/server.mjs
CHANGED
|
@@ -29,19 +29,20 @@ function textResult(text) {
|
|
|
29
29
|
|
|
30
30
|
const server = new McpServer({
|
|
31
31
|
name: "ynosis-agent",
|
|
32
|
-
version: "0.2.
|
|
32
|
+
version: "0.2.3",
|
|
33
33
|
instructions:
|
|
34
34
|
"Ynosis(温泉データベース)への施設・成分表登録・検索用ツール。" +
|
|
35
35
|
"新規施設登録前: find_existing_facility で重複確認 → 既存なら update_facility、" +
|
|
36
36
|
"新規のみ resolve_place_candidates → LLM が同一候補を選び prepare_facility_for_register → register_facility(推測禁止)。" +
|
|
37
37
|
"検索は search_facilities / get_facility / find_existing_facility。" +
|
|
38
38
|
"施設登録は register_facilities_batch または register_facility。" +
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
39
|
+
"成分表一括は CLI register-batch 推奨(3件ごと60秒休憩)。MCP は1件ずつ。" +
|
|
40
|
+
"成分表は手元 Vision なら get_parse_prompt → 手元 LLM → parse_transcript(llm_json 推奨)、" +
|
|
41
|
+
"または analyze_source_image(Ynosis OCR・負荷大)。" +
|
|
42
|
+
"その後 register_source。新規源泉は image_path 必須。ツール呼び出し間隔20秒以上。" +
|
|
43
|
+
"並列禁止。本番512MBのため4件以上を休憩なしで連打しない。" +
|
|
43
44
|
"YNOSIS_AGENT_API_KEY と座標候補用 GOOGLE_MAPS_API_KEY が必要。" +
|
|
44
|
-
"CLI は PyPI
|
|
45
|
+
"CLI は PyPI ynosis-agent 0.2.6+。CSV 一括は CLI / MCP。",
|
|
45
46
|
});
|
|
46
47
|
|
|
47
48
|
const dict = z.record(z.any());
|
|
@@ -101,6 +102,44 @@ server.tool(
|
|
|
101
102
|
}
|
|
102
103
|
);
|
|
103
104
|
|
|
105
|
+
server.tool(
|
|
106
|
+
"request_facility_deletion",
|
|
107
|
+
"施設の削除依頼(管理者承認後に削除)。source_link 必須。",
|
|
108
|
+
{
|
|
109
|
+
facility_id: z.number(),
|
|
110
|
+
reason: z.string(),
|
|
111
|
+
source_link: z.string(),
|
|
112
|
+
},
|
|
113
|
+
async ({ facility_id, reason, source_link }) => {
|
|
114
|
+
try {
|
|
115
|
+
return textResult(
|
|
116
|
+
dump(await client().requestFacilityDeletion(facility_id, { reason, sourceLink: source_link }))
|
|
117
|
+
);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return textResult(errText(e));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
server.tool(
|
|
125
|
+
"request_source_deletion",
|
|
126
|
+
"源泉の削除依頼(管理者承認後に削除)。source_link 必須。",
|
|
127
|
+
{
|
|
128
|
+
source_id: z.number(),
|
|
129
|
+
reason: z.string(),
|
|
130
|
+
source_link: z.string(),
|
|
131
|
+
},
|
|
132
|
+
async ({ source_id, reason, source_link }) => {
|
|
133
|
+
try {
|
|
134
|
+
return textResult(
|
|
135
|
+
dump(await client().requestSourceDeletion(source_id, { reason, sourceLink: source_link }))
|
|
136
|
+
);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return textResult(errText(e));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
104
143
|
server.tool(
|
|
105
144
|
"remediate_duplicate_facility",
|
|
106
145
|
"正規施設へ編集依頼(備考に重複整理メモ)→ 誤登録を withdraw。",
|
|
@@ -159,7 +198,7 @@ server.tool(
|
|
|
159
198
|
|
|
160
199
|
server.tool(
|
|
161
200
|
"register_facility",
|
|
162
|
-
"施設を1件登録する(FacilityCreate 形式)。新規前に find_existing_facility → prepare_facility_for_register
|
|
201
|
+
"施設を1件登録する(FacilityCreate 形式)。新規前に find_existing_facility → prepare_facility_for_register 推奨。既存ヒットで編集依頼になる場合は source_link 必須。",
|
|
163
202
|
{ facility: dict },
|
|
164
203
|
async ({ facility }) => {
|
|
165
204
|
try {
|
|
@@ -245,7 +284,7 @@ server.tool(
|
|
|
245
284
|
|
|
246
285
|
server.tool(
|
|
247
286
|
"update_facility",
|
|
248
|
-
"既存施設を部分更新する(Agent キー時は編集依頼)。",
|
|
287
|
+
"既存施設を部分更新する(Agent キー時は編集依頼)。updates に source_link(根拠 URL・http/https)必須。",
|
|
249
288
|
{ facility_id: z.number(), updates: dict },
|
|
250
289
|
async ({ facility_id, updates }) => {
|
|
251
290
|
try {
|
|
@@ -258,7 +297,7 @@ server.tool(
|
|
|
258
297
|
|
|
259
298
|
server.tool(
|
|
260
299
|
"register_source",
|
|
261
|
-
"成分表付きで施設・源泉を登録する。新規源泉は image_path
|
|
300
|
+
"成分表付きで施設・源泉を登録する。新規源泉は image_path 必須。3件以上は60秒休憩。HEICは事前JPEG化推奨。",
|
|
262
301
|
{ payload: dict, image_path: z.string().optional() },
|
|
263
302
|
async ({ payload, image_path }) => {
|
|
264
303
|
try {
|
|
@@ -290,13 +329,18 @@ server.tool(
|
|
|
290
329
|
}
|
|
291
330
|
);
|
|
292
331
|
|
|
293
|
-
server.tool(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
332
|
+
server.tool(
|
|
333
|
+
"analyze_source_image",
|
|
334
|
+
"成分表画像を Ynosis OCR で解析する(DB 未書込)。並列禁止。連続呼び出しは20秒以上空ける。一括は CLI register-batch 推奨。",
|
|
335
|
+
{ image_path: z.string() },
|
|
336
|
+
async ({ image_path }) => {
|
|
337
|
+
try {
|
|
338
|
+
return textResult(dump(await client().analyzeImage(image_path)));
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return textResult(errText(e));
|
|
341
|
+
}
|
|
298
342
|
}
|
|
299
|
-
|
|
343
|
+
);
|
|
300
344
|
|
|
301
345
|
server.tool("prepare_parsed", "register_source 用に dissolved_material を mg 換算する。", { parsed: dict }, async ({ parsed }) => {
|
|
302
346
|
try {
|