viviscape-mcp 2.6.0 → 2.8.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,247 +1,290 @@
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), 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
- - Tags are per task. There is no tag-search route, and list rows from
157
- `tasks_open` / `project_tasks` carry `tags: null`, so read a task's tags with
158
- `task_tags` or `task_get`.
159
- - A tag failure inside `task_add` never fails the create: the task is returned
160
- with the per-tag error recorded in its `tags` array.
161
-
162
- ### Task references
163
-
164
- `task_references` links related tasks to each other - a duplicate, a blocker,
165
- the ticket a task came from - and `task_search` finds the id to link when only
166
- a keyword is known.
167
-
168
- Verified behaviour:
169
-
170
- - Links are **symmetric**. Adding A to B makes the pair visible from both
171
- tasks, so link once; mirroring it by hand just returns the same row.
172
- - `list` rows describe the *other* task: `related_task_id` plus its title,
173
- status, project and company, so a reference list needs no follow-up
174
- `task_get`.
175
- - `add` is idempotent per pair (in either direction) and returns the persisted
176
- link with its `reference_id`; `remove` takes that `reference_id`, not a task
177
- id, and answers `true`.
178
- - Both tasks must belong to the signed-in account; a cross-account id comes
179
- back as an empty link rather than an error.
180
- - `task_search` matches the task title, the description, and - for a numeric
181
- query - the task id itself. It covers tickets too, so it is the one keyword
182
- route that reaches ticket rows `tasks_open` never returns.
183
-
184
- ### Task notes
185
-
186
- `task_update` takes a `notes` field: the free-form working notes on the task's
187
- Overview tab, stored as HTML by the rich-text editor. It **replaces** the field
188
- outright, so read the current value with `task_get` and send the merged text
189
- rather than only the new lines.
190
-
191
- ### Repeat-safe creates
192
-
193
- No create route accepts an idempotency key, so a retried agent step silently
194
- creates a second record. `task_add`, `note_add`, `timelog_add`,
195
- `prospect_add`, and `tasks_bulk_add` accept an optional
196
- `idempotency_key`: calling again with the same key replays the first result
197
- instead of writing again.
198
-
199
- The record is local (`~/.viviscape/mcp-idempotency.json`, 7-day expiry), so it
200
- stops the common case -- the same agent retrying the same step -- and does not
201
- make the server idempotent. Two machines running the same plan will still
202
- duplicate.
203
-
204
- ### Delta queries
205
-
206
- List tools accept `updated_since` (ISO 8601) and return only rows touched at
207
- or after that time, so a recurring agent does not re-read the whole working set.
208
- It is filtered in-process (no list route supports a modified-since filter), and
209
- rows carrying no usable timestamp are kept rather than dropped.
210
-
211
- ### Paging and field selection
212
-
213
- List tools (`tasks_open`, `project_list_active`, `project_tasks`, and the rest)
214
- return a page of trimmed rows rather than every column of every row. A task row
215
- carries ~94 columns and a project row 87, most of them irrelevant to project
216
- work, so each list tool accepts:
217
-
218
- - `fields` - columns to return; omit for a curated default, or pass `["all"]`
219
- - `limit` / `offset` - page window, default 50 rows
220
-
221
- Task rows also carry computed `due_date`, `due_in_days` (negative when
222
- overdue), and `overdue`, derived from the task's `end` date. Read those rather
223
- than the API's `deadline` string, which does not track the due date -- it
224
- reported "In 2 Days" for a task 39 days overdue. `deadline` is excluded from the
225
- default field set; ask for it explicitly if you need to see what the UI shows.
226
-
227
- Responses are wrapped as `{ total, returned, offset, next_offset, fields, items }`
228
- so a caller can tell when more rows exist. Task lists also accept
229
- `company_id`, `project_id`, `assignee_id`, `status`, `priority`,
230
- `due_before`, `due_after`, and `search`; `tasks_open` pushes `company_id`
231
- and `only_mine` to the server and filters the rest in-process.
232
-
233
- In practice this took `tasks_open` from 97 KB to 6.4 KB and
234
- `project_list_active` from 375 KB to 19 KB.
235
-
236
- ## Development
237
-
238
- ```bash
239
- npm install
240
- npm run dev # run with tsx, hot reload
241
- npm run build # compile to dist/
242
- npm start # run compiled
243
- ```
244
-
245
- ## License
246
-
247
- 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`
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
+ - **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
+
198
+ ### Task plans
199
+
200
+ `task_add` and `task_update` take a `plan` field: the plan of action for the
201
+ task or ticket -- the ordered steps an agent intends to take **before** it
202
+ starts, so the work is inspectable and the next agent can pick it up. It shows
203
+ up in the portal on the task's Plan tab, immediately left of Solution.
204
+
205
+ Read it back with `task_get`, or across a set of tasks by asking a list tool
206
+ for it (`fields: ["task_id", "task", "plan"]`) -- the default field set is
207
+ light and leaves it out. Like `notes`, it **replaces** the field outright, so
208
+ send the merged text rather than only the new steps. Omitting the argument
209
+ leaves an existing plan untouched.
210
+
211
+ The three long-text fields divide up as: `plan` = what will be done,
212
+ `notes` = the extended instructions and scenario, `solution` = the outcome.
213
+
214
+ ### Second Brain
215
+
216
+ `kb_search` reads the account knowledge base: the semantic index over tickets
217
+ (description, plan, notes, solution), notes, products, distilled resolutions and
218
+ SOPs. Consult it *before* planning work it answers whether the thing has been
219
+ solved here before.
220
+
221
+ Two traps:
222
+
223
+ - An empty result is **not** proof the knowledge is absent. Search answers with
224
+ no hits, not an error, when Second Brain is switched off for the account or
225
+ its AI token budget is inside the reserve. `kb_status` distinguishes the two.
226
+ - A `kb_sop` save is *queued* for indexing, not indexed on the spot, so it does
227
+ not turn up in `kb_search` until the next ingest tick.
228
+
229
+ Ticket and note knowledge is ingested automatically. An SOP is the only
230
+ knowledge an agent authors directly, so `kb_sop` save is how a lesson from one
231
+ ticket becomes reusable — search `source_types: ["sop"]` first and update the
232
+ existing SOP rather than filing a near-duplicate.
233
+
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
@@ -162,6 +162,19 @@ export declare class ViviScapeClient {
162
162
  addTaskTag(taskId: number, tag: string, color?: string): Promise<unknown>;
163
163
  /** Takes the tag_id from getTaskTags, not the label. */
164
164
  removeTaskTag(tagId: string): Promise<unknown>;
165
+ /**
166
+ * Tasks carrying one or more tags. Labels are matched trimmed and case
167
+ * insensitively; match 'all' narrows to tasks carrying every label, 'any'
168
+ * (the default) returns tasks carrying at least one. Each hit carries its
169
+ * whole tag set, so a task's other labels come back with it.
170
+ */
171
+ searchTasksByTags(tags: string[], opts?: {
172
+ match?: 'any' | 'all';
173
+ status?: string[];
174
+ max?: number;
175
+ }): Promise<unknown>;
176
+ /** Every distinct task tag on the account, with the task count per label. */
177
+ getAccountTaskTags(): Promise<unknown>;
165
178
  /**
166
179
  * Cross references between tasks. The link is symmetric: adding A -> B makes
167
180
  * the pair visible from both tasks, and each row describes the OTHER task
@@ -282,6 +295,41 @@ export declare class ViviScapeClient {
282
295
  service_id?: number;
283
296
  }>;
284
297
  }): Promise<unknown>;
298
+ /**
299
+ * Semantic search over the account knowledge base -- tickets and their plans,
300
+ * notes, products, distilled resolutions, and SOPs.
301
+ *
302
+ * Answers an empty list, not an error, when Second Brain is disabled for the
303
+ * account or its AI token budget is inside the reserve, so "no hits" is NOT
304
+ * proof the knowledge is absent. Check kbStatus() before concluding that.
305
+ */
306
+ kbSearch(query: string, opts?: {
307
+ top_k?: number;
308
+ company_id?: number;
309
+ product_id?: string;
310
+ source_types?: string[];
311
+ visibility?: string[];
312
+ }): Promise<unknown>;
313
+ kbStatus(): Promise<unknown>;
314
+ kbFreshness(): Promise<unknown>;
315
+ listSops(opts?: {
316
+ product_id?: string;
317
+ status?: string;
318
+ }): Promise<unknown>;
319
+ getSop(sopId: string): Promise<unknown>;
320
+ /**
321
+ * Create or update an SOP -- the durable, deliberately authored half of the
322
+ * brain, and the way an agent contributes knowledge that outlives one ticket.
323
+ * The server stamps account and creator, then queues the SOP for indexing, so
324
+ * a save becomes searchable on the next ingest tick rather than immediately.
325
+ * Passing sop_id updates in place; omitting it creates.
326
+ */
327
+ saveSop(sop: Record<string, unknown>): Promise<unknown>;
328
+ /**
329
+ * Queue one source for (re)indexing. The portal already enqueues on write,
330
+ * so this is for repairing a document that never made it into the brain.
331
+ */
332
+ kbEnqueue(sourceType: string, sourceKey: string, priority?: number): Promise<unknown>;
285
333
  getHoursByPerson(data: Record<string, unknown>): Promise<unknown>;
286
334
  getTimeLogsByDateRange(data: Record<string, unknown>): Promise<unknown>;
287
335
  getPersonStats(data: Record<string, unknown>): Promise<unknown>;
@@ -88,6 +88,8 @@ function buildTaskPayload(data, ctx) {
88
88
  };
89
89
  if (data.notes)
90
90
  payload.notes = data.notes;
91
+ if (data.plan)
92
+ payload.plan = data.plan;
91
93
  if (data.company_id)
92
94
  payload.company_id = data.company_id;
93
95
  if (data.assigned_to) {
@@ -108,6 +110,8 @@ function buildTaskUpdatePayload(data) {
108
110
  mapped.description = data.description;
109
111
  if (data.notes !== undefined)
110
112
  mapped.notes = data.notes;
113
+ if (data.plan !== undefined)
114
+ mapped.plan = data.plan;
111
115
  if (data.status !== undefined)
112
116
  mapped.status = data.status;
113
117
  if (data.priority !== undefined) {
@@ -654,6 +658,25 @@ export class ViviScapeClient {
654
658
  async removeTaskTag(tagId) {
655
659
  return this.get(`grouptask/tag/remove/${tagId}`);
656
660
  }
661
+ /**
662
+ * Tasks carrying one or more tags. Labels are matched trimmed and case
663
+ * insensitively; match 'all' narrows to tasks carrying every label, 'any'
664
+ * (the default) returns tasks carrying at least one. Each hit carries its
665
+ * whole tag set, so a task's other labels come back with it.
666
+ */
667
+ async searchTasksByTags(tags, opts = {}) {
668
+ const params = new URLSearchParams({
669
+ tags: tags.map((t) => t.trim()).filter(Boolean).join(','),
670
+ match: opts.match ?? 'any',
671
+ status: (opts.status ?? []).map((s) => s.trim()).filter(Boolean).join(','),
672
+ max: String(opts.max ?? 25),
673
+ });
674
+ return this.get(`grouptask/tags/search?${params.toString()}`);
675
+ }
676
+ /** Every distinct task tag on the account, with the task count per label. */
677
+ async getAccountTaskTags() {
678
+ return this.get('grouptask/tags/all');
679
+ }
657
680
  // -- Task references ----------------------------------------
658
681
  /**
659
682
  * Cross references between tasks. The link is symmetric: adding A -> B makes
@@ -1042,6 +1065,64 @@ export class ViviScapeClient {
1042
1065
  })),
1043
1066
  });
1044
1067
  }
1068
+ // -- Second Brain (knowledge base) ---------------------------
1069
+ /**
1070
+ * Semantic search over the account knowledge base -- tickets and their plans,
1071
+ * notes, products, distilled resolutions, and SOPs.
1072
+ *
1073
+ * Answers an empty list, not an error, when Second Brain is disabled for the
1074
+ * account or its AI token budget is inside the reserve, so "no hits" is NOT
1075
+ * proof the knowledge is absent. Check kbStatus() before concluding that.
1076
+ */
1077
+ async kbSearch(query, opts = {}) {
1078
+ return this.post('kb/search', {
1079
+ query,
1080
+ top_k: opts.top_k && opts.top_k > 0 ? opts.top_k : 8,
1081
+ company_id: opts.company_id,
1082
+ product_id: opts.product_id,
1083
+ source_types: opts.source_types,
1084
+ visibility: opts.visibility,
1085
+ });
1086
+ }
1087
+ async kbStatus() {
1088
+ return this.get('kb/account/status');
1089
+ }
1090
+ async kbFreshness() {
1091
+ return this.get('kb/freshness');
1092
+ }
1093
+ async listSops(opts = {}) {
1094
+ const qs = [
1095
+ opts.product_id ? `product_id=${encodeURIComponent(opts.product_id)}` : '',
1096
+ opts.status ? `status=${encodeURIComponent(opts.status)}` : '',
1097
+ ].filter(Boolean).join('&');
1098
+ return this.get(`kb/sop/list${qs ? '?' + qs : ''}`);
1099
+ }
1100
+ async getSop(sopId) {
1101
+ return this.get(`kb/sop/${sopId}`);
1102
+ }
1103
+ /**
1104
+ * Create or update an SOP -- the durable, deliberately authored half of the
1105
+ * brain, and the way an agent contributes knowledge that outlives one ticket.
1106
+ * The server stamps account and creator, then queues the SOP for indexing, so
1107
+ * a save becomes searchable on the next ingest tick rather than immediately.
1108
+ * Passing sop_id updates in place; omitting it creates.
1109
+ */
1110
+ async saveSop(sop) {
1111
+ return this.post('kb/sop/save', sop);
1112
+ }
1113
+ /**
1114
+ * Queue one source for (re)indexing. The portal already enqueues on write,
1115
+ * so this is for repairing a document that never made it into the brain.
1116
+ */
1117
+ async kbEnqueue(sourceType, sourceKey, priority = 5) {
1118
+ return this.post('kb/enqueue', {
1119
+ account_id: this.accountId,
1120
+ source_type: sourceType,
1121
+ source_key: sourceKey,
1122
+ operation: 'upsert',
1123
+ priority,
1124
+ });
1125
+ }
1045
1126
  // -- Insights -----------------------------------------------
1046
1127
  async getHoursByPerson(data) {
1047
1128
  const userId = Number(data.user_id) || this.userId;
package/dist/index.js CHANGED
@@ -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)'),
@@ -433,7 +435,7 @@ server.tool('task_get', 'Get a single task by ID', { task_id: z.number().describ
433
435
  const result = await requireClient().getTask(task_id);
434
436
  return { content: [{ type: 'text', text: json(result) }] };
435
437
  });
436
- server.tool('task_tags', 'List, add, or remove tags on a task -- the categorisation primitive of the task board, and the way to mark work an agent created or touched. Labels are stored upper-case, and add is idempotent per label: re-adding one returns the existing tag rather than a duplicate. add returns the persisted tag including its tag_id; remove takes that tag_id, not the label, and answers true. Tags are per task -- there is no tag search route, and list rows from tasks_open / project_tasks carry tags: null, so read a task tag set here or with task_get.', {
438
+ server.tool('task_tags', 'List, add, or remove tags on a task -- the categorisation primitive of the task board, and the way to mark work an agent created or touched. Labels are stored upper-case, and add is idempotent per label: re-adding one returns the existing tag rather than a duplicate. add returns the persisted tag including its tag_id; remove takes that tag_id, not the label, and answers true. This tool is per task -- list rows from tasks_open / project_tasks carry tags: null, so read a task tag set here or with task_get. To go the other way, from a label to the tasks carrying it, use tasks_by_tag.', {
437
439
  action: z.enum(['list', 'add', 'remove']).describe('What to do'),
438
440
  task_id: z.number().optional().describe('Task ID (list, add)'),
439
441
  tag: z.string().optional().describe('Tag label (add). Stored upper-case.'),
@@ -467,6 +469,19 @@ server.tool('task_search', 'Keyword search over the account tasks and tickets --
467
469
  const result = await requireClient().searchTasks(query, { exclude_task_id, max });
468
470
  return { content: [{ type: 'text', text: json(result) }] };
469
471
  });
472
+ server.tool('tasks_by_tag', 'Find the account tasks carrying given tags -- the reverse of task_tags, and the way a tag-driven queue is read (every task an agent marked, everything labelled RELEASE-BLOCKER, and so on). Labels match trimmed and case insensitively, so DEVOPS, devops and " DevOps " are the same tag. match "any" (default) returns a task carrying at least one label; match "all" narrows to tasks carrying every one. Rows are light (task_id, title, status, project, company) and carry the task whole tag set, so the other labels come back with the hit -- no follow-up task_tags call. Pass no tags to list instead the account tag vocabulary: every distinct label with the number of tasks using it, which is how to discover what is worth filtering on.', {
473
+ tags: z.array(z.string()).optional().describe('Tag labels to match. Omit or leave empty to list the account tag vocabulary instead.'),
474
+ match: z.enum(['any', 'all']).optional().describe('any = tasks carrying at least one of the tags (default); all = tasks carrying every one'),
475
+ status: z.array(z.enum(TASK_STATUSES)).optional().describe('Optional status filter, e.g. ["new","inprogress"]. Omit to include every status, completed and cancelled included.'),
476
+ max: z.number().optional().describe('Maximum rows to return (default 25, cap 100)'),
477
+ }, async ({ tags, match, status, max }) => {
478
+ const c = requireClient();
479
+ const labels = (tags ?? []).map((t) => t.trim()).filter(Boolean);
480
+ const result = labels.length
481
+ ? await c.searchTasksByTags(labels, { match, status, max })
482
+ : await c.getAccountTaskTags();
483
+ return { content: [{ type: 'text', text: json(result) }] };
484
+ });
470
485
  server.tool('tasks_pending', 'Get pending tasks for a user. Returns a page of trimmed rows; see fields/limit/offset.', {
471
486
  user_id: z.number().describe('User ID'),
472
487
  ...pageArgs,
@@ -1087,6 +1102,74 @@ server.tool('insights_all_users_time', 'Get time totals for all users', {}, asyn
1087
1102
  const result = await requireClient().getAllUsersTime();
1088
1103
  return { content: [{ type: 'text', text: json(result) }] };
1089
1104
  });
1105
+ 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.', {
1106
+ 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'),
1107
+ top_k: z.number().optional().describe('How many chunks to return (default 8)'),
1108
+ company_id: z.number().optional().describe('Restrict to knowledge tied to one company'),
1109
+ product_id: z.string().optional().describe('Restrict to knowledge tied to one product (GUID)'),
1110
+ source_types: z.array(z.enum(['ticket', 'note', 'product', 'resolution', 'sop'])).optional()
1111
+ .describe('Restrict to these kinds of source. Omit for all of them.'),
1112
+ visibility: z.array(z.string()).optional()
1113
+ .describe('Visibility bands to search; defaults to ["staff"]. Personal note knowledge is never reachable here.'),
1114
+ }, async ({ query, top_k, company_id, product_id, source_types, visibility }) => {
1115
+ const result = await requireClient().kbSearch(query, { top_k, company_id, product_id, source_types, visibility });
1116
+ return { content: [{ type: 'text', text: json(result) }] };
1117
+ });
1118
+ 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 () => {
1119
+ const c = requireClient();
1120
+ const [status, freshness] = await Promise.all([c.kbStatus(), c.kbFreshness()]);
1121
+ return { content: [{ type: 'text', text: json({ status, freshness }) }] };
1122
+ });
1123
+ 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.', {
1124
+ action: z.enum(['list', 'get', 'save']).describe('What to do'),
1125
+ sop_id: z.string().optional().describe('SOP ID (get; and on save, to update that SOP in place instead of creating one)'),
1126
+ title: z.string().optional().describe('SOP title (save, required)'),
1127
+ body: z.string().optional().describe('The procedure itself (save). Plain text or light markdown; this is what gets embedded and retrieved.'),
1128
+ triggers: z.string().optional().describe('When this SOP applies -- the symptoms, phrases or conditions that should pull it up (save)'),
1129
+ product_id: z.string().optional().describe('Product this SOP belongs to (GUID), for both filtering a list and tagging a save'),
1130
+ source_task_id: z.number().optional().describe('The task or ticket this SOP was written from (save) -- keeps the provenance of the procedure'),
1131
+ status: z.enum(['draft', 'approved', 'archived']).optional()
1132
+ .describe('On list, filter by status. On save, the status to store -- leave unset when creating so it lands as a draft for review.'),
1133
+ visibility: z.enum(['staff', 'customer']).optional()
1134
+ .describe('Who may retrieve this SOP (save). Defaults to staff; use customer only for text fit to leave the building.'),
1135
+ }, async ({ action, sop_id, title, body, triggers, product_id, source_task_id, status, visibility }) => {
1136
+ const c = requireClient();
1137
+ let result;
1138
+ if (action === 'list') {
1139
+ result = await c.listSops({ product_id, status });
1140
+ }
1141
+ else if (action === 'get') {
1142
+ result = await c.getSop(need(action, 'sop_id', sop_id));
1143
+ }
1144
+ else {
1145
+ // The server requires a title and fills account/creator itself. Only send
1146
+ // what was supplied: an omitted field must not blank a stored one.
1147
+ const sop = { title: need(action, 'title', title) };
1148
+ if (sop_id !== undefined)
1149
+ sop.sop_id = sop_id;
1150
+ if (body !== undefined)
1151
+ sop.body = body;
1152
+ if (triggers !== undefined)
1153
+ sop.triggers = triggers;
1154
+ if (product_id !== undefined)
1155
+ sop.product_id = product_id;
1156
+ if (source_task_id !== undefined)
1157
+ sop.source_task_id = source_task_id;
1158
+ if (status !== undefined)
1159
+ sop.status = status;
1160
+ if (visibility !== undefined)
1161
+ sop.visibility = visibility;
1162
+ result = await c.saveSop(sop);
1163
+ }
1164
+ return { content: [{ type: 'text', text: json(result) }] };
1165
+ });
1166
+ 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.', {
1167
+ source_type: z.enum(['ticket', 'note', 'product', 'resolution', 'sop']).describe('Kind of source to reindex'),
1168
+ source_key: z.string().describe('Task id (ticket) or GUID (note, product, resolution, sop)'),
1169
+ }, async ({ source_type, source_key }) => {
1170
+ const result = await requireClient().kbEnqueue(source_type, source_key);
1171
+ return { content: [{ type: 'text', text: json(result) }] };
1172
+ });
1090
1173
  // ============================================================
1091
1174
  // START
1092
1175
  // ============================================================
@@ -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.6.0",
3
+ "version": "2.8.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",