@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.
- package/README.md +173 -0
- package/package.json +49 -0
- 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
|
+
};
|