dokutrak-mcp 0.1.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/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/dist/cli.js +425 -0
- package/package.json +50 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `dokutrak-mcp` are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
|
+
[SemVer](https://semver.org/). The tool surface (names, descriptions, inputs)
|
|
6
|
+
is part of the public contract: it changes only with a decision recorded in
|
|
7
|
+
the DokuTrak product repository, and every such change is listed here.
|
|
8
|
+
|
|
9
|
+
## [0.1.0] — 2026-09-14
|
|
10
|
+
|
|
11
|
+
First public release. One complete round trip — ask, chase, know, collect —
|
|
12
|
+
as decided in ADR-014 of the DokuTrak product repository.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `create_request` — creates a Document Request and sends it, in one call.
|
|
17
|
+
Two API calls under the hood, creation with `sendEmail: false` then send,
|
|
18
|
+
so a failed email is reported with the id of the request it left behind.
|
|
19
|
+
- `request_replacement` — the deterministic chase on rejected files. Nothing
|
|
20
|
+
is emailed by the call itself; the request returns to the reminder cadence.
|
|
21
|
+
- `get_request` — status, checklist, every collected file with its verdict,
|
|
22
|
+
and the reminder state, in one call. By id, or by a search term.
|
|
23
|
+
- `download_documents` — every collected file as one zip, embedded in the
|
|
24
|
+
result as binary content (no link, no disk write).
|
|
25
|
+
- stdio transport; authentication by an Agent Connection read from
|
|
26
|
+
`DOKUTRAK_API_KEY`, never from the command line; `DOKUTRAK_API_URL` to
|
|
27
|
+
point at another deployment.
|
|
28
|
+
- Contract tests at the MCP seam: a real client and the real server over the
|
|
29
|
+
SDK's in-memory transport, HTTP stubbed at `fetch`, no account needed.
|
|
30
|
+
- `npm run staging` — the scripted run against a real workspace
|
|
31
|
+
(create → chase → read → collect → revoke → 401), never a CI check.
|
|
32
|
+
|
|
33
|
+
### Not in this release
|
|
34
|
+
|
|
35
|
+
- claude.ai (remote MCP over HTTP with OAuth). Only Claude Desktop and Claude
|
|
36
|
+
Code, or any client that launches a stdio server with an `env` block.
|
|
37
|
+
- Approving or rejecting a document, billing, workspace settings, key
|
|
38
|
+
management: the service refuses them to every Agent Connection, whatever
|
|
39
|
+
the connector.
|
|
40
|
+
|
|
41
|
+
[0.1.0]: https://github.com/Crackx17/dokutrak-mcp/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arthur
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# dokutrak-mcp
|
|
2
|
+
|
|
3
|
+
The open [MCP](https://modelcontextprotocol.io) connector for [DokuTrak](https://dokutrak.com):
|
|
4
|
+
let your agent chase the documents.
|
|
5
|
+
|
|
6
|
+
DokuTrak collects documents from your clients on your behalf: you send a request, the client
|
|
7
|
+
uploads through a secure link, the files are reviewed, and silent clients get reminded. This
|
|
8
|
+
connector puts that loop inside the agent you already work in, so "where does the Dupont file
|
|
9
|
+
stand?" is answered without leaving Claude.
|
|
10
|
+
|
|
11
|
+
The connector is a thin, stateless client of the DokuTrak API. It holds the Agent Connection
|
|
12
|
+
you give it, stores nothing on disk, keeps no cache, and duplicates no rule: what your agent may
|
|
13
|
+
and may not do is decided by the service, and refusals come back as tool errors with the
|
|
14
|
+
service's own explanation.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
You need a DokuTrak workspace and an **Agent Connection**, issued from
|
|
19
|
+
**Settings → Connect an agent** in the DokuTrak app. That screen hands you a paste-ready
|
|
20
|
+
configuration with your key already in place; the instructions below are the same thing, by hand.
|
|
21
|
+
|
|
22
|
+
The key is read from the environment variable `DOKUTRAK_API_KEY`. It is never taken from the
|
|
23
|
+
command line.
|
|
24
|
+
|
|
25
|
+
### Claude Desktop
|
|
26
|
+
|
|
27
|
+
Open the configuration file:
|
|
28
|
+
|
|
29
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
30
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
31
|
+
|
|
32
|
+
Add the server under `mcpServers` (create the object if the file is empty):
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"dokutrak": {
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": ["-y", "dokutrak-mcp"],
|
|
40
|
+
"env": { "DOKUTRAK_API_KEY": "dk_live_…" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Restart Claude Desktop. The DokuTrak tools appear in the tools menu of a new conversation.
|
|
47
|
+
|
|
48
|
+
### Claude Code
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
claude mcp add dokutrak -e DOKUTRAK_API_KEY=dk_live_… -- npx -y dokutrak-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then `/mcp` inside Claude Code lists `dokutrak` and its tools.
|
|
55
|
+
|
|
56
|
+
### claude.ai
|
|
57
|
+
|
|
58
|
+
**Not supported in this release.** claude.ai connects to remote MCP servers over HTTP with
|
|
59
|
+
OAuth; this connector speaks stdio with an API key, which is what a local install into Claude
|
|
60
|
+
Desktop or Claude Code needs. A hosted variant is a separate, later decision.
|
|
61
|
+
|
|
62
|
+
### From a clone, before the npm release
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git clone https://github.com/Crackx17/dokutrak-mcp.git
|
|
66
|
+
cd dokutrak-mcp
|
|
67
|
+
npm ci && npm run build
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then point the client at the built file instead of `npx`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"dokutrak": {
|
|
76
|
+
"command": "node",
|
|
77
|
+
"args": ["/path/to/dokutrak-mcp/dist/cli.js"],
|
|
78
|
+
"env": { "DOKUTRAK_API_KEY": "dk_live_…" }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
or, for Claude Code: `claude mcp add dokutrak -e DOKUTRAK_API_KEY=dk_live_… -- node /path/to/dokutrak-mcp/dist/cli.js`.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
| Variable | Required | Default | Meaning |
|
|
89
|
+
| ------------------ | -------- | -------------------------------- | ---------------------------------------------------------- |
|
|
90
|
+
| `DOKUTRAK_API_KEY` | yes | — | The Agent Connection, from Settings → Connect an agent. |
|
|
91
|
+
| `DOKUTRAK_API_URL` | no | `https://app.dokutrak.com/api` | Base URL of the API. Ends in `/api`; the connector adds `/v1`. |
|
|
92
|
+
|
|
93
|
+
## Tools
|
|
94
|
+
|
|
95
|
+
Four tools, one round trip: ask, chase, know, collect.
|
|
96
|
+
|
|
97
|
+
### `create_request`
|
|
98
|
+
|
|
99
|
+
Creates a Document Request **and** sends it, in one call, so nothing is left created but unsent.
|
|
100
|
+
Takes the client's email, a deadline (`YYYY-MM-DD` or an ISO datetime), the checklist of
|
|
101
|
+
documents wanted, and an optional title and message. The email goes to the recipient given here
|
|
102
|
+
and to nobody else; the client uploads through the secure link it contains. Under the hood this
|
|
103
|
+
is the same two-step the DokuTrak app performs: create with `sendEmail: false`, then send. If the
|
|
104
|
+
send fails, the error names the created request, which stays visible in the dashboard.
|
|
105
|
+
|
|
106
|
+
### `request_replacement`
|
|
107
|
+
|
|
108
|
+
Chases the client on the rejected files of a request: flags them, moves the request back to
|
|
109
|
+
awaiting the client, and returns it to the automatic reminder cadence. **This call sends no
|
|
110
|
+
email by itself**; the reminders do. The optional message is recorded in the request's audit
|
|
111
|
+
trail and is not sent to the client. It refuses a request with no rejected file.
|
|
112
|
+
|
|
113
|
+
### `get_request`
|
|
114
|
+
|
|
115
|
+
Where a Document Request stands, in one call: status, the checklist, every collected file with
|
|
116
|
+
its verdict (approved, rejected with the reviewer's reason, or pending), and the reminder state.
|
|
117
|
+
Give a `request_id`, or a `search` term matching the title or the client's name or email. When
|
|
118
|
+
several requests match, the tool returns the candidates and asks for the id.
|
|
119
|
+
|
|
120
|
+
### `download_documents`
|
|
121
|
+
|
|
122
|
+
Every collected file of a request, as one zip archive. The archive comes back **embedded in the
|
|
123
|
+
tool result as binary content** (an MCP resource with a base64 blob and `application/zip`), not
|
|
124
|
+
as a link: the API has no short-link endpoint for a zip, and the connector writes nothing to
|
|
125
|
+
disk. What the agent does with the bytes is decided on the professional's side, exactly like a
|
|
126
|
+
download from the browser. Large archives make large results; check with `get_request` that
|
|
127
|
+
documents have arrived before calling it.
|
|
128
|
+
|
|
129
|
+
## What the connector cannot do
|
|
130
|
+
|
|
131
|
+
Approving or rejecting a document is your decision, taken in the DokuTrak dashboard. No tool
|
|
132
|
+
here can take it, and the service refuses it to any Agent Connection regardless of which
|
|
133
|
+
connector asks. The same goes for billing, workspace settings and the management of API keys.
|
|
134
|
+
|
|
135
|
+
Revoking the Agent Connection in DokuTrak takes effect on the very next call: the connector
|
|
136
|
+
answers with the service's 401 and nothing else.
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npm ci
|
|
142
|
+
npm run check # typecheck, build, tests
|
|
143
|
+
npm test # tests alone
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The tests are contract tests at the MCP seam: a real MCP client and the real server, connected
|
|
147
|
+
in memory through the official SDK's transport, with HTTP stubbed at `fetch` using recorded
|
|
148
|
+
responses. They call tools, never functions, and run with no DokuTrak account and no network.
|
|
149
|
+
|
|
150
|
+
### The staging run
|
|
151
|
+
|
|
152
|
+
Before a release, the built binary is driven once against a real workspace, by a real MCP
|
|
153
|
+
client over stdio: create → chase → read → collect → revoke → 401. It is a record pasted into
|
|
154
|
+
the release PR, never a CI check (a blocking check calls no third party). It pauses twice for
|
|
155
|
+
acts the service refuses to any Agent Connection: rejecting the uploaded file, and revoking
|
|
156
|
+
the key.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npm run build
|
|
160
|
+
DOKUTRAK_API_KEY=dk_live_… STAGING_RECIPIENT_EMAIL=you@example.com npm run staging
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A real Document Request is created and a real email goes to `STAGING_RECIPIENT_EMAIL`. The
|
|
164
|
+
transcript lands in `staging-run-<timestamp>.md` (git-ignored); the key is never written to it.
|
|
165
|
+
|
|
166
|
+
### Releasing
|
|
167
|
+
|
|
168
|
+
A tag `vX.Y.Z` matching `package.json` and `SERVER_VERSION` triggers `.github/workflows/release.yml`:
|
|
169
|
+
`npm run check`, `npm publish` (trusted publishing through GitHub's OIDC token, provenance
|
|
170
|
+
attached), then the listing in the [MCP Registry](https://registry.modelcontextprotocol.io) as
|
|
171
|
+
`io.github.Crackx17/dokutrak-mcp` — the `mcpName` of `package.json`, which the registry checks
|
|
172
|
+
against the published tarball. Running the workflow by hand does a `--dry-run` and publishes nothing.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](LICENSE).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
var CONNECTION_ENV_VAR = "DOKUTRAK_API_KEY";
|
|
8
|
+
var API_URL_ENV_VAR = "DOKUTRAK_API_URL";
|
|
9
|
+
var DEFAULT_API_URL = "https://app.dokutrak.com/api";
|
|
10
|
+
var ConfigError = class extends Error {
|
|
11
|
+
};
|
|
12
|
+
function readConfig(env = process.env) {
|
|
13
|
+
const apiKey = env[CONNECTION_ENV_VAR]?.trim();
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new ConfigError(
|
|
16
|
+
`${CONNECTION_ENV_VAR} is not set. Create an Agent Connection in DokuTrak (Settings \u2192 Connect an agent) and put the key in the "env" block of your MCP client configuration.`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const baseUrl = (env[API_URL_ENV_VAR]?.trim() || DEFAULT_API_URL).replace(
|
|
20
|
+
/\/+$/,
|
|
21
|
+
""
|
|
22
|
+
);
|
|
23
|
+
return { apiKey, baseUrl };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/server.ts
|
|
27
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
28
|
+
|
|
29
|
+
// src/api.ts
|
|
30
|
+
var ApiError = class extends Error {
|
|
31
|
+
status;
|
|
32
|
+
title;
|
|
33
|
+
detail;
|
|
34
|
+
problem;
|
|
35
|
+
constructor(status, title, detail, problem) {
|
|
36
|
+
super(`${title} (${status}): ${detail}`);
|
|
37
|
+
this.name = "ApiError";
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.title = title;
|
|
40
|
+
this.detail = detail;
|
|
41
|
+
this.problem = problem;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
function createApiClient(options) {
|
|
45
|
+
const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
46
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
47
|
+
const headers = {
|
|
48
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
49
|
+
"User-Agent": options.userAgent ?? "dokutrak-mcp"
|
|
50
|
+
};
|
|
51
|
+
function url(path, query) {
|
|
52
|
+
const u = new URL(baseUrl + path);
|
|
53
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
54
|
+
if (value !== void 0) u.searchParams.set(key, value);
|
|
55
|
+
}
|
|
56
|
+
return u.toString();
|
|
57
|
+
}
|
|
58
|
+
async function raiseIfNotOk(res) {
|
|
59
|
+
if (res.ok) return;
|
|
60
|
+
const text = await res.text();
|
|
61
|
+
let problem;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(text);
|
|
64
|
+
if (parsed && typeof parsed === "object") problem = parsed;
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
const title = problem?.title ?? res.statusText ?? "Request failed";
|
|
68
|
+
const detail = problem?.detail ?? (text.trim() || `The API answered ${res.status} with no detail.`);
|
|
69
|
+
throw new ApiError(res.status, title, detail, problem);
|
|
70
|
+
}
|
|
71
|
+
async function json(path, init, query) {
|
|
72
|
+
const res = await fetchImpl(url(path, query), {
|
|
73
|
+
...init,
|
|
74
|
+
headers: { ...headers, Accept: "application/json", ...init.headers }
|
|
75
|
+
});
|
|
76
|
+
await raiseIfNotOk(res);
|
|
77
|
+
return await res.json();
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
get: (path, query) => json(path, { method: "GET" }, query),
|
|
81
|
+
post: (path, body) => json(path, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify(body ?? {})
|
|
85
|
+
}),
|
|
86
|
+
async getBinary(path) {
|
|
87
|
+
const res = await fetchImpl(url(path), { method: "GET", headers });
|
|
88
|
+
await raiseIfNotOk(res);
|
|
89
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
90
|
+
return {
|
|
91
|
+
bytes,
|
|
92
|
+
contentType: res.headers.get("content-type") ?? "application/octet-stream",
|
|
93
|
+
fileName: fileNameFromDisposition(res.headers.get("content-disposition"))
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function fileNameFromDisposition(header) {
|
|
99
|
+
if (!header) return void 0;
|
|
100
|
+
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
|
101
|
+
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
|
|
102
|
+
const plain = /filename="?([^";]+)"?/i.exec(header);
|
|
103
|
+
return plain?.[1];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/tools/create-request.ts
|
|
107
|
+
import { z } from "zod";
|
|
108
|
+
|
|
109
|
+
// src/result.ts
|
|
110
|
+
async function runTool(body) {
|
|
111
|
+
try {
|
|
112
|
+
return await body();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return errorResult(describe(error));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function describe(error) {
|
|
118
|
+
if (error instanceof ApiError) {
|
|
119
|
+
const code = typeof error.problem?.code === "string" ? ` [${error.problem.code}]` : "";
|
|
120
|
+
return `DokuTrak refused the call \u2014 ${error.title} (${error.status})${code}: ${error.detail}`;
|
|
121
|
+
}
|
|
122
|
+
if (error instanceof Error) {
|
|
123
|
+
return `The Connector could not reach DokuTrak: ${error.message}`;
|
|
124
|
+
}
|
|
125
|
+
return `The Connector failed: ${String(error)}`;
|
|
126
|
+
}
|
|
127
|
+
function errorResult(text) {
|
|
128
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
129
|
+
}
|
|
130
|
+
function jsonResult(value) {
|
|
131
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/tools/create-request.ts
|
|
135
|
+
var CREATE_REQUEST_DESCRIPTION = "Call this when the Professional wants to ask a Client for documents: it creates the Document Request and sends the email in one step, so nothing is left created but unsent. Provide the Client email, a deadline, and the list of documents wanted, plus an optional title and a message written by the Professional. The email goes to the recipient given here and to nobody else, and the Client uploads through the secure link it contains. If the email fails after creation, the error names the created request so it can be sent from the dashboard.";
|
|
136
|
+
var registerCreateRequest = (server, api) => {
|
|
137
|
+
server.registerTool(
|
|
138
|
+
"create_request",
|
|
139
|
+
{
|
|
140
|
+
title: "Create and send a Document Request",
|
|
141
|
+
description: CREATE_REQUEST_DESCRIPTION,
|
|
142
|
+
inputSchema: {
|
|
143
|
+
recipient_email: z.email().describe("The Client who must upload the documents. The only address the email will go to."),
|
|
144
|
+
recipient_name: z.string().min(1).max(255).optional().describe("How the Client is addressed in the email."),
|
|
145
|
+
title: z.string().min(1).max(500).optional().describe("Title of the request, as the Professional names the file."),
|
|
146
|
+
message: z.string().min(1).max(1e3).optional().describe("A message from the Professional to the Client, included in the email."),
|
|
147
|
+
deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2}))?$/, "A date (YYYY-MM-DD) or an ISO 8601 datetime.").describe("When the documents are due. A date (YYYY-MM-DD) or an ISO 8601 datetime."),
|
|
148
|
+
documents: z.array(
|
|
149
|
+
z.object({
|
|
150
|
+
title: z.string().min(1).max(500).describe("What is asked for, as the Client will read it."),
|
|
151
|
+
description: z.string().min(1).max(1e3).optional().describe("Precision for the Client, for example which period."),
|
|
152
|
+
required: z.boolean().optional().describe("Defaults to true."),
|
|
153
|
+
coverage_days: z.number().int().positive().optional().describe("For a span rather than a file: how many days the documents must cover.")
|
|
154
|
+
})
|
|
155
|
+
).min(1).describe("The checklist the Client must fill.")
|
|
156
|
+
},
|
|
157
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
|
|
158
|
+
},
|
|
159
|
+
(input) => runTool(async () => {
|
|
160
|
+
const created = await api.post("/v1/requests", {
|
|
161
|
+
recipientEmail: input.recipient_email,
|
|
162
|
+
...input.recipient_name && { recipientName: input.recipient_name },
|
|
163
|
+
...input.title && { title: input.title },
|
|
164
|
+
...input.message && { message: input.message },
|
|
165
|
+
deadline: toDeadlineIso(input.deadline),
|
|
166
|
+
sendEmail: false,
|
|
167
|
+
documents: input.documents.map((doc) => ({
|
|
168
|
+
title: doc.title,
|
|
169
|
+
...doc.description && { description: doc.description },
|
|
170
|
+
...doc.required !== void 0 && { required: doc.required },
|
|
171
|
+
...doc.coverage_days !== void 0 && { coverage_days: doc.coverage_days }
|
|
172
|
+
}))
|
|
173
|
+
});
|
|
174
|
+
const id = created.data.id;
|
|
175
|
+
try {
|
|
176
|
+
const sent = await api.post(`/v1/requests/${id}/send`, {});
|
|
177
|
+
return jsonResult({
|
|
178
|
+
requestId: id,
|
|
179
|
+
title: created.data.title,
|
|
180
|
+
status: created.data.status,
|
|
181
|
+
recipient: { name: created.data.recipientName, email: created.data.recipientEmail },
|
|
182
|
+
deadline: created.data.deadline,
|
|
183
|
+
sent: true,
|
|
184
|
+
uploadLinkExpiresAt: sent.data?.expiresAt ?? null
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const reason = error instanceof ApiError ? `${error.title} (${error.status}): ${error.detail}` : describe(error);
|
|
188
|
+
return errorResult(
|
|
189
|
+
`Document Request ${id} was created but the email did not go out \u2014 ${reason}. It is visible in the DokuTrak dashboard, where it can be sent.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
function toDeadlineIso(deadline) {
|
|
196
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(deadline) ? `${deadline}T12:00:00.000Z` : deadline;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/tools/request-replacement.ts
|
|
200
|
+
import { z as z2 } from "zod";
|
|
201
|
+
var REQUEST_REPLACEMENT_DESCRIPTION = "Call this when a Document Request has rejected files and the Professional wants the Client to send them again. It flags every rejected file, moves the request back to awaiting the Client, and returns it to the automatic reminder cadence, which is what reaches the Client. Nothing is emailed by this call itself, and the optional message is kept in the audit trail rather than sent. Do not use it on a request with no rejected file; get_request shows which files were rejected.";
|
|
202
|
+
var registerRequestReplacement = (server, api) => {
|
|
203
|
+
server.registerTool(
|
|
204
|
+
"request_replacement",
|
|
205
|
+
{
|
|
206
|
+
title: "Chase the Client on rejected files",
|
|
207
|
+
description: REQUEST_REPLACEMENT_DESCRIPTION,
|
|
208
|
+
inputSchema: {
|
|
209
|
+
request_id: z2.uuid().describe("The Document Request whose rejected files must be sent again."),
|
|
210
|
+
message: z2.string().min(1).max(1e3).optional().describe("A note from the Professional, recorded in the audit trail of the request. Not sent to the Client.")
|
|
211
|
+
},
|
|
212
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
213
|
+
},
|
|
214
|
+
({ request_id, message }) => runTool(async () => {
|
|
215
|
+
await api.post(`/v1/requests/${request_id}/request-resubmission`, {
|
|
216
|
+
...message && { message }
|
|
217
|
+
});
|
|
218
|
+
const after = await api.get(`/v1/requests/${request_id}`);
|
|
219
|
+
const r = after.data;
|
|
220
|
+
return jsonResult({
|
|
221
|
+
requestId: r.id,
|
|
222
|
+
status: r.status,
|
|
223
|
+
recipient: { name: r.recipientName, email: r.recipientEmail },
|
|
224
|
+
reminders: {
|
|
225
|
+
enabled: r.reminderEnabled ?? null,
|
|
226
|
+
sentCount: r.reminderCount ?? 0,
|
|
227
|
+
nextDueAt: r.nextReminderAt ?? null,
|
|
228
|
+
pausedUntil: r.reminderPausedUntil ?? null
|
|
229
|
+
},
|
|
230
|
+
note: "No email was sent by this call. The Client will be reached by the reminder cadence."
|
|
231
|
+
});
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// src/tools/get-request.ts
|
|
237
|
+
import { z as z3 } from "zod";
|
|
238
|
+
var GET_REQUEST_DESCRIPTION = "Call this when the Professional asks where a Document Request stands: which documents arrived, which were approved or rejected, and when the Client was last chased. Give the request id when you have it, or a search term matching the title, the Client name or the Client email; several matches come back as a short list to choose from. One call returns the status, the checklist, every collected file with its verdict, and the reminder state, so no follow-up read is needed. Approving or rejecting a document is the decision of the Professional, made in the DokuTrak dashboard, and no tool here can take it.";
|
|
239
|
+
var CANDIDATES_LIMIT = "10";
|
|
240
|
+
var registerGetRequest = (server, api) => {
|
|
241
|
+
server.registerTool(
|
|
242
|
+
"get_request",
|
|
243
|
+
{
|
|
244
|
+
title: "Where does a Document Request stand",
|
|
245
|
+
description: GET_REQUEST_DESCRIPTION,
|
|
246
|
+
inputSchema: {
|
|
247
|
+
request_id: z3.uuid().optional().describe("The id of the Document Request, when known."),
|
|
248
|
+
search: z3.string().min(1).max(200).optional().describe(
|
|
249
|
+
"Text to match against the request title, the Client name or the Client email, when the id is not known."
|
|
250
|
+
)
|
|
251
|
+
},
|
|
252
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
253
|
+
},
|
|
254
|
+
({ request_id, search }) => runTool(async () => {
|
|
255
|
+
if (request_id) return jsonResult(await compose(api, request_id));
|
|
256
|
+
if (!search) {
|
|
257
|
+
return errorResult(
|
|
258
|
+
"Give either request_id or search: without one of them there is nothing to look up."
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const list = await api.get("/v1/requests", {
|
|
262
|
+
search,
|
|
263
|
+
limit: CANDIDATES_LIMIT
|
|
264
|
+
});
|
|
265
|
+
const rows = list.data ?? [];
|
|
266
|
+
if (rows.length === 0) {
|
|
267
|
+
return errorResult(`No Document Request matches "${search}".`);
|
|
268
|
+
}
|
|
269
|
+
if (rows.length === 1 && rows[0]) {
|
|
270
|
+
return jsonResult(await compose(api, rows[0].id));
|
|
271
|
+
}
|
|
272
|
+
return jsonResult({
|
|
273
|
+
matches: rows.map(summarize),
|
|
274
|
+
total: list.pagination?.total ?? rows.length,
|
|
275
|
+
hint: "Several Document Requests match. Call get_request again with the request_id of the right one."
|
|
276
|
+
});
|
|
277
|
+
})
|
|
278
|
+
);
|
|
279
|
+
};
|
|
280
|
+
async function compose(api, requestId) {
|
|
281
|
+
const [detail, documents] = await Promise.all([
|
|
282
|
+
api.get(`/v1/requests/${requestId}`),
|
|
283
|
+
api.get(`/v1/requests/${requestId}/documents`)
|
|
284
|
+
]);
|
|
285
|
+
const r = detail.data;
|
|
286
|
+
return {
|
|
287
|
+
request: {
|
|
288
|
+
id: r.id,
|
|
289
|
+
title: r.title,
|
|
290
|
+
status: r.status,
|
|
291
|
+
recipient: { name: r.recipientName, email: r.recipientEmail },
|
|
292
|
+
message: r.message,
|
|
293
|
+
deadline: r.deadline,
|
|
294
|
+
createdAt: r.createdAt,
|
|
295
|
+
updatedAt: r.updatedAt,
|
|
296
|
+
completedAt: r.completedAt ?? null,
|
|
297
|
+
uploadLink: {
|
|
298
|
+
active: r.hasMagicLink ?? false,
|
|
299
|
+
lastOpenedByClientAt: r.linkOpenedAt ?? null
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
checklist: (r.documentRequirements ?? []).map((slot) => ({
|
|
303
|
+
title: slot.title ?? null,
|
|
304
|
+
docType: slot.doc_type ?? null,
|
|
305
|
+
description: slot.description ?? null,
|
|
306
|
+
required: slot.required !== false,
|
|
307
|
+
...slot.coverage_days !== void 0 && { coverageDays: slot.coverage_days }
|
|
308
|
+
})),
|
|
309
|
+
documents: (documents.data ?? []).map((doc) => ({
|
|
310
|
+
id: doc.id,
|
|
311
|
+
name: doc.name,
|
|
312
|
+
type: doc.type,
|
|
313
|
+
size: doc.size,
|
|
314
|
+
verdict: doc.validationStatus,
|
|
315
|
+
rejectionReason: doc.rejectionReason
|
|
316
|
+
})),
|
|
317
|
+
reminders: {
|
|
318
|
+
enabled: r.reminderEnabled ?? null,
|
|
319
|
+
sentCount: r.reminderCount ?? 0,
|
|
320
|
+
lastSentAt: r.lastReminderAt ?? null,
|
|
321
|
+
nextDueAt: r.nextReminderAt ?? null,
|
|
322
|
+
pausedUntil: r.reminderPausedUntil ?? null,
|
|
323
|
+
pauseReason: r.reminderPauseReason ?? null
|
|
324
|
+
},
|
|
325
|
+
...r.coverage !== void 0 && { coverage: r.coverage }
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function summarize(row) {
|
|
329
|
+
return {
|
|
330
|
+
id: row.id,
|
|
331
|
+
title: row.title,
|
|
332
|
+
status: row.status,
|
|
333
|
+
recipient: { name: row.recipientName, email: row.recipientEmail },
|
|
334
|
+
deadline: row.deadline,
|
|
335
|
+
createdAt: row.createdAt,
|
|
336
|
+
documentsCollected: row.uploadedDocumentCount ?? null
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/tools/download-documents.ts
|
|
341
|
+
import { z as z4 } from "zod";
|
|
342
|
+
var DOWNLOAD_DOCUMENTS_DESCRIPTION = "Call this when the Professional wants the files a Client has uploaded on a Document Request, for example to file them or read them locally. It returns every collected document as one zip archive, embedded in the result as binary content. The archive is the same download the dashboard offers and comes back only to the agent of the Professional, never to a third party. Use get_request first to check that documents have actually arrived.";
|
|
343
|
+
var registerDownloadDocuments = (server, api) => {
|
|
344
|
+
server.registerTool(
|
|
345
|
+
"download_documents",
|
|
346
|
+
{
|
|
347
|
+
title: "Collect the documents of a Document Request",
|
|
348
|
+
description: DOWNLOAD_DOCUMENTS_DESCRIPTION,
|
|
349
|
+
inputSchema: {
|
|
350
|
+
request_id: z4.uuid().describe("The Document Request whose collected files are wanted.")
|
|
351
|
+
},
|
|
352
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
|
|
353
|
+
},
|
|
354
|
+
({ request_id }) => runTool(async () => {
|
|
355
|
+
const zip = await api.getBinary(`/v1/requests/${request_id}/download-zip`);
|
|
356
|
+
const fileName = zip.fileName ?? `request-${request_id}.zip`;
|
|
357
|
+
return {
|
|
358
|
+
content: [
|
|
359
|
+
{
|
|
360
|
+
type: "text",
|
|
361
|
+
text: `${fileName} \u2014 ${zip.bytes.byteLength} bytes, ${zip.contentType}. The archive follows as an embedded resource.`
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
type: "resource",
|
|
365
|
+
resource: {
|
|
366
|
+
uri: `dokutrak://requests/${request_id}/${encodeURIComponent(fileName)}`,
|
|
367
|
+
mimeType: zip.contentType,
|
|
368
|
+
blob: Buffer.from(zip.bytes).toString("base64")
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
]
|
|
372
|
+
};
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// src/tools/index.ts
|
|
378
|
+
var TOOL_REGISTRARS = [
|
|
379
|
+
registerCreateRequest,
|
|
380
|
+
registerRequestReplacement,
|
|
381
|
+
registerGetRequest,
|
|
382
|
+
registerDownloadDocuments
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
// src/server.ts
|
|
386
|
+
var SERVER_NAME = "dokutrak";
|
|
387
|
+
var SERVER_VERSION = "0.1.0";
|
|
388
|
+
function createServer(options) {
|
|
389
|
+
const api = createApiClient({
|
|
390
|
+
baseUrl: options.baseUrl,
|
|
391
|
+
apiKey: options.apiKey,
|
|
392
|
+
userAgent: `dokutrak-mcp/${SERVER_VERSION}`,
|
|
393
|
+
...options.fetch && { fetch: options.fetch }
|
|
394
|
+
});
|
|
395
|
+
const server = new McpServer(
|
|
396
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
397
|
+
{
|
|
398
|
+
instructions: "DokuTrak collects documents from Clients on behalf of a Professional. These tools let the agent create a Document Request, chase the Client on rejected files, read where a request stands and collect the files. Approving or rejecting a document is the decision of the Professional and is not available here."
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
for (const register of TOOL_REGISTRARS) register(server, api);
|
|
402
|
+
return server;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/cli.ts
|
|
406
|
+
async function main() {
|
|
407
|
+
let config;
|
|
408
|
+
try {
|
|
409
|
+
config = readConfig();
|
|
410
|
+
} catch (error) {
|
|
411
|
+
process.stderr.write(
|
|
412
|
+
`dokutrak-mcp: ${error instanceof ConfigError ? error.message : String(error)}
|
|
413
|
+
`
|
|
414
|
+
);
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
const server = createServer(config);
|
|
418
|
+
const transport = new StdioServerTransport();
|
|
419
|
+
await server.connect(transport);
|
|
420
|
+
}
|
|
421
|
+
main().catch((error) => {
|
|
422
|
+
process.stderr.write(`dokutrak-mcp: ${error instanceof Error ? error.message : String(error)}
|
|
423
|
+
`);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dokutrak-mcp",
|
|
3
|
+
"mcpName": "io.github.Crackx17/dokutrak-mcp",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "MCP connector for DokuTrak: let your agent chase the documents.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dokutrak-mcp": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"check": "npm run typecheck && npm run build && npm test",
|
|
25
|
+
"prepublishOnly": "npm run check",
|
|
26
|
+
"staging": "node scripts/staging-run.mjs"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Crackx17/dokutrak-mcp.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://dokutrak.com",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mcp",
|
|
35
|
+
"model-context-protocol",
|
|
36
|
+
"dokutrak",
|
|
37
|
+
"document-collection",
|
|
38
|
+
"claude"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
42
|
+
"zod": "^4.6.5"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.20.2",
|
|
46
|
+
"tsup": "^8.5.1",
|
|
47
|
+
"typescript": "^5.9.3",
|
|
48
|
+
"vitest": "^5.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|