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 CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "ynosis-agent-mcp",
3
- "version": "0.2.1",
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": "./src/server.mjs"
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 init = { method, headers: form ? { "X-API-Key": this.apiKey } : this.headers(json != null), signal: ctrl.signal };
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
- form.append("file", new Blob([buf], { type: imageContentType(imagePath) }), basename(imagePath));
184
- const res = await this.request("POST", "/api/agent/register", { form });
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
- form.append("file", new Blob([buf], { type: imageContentType(imagePath) }), basename(imagePath));
194
- const res = await this.request("POST", "/api/agent/analyze", { form });
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
- form.append("file", new Blob([buf], { type: imageContentType(imagePath) }), basename(imagePath));
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
  "モール泉",
@@ -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) score = Math.max(score, await nameSimilarity(candidateName, String(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
- let bestScore = 0;
113
- let bestItem = null;
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
- if (sc > bestScore) {
133
- bestScore = sc;
134
- bestItem = item;
135
- bestSim = sim;
136
- }
149
+ candidates.push([sc, sim, item]);
137
150
  }
138
151
  }
139
152
 
140
- if (bestScore < MATCH_SCORE_MIN || !bestItem) return null;
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.1",
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
- "成分表は手元 Vision なら get_parse_prompt → 手元 LLM → parse_transcript、" +
40
- "または analyze_source_image(Ynosis OCR)。" +
41
- "その後 register_source。新規源泉は image_path 必須。" +
42
- "OCR / サーバー抽出の parse は並列禁止。" +
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 の ynosis-agent。CSV ファイル一括は CLI / MCP。",
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("analyze_source_image", "成分表画像を Ynosis OCR で解析する(DB 未書込)。並列禁止。", { image_path: z.string() }, async ({ image_path }) => {
294
- try {
295
- return textResult(dump(await client().analyzeImage(image_path)));
296
- } catch (e) {
297
- return textResult(errText(e));
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 {