@findtime/mcp-server 3.25.1

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.
Files changed (3) hide show
  1. package/README.md +173 -0
  2. package/package.json +49 -0
  3. package/server.js +885 -0
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # @findtime/mcp-server
2
+
3
+ `@findtime/mcp-server` is a thin stdio MCP wrapper over the production findtime.io Time API at `https://time-api.findtime.io`.
4
+
5
+ The package intentionally proxies the production API instead of re-implementing time logic locally. Current time, DST, conversion, overlap, meeting search, and location resolution should stay aligned with the live API.
6
+
7
+ ## Tool surface
8
+
9
+ - `time_snapshot`
10
+ - `get_current_time`
11
+ - `get_dst_schedule`
12
+ - `convert_time`
13
+ - `get_overlap_hours`
14
+ - `find_meeting_time`
15
+ - `search_timezones`
16
+ - `get_location_by_id`
17
+
18
+ ## Install in MCP clients
19
+
20
+ Use the published package through `npx`:
21
+
22
+ ```bash
23
+ npx -y @findtime/mcp-server
24
+ ```
25
+
26
+ Required runtime:
27
+
28
+ - Node 20+
29
+ - a valid findtime developer key in `FINDTIME_TIME_API_KEY`, `FINDTIME_API_KEY`, `TIME_API_KEY`, or `FINDTIME_MCP_API_KEY`
30
+
31
+ Optional environment variables:
32
+
33
+ - `FINDTIME_TIME_API_BASE_URL`
34
+ - `TIME_API_BASE_URL`
35
+ - `TIME_API_TIMEOUT_MS`
36
+ - `FINDTIME_MCP_CLIENT_TYPE`
37
+ - `FINDTIME_MCP_INSTRUMENTATION_ENABLED`
38
+
39
+ ### Cursor
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "findtime": {
45
+ "type": "stdio",
46
+ "command": "npx",
47
+ "args": ["-y", "@findtime/mcp-server"],
48
+ "env": {
49
+ "FINDTIME_MCP_CLIENT_TYPE": "cursor",
50
+ "FINDTIME_TIME_API_BASE_URL": "https://time-api.findtime.io",
51
+ "FINDTIME_TIME_API_KEY": "YOUR_FINDTIME_SECRET_KEY",
52
+ "FINDTIME_MCP_INSTRUMENTATION_ENABLED": "false"
53
+ }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ ### Codex
60
+
61
+ ```toml
62
+ [mcp_servers.findtime]
63
+ command = "npx"
64
+ args = ["-y", "@findtime/mcp-server"]
65
+ enabled = true
66
+
67
+ [mcp_servers.findtime.env]
68
+ FINDTIME_MCP_CLIENT_TYPE = "codex"
69
+ FINDTIME_TIME_API_BASE_URL = "https://time-api.findtime.io"
70
+ FINDTIME_TIME_API_KEY = "YOUR_FINDTIME_SECRET_KEY"
71
+ FINDTIME_MCP_INSTRUMENTATION_ENABLED = "false"
72
+ ```
73
+
74
+ ### Claude Desktop
75
+
76
+ ```json
77
+ {
78
+ "preferences": {
79
+ "...": "keep your existing preferences here"
80
+ },
81
+ "mcpServers": {
82
+ "findtime": {
83
+ "command": "npx",
84
+ "args": ["-y", "@findtime/mcp-server"],
85
+ "env": {
86
+ "FINDTIME_TIME_API_BASE_URL": "https://time-api.findtime.io",
87
+ "FINDTIME_TIME_API_KEY": "YOUR_FINDTIME_SECRET_KEY",
88
+ "FINDTIME_MCP_INSTRUMENTATION_ENABLED": "false"
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ ## Verify installation
96
+
97
+ Use an explicit tool-call prompt first:
98
+
99
+ ```text
100
+ Use the findtime MCP tool get_current_time for city "Tokyo" with countryCode "JP".
101
+ ```
102
+
103
+ After that succeeds, switch back to normal natural-language prompts:
104
+
105
+ ```text
106
+ Best meeting time between New York, Sydney, and Mumbai?
107
+ ```
108
+
109
+ ## Local development
110
+
111
+ Run the server directly from the repo root:
112
+
113
+ ```bash
114
+ npm start
115
+ ```
116
+
117
+ The server attempts to load `.env.development.local`, `.env.development`, `.env.local`, and `.env` from:
118
+
119
+ - the current working directory
120
+ - the repo root
121
+
122
+ ## Tests
123
+
124
+ Protocol and transport tests:
125
+
126
+ ```bash
127
+ npm test
128
+ ```
129
+
130
+ Live production-parity smoke tests:
131
+
132
+ ```bash
133
+ npm run test:smoke
134
+ ```
135
+
136
+ The smoke suite checks:
137
+
138
+ - `search_timezones`
139
+ - `get_current_time`
140
+ - `get_dst_schedule`
141
+ - `convert_time`
142
+ - `get_overlap_hours`
143
+ - `find_meeting_time`
144
+ - `get_location_by_id`
145
+
146
+ ## Maintainer release flow
147
+
148
+ This repository is intended to be the canonical public source for `@findtime/mcp-server`.
149
+
150
+ Recommended setup:
151
+
152
+ - keep `@findtime/mcp-server` as the npm package name
153
+ - add `repository` and `bugs` metadata after creating the GitHub repo
154
+ - add an `NPM_TOKEN` secret to the GitHub repository
155
+ - publish through GitHub Actions or a maintainer terminal from this repo root
156
+
157
+ Standard local publish flow:
158
+
159
+ ```bash
160
+ npm test
161
+ npm pack --dry-run
162
+ npm publish --access public
163
+ ```
164
+
165
+ GitHub Actions release flow:
166
+
167
+ ```bash
168
+ npm test
169
+ npm pack --dry-run
170
+ npm publish --access public
171
+ ```
172
+
173
+ Use the workflow in `.github/workflows/publish.yml` for repo-backed publishes.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@findtime/mcp-server",
3
+ "version": "3.25.1",
4
+ "description": "Production-parity MCP server for the findtime.io Time API",
5
+ "bin": {
6
+ "findtime-mcp": "server.js"
7
+ },
8
+ "main": "./server.js",
9
+ "type": "commonjs",
10
+ "files": [
11
+ "server.js",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "dependencies": {
18
+ "dotenv": "^17.2.3"
19
+ },
20
+ "scripts": {
21
+ "start": "node ./server.js",
22
+ "test": "node --test ./server.test.js",
23
+ "test:smoke": "node --test ./smoke.test.js",
24
+ "pack": "npm pack --dry-run",
25
+ "publish:dry-run": "npm publish --dry-run --access public",
26
+ "prepublishOnly": "node --test ./server.test.js"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/hkchao/findtime-mcp-server.git"
34
+ },
35
+ "homepage": "https://findtime.io/developers/mcp/",
36
+ "bugs": {
37
+ "url": "https://github.com/hkchao/findtime-mcp-server/issues"
38
+ },
39
+ "keywords": [
40
+ "mcp",
41
+ "model-context-protocol",
42
+ "time",
43
+ "timezone",
44
+ "dst",
45
+ "meeting-planner",
46
+ "findtime"
47
+ ],
48
+ "license": "UNLICENSED"
49
+ }
package/server.js ADDED
@@ -0,0 +1,885 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+
5
+ const PACKAGE_ROOT = __dirname;
6
+ const LOCAL_PACKAGE_PATH = path.join(PACKAGE_ROOT, 'package.json');
7
+ const REPO_ROOT = path.resolve(PACKAGE_ROOT, '..', '..');
8
+ const REPO_PACKAGE_PATH = path.join(REPO_ROOT, 'package.json');
9
+ const DEFAULT_PROTOCOL_VERSION = '2024-11-05';
10
+ const SUPPORTED_PROTOCOL_VERSIONS = new Set([
11
+ '2024-11-05',
12
+ '2025-03-26',
13
+ '2025-06-18',
14
+ '2025-11-05',
15
+ '2025-11-25'
16
+ ]);
17
+
18
+ loadEnvironmentFiles();
19
+
20
+ const PACKAGE_METADATA = safeReadJson(LOCAL_PACKAGE_PATH) || safeReadJson(REPO_PACKAGE_PATH) || {};
21
+ const SERVER_VERSION = PACKAGE_METADATA.version || '0.0.0';
22
+ const DEFAULT_API_BASE_URL = firstNonEmpty(
23
+ process.env.TIME_API_BASE_URL,
24
+ process.env.FINDTIME_TIME_API_BASE_URL
25
+ ) || 'https://time-api.findtime.io';
26
+ const DEFAULT_TIMEOUT_MS = parseInteger(process.env.TIME_API_TIMEOUT_MS, 15000);
27
+ const DEFAULT_API_KEY = firstNonEmpty(
28
+ process.env.FINDTIME_API_KEY,
29
+ process.env.TIME_API_KEY,
30
+ process.env.FINDTIME_MCP_API_KEY,
31
+ process.env.FINDTIME_TIME_API_KEY
32
+ );
33
+
34
+ const TOOL_DEFINITIONS = [
35
+ {
36
+ name: 'time_snapshot',
37
+ description: 'Return the production time snapshot payload for one location or a list of locations.',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ query: {
42
+ type: 'string',
43
+ description: 'Single location query such as "Tokyo" or "Europe/London".'
44
+ },
45
+ locations: stringOrStringArraySchema(
46
+ 'One or more locations. Arrays are joined with "|" before calling the API.'
47
+ ),
48
+ countryCode: {
49
+ type: 'string',
50
+ description: 'Optional ISO country hint for a single query.'
51
+ },
52
+ countryCodes: stringOrStringArraySchema(
53
+ 'Optional ISO country hints aligned to the locations list.'
54
+ ),
55
+ includeTransitions: {
56
+ type: 'boolean',
57
+ description: 'Include last and next timezone transition details.'
58
+ }
59
+ },
60
+ additionalProperties: false
61
+ },
62
+ buildRequest(args) {
63
+ ensureAtLeastOne(args, ['query', 'locations'], 'time_snapshot requires query or locations.');
64
+ const params = new URLSearchParams();
65
+ setParam(params, 'query', args.query);
66
+ setParam(params, 'locations', args.locations, { joinArraysWith: '|' });
67
+ setParam(params, 'countryCode', args.countryCode);
68
+ setParam(params, 'countryCodes', args.countryCodes, { joinArraysWith: '|' });
69
+ setParam(params, 'includeTransitions', args.includeTransitions);
70
+ return { path: '/time/snapshot', params };
71
+ }
72
+ },
73
+ {
74
+ name: 'get_current_time',
75
+ description: 'Return the production current time payload for a single city, query, or timezone.',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ city: {
80
+ type: 'string',
81
+ description: 'City name such as "Tokyo".'
82
+ },
83
+ query: {
84
+ type: 'string',
85
+ description: 'Free-form location query or timezone abbreviation.'
86
+ },
87
+ timezone: {
88
+ type: 'string',
89
+ description: 'Direct IANA timezone, such as "Europe/London".'
90
+ },
91
+ countryCode: {
92
+ type: 'string',
93
+ description: 'Optional ISO country hint for ambiguous city names.'
94
+ }
95
+ },
96
+ additionalProperties: false
97
+ },
98
+ buildRequest(args) {
99
+ ensureAtLeastOne(args, ['city', 'query', 'timezone'], 'get_current_time requires city, query, or timezone.');
100
+ const params = new URLSearchParams();
101
+ setParam(params, 'city', args.city);
102
+ setParam(params, 'query', args.query);
103
+ setParam(params, 'timezone', args.timezone);
104
+ setParam(params, 'countryCode', args.countryCode);
105
+ return { path: '/time/current', params };
106
+ }
107
+ },
108
+ {
109
+ name: 'get_dst_schedule',
110
+ description: 'Return the production DST schedule payload, including current abbreviation and transition details.',
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ city: {
115
+ type: 'string',
116
+ description: 'City name such as "Reykjavik".'
117
+ },
118
+ query: {
119
+ type: 'string',
120
+ description: 'Free-form location query.'
121
+ },
122
+ timezone: {
123
+ type: 'string',
124
+ description: 'Direct IANA timezone, such as "America/New_York".'
125
+ },
126
+ countryCode: {
127
+ type: 'string',
128
+ description: 'Optional ISO country hint for ambiguous city names.'
129
+ },
130
+ at: {
131
+ type: 'string',
132
+ description: 'Optional ISO timestamp used as the reference instant.'
133
+ }
134
+ },
135
+ additionalProperties: false
136
+ },
137
+ buildRequest(args) {
138
+ ensureAtLeastOne(args, ['city', 'query', 'timezone'], 'get_dst_schedule requires city, query, or timezone.');
139
+ const params = new URLSearchParams();
140
+ setParam(params, 'city', args.city);
141
+ setParam(params, 'query', args.query);
142
+ setParam(params, 'timezone', args.timezone);
143
+ setParam(params, 'countryCode', args.countryCode);
144
+ setParam(params, 'at', args.at);
145
+ return { path: '/timezone/dst', params };
146
+ }
147
+ },
148
+ {
149
+ name: 'convert_time',
150
+ description: 'Convert a source local time into one or more target locations using the production conversion endpoint.',
151
+ inputSchema: {
152
+ type: 'object',
153
+ required: ['from', 'to', 'time'],
154
+ properties: {
155
+ from: {
156
+ type: 'string',
157
+ description: 'Source location or timezone.'
158
+ },
159
+ fromCountryCode: {
160
+ type: 'string',
161
+ description: 'Optional ISO country hint for the source location.'
162
+ },
163
+ to: stringOrStringArraySchema(
164
+ 'One target or a list of targets. Arrays are joined with "|" before calling the API.'
165
+ ),
166
+ toCountryCodes: stringOrStringArraySchema(
167
+ 'Optional ISO country hints aligned to the target list.'
168
+ ),
169
+ time: {
170
+ type: 'string',
171
+ description: 'Source local time, such as "9:00 AM".'
172
+ },
173
+ date: {
174
+ type: 'string',
175
+ description: 'Optional ISO date used as the conversion context.'
176
+ }
177
+ },
178
+ additionalProperties: false
179
+ },
180
+ buildRequest(args) {
181
+ ensureRequired(args, ['from', 'to', 'time'], 'convert_time requires from, to, and time.');
182
+ const params = new URLSearchParams();
183
+ setParam(params, 'from', args.from);
184
+ setParam(params, 'fromCountryCode', args.fromCountryCode);
185
+ setParam(params, 'to', args.to, { joinArraysWith: '|' });
186
+ setParam(params, 'toCountryCodes', args.toCountryCodes, { joinArraysWith: '|' });
187
+ setParam(params, 'time', args.time);
188
+ setParam(params, 'date', args.date);
189
+ return { path: '/time/convert', params };
190
+ }
191
+ },
192
+ {
193
+ name: 'get_overlap_hours',
194
+ description: 'Return shared business-hours overlap across multiple locations using the production overlap endpoint.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ required: ['locations'],
198
+ properties: {
199
+ locations: stringOrStringArraySchema(
200
+ 'Two or more locations. Arrays are joined with "|" before calling the API.'
201
+ ),
202
+ countryCodes: stringOrStringArraySchema(
203
+ 'Optional ISO country hints aligned to the locations list.'
204
+ ),
205
+ date: {
206
+ type: 'string',
207
+ description: 'Optional ISO date used to compute overlap.'
208
+ }
209
+ },
210
+ additionalProperties: false
211
+ },
212
+ buildRequest(args) {
213
+ ensureRequired(args, ['locations'], 'get_overlap_hours requires locations.');
214
+ const params = new URLSearchParams();
215
+ setParam(params, 'locations', args.locations, { joinArraysWith: '|' });
216
+ setParam(params, 'countryCodes', args.countryCodes, { joinArraysWith: '|' });
217
+ setParam(params, 'date', args.date);
218
+ return { path: '/time/overlap', params };
219
+ }
220
+ },
221
+ {
222
+ name: 'find_meeting_time',
223
+ description: 'Return ranked meeting suggestions from the production meeting search endpoint.',
224
+ inputSchema: {
225
+ type: 'object',
226
+ required: ['locations'],
227
+ properties: {
228
+ locations: stringOrStringArraySchema(
229
+ 'Two or more locations. Arrays are joined with "|" before calling the API.'
230
+ ),
231
+ countryCodes: stringOrStringArraySchema(
232
+ 'Optional ISO country hints aligned to the locations list.'
233
+ ),
234
+ date: {
235
+ type: 'string',
236
+ description: 'Optional ISO date to anchor the meeting search.'
237
+ }
238
+ },
239
+ additionalProperties: false
240
+ },
241
+ buildRequest(args) {
242
+ ensureRequired(args, ['locations'], 'find_meeting_time requires locations.');
243
+ const params = new URLSearchParams();
244
+ setParam(params, 'locations', args.locations, { joinArraysWith: '|' });
245
+ setParam(params, 'countryCodes', args.countryCodes, { joinArraysWith: '|' });
246
+ setParam(params, 'date', args.date);
247
+ return { path: '/meeting/find', params };
248
+ }
249
+ },
250
+ {
251
+ name: 'search_timezones',
252
+ description: 'Search production location records by city, country, or timezone-related query.',
253
+ inputSchema: {
254
+ type: 'object',
255
+ required: ['query'],
256
+ properties: {
257
+ query: {
258
+ type: 'string',
259
+ description: 'Search term such as "Victoria", "Sao Paulo", or "Tokyo".'
260
+ },
261
+ countryCode: {
262
+ type: 'string',
263
+ description: 'Optional ISO country hint.'
264
+ },
265
+ limit: {
266
+ type: 'integer',
267
+ minimum: 1,
268
+ maximum: 25,
269
+ description: 'Maximum number of results to return.'
270
+ }
271
+ },
272
+ additionalProperties: false
273
+ },
274
+ buildRequest(args) {
275
+ ensureRequired(args, ['query'], 'search_timezones requires query.');
276
+ const params = new URLSearchParams();
277
+ setParam(params, 'query', args.query);
278
+ setParam(params, 'countryCode', args.countryCode);
279
+ setParam(params, 'limit', args.limit);
280
+ return { path: '/locations/search', params };
281
+ }
282
+ },
283
+ {
284
+ name: 'get_location_by_id',
285
+ description: 'Hydrate an exact production location record by stable findtime id.',
286
+ inputSchema: {
287
+ type: 'object',
288
+ required: ['id'],
289
+ properties: {
290
+ id: {
291
+ type: 'string',
292
+ description: 'Stable location id such as "findtime:victoria|CA|America/Vancouver".'
293
+ }
294
+ },
295
+ additionalProperties: false
296
+ },
297
+ buildRequest(args) {
298
+ ensureRequired(args, ['id'], 'get_location_by_id requires id.');
299
+ return {
300
+ path: `/locations/${encodeURIComponent(String(args.id).trim())}`,
301
+ params: new URLSearchParams()
302
+ };
303
+ }
304
+ }
305
+ ];
306
+
307
+ const TOOL_DEFINITIONS_BY_NAME = new Map(TOOL_DEFINITIONS.map((tool) => [tool.name, tool]));
308
+
309
+ function safeReadJson(filePath) {
310
+ try {
311
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
312
+ } catch (_error) {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ function loadEnvironmentFiles() {
318
+ const searchRoots = uniquePaths([
319
+ process.cwd(),
320
+ PACKAGE_ROOT,
321
+ REPO_ROOT
322
+ ]);
323
+ const dotenvPaths = searchRoots.flatMap((root) => ([
324
+ path.join(root, '.env.development.local'),
325
+ path.join(root, '.env.development'),
326
+ path.join(root, '.env.local'),
327
+ path.join(root, '.env')
328
+ ]));
329
+
330
+ try {
331
+ const dotenv = require('dotenv');
332
+ for (const dotenvPath of dotenvPaths) {
333
+ if (fs.existsSync(dotenvPath)) {
334
+ dotenv.config({ path: dotenvPath, override: false, quiet: true });
335
+ }
336
+ }
337
+ } catch (_error) {
338
+ // The server can still run when dotenv is unavailable.
339
+ }
340
+ }
341
+
342
+ function uniquePaths(paths) {
343
+ const seen = new Set();
344
+ const values = [];
345
+
346
+ for (const candidate of paths) {
347
+ const normalized = typeof candidate === 'string' && candidate.trim()
348
+ ? path.resolve(candidate)
349
+ : null;
350
+ if (!normalized || seen.has(normalized)) continue;
351
+ seen.add(normalized);
352
+ values.push(normalized);
353
+ }
354
+
355
+ return values;
356
+ }
357
+
358
+ function firstNonEmpty(...values) {
359
+ for (const value of values) {
360
+ if (typeof value === 'string' && value.trim()) {
361
+ return value.trim();
362
+ }
363
+ }
364
+ return null;
365
+ }
366
+
367
+ function parseInteger(value, fallback) {
368
+ const parsed = Number.parseInt(String(value || ''), 10);
369
+ return Number.isFinite(parsed) ? parsed : fallback;
370
+ }
371
+
372
+ function stringOrStringArraySchema(description) {
373
+ return {
374
+ anyOf: [
375
+ {
376
+ type: 'string',
377
+ description
378
+ },
379
+ {
380
+ type: 'array',
381
+ items: { type: 'string' },
382
+ minItems: 1,
383
+ description
384
+ }
385
+ ]
386
+ };
387
+ }
388
+
389
+ function ensureRequired(args, keys, message) {
390
+ for (const key of keys) {
391
+ if (args[key] === undefined || args[key] === null || String(args[key]).trim() === '') {
392
+ throw invalidParamsError(message);
393
+ }
394
+ }
395
+ }
396
+
397
+ function ensureAtLeastOne(args, keys, message) {
398
+ const present = keys.some((key) => {
399
+ const value = args[key];
400
+ if (Array.isArray(value)) return value.some((item) => typeof item === 'string' && item.trim());
401
+ if (typeof value === 'boolean') return true;
402
+ return typeof value === 'string' && value.trim();
403
+ });
404
+
405
+ if (!present) {
406
+ throw invalidParamsError(message);
407
+ }
408
+ }
409
+
410
+ function invalidParamsError(message) {
411
+ const error = new Error(message);
412
+ error.code = -32602;
413
+ return error;
414
+ }
415
+
416
+ function methodNotFoundError(method) {
417
+ const error = new Error(`Method not found: ${method}`);
418
+ error.code = -32601;
419
+ return error;
420
+ }
421
+
422
+ function setParam(searchParams, key, value, options = {}) {
423
+ const { joinArraysWith = ',' } = options;
424
+ if (value === undefined || value === null) return;
425
+
426
+ if (Array.isArray(value)) {
427
+ const cleaned = value
428
+ .map((item) => (typeof item === 'string' ? item.trim() : String(item || '').trim()))
429
+ .filter(Boolean);
430
+ if (cleaned.length > 0) {
431
+ searchParams.set(key, cleaned.join(joinArraysWith));
432
+ }
433
+ return;
434
+ }
435
+
436
+ if (typeof value === 'boolean') {
437
+ searchParams.set(key, String(value));
438
+ return;
439
+ }
440
+
441
+ if (typeof value === 'number') {
442
+ if (Number.isFinite(value)) {
443
+ searchParams.set(key, String(value));
444
+ }
445
+ return;
446
+ }
447
+
448
+ const text = String(value).trim();
449
+ if (text) {
450
+ searchParams.set(key, text);
451
+ }
452
+ }
453
+
454
+ function negotiateProtocolVersion(requestedVersion) {
455
+ if (typeof requestedVersion === 'string' && SUPPORTED_PROTOCOL_VERSIONS.has(requestedVersion)) {
456
+ return requestedVersion;
457
+ }
458
+ return DEFAULT_PROTOCOL_VERSION;
459
+ }
460
+
461
+ function createSuccessResponse(id, result) {
462
+ return {
463
+ jsonrpc: '2.0',
464
+ id,
465
+ result
466
+ };
467
+ }
468
+
469
+ function createErrorResponse(id, code, message, data) {
470
+ const payload = {
471
+ jsonrpc: '2.0',
472
+ id,
473
+ error: {
474
+ code,
475
+ message
476
+ }
477
+ };
478
+ if (data !== undefined) {
479
+ payload.error.data = data;
480
+ }
481
+ return payload;
482
+ }
483
+
484
+ function encodeMessage(message) {
485
+ const body = Buffer.from(JSON.stringify(message), 'utf8');
486
+ return Buffer.concat([
487
+ Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf8'),
488
+ body
489
+ ]);
490
+ }
491
+
492
+ class ContentLengthMessageBuffer {
493
+ constructor() {
494
+ this.buffer = Buffer.alloc(0);
495
+ this.lineBuffer = '';
496
+ this.lastMode = null;
497
+ }
498
+
499
+ push(chunk) {
500
+ this.buffer = Buffer.concat([this.buffer, Buffer.from(chunk)]);
501
+ const framedMessages = this.extractFramedMessages();
502
+ if (framedMessages.length > 0) {
503
+ this.lastMode = 'content-length';
504
+ return framedMessages;
505
+ }
506
+
507
+ const lineMessages = this.extractLineDelimitedMessages(chunk);
508
+ if (lineMessages.length > 0) {
509
+ this.lastMode = 'json-line';
510
+ }
511
+ return lineMessages;
512
+ }
513
+
514
+ extractFramedMessages() {
515
+ const messages = [];
516
+
517
+ while (true) {
518
+ const headerEnd = this.buffer.indexOf('\r\n\r\n');
519
+ if (headerEnd === -1) break;
520
+
521
+ const headerText = this.buffer.slice(0, headerEnd).toString('utf8');
522
+ const contentLengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
523
+
524
+ if (!contentLengthMatch) {
525
+ throw new Error('Missing Content-Length header.');
526
+ }
527
+
528
+ const contentLength = Number.parseInt(contentLengthMatch[1], 10);
529
+ const messageEnd = headerEnd + 4 + contentLength;
530
+
531
+ if (this.buffer.length < messageEnd) break;
532
+
533
+ const bodyBuffer = this.buffer.slice(headerEnd + 4, messageEnd);
534
+ this.buffer = this.buffer.slice(messageEnd);
535
+ messages.push(JSON.parse(bodyBuffer.toString('utf8')));
536
+ }
537
+
538
+ return messages;
539
+ }
540
+
541
+ extractLineDelimitedMessages(chunk) {
542
+ const messages = [];
543
+ const text = Buffer.from(chunk).toString('utf8');
544
+ this.lineBuffer += text;
545
+
546
+ const lines = this.lineBuffer.split(/\r?\n/);
547
+ this.lineBuffer = lines.pop() || '';
548
+
549
+ for (const line of lines) {
550
+ const trimmed = line.trim();
551
+ if (!trimmed) continue;
552
+ try {
553
+ messages.push(JSON.parse(trimmed));
554
+ } catch (_error) {
555
+ this.lineBuffer = `${trimmed}\n${this.lineBuffer}`;
556
+ }
557
+ }
558
+
559
+ return messages;
560
+ }
561
+ }
562
+
563
+ function summarizeToolPayload(toolName, payload) {
564
+ return `${toolName} response\n${JSON.stringify(payload, null, 2)}`;
565
+ }
566
+
567
+ function buildToolErrorResult(toolName, apiResponse) {
568
+ const summary = {
569
+ ok: false,
570
+ tool: toolName,
571
+ status: apiResponse.status,
572
+ url: apiResponse.url,
573
+ error: apiResponse.parsedBody !== undefined ? apiResponse.parsedBody : apiResponse.rawBody
574
+ };
575
+
576
+ if (apiResponse.networkError) {
577
+ summary.networkError = apiResponse.networkError;
578
+ }
579
+
580
+ return {
581
+ isError: true,
582
+ content: [
583
+ {
584
+ type: 'text',
585
+ text: summarizeToolPayload(toolName, summary)
586
+ }
587
+ ],
588
+ structuredContent: summary
589
+ };
590
+ }
591
+
592
+ function buildToolSuccessResult(toolName, payload, apiMeta) {
593
+ const structuredContent = {
594
+ ...payload,
595
+ _meta: {
596
+ endpoint: apiMeta.endpoint,
597
+ url: apiMeta.url
598
+ }
599
+ };
600
+
601
+ return {
602
+ content: [
603
+ {
604
+ type: 'text',
605
+ text: summarizeToolPayload(toolName, structuredContent)
606
+ }
607
+ ],
608
+ structuredContent
609
+ };
610
+ }
611
+
612
+ function createFindtimeMcpServer(options = {}) {
613
+ const fetchImpl = options.fetchImpl || global.fetch;
614
+ const apiBaseUrl = options.apiBaseUrl || DEFAULT_API_BASE_URL;
615
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
616
+ const apiKey = options.apiKey === undefined ? DEFAULT_API_KEY : options.apiKey;
617
+ const serverName = options.serverName || 'findtime';
618
+ const serverTitle = options.serverTitle || 'findtime Time API MCP';
619
+
620
+ if (typeof fetchImpl !== 'function') {
621
+ throw new Error('Fetch implementation is required to run the MCP server.');
622
+ }
623
+
624
+ const state = {
625
+ initialized: false,
626
+ protocolVersion: DEFAULT_PROTOCOL_VERSION
627
+ };
628
+
629
+ async function fetchJson(toolName, request) {
630
+ const url = new URL(request.path, apiBaseUrl);
631
+ if (request.params && request.params.size > 0) {
632
+ url.search = request.params.toString();
633
+ }
634
+
635
+ const headers = {
636
+ Accept: 'application/json',
637
+ 'User-Agent': `findtime-mcp/${SERVER_VERSION}`,
638
+ 'X-Findtime-MCP-Tool': toolName
639
+ };
640
+
641
+ if (typeof apiKey === 'string' && apiKey.trim()) {
642
+ headers.Authorization = `Bearer ${apiKey.trim()}`;
643
+ }
644
+
645
+ const controller = new AbortController();
646
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
647
+
648
+ try {
649
+ const response = await fetchImpl(url.toString(), {
650
+ method: 'GET',
651
+ headers,
652
+ signal: controller.signal
653
+ });
654
+
655
+ const rawBody = await response.text();
656
+ const parsedBody = tryParseJson(rawBody);
657
+
658
+ if (!response.ok) {
659
+ return {
660
+ ok: false,
661
+ status: response.status,
662
+ url: url.toString(),
663
+ rawBody,
664
+ parsedBody
665
+ };
666
+ }
667
+
668
+ return {
669
+ ok: true,
670
+ status: response.status,
671
+ url: url.toString(),
672
+ parsedBody: parsedBody === undefined ? { rawBody } : parsedBody
673
+ };
674
+ } catch (error) {
675
+ return {
676
+ ok: false,
677
+ status: null,
678
+ url: url.toString(),
679
+ rawBody: null,
680
+ parsedBody: undefined,
681
+ networkError: error && error.name === 'AbortError'
682
+ ? `Request timed out after ${timeoutMs}ms`
683
+ : String(error && error.message ? error.message : error)
684
+ };
685
+ } finally {
686
+ clearTimeout(timeout);
687
+ }
688
+ }
689
+
690
+ async function callTool(name, args = {}) {
691
+ const tool = TOOL_DEFINITIONS_BY_NAME.get(name);
692
+ if (!tool) {
693
+ throw invalidParamsError(`Unknown tool: ${name}`);
694
+ }
695
+
696
+ const request = tool.buildRequest(args || {});
697
+ const apiResponse = await fetchJson(name, request);
698
+
699
+ if (!apiResponse.ok) {
700
+ return buildToolErrorResult(name, apiResponse);
701
+ }
702
+
703
+ return buildToolSuccessResult(name, apiResponse.parsedBody, {
704
+ endpoint: request.path,
705
+ url: apiResponse.url
706
+ });
707
+ }
708
+
709
+ async function handleMessage(message) {
710
+ const isRequest = message && typeof message === 'object' && Object.prototype.hasOwnProperty.call(message, 'id');
711
+ const method = message && typeof message === 'object' ? message.method : null;
712
+
713
+ if (!method) {
714
+ if (isRequest) {
715
+ return createErrorResponse(message.id, -32600, 'Invalid request');
716
+ }
717
+ return null;
718
+ }
719
+
720
+ try {
721
+ if (method === 'initialize') {
722
+ state.initialized = true;
723
+ state.protocolVersion = negotiateProtocolVersion(message.params && message.params.protocolVersion);
724
+
725
+ return createSuccessResponse(message.id, {
726
+ protocolVersion: state.protocolVersion,
727
+ capabilities: {
728
+ tools: {}
729
+ },
730
+ serverInfo: {
731
+ name: serverName,
732
+ title: serverTitle,
733
+ version: SERVER_VERSION
734
+ }
735
+ });
736
+ }
737
+
738
+ if (method === 'notifications/initialized') {
739
+ state.initialized = true;
740
+ return null;
741
+ }
742
+
743
+ if (method === 'ping') {
744
+ return createSuccessResponse(message.id, {});
745
+ }
746
+
747
+ if (method === 'tools/list') {
748
+ return createSuccessResponse(message.id, {
749
+ tools: TOOL_DEFINITIONS.map(({ name, description, inputSchema }) => ({
750
+ name,
751
+ description,
752
+ inputSchema
753
+ }))
754
+ });
755
+ }
756
+
757
+ if (method === 'tools/call') {
758
+ const toolName = message.params && message.params.name;
759
+ const toolArgs = (message.params && message.params.arguments) || {};
760
+ const result = await callTool(toolName, toolArgs);
761
+ return createSuccessResponse(message.id, result);
762
+ }
763
+
764
+ throw methodNotFoundError(method);
765
+ } catch (error) {
766
+ const code = Number.isFinite(error.code) ? error.code : -32603;
767
+ const data = code === -32603
768
+ ? { message: String(error && error.message ? error.message : error) }
769
+ : undefined;
770
+ return isRequest
771
+ ? createErrorResponse(message.id, code, error.message || 'Internal error', data)
772
+ : null;
773
+ }
774
+ }
775
+
776
+ return {
777
+ state,
778
+ callTool,
779
+ handleMessage
780
+ };
781
+ }
782
+
783
+ function tryParseJson(value) {
784
+ if (typeof value !== 'string' || !value.trim()) {
785
+ return undefined;
786
+ }
787
+
788
+ try {
789
+ return JSON.parse(value);
790
+ } catch (_error) {
791
+ return undefined;
792
+ }
793
+ }
794
+
795
+ function startStdioServer(options = {}) {
796
+ const server = createFindtimeMcpServer(options);
797
+ const messageBuffer = new ContentLengthMessageBuffer();
798
+ let outputMode = detectOutputMode(options.outputMode);
799
+ let queue = Promise.resolve();
800
+
801
+ process.stdin.on('data', (chunk) => {
802
+ let messages;
803
+ try {
804
+ messages = messageBuffer.push(chunk);
805
+ } catch (error) {
806
+ console.error(`[findtime-mcp] Failed to parse incoming message: ${error.message}`);
807
+ return;
808
+ }
809
+
810
+ if (!outputMode && messageBuffer.lastMode) {
811
+ outputMode = messageBuffer.lastMode;
812
+ }
813
+
814
+ for (const message of messages) {
815
+ queue = queue
816
+ .then(async () => {
817
+ const response = await server.handleMessage(message);
818
+ if (response) {
819
+ writeResponse(process.stdout, response, outputMode);
820
+ }
821
+ })
822
+ .catch((error) => {
823
+ const requestId = message && Object.prototype.hasOwnProperty.call(message, 'id')
824
+ ? message.id
825
+ : null;
826
+
827
+ if (requestId !== null) {
828
+ writeResponse(
829
+ process.stdout,
830
+ createErrorResponse(
831
+ requestId,
832
+ -32603,
833
+ 'Internal error',
834
+ { message: String(error && error.message ? error.message : error) }
835
+ ),
836
+ outputMode
837
+ );
838
+ }
839
+ });
840
+ }
841
+ });
842
+
843
+ process.stdin.resume();
844
+ return server;
845
+ }
846
+
847
+ function detectOutputMode(explicitMode) {
848
+ if (explicitMode === 'content-length' || explicitMode === 'json-line') {
849
+ return explicitMode;
850
+ }
851
+
852
+ const clientType = String(process.env.FINDTIME_MCP_CLIENT_TYPE || '').trim().toLowerCase();
853
+ if (clientType === 'cursor') {
854
+ return 'json-line';
855
+ }
856
+
857
+ if (clientType === 'codex') {
858
+ return null;
859
+ }
860
+
861
+ return null;
862
+ }
863
+
864
+ function writeResponse(stream, message, outputMode) {
865
+ if (outputMode === 'json-line') {
866
+ stream.write(`${JSON.stringify(message)}\n`);
867
+ return;
868
+ }
869
+
870
+ stream.write(encodeMessage(message));
871
+ }
872
+
873
+ if (require.main === module) {
874
+ startStdioServer();
875
+ }
876
+
877
+ module.exports = {
878
+ ContentLengthMessageBuffer,
879
+ TOOL_DEFINITIONS,
880
+ createErrorResponse,
881
+ createFindtimeMcpServer,
882
+ createSuccessResponse,
883
+ encodeMessage,
884
+ startStdioServer
885
+ };