@abbit/abbit-mcp 0.1.0 → 0.3.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 +61 -3
- package/dist/api-key.js +103 -0
- package/dist/attachment-transfer.js +104 -0
- package/dist/auth.js +43 -2
- package/dist/cli.js +42 -6
- package/dist/constants.js +11 -2
- package/dist/model.js +27 -1
- package/dist/proxy.js +2 -2
- package/dist/token-manager.js +37 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# @abbit/abbit-mcp
|
|
2
2
|
|
|
3
|
-
Preview CLI that connects an MCP client to Abbit's remote MCP over local stdio. It
|
|
3
|
+
Preview CLI that connects an MCP client to Abbit's remote MCP over local stdio. It supports browser, personal-key or service-key login, private local credentials, and serialized OAuth token refresh. Requires Node.js `>=24.15.0 <25` and an Abbit account with access to a workspace.
|
|
4
|
+
|
|
5
|
+
## Supported platforms
|
|
6
|
+
|
|
7
|
+
The current preview is supported and tested only on macOS. Linux and Windows support is planned; neither platform is currently supported or tested.
|
|
4
8
|
|
|
5
9
|
## Install and log in
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
Install the current preview release:
|
|
8
12
|
|
|
9
13
|
```sh
|
|
10
14
|
npm install --global @abbit/abbit-mcp@preview
|
|
@@ -12,7 +16,43 @@ abbit-mcp login
|
|
|
12
16
|
abbit-mcp status
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
Login opens or prints a browser authorization URL. Select your workspace and approve access.
|
|
19
|
+
Login opens or prints a browser authorization URL. Select your workspace and approve access. Browser login requires reaching the same machine's loopback callback. Use an existing personal or service key for SSH/headless login.
|
|
20
|
+
|
|
21
|
+
Login requests Tracker access and Wiki listing, creation, reading, search rebuilding, and editing. IAM grants only your approved, permitted scope. Existing sessions keep their original permissions during refresh. After updating from a version without Wiki listing or creation scopes, run `abbit-mcp login` again and approve the needed Wiki access.
|
|
22
|
+
|
|
23
|
+
## API-key login
|
|
24
|
+
|
|
25
|
+
Use an existing Abbit personal (`abbit_pk_v1`) or service (`abbit_sk_v1`) API key with the permissions needed by your tools:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
abbit-mcp login --key
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Paste the key into the hidden prompt and press Enter. For headless use, redirect a protected file or pipe a secret manager's output into the same command:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
abbit-mcp login --key < /secure/path/api-key
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For the local adapter, supply the key only through hidden input or stdin; never as a command argument. Login validates the key with Abbit MCP before replacing your local login. Key issuance and permission management remain in IAM.
|
|
38
|
+
|
|
39
|
+
`serve` sends the saved key as `Authorization: Bearer <key>`. IAM checks its current permissions, expiry and revocation on every request. Keys do not refresh; replace a rejected key by running `login --key` again. Failed authentication never falls back to another login. `status` reports the locally saved key's class and namespace without displaying the key; it does not check current server validity.
|
|
40
|
+
|
|
41
|
+
A successful login selects its authentication method: key login disables previous browser sessions locally and attempts to revoke them; browser login removes the local key. Failed OAuth revocation stays pending for a later `logout` retry. `logout` removes the saved key **only from this machine**; revoke it separately in IAM to invalidate other copies.
|
|
42
|
+
|
|
43
|
+
Both methods use the locked, atomic `~/.abbit/credentials.json` store (directory mode 0700, file mode 0600). Key login stores exactly one optional `personalKey` or `serviceKey` field in the version-1 document. Existing OAuth and personal-key documents remain readable. Clients before 0.3.0 reject service-key documents safely; use this version to log out or return to browser login before downgrading.
|
|
44
|
+
|
|
45
|
+
## Direct HTTP MCP in Codex
|
|
46
|
+
|
|
47
|
+
Both key classes can also authenticate directly to the remote MCP endpoint. Codex supports an environment-sourced bearer in `~/.codex/config.toml`:
|
|
48
|
+
|
|
49
|
+
```toml
|
|
50
|
+
[mcp_servers.abbit]
|
|
51
|
+
url = "https://api.abusybit.com/v1/mcp"
|
|
52
|
+
bearer_token_env_var = "ABBIT_API_KEY"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Set `ABBIT_API_KEY` in the environment of the Codex process. Its value is the complete key without the `Bearer ` prefix. This connection does not use the local adapter or its credential store. Codex also accepts a literal header via `http_headers = { Authorization = "Bearer <key>" }`; that stores the secret in the configuration file. Use only one credential source. See the [official Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference#configtoml).
|
|
16
56
|
|
|
17
57
|
## MCP client configuration
|
|
18
58
|
|
|
@@ -136,3 +176,21 @@ abbit-mcp logout
|
|
|
136
176
|
This package provides the `abbit-mcp` command, not an importable JavaScript library. It contains compiled runtime modules and this README; npm installs its declared runtime dependencies. The preview channel starts at `0.1.0` and carries no stable API guarantee. Abbit code remains `UNLICENSED`; public registry access does not grant an open-source license. Dependencies retain their respective licenses.
|
|
137
177
|
|
|
138
178
|
Maintainers: see the [publication policy](https://github.com/abbitco/abbit-platfrom/blob/main/wiki/mcp/npm-publication.md) and [runtime contract](https://github.com/abbitco/abbit-platfrom/blob/main/wiki/mcp/local-stdio-adapter.md).
|
|
179
|
+
|
|
180
|
+
## Tracker attachment bytes
|
|
181
|
+
|
|
182
|
+
Initiate an upload using `tracker_initiate_attachment_upload`, then stream its exact file locally:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
abbit-mcp attachment-upload TRACKER_ID ITEM_ID UPLOAD_ID ./proof.png
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Complete it with `tracker_complete_attachment_upload`, then call `tracker_add_comment_with_attachments` (optional caption) or `tracker_publish_attachments`. Reuse each metadata command's idempotency key after an uncertain response. A transfer failure should be followed by upload inspection before retrying. Files are capped at 25 MiB; tool arguments never contain bytes/base64, URLs, or server-side local paths.
|
|
189
|
+
|
|
190
|
+
List associations with `tracker_list_attachments`, then retrieve an original to a new destination:
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
abbit-mcp attachment-download TRACKER_ID ITEM_ID ATTACHMENT_ID ./downloaded-proof.png
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The helper verifies size/SHA-256 before publishing the local file and never overwrites an existing destination. Both byte helpers use the selected local credential, fixed gateway origin and exact Tracker routes. Credentials are absent from arguments, output and filenames. These commands require the attachment-capable gateway deployment.
|
package/dist/api-key.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
3
|
+
import { iamRequestTimeoutMs, mcpAudience } from './constants.js';
|
|
4
|
+
import { parseApiKey } from './model.js';
|
|
5
|
+
export const readApiKey = (input = process.stdin, output = process.stderr) => new Promise((resolve, reject) => {
|
|
6
|
+
const interactive = input.isTTY === true;
|
|
7
|
+
const wasRaw = input.isRaw === true;
|
|
8
|
+
let value = '';
|
|
9
|
+
let finished = false;
|
|
10
|
+
const finish = (error) => {
|
|
11
|
+
if (finished)
|
|
12
|
+
return;
|
|
13
|
+
finished = true;
|
|
14
|
+
input.removeListener('data', onData);
|
|
15
|
+
input.removeListener('end', onEnd);
|
|
16
|
+
input.removeListener('error', onError);
|
|
17
|
+
input.removeListener('close', onClose);
|
|
18
|
+
process.removeListener('SIGTERM', onTerminate);
|
|
19
|
+
process.removeListener('SIGINT', onTerminate);
|
|
20
|
+
input.pause();
|
|
21
|
+
if (interactive) {
|
|
22
|
+
input.setRawMode?.(wasRaw);
|
|
23
|
+
output.write('\n');
|
|
24
|
+
}
|
|
25
|
+
const key = value.replace(/\r?\n$/u, '');
|
|
26
|
+
value = '';
|
|
27
|
+
if (error !== undefined)
|
|
28
|
+
reject(error);
|
|
29
|
+
else if (parseApiKey(key) === undefined)
|
|
30
|
+
reject(new Error('Enter a valid Abbit API key.'));
|
|
31
|
+
else
|
|
32
|
+
resolve(key);
|
|
33
|
+
};
|
|
34
|
+
const onError = () => finish(new Error('Could not read the API key.'));
|
|
35
|
+
const onEnd = () => finish();
|
|
36
|
+
const onClose = () => finish(new Error('API-key input closed.'));
|
|
37
|
+
const onTerminate = () => finish(new Error('API-key login canceled.'));
|
|
38
|
+
const onData = (chunk) => {
|
|
39
|
+
for (const char of chunk.toString()) {
|
|
40
|
+
if (interactive && (char === '\u0003' || char === '\u0004'))
|
|
41
|
+
return onTerminate();
|
|
42
|
+
if (interactive && (char === '\r' || char === '\n'))
|
|
43
|
+
return finish();
|
|
44
|
+
if (interactive && (char === '\u007f' || char === '\b'))
|
|
45
|
+
value = value.slice(0, -1);
|
|
46
|
+
else
|
|
47
|
+
value += char;
|
|
48
|
+
if (value.length > 2_048)
|
|
49
|
+
return finish(new Error('API-key input is too long.'));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
if (interactive) {
|
|
53
|
+
if (input.setRawMode === undefined)
|
|
54
|
+
return reject(new Error('Hidden input is unavailable. Redirect the key through stdin.'));
|
|
55
|
+
input.setRawMode(true);
|
|
56
|
+
output.write('API key (hidden): ');
|
|
57
|
+
}
|
|
58
|
+
input.on('data', onData).once('end', onEnd).once('error', onError).once('close', onClose);
|
|
59
|
+
process.once('SIGTERM', onTerminate);
|
|
60
|
+
process.once('SIGINT', onTerminate);
|
|
61
|
+
input.resume();
|
|
62
|
+
});
|
|
63
|
+
export const fetchWithApiKey = async (key, input, init = {}, fetchImpl = fetch) => {
|
|
64
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
65
|
+
if (url !== mcpAudience)
|
|
66
|
+
throw new Error('API keys may only be sent to the Abbit MCP endpoint.');
|
|
67
|
+
const headers = new Headers(init.headers);
|
|
68
|
+
headers.set('authorization', `Bearer ${key}`);
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetchImpl(input, { ...init, headers, redirect: 'error' });
|
|
71
|
+
if (response.ok)
|
|
72
|
+
return response;
|
|
73
|
+
await response.body?.cancel();
|
|
74
|
+
return new Response('Abbit MCP request rejected.', { status: response.status });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error('Abbit MCP request failed.');
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
export const validateApiKey = async (key, fetchImpl = fetch) => {
|
|
81
|
+
if (parseApiKey(key) === undefined)
|
|
82
|
+
throw new Error('Enter a valid Abbit API key.');
|
|
83
|
+
const client = new Client({ name: 'abbit-mcp-r0', version: '0.3.0' }, { capabilities: {} });
|
|
84
|
+
const transport = new StreamableHTTPClientTransport(new URL(mcpAudience), {
|
|
85
|
+
fetch: async (input, init) => {
|
|
86
|
+
const response = await fetchWithApiKey(key, input, { ...init, signal: AbortSignal.timeout(iamRequestTimeoutMs) }, fetchImpl);
|
|
87
|
+
if (!response.ok && response.status !== 405) {
|
|
88
|
+
await response.body?.cancel();
|
|
89
|
+
throw new Error('API-key authentication failed.');
|
|
90
|
+
}
|
|
91
|
+
return response;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
await client.connect(transport, { timeout: iamRequestTimeoutMs });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error('Could not authenticate the API key with Abbit MCP. Check the key and connection.');
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await client.close().catch(() => undefined);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, open, unlink } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
const encode = (value) => {
|
|
6
|
+
if (!value || [...value].length > 128 || value === '.' || value === '..' || value.includes('\0') || /[\uD800-\uDFFF]/u.test(value))
|
|
7
|
+
throw new Error('Invalid attachment identifier.');
|
|
8
|
+
return encodeURIComponent(value);
|
|
9
|
+
};
|
|
10
|
+
const target = (trackerId, itemId, suffix) => new URL(`https://api.abusybit.com/v2/trackers/${encode(trackerId)}/items/${encode(itemId)}/attachments/${suffix}`);
|
|
11
|
+
export async function uploadAttachment(manager, trackerId, itemId, uploadId, path) {
|
|
12
|
+
const file = await open(path, 'r');
|
|
13
|
+
try {
|
|
14
|
+
const stat = await file.stat();
|
|
15
|
+
if (!stat.isFile() || stat.size > 26214400)
|
|
16
|
+
throw new Error('Choose a regular file no larger than 25 MiB.');
|
|
17
|
+
const key = `stream-${randomUUID()}`;
|
|
18
|
+
const response = await manager.attachmentFetch(target(trackerId, itemId, `uploads/${encode(uploadId)}/content`), () => ({
|
|
19
|
+
method: 'PUT',
|
|
20
|
+
headers: { 'Content-Type': 'application/octet-stream', 'Idempotency-Key': key },
|
|
21
|
+
body: Readable.toWeb(file.createReadStream({ autoClose: false, start: 0 })),
|
|
22
|
+
duplex: 'half',
|
|
23
|
+
signal: AbortSignal.timeout(125000),
|
|
24
|
+
}));
|
|
25
|
+
if (response.status !== 200) {
|
|
26
|
+
await response.body?.cancel();
|
|
27
|
+
throw new Error(`Attachment upload was not confirmed (HTTP ${response.status}). Inspect the upload before retrying.`);
|
|
28
|
+
}
|
|
29
|
+
await response.body?.cancel();
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof Error && /^(Choose|Attachment upload was not confirmed)/.test(error.message))
|
|
33
|
+
throw error;
|
|
34
|
+
throw new Error('Attachment upload could not be confirmed. Inspect the upload before retrying.');
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await file.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function downloadAttachment(manager, trackerId, itemId, attachmentId, path) {
|
|
41
|
+
const temporary = join(dirname(path), `.abbit-attachment-${randomUUID()}`);
|
|
42
|
+
let created = false;
|
|
43
|
+
try {
|
|
44
|
+
const response = await manager.attachmentFetch(target(trackerId, itemId, `${encode(attachmentId)}/content`), () => ({
|
|
45
|
+
method: 'GET',
|
|
46
|
+
signal: AbortSignal.timeout(125000),
|
|
47
|
+
}));
|
|
48
|
+
if (response.status !== 200) {
|
|
49
|
+
await response.body?.cancel();
|
|
50
|
+
throw new Error(`Attachment download failed (HTTP ${response.status}).`);
|
|
51
|
+
}
|
|
52
|
+
const raw = response.headers.get('content-length'), sha = response.headers.get('x-attachment-sha256');
|
|
53
|
+
if (!raw ||
|
|
54
|
+
!/^\d{1,8}$/.test(raw) ||
|
|
55
|
+
Number(raw) > 26214400 ||
|
|
56
|
+
!sha ||
|
|
57
|
+
!/^[a-f0-9]{64}$/.test(sha) ||
|
|
58
|
+
response.headers.get('content-type') !== 'application/octet-stream') {
|
|
59
|
+
await response.body?.cancel();
|
|
60
|
+
throw new Error('Attachment response could not be verified.');
|
|
61
|
+
}
|
|
62
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
63
|
+
created = true;
|
|
64
|
+
const hash = createHash('sha256');
|
|
65
|
+
let count = 0;
|
|
66
|
+
const reader = response.body?.getReader();
|
|
67
|
+
try {
|
|
68
|
+
if (reader)
|
|
69
|
+
while (true) {
|
|
70
|
+
const part = await reader.read();
|
|
71
|
+
if (part.done)
|
|
72
|
+
break;
|
|
73
|
+
count += part.value.length;
|
|
74
|
+
if (count > Number(raw))
|
|
75
|
+
throw new Error('Attachment response could not be verified.');
|
|
76
|
+
hash.update(part.value);
|
|
77
|
+
let offset = 0;
|
|
78
|
+
while (offset < part.value.length) {
|
|
79
|
+
const written = await file.write(part.value.subarray(offset));
|
|
80
|
+
offset += written.bytesWritten;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (count !== Number(raw) || hash.digest('hex') !== sha)
|
|
84
|
+
throw new Error('Attachment response could not be verified.');
|
|
85
|
+
await file.sync();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await reader?.cancel().catch(() => undefined);
|
|
89
|
+
reader?.releaseLock();
|
|
90
|
+
await file.close();
|
|
91
|
+
}
|
|
92
|
+
// Atomic no-overwrite publication: an existing destination is never replaced.
|
|
93
|
+
await link(temporary, path);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof Error && /^Attachment (download failed|response could not)/.test(error.message))
|
|
97
|
+
throw error;
|
|
98
|
+
throw new Error('Attachment download failed. Choose a new writable destination; existing files are never replaced.');
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (created)
|
|
102
|
+
await unlink(temporary).catch(() => undefined);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/auth.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
|
+
import { validateApiKey } from './api-key.js';
|
|
4
5
|
import { authorizationTimeoutMs, iamOrigin, isMcpClientId, isMcpScopeSet, mcpAudience, mcpScope, parseMcpScope, refreshHardLifetimeMs, refreshIdleLifetimeMs, } from './constants.js';
|
|
5
|
-
import { createAccessTokenEntry } from './model.js';
|
|
6
|
+
import { createAccessTokenEntry, parseApiKey } from './model.js';
|
|
6
7
|
const matchesMcpIdentity = (record) => record.origin === iamOrigin && isMcpClientId(record.clientId) && record.audience === mcpAudience;
|
|
7
8
|
const matchesMcp = (record) => matchesMcpIdentity(record) && isMcpScopeSet(record.approvedScopes);
|
|
8
9
|
const matchesLegacyMcp = (record) => matchesMcpIdentity(record) && record.approvedScopes.length === 1 && record.approvedScopes[0] === 'mcp:access';
|
|
@@ -213,6 +214,8 @@ export class McpAuthService {
|
|
|
213
214
|
lastUsedAt: persistedAt.toISOString(),
|
|
214
215
|
};
|
|
215
216
|
const predecessors = await this.store.withLock(async (document) => {
|
|
217
|
+
delete document.personalKey;
|
|
218
|
+
delete document.serviceKey;
|
|
216
219
|
const pending = [];
|
|
217
220
|
for (const previous of Object.values(document.credentials)) {
|
|
218
221
|
if (!matchesAnyMcp(previous) || previous.credentialId === record.credentialId)
|
|
@@ -243,8 +246,36 @@ export class McpAuthService {
|
|
|
243
246
|
await callback.close();
|
|
244
247
|
}
|
|
245
248
|
}
|
|
249
|
+
async loginWithKey(key, fetchImpl = fetch) {
|
|
250
|
+
const parsed = parseApiKey(key);
|
|
251
|
+
if (parsed === undefined)
|
|
252
|
+
throw new Error('Enter a valid Abbit API key.');
|
|
253
|
+
await validateApiKey(key, fetchImpl);
|
|
254
|
+
const pending = await this.store.withLock(async (document) => {
|
|
255
|
+
for (const record of Object.values(document.credentials).filter(matchesAnyMcp)) {
|
|
256
|
+
record.state = 'pendingRevocation';
|
|
257
|
+
record.accessTokens = [];
|
|
258
|
+
}
|
|
259
|
+
delete document.personalKey;
|
|
260
|
+
delete document.serviceKey;
|
|
261
|
+
if (parsed.kind === 'personal')
|
|
262
|
+
document.personalKey = key;
|
|
263
|
+
else
|
|
264
|
+
document.serviceKey = key;
|
|
265
|
+
return {
|
|
266
|
+
result: Object.values(document.credentials)
|
|
267
|
+
.filter(matchesAnyMcp)
|
|
268
|
+
.map(({ credentialId, refreshToken, clientId }) => ({ credentialId, refreshToken, clientId })),
|
|
269
|
+
document,
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
await this.revokePending(pending);
|
|
273
|
+
return parsed;
|
|
274
|
+
}
|
|
246
275
|
async status(currentTime = new Date()) {
|
|
247
|
-
const
|
|
276
|
+
const document = await this.store.read();
|
|
277
|
+
const key = parseApiKey(document.personalKey ?? document.serviceKey);
|
|
278
|
+
const records = Object.values(document.credentials).filter(matchesAnyMcp);
|
|
248
279
|
const stateFor = (record) => {
|
|
249
280
|
if (record.state === 'pendingRevocation')
|
|
250
281
|
return 'pendingRevocation';
|
|
@@ -256,7 +287,10 @@ export class McpAuthService {
|
|
|
256
287
|
: 'active';
|
|
257
288
|
};
|
|
258
289
|
const namespaces = records.map((record) => ({ namespaceId: record.namespaceId, state: stateFor(record) }));
|
|
290
|
+
if (key !== undefined)
|
|
291
|
+
namespaces.unshift({ namespaceId: key.namespaceId, state: 'active' });
|
|
259
292
|
return {
|
|
293
|
+
...(key === undefined ? {} : { apiKey: key }),
|
|
260
294
|
authenticated: namespaces.some(({ state }) => state === 'active'),
|
|
261
295
|
pendingRevocation: namespaces.some(({ state }) => state === 'pendingRevocation'),
|
|
262
296
|
reauthenticationRequired: namespaces.some(({ state }) => state === 'reauthenticationRequired'),
|
|
@@ -264,7 +298,11 @@ export class McpAuthService {
|
|
|
264
298
|
};
|
|
265
299
|
}
|
|
266
300
|
async logout() {
|
|
301
|
+
let forgotten = 0;
|
|
267
302
|
const pending = await this.store.withLock(async (document) => {
|
|
303
|
+
forgotten = document.personalKey === undefined && document.serviceKey === undefined ? 0 : 1;
|
|
304
|
+
delete document.personalKey;
|
|
305
|
+
delete document.serviceKey;
|
|
268
306
|
const selected = Object.values(document.credentials).filter(matchesAnyMcp);
|
|
269
307
|
for (const record of selected) {
|
|
270
308
|
record.state = 'pendingRevocation';
|
|
@@ -275,6 +313,9 @@ export class McpAuthService {
|
|
|
275
313
|
document,
|
|
276
314
|
};
|
|
277
315
|
});
|
|
316
|
+
return { ...(await this.revokePending(pending)), forgotten };
|
|
317
|
+
}
|
|
318
|
+
async revokePending(pending) {
|
|
278
319
|
let revoked = 0;
|
|
279
320
|
for (const candidate of pending) {
|
|
280
321
|
if (!(await this.iam.revoke(candidate.refreshToken, candidate.clientId)))
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { readApiKey } from './api-key.js';
|
|
4
|
+
import { downloadAttachment, uploadAttachment } from './attachment-transfer.js';
|
|
3
5
|
import { McpAuthService } from './auth.js';
|
|
4
6
|
import { CredentialStore } from './credentials.js';
|
|
5
7
|
import { createMcpIamClient, refreshMcpToken } from './iam-client.js';
|
|
@@ -8,11 +10,21 @@ import { McpTokenManager } from './token-manager.js';
|
|
|
8
10
|
const store = new CredentialStore();
|
|
9
11
|
const iam = createMcpIamClient();
|
|
10
12
|
const auth = new McpAuthService(store, iam);
|
|
11
|
-
const program = new Command().name('abbit-mcp').description('Abbit local stdio MCP adapter').version('0.
|
|
13
|
+
const program = new Command().name('abbit-mcp').description('Abbit local stdio MCP adapter').version('0.3.0').showHelpAfterError();
|
|
14
|
+
program.configureOutput({
|
|
15
|
+
outputError: (_message, write) => write('Invalid command. Use --help. Supply API keys through hidden input or stdin, never arguments.\n'),
|
|
16
|
+
});
|
|
12
17
|
program
|
|
13
18
|
.command('login')
|
|
14
|
-
.description('Authorize Abbit MCP in your browser')
|
|
15
|
-
.
|
|
19
|
+
.description('Authorize Abbit MCP in your browser or with a personal or service key')
|
|
20
|
+
.option('--key', 'Read an existing personal or service API key from hidden input or stdin')
|
|
21
|
+
.allowExcessArguments(false)
|
|
22
|
+
.action(async (options) => {
|
|
23
|
+
if (options.key) {
|
|
24
|
+
const record = await auth.loginWithKey(await readApiKey());
|
|
25
|
+
process.stdout.write(`Authenticated Abbit MCP with a ${record.kind} key for namespace ${JSON.stringify(record.namespaceId)}.\n`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
16
28
|
const record = await auth.login({ output: (line) => process.stdout.write(`${line}\n`) });
|
|
17
29
|
process.stdout.write(`Authenticated Abbit MCP for namespace ${record.namespaceId}.\n`);
|
|
18
30
|
});
|
|
@@ -21,7 +33,10 @@ program
|
|
|
21
33
|
.description('Show local Abbit MCP authentication status')
|
|
22
34
|
.action(async () => {
|
|
23
35
|
const status = await auth.status();
|
|
24
|
-
if (status.
|
|
36
|
+
if (status.apiKey !== undefined) {
|
|
37
|
+
process.stdout.write(`${status.apiKey.kind === 'personal' ? 'Personal' : 'Service'} key saved for namespace ${JSON.stringify(status.apiKey.namespaceId)} (server checks validity on use).\n`);
|
|
38
|
+
}
|
|
39
|
+
else if (status.authenticated) {
|
|
25
40
|
for (const record of status.namespaces.filter(({ state }) => state === 'active')) {
|
|
26
41
|
process.stdout.write(`Authenticated: ${record.namespaceId}\n`);
|
|
27
42
|
}
|
|
@@ -36,15 +51,20 @@ program
|
|
|
36
51
|
});
|
|
37
52
|
program
|
|
38
53
|
.command('logout')
|
|
39
|
-
.description('
|
|
54
|
+
.description('Forget an API key locally and revoke browser OAuth credentials')
|
|
40
55
|
.action(async () => {
|
|
41
56
|
const result = await auth.logout();
|
|
57
|
+
if (result.forgotten > 0)
|
|
58
|
+
process.stdout.write('API key removed locally. The key remains valid until revoked in IAM.\n');
|
|
42
59
|
if (result.pending > 0) {
|
|
43
60
|
process.stdout.write('Local access disabled; remote revocation is pending.\n');
|
|
44
61
|
process.exitCode = 1;
|
|
45
62
|
return;
|
|
46
63
|
}
|
|
47
|
-
|
|
64
|
+
if (result.revoked > 0)
|
|
65
|
+
process.stdout.write('Abbit MCP credentials revoked.\n');
|
|
66
|
+
else if (result.forgotten === 0)
|
|
67
|
+
process.stdout.write('No Abbit MCP credentials were configured.\n');
|
|
48
68
|
});
|
|
49
69
|
program
|
|
50
70
|
.command('serve')
|
|
@@ -58,6 +78,22 @@ program
|
|
|
58
78
|
});
|
|
59
79
|
}
|
|
60
80
|
});
|
|
81
|
+
program
|
|
82
|
+
.command('attachment-upload <trackerId> <itemId> <uploadId> <file>')
|
|
83
|
+
.description('Stream a local file to an initiated Tracker upload; complete and publish through MCP')
|
|
84
|
+
.allowExcessArguments(false)
|
|
85
|
+
.action(async (trackerId, itemId, uploadId, file) => {
|
|
86
|
+
await uploadAttachment(new McpTokenManager(store, refreshMcpToken()), trackerId, itemId, uploadId, file);
|
|
87
|
+
process.stdout.write('Bytes staged. Complete the upload and publish its attachment through MCP.\n');
|
|
88
|
+
});
|
|
89
|
+
program
|
|
90
|
+
.command('attachment-download <trackerId> <itemId> <attachmentId> <destination>')
|
|
91
|
+
.description('Download an original Tracker attachment to a new local file, verifying its SHA-256')
|
|
92
|
+
.allowExcessArguments(false)
|
|
93
|
+
.action(async (trackerId, itemId, attachmentId, destination) => {
|
|
94
|
+
await downloadAttachment(new McpTokenManager(store, refreshMcpToken()), trackerId, itemId, attachmentId, destination);
|
|
95
|
+
process.stdout.write('Original attachment downloaded and verified.\n');
|
|
96
|
+
});
|
|
61
97
|
program.parseAsync().catch((error) => {
|
|
62
98
|
const message = error instanceof Error ? error.message : 'Abbit MCP failed.';
|
|
63
99
|
process.stderr.write(`${message}\n`);
|
package/dist/constants.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
export const iamOrigin = 'https://auth.abusybit.com';
|
|
2
2
|
export const mcpAudience = 'https://api.abusybit.com/v1/mcp';
|
|
3
|
-
export const mcpScopes = [
|
|
4
|
-
|
|
3
|
+
export const mcpScopes = [
|
|
4
|
+
'tracker:claim',
|
|
5
|
+
'tracker:read',
|
|
6
|
+
'tracker:write',
|
|
7
|
+
'wiki:list',
|
|
8
|
+
'wiki:provision',
|
|
9
|
+
'wiki:read',
|
|
10
|
+
'wiki:rebuild',
|
|
11
|
+
'wiki:write',
|
|
12
|
+
];
|
|
13
|
+
export const mcpScope = 'tracker:claim tracker:read tracker:write wiki:list wiki:provision wiki:read wiki:rebuild wiki:write';
|
|
5
14
|
export const parseMcpScope = (value) => {
|
|
6
15
|
if (typeof value !== 'string')
|
|
7
16
|
return undefined;
|
package/dist/model.js
CHANGED
|
@@ -56,6 +56,21 @@ export const isMcpRefreshToken = (value, expectedNamespaceId) => {
|
|
|
56
56
|
const namespaceId = decodeNamespaceLocator(locator);
|
|
57
57
|
return namespaceId !== undefined && (expectedNamespaceId === undefined || namespaceId === expectedNamespaceId);
|
|
58
58
|
};
|
|
59
|
+
export const parseApiKey = (value) => {
|
|
60
|
+
if (typeof value !== 'string' || value.length > 2_048)
|
|
61
|
+
return undefined;
|
|
62
|
+
const [prefix, locator, lookup, secret, extra] = value.split('.');
|
|
63
|
+
if ((prefix !== 'abbit_pk_v1' && prefix !== 'abbit_sk_v1') ||
|
|
64
|
+
locator === undefined ||
|
|
65
|
+
lookup === undefined ||
|
|
66
|
+
secret === undefined ||
|
|
67
|
+
extra !== undefined ||
|
|
68
|
+
!/^[A-Za-z0-9_-]{21}[AQgw]$/u.test(lookup) ||
|
|
69
|
+
!/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/u.test(secret))
|
|
70
|
+
return undefined;
|
|
71
|
+
const namespaceId = decodeNamespaceLocator(locator);
|
|
72
|
+
return namespaceId === undefined ? undefined : { kind: prefix === 'abbit_pk_v1' ? 'personal' : 'service', namespaceId };
|
|
73
|
+
};
|
|
59
74
|
const isAccessToken = (value) => isRecord(value) &&
|
|
60
75
|
hasExactKeys(value, ['accessToken', 'audience', 'scopes', 'issuedAt', 'expiresAt']) &&
|
|
61
76
|
boundedString(value.accessToken, 1, 32_768) &&
|
|
@@ -113,7 +128,18 @@ const isCredential = (value) => {
|
|
|
113
128
|
};
|
|
114
129
|
export const emptyCredentialDocument = () => ({ version: 1, credentials: {} });
|
|
115
130
|
export const parseCredentialDocument = (value) => {
|
|
116
|
-
if (!isRecord(value) ||
|
|
131
|
+
if (!isRecord(value) ||
|
|
132
|
+
!hasExactKeys(value, [
|
|
133
|
+
'version',
|
|
134
|
+
'credentials',
|
|
135
|
+
...('personalKey' in value ? ['personalKey'] : []),
|
|
136
|
+
...('serviceKey' in value ? ['serviceKey'] : []),
|
|
137
|
+
]) ||
|
|
138
|
+
value.version !== 1 ||
|
|
139
|
+
!isRecord(value.credentials) ||
|
|
140
|
+
('personalKey' in value && parseApiKey(value.personalKey)?.kind !== 'personal') ||
|
|
141
|
+
('serviceKey' in value && parseApiKey(value.serviceKey)?.kind !== 'service') ||
|
|
142
|
+
('personalKey' in value && 'serviceKey' in value)) {
|
|
117
143
|
throw new TypeError('credential document validation failed');
|
|
118
144
|
}
|
|
119
145
|
for (const [key, credential] of Object.entries(value.credentials)) {
|
package/dist/proxy.js
CHANGED
|
@@ -5,13 +5,13 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
import { mcpAudience } from './constants.js';
|
|
7
7
|
export const createLocalMcpProxyServer = (upstream) => {
|
|
8
|
-
const server = new Server({ name: 'abbit-mcp-r0', version: '0.
|
|
8
|
+
const server = new Server({ name: 'abbit-mcp-r0', version: '0.3.0' }, { capabilities: { tools: {} } });
|
|
9
9
|
server.setRequestHandler(ListToolsRequestSchema, (request) => upstream.listTools(request.params));
|
|
10
10
|
server.setRequestHandler(CallToolRequestSchema, (request) => upstream.callTool(request.params));
|
|
11
11
|
return server;
|
|
12
12
|
};
|
|
13
13
|
export const serveLocalMcp = async (options) => {
|
|
14
|
-
const upstream = new Client({ name: 'abbit-mcp-r0', version: '0.
|
|
14
|
+
const upstream = new Client({ name: 'abbit-mcp-r0', version: '0.3.0' }, { capabilities: {} });
|
|
15
15
|
const fetchImpl = options.fetch ?? fetch;
|
|
16
16
|
const upstreamTransport = new StreamableHTTPClientTransport(new URL(mcpAudience), {
|
|
17
17
|
fetch: (input, init) => options.tokenManager.authenticatedFetch(input, init, fetchImpl),
|
package/dist/token-manager.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { fetchWithApiKey } from './api-key.js';
|
|
2
3
|
import { accessTokenSafetyWindowMs, iamOrigin, isMcpClientId, isMcpScopeSet, mcpAudience, parseMcpScope, refreshIdleLifetimeMs, } from './constants.js';
|
|
3
4
|
import { createAccessTokenEntry } from './model.js';
|
|
4
5
|
export class McpReauthenticationRequiredError extends Error {
|
|
@@ -35,6 +36,12 @@ export class McpTokenManager {
|
|
|
35
36
|
async getAccessContext(options = {}) {
|
|
36
37
|
const initialNow = this.now();
|
|
37
38
|
const initialDocument = await this.store.read();
|
|
39
|
+
const key = initialDocument.personalKey ?? initialDocument.serviceKey;
|
|
40
|
+
if (key !== undefined) {
|
|
41
|
+
if (options.credentialId !== undefined || options.rejectedToken !== undefined)
|
|
42
|
+
throw new McpReauthenticationRequiredError();
|
|
43
|
+
return { accessToken: key, credentialId: 'api-key', apiKey: true };
|
|
44
|
+
}
|
|
38
45
|
const initial = options.credentialId === undefined
|
|
39
46
|
? selectCredential(Object.values(initialDocument.credentials))
|
|
40
47
|
: initialDocument.credentials[options.credentialId];
|
|
@@ -50,6 +57,8 @@ export class McpTokenManager {
|
|
|
50
57
|
return { accessToken: cached.accessToken, credentialId };
|
|
51
58
|
return this.store.withLock(async (document, commit) => {
|
|
52
59
|
const completedAt = this.now();
|
|
60
|
+
if (document.personalKey !== undefined || document.serviceKey !== undefined)
|
|
61
|
+
throw new McpReauthenticationRequiredError();
|
|
53
62
|
const credential = document.credentials[credentialId];
|
|
54
63
|
if (credential === undefined || credential.state !== 'active' || !matchesMcpProfile(credential)) {
|
|
55
64
|
throw new McpReauthenticationRequiredError();
|
|
@@ -88,6 +97,8 @@ export class McpTokenManager {
|
|
|
88
97
|
}
|
|
89
98
|
async authenticatedFetch(input, init = {}, fetchImpl = fetch) {
|
|
90
99
|
const initial = await this.getAccessContext();
|
|
100
|
+
if (initial.apiKey)
|
|
101
|
+
return fetchWithApiKey(initial.accessToken, input, init, fetchImpl);
|
|
91
102
|
const first = await fetchImpl(input, this.withBearer(init, initial.accessToken));
|
|
92
103
|
if (first.status !== 401)
|
|
93
104
|
return first;
|
|
@@ -95,6 +106,32 @@ export class McpTokenManager {
|
|
|
95
106
|
const successor = await this.getAccessContext({ credentialId: initial.credentialId, rejectedToken: initial.accessToken });
|
|
96
107
|
return fetchImpl(input, this.withBearer(init, successor.accessToken));
|
|
97
108
|
}
|
|
109
|
+
async attachmentFetch(url, init, fetchImpl = fetch) {
|
|
110
|
+
if (url.origin !== 'https://api.abusybit.com' ||
|
|
111
|
+
url.search ||
|
|
112
|
+
url.hash ||
|
|
113
|
+
url.username ||
|
|
114
|
+
url.password ||
|
|
115
|
+
!/^\/v2\/trackers\/[^/]+\/items\/[^/]+\/attachments\/(?:uploads\/[^/]+\/content|[^/]+\/content)$/.test(url.pathname))
|
|
116
|
+
throw new Error('Invalid Tracker attachment transfer route.');
|
|
117
|
+
const initial = await this.getAccessContext();
|
|
118
|
+
const send = (accessToken) => {
|
|
119
|
+
const options = init();
|
|
120
|
+
const upload = url.pathname.includes('/attachments/uploads/');
|
|
121
|
+
if (options.method !== (upload ? 'PUT' : 'GET'))
|
|
122
|
+
throw new Error('Invalid attachment transfer method.');
|
|
123
|
+
const headers = new Headers(options.headers);
|
|
124
|
+
headers.set('authorization', `Bearer ${accessToken}`);
|
|
125
|
+
headers.set('x-abbit-credential-profile', 'mcp');
|
|
126
|
+
return fetchImpl(url, { ...options, headers, redirect: 'error' });
|
|
127
|
+
};
|
|
128
|
+
const first = await send(initial.accessToken);
|
|
129
|
+
if (first.status !== 401 || initial.apiKey)
|
|
130
|
+
return first;
|
|
131
|
+
await first.body?.cancel();
|
|
132
|
+
const successor = await this.getAccessContext({ credentialId: initial.credentialId, rejectedToken: initial.accessToken });
|
|
133
|
+
return send(successor.accessToken);
|
|
134
|
+
}
|
|
98
135
|
withBearer(init, token) {
|
|
99
136
|
const headers = new Headers(init.headers);
|
|
100
137
|
headers.set('authorization', `Bearer ${token}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abbit/abbit-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Abbit-owned local stdio MCP adapter.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build": "tsc -p tsconfig.build.json",
|
|
30
30
|
"verify:package": "node scripts/verify-package.ts",
|
|
31
31
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
32
|
-
"test": "npm run build && tsc -p tsconfig.test.json && node --test test-dist/test/*.test.js",
|
|
32
|
+
"test": "npm run build && tsc -p tsconfig.test.json && node --test test-dist/test/*.test.js test-dist/test/unit/*.unit.spec.js",
|
|
33
33
|
"test:e2e": "npm run build && tsc -p tsconfig.test.json && node --test test-dist/test/proxy.test.js test-dist/test/token-manager.test.js"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|