viviscape-mcp 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,253 +1,294 @@
1
- # viviscape-mcp
2
-
3
- MCP server for the [ViviScape](https://viviscape.io) API - exposes CRM, projects, companies, tasks, time tracking, notes, and insights as tools for Claude and other MCP clients.
4
-
5
- ## Installation
6
-
7
- No install needed - run via `npx`:
8
-
9
- ```bash
10
- npx viviscape-mcp
11
- ```
12
-
13
- Or install globally:
14
-
15
- ```bash
16
- npm install -g viviscape-mcp
17
- ```
18
-
19
- ## Sign in
20
-
21
- Authentication is per-user, the same browser flow the ViviScape CLI uses - no
22
- API keys. Sign in once:
23
-
24
- ```bash
25
- npx viviscape-mcp login
26
- ```
27
-
28
- A browser tab opens on ViviScape Work; confirm **Allow access** and the session
29
- is stored in Windows Credential Manager (or `~/.viviscape/mcp-credentials.json`
30
- on macOS/Linux). Every tool call then runs as *you*: your user id, account,
31
- role, and plan come from the session, so tools see exactly the data you can see
32
- in the app.
33
-
34
- ```bash
35
- npx viviscape-mcp status # who am I, which account, role, plan
36
- npx viviscape-mcp logout # clear the stored session
37
- ```
38
-
39
- If you already ran `vs auth login` with the [ViviScape CLI](https://viviscape.io),
40
- that session is picked up automatically. Inside an MCP client you can also call
41
- the `auth_login`, `auth_status`, and `auth_logout` tools. Sessions expire; when
42
- one does, tools report it and you re-run `login`.
43
-
44
- ## Configuration
45
-
46
- | Variable | Required | Default | Description |
47
- |----------|----------|---------|-------------|
48
- | `VIVISCAPE_BASE_URL` | no | `https://work.viviscape.io` | ViviScape Work base URL |
49
-
50
- ## Claude Desktop / Claude Code
51
-
52
- Add to your MCP client config (e.g. `claude_desktop_config.json` or `.mcp.json`):
53
-
54
- ```json
55
- {
56
- "mcpServers": {
57
- "viviscape": {
58
- "command": "npx",
59
- "args": ["-y", "viviscape-mcp"]
60
- }
61
- }
62
- }
63
- ```
64
-
65
- ## OpenAI Codex CLI
66
-
67
- Codex accepts stdio MCP servers directly. Add to `~/.codex/config.toml`:
68
-
69
- ```toml
70
- [mcp_servers.viviscape]
71
- command = "npx"
72
- args = ["-y", "viviscape-mcp"]
73
- ```
74
-
75
- ## ChatGPT (Developer Mode / Apps SDK)
76
-
77
- ChatGPT only accepts **remote HTTP** MCP servers - it does not run local stdio
78
- processes. Bridge this server to an HTTP endpoint with
79
- [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) or
80
- [`supergateway`](https://www.npmjs.com/package/supergateway), then expose it
81
- publicly (Cloudflare Tunnel, ngrok, or deploy to a host).
82
-
83
- Example with `supergateway`:
84
-
85
- ```bash
86
- npx viviscape-mcp login # once, on the host running the gateway
87
- npx -y supergateway --stdio "npx -y viviscape-mcp" --port 8000
88
- ```
89
-
90
- The gateway shares the host's stored session, so everyone reaching that endpoint
91
- acts as the signed-in user - put your own auth in front of it.
92
-
93
- Expose `http://localhost:8000/sse` publicly, then in ChatGPT:
94
-
95
- **Settings -> Connectors -> Create -> Custom MCP server**
96
- - **URL:** `https://your-public-host/sse`
97
- - **Auth:** none (or bearer, depending on your gateway)
98
-
99
- ## Tools
100
-
101
- The server exposes tools across these domains:
102
-
103
- - **Prospects** - `prospect_add`, `prospect_get`, `prospect_update`, `prospect_query`, follow-ups, notes
104
- - **Clients** - `client_add`, `client_get`, `client_get_by_email`, `clients_by_company`
105
- - **Companies** - `company_add`, `company_list`, `company_update`
106
- - **Projects** - `project_get`, `project_list`, `project_list_active`, `project_staff`, `project_tasks`, `projects_by_company`
107
- - **Tasks** - `task_add`, `task_get`, `task_update`, `tasks_open`, `tasks_pending`, `tasks_by_company`, `tasks_by_milestone`
108
- - **Task comments** - `task_comments`, `task_comment_get`, `task_comment_add`, `task_comment_update`, `task_comment_remove`, `task_mark_read`
109
- - **Task assignment** - `task_assignee_add`, `task_assignee_remove`, `task_set_leader`, `task_set_attention`, `task_delete`, `task_merge`
110
- - **Task tags** - `task_tags` (list/add/remove), `tasks_by_tag` (tasks carrying a label, or the account tag vocabulary), and a `tags` list on `task_add`
111
- - **Task references** - `task_references` (list/add/remove), `task_search` (find a task by keyword or id)
112
- - **Milestones** - `milestone_list`, `milestones_active`, `milestones_by_company`, `milestones_by_user`, `milestone_get`, `milestone_add`, `milestone_update`, `milestone_clone`, `milestone_remove`
113
- - **Project writes** - `project_add`, `project_update`, `project_set_status`, `project_user_add`, `project_user_remove`
114
- - **Reference** - `enums` (the status, priority, and group-type values the platform accepts)
115
- - **Lookup** - `services` (resolve service_id to a name), `user_lookup` (id, email, team, groups)
116
- - **Bulk** - `tasks_bulk_add` (many tasks in one request)
117
- - **Time logs** - `timelog_add`, `timelog_update`
118
- - **Notes** - `note_add`, `note_get`, `note_update`, `note_remove`, `notes_mine`, `notes_query`, `notes_account`, `note_revisions`
119
- - **Note filing** - `note_companies`, `note_users`, `note_tags`, `note_attachments`, `notebook`, `notebook_users` (each takes an `action`)
120
- - **Files** - `project_files` (list/upload/update/delete/download), `task_files`
121
- - **Insights** - hours by person/service/project, AI summary, person stats, time totals
122
- - **Account** - `account_info`, `account_services`, `account_users`
123
- - **Auth** - `auth_login`, `auth_status`, `auth_logout`
124
-
125
- ### Status and priority values
126
-
127
- Use the `enums` tool rather than guessing. The platform's vocabularies are:
128
-
129
- | field | values |
130
- |---|---|
131
- | task status | backlog, new, research, discussion, inprogress, pendingreview, inreview, testing, waitingcustomer, waitingteammember, onhold, cancelled, completed |
132
- | priority | low, moderate, important, urgent, critical |
133
- | project status | new, inprogress, complete |
134
- | prospect status | new, firstcontact, negotiation, pending, won, lost, spam |
135
-
136
- Two traps: tasks complete as `completed` while projects complete as
137
- `complete`, and priority is *not* low/medium/high. Task and project tools
138
- validate these with `z.enum`, so an invalid value is rejected before it reaches
139
- the API.
140
-
141
- ### Task tags
142
-
143
- `task_tags` lists, adds, and removes the tags shown on the task board, and
144
- `task_add` takes a `tags` list so a generated task lands already categorised
145
- (handy for marking agent-written work).
146
-
147
- Verified behaviour, so callers do not have to discover it:
148
-
149
- - Labels are stored upper-case, and `add` is idempotent per label - re-adding
150
- one returns the existing tag, not a duplicate.
151
- - `add` returns the persisted tag with its `tag_id`; `remove` takes that
152
- `tag_id`, not the label, and answers `true` (unlike `note_tags`, whose remove
153
- answers `false` even on success).
154
- - `color` is any hex string, defaulting to the board palette's first entry
155
- (`#00325e`).
156
- - `task_tags` is per task, and list rows from `tasks_open` / `project_tasks`
157
- carry `tags: null`, so read one task's tags with `task_tags` or `task_get`.
158
- - `tasks_by_tag` goes the other way, from a label to the tasks carrying it.
159
- Labels match trimmed and case insensitively; `match: "any"` (default) returns
160
- a task carrying at least one label, `match: "all"` only tasks carrying every
161
- one. An optional `status` list narrows further. Hits are light rows that
162
- carry the task's whole tag set, so the other labels arrive with the hit.
163
- Called with no `tags`, it returns the account's tag vocabulary instead -
164
- every distinct label with the number of tasks using it.
165
- - A tag failure inside `task_add` never fails the create: the task is returned
166
- with the per-tag error recorded in its `tags` array.
167
-
168
- ### Task references
169
-
170
- `task_references` links related tasks to each other - a duplicate, a blocker,
171
- the ticket a task came from - and `task_search` finds the id to link when only
172
- a keyword is known.
173
-
174
- Verified behaviour:
175
-
176
- - Links are **symmetric**. Adding A to B makes the pair visible from both
177
- tasks, so link once; mirroring it by hand just returns the same row.
178
- - `list` rows describe the *other* task: `related_task_id` plus its title,
179
- status, project and company, so a reference list needs no follow-up
180
- `task_get`.
181
- - `add` is idempotent per pair (in either direction) and returns the persisted
182
- link with its `reference_id`; `remove` takes that `reference_id`, not a task
183
- id, and answers `true`.
184
- - Both tasks must belong to the signed-in account; a cross-account id comes
185
- back as an empty link rather than an error.
186
- - `task_search` matches the task title, the description, and - for a numeric
187
- query - the task id itself. It covers tickets too, so it is the one keyword
188
- route that reaches ticket rows `tasks_open` never returns.
189
-
190
- ### Task notes
191
-
192
- `task_update` takes a `notes` field: the free-form working notes on the task's
193
- Overview tab, stored as HTML by the rich-text editor. It **replaces** the field
194
- outright, so read the current value with `task_get` and send the merged text
195
- rather than only the new lines.
196
-
197
- ### Repeat-safe creates
198
-
199
- No create route accepts an idempotency key, so a retried agent step silently
200
- creates a second record. `task_add`, `note_add`, `timelog_add`,
201
- `prospect_add`, and `tasks_bulk_add` accept an optional
202
- `idempotency_key`: calling again with the same key replays the first result
203
- instead of writing again.
204
-
205
- The record is local (`~/.viviscape/mcp-idempotency.json`, 7-day expiry), so it
206
- stops the common case -- the same agent retrying the same step -- and does not
207
- make the server idempotent. Two machines running the same plan will still
208
- duplicate.
209
-
210
- ### Delta queries
211
-
212
- List tools accept `updated_since` (ISO 8601) and return only rows touched at
213
- or after that time, so a recurring agent does not re-read the whole working set.
214
- It is filtered in-process (no list route supports a modified-since filter), and
215
- rows carrying no usable timestamp are kept rather than dropped.
216
-
217
- ### Paging and field selection
218
-
219
- List tools (`tasks_open`, `project_list_active`, `project_tasks`, and the rest)
220
- return a page of trimmed rows rather than every column of every row. A task row
221
- carries ~94 columns and a project row 87, most of them irrelevant to project
222
- work, so each list tool accepts:
223
-
224
- - `fields` - columns to return; omit for a curated default, or pass `["all"]`
225
- - `limit` / `offset` - page window, default 50 rows
226
-
227
- Task rows also carry computed `due_date`, `due_in_days` (negative when
228
- overdue), and `overdue`, derived from the task's `end` date. Read those rather
229
- than the API's `deadline` string, which does not track the due date -- it
230
- reported "In 2 Days" for a task 39 days overdue. `deadline` is excluded from the
231
- default field set; ask for it explicitly if you need to see what the UI shows.
232
-
233
- Responses are wrapped as `{ total, returned, offset, next_offset, fields, items }`
234
- so a caller can tell when more rows exist. Task lists also accept
235
- `company_id`, `project_id`, `assignee_id`, `status`, `priority`,
236
- `due_before`, `due_after`, and `search`; `tasks_open` pushes `company_id`
237
- and `only_mine` to the server and filters the rest in-process.
238
-
239
- In practice this took `tasks_open` from 97 KB to 6.4 KB and
240
- `project_list_active` from 375 KB to 19 KB.
241
-
242
- ## Development
243
-
244
- ```bash
245
- npm install
246
- npm run dev # run with tsx, hot reload
247
- npm run build # compile to dist/
248
- npm start # run compiled
249
- ```
250
-
251
- ## License
252
-
253
- MIT
1
+ # viviscape-mcp
2
+
3
+ MCP server for the [ViviScape](https://viviscape.io) API - exposes CRM, projects, companies, tasks, time tracking, notes, and insights as tools for Claude and other MCP clients.
4
+
5
+ ## Installation
6
+
7
+ No install needed - run via `npx`:
8
+
9
+ ```bash
10
+ npx viviscape-mcp
11
+ ```
12
+
13
+ Or install globally:
14
+
15
+ ```bash
16
+ npm install -g viviscape-mcp
17
+ ```
18
+
19
+ ## Sign in
20
+
21
+ Authentication is per-user, the same browser flow the ViviScape CLI uses - no
22
+ API keys. Sign in once:
23
+
24
+ ```bash
25
+ npx viviscape-mcp login
26
+ ```
27
+
28
+ A browser tab opens on ViviScape Work; confirm **Allow access** and the session
29
+ is stored in Windows Credential Manager (or `~/.viviscape/mcp-credentials.json`
30
+ on macOS/Linux). Every tool call then runs as *you*: your user id, account,
31
+ role, and plan come from the session, so tools see exactly the data you can see
32
+ in the app.
33
+
34
+ ```bash
35
+ npx viviscape-mcp status # who am I, which account, role, plan
36
+ npx viviscape-mcp logout # clear the stored session
37
+ ```
38
+
39
+ If you already ran `vs auth login` with the [ViviScape CLI](https://viviscape.io),
40
+ that session is picked up automatically. Inside an MCP client you can also call
41
+ the `auth_login`, `auth_status`, and `auth_logout` tools. Sessions expire; when
42
+ one does, tools report it and you re-run `login`.
43
+
44
+ ## Configuration
45
+
46
+ | Variable | Required | Default | Description |
47
+ |----------|----------|---------|-------------|
48
+ | `VIVISCAPE_BASE_URL` | no | `https://work.viviscape.io` | ViviScape Work base URL |
49
+
50
+ ## Claude Desktop / Claude Code
51
+
52
+ Add to your MCP client config (e.g. `claude_desktop_config.json` or `.mcp.json`):
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "viviscape": {
58
+ "command": "npx",
59
+ "args": ["-y", "viviscape-mcp"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ## OpenAI Codex CLI
66
+
67
+ Codex accepts stdio MCP servers directly. Add to `~/.codex/config.toml`:
68
+
69
+ ```toml
70
+ [mcp_servers.viviscape]
71
+ command = "npx"
72
+ args = ["-y", "viviscape-mcp"]
73
+ ```
74
+
75
+ ## ChatGPT (Developer Mode / Apps SDK)
76
+
77
+ ChatGPT only accepts **remote HTTP** MCP servers - it does not run local stdio
78
+ processes. Bridge this server to an HTTP endpoint with
79
+ [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) or
80
+ [`supergateway`](https://www.npmjs.com/package/supergateway), then expose it
81
+ publicly (Cloudflare Tunnel, ngrok, or deploy to a host).
82
+
83
+ Example with `supergateway`:
84
+
85
+ ```bash
86
+ npx viviscape-mcp login # once, on the host running the gateway
87
+ npx -y supergateway --stdio "npx -y viviscape-mcp" --port 8000
88
+ ```
89
+
90
+ The gateway shares the host's stored session, so everyone reaching that endpoint
91
+ acts as the signed-in user - put your own auth in front of it.
92
+
93
+ Expose `http://localhost:8000/sse` publicly, then in ChatGPT:
94
+
95
+ **Settings -> Connectors -> Create -> Custom MCP server**
96
+ - **URL:** `https://your-public-host/sse`
97
+ - **Auth:** none (or bearer, depending on your gateway)
98
+
99
+ ## Tools
100
+
101
+ The server exposes tools across these domains:
102
+
103
+ - **Prospects** - `prospect_add`, `prospect_get`, `prospect_update`, `prospect_query`, follow-ups, notes
104
+ - **Clients** - `client_add`, `client_get`, `client_get_by_email`, `clients_by_company`
105
+ - **Companies** - `company_add`, `company_list`, `company_update`
106
+ - **Projects** - `project_get`, `project_list`, `project_list_active`, `project_staff`, `project_tasks`, `projects_by_company`
107
+ - **Tasks** - `task_add`, `task_get`, `task_update`, `tasks_open`, `tasks_pending`, `tasks_by_company`, `tasks_by_milestone`
108
+ - **Task comments** - `task_comments`, `task_comment_get`, `task_comment_add`, `task_comment_update`, `task_comment_remove`, `task_mark_read`
109
+ - **Task assignment** - `task_assignee_add`, `task_assignee_remove`, `task_set_leader`, `task_set_attention`, `task_delete`, `task_merge`, `task_complete`
110
+ - **Task tags** - `task_tags` (list/add/remove), `tasks_by_tag` (tasks carrying a label, or the account tag vocabulary), and a `tags` list on `task_add`
111
+ - **Task references** - `task_references` (list/add/remove), `task_search` (find a task by keyword or id)
112
+ - **Milestones** - `milestone_list`, `milestones_active`, `milestones_by_company`, `milestones_by_user`, `milestone_get`, `milestone_add`, `milestone_update`, `milestone_clone`, `milestone_remove`
113
+ - **Project writes** - `project_add`, `project_update`, `project_set_status`, `project_user_add`, `project_user_remove`
114
+ - **Reference** - `enums` (the status, priority, and group-type values the platform accepts)
115
+ - **Lookup** - `services` (resolve service_id to a name), `user_lookup` (id, email, team, groups)
116
+ - **Bulk** - `tasks_bulk_add` (many tasks in one request)
117
+ - **Time logs** - `timelog_add`, `timelog_update`, `task_log_time` (quick log against a task, or a note-only entry)
118
+ - **Personal logs** - `personal_logs`, `personal_log_add`, `personal_log_remove` (service work that belongs to no task)
119
+ - **Time clock** - `timeclock_punch` (in/out), `timeclock_history`
120
+ - **Tickets** - `tickets_list` (the open, closed, or complete ticket queue; enterprise plan)
121
+ - **Notes** - `note_add`, `note_get`, `note_update`, `note_remove`, `notes_mine`, `notes_query`, `notes_account`, `note_revisions`
122
+ - **Second Brain** - `kb_search` (semantic search over tickets, plans, notes, products, resolutions and SOPs), `kb_sop` (list/get/save an SOP), `kb_status`, `kb_reindex`
123
+ - **Note filing** - `note_companies`, `note_users`, `note_tags`, `note_attachments`, `notebook`, `notebook_users` (each takes an `action`)
124
+ - **Files** - `project_files` (list/upload/update/delete/download), `task_files`
125
+ - **Insights** - hours by person/service/project, AI summary, person stats, time totals
126
+ - **Account** - `account_info`, `account_services`, `account_users`
127
+ - **Auth** - `auth_login`, `auth_status`, `auth_logout`
128
+
129
+ ### Status and priority values
130
+
131
+ Use the `enums` tool rather than guessing. The platform's vocabularies are:
132
+
133
+ | field | values |
134
+ |---|---|
135
+ | task status | backlog, new, research, discussion, inprogress, pendingreview, inreview, testing, waitingcustomer, waitingteammember, onhold, cancelled, completed |
136
+ | priority | low, moderate, important, urgent, critical |
137
+ | project status | new, inprogress, complete |
138
+ | prospect status | new, firstcontact, negotiation, pending, won, lost, spam |
139
+
140
+ Two traps: tasks complete as `completed` while projects complete as
141
+ `complete`, and priority is *not* low/medium/high. Task and project tools
142
+ validate these with `z.enum`, so an invalid value is rejected before it reaches
143
+ the API.
144
+
145
+ ### Task tags
146
+
147
+ `task_tags` lists, adds, and removes the tags shown on the task board, and
148
+ `task_add` takes a `tags` list so a generated task lands already categorised
149
+ (handy for marking agent-written work).
150
+
151
+ Verified behaviour, so callers do not have to discover it:
152
+
153
+ - Labels are stored upper-case, and `add` is idempotent per label - re-adding
154
+ one returns the existing tag, not a duplicate.
155
+ - `add` returns the persisted tag with its `tag_id`; `remove` takes that
156
+ `tag_id`, not the label, and answers `true` (unlike `note_tags`, whose remove
157
+ answers `false` even on success).
158
+ - `color` is any hex string, defaulting to the board palette's first entry
159
+ (`#00325e`).
160
+ - `task_tags` is per task, and list rows from `tasks_open` / `project_tasks`
161
+ carry `tags: null`, so read one task's tags with `task_tags` or `task_get`.
162
+ - `tasks_by_tag` goes the other way, from a label to the tasks carrying it.
163
+ Labels match trimmed and case insensitively; `match: "any"` (default) returns
164
+ a task carrying at least one label, `match: "all"` only tasks carrying every
165
+ one. An optional `status` list narrows further. Hits are light rows that
166
+ carry the task's whole tag set, so the other labels arrive with the hit.
167
+ Called with no `tags`, it returns the account's tag vocabulary instead -
168
+ every distinct label with the number of tasks using it.
169
+ - A tag failure inside `task_add` never fails the create: the task is returned
170
+ with the per-tag error recorded in its `tags` array.
171
+
172
+ ### Task references
173
+
174
+ `task_references` links related tasks to each other - a duplicate, a blocker,
175
+ the ticket a task came from - and `task_search` finds the id to link when only
176
+ a keyword is known.
177
+
178
+ Verified behaviour:
179
+
180
+ - Links are **symmetric**. Adding A to B makes the pair visible from both
181
+ tasks, so link once; mirroring it by hand just returns the same row.
182
+ - `list` rows describe the *other* task: `related_task_id` plus its title,
183
+ status, project and company, so a reference list needs no follow-up
184
+ `task_get`.
185
+ - `add` is idempotent per pair (in either direction) and returns the persisted
186
+ link with its `reference_id`; `remove` takes that `reference_id`, not a task
187
+ id, and answers `true`.
188
+ - Both tasks must belong to the signed-in account; a cross-account id comes
189
+ back as an empty link rather than an error.
190
+ - `task_search` matches the task title, the description, and - for a numeric
191
+ query - the task id itself. It covers tickets too, so it is the one keyword
192
+ route that reaches ticket rows `tasks_open` never returns.
193
+
194
+ ### Task notes
195
+
196
+ `task_update` takes a `notes` field: the free-form working notes on the task's
197
+ Overview tab, stored as HTML by the rich-text editor. It **replaces** the field
198
+ outright, so read the current value with `task_get` and send the merged text
199
+ rather than only the new lines.
200
+
201
+ ### Task plans
202
+
203
+ `task_add` and `task_update` take a `plan` field: the plan of action for the
204
+ task or ticket -- the ordered steps an agent intends to take **before** it
205
+ starts, so the work is inspectable and the next agent can pick it up. It shows
206
+ up in the portal on the task's Plan tab, immediately left of Solution.
207
+
208
+ Read it back with `task_get`, or across a set of tasks by asking a list tool
209
+ for it (`fields: ["task_id", "task", "plan"]`) -- the default field set is
210
+ light and leaves it out. Like `notes`, it **replaces** the field outright, so
211
+ send the merged text rather than only the new steps. Omitting the argument
212
+ leaves an existing plan untouched.
213
+
214
+ The three long-text fields divide up as: `plan` = what will be done,
215
+ `notes` = the extended instructions and scenario, `solution` = the outcome.
216
+
217
+ ### Second Brain
218
+
219
+ `kb_search` reads the account knowledge base: the semantic index over tickets
220
+ (description, plan, notes, solution), notes, products, distilled resolutions and
221
+ SOPs. Consult it *before* planning work it answers whether the thing has been
222
+ solved here before.
223
+
224
+ Two traps:
225
+
226
+ - An empty result is **not** proof the knowledge is absent. Search answers with
227
+ no hits, not an error, when Second Brain is switched off for the account or
228
+ its AI token budget is inside the reserve. `kb_status` distinguishes the two.
229
+ - A `kb_sop` save is *queued* for indexing, not indexed on the spot, so it does
230
+ not turn up in `kb_search` until the next ingest tick.
231
+
232
+ Ticket and note knowledge is ingested automatically. An SOP is the only
233
+ knowledge an agent authors directly, so `kb_sop` save is how a lesson from one
234
+ ticket becomes reusable search `source_types: ["sop"]` first and update the
235
+ existing SOP rather than filing a near-duplicate.
236
+
237
+ ### Repeat-safe creates
238
+
239
+ No create route accepts an idempotency key, so a retried agent step silently
240
+ creates a second record. `task_add`, `note_add`, `timelog_add`,
241
+ `task_log_time`, `personal_log_add`, `timeclock_punch`, `task_complete`,
242
+ `prospect_add`, and `tasks_bulk_add` accept an optional
243
+ `idempotency_key`: calling again with the same key replays the first result
244
+ instead of writing again.
245
+
246
+ The record is local (`~/.viviscape/mcp-idempotency.json`, 7-day expiry), so it
247
+ stops the common case -- the same agent retrying the same step -- and does not
248
+ make the server idempotent. Two machines running the same plan will still
249
+ duplicate.
250
+
251
+ ### Delta queries
252
+
253
+ List tools accept `updated_since` (ISO 8601) and return only rows touched at
254
+ or after that time, so a recurring agent does not re-read the whole working set.
255
+ It is filtered in-process (no list route supports a modified-since filter), and
256
+ rows carrying no usable timestamp are kept rather than dropped.
257
+
258
+ ### Paging and field selection
259
+
260
+ List tools (`tasks_open`, `project_list_active`, `project_tasks`, and the rest)
261
+ return a page of trimmed rows rather than every column of every row. A task row
262
+ carries ~94 columns and a project row 87, most of them irrelevant to project
263
+ work, so each list tool accepts:
264
+
265
+ - `fields` - columns to return; omit for a curated default, or pass `["all"]`
266
+ - `limit` / `offset` - page window, default 50 rows
267
+
268
+ Task rows also carry computed `due_date`, `due_in_days` (negative when
269
+ overdue), and `overdue`, derived from the task's `end` date. Read those rather
270
+ than the API's `deadline` string, which does not track the due date -- it
271
+ reported "In 2 Days" for a task 39 days overdue. `deadline` is excluded from the
272
+ default field set; ask for it explicitly if you need to see what the UI shows.
273
+
274
+ Responses are wrapped as `{ total, returned, offset, next_offset, fields, items }`
275
+ so a caller can tell when more rows exist. Task lists also accept
276
+ `company_id`, `project_id`, `assignee_id`, `status`, `priority`,
277
+ `due_before`, `due_after`, and `search`; `tasks_open` pushes `company_id`
278
+ and `only_mine` to the server and filters the rest in-process.
279
+
280
+ In practice this took `tasks_open` from 97 KB to 6.4 KB and
281
+ `project_list_active` from 375 KB to 19 KB.
282
+
283
+ ## Development
284
+
285
+ ```bash
286
+ npm install
287
+ npm run dev # run with tsx, hot reload
288
+ npm run build # compile to dist/
289
+ npm start # run compiled
290
+ ```
291
+
292
+ ## License
293
+
294
+ MIT
@@ -153,6 +153,25 @@ export declare class ViviScapeClient {
153
153
  deleteTask(taskId: number): Promise<unknown>;
154
154
  /** Fold one task into another; the source task is consumed. */
155
155
  mergeTasks(fromTaskId: number, toTaskId: number): Promise<unknown>;
156
+ /**
157
+ * Close a task through the platform's completion route (what `vs tasks done`
158
+ * calls). This is not the same as posting status "completed": the route also
159
+ * stamps the completing user and runs the milestone/progress rollup the task
160
+ * board relies on.
161
+ */
162
+ completeTask(taskId: number, userId?: number): Promise<unknown>;
163
+ /**
164
+ * The ticket queue, filtered server-side by status set, company and team --
165
+ * the view behind `vs tickets list`. Closed tickets live on their own route.
166
+ * Tickets are Group_Tasks, so the rows are task rows and task_get reads one.
167
+ */
168
+ lookupTickets(opts?: {
169
+ status?: 'open' | 'closed' | 'all';
170
+ statuses?: string[];
171
+ query?: string;
172
+ company_id?: number;
173
+ team?: string;
174
+ }): Promise<unknown>;
156
175
  getTaskTags(taskId: number): Promise<unknown>;
157
176
  /**
158
177
  * Tag a task. The web app upper-cases the label and always sends a colour, so
@@ -209,6 +228,39 @@ export declare class ViviScapeClient {
209
228
  removeMilestone(milestoneId: number): Promise<unknown>;
210
229
  addTimeLog(data: Record<string, unknown>): Promise<unknown>;
211
230
  updateTimeLog(data: Record<string, unknown>): Promise<unknown>;
231
+ /**
232
+ * Log time against a task the short way (`vs tasks log`). The quick route
233
+ * fills group_id and service_id from the task itself, so only the note, the
234
+ * duration and the date are needed. A zero duration posts a note-only entry:
235
+ * it lands on the task's time feed without adding hours.
236
+ */
237
+ quickLogTime(data: {
238
+ task_id: number;
239
+ note: string;
240
+ duration?: string;
241
+ log_date?: string;
242
+ billable?: boolean;
243
+ }): Promise<unknown>;
244
+ /** Personal (non-task) time logs for a user -- the `vs logs list` feed. */
245
+ getPersonalLogs(userId?: number): Promise<unknown>;
246
+ /**
247
+ * Add a personal time log: work that belongs to a service rather than to any
248
+ * task. start_date and end_date mirror log_date, which is what the web form
249
+ * posts for a single-day entry.
250
+ */
251
+ addPersonalLog(data: {
252
+ note: string;
253
+ duration?: string;
254
+ service_id?: number;
255
+ log_date?: string;
256
+ }): Promise<unknown>;
257
+ removePersonalLog(logId: number | string): Promise<unknown>;
258
+ /**
259
+ * Punch the time clock. type "in" opens a punch, "out" closes the open one;
260
+ * both go to the same route with clocked_in set accordingly.
261
+ */
262
+ addPunch(type: 'in' | 'out', note?: string): Promise<unknown>;
263
+ getPunchHistory(userId?: number): Promise<unknown>;
212
264
  getMyNotes(): Promise<unknown>;
213
265
  getNoteById(noteId: number | string): Promise<unknown>;
214
266
  addNote(data: Record<string, unknown>): Promise<unknown>;
@@ -295,6 +347,41 @@ export declare class ViviScapeClient {
295
347
  service_id?: number;
296
348
  }>;
297
349
  }): Promise<unknown>;
350
+ /**
351
+ * Semantic search over the account knowledge base -- tickets and their plans,
352
+ * notes, products, distilled resolutions, and SOPs.
353
+ *
354
+ * Answers an empty list, not an error, when Second Brain is disabled for the
355
+ * account or its AI token budget is inside the reserve, so "no hits" is NOT
356
+ * proof the knowledge is absent. Check kbStatus() before concluding that.
357
+ */
358
+ kbSearch(query: string, opts?: {
359
+ top_k?: number;
360
+ company_id?: number;
361
+ product_id?: string;
362
+ source_types?: string[];
363
+ visibility?: string[];
364
+ }): Promise<unknown>;
365
+ kbStatus(): Promise<unknown>;
366
+ kbFreshness(): Promise<unknown>;
367
+ listSops(opts?: {
368
+ product_id?: string;
369
+ status?: string;
370
+ }): Promise<unknown>;
371
+ getSop(sopId: string): Promise<unknown>;
372
+ /**
373
+ * Create or update an SOP -- the durable, deliberately authored half of the
374
+ * brain, and the way an agent contributes knowledge that outlives one ticket.
375
+ * The server stamps account and creator, then queues the SOP for indexing, so
376
+ * a save becomes searchable on the next ingest tick rather than immediately.
377
+ * Passing sop_id updates in place; omitting it creates.
378
+ */
379
+ saveSop(sop: Record<string, unknown>): Promise<unknown>;
380
+ /**
381
+ * Queue one source for (re)indexing. The portal already enqueues on write,
382
+ * so this is for repairing a document that never made it into the brain.
383
+ */
384
+ kbEnqueue(sourceType: string, sourceKey: string, priority?: number): Promise<unknown>;
298
385
  getHoursByPerson(data: Record<string, unknown>): Promise<unknown>;
299
386
  getTimeLogsByDateRange(data: Record<string, unknown>): Promise<unknown>;
300
387
  getPersonStats(data: Record<string, unknown>): Promise<unknown>;
@@ -50,6 +50,46 @@ function parseShorthandDuration(input) {
50
50
  const mm = String(minutes).padStart(2, '0');
51
51
  return { duration: `${hh}:${mm}:00`, log_time: raw };
52
52
  }
53
+ /**
54
+ * Total minutes from shorthand ("1h30m", "45m", "2h", "90"). Used by the quick
55
+ * log and personal log routes, which want human text ("1h 30m") rather than the
56
+ * HH:MM:SS TimeSpan the full LOG_Time payload carries.
57
+ */
58
+ function shorthandToMinutes(input) {
59
+ const raw = String(input ?? '').trim();
60
+ if (!raw)
61
+ return 0;
62
+ const clock = raw.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
63
+ if (clock)
64
+ return parseInt(clock[1], 10) * 60 + parseInt(clock[2], 10);
65
+ const hMatch = raw.match(/(\d+(?:\.\d+)?)\s*h/i);
66
+ const mMatch = raw.match(/(\d+)\s*m/i);
67
+ if (!hMatch && !mMatch) {
68
+ const bare = Number(raw);
69
+ return Number.isFinite(bare) ? Math.round(bare) : 0;
70
+ }
71
+ const hours = hMatch ? parseFloat(hMatch[1]) : 0;
72
+ const minutes = mMatch ? parseInt(mMatch[1], 10) : 0;
73
+ return Math.round(hours * 60) + minutes;
74
+ }
75
+ /** "1h 30m" / "45m" -- the log_time text the quick and personal log routes store. */
76
+ function friendlyDuration(totalMinutes) {
77
+ if (totalMinutes <= 0)
78
+ return '';
79
+ if (totalMinutes < 60)
80
+ return `${totalMinutes}m`;
81
+ const h = Math.floor(totalMinutes / 60);
82
+ const m = totalMinutes % 60;
83
+ return m ? `${h}h ${m}m` : `${h}h`;
84
+ }
85
+ /** MM/DD/YYYY -- the date format both log routes expect. */
86
+ function usDate(value) {
87
+ const d = value ? new Date(value) : new Date();
88
+ const use = Number.isNaN(d.getTime()) ? new Date() : d;
89
+ const mm = String(use.getMonth() + 1).padStart(2, '0');
90
+ const dd = String(use.getDate()).padStart(2, '0');
91
+ return `${mm}/${dd}/${use.getFullYear()}`;
92
+ }
53
93
  /**
54
94
  * Build a full Group_Task payload from simplified MCP tool params.
55
95
  */
@@ -88,6 +128,8 @@ function buildTaskPayload(data, ctx) {
88
128
  };
89
129
  if (data.notes)
90
130
  payload.notes = data.notes;
131
+ if (data.plan)
132
+ payload.plan = data.plan;
91
133
  if (data.company_id)
92
134
  payload.company_id = data.company_id;
93
135
  if (data.assigned_to) {
@@ -108,6 +150,8 @@ function buildTaskUpdatePayload(data) {
108
150
  mapped.description = data.description;
109
151
  if (data.notes !== undefined)
110
152
  mapped.notes = data.notes;
153
+ if (data.plan !== undefined)
154
+ mapped.plan = data.plan;
111
155
  if (data.status !== undefined)
112
156
  mapped.status = data.status;
113
157
  if (data.priority !== undefined) {
@@ -633,6 +677,39 @@ export class ViviScapeClient {
633
677
  async mergeTasks(fromTaskId, toTaskId) {
634
678
  return this.post('tasks/merge', { task_from_id: fromTaskId, task_to_id: toTaskId });
635
679
  }
680
+ /**
681
+ * Close a task through the platform's completion route (what `vs tasks done`
682
+ * calls). This is not the same as posting status "completed": the route also
683
+ * stamps the completing user and runs the milestone/progress rollup the task
684
+ * board relies on.
685
+ */
686
+ async completeTask(taskId, userId) {
687
+ return this.get(`complete/task?task_id=${taskId}&user_id=${userId || this.userId}`);
688
+ }
689
+ // -- Tickets ------------------------------------------------
690
+ /**
691
+ * The ticket queue, filtered server-side by status set, company and team --
692
+ * the view behind `vs tickets list`. Closed tickets live on their own route.
693
+ * Tickets are Group_Tasks, so the rows are task rows and task_get reads one.
694
+ */
695
+ async lookupTickets(opts = {}) {
696
+ const state = opts.status ?? 'open';
697
+ const statuses = opts.statuses?.length
698
+ ? opts.statuses
699
+ : state === 'all'
700
+ ? []
701
+ : state === 'closed'
702
+ ? ['completed', 'cancelled']
703
+ : ['backlog', 'new', 'research', 'inprogress', 'testing', 'inreview', 'onhold'];
704
+ const path = state === 'closed' ? 'tickets/completed/lookup' : 'tickets/lookup';
705
+ return this.post(path, {
706
+ company_id: opts.company_id ?? 0,
707
+ status: '',
708
+ statuses,
709
+ query: opts.query ?? '',
710
+ teamfilter: opts.team ?? '',
711
+ });
712
+ }
636
713
  // -- Task tags ----------------------------------------------
637
714
  async getTaskTags(taskId) {
638
715
  return this.get(`grouptask/tags/${taskId}`);
@@ -777,6 +854,70 @@ export class ViviScapeClient {
777
854
  async updateTimeLog(data) {
778
855
  return this.post('logs/update', buildTimeLogPayload(data, this.ctx));
779
856
  }
857
+ /**
858
+ * Log time against a task the short way (`vs tasks log`). The quick route
859
+ * fills group_id and service_id from the task itself, so only the note, the
860
+ * duration and the date are needed. A zero duration posts a note-only entry:
861
+ * it lands on the task's time feed without adding hours.
862
+ */
863
+ async quickLogTime(data) {
864
+ const minutes = shorthandToMinutes(data.duration ?? '');
865
+ return this.post('quick/logs/add', {
866
+ account_id: this.accountId,
867
+ user_id: this.userId,
868
+ task_id: data.task_id,
869
+ note: data.note,
870
+ log_time: friendlyDuration(minutes),
871
+ log_date: usDate(data.log_date),
872
+ isnoteonly: minutes <= 0,
873
+ billable: data.billable ?? false,
874
+ });
875
+ }
876
+ // -- Personal logs -------------------------------------------
877
+ /** Personal (non-task) time logs for a user -- the `vs logs list` feed. */
878
+ async getPersonalLogs(userId) {
879
+ return this.get(`personal/logs/user/${userId || this.userId}`);
880
+ }
881
+ /**
882
+ * Add a personal time log: work that belongs to a service rather than to any
883
+ * task. start_date and end_date mirror log_date, which is what the web form
884
+ * posts for a single-day entry.
885
+ */
886
+ async addPersonalLog(data) {
887
+ const date = usDate(data.log_date);
888
+ return this.post('personal/log/add', {
889
+ account_id: this.accountId,
890
+ user_id: this.userId,
891
+ service_id: data.service_id ?? 0,
892
+ note: data.note,
893
+ log_time: friendlyDuration(shorthandToMinutes(data.duration ?? '')),
894
+ log_date: date,
895
+ start_date: date,
896
+ end_date: date,
897
+ });
898
+ }
899
+ async removePersonalLog(logId) {
900
+ return this.get(`personal/log/remove/${logId}`);
901
+ }
902
+ // -- Time clock ----------------------------------------------
903
+ /**
904
+ * Punch the time clock. type "in" opens a punch, "out" closes the open one;
905
+ * both go to the same route with clocked_in set accordingly.
906
+ */
907
+ async addPunch(type, note = '') {
908
+ return this.post('user/punch/add', {
909
+ type,
910
+ note,
911
+ user_id: this.userId,
912
+ account_id: this.accountId,
913
+ platform_account_id: this.accountId,
914
+ punch_time: nowIso(),
915
+ clocked_in: type === 'in',
916
+ });
917
+ }
918
+ async getPunchHistory(userId) {
919
+ return this.get(`user/punch/history?user_id=${userId || this.userId}&account_id=${this.accountId}`);
920
+ }
780
921
  // -- Notes --------------------------------------------------
781
922
  async getMyNotes() {
782
923
  return this.get('notes/me');
@@ -1061,6 +1202,64 @@ export class ViviScapeClient {
1061
1202
  })),
1062
1203
  });
1063
1204
  }
1205
+ // -- Second Brain (knowledge base) ---------------------------
1206
+ /**
1207
+ * Semantic search over the account knowledge base -- tickets and their plans,
1208
+ * notes, products, distilled resolutions, and SOPs.
1209
+ *
1210
+ * Answers an empty list, not an error, when Second Brain is disabled for the
1211
+ * account or its AI token budget is inside the reserve, so "no hits" is NOT
1212
+ * proof the knowledge is absent. Check kbStatus() before concluding that.
1213
+ */
1214
+ async kbSearch(query, opts = {}) {
1215
+ return this.post('kb/search', {
1216
+ query,
1217
+ top_k: opts.top_k && opts.top_k > 0 ? opts.top_k : 8,
1218
+ company_id: opts.company_id,
1219
+ product_id: opts.product_id,
1220
+ source_types: opts.source_types,
1221
+ visibility: opts.visibility,
1222
+ });
1223
+ }
1224
+ async kbStatus() {
1225
+ return this.get('kb/account/status');
1226
+ }
1227
+ async kbFreshness() {
1228
+ return this.get('kb/freshness');
1229
+ }
1230
+ async listSops(opts = {}) {
1231
+ const qs = [
1232
+ opts.product_id ? `product_id=${encodeURIComponent(opts.product_id)}` : '',
1233
+ opts.status ? `status=${encodeURIComponent(opts.status)}` : '',
1234
+ ].filter(Boolean).join('&');
1235
+ return this.get(`kb/sop/list${qs ? '?' + qs : ''}`);
1236
+ }
1237
+ async getSop(sopId) {
1238
+ return this.get(`kb/sop/${sopId}`);
1239
+ }
1240
+ /**
1241
+ * Create or update an SOP -- the durable, deliberately authored half of the
1242
+ * brain, and the way an agent contributes knowledge that outlives one ticket.
1243
+ * The server stamps account and creator, then queues the SOP for indexing, so
1244
+ * a save becomes searchable on the next ingest tick rather than immediately.
1245
+ * Passing sop_id updates in place; omitting it creates.
1246
+ */
1247
+ async saveSop(sop) {
1248
+ return this.post('kb/sop/save', sop);
1249
+ }
1250
+ /**
1251
+ * Queue one source for (re)indexing. The portal already enqueues on write,
1252
+ * so this is for repairing a document that never made it into the brain.
1253
+ */
1254
+ async kbEnqueue(sourceType, sourceKey, priority = 5) {
1255
+ return this.post('kb/enqueue', {
1256
+ account_id: this.accountId,
1257
+ source_type: sourceType,
1258
+ source_key: sourceKey,
1259
+ operation: 'upsert',
1260
+ priority,
1261
+ });
1262
+ }
1064
1263
  // -- Insights -----------------------------------------------
1065
1264
  async getHoursByPerson(data) {
1066
1265
  const userId = Number(data.user_id) || this.userId;
@@ -60,6 +60,16 @@ export function featureForPath(path) {
60
60
  return 'insights';
61
61
  if (p.startsWith('logs') || p.startsWith('timelog'))
62
62
  return 'logs';
63
+ // Quick task logging and personal logs are the same Pro-tier logging feature.
64
+ if (p.startsWith('quick/logs') || p.startsWith('personal/log'))
65
+ return 'logs';
66
+ if (p.startsWith('ticket'))
67
+ return 'tickets';
68
+ if (p.startsWith('user/punch'))
69
+ return 'time';
70
+ // Task completion route sits outside the tasks/* prefix.
71
+ if (p.startsWith('complete/task'))
72
+ return 'tasks';
63
73
  if (p.startsWith('prospect'))
64
74
  return 'crm';
65
75
  if (p.startsWith('task/comment'))
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ function invalidateClient() {
35
35
  }
36
36
  const server = new McpServer({
37
37
  name: 'viviscape',
38
- version: '2.0.0',
38
+ version: '2.9.0',
39
39
  });
40
40
  function json(data) {
41
41
  return JSON.stringify(data, null, 2);
@@ -57,7 +57,7 @@ const pageArgs = {
57
57
  updated_since: z.string().optional()
58
58
  .describe('Only rows touched at or after this time (ISO 8601). Filtered in-process; rows with no timestamp are kept.'),
59
59
  fields: z.array(z.string()).optional()
60
- .describe('Columns to return. Omit for a PM-relevant default; ["all"] for every column.'),
60
+ .describe('Columns to return. Omit for a PM-relevant default; ["all"] for every column. The default set is light: ask for e.g. ["task_id","task","plan"] to read plans across a set of tasks.'),
61
61
  limit: z.number().optional().describe('Max rows to return (default 50)'),
62
62
  offset: z.number().optional().describe('Rows to skip, for paging (default 0)'),
63
63
  };
@@ -378,6 +378,7 @@ server.tool('task_add', 'Add a task to a project. The API requires a full Group_
378
378
  title: z.string().describe('Task title'),
379
379
  description: z.string().optional().describe('Task description'),
380
380
  notes: z.string().optional().describe('Task notes'),
381
+ plan: z.string().optional().describe('Plan of action for the new task -- the ordered steps to be taken before the work starts. Use notes for the extended instructions and scenario.'),
381
382
  assigned_to: z.number().optional().describe('User ID to assign the task to. The signed-in user is always recorded as creator.'),
382
383
  company_id: z.number().optional().describe('Company ID associated with the project'),
383
384
  service_id: z.number().optional().describe('Service ID classifying the work type'),
@@ -416,11 +417,12 @@ server.tool('task_add', 'Add a task to a project. The API requires a full Group_
416
417
  });
417
418
  return { content: [{ type: 'text', text: json(result) }] };
418
419
  });
419
- server.tool('task_update', 'Update a task. Fetches existing task first and merges changes.', {
420
+ server.tool('task_update', 'Update a task. Fetches existing task first and merges changes. This is where an agent records its plan -- see the plan argument.', {
420
421
  task_id: z.number().describe('Task ID to update'),
421
422
  title: z.string().optional().describe('Task title'),
422
423
  description: z.string().optional(),
423
424
  notes: z.string().optional().describe('Task notes -- the free-form working notes on the task Overview tab, held in HTML by the editor. Replaces the field outright, so read the current value with task_get and send the merged text rather than only the new lines.'),
425
+ plan: z.string().optional().describe('Plan of action for the task -- the ordered steps an agent intends to take BEFORE it starts, so the work is inspectable and the next agent can pick it up. Held in HTML by the Plan tab editor; plain text is accepted and stored as typed. Replaces the field outright, so read the current value with task_get and send the merged text rather than only the new steps. Use notes for the extended instructions and scenario, and solution for the outcome. Omit the argument to leave an existing plan untouched.'),
424
426
  assigned_to: z.number().optional().describe('User ID to assign to'),
425
427
  priority: z.enum(PRIORITIES).optional().describe('Priority (platform values)'),
426
428
  status: z.enum(TASK_STATUSES).optional().describe('Task status (platform values)'),
@@ -892,6 +894,35 @@ server.tool('task_merge', 'Merge one task into another. The source task is consu
892
894
  const result = await requireClient().mergeTasks(from_task_id, to_task_id);
893
895
  return { content: [{ type: 'text', text: json(result) }] };
894
896
  });
897
+ server.tool('task_complete', 'Close a task through the platform completion route. Prefer this over task_update with status "completed": it also stamps who finished the work and runs the milestone/progress rollup the task board reads.', {
898
+ task_id: z.number().describe('Task ID to complete'),
899
+ user_id: z.number().optional().describe('User completing the task. Defaults to the signed-in user.'),
900
+ ...idempotencyArg,
901
+ }, async ({ task_id, user_id, idempotency_key }) => {
902
+ const result = await once('task_complete', idempotency_key, () => requireClient().completeTask(task_id, user_id));
903
+ return { content: [{ type: 'text', text: json(result) }] };
904
+ });
905
+ // ============================================================
906
+ // TICKETS
907
+ // ============================================================
908
+ server.tool('tickets_list', 'The account ticket queue, filtered server-side by status set, company and team. status "open" (default) covers backlog, new, research, inprogress, testing, inreview and onhold; "closed" reads the completed/cancelled route; "all" drops the status filter. Tickets are tasks, so rows are task rows -- read one with task_get and search by keyword with task_search. Requires the enterprise plan.', {
909
+ status: z.enum(['open', 'closed', 'all']).optional().describe('Which ticket queue to read (default: open)'),
910
+ statuses: z.array(z.enum(TASK_STATUSES)).optional().describe('Exact status set, overriding the status shorthand'),
911
+ query: z.string().optional().describe('Search text matched server-side'),
912
+ company_id: z.number().optional().describe('Only tickets for this company'),
913
+ team: z.string().optional().describe('Team filter passed through to the API'),
914
+ ...pageArgs,
915
+ }, async (params) => {
916
+ const result = await requireClient().lookupTickets({
917
+ status: params.status,
918
+ statuses: params.statuses,
919
+ query: params.query,
920
+ company_id: params.company_id,
921
+ team: params.team,
922
+ });
923
+ const rows = Array.isArray(result) ? annotateTasks(result) : result;
924
+ return { content: [{ type: 'text', text: json(shape(since(rows, params), TASK_FIELDS, paging(params))) }] };
925
+ });
895
926
  // ============================================================
896
927
  // TASK COMMENTS
897
928
  // ============================================================
@@ -963,6 +994,56 @@ server.tool('timelog_update', 'Update an existing time log entry. Builds full LO
963
994
  const result = await requireClient().updateTimeLog(params);
964
995
  return { content: [{ type: 'text', text: json(result) }] };
965
996
  });
997
+ server.tool('task_log_time', 'Log time against a task the short way: the quick route takes the project and service from the task itself, so only the note matters. Omit duration (or pass 0) to post a note-only entry -- it lands on the task time feed without adding hours. Use timelog_add when the entry needs an explicit project, service or user.', {
998
+ task_id: z.number().describe('Task ID to log against'),
999
+ note: z.string().describe('What was worked on'),
1000
+ duration: z.string().optional().describe('Duration in shorthand (e.g., 1h30m, 45m, 2h). Omit for a note-only entry.'),
1001
+ log_date: z.string().optional().describe('Date of the work (ISO 8601). Defaults to today.'),
1002
+ billable: z.boolean().optional().describe('Whether this time is billable (default false)'),
1003
+ ...idempotencyArg,
1004
+ }, async (params) => {
1005
+ const result = await once('task_log_time', params.idempotency_key, () => requireClient().quickLogTime(params));
1006
+ return { content: [{ type: 'text', text: json(result) }] };
1007
+ });
1008
+ // ============================================================
1009
+ // PERSONAL LOGS AND TIME CLOCK
1010
+ // ============================================================
1011
+ server.tool('personal_logs', 'Personal time logs for a user -- work booked against a service rather than any task.', {
1012
+ user_id: z.number().optional().describe('User whose logs to read. Defaults to the signed-in user.'),
1013
+ ...pageArgs,
1014
+ }, async (params) => {
1015
+ const result = await requireClient().getPersonalLogs(params.user_id);
1016
+ return { content: [{ type: 'text', text: json(shape(since(result, params), [], paging(params))) }] };
1017
+ });
1018
+ server.tool('personal_log_add', 'Add a personal time log: work that belongs to a service rather than to a task. For task work use task_log_time or timelog_add.', {
1019
+ note: z.string().describe('What was worked on'),
1020
+ duration: z.string().optional().describe('Duration in shorthand (e.g., 1h30m, 45m, 2h)'),
1021
+ service_id: z.number().optional().describe('Service ID classifying the work type'),
1022
+ log_date: z.string().optional().describe('Date of the work (ISO 8601). Defaults to today.'),
1023
+ ...idempotencyArg,
1024
+ }, async (params) => {
1025
+ const result = await once('personal_log_add', params.idempotency_key, () => requireClient().addPersonalLog(params));
1026
+ return { content: [{ type: 'text', text: json(result) }] };
1027
+ });
1028
+ server.tool('personal_log_remove', 'Delete a personal time log by its log_id (from personal_logs).', { log_id: z.number().describe('Personal log ID to remove') }, async ({ log_id }) => {
1029
+ const result = await requireClient().removePersonalLog(log_id);
1030
+ return { content: [{ type: 'text', text: json(result) }] };
1031
+ });
1032
+ server.tool('timeclock_punch', 'Punch the time clock for the signed-in user. type "in" opens a punch, "out" closes the open one. This is the attendance clock, separate from task time logs.', {
1033
+ type: z.enum(['in', 'out']).describe('Punch direction'),
1034
+ note: z.string().optional().describe('Optional punch note'),
1035
+ ...idempotencyArg,
1036
+ }, async ({ type, note, idempotency_key }) => {
1037
+ const result = await once('timeclock_punch', idempotency_key, () => requireClient().addPunch(type, note ?? ''));
1038
+ return { content: [{ type: 'text', text: json(result) }] };
1039
+ });
1040
+ server.tool('timeclock_history', 'Time clock punch history for a user -- clock-in/clock-out pairs with their notes.', {
1041
+ user_id: z.number().optional().describe('User whose punches to read. Defaults to the signed-in user.'),
1042
+ ...pageArgs,
1043
+ }, async (params) => {
1044
+ const result = await requireClient().getPunchHistory(params.user_id);
1045
+ return { content: [{ type: 'text', text: json(shape(since(result, params), [], paging(params))) }] };
1046
+ });
966
1047
  // ============================================================
967
1048
  // NOTES
968
1049
  // ============================================================
@@ -1100,6 +1181,74 @@ server.tool('insights_all_users_time', 'Get time totals for all users', {}, asyn
1100
1181
  const result = await requireClient().getAllUsersTime();
1101
1182
  return { content: [{ type: 'text', text: json(result) }] };
1102
1183
  });
1184
+ server.tool('kb_search', 'Search the account Second Brain -- the semantic index over tickets (their description, plan, notes and solution), notes, products, distilled resolutions and SOPs. This is the memory to consult BEFORE planning work: it answers "has this been solved here before, and how". Rows carry source_type + source_key, so a hit on a ticket names the task id to open with task_get, and a hit on an SOP names the sop_id to read with kb_sop. score is 1 - cosine distance, so higher is closer. IMPORTANT: an empty result is not proof the knowledge is absent -- the route answers with no hits when Second Brain is switched off for the account or its AI token budget is inside the reserve. Check kb_status when a search of an established account comes back empty.', {
1185
+ query: z.string().describe('What to look for, in natural language -- this is embedded, so a question or a sentence retrieves better than a bare keyword'),
1186
+ top_k: z.number().optional().describe('How many chunks to return (default 8)'),
1187
+ company_id: z.number().optional().describe('Restrict to knowledge tied to one company'),
1188
+ product_id: z.string().optional().describe('Restrict to knowledge tied to one product (GUID)'),
1189
+ source_types: z.array(z.enum(['ticket', 'note', 'product', 'resolution', 'sop'])).optional()
1190
+ .describe('Restrict to these kinds of source. Omit for all of them.'),
1191
+ visibility: z.array(z.string()).optional()
1192
+ .describe('Visibility bands to search; defaults to ["staff"]. Personal note knowledge is never reachable here.'),
1193
+ }, async ({ query, top_k, company_id, product_id, source_types, visibility }) => {
1194
+ const result = await requireClient().kbSearch(query, { top_k, company_id, product_id, source_types, visibility });
1195
+ return { content: [{ type: 'text', text: json(result) }] };
1196
+ });
1197
+ server.tool('kb_status', 'Report whether Second Brain is switched on for this account and how current its index is. Read this when kb_search comes back empty on an account that plainly has history: search answers with no hits (not an error) when the brain is disabled or the AI token budget is inside its reserve, and the freshness figures show what is still queued for ingest.', {}, async () => {
1198
+ const c = requireClient();
1199
+ const [status, freshness] = await Promise.all([c.kbStatus(), c.kbFreshness()]);
1200
+ return { content: [{ type: 'text', text: json({ status, freshness }) }] };
1201
+ });
1202
+ server.tool('kb_sop', 'List, read, or write SOPs -- the durable, deliberately authored half of the Second Brain, and the way an agent contributes knowledge meant to outlive one ticket (a runbook, a recurring fix, a house convention). Ticket and note knowledge is ingested automatically; an SOP is the only knowledge an agent authors directly. save creates when sop_id is omitted and updates in place when it is given, and it returns the sop_id either way. A saved SOP is QUEUED for indexing rather than indexed on the spot, so kb_search will not surface it until the next ingest tick. Prefer updating the existing SOP over filing a near-duplicate: search first with kb_search source_types ["sop"]. New SOPs land as status "draft" so a person can approve them.', {
1203
+ action: z.enum(['list', 'get', 'save']).describe('What to do'),
1204
+ sop_id: z.string().optional().describe('SOP ID (get; and on save, to update that SOP in place instead of creating one)'),
1205
+ title: z.string().optional().describe('SOP title (save, required)'),
1206
+ body: z.string().optional().describe('The procedure itself (save). Plain text or light markdown; this is what gets embedded and retrieved.'),
1207
+ triggers: z.string().optional().describe('When this SOP applies -- the symptoms, phrases or conditions that should pull it up (save)'),
1208
+ product_id: z.string().optional().describe('Product this SOP belongs to (GUID), for both filtering a list and tagging a save'),
1209
+ source_task_id: z.number().optional().describe('The task or ticket this SOP was written from (save) -- keeps the provenance of the procedure'),
1210
+ status: z.enum(['draft', 'approved', 'archived']).optional()
1211
+ .describe('On list, filter by status. On save, the status to store -- leave unset when creating so it lands as a draft for review.'),
1212
+ visibility: z.enum(['staff', 'customer']).optional()
1213
+ .describe('Who may retrieve this SOP (save). Defaults to staff; use customer only for text fit to leave the building.'),
1214
+ }, async ({ action, sop_id, title, body, triggers, product_id, source_task_id, status, visibility }) => {
1215
+ const c = requireClient();
1216
+ let result;
1217
+ if (action === 'list') {
1218
+ result = await c.listSops({ product_id, status });
1219
+ }
1220
+ else if (action === 'get') {
1221
+ result = await c.getSop(need(action, 'sop_id', sop_id));
1222
+ }
1223
+ else {
1224
+ // The server requires a title and fills account/creator itself. Only send
1225
+ // what was supplied: an omitted field must not blank a stored one.
1226
+ const sop = { title: need(action, 'title', title) };
1227
+ if (sop_id !== undefined)
1228
+ sop.sop_id = sop_id;
1229
+ if (body !== undefined)
1230
+ sop.body = body;
1231
+ if (triggers !== undefined)
1232
+ sop.triggers = triggers;
1233
+ if (product_id !== undefined)
1234
+ sop.product_id = product_id;
1235
+ if (source_task_id !== undefined)
1236
+ sop.source_task_id = source_task_id;
1237
+ if (status !== undefined)
1238
+ sop.status = status;
1239
+ if (visibility !== undefined)
1240
+ sop.visibility = visibility;
1241
+ result = await c.saveSop(sop);
1242
+ }
1243
+ return { content: [{ type: 'text', text: json(result) }] };
1244
+ });
1245
+ server.tool('kb_reindex', 'Queue one source document for re-indexing into the Second Brain. The portal enqueues on every write already, so reach for this only to repair a document that never made it in -- a ticket whose plan or solution kb_search cannot find, say. source_key is the task id for a ticket, the note/product/SOP GUID otherwise.', {
1246
+ source_type: z.enum(['ticket', 'note', 'product', 'resolution', 'sop']).describe('Kind of source to reindex'),
1247
+ source_key: z.string().describe('Task id (ticket) or GUID (note, product, resolution, sop)'),
1248
+ }, async ({ source_type, source_key }) => {
1249
+ const result = await requireClient().kbEnqueue(source_type, source_key);
1250
+ return { content: [{ type: 'text', text: json(result) }] };
1251
+ });
1103
1252
  // ============================================================
1104
1253
  // START
1105
1254
  // ============================================================
@@ -90,7 +90,7 @@ export function filterTasks(rows, f) {
90
90
  if (f.assignee_id && !assigneeIds(row).includes(f.assignee_id))
91
91
  return false;
92
92
  if (f.search) {
93
- const hay = `${lower(row.task)} ${lower(row.description)} ${lower(row.notes)}`;
93
+ const hay = `${lower(row.task)} ${lower(row.description)} ${lower(row.notes)} ${lower(row.plan)}`;
94
94
  if (!hay.includes(f.search.toLowerCase()))
95
95
  return false;
96
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viviscape-mcp",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "MCP server for the ViviScape API — CRM, projects, companies, notes, and insights, authenticated as the signed-in ViviScape user",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",