@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.
- package/CHANGELOG.md +24 -0
- package/LICENSE +201 -0
- package/README.es.md +41 -0
- package/README.hi.md +33 -0
- package/README.md +111 -0
- package/README.pt.md +41 -0
- package/README.zh.md +111 -0
- package/cordis.patch.yml +35 -0
- package/lib/client.js +6233 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1349 -0
- package/lib/typert.host.js +26 -0
- package/lib/types/client/TicktickAction.d.ts +29 -0
- package/lib/types/client/TicktickAction.d.ts.map +1 -0
- package/lib/types/client/TicktickSettingsCard.d.ts +27 -0
- package/lib/types/client/TicktickSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +34 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/dates.d.ts +26 -0
- package/lib/types/client/dates.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +61 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/order.d.ts +25 -0
- package/lib/types/client/order.d.ts.map +1 -0
- package/lib/types/client/remote.d.ts +260 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/styles.d.ts +13 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/domain.d.ts +70 -0
- package/lib/types/domain.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mcp.d.ts +94 -0
- package/lib/types/mcp.d.ts.map +1 -0
- package/lib/types/service.d.ts +160 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/tools.d.ts +18 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/typert.host.d.ts +216 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/wire.d.ts +561 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/lib/wire-C-vDxxnC.js +5288 -0
- package/package.json +187 -0
- package/probes/lib.mjs +65 -0
- package/probes/probe-bootstrap.mjs +10 -0
- package/probes/probe-crud.mjs +15 -0
- package/probes/probe-due.mjs +28 -0
- package/probes/probe-queries.mjs +18 -0
- package/probes/probe-reorder.mjs +24 -0
- package/src/client/TicktickAction.tsx +356 -0
- package/src/client/TicktickSettingsCard.tsx +182 -0
- package/src/client/api.ts +56 -0
- package/src/client/dates.ts +61 -0
- package/src/client/index.ts +119 -0
- package/src/client/locales.ts +113 -0
- package/src/client/order.ts +37 -0
- package/src/client/remote.ts +76 -0
- package/src/client/styles.ts +48 -0
- package/src/config.ts +136 -0
- package/src/domain.ts +224 -0
- package/src/index.ts +139 -0
- package/src/mcp.ts +213 -0
- package/src/service.ts +403 -0
- package/src/tools.ts +336 -0
- package/src/typert.host.ts +25 -0
- 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
|
+
}
|