viviscape-mcp 2.8.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,200 +1,203 @@
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`
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`
119
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`
120
- - **Note filing** - `note_companies`, `note_users`, `note_tags`, `note_attachments`, `notebook`, `notebook_users` (each takes an `action`)
121
- - **Files** - `project_files` (list/upload/update/delete/download), `task_files`
122
- - **Insights** - hours by person/service/project, AI summary, person stats, time totals
123
- - **Account** - `account_info`, `account_services`, `account_users`
124
- - **Auth** - `auth_login`, `auth_status`, `auth_logout`
125
-
126
- ### Status and priority values
127
-
128
- Use the `enums` tool rather than guessing. The platform's vocabularies are:
129
-
130
- | field | values |
131
- |---|---|
132
- | task status | backlog, new, research, discussion, inprogress, pendingreview, inreview, testing, waitingcustomer, waitingteammember, onhold, cancelled, completed |
133
- | priority | low, moderate, important, urgent, critical |
134
- | project status | new, inprogress, complete |
135
- | prospect status | new, firstcontact, negotiation, pending, won, lost, spam |
136
-
137
- Two traps: tasks complete as `completed` while projects complete as
138
- `complete`, and priority is *not* low/medium/high. Task and project tools
139
- validate these with `z.enum`, so an invalid value is rejected before it reaches
140
- the API.
141
-
142
- ### Task tags
143
-
144
- `task_tags` lists, adds, and removes the tags shown on the task board, and
145
- `task_add` takes a `tags` list so a generated task lands already categorised
146
- (handy for marking agent-written work).
147
-
148
- Verified behaviour, so callers do not have to discover it:
149
-
150
- - Labels are stored upper-case, and `add` is idempotent per label - re-adding
151
- one returns the existing tag, not a duplicate.
152
- - `add` returns the persisted tag with its `tag_id`; `remove` takes that
153
- `tag_id`, not the label, and answers `true` (unlike `note_tags`, whose remove
154
- answers `false` even on success).
155
- - `color` is any hex string, defaulting to the board palette's first entry
156
- (`#00325e`).
157
- - `task_tags` is per task, and list rows from `tasks_open` / `project_tasks`
158
- carry `tags: null`, so read one task's tags with `task_tags` or `task_get`.
159
- - `tasks_by_tag` goes the other way, from a label to the tasks carrying it.
160
- Labels match trimmed and case insensitively; `match: "any"` (default) returns
161
- a task carrying at least one label, `match: "all"` only tasks carrying every
162
- one. An optional `status` list narrows further. Hits are light rows that
163
- carry the task's whole tag set, so the other labels arrive with the hit.
164
- Called with no `tags`, it returns the account's tag vocabulary instead -
165
- every distinct label with the number of tasks using it.
166
- - A tag failure inside `task_add` never fails the create: the task is returned
167
- with the per-tag error recorded in its `tags` array.
168
-
169
- ### Task references
170
-
171
- `task_references` links related tasks to each other - a duplicate, a blocker,
172
- the ticket a task came from - and `task_search` finds the id to link when only
173
- a keyword is known.
174
-
175
- Verified behaviour:
176
-
177
- - Links are **symmetric**. Adding A to B makes the pair visible from both
178
- tasks, so link once; mirroring it by hand just returns the same row.
179
- - `list` rows describe the *other* task: `related_task_id` plus its title,
180
- status, project and company, so a reference list needs no follow-up
181
- `task_get`.
182
- - `add` is idempotent per pair (in either direction) and returns the persisted
183
- link with its `reference_id`; `remove` takes that `reference_id`, not a task
184
- id, and answers `true`.
185
- - Both tasks must belong to the signed-in account; a cross-account id comes
186
- back as an empty link rather than an error.
187
- - `task_search` matches the task title, the description, and - for a numeric
188
- query - the task id itself. It covers tickets too, so it is the one keyword
189
- route that reaches ticket rows `tasks_open` never returns.
190
-
191
- ### Task notes
192
-
193
- `task_update` takes a `notes` field: the free-form working notes on the task's
194
- Overview tab, stored as HTML by the rich-text editor. It **replaces** the field
195
- outright, so read the current value with `task_get` and send the merged text
196
- rather than only the new lines.
197
-
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
+
198
201
  ### Task plans
199
202
 
200
203
  `task_add` and `task_update` take a `plan` field: the plan of action for the
@@ -231,60 +234,61 @@ knowledge an agent authors directly, so `kb_sop` save is how a lesson from one
231
234
  ticket becomes reusable — search `source_types: ["sop"]` first and update the
232
235
  existing SOP rather than filing a near-duplicate.
233
236
 
234
- ### Repeat-safe creates
235
-
236
- No create route accepts an idempotency key, so a retried agent step silently
237
- creates a second record. `task_add`, `note_add`, `timelog_add`,
238
- `prospect_add`, and `tasks_bulk_add` accept an optional
239
- `idempotency_key`: calling again with the same key replays the first result
240
- instead of writing again.
241
-
242
- The record is local (`~/.viviscape/mcp-idempotency.json`, 7-day expiry), so it
243
- stops the common case -- the same agent retrying the same step -- and does not
244
- make the server idempotent. Two machines running the same plan will still
245
- duplicate.
246
-
247
- ### Delta queries
248
-
249
- List tools accept `updated_since` (ISO 8601) and return only rows touched at
250
- or after that time, so a recurring agent does not re-read the whole working set.
251
- It is filtered in-process (no list route supports a modified-since filter), and
252
- rows carrying no usable timestamp are kept rather than dropped.
253
-
254
- ### Paging and field selection
255
-
256
- List tools (`tasks_open`, `project_list_active`, `project_tasks`, and the rest)
257
- return a page of trimmed rows rather than every column of every row. A task row
258
- carries ~94 columns and a project row 87, most of them irrelevant to project
259
- work, so each list tool accepts:
260
-
261
- - `fields` - columns to return; omit for a curated default, or pass `["all"]`
262
- - `limit` / `offset` - page window, default 50 rows
263
-
264
- Task rows also carry computed `due_date`, `due_in_days` (negative when
265
- overdue), and `overdue`, derived from the task's `end` date. Read those rather
266
- than the API's `deadline` string, which does not track the due date -- it
267
- reported "In 2 Days" for a task 39 days overdue. `deadline` is excluded from the
268
- default field set; ask for it explicitly if you need to see what the UI shows.
269
-
270
- Responses are wrapped as `{ total, returned, offset, next_offset, fields, items }`
271
- so a caller can tell when more rows exist. Task lists also accept
272
- `company_id`, `project_id`, `assignee_id`, `status`, `priority`,
273
- `due_before`, `due_after`, and `search`; `tasks_open` pushes `company_id`
274
- and `only_mine` to the server and filters the rest in-process.
275
-
276
- In practice this took `tasks_open` from 97 KB to 6.4 KB and
277
- `project_list_active` from 375 KB to 19 KB.
278
-
279
- ## Development
280
-
281
- ```bash
282
- npm install
283
- npm run dev # run with tsx, hot reload
284
- npm run build # compile to dist/
285
- npm start # run compiled
286
- ```
287
-
288
- ## License
289
-
290
- MIT
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>;
@@ -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
  */
@@ -637,6 +677,39 @@ export class ViviScapeClient {
637
677
  async mergeTasks(fromTaskId, toTaskId) {
638
678
  return this.post('tasks/merge', { task_from_id: fromTaskId, task_to_id: toTaskId });
639
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
+ }
640
713
  // -- Task tags ----------------------------------------------
641
714
  async getTaskTags(taskId) {
642
715
  return this.get(`grouptask/tags/${taskId}`);
@@ -781,6 +854,70 @@ export class ViviScapeClient {
781
854
  async updateTimeLog(data) {
782
855
  return this.post('logs/update', buildTimeLogPayload(data, this.ctx));
783
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
+ }
784
921
  // -- Notes --------------------------------------------------
785
922
  async getMyNotes() {
786
923
  return this.get('notes/me');
@@ -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);
@@ -894,6 +894,35 @@ server.tool('task_merge', 'Merge one task into another. The source task is consu
894
894
  const result = await requireClient().mergeTasks(from_task_id, to_task_id);
895
895
  return { content: [{ type: 'text', text: json(result) }] };
896
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
+ });
897
926
  // ============================================================
898
927
  // TASK COMMENTS
899
928
  // ============================================================
@@ -965,6 +994,56 @@ server.tool('timelog_update', 'Update an existing time log entry. Builds full LO
965
994
  const result = await requireClient().updateTimeLog(params);
966
995
  return { content: [{ type: 'text', text: json(result) }] };
967
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
+ });
968
1047
  // ============================================================
969
1048
  // NOTES
970
1049
  // ============================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viviscape-mcp",
3
- "version": "2.8.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",