@yuiseki/gyazocli 0.0.2 → 0.2.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/dist/mcp.js ADDED
@@ -0,0 +1,328 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createMcpServer = createMcpServer;
4
+ exports.runMcpServer = runMcpServer;
5
+ /**
6
+ * A Model Context Protocol server over stdio, started with `gyazo --mcp-server`.
7
+ *
8
+ * The tool names and their argument shapes follow nota/gyazo-mcp-server, so a
9
+ * client already configured against that server keeps working. What differs is
10
+ * where it runs: this one is the CLI itself, so it reads the same token and the
11
+ * same cache as every other `gyazo` command.
12
+ *
13
+ * stdout belongs to the protocol. Everything this file has to say to a human
14
+ * goes to stderr.
15
+ */
16
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
17
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
18
+ const zod_1 = require("zod");
19
+ const api_1 = require("./api");
20
+ const credentials_1 = require("./credentials");
21
+ const ids_1 = require("./ids");
22
+ const dates_1 = require("./dates");
23
+ const memory_1 = require("./services/memory");
24
+ const analytics_1 = require("./services/analytics");
25
+ const collections_1 = require("./services/collections");
26
+ const SEARCH_QUERY_DESCRIPTION = [
27
+ 'Search keyword (max length: 200 characters).',
28
+ 'Examples: cat | title:cat | app:"Google Chrome" | url:google.com |',
29
+ 'cat since:2024-01-01 until:2024-12-31.',
30
+ 'If nothing suitable comes back, rephrase the query to match what the user',
31
+ 'meant and search again rather than giving up on the first attempt.',
32
+ ].join(' ');
33
+ function serverVersion() {
34
+ // The published tarball always contains package.json, and dist/ sits one
35
+ // level below it, so this holds both in the repository and once installed.
36
+ return require('../package.json').version;
37
+ }
38
+ /**
39
+ * The fields worth handing to a model: enough to cite a capture and to open it,
40
+ * without the parts of the API response it cannot act on. Absent fields stay
41
+ * absent rather than becoming null.
42
+ *
43
+ * Metadata only, on purpose. Handing image bytes to a model as base64 was the
44
+ * ambitious part of the upstream design and it did not hold up in use, so a
45
+ * capture is described here and its URLs are given for anything that wants the
46
+ * pixels.
47
+ */
48
+ function toMetadata(image) {
49
+ return {
50
+ image_id: image.image_id,
51
+ permalink_url: image.permalink_url,
52
+ url: image.url,
53
+ ...(image.thumb_url !== undefined ? { thumb_url: image.thumb_url } : {}),
54
+ ...(image.type !== undefined ? { mimeType: `image/${image.type}` } : {}),
55
+ created_at: image.created_at,
56
+ ...(image.alt_text !== undefined ? { alt_text: image.alt_text } : {}),
57
+ ...(image.ocr !== undefined ? { ocr: image.ocr } : {}),
58
+ ...(image.metadata !== undefined ? { metadata: image.metadata } : {}),
59
+ ...(image.exif_normalized !== undefined ? { exif_normalized: image.exif_normalized } : {}),
60
+ };
61
+ }
62
+ const NO_IMAGES = { content: [{ type: 'text', text: 'No images found' }] };
63
+ function asJsonResult(payload) {
64
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
65
+ }
66
+ function asMetadataListResult(images) {
67
+ if (!images || images.length === 0) {
68
+ return NO_IMAGES;
69
+ }
70
+ return asJsonResult(images.map(toMetadata));
71
+ }
72
+ /**
73
+ * A date argument, refused by throwing. The CLI reports and exits here, which
74
+ * a server must not do: it would take the whole session down over one bad
75
+ * argument.
76
+ */
77
+ function requireDate(value, today) {
78
+ if (!value && !today)
79
+ return undefined;
80
+ if (value && today) {
81
+ throw new Error('today and date cannot be used together.');
82
+ }
83
+ const parsed = (0, dates_1.tryParseDateOption)(today ? undefined : value);
84
+ if (!parsed.ok) {
85
+ throw new Error(dates_1.DATE_OPTION_PROBLEMS[parsed.problem].replace('--date', 'date'));
86
+ }
87
+ return parsed.value;
88
+ }
89
+ function asMetadataResult(image) {
90
+ return {
91
+ content: [{ type: 'text', text: JSON.stringify(toMetadata(image), null, 2) }],
92
+ };
93
+ }
94
+ function createMcpServer() {
95
+ const server = new mcp_js_1.McpServer({ name: 'gyazocli', version: serverVersion() });
96
+ server.registerTool('gyazo_search', {
97
+ title: 'Search Gyazo captures',
98
+ description: 'Full-text search for captures uploaded by the user on Gyazo',
99
+ inputSchema: {
100
+ query: zod_1.z.string().min(1).max(200).describe(SEARCH_QUERY_DESCRIPTION),
101
+ page: zod_1.z.number().int().min(1).default(1).describe('Page number for pagination'),
102
+ per: zod_1.z
103
+ .number()
104
+ .int()
105
+ .min(1)
106
+ .max(100)
107
+ .default(20)
108
+ .describe('Number of results per page (max: 100)'),
109
+ },
110
+ annotations: { readOnlyHint: true, openWorldHint: true },
111
+ }, async ({ query, page, per }) => {
112
+ const images = await (0, api_1.searchImages)(query, page, per);
113
+ if (!images || images.length === 0) {
114
+ return NO_IMAGES;
115
+ }
116
+ return {
117
+ content: [
118
+ {
119
+ type: 'text',
120
+ text: JSON.stringify(images.map(toMetadata), null, 2),
121
+ },
122
+ ],
123
+ };
124
+ });
125
+ server.registerTool('gyazo_image', {
126
+ title: 'Describe one Gyazo capture',
127
+ description: 'Fetch the metadata of one capture on Gyazo: its URLs, timestamp, OCR text, ' +
128
+ 'title, source application and page, and location when the capture carries one. ' +
129
+ 'Returns no image bytes; use the URLs in the result to show the capture itself.',
130
+ inputSchema: {
131
+ id_or_url: zod_1.z
132
+ .string()
133
+ .min(1)
134
+ .describe('ID or URL of the capture on Gyazo. A bare 32-character ID, a ' +
135
+ 'https://gyazo.com/<id> permalink, or a direct image URL all work.'),
136
+ },
137
+ annotations: { readOnlyHint: true, openWorldHint: true },
138
+ }, async ({ id_or_url }) => {
139
+ const imageId = (0, ids_1.normalizeImageId)(id_or_url);
140
+ if (!imageId) {
141
+ throw new Error(`'${id_or_url}' is not a Gyazo image ID or URL. Pass a 32-character ID or a ` +
142
+ 'https://gyazo.com/<id> URL. A https://gyazo.com/collections/<id> URL is a ' +
143
+ 'collection, which this server does not read.');
144
+ }
145
+ const image = await (0, api_1.getImageDetail)(imageId);
146
+ if (!image || !image.image_id) {
147
+ return NO_IMAGES;
148
+ }
149
+ return asMetadataResult(image);
150
+ });
151
+ server.registerTool('gyazo_latest_image', {
152
+ title: 'Describe the most recent Gyazo capture',
153
+ description: 'Fetch the metadata of the capture the user uploaded most recently. Useful when ' +
154
+ 'they refer to what they just captured. Returns no image bytes.',
155
+ // No arguments. The upstream server declared a `name` property here, so
156
+ // a client configured against it may still send one; unknown properties
157
+ // are dropped rather than refused.
158
+ inputSchema: {},
159
+ annotations: { readOnlyHint: true, openWorldHint: true },
160
+ }, async () => {
161
+ const images = await (0, api_1.listImages)(1, 1);
162
+ const latest = images && images[0];
163
+ if (!latest) {
164
+ return NO_IMAGES;
165
+ }
166
+ return asMetadataResult(latest);
167
+ });
168
+ server.registerTool('gyazo_list', {
169
+ title: 'List Gyazo captures',
170
+ description: 'List the captures the user uploaded, newest first, taking the same options as ' +
171
+ '`gyazo list`. With no arguments it returns the most recent page. Returns metadata, ' +
172
+ 'not image bytes.',
173
+ inputSchema: {
174
+ page: zod_1.z.number().int().min(1).default(1).describe('Page number for pagination'),
175
+ limit: zod_1.z
176
+ .number()
177
+ .int()
178
+ .min(1)
179
+ .max(100)
180
+ .default(20)
181
+ .describe('Number of captures per page (max: 100)'),
182
+ date: zod_1.z
183
+ .string()
184
+ .optional()
185
+ .describe('Restrict to a date or range: yyyy, yyyy-mm or yyyy-mm-dd, read as local time'),
186
+ today: zod_1.z.boolean().default(false).describe('Restrict to today. Not with date'),
187
+ hour: zod_1.z
188
+ .string()
189
+ .optional()
190
+ .describe('Read one hour out of the local cache, as yyyy-mm-dd-hh. Not with date, today, ' +
191
+ 'photos or uploaded'),
192
+ photos: zod_1.z
193
+ .boolean()
194
+ .default(false)
195
+ .describe('Only captures that carry a location. Not with uploaded'),
196
+ uploaded: zod_1.z
197
+ .boolean()
198
+ .default(false)
199
+ .describe('Only captures uploaded by this CLI. Not with photos'),
200
+ max_pages: zod_1.z
201
+ .number()
202
+ .int()
203
+ .min(1)
204
+ .default(100)
205
+ .describe('How many API pages to scan when a date range is given'),
206
+ use_cache: zod_1.z
207
+ .boolean()
208
+ .default(true)
209
+ .describe('Answer from the local cache where possible. Set false to force a fetch'),
210
+ },
211
+ annotations: { readOnlyHint: true, openWorldHint: true },
212
+ }, async (args) => {
213
+ const { page, limit, today, photos, uploaded, max_pages: maxPages, use_cache: useCache } = args;
214
+ if (photos && uploaded) {
215
+ throw new Error('photos and uploaded cannot be used together.');
216
+ }
217
+ if (args.hour && (photos || uploaded)) {
218
+ throw new Error('hour cannot be used with photos or uploaded.');
219
+ }
220
+ if (args.hour && (args.date || today)) {
221
+ throw new Error('hour cannot be used with date or today.');
222
+ }
223
+ const date = requireDate(args.date, today);
224
+ const hour = args.hour ? (0, dates_1.parseHourOption)(args.hour) : null;
225
+ if (args.hour && !hour) {
226
+ throw new Error('hour format must be yyyy-mm-dd-hh.');
227
+ }
228
+ const alias = photos ? 'photos' : uploaded ? 'uploaded' : undefined;
229
+ const { images } = await (0, memory_1.listCaptures)({
230
+ page,
231
+ limit,
232
+ maxPages,
233
+ useCache,
234
+ date,
235
+ hour: hour || undefined,
236
+ alias,
237
+ });
238
+ return asMetadataListResult(images);
239
+ });
240
+ server.registerTool('gyazo_summary', {
241
+ title: 'Summarise a stretch of Gyazo captures',
242
+ description: 'What a day or a range adds up to: how many captures each day, and which ' +
243
+ 'applications, sites, tags and places recur, taking the same options as ' +
244
+ '`gyazo summary`. With no arguments it covers the week up to yesterday.',
245
+ inputSchema: {
246
+ date: zod_1.z
247
+ .string()
248
+ .optional()
249
+ .describe('A date or range: yyyy, yyyy-mm or yyyy-mm-dd, read as local time'),
250
+ today: zod_1.z.boolean().default(false).describe('Cover today only. Not with date'),
251
+ limit: zod_1.z
252
+ .number()
253
+ .int()
254
+ .min(1)
255
+ .max(10)
256
+ .default(10)
257
+ .describe('How many ranking rows per day (max: 10)'),
258
+ max_pages: zod_1.z
259
+ .number()
260
+ .int()
261
+ .min(1)
262
+ .default(10)
263
+ .describe('How many API pages to scan when the cache has nothing to say'),
264
+ use_cache: zod_1.z
265
+ .boolean()
266
+ .default(true)
267
+ .describe('Answer from the local cache where possible. Set false to force a fetch'),
268
+ },
269
+ annotations: { readOnlyHint: true, openWorldHint: true },
270
+ }, async (args) => {
271
+ const { today, limit, max_pages: maxPages, use_cache: useCache } = args;
272
+ const targetDate = requireDate(args.date, today) || (0, dates_1.buildRecentWeekRangeUntilYesterday)();
273
+ const dailySummaries = await (0, analytics_1.buildSummary)({ targetDate, maxPages, useCache });
274
+ return asJsonResult((0, analytics_1.toSummaryJson)(targetDate.dateKey, dailySummaries, limit));
275
+ });
276
+ server.registerTool('gyazo_collection', {
277
+ title: 'Read a Gyazo collection',
278
+ description: 'The metadata of a collection and of the captures in it. A collection ID looks ' +
279
+ 'exactly like a capture ID, so a bare ID is read as a collection here; pass a ' +
280
+ 'https://gyazo.com/collections/<id> URL when in doubt.',
281
+ inputSchema: {
282
+ id_or_url: zod_1.z
283
+ .string()
284
+ .min(1)
285
+ .describe('Collection ID, or a https://gyazo.com/collections/<id> URL'),
286
+ sort: zod_1.z
287
+ .enum(collections_1.COLLECTION_SORTS)
288
+ .default('added')
289
+ .describe('Image order: added (as the collection holds them), created (upload time) or ' +
290
+ 'captured (when the photo was taken)'),
291
+ },
292
+ annotations: { readOnlyHint: true, openWorldHint: true },
293
+ }, async ({ id_or_url, sort }) => {
294
+ const collectionId = (0, ids_1.normalizeCollectionId)(id_or_url);
295
+ if (!collectionId) {
296
+ throw new Error(`'${id_or_url}' is not a Gyazo collection ID or URL. Pass a 32-character ID or a ` +
297
+ 'https://gyazo.com/collections/<id> URL. A https://gyazo.com/<id> URL is a single ' +
298
+ 'capture, which gyazo_image reads.');
299
+ }
300
+ const { collection, images } = await (0, collections_1.readCollection)(collectionId, {
301
+ sort: sort,
302
+ });
303
+ return asJsonResult({
304
+ id: collection?.id ?? collectionId,
305
+ ...(collection?.name !== undefined ? { name: collection.name } : {}),
306
+ ...(collection?.description ? { description: collection.description } : {}),
307
+ ...(collection?.url !== undefined ? { url: collection.url } : {}),
308
+ ...(collection?.total_image_count !== undefined
309
+ ? { total_image_count: collection.total_image_count }
310
+ : {}),
311
+ ...(collection?.user !== undefined ? { user: collection.user } : {}),
312
+ images: images.map(toMetadata),
313
+ });
314
+ });
315
+ return server;
316
+ }
317
+ async function runMcpServer() {
318
+ if (!(0, credentials_1.resolveAccessToken)()) {
319
+ console.error('Error: Gyazo Access Token is not set.');
320
+ console.error('The MCP server needs one before it can start. Set it with:');
321
+ console.error(' gyazo config set token <your_access_token>');
322
+ console.error('or pass GYAZO_ACCESS_TOKEN in the environment of the MCP client.');
323
+ process.exit(1);
324
+ }
325
+ const server = createMcpServer();
326
+ await server.connect(new stdio_js_1.StdioServerTransport());
327
+ console.error('gyazo MCP server ready on stdio.');
328
+ }
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /**
3
+ * Validating the numeric options the commands accept. Lives on its own because
4
+ * the date handling needs it too, and because failing here means exiting with
5
+ * a message rather than throwing.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.parsePositiveIntegerOption = parsePositiveIntegerOption;
9
+ function parsePositiveIntegerOption(value, optionName) {
10
+ if (!/^\d+$/.test(value)) {
11
+ console.error(`Error: ${optionName} must be a positive integer.`);
12
+ process.exit(1);
13
+ }
14
+ const parsed = Number(value);
15
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
16
+ console.error(`Error: ${optionName} must be a positive integer.`);
17
+ process.exit(1);
18
+ }
19
+ return parsed;
20
+ }