viviscape-mcp 2.4.1 → 2.7.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 +253 -195
- package/dist/api-client.d.ts +43 -0
- package/dist/api-client.js +82 -0
- package/dist/auth/permissions.js +3 -0
- package/dist/enums.d.ts +2 -2
- package/dist/enums.js +1 -0
- package/dist/index.js +74 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,195 +1,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`
|
|
110
|
-
- **
|
|
111
|
-
- **
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
115
|
-
- **
|
|
116
|
-
- **
|
|
117
|
-
- **
|
|
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
|
-
duplicate.
|
|
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
|
-
and
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1
|
+
# viviscape-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for the [ViviScape](https://viviscape.io) API - exposes CRM, projects, companies, tasks, time tracking, notes, and insights as tools for Claude and other MCP clients.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
No install needed - run via `npx`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx viviscape-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install globally:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g viviscape-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Sign in
|
|
20
|
+
|
|
21
|
+
Authentication is per-user, the same browser flow the ViviScape CLI uses - no
|
|
22
|
+
API keys. Sign in once:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx viviscape-mcp login
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A browser tab opens on ViviScape Work; confirm **Allow access** and the session
|
|
29
|
+
is stored in Windows Credential Manager (or `~/.viviscape/mcp-credentials.json`
|
|
30
|
+
on macOS/Linux). Every tool call then runs as *you*: your user id, account,
|
|
31
|
+
role, and plan come from the session, so tools see exactly the data you can see
|
|
32
|
+
in the app.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx viviscape-mcp status # who am I, which account, role, plan
|
|
36
|
+
npx viviscape-mcp logout # clear the stored session
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If you already ran `vs auth login` with the [ViviScape CLI](https://viviscape.io),
|
|
40
|
+
that session is picked up automatically. Inside an MCP client you can also call
|
|
41
|
+
the `auth_login`, `auth_status`, and `auth_logout` tools. Sessions expire; when
|
|
42
|
+
one does, tools report it and you re-run `login`.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
| Variable | Required | Default | Description |
|
|
47
|
+
|----------|----------|---------|-------------|
|
|
48
|
+
| `VIVISCAPE_BASE_URL` | no | `https://work.viviscape.io` | ViviScape Work base URL |
|
|
49
|
+
|
|
50
|
+
## Claude Desktop / Claude Code
|
|
51
|
+
|
|
52
|
+
Add to your MCP client config (e.g. `claude_desktop_config.json` or `.mcp.json`):
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"viviscape": {
|
|
58
|
+
"command": "npx",
|
|
59
|
+
"args": ["-y", "viviscape-mcp"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## OpenAI Codex CLI
|
|
66
|
+
|
|
67
|
+
Codex accepts stdio MCP servers directly. Add to `~/.codex/config.toml`:
|
|
68
|
+
|
|
69
|
+
```toml
|
|
70
|
+
[mcp_servers.viviscape]
|
|
71
|
+
command = "npx"
|
|
72
|
+
args = ["-y", "viviscape-mcp"]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## ChatGPT (Developer Mode / Apps SDK)
|
|
76
|
+
|
|
77
|
+
ChatGPT only accepts **remote HTTP** MCP servers - it does not run local stdio
|
|
78
|
+
processes. Bridge this server to an HTTP endpoint with
|
|
79
|
+
[`mcp-remote`](https://www.npmjs.com/package/mcp-remote) or
|
|
80
|
+
[`supergateway`](https://www.npmjs.com/package/supergateway), then expose it
|
|
81
|
+
publicly (Cloudflare Tunnel, ngrok, or deploy to a host).
|
|
82
|
+
|
|
83
|
+
Example with `supergateway`:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx viviscape-mcp login # once, on the host running the gateway
|
|
87
|
+
npx -y supergateway --stdio "npx -y viviscape-mcp" --port 8000
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The gateway shares the host's stored session, so everyone reaching that endpoint
|
|
91
|
+
acts as the signed-in user - put your own auth in front of it.
|
|
92
|
+
|
|
93
|
+
Expose `http://localhost:8000/sse` publicly, then in ChatGPT:
|
|
94
|
+
|
|
95
|
+
**Settings -> Connectors -> Create -> Custom MCP server**
|
|
96
|
+
- **URL:** `https://your-public-host/sse`
|
|
97
|
+
- **Auth:** none (or bearer, depending on your gateway)
|
|
98
|
+
|
|
99
|
+
## Tools
|
|
100
|
+
|
|
101
|
+
The server exposes tools across these domains:
|
|
102
|
+
|
|
103
|
+
- **Prospects** - `prospect_add`, `prospect_get`, `prospect_update`, `prospect_query`, follow-ups, notes
|
|
104
|
+
- **Clients** - `client_add`, `client_get`, `client_get_by_email`, `clients_by_company`
|
|
105
|
+
- **Companies** - `company_add`, `company_list`, `company_update`
|
|
106
|
+
- **Projects** - `project_get`, `project_list`, `project_list_active`, `project_staff`, `project_tasks`, `projects_by_company`
|
|
107
|
+
- **Tasks** - `task_add`, `task_get`, `task_update`, `tasks_open`, `tasks_pending`, `tasks_by_company`, `tasks_by_milestone`
|
|
108
|
+
- **Task comments** - `task_comments`, `task_comment_get`, `task_comment_add`, `task_comment_update`, `task_comment_remove`, `task_mark_read`
|
|
109
|
+
- **Task assignment** - `task_assignee_add`, `task_assignee_remove`, `task_set_leader`, `task_set_attention`, `task_delete`, `task_merge`
|
|
110
|
+
- **Task tags** - `task_tags` (list/add/remove), `tasks_by_tag` (tasks carrying a label, or the account tag vocabulary), and a `tags` list on `task_add`
|
|
111
|
+
- **Task references** - `task_references` (list/add/remove), `task_search` (find a task by keyword or id)
|
|
112
|
+
- **Milestones** - `milestone_list`, `milestones_active`, `milestones_by_company`, `milestones_by_user`, `milestone_get`, `milestone_add`, `milestone_update`, `milestone_clone`, `milestone_remove`
|
|
113
|
+
- **Project writes** - `project_add`, `project_update`, `project_set_status`, `project_user_add`, `project_user_remove`
|
|
114
|
+
- **Reference** - `enums` (the status, priority, and group-type values the platform accepts)
|
|
115
|
+
- **Lookup** - `services` (resolve service_id to a name), `user_lookup` (id, email, team, groups)
|
|
116
|
+
- **Bulk** - `tasks_bulk_add` (many tasks in one request)
|
|
117
|
+
- **Time logs** - `timelog_add`, `timelog_update`
|
|
118
|
+
- **Notes** - `note_add`, `note_get`, `note_update`, `note_remove`, `notes_mine`, `notes_query`, `notes_account`, `note_revisions`
|
|
119
|
+
- **Note filing** - `note_companies`, `note_users`, `note_tags`, `note_attachments`, `notebook`, `notebook_users` (each takes an `action`)
|
|
120
|
+
- **Files** - `project_files` (list/upload/update/delete/download), `task_files`
|
|
121
|
+
- **Insights** - hours by person/service/project, AI summary, person stats, time totals
|
|
122
|
+
- **Account** - `account_info`, `account_services`, `account_users`
|
|
123
|
+
- **Auth** - `auth_login`, `auth_status`, `auth_logout`
|
|
124
|
+
|
|
125
|
+
### Status and priority values
|
|
126
|
+
|
|
127
|
+
Use the `enums` tool rather than guessing. The platform's vocabularies are:
|
|
128
|
+
|
|
129
|
+
| field | values |
|
|
130
|
+
|---|---|
|
|
131
|
+
| task status | backlog, new, research, discussion, inprogress, pendingreview, inreview, testing, waitingcustomer, waitingteammember, onhold, cancelled, completed |
|
|
132
|
+
| priority | low, moderate, important, urgent, critical |
|
|
133
|
+
| project status | new, inprogress, complete |
|
|
134
|
+
| prospect status | new, firstcontact, negotiation, pending, won, lost, spam |
|
|
135
|
+
|
|
136
|
+
Two traps: tasks complete as `completed` while projects complete as
|
|
137
|
+
`complete`, and priority is *not* low/medium/high. Task and project tools
|
|
138
|
+
validate these with `z.enum`, so an invalid value is rejected before it reaches
|
|
139
|
+
the API.
|
|
140
|
+
|
|
141
|
+
### Task tags
|
|
142
|
+
|
|
143
|
+
`task_tags` lists, adds, and removes the tags shown on the task board, and
|
|
144
|
+
`task_add` takes a `tags` list so a generated task lands already categorised
|
|
145
|
+
(handy for marking agent-written work).
|
|
146
|
+
|
|
147
|
+
Verified behaviour, so callers do not have to discover it:
|
|
148
|
+
|
|
149
|
+
- Labels are stored upper-case, and `add` is idempotent per label - re-adding
|
|
150
|
+
one returns the existing tag, not a duplicate.
|
|
151
|
+
- `add` returns the persisted tag with its `tag_id`; `remove` takes that
|
|
152
|
+
`tag_id`, not the label, and answers `true` (unlike `note_tags`, whose remove
|
|
153
|
+
answers `false` even on success).
|
|
154
|
+
- `color` is any hex string, defaulting to the board palette's first entry
|
|
155
|
+
(`#00325e`).
|
|
156
|
+
- `task_tags` is per task, and list rows from `tasks_open` / `project_tasks`
|
|
157
|
+
carry `tags: null`, so read one task's tags with `task_tags` or `task_get`.
|
|
158
|
+
- `tasks_by_tag` goes the other way, from a label to the tasks carrying it.
|
|
159
|
+
Labels match trimmed and case insensitively; `match: "any"` (default) returns
|
|
160
|
+
a task carrying at least one label, `match: "all"` only tasks carrying every
|
|
161
|
+
one. An optional `status` list narrows further. Hits are light rows that
|
|
162
|
+
carry the task's whole tag set, so the other labels arrive with the hit.
|
|
163
|
+
Called with no `tags`, it returns the account's tag vocabulary instead -
|
|
164
|
+
every distinct label with the number of tasks using it.
|
|
165
|
+
- A tag failure inside `task_add` never fails the create: the task is returned
|
|
166
|
+
with the per-tag error recorded in its `tags` array.
|
|
167
|
+
|
|
168
|
+
### Task references
|
|
169
|
+
|
|
170
|
+
`task_references` links related tasks to each other - a duplicate, a blocker,
|
|
171
|
+
the ticket a task came from - and `task_search` finds the id to link when only
|
|
172
|
+
a keyword is known.
|
|
173
|
+
|
|
174
|
+
Verified behaviour:
|
|
175
|
+
|
|
176
|
+
- Links are **symmetric**. Adding A to B makes the pair visible from both
|
|
177
|
+
tasks, so link once; mirroring it by hand just returns the same row.
|
|
178
|
+
- `list` rows describe the *other* task: `related_task_id` plus its title,
|
|
179
|
+
status, project and company, so a reference list needs no follow-up
|
|
180
|
+
`task_get`.
|
|
181
|
+
- `add` is idempotent per pair (in either direction) and returns the persisted
|
|
182
|
+
link with its `reference_id`; `remove` takes that `reference_id`, not a task
|
|
183
|
+
id, and answers `true`.
|
|
184
|
+
- Both tasks must belong to the signed-in account; a cross-account id comes
|
|
185
|
+
back as an empty link rather than an error.
|
|
186
|
+
- `task_search` matches the task title, the description, and - for a numeric
|
|
187
|
+
query - the task id itself. It covers tickets too, so it is the one keyword
|
|
188
|
+
route that reaches ticket rows `tasks_open` never returns.
|
|
189
|
+
|
|
190
|
+
### Task notes
|
|
191
|
+
|
|
192
|
+
`task_update` takes a `notes` field: the free-form working notes on the task's
|
|
193
|
+
Overview tab, stored as HTML by the rich-text editor. It **replaces** the field
|
|
194
|
+
outright, so read the current value with `task_get` and send the merged text
|
|
195
|
+
rather than only the new lines.
|
|
196
|
+
|
|
197
|
+
### Repeat-safe creates
|
|
198
|
+
|
|
199
|
+
No create route accepts an idempotency key, so a retried agent step silently
|
|
200
|
+
creates a second record. `task_add`, `note_add`, `timelog_add`,
|
|
201
|
+
`prospect_add`, and `tasks_bulk_add` accept an optional
|
|
202
|
+
`idempotency_key`: calling again with the same key replays the first result
|
|
203
|
+
instead of writing again.
|
|
204
|
+
|
|
205
|
+
The record is local (`~/.viviscape/mcp-idempotency.json`, 7-day expiry), so it
|
|
206
|
+
stops the common case -- the same agent retrying the same step -- and does not
|
|
207
|
+
make the server idempotent. Two machines running the same plan will still
|
|
208
|
+
duplicate.
|
|
209
|
+
|
|
210
|
+
### Delta queries
|
|
211
|
+
|
|
212
|
+
List tools accept `updated_since` (ISO 8601) and return only rows touched at
|
|
213
|
+
or after that time, so a recurring agent does not re-read the whole working set.
|
|
214
|
+
It is filtered in-process (no list route supports a modified-since filter), and
|
|
215
|
+
rows carrying no usable timestamp are kept rather than dropped.
|
|
216
|
+
|
|
217
|
+
### Paging and field selection
|
|
218
|
+
|
|
219
|
+
List tools (`tasks_open`, `project_list_active`, `project_tasks`, and the rest)
|
|
220
|
+
return a page of trimmed rows rather than every column of every row. A task row
|
|
221
|
+
carries ~94 columns and a project row 87, most of them irrelevant to project
|
|
222
|
+
work, so each list tool accepts:
|
|
223
|
+
|
|
224
|
+
- `fields` - columns to return; omit for a curated default, or pass `["all"]`
|
|
225
|
+
- `limit` / `offset` - page window, default 50 rows
|
|
226
|
+
|
|
227
|
+
Task rows also carry computed `due_date`, `due_in_days` (negative when
|
|
228
|
+
overdue), and `overdue`, derived from the task's `end` date. Read those rather
|
|
229
|
+
than the API's `deadline` string, which does not track the due date -- it
|
|
230
|
+
reported "In 2 Days" for a task 39 days overdue. `deadline` is excluded from the
|
|
231
|
+
default field set; ask for it explicitly if you need to see what the UI shows.
|
|
232
|
+
|
|
233
|
+
Responses are wrapped as `{ total, returned, offset, next_offset, fields, items }`
|
|
234
|
+
so a caller can tell when more rows exist. Task lists also accept
|
|
235
|
+
`company_id`, `project_id`, `assignee_id`, `status`, `priority`,
|
|
236
|
+
`due_before`, `due_after`, and `search`; `tasks_open` pushes `company_id`
|
|
237
|
+
and `only_mine` to the server and filters the rest in-process.
|
|
238
|
+
|
|
239
|
+
In practice this took `tasks_open` from 97 KB to 6.4 KB and
|
|
240
|
+
`project_list_active` from 375 KB to 19 KB.
|
|
241
|
+
|
|
242
|
+
## Development
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
npm install
|
|
246
|
+
npm run dev # run with tsx, hot reload
|
|
247
|
+
npm run build # compile to dist/
|
|
248
|
+
npm start # run compiled
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT
|
package/dist/api-client.d.ts
CHANGED
|
@@ -7,6 +7,12 @@ export declare class ApiError extends Error {
|
|
|
7
7
|
status: number;
|
|
8
8
|
constructor(status: number, message: string);
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Tag colours the ViviScape Work task board offers (tasks.js \$scope.tagPalette).
|
|
12
|
+
* The API takes any hex string; staying inside the palette keeps agent-written
|
|
13
|
+
* tags visually consistent with the ones people add by hand. First is default.
|
|
14
|
+
*/
|
|
15
|
+
export declare const TAG_PALETTE: readonly ["#00325e", "#13B955", "#EFA31D", "#FF5757", "#009CDC", "#6610f2", "#fd7e14", "#e83e8c", "#20c997", "#868e96"];
|
|
10
16
|
/**
|
|
11
17
|
* Client for the ViviScape Work core API. Authenticates exactly like the
|
|
12
18
|
* ViviScape CLI: the signed-in user's bearer token plus the user_id / pid
|
|
@@ -147,6 +153,43 @@ export declare class ViviScapeClient {
|
|
|
147
153
|
deleteTask(taskId: number): Promise<unknown>;
|
|
148
154
|
/** Fold one task into another; the source task is consumed. */
|
|
149
155
|
mergeTasks(fromTaskId: number, toTaskId: number): Promise<unknown>;
|
|
156
|
+
getTaskTags(taskId: number): Promise<unknown>;
|
|
157
|
+
/**
|
|
158
|
+
* Tag a task. The web app upper-cases the label and always sends a colour, so
|
|
159
|
+
* match it: tags written by an agent then look and sort like the human ones.
|
|
160
|
+
* Returns the persisted Group_Task_Tag, including its tag_id.
|
|
161
|
+
*/
|
|
162
|
+
addTaskTag(taskId: number, tag: string, color?: string): Promise<unknown>;
|
|
163
|
+
/** Takes the tag_id from getTaskTags, not the label. */
|
|
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>;
|
|
178
|
+
/**
|
|
179
|
+
* Cross references between tasks. The link is symmetric: adding A -> B makes
|
|
180
|
+
* the pair visible from both tasks, and each row describes the OTHER task
|
|
181
|
+
* (related_task_id, its title and status), so no second fetch is needed.
|
|
182
|
+
*/
|
|
183
|
+
getTaskReferences(taskId: number): Promise<unknown>;
|
|
184
|
+
/** Idempotent per pair: re-adding an existing link returns that link. */
|
|
185
|
+
addTaskReference(taskId: number, relatedTaskId: number, note?: string): Promise<unknown>;
|
|
186
|
+
/** Takes the reference_id from getTaskReferences, not a task id. */
|
|
187
|
+
removeTaskReference(referenceId: string): Promise<unknown>;
|
|
188
|
+
/** Keyword search over the account's tasks -- matches title, description and task id. */
|
|
189
|
+
searchTasks(query: string, opts?: {
|
|
190
|
+
exclude_task_id?: number;
|
|
191
|
+
max?: number;
|
|
192
|
+
}): Promise<unknown>;
|
|
150
193
|
getMilestones(projectId: number): Promise<unknown>;
|
|
151
194
|
getActiveMilestones(): Promise<unknown>;
|
|
152
195
|
getMilestonesByCompany(companyId: number): Promise<unknown>;
|
package/dist/api-client.js
CHANGED
|
@@ -106,6 +106,8 @@ function buildTaskUpdatePayload(data) {
|
|
|
106
106
|
mapped.task = data.title;
|
|
107
107
|
if (data.description !== undefined)
|
|
108
108
|
mapped.description = data.description;
|
|
109
|
+
if (data.notes !== undefined)
|
|
110
|
+
mapped.notes = data.notes;
|
|
109
111
|
if (data.status !== undefined)
|
|
110
112
|
mapped.status = data.status;
|
|
111
113
|
if (data.priority !== undefined) {
|
|
@@ -181,6 +183,16 @@ function buildTimeLogPayload(data, ctx) {
|
|
|
181
183
|
}
|
|
182
184
|
const EPOCH = '2000-01-01T00:00:00';
|
|
183
185
|
const EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
|
|
186
|
+
/**
|
|
187
|
+
* Tag colours the ViviScape Work task board offers (tasks.js \$scope.tagPalette).
|
|
188
|
+
* The API takes any hex string; staying inside the palette keeps agent-written
|
|
189
|
+
* tags visually consistent with the ones people add by hand. First is default.
|
|
190
|
+
*/
|
|
191
|
+
export const TAG_PALETTE = [
|
|
192
|
+
'#00325e', '#13B955', '#EFA31D', '#FF5757',
|
|
193
|
+
'#009CDC', '#6610f2', '#fd7e14', '#e83e8c',
|
|
194
|
+
'#20c997', '#868e96',
|
|
195
|
+
];
|
|
184
196
|
function isoDate(value, fallback) {
|
|
185
197
|
if (typeof value === 'string' && value.trim())
|
|
186
198
|
return value;
|
|
@@ -621,6 +633,76 @@ export class ViviScapeClient {
|
|
|
621
633
|
async mergeTasks(fromTaskId, toTaskId) {
|
|
622
634
|
return this.post('tasks/merge', { task_from_id: fromTaskId, task_to_id: toTaskId });
|
|
623
635
|
}
|
|
636
|
+
// -- Task tags ----------------------------------------------
|
|
637
|
+
async getTaskTags(taskId) {
|
|
638
|
+
return this.get(`grouptask/tags/${taskId}`);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Tag a task. The web app upper-cases the label and always sends a colour, so
|
|
642
|
+
* match it: tags written by an agent then look and sort like the human ones.
|
|
643
|
+
* Returns the persisted Group_Task_Tag, including its tag_id.
|
|
644
|
+
*/
|
|
645
|
+
async addTaskTag(taskId, tag, color) {
|
|
646
|
+
return this.post('grouptask/tags/add', {
|
|
647
|
+
tag_id: EMPTY_GUID,
|
|
648
|
+
task_id: taskId,
|
|
649
|
+
tag: tag.trim().toUpperCase(),
|
|
650
|
+
color: color || TAG_PALETTE[0],
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
/** Takes the tag_id from getTaskTags, not the label. */
|
|
654
|
+
async removeTaskTag(tagId) {
|
|
655
|
+
return this.get(`grouptask/tag/remove/${tagId}`);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Tasks carrying one or more tags. Labels are matched trimmed and case
|
|
659
|
+
* insensitively; match 'all' narrows to tasks carrying every label, 'any'
|
|
660
|
+
* (the default) returns tasks carrying at least one. Each hit carries its
|
|
661
|
+
* whole tag set, so a task's other labels come back with it.
|
|
662
|
+
*/
|
|
663
|
+
async searchTasksByTags(tags, opts = {}) {
|
|
664
|
+
const params = new URLSearchParams({
|
|
665
|
+
tags: tags.map((t) => t.trim()).filter(Boolean).join(','),
|
|
666
|
+
match: opts.match ?? 'any',
|
|
667
|
+
status: (opts.status ?? []).map((s) => s.trim()).filter(Boolean).join(','),
|
|
668
|
+
max: String(opts.max ?? 25),
|
|
669
|
+
});
|
|
670
|
+
return this.get(`grouptask/tags/search?${params.toString()}`);
|
|
671
|
+
}
|
|
672
|
+
/** Every distinct task tag on the account, with the task count per label. */
|
|
673
|
+
async getAccountTaskTags() {
|
|
674
|
+
return this.get('grouptask/tags/all');
|
|
675
|
+
}
|
|
676
|
+
// -- Task references ----------------------------------------
|
|
677
|
+
/**
|
|
678
|
+
* Cross references between tasks. The link is symmetric: adding A -> B makes
|
|
679
|
+
* the pair visible from both tasks, and each row describes the OTHER task
|
|
680
|
+
* (related_task_id, its title and status), so no second fetch is needed.
|
|
681
|
+
*/
|
|
682
|
+
async getTaskReferences(taskId) {
|
|
683
|
+
return this.get(`grouptask/references/${taskId}`);
|
|
684
|
+
}
|
|
685
|
+
/** Idempotent per pair: re-adding an existing link returns that link. */
|
|
686
|
+
async addTaskReference(taskId, relatedTaskId, note) {
|
|
687
|
+
return this.post('grouptask/references/add', {
|
|
688
|
+
task_id: taskId,
|
|
689
|
+
related_task_id: relatedTaskId,
|
|
690
|
+
note: note || '',
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
/** Takes the reference_id from getTaskReferences, not a task id. */
|
|
694
|
+
async removeTaskReference(referenceId) {
|
|
695
|
+
return this.get(`grouptask/reference/remove/${referenceId}`);
|
|
696
|
+
}
|
|
697
|
+
/** Keyword search over the account's tasks -- matches title, description and task id. */
|
|
698
|
+
async searchTasks(query, opts = {}) {
|
|
699
|
+
const params = new URLSearchParams({
|
|
700
|
+
query,
|
|
701
|
+
exclude_task_id: String(opts.exclude_task_id ?? 0),
|
|
702
|
+
max: String(opts.max ?? 25),
|
|
703
|
+
});
|
|
704
|
+
return this.get(`grouptask/search?${params.toString()}`);
|
|
705
|
+
}
|
|
624
706
|
// -- Milestones ---------------------------------------------
|
|
625
707
|
async getMilestones(projectId) {
|
|
626
708
|
return this.get(`milestones/list/${projectId}`);
|
package/dist/auth/permissions.js
CHANGED
|
@@ -66,5 +66,8 @@ export function featureForPath(path) {
|
|
|
66
66
|
return 'comment';
|
|
67
67
|
if (p.startsWith('task'))
|
|
68
68
|
return 'tasks';
|
|
69
|
+
// Task tags live under grouptask/*, which would otherwise fall through to auth.
|
|
70
|
+
if (p.startsWith('grouptask'))
|
|
71
|
+
return 'tasks';
|
|
69
72
|
return 'auth';
|
|
70
73
|
}
|
package/dist/enums.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* low/medium/high, which is what this server's task_add used to claim.
|
|
13
13
|
*/
|
|
14
14
|
/** Task status values, in board order. */
|
|
15
|
-
export declare const TASK_STATUSES: readonly ["backlog", "new", "research", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
|
|
15
|
+
export declare const TASK_STATUSES: readonly ["backlog", "new", "research", "discussion", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
|
|
16
16
|
/** Task and project priority values, ascending. */
|
|
17
17
|
export declare const PRIORITIES: readonly ["low", "moderate", "important", "urgent", "critical"];
|
|
18
18
|
/**
|
|
@@ -34,7 +34,7 @@ export type Priority = (typeof PRIORITIES)[number];
|
|
|
34
34
|
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
|
35
35
|
/** Payload for the enums tool, so callers can discover these without guessing. */
|
|
36
36
|
export declare function enumReference(): {
|
|
37
|
-
task_status: readonly ["backlog", "new", "research", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
|
|
37
|
+
task_status: readonly ["backlog", "new", "research", "discussion", "inprogress", "pendingreview", "inreview", "testing", "waitingcustomer", "waitingteammember", "onhold", "cancelled", "completed"];
|
|
38
38
|
priority: readonly ["low", "moderate", "important", "urgent", "critical"];
|
|
39
39
|
project_status: readonly ["new", "inprogress", "complete"];
|
|
40
40
|
prospect_status: readonly ["new", "firstcontact", "negotiation", "pending", "won", "lost", "spam"];
|
package/dist/enums.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { config } from 'dotenv';
|
|
6
|
-
import { ViviScapeClient } from './api-client.js';
|
|
6
|
+
import { TAG_PALETTE, ViviScapeClient } from './api-client.js';
|
|
7
7
|
import { AuthService } from './auth/auth-service.js';
|
|
8
8
|
import { describe } from './auth/credentials.js';
|
|
9
9
|
import { PRIORITIES, PROJECT_STATUSES, PROSPECT_STATUSES, TASK_STATUSES, enumReference, } from './enums.js';
|
|
@@ -387,15 +387,40 @@ server.tool('task_add', 'Add a task to a project. The API requires a full Group_
|
|
|
387
387
|
start_date: z.string().optional().describe('Start date (ISO 8601)'),
|
|
388
388
|
due_date: z.string().optional().describe('Due date (ISO 8601)'),
|
|
389
389
|
milestone_id: z.number().optional().describe('Milestone ID'),
|
|
390
|
+
tags: z.array(z.string()).optional().describe('Tag labels to apply to the new task (stored upper-case). Applied after creation, in the board default colour; use task_tags to pick a colour.'),
|
|
390
391
|
...idempotencyArg,
|
|
391
392
|
}, async (params) => {
|
|
392
|
-
const result = await once('task_add', params.idempotency_key, () =>
|
|
393
|
+
const result = await once('task_add', params.idempotency_key, async () => {
|
|
394
|
+
const c = requireClient();
|
|
395
|
+
const task = await c.addTask(params);
|
|
396
|
+
if (!params.tags?.length)
|
|
397
|
+
return task;
|
|
398
|
+
const row = task && typeof task === 'object' ? task : null;
|
|
399
|
+
const taskId = Number(row?.task_id ?? 0);
|
|
400
|
+
if (!taskId) {
|
|
401
|
+
return { task, tags_skipped: 'The create response carried no task_id; tag the task with task_tags.' };
|
|
402
|
+
}
|
|
403
|
+
// A tag failure must not throw: the task already exists, and an error here
|
|
404
|
+
// would leave the idempotency key unrecorded, so a retry would create a
|
|
405
|
+
// second task. Report per-tag outcomes as data instead.
|
|
406
|
+
const tags = [];
|
|
407
|
+
for (const label of params.tags) {
|
|
408
|
+
try {
|
|
409
|
+
tags.push(await c.addTaskTag(taskId, label));
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
tags.push({ tag: label, error: err instanceof Error ? err.message : String(err) });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { ...row, tags };
|
|
416
|
+
});
|
|
393
417
|
return { content: [{ type: 'text', text: json(result) }] };
|
|
394
418
|
});
|
|
395
419
|
server.tool('task_update', 'Update a task. Fetches existing task first and merges changes.', {
|
|
396
420
|
task_id: z.number().describe('Task ID to update'),
|
|
397
421
|
title: z.string().optional().describe('Task title'),
|
|
398
422
|
description: z.string().optional(),
|
|
423
|
+
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.'),
|
|
399
424
|
assigned_to: z.number().optional().describe('User ID to assign to'),
|
|
400
425
|
priority: z.enum(PRIORITIES).optional().describe('Priority (platform values)'),
|
|
401
426
|
status: z.enum(TASK_STATUSES).optional().describe('Task status (platform values)'),
|
|
@@ -408,6 +433,53 @@ server.tool('task_get', 'Get a single task by ID', { task_id: z.number().describ
|
|
|
408
433
|
const result = await requireClient().getTask(task_id);
|
|
409
434
|
return { content: [{ type: 'text', text: json(result) }] };
|
|
410
435
|
});
|
|
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. 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
|
+
action: z.enum(['list', 'add', 'remove']).describe('What to do'),
|
|
438
|
+
task_id: z.number().optional().describe('Task ID (list, add)'),
|
|
439
|
+
tag: z.string().optional().describe('Tag label (add). Stored upper-case.'),
|
|
440
|
+
color: z.string().optional().describe(`Hex colour for the tag (add). Defaults to ${TAG_PALETTE[0]}; the board palette is ${TAG_PALETTE.join(' ')}.`),
|
|
441
|
+
tag_id: z.string().optional().describe('Tag ID from list (remove)'),
|
|
442
|
+
}, async ({ action, task_id, tag, color, tag_id }) => {
|
|
443
|
+
const c = requireClient();
|
|
444
|
+
const result = action === 'list' ? await c.getTaskTags(need(action, 'task_id', task_id))
|
|
445
|
+
: action === 'add' ? await c.addTaskTag(need(action, 'task_id', task_id), need(action, 'tag', tag), color)
|
|
446
|
+
: await c.removeTaskTag(need(action, 'tag_id', tag_id));
|
|
447
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
448
|
+
});
|
|
449
|
+
server.tool('task_references', 'List, add, or remove cross references between tasks -- the way related work is linked so one task points at another (a duplicate, a blocker, the ticket a task came from). Links are SYMMETRIC: adding A -> B makes the pair visible from both tasks, so link once and never mirror it by hand. list rows describe the OTHER task -- related_task_id plus its title, status and project -- so no second task_get is needed to report them. add is idempotent per pair and returns the persisted link including its reference_id; remove takes that reference_id, not a task id, and answers true. Both tasks must belong to the signed-in account. Use task_search to find the id to link when only a keyword is known.', {
|
|
450
|
+
action: z.enum(['list', 'add', 'remove']).describe('What to do'),
|
|
451
|
+
task_id: z.number().optional().describe('Task ID whose references are read or extended (list, add)'),
|
|
452
|
+
related_task_id: z.number().optional().describe('Task ID to link to task_id (add)'),
|
|
453
|
+
note: z.string().optional().describe('Optional note describing why the two are linked (add)'),
|
|
454
|
+
reference_id: z.string().optional().describe('Reference ID from list (remove)'),
|
|
455
|
+
}, async ({ action, task_id, related_task_id, note, reference_id }) => {
|
|
456
|
+
const c = requireClient();
|
|
457
|
+
const result = action === 'list' ? await c.getTaskReferences(need(action, 'task_id', task_id))
|
|
458
|
+
: action === 'add' ? await c.addTaskReference(need(action, 'task_id', task_id), need(action, 'related_task_id', related_task_id), note)
|
|
459
|
+
: await c.removeTaskReference(need(action, 'reference_id', reference_id));
|
|
460
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
461
|
+
});
|
|
462
|
+
server.tool('task_search', 'Keyword search over the account tasks and tickets -- matches the title, the description, and, for a numeric query, the task id itself. Returns light rows (task_id, title, status, project, company), enough to pick an id for task_get or task_references without pulling a full task feed.', {
|
|
463
|
+
query: z.string().describe('Keyword or task id to search for'),
|
|
464
|
+
exclude_task_id: z.number().optional().describe('Task ID to leave out of the results (e.g. the task being linked from)'),
|
|
465
|
+
max: z.number().optional().describe('Maximum rows to return (default 25, cap 100)'),
|
|
466
|
+
}, async ({ query, exclude_task_id, max }) => {
|
|
467
|
+
const result = await requireClient().searchTasks(query, { exclude_task_id, max });
|
|
468
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
469
|
+
});
|
|
470
|
+
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.', {
|
|
471
|
+
tags: z.array(z.string()).optional().describe('Tag labels to match. Omit or leave empty to list the account tag vocabulary instead.'),
|
|
472
|
+
match: z.enum(['any', 'all']).optional().describe('any = tasks carrying at least one of the tags (default); all = tasks carrying every one'),
|
|
473
|
+
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.'),
|
|
474
|
+
max: z.number().optional().describe('Maximum rows to return (default 25, cap 100)'),
|
|
475
|
+
}, async ({ tags, match, status, max }) => {
|
|
476
|
+
const c = requireClient();
|
|
477
|
+
const labels = (tags ?? []).map((t) => t.trim()).filter(Boolean);
|
|
478
|
+
const result = labels.length
|
|
479
|
+
? await c.searchTasksByTags(labels, { match, status, max })
|
|
480
|
+
: await c.getAccountTaskTags();
|
|
481
|
+
return { content: [{ type: 'text', text: json(result) }] };
|
|
482
|
+
});
|
|
411
483
|
server.tool('tasks_pending', 'Get pending tasks for a user. Returns a page of trimmed rows; see fields/limit/offset.', {
|
|
412
484
|
user_id: z.number().describe('User ID'),
|
|
413
485
|
...pageArgs,
|
package/package.json
CHANGED