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