@perrylink/dsh-ticktick 0.1.2

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 (71) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +41 -0
  4. package/README.hi.md +33 -0
  5. package/README.md +111 -0
  6. package/README.pt.md +41 -0
  7. package/README.zh.md +111 -0
  8. package/cordis.patch.yml +35 -0
  9. package/lib/client.js +6233 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/index.js +1349 -0
  12. package/lib/typert.host.js +26 -0
  13. package/lib/types/client/TicktickAction.d.ts +29 -0
  14. package/lib/types/client/TicktickAction.d.ts.map +1 -0
  15. package/lib/types/client/TicktickSettingsCard.d.ts +27 -0
  16. package/lib/types/client/TicktickSettingsCard.d.ts.map +1 -0
  17. package/lib/types/client/api.d.ts +34 -0
  18. package/lib/types/client/api.d.ts.map +1 -0
  19. package/lib/types/client/dates.d.ts +26 -0
  20. package/lib/types/client/dates.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +34 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +61 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/order.d.ts +25 -0
  26. package/lib/types/client/order.d.ts.map +1 -0
  27. package/lib/types/client/remote.d.ts +260 -0
  28. package/lib/types/client/remote.d.ts.map +1 -0
  29. package/lib/types/client/styles.d.ts +13 -0
  30. package/lib/types/client/styles.d.ts.map +1 -0
  31. package/lib/types/config.d.ts +64 -0
  32. package/lib/types/config.d.ts.map +1 -0
  33. package/lib/types/domain.d.ts +70 -0
  34. package/lib/types/domain.d.ts.map +1 -0
  35. package/lib/types/index.d.ts +56 -0
  36. package/lib/types/index.d.ts.map +1 -0
  37. package/lib/types/mcp.d.ts +94 -0
  38. package/lib/types/mcp.d.ts.map +1 -0
  39. package/lib/types/service.d.ts +160 -0
  40. package/lib/types/service.d.ts.map +1 -0
  41. package/lib/types/tools.d.ts +18 -0
  42. package/lib/types/tools.d.ts.map +1 -0
  43. package/lib/types/typert.host.d.ts +216 -0
  44. package/lib/types/typert.host.d.ts.map +1 -0
  45. package/lib/types/wire.d.ts +561 -0
  46. package/lib/types/wire.d.ts.map +1 -0
  47. package/lib/wire-C-vDxxnC.js +5288 -0
  48. package/package.json +187 -0
  49. package/probes/lib.mjs +65 -0
  50. package/probes/probe-bootstrap.mjs +10 -0
  51. package/probes/probe-crud.mjs +15 -0
  52. package/probes/probe-due.mjs +28 -0
  53. package/probes/probe-queries.mjs +18 -0
  54. package/probes/probe-reorder.mjs +24 -0
  55. package/src/client/TicktickAction.tsx +356 -0
  56. package/src/client/TicktickSettingsCard.tsx +182 -0
  57. package/src/client/api.ts +56 -0
  58. package/src/client/dates.ts +61 -0
  59. package/src/client/index.ts +119 -0
  60. package/src/client/locales.ts +113 -0
  61. package/src/client/order.ts +37 -0
  62. package/src/client/remote.ts +76 -0
  63. package/src/client/styles.ts +48 -0
  64. package/src/config.ts +136 -0
  65. package/src/domain.ts +224 -0
  66. package/src/index.ts +139 -0
  67. package/src/mcp.ts +213 -0
  68. package/src/service.ts +403 -0
  69. package/src/tools.ts +336 -0
  70. package/src/typert.host.ts +25 -0
  71. package/src/wire.ts +401 -0
package/src/domain.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Tool discovery and task normalization over the TickTick MCP tool list:
3
+ * names are resolved by pattern (pinnable per deployment) and results are
4
+ * normalized into a stable `{ id, title, done, projectId, dueDate,
5
+ * sortOrder }` view. Ported from the measured 0.1.0-era bridge; the wire
6
+ * facts (list order = descending sortOrder, inbox ids carry 'inbox') were
7
+ * verified against the live endpoint.
8
+ *
9
+ * @module dsh-ticktick/domain
10
+ */
11
+
12
+ import type { McpCallResult, McpTool } from './mcp.ts'
13
+ import type { ToolPins } from './config.ts'
14
+
15
+ /** The resolved raw MCP tool names for the bridge operations. */
16
+ export interface ToolResolver {
17
+ readonly projects: string
18
+ readonly tasks: string
19
+ readonly create: string
20
+ readonly complete: string
21
+ readonly remove: string
22
+ readonly update: string
23
+ readonly move: string
24
+ /** `list_completed_tasks_by_date` (P2 completed view). */
25
+ readonly completed: string
26
+ /** `search` or `search_task` (P2 full-text search). */
27
+ readonly search: string
28
+ /** `get_task_by_id` (P2 read-after-write verification). */
29
+ readonly getTask: string
30
+ /** `batch_add_tasks` (P2 batch create). */
31
+ readonly batchAdd: string
32
+ }
33
+
34
+ /** Stable task view the widget and tools render. */
35
+ export interface TicktickTask {
36
+ readonly id: string
37
+ readonly title: string
38
+ readonly done: boolean
39
+ /** Owning project, required by TickTick's complete_task call. */
40
+ readonly projectId?: string
41
+ /** ISO due date (TickTick date-time), absent when the task has none. */
42
+ readonly dueDate?: string
43
+ /** TickTick sort order (list order is descending). */
44
+ readonly sortOrder?: number
45
+ }
46
+
47
+ /** One project entry from `list_projects` (the virtual inbox included). */
48
+ export interface TicktickProject {
49
+ readonly id: string
50
+ readonly name: string
51
+ }
52
+
53
+ const PROJECTS_PATTERN = /(list|get)[^a-z0-9]*(project|projects)|(project|projects)[^a-z0-9]*(list|get)/i
54
+ const TASKS_PATTERN = /(undone|open)[^a-z0-9]*(task|tasks)|project[^a-z0-9]*(with|undone)[^a-z0-9]*(task|tasks)/i
55
+ const CREATE_PATTERN = /^(create|add|insert|new)[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*(create|add|insert|new)/i
56
+ const COMPLETE_PATTERN = /^(complete|finish|done|close|check)[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*(complete|finish|done|close|check)/i
57
+ const REMOVE_PATTERN = /^(delete|remove)[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*(delete|remove)/i
58
+ const UPDATE_PATTERN = /^update[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*update/i
59
+ const MOVE_PATTERN = /^move[^a-z0-9]*(task|todo)|^(task|todo)[^a-z0-9]*move/i
60
+ const COMPLETED_PATTERN = /completed[^a-z0-9]*(task|tasks)|completed_by/i
61
+ const SEARCH_PATTERN = /^search(_task)?$/i
62
+ const GET_TASK_PATTERN = /get[^a-z0-9]*task[^a-z0-9]*by[^a-z0-9]*id|^fetch$/i
63
+ const BATCH_ADD_PATTERN = /batch[^a-z0-9]*add[^a-z0-9]*(task|tasks)/i
64
+
65
+ /**
66
+ * Pick the raw tool names for the eleven operations, honoring pins.
67
+ * A pin that is not advertised falls back to pattern discovery, then to
68
+ * the first advertised tool, then to the pin itself (an empty resolver
69
+ * surfaces as a failed call, never a silent no-op).
70
+ * @param tools - the advertised tool list.
71
+ * @param pins - config-level name pins.
72
+ * @returns the resolved names.
73
+ */
74
+ export function resolveTools(tools: readonly McpTool[], pins: ToolPins): ToolResolver {
75
+ const pick = (pattern: RegExp, pin: string): string => {
76
+ if (pin !== '' && tools.some(tool => tool.name === pin)) return pin
77
+ return tools.find(tool => pattern.test(tool.name))?.name
78
+ ?? (pin !== '' ? pin : tools[0]?.name)
79
+ ?? ''
80
+ }
81
+ // Prefer the exact `search` tool over `search_task` (both match the pattern).
82
+ const searchPin = pins.search
83
+ const exactSearch = tools.find(tool => tool.name === 'search')
84
+ const search = (searchPin !== '' && tools.some(tool => tool.name === searchPin)) ? searchPin
85
+ : (exactSearch?.name ?? pick(SEARCH_PATTERN, searchPin))
86
+ return {
87
+ projects: pick(PROJECTS_PATTERN, pins.projects),
88
+ tasks: pick(TASKS_PATTERN, pins.tasks),
89
+ create: pick(CREATE_PATTERN, pins.create),
90
+ complete: pick(COMPLETE_PATTERN, pins.complete),
91
+ remove: pick(REMOVE_PATTERN, pins.remove),
92
+ update: pick(UPDATE_PATTERN, pins.update),
93
+ move: pick(MOVE_PATTERN, pins.move),
94
+ completed: pick(COMPLETED_PATTERN, pins.completed),
95
+ search,
96
+ getTask: pick(GET_TASK_PATTERN, pins.getTask),
97
+ batchAdd: pick(BATCH_ADD_PATTERN, pins.batchAdd),
98
+ }
99
+ }
100
+
101
+ const VALUE_KEY = /title|content|text|subject|name|desc/i
102
+ const ID_KEY = /(^|_)(id|uuid)$|taskId|task_id/i
103
+ const PROJECT_KEY = /projectId|project_id/i
104
+
105
+ /** Parse the project entries out of a `list_projects` result (object or array payloads). */
106
+ export function normalizeProjects(result: McpCallResult): readonly TicktickProject[] {
107
+ const projects: TicktickProject[] = []
108
+ const seen = new Set<string>()
109
+ const accept = (record: Record<string, unknown>): void => {
110
+ const id = record.id
111
+ const name = record.name
112
+ if (typeof id === 'string' && typeof name === 'string' && !seen.has(id)) {
113
+ seen.add(id)
114
+ projects.push({ id, name })
115
+ }
116
+ }
117
+ for (const block of result.content ?? []) {
118
+ if (block.type !== 'text' || block.text === undefined) continue
119
+ try {
120
+ const parsed = JSON.parse(block.text) as unknown
121
+ if (Array.isArray(parsed)) {
122
+ for (const entry of parsed) {
123
+ if (entry !== null && typeof entry === 'object') accept(entry as Record<string, unknown>)
124
+ }
125
+ } else if (parsed !== null && typeof parsed === 'object') {
126
+ accept(parsed as Record<string, unknown>)
127
+ }
128
+ } catch {
129
+ // Non-JSON text blocks are not project entries.
130
+ }
131
+ }
132
+ return projects
133
+ }
134
+
135
+ /** Walk a value for arrays whose elements look like task objects. */
136
+ function findTaskArrays(value: unknown, depth: number): unknown[][] {
137
+ if (depth > 8 || value === null || typeof value !== 'object') return []
138
+ if (Array.isArray(value)) {
139
+ const elements = value.filter(item => item !== null && typeof item === 'object' && !Array.isArray(item))
140
+ if (elements.length > 0 && elements.every(item => {
141
+ const record = item as Record<string, unknown>
142
+ return Object.keys(record).some(key => VALUE_KEY.test(key))
143
+ })) return [value as unknown[]]
144
+ return value.flatMap(item => findTaskArrays(item, depth + 1))
145
+ }
146
+ return Object.values(value as Record<string, unknown>).flatMap(item => findTaskArrays(item, depth + 1))
147
+ }
148
+
149
+ /** Read the task id, title, done flag, project, due date, and sort order from one task-like record. */
150
+ function readTask(record: Record<string, unknown>): TicktickTask | null {
151
+ const keys = Object.keys(record)
152
+ const titleKey = keys.find(key => VALUE_KEY.test(key) && typeof record[key] === 'string')
153
+ const idKey = keys.find(key => ID_KEY.test(key))
154
+ if (titleKey === undefined || idKey === undefined) return null
155
+ const idValue = record[idKey]
156
+ if (typeof idValue !== 'string' && typeof idValue !== 'number') return null
157
+ const projectKey = keys.find(key => PROJECT_KEY.test(key))
158
+ const projectValue = projectKey === undefined ? undefined : record[projectKey]
159
+ const dueKey = keys.find(key => /dueDate|due_date|deadline/i.test(key))
160
+ const dueValue = dueKey === undefined ? undefined : record[dueKey]
161
+ const sortKey = keys.find(key => /sortOrder|sort_order/i.test(key))
162
+ const sortValue = sortKey === undefined ? undefined : record[sortKey]
163
+ const status = record.status
164
+ const done = status === 2 || status === '2' || status === 'completed'
165
+ || record.completed === true || record.done === true || record.finished === true
166
+ return {
167
+ id: String(idValue),
168
+ title: record[titleKey] as string,
169
+ done,
170
+ ...(typeof projectValue === 'string' ? { projectId: projectValue } : {}),
171
+ ...(typeof dueValue === 'string' && dueValue !== '' ? { dueDate: dueValue } : {}),
172
+ ...(typeof sortValue === 'number' ? { sortOrder: sortValue } : {}),
173
+ }
174
+ }
175
+
176
+ /** Normalize a tools/call result into the stable task view. */
177
+ export function normalizeTasks(result: McpCallResult): readonly TicktickTask[] {
178
+ const sources: unknown[] = [result.structuredContent]
179
+ for (const block of result.content ?? []) {
180
+ if (block.type === 'text' && block.text !== undefined) {
181
+ try { sources.push(JSON.parse(block.text) as unknown) } catch { sources.push(block.text) }
182
+ }
183
+ }
184
+ const seen = new Set<string>()
185
+ const tasks: TicktickTask[] = []
186
+ for (const source of sources) {
187
+ // A single task echo (create_task answers one object) is accepted
188
+ // directly — guarded by a task field so project/list wrappers and
189
+ // plain objects never masquerade as tasks; arrays of task-like rows
190
+ // come through findTaskArrays.
191
+ if (source !== null && typeof source === 'object' && !Array.isArray(source)) {
192
+ const record = source as Record<string, unknown>
193
+ const taskish = Object.keys(record).some(key => /status|projectId|project_id|dueDate|due_date|sortOrder|sort_order/i.test(key))
194
+ if (taskish) {
195
+ const direct = readTask(record)
196
+ if (direct !== null && !seen.has(direct.id)) {
197
+ seen.add(direct.id)
198
+ tasks.push(direct)
199
+ }
200
+ }
201
+ }
202
+ for (const array of findTaskArrays(source, 0)) {
203
+ for (const item of array) {
204
+ const task = readTask(item as Record<string, unknown>)
205
+ if (task !== null && !seen.has(task.id)) {
206
+ seen.add(task.id)
207
+ tasks.push(task)
208
+ }
209
+ }
210
+ }
211
+ }
212
+ return tasks
213
+ }
214
+
215
+ /**
216
+ * Whether a project id names the virtual inbox. Measured wire fact: inbox
217
+ * ids carry the literal 'inbox' (either exactly or inside an internal id),
218
+ * while regular projects are hex ids.
219
+ * @param projectId - the project id.
220
+ * @returns true for inbox ids.
221
+ */
222
+ export function isInboxProject(projectId: string): boolean {
223
+ return projectId === 'inbox' || projectId.includes('inbox')
224
+ }
package/src/index.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `dsh-ticktick` — the TickTick (Dida365) task bridge for DeepSeek Harness.
3
+ *
4
+ * Host half: mounts the `ticktick` Remote service (status plus the seven
5
+ * task operations over the official TickTick MCP endpoint), registers the
6
+ * eight curated `ticktick_*` tools on the shared service, installs the
7
+ * `ticktick` settings namespace (token / token file / endpoint / protected
8
+ * ids) that the browser settings card edits, and announces the tool set in
9
+ * one system-prompt section.
10
+ *
11
+ * The token is re-read per request: the settings card's secret field wins,
12
+ * then the token file (`$DSH_HOME/.ticktick-token` by default). Writing the
13
+ * file activates the bridge without a config change.
14
+ *
15
+ * Function plugin — no default export (the Loader unwraps
16
+ * `exports.default ?? exports`).
17
+ *
18
+ * @module dsh-ticktick
19
+ */
20
+
21
+ import { existsSync, readFileSync } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { join } from 'node:path'
24
+ import type { Context } from '@deepseek-ai/cordis'
25
+ import type {} from '@deepseek-ai/dsh-tools'
26
+ import type {} from '@deepseek-ai/dsh-system-prompt'
27
+ // Type-only: activates the `ctx.settings` Context merge.
28
+ import type {} from '@deepseek-ai/dsh-settings'
29
+ import z from '@deepseek-ai/schemastery'
30
+ import { DEFAULT_MCP_URL, resolveConfig, type Config, type ResolvedConfig } from './config.ts'
31
+ import { TicktickService, type TicktickServiceConfig } from './service.ts'
32
+ import { buildTicktickTools } from './tools.ts'
33
+ import { TICKTICK_SETTINGS_NS, type TicktickSettings } from './wire.ts'
34
+
35
+ export const name = 'ticktick'
36
+
37
+ /** Hard services: the tool registry and the prompt assembly. `settings` is an optional child. */
38
+ export const inject = ['tools', 'systemPrompt']
39
+
40
+ export { Config, resolveConfig, DEFAULT_MCP_URL, DEFAULT_TOOL_CALL_TIMEOUT_MS } from './config.ts'
41
+ export { TicktickService } from './service.ts'
42
+ export type { TicktickServiceConfig } from './service.ts'
43
+ export { buildTicktickTools } from './tools.ts'
44
+ export { McpStreamableClient, parseMcpMessage } from './mcp.ts'
45
+ export type { McpCallResult, McpClientFace, McpTool } from './mcp.ts'
46
+ export { isInboxProject, normalizeProjects, normalizeTasks, resolveTools } from './domain.ts'
47
+ export type { TicktickProject, TicktickTask, ToolResolver } from './domain.ts'
48
+ export type * from './wire.ts'
49
+
50
+ /** Settings namespace the browser card edits (paired by this exact key). */
51
+ export const SETTINGS_NS = TICKTICK_SETTINGS_NS
52
+
53
+ /** Schemastery schema for the settings namespace (the card renders this). */
54
+ export const TicktickSettingsSchema: z<TicktickSettings> = z.object({
55
+ token: z.string().role('secret').default(''),
56
+ tokenFile: z.string().default(''),
57
+ mcpUrl: z.string().default(DEFAULT_MCP_URL),
58
+ protectedTaskIds: z.array(z.string()).default([]),
59
+ })
60
+
61
+ /** Default token file: `<DSH_HOME>/.ticktick-token` (or `~/.dsh/.ticktick-token`). */
62
+ function defaultTokenFile(): string {
63
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), '.ticktick-token')
64
+ }
65
+
66
+ /**
67
+ * Resolve the current Bearer token: the settings card's secret wins, then
68
+ * the DIDA365_TOKEN environment variable, then the configured token file.
69
+ * @param settings - current settings layer.
70
+ * @param resolved - loader config.
71
+ * @returns the token, or `null` when unconfigured.
72
+ */
73
+ export function resolveToken(settings: TicktickSettings, resolved: ResolvedConfig): string | null {
74
+ if (settings.token !== '') return settings.token
75
+ const env = process.env.DIDA365_TOKEN
76
+ if (env !== undefined && env.trim() !== '') return env.trim()
77
+ const file = settings.tokenFile !== ''
78
+ ? settings.tokenFile
79
+ : (resolved.tokenFile !== '' ? resolved.tokenFile : defaultTokenFile())
80
+ if (!existsSync(file)) return null
81
+ const token = readFileSync(file, 'utf8').trim()
82
+ return token === '' ? null : token
83
+ }
84
+
85
+ /**
86
+ * Mount the bridge: the settings namespace, the Remote service, the eight
87
+ * tools, and the usage announcement.
88
+ *
89
+ * @param ctx - context carrying tools + systemPrompt.
90
+ * @param config - raw loader config; defaults applied through {@link resolveConfig}.
91
+ */
92
+ export async function apply(ctx: Context, config: Config | undefined): Promise<void> {
93
+ const resolved = resolveConfig(config)
94
+ let settingsSource: () => TicktickSettings = () => ({
95
+ token: '',
96
+ tokenFile: resolved.tokenFile,
97
+ mcpUrl: resolved.mcpUrl,
98
+ protectedTaskIds: [...resolved.protectedTaskIds],
99
+ })
100
+ let service: TicktickService | undefined
101
+
102
+ ctx.inject(['settings'], (scope) => {
103
+ scope.settings.installSection(ctx, SETTINGS_NS, TicktickSettingsSchema, {
104
+ token: '',
105
+ tokenFile: resolved.tokenFile,
106
+ mcpUrl: resolved.mcpUrl,
107
+ protectedTaskIds: [...resolved.protectedTaskIds],
108
+ }, {
109
+ validate: (value) => {
110
+ if (value.mcpUrl.trim() === '') throw new Error('dsh-ticktick: mcpUrl must not be empty')
111
+ },
112
+ setSource: (source) => { settingsSource = source },
113
+ onChange: () => { service?.reset() },
114
+ })
115
+ })
116
+
117
+ const serviceConfig: TicktickServiceConfig = {
118
+ getToken: () => resolveToken(settingsSource(), resolved),
119
+ mcpUrl: resolved.mcpUrl,
120
+ toolCallTimeoutMs: resolved.toolCallTimeoutMs,
121
+ protectedTaskIds: resolved.protectedTaskIds,
122
+ pins: resolved.tools,
123
+ }
124
+
125
+ // The service has no injects beyond its own registration; capture the
126
+ // mounted instance for the tools and the settings change hook.
127
+ await ctx.plugin(TicktickService, serviceConfig)
128
+ service = ctx.get('ticktick') as TicktickService
129
+
130
+ for (const tool of buildTicktickTools(service)) {
131
+ ctx.effect(() => ctx.tools.register(tool), `dsh-ticktick: ${tool.name} tool`)
132
+ }
133
+
134
+ ctx.effect(() => ctx.systemPrompt.section({
135
+ name: 'ticktick:usage',
136
+ order: 350,
137
+ text: 'TickTick (Dida365) tasks are available through the ticktick_* tools: ticktick_status, ticktick_lists, ticktick_tasks, ticktick_add, ticktick_complete, ticktick_delete, ticktick_due, ticktick_reorder, ticktick_completed, ticktick_search, ticktick_batch_add. The Session header also shows a TickTick panel for browsing lists (undone/completed views, full-text search), adding, completing, deleting, setting due dates, and drag reordering.',
138
+ }), 'dsh-ticktick: system prompt section')
139
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Minimal Model Context Protocol streamable-HTTP client for the TickTick
3
+ * endpoint: initialize, tools/list, and tools/call, accepting SSE or JSON
4
+ * responses, propagating the session id and the negotiated protocol version,
5
+ * honoring Retry-After on 429, and surfacing tool-level `isError` failures
6
+ * as thrown errors.
7
+ *
8
+ * @module dsh-ticktick/mcp
9
+ */
10
+
11
+ /** One tool as advertised by `tools/list`. */
12
+ export interface McpTool {
13
+ readonly name: string
14
+ readonly description?: string
15
+ readonly inputSchema?: {
16
+ readonly properties?: Readonly<Record<string, { readonly type?: string }>>
17
+ readonly required?: readonly string[]
18
+ }
19
+ }
20
+
21
+ /** Settled `tools/call` result (text blocks or structured content). */
22
+ export interface McpCallResult {
23
+ readonly content?: readonly { readonly type: string; readonly text?: string }[]
24
+ readonly structuredContent?: unknown
25
+ }
26
+
27
+ /** Structural face of the MCP client the service drives (test seam). */
28
+ export interface McpClientFace {
29
+ initialize(): Promise<void>
30
+ listTools(): Promise<readonly McpTool[]>
31
+ callTool(name: string, args: Record<string, unknown>): Promise<McpCallResult>
32
+ }
33
+
34
+ /** One JSON-RPC request payload. */
35
+ interface McpRequest {
36
+ readonly jsonrpc: '2.0'
37
+ readonly id?: number
38
+ readonly method: string
39
+ readonly params?: unknown
40
+ }
41
+
42
+ /**
43
+ * Parse one MCP response body: the last SSE `data:` payload, or plain JSON.
44
+ * @param body - raw response text.
45
+ * @returns the parsed message, or `undefined` for an empty body.
46
+ */
47
+ export function parseMcpMessage(body: string): unknown | undefined {
48
+ if (body.trim() === '') return undefined
49
+ const dataLines = body.split(/\r?\n/).filter(line => line.startsWith('data:'))
50
+ if (dataLines.length > 0) {
51
+ const last = dataLines[dataLines.length - 1]!.slice(5).trim()
52
+ return JSON.parse(last) as unknown
53
+ }
54
+ return JSON.parse(body) as unknown
55
+ }
56
+
57
+ /** Fetch face, injectable for tests. */
58
+ export type FetchFace = (url: string, init?: RequestInit) => Promise<Response>
59
+
60
+ /**
61
+ * Streamable-HTTP MCP client with Bearer auth. Requests are simple POSTs;
62
+ * notifications tolerate empty responses. A 429 response is retried once
63
+ * after the server's Retry-After delay (bounded to one minute).
64
+ */
65
+ export class McpStreamableClient implements McpClientFace {
66
+ private sessionId: string | undefined
67
+ private protocolVersion: string | undefined
68
+ private nextId = 1
69
+
70
+ /**
71
+ * @param url - MCP server URL (e.g. https://mcp.dida365.com).
72
+ * @param token - Bearer token (the TickTick API 口令).
73
+ * @param timeoutMs - per-request deadline in milliseconds.
74
+ * @param fetchFn - fetch implementation (defaults to global fetch).
75
+ */
76
+ constructor(
77
+ private readonly url: string,
78
+ private readonly token: string,
79
+ private readonly timeoutMs: number,
80
+ private readonly fetchFn: FetchFace = fetch,
81
+ ) {}
82
+
83
+ /** Perform the initialize handshake and send the initialized notification. */
84
+ async initialize(): Promise<void> {
85
+ const response = await this.send({
86
+ jsonrpc: '2.0',
87
+ id: this.nextId++,
88
+ method: 'initialize',
89
+ params: {
90
+ protocolVersion: '2025-03-26',
91
+ capabilities: {},
92
+ clientInfo: { name: 'dsh-ticktick', version: '0.1.0' },
93
+ },
94
+ }, false)
95
+ const body = await response.text()
96
+ if (response.status !== 200) throw new Error(`ticktick MCP initialize: HTTP ${response.status}: ${body.slice(0, 200)}`)
97
+ this.sessionId = response.headers.get('mcp-session-id') ?? undefined
98
+ const message = parseMcpMessage(body) as { readonly result?: { readonly protocolVersion?: string } } | undefined
99
+ this.protocolVersion = message?.result?.protocolVersion
100
+ if (this.protocolVersion === undefined) {
101
+ throw new Error(`ticktick MCP initialize: unexpected response: ${body.slice(0, 200)}`)
102
+ }
103
+ await this.post({ jsonrpc: '2.0', method: 'notifications/initialized' })
104
+ }
105
+
106
+ /** Fetch the server's tool list. */
107
+ async listTools(): Promise<readonly McpTool[]> {
108
+ const message = await this.post({ jsonrpc: '2.0', id: this.nextId++, method: 'tools/list', params: {} }) as
109
+ | { readonly result?: { readonly tools?: readonly McpTool[] } }
110
+ | undefined
111
+ return message?.result?.tools ?? []
112
+ }
113
+
114
+ /**
115
+ * Invoke one tool. A tool-level failure (the MCP result flag `isError`)
116
+ * throws with the server's message — TickTick reports validation failures
117
+ * that way, not as JSON-RPC errors.
118
+ * @param name - the raw MCP tool name.
119
+ * @param args - the tool arguments object.
120
+ * @returns the settled result (text blocks or structured content).
121
+ */
122
+ async callTool(name: string, args: Record<string, unknown>): Promise<McpCallResult> {
123
+ const message = await this.post({
124
+ jsonrpc: '2.0',
125
+ id: this.nextId++,
126
+ method: 'tools/call',
127
+ params: { name, arguments: args },
128
+ }) as { readonly result?: McpCallResult & { readonly isError?: boolean }; readonly error?: unknown } | undefined
129
+ if (message?.error !== undefined) {
130
+ throw new Error(`ticktick MCP tools/call ${name}: ${JSON.stringify(message.error)}`)
131
+ }
132
+ if (message?.result?.isError === true) {
133
+ const text = message.result.content
134
+ ?.filter(block => block.type === 'text' && block.text !== undefined)
135
+ .map(block => block.text)
136
+ .join('\n')
137
+ throw new Error(`ticktick MCP tools/call ${name}: ${text ?? 'unknown tool error'}`)
138
+ }
139
+ return message?.result ?? {}
140
+ }
141
+
142
+ /**
143
+ * Send one request and parse the response message: a 429 is retried once
144
+ * after Retry-After, any other non-2xx status throws, and an empty body
145
+ * (notification success) parses to `undefined`.
146
+ * @param request - JSON-RPC payload.
147
+ * @param carryHeaders - include session/protocol headers (false for initialize).
148
+ * @returns the parsed message, or `undefined` for an empty body.
149
+ */
150
+ private async post(request: McpRequest, carryHeaders = true): Promise<unknown | undefined> {
151
+ const response = await this.send(request, carryHeaders)
152
+ const body = await response.text()
153
+ if (response.status !== 200 && response.status !== 202) {
154
+ throw new Error(`ticktick MCP request failed: HTTP ${response.status}: ${body.slice(0, 200)}`)
155
+ }
156
+ return parseMcpMessage(body)
157
+ }
158
+
159
+ /**
160
+ * Send one request, retrying once after Retry-After when the server
161
+ * answers 429.
162
+ * @param request - JSON-RPC payload.
163
+ * @param carryHeaders - include session/protocol headers (false for initialize).
164
+ * @returns the raw response (body not yet read).
165
+ */
166
+ private async send(request: McpRequest, carryHeaders = true): Promise<Response> {
167
+ const headers: Record<string, string> = {
168
+ 'content-type': 'application/json',
169
+ accept: 'application/json, text/event-stream',
170
+ authorization: `Bearer ${this.token}`,
171
+ }
172
+ if (carryHeaders) {
173
+ if (this.sessionId !== undefined) headers['mcp-session-id'] = this.sessionId
174
+ if (this.protocolVersion !== undefined) headers['mcp-protocol-version'] = this.protocolVersion
175
+ }
176
+ const signal = AbortSignal.timeout(this.timeoutMs)
177
+ const send = async (): Promise<Response> =>
178
+ await this.fetchFn(this.url, { method: 'POST', headers, body: JSON.stringify(request), signal })
179
+ let response = await send()
180
+ if (response.status === 429) {
181
+ const retryAfter = parseRetryAfter(response.headers.get('retry-after'))
182
+ await delay(retryAfter, signal)
183
+ response = await send()
184
+ }
185
+ return response
186
+ }
187
+ }
188
+
189
+ /** Parse a Retry-After header (seconds or HTTP date), bounded to 60s. */
190
+ function parseRetryAfter(value: string | null): number {
191
+ if (value === null) return 1_000
192
+ const seconds = Number(value)
193
+ if (Number.isFinite(seconds)) return Math.min(Math.max(seconds * 1_000, 0), 60_000)
194
+ const epoch = Date.parse(value)
195
+ if (Number.isFinite(epoch)) return Math.min(Math.max(epoch - Date.now(), 0), 60_000)
196
+ return 1_000
197
+ }
198
+
199
+ /** Abortable delay (bounded by the request timeout signal). */
200
+ function delay(ms: number, signal: AbortSignal): Promise<void> {
201
+ if (ms <= 0) return Promise.resolve()
202
+ return new Promise((resolve, reject) => {
203
+ const timer = setTimeout(() => {
204
+ signal.removeEventListener('abort', onAbort)
205
+ resolve()
206
+ }, ms)
207
+ const onAbort = (): void => {
208
+ clearTimeout(timer)
209
+ reject(signal.reason)
210
+ }
211
+ signal.addEventListener('abort', onAbort, { once: true })
212
+ })
213
+ }