ynosis-agent-mcp 0.1.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 ADDED
@@ -0,0 +1,35 @@
1
+ # ynosis-agent-mcp
2
+
3
+ Ynosis Agent API 用の **MCP(stdio)**。Cursor など Node がある環境向け。
4
+
5
+ ソース clone は不要です。CLI は別パッケージ [ynosis-agent](https://pypi.org/project/ynosis-agent/)(PyPI)。
6
+
7
+ ## 前提
8
+
9
+ - Node.js 18 以降(`npx` が付属)
10
+ - プロフィール(… → Agent API)で発行した `yna_…` キー
11
+
12
+ `uv` / `uvx` は不要です。
13
+
14
+ ## Cursor(mcp.json)
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "ynosis-agent": {
20
+ "command": "npx",
21
+ "args": ["-y", "ynosis-agent-mcp"],
22
+ "env": {
23
+ "YNOSIS_AGENT_API_KEY": "yna_REPLACE_ME",
24
+ "YNOSIS_AGENT_API_BASE": "https://ynosis.jp"
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ 保存後 **Cursor を再起動**し、Customize → MCPs で有効か、チャットのツールに `search_facilities` が出るかを確認してください。`npx` が Cursor から見えないときは `command` を `which npx` の絶対パスにします。
32
+
33
+ 接続先の既定は `https://ynosis.jp`(リダイレクトを追います)。
34
+
35
+ 手順の詳細: <https://ynosis.jp/notes/usage-037>
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "ynosis-agent-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP stdio server for the Ynosis Agent API (facilities and spring analyses)",
5
+ "type": "module",
6
+ "bin": {
7
+ "ynosis-agent-mcp": "./src/server.mjs"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "license": "UNLICENSED",
17
+ "homepage": "https://ynosis.jp/notes/usage-037",
18
+ "keywords": [
19
+ "ynosis",
20
+ "onsen",
21
+ "mcp",
22
+ "agent"
23
+ ],
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.12.1",
26
+ "zod": "^3.24.2"
27
+ }
28
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,238 @@
1
+ const DEFAULT_BASE = "https://ynosis.jp";
2
+
3
+ function resolveKey() {
4
+ return process.env.YNOSIS_AGENT_API_KEY || process.env.AGENT_API_KEY || "";
5
+ }
6
+
7
+ function resolveBase() {
8
+ const raw =
9
+ process.env.YNOSIS_AGENT_API_BASE ||
10
+ process.env.AGENT_API_BASE ||
11
+ process.env.API_BASE ||
12
+ DEFAULT_BASE;
13
+ return raw.replace(/\/$/, "");
14
+ }
15
+
16
+ function imageContentType(filePath) {
17
+ const suffix = filePath.toLowerCase().split(".").pop();
18
+ if (suffix === "png") return "image/png";
19
+ if (["webp", "bmp", "tif", "tiff"].includes(suffix)) return `image/${suffix}`;
20
+ if (suffix === "heic" || suffix === "heif") return "image/heic";
21
+ if (suffix === "pdf") return "application/pdf";
22
+ return "image/jpeg";
23
+ }
24
+
25
+ export class AgentApiError extends Error {
26
+ constructor(statusCode, detail) {
27
+ super(`HTTP ${statusCode}: ${detail}`);
28
+ this.statusCode = statusCode;
29
+ this.detail = detail;
30
+ }
31
+ }
32
+
33
+ export class AgentApiClient {
34
+ constructor({ baseUrl, apiKey, timeoutMs = 120_000 } = {}) {
35
+ this.baseUrl = (baseUrl || resolveBase()).replace(/\/$/, "");
36
+ this.apiKey = apiKey ?? resolveKey();
37
+ this.timeoutMs = timeoutMs;
38
+ }
39
+
40
+ requireKey() {
41
+ if (!this.apiKey) {
42
+ throw new Error(
43
+ "API キーが未設定です。YNOSIS_AGENT_API_KEY または AGENT_API_KEY に yna_… を設定してください"
44
+ );
45
+ }
46
+ }
47
+
48
+ headers(jsonBody = true) {
49
+ this.requireKey();
50
+ const headers = { "X-API-Key": this.apiKey };
51
+ if (jsonBody) headers["Content-Type"] = "application/json";
52
+ return headers;
53
+ }
54
+
55
+ async request(method, path, { json, form } = {}) {
56
+ const url = `${this.baseUrl}${path}`;
57
+ const ctrl = new AbortController();
58
+ const timer = setTimeout(() => ctrl.abort(), form ? Math.max(this.timeoutMs, 180_000) : this.timeoutMs);
59
+ try {
60
+ const init = { method, headers: form ? { "X-API-Key": this.apiKey } : this.headers(json != null), signal: ctrl.signal };
61
+ if (form) init.body = form;
62
+ else if (json != null) init.body = JSON.stringify(json);
63
+ const res = await fetch(url, init);
64
+ const text = await res.text();
65
+ if (!res.ok) {
66
+ let detail = text;
67
+ try {
68
+ const body = JSON.parse(text);
69
+ if (body && typeof body === "object" && "detail" in body) detail = String(body.detail);
70
+ } catch {
71
+ /* keep text */
72
+ }
73
+ throw new AgentApiError(res.status, detail);
74
+ }
75
+ if (!text) return { status: res.status, json: null, headers: res.headers };
76
+ return { status: res.status, json: JSON.parse(text), headers: res.headers };
77
+ } finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }
81
+
82
+ async registerFacility(payload) {
83
+ const res = await this.request("POST", "/api/agent/facilities", { json: payload });
84
+ return { status_code: res.status, facility: res.json };
85
+ }
86
+
87
+ async searchFacilities(params = {}) {
88
+ const clean = Object.fromEntries(
89
+ Object.entries(params).filter(([, v]) => v != null && v !== "")
90
+ );
91
+ const qs = new URLSearchParams(clean).toString();
92
+ const path = `/api/agent/facilities/search${qs ? `?${qs}` : ""}`;
93
+ const res = await this.request("GET", path);
94
+ const total = res.headers.get("X-Total-Count");
95
+ const items = res.json;
96
+ return { items, total_count: total != null ? Number(total) : (items?.length ?? 0) };
97
+ }
98
+
99
+ async getFacility(facilityId) {
100
+ const res = await this.request("GET", `/api/agent/facilities/${facilityId}`);
101
+ return res.json;
102
+ }
103
+
104
+ async registerFacilitiesBatch(facilities, { continueOnError = true } = {}) {
105
+ const results = [];
106
+ let created = 0;
107
+ let updated = 0;
108
+ let failed = 0;
109
+ for (let i = 0; i < facilities.length; i += 1) {
110
+ const payload = facilities[i];
111
+ try {
112
+ const one = await this.registerFacility(payload);
113
+ const statusCode = Number(one.status_code);
114
+ if (statusCode === 201) {
115
+ created += 1;
116
+ } else {
117
+ updated += 1;
118
+ }
119
+ results.push({
120
+ index: i,
121
+ ok: true,
122
+ action: statusCode === 201 ? "created" : "updated",
123
+ status_code: statusCode,
124
+ facility_id: one.facility?.id,
125
+ name: one.facility?.name,
126
+ });
127
+ } catch (e) {
128
+ failed += 1;
129
+ results.push({
130
+ index: i,
131
+ ok: false,
132
+ error: e instanceof AgentApiError ? e.detail : String(e),
133
+ status_code: e instanceof AgentApiError ? e.statusCode : undefined,
134
+ name: payload?.name,
135
+ });
136
+ if (!continueOnError) break;
137
+ }
138
+ }
139
+ return { created, updated, failed, results };
140
+ }
141
+
142
+ async updateFacility(facilityId, payload) {
143
+ const res = await this.request("PUT", `/api/facilities/${facilityId}`, { json: payload });
144
+ return res.json;
145
+ }
146
+
147
+ async registerSource(payload, imagePath) {
148
+ const { readFile } = await import("node:fs/promises");
149
+ const { basename } = await import("node:path");
150
+ if (!imagePath) {
151
+ const res = await this.request("POST", "/api/agent/register", { json: payload });
152
+ return res.json;
153
+ }
154
+ const buf = await readFile(imagePath);
155
+ const form = new FormData();
156
+ form.append("payload", JSON.stringify(payload));
157
+ form.append("file", new Blob([buf], { type: imageContentType(imagePath) }), basename(imagePath));
158
+ const res = await this.request("POST", "/api/agent/register", { form });
159
+ return res.json;
160
+ }
161
+
162
+ async analyzeImage(imagePath) {
163
+ const { readFile } = await import("node:fs/promises");
164
+ const { basename } = await import("node:path");
165
+ const buf = await readFile(imagePath);
166
+ const form = new FormData();
167
+ form.append("file", new Blob([buf], { type: imageContentType(imagePath) }), basename(imagePath));
168
+ const res = await this.request("POST", "/api/agent/analyze", { form });
169
+ return res.json;
170
+ }
171
+
172
+ async getParsePrompt(rawText) {
173
+ const res = await this.request("POST", "/api/agent/parse-prompt", { json: { raw_text: rawText } });
174
+ return res.json;
175
+ }
176
+
177
+ async parseTranscript(rawText, llmJson) {
178
+ const body = { raw_text: rawText };
179
+ if (llmJson != null) body.llm_json = llmJson;
180
+ const res = await this.request("POST", "/api/agent/parse", { json: body });
181
+ return res.json;
182
+ }
183
+
184
+ async findSimilarSources(payload) {
185
+ const res = await this.request("POST", "/api/sources/similar", { json: payload });
186
+ return res.json;
187
+ }
188
+
189
+ async uploadSourceImage(sourceId, imagePath) {
190
+ const { readFile } = await import("node:fs/promises");
191
+ const { basename } = await import("node:path");
192
+ const buf = await readFile(imagePath);
193
+ const form = new FormData();
194
+ form.append("file", new Blob([buf], { type: imageContentType(imagePath) }), basename(imagePath));
195
+ const res = await this.request("POST", `/api/sources/${sourceId}/image`, { form });
196
+ return res.json;
197
+ }
198
+ }
199
+
200
+ export function prepareParsedForSave(parsed) {
201
+ const out = { ...parsed };
202
+ const unit = out.dissolved_material_unit || "mg";
203
+ const dm = out.dissolved_material;
204
+ if (typeof dm === "number") {
205
+ out.dissolved_material = unit === "g" ? Math.round(dm * 1000 * 10) / 10 : Math.round(dm * 10) / 10;
206
+ }
207
+ delete out.dissolved_material_unit;
208
+ for (const key of [
209
+ "is_composition_table",
210
+ "ion_total_warnings",
211
+ "bisulfate_keyword_detected",
212
+ "metasilicate_ion_keyword_detected",
213
+ "metaborate_ion_keyword_detected",
214
+ "trace_quantified_detected",
215
+ "name",
216
+ "address",
217
+ "prefecture",
218
+ ]) {
219
+ delete out[key];
220
+ }
221
+ return out;
222
+ }
223
+
224
+ export const FACILITY_TAG_OPTIONS = [
225
+ "足元湧出",
226
+ "掛け流し",
227
+ "秘湯",
228
+ "野湯",
229
+ "露天あり",
230
+ "貸切湯あり",
231
+ "日帰り入浴可",
232
+ "共同浴場",
233
+ "造成泉",
234
+ "モール泉",
235
+ "混浴",
236
+ "ぬる湯",
237
+ "冷鉱泉",
238
+ ];
package/src/server.mjs ADDED
@@ -0,0 +1,216 @@
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 {
6
+ AgentApiClient,
7
+ AgentApiError,
8
+ FACILITY_TAG_OPTIONS,
9
+ prepareParsedForSave,
10
+ } from "./client.mjs";
11
+
12
+ function client() {
13
+ return new AgentApiClient();
14
+ }
15
+
16
+ function dump(data) {
17
+ return JSON.stringify(data, null, 2);
18
+ }
19
+
20
+ function errText(e) {
21
+ return dump({ error: e instanceof Error ? e.message : String(e) });
22
+ }
23
+
24
+ function textResult(text) {
25
+ return { content: [{ type: "text", text }] };
26
+ }
27
+
28
+ const server = new McpServer({
29
+ name: "ynosis-agent",
30
+ version: "0.1.0",
31
+ instructions:
32
+ "Ynosis(温泉データベース)への施設・成分表登録・検索用ツール。" +
33
+ "検索は search_facilities / get_facility。" +
34
+ "施設登録は register_facilities_batch または register_facility。" +
35
+ "成分表は手元 Vision なら get_parse_prompt → 手元 LLM → parse_transcript、" +
36
+ "または analyze_source_image(Ynosis OCR)。" +
37
+ "その後 register_source。新規源泉は image_path 必須。" +
38
+ "OCR / サーバー抽出の parse は並列禁止。" +
39
+ "YNOSIS_AGENT_API_KEY(または AGENT_API_KEY)が必要。" +
40
+ "CLI は PyPI の ynosis-agent。CSV ファイル一括は CLI / Python MCP。",
41
+ });
42
+
43
+ const dict = z.record(z.any());
44
+
45
+ server.tool("register_facility", "施設を1件登録する(FacilityCreate 形式)。", { facility: dict }, async ({ facility }) => {
46
+ try {
47
+ return textResult(dump(await client().registerFacility(facility)));
48
+ } catch (e) {
49
+ return textResult(errText(e));
50
+ }
51
+ });
52
+
53
+ server.tool(
54
+ "search_facilities",
55
+ "施設を検索する(未承認の Agent 登録も含む)。",
56
+ {
57
+ q: z.string().optional(),
58
+ prefecture: z.string().optional(),
59
+ area: z.string().optional(),
60
+ onsen_name: z.string().optional(),
61
+ spring_type: z.string().optional(),
62
+ tags: z.string().optional(),
63
+ has_analysis: z.string().optional(),
64
+ status: z.string().optional(),
65
+ limit: z.number().optional(),
66
+ skip: z.number().optional(),
67
+ sort: z.string().optional(),
68
+ },
69
+ async (args) => {
70
+ try {
71
+ return textResult(
72
+ dump(
73
+ await client().searchFacilities({
74
+ q: args.q,
75
+ prefecture: args.prefecture,
76
+ area: args.area,
77
+ onsen_name: args.onsen_name,
78
+ spring_type: args.spring_type,
79
+ tags: args.tags,
80
+ has_analysis: args.has_analysis,
81
+ status: args.status,
82
+ limit: args.limit ?? 20,
83
+ skip: args.skip ?? 0,
84
+ sort: args.sort ?? "default",
85
+ })
86
+ )
87
+ );
88
+ } catch (e) {
89
+ return textResult(errText(e));
90
+ }
91
+ }
92
+ );
93
+
94
+ server.tool("get_facility", "施設詳細を取得する(未承認可)。", { facility_id: z.number() }, async ({ facility_id }) => {
95
+ try {
96
+ return textResult(dump(await client().getFacility(facility_id)));
97
+ } catch (e) {
98
+ return textResult(errText(e));
99
+ }
100
+ });
101
+
102
+ server.tool(
103
+ "register_facilities_batch",
104
+ "施設を一括登録する(JSON 配列。CSV ファイルは ynosis-agent CLI)。",
105
+ {
106
+ facilities: z.array(dict).optional(),
107
+ continue_on_error: z.boolean().optional(),
108
+ dry_run: z.boolean().optional(),
109
+ },
110
+ async ({ facilities, continue_on_error, dry_run }) => {
111
+ try {
112
+ if (!facilities?.length) {
113
+ return textResult(dump({ error: "facilities 配列を指定してください。CSV は ynosis-agent CLI を使ってください" }));
114
+ }
115
+ if (dry_run) {
116
+ return textResult(dump({ dry_run: true, count: facilities.length, facilities }));
117
+ }
118
+ return textResult(
119
+ dump(await client().registerFacilitiesBatch(facilities, { continueOnError: continue_on_error ?? true }))
120
+ );
121
+ } catch (e) {
122
+ return textResult(errText(e));
123
+ }
124
+ }
125
+ );
126
+
127
+ server.tool(
128
+ "update_facility",
129
+ "既存施設を部分更新する(Agent キー時は編集依頼)。",
130
+ { facility_id: z.number(), updates: dict },
131
+ async ({ facility_id, updates }) => {
132
+ try {
133
+ return textResult(dump(await client().updateFacility(facility_id, updates)));
134
+ } catch (e) {
135
+ return textResult(errText(e));
136
+ }
137
+ }
138
+ );
139
+
140
+ server.tool(
141
+ "register_source",
142
+ "成分表付きで施設・源泉を登録する。新規源泉は image_path(成分表画像)必須。",
143
+ { payload: dict, image_path: z.string().optional() },
144
+ async ({ payload, image_path }) => {
145
+ try {
146
+ return textResult(dump(await client().registerSource(payload, image_path)));
147
+ } catch (e) {
148
+ return textResult(e instanceof AgentApiError ? dump({ error: String(e) }) : errText(e));
149
+ }
150
+ }
151
+ );
152
+
153
+ server.tool("get_parse_prompt", "書き起こし全文から本番抽出プロンプト(system/user)を返す。LLM は呼ばない。", { raw_text: z.string() }, async ({ raw_text }) => {
154
+ try {
155
+ return textResult(dump(await client().getParsePrompt(raw_text)));
156
+ } catch (e) {
157
+ return textResult(errText(e));
158
+ }
159
+ });
160
+
161
+ server.tool(
162
+ "parse_transcript",
163
+ "書き起こし全文を parsed にする。llm_json があれば後処理のみ。DI は使わない。",
164
+ { raw_text: z.string(), llm_json: dict.optional() },
165
+ async ({ raw_text, llm_json }) => {
166
+ try {
167
+ return textResult(dump(await client().parseTranscript(raw_text, llm_json)));
168
+ } catch (e) {
169
+ return textResult(errText(e));
170
+ }
171
+ }
172
+ );
173
+
174
+ server.tool("analyze_source_image", "成分表画像を Ynosis OCR で解析する(DB 未書込)。並列禁止。", { image_path: z.string() }, async ({ image_path }) => {
175
+ try {
176
+ return textResult(dump(await client().analyzeImage(image_path)));
177
+ } catch (e) {
178
+ return textResult(errText(e));
179
+ }
180
+ });
181
+
182
+ server.tool("prepare_parsed", "register_source 用に dissolved_material を mg 換算する。", { parsed: dict }, async ({ parsed }) => {
183
+ try {
184
+ return textResult(dump(prepareParsedForSave(parsed)));
185
+ } catch (e) {
186
+ return textResult(errText(e));
187
+ }
188
+ });
189
+
190
+ server.tool("find_similar_sources", "登録前に成分が近い既存源泉を検索する。", { source: dict }, async ({ source }) => {
191
+ try {
192
+ return textResult(dump(await client().findSimilarSources(source)));
193
+ } catch (e) {
194
+ return textResult(errText(e));
195
+ }
196
+ });
197
+
198
+ server.tool("get_tag_options", "登録可能な施設タグ一覧を返す。", {}, async () =>
199
+ textResult(dump({ tags: FACILITY_TAG_OPTIONS }))
200
+ );
201
+
202
+ server.tool(
203
+ "upload_source_image",
204
+ "成分表の証憑画像をアップロードする。",
205
+ { source_id: z.number(), image_path: z.string() },
206
+ async ({ source_id, image_path }) => {
207
+ try {
208
+ return textResult(dump(await client().uploadSourceImage(source_id, image_path)));
209
+ } catch (e) {
210
+ return textResult(errText(e));
211
+ }
212
+ }
213
+ );
214
+
215
+ const transport = new StdioServerTransport();
216
+ await server.connect(transport);