@abbit/abbit-mcp 0.1.0 → 0.2.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 +31 -3
- package/dist/auth.js +36 -2
- package/dist/cli.js +25 -6
- package/dist/constants.js +11 -2
- package/dist/model.js +19 -1
- package/dist/personal-key.js +103 -0
- package/dist/proxy.js +2 -2
- package/dist/token-manager.js +10 -0
- package/package.json +1 -1
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 or personal-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,31 @@ 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 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
|
+
## Personal-key login
|
|
24
|
+
|
|
25
|
+
Use an existing Abbit personal 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/personal-key
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Never put the key in command arguments, shell history, environment variables, or MCP client configuration. Login validates the key with Abbit MCP before replacing your local login. Service keys are not accepted. Key issuance and permission management remain in IAM.
|
|
38
|
+
|
|
39
|
+
`serve` uses the saved key directly. 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 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 adds an optional `personalKey` field to the version-1 document. Existing OAuth documents remain readable. Older clients reject a key-bearing document safely; use this version to log out or return to browser login before downgrading.
|
|
16
44
|
|
|
17
45
|
## MCP client configuration
|
|
18
46
|
|
package/dist/auth.js
CHANGED
|
@@ -2,7 +2,8 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { authorizationTimeoutMs, iamOrigin, isMcpClientId, isMcpScopeSet, mcpAudience, mcpScope, parseMcpScope, refreshHardLifetimeMs, refreshIdleLifetimeMs, } from './constants.js';
|
|
5
|
-
import { createAccessTokenEntry } from './model.js';
|
|
5
|
+
import { createAccessTokenEntry, personalKeyNamespace } from './model.js';
|
|
6
|
+
import { validatePersonalKey } from './personal-key.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,7 @@ export class McpAuthService {
|
|
|
213
214
|
lastUsedAt: persistedAt.toISOString(),
|
|
214
215
|
};
|
|
215
216
|
const predecessors = await this.store.withLock(async (document) => {
|
|
217
|
+
delete document.personalKey;
|
|
216
218
|
const pending = [];
|
|
217
219
|
for (const previous of Object.values(document.credentials)) {
|
|
218
220
|
if (!matchesAnyMcp(previous) || previous.credentialId === record.credentialId)
|
|
@@ -243,8 +245,31 @@ export class McpAuthService {
|
|
|
243
245
|
await callback.close();
|
|
244
246
|
}
|
|
245
247
|
}
|
|
248
|
+
async loginWithKey(key, fetchImpl = fetch) {
|
|
249
|
+
const namespaceId = personalKeyNamespace(key);
|
|
250
|
+
if (namespaceId === undefined)
|
|
251
|
+
throw new Error('Enter a valid Abbit personal API key.');
|
|
252
|
+
await validatePersonalKey(key, fetchImpl);
|
|
253
|
+
const pending = await this.store.withLock(async (document) => {
|
|
254
|
+
for (const record of Object.values(document.credentials).filter(matchesAnyMcp)) {
|
|
255
|
+
record.state = 'pendingRevocation';
|
|
256
|
+
record.accessTokens = [];
|
|
257
|
+
}
|
|
258
|
+
document.personalKey = key;
|
|
259
|
+
return {
|
|
260
|
+
result: Object.values(document.credentials)
|
|
261
|
+
.filter(matchesAnyMcp)
|
|
262
|
+
.map(({ credentialId, refreshToken, clientId }) => ({ credentialId, refreshToken, clientId })),
|
|
263
|
+
document,
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
await this.revokePending(pending);
|
|
267
|
+
return { namespaceId };
|
|
268
|
+
}
|
|
246
269
|
async status(currentTime = new Date()) {
|
|
247
|
-
const
|
|
270
|
+
const document = await this.store.read();
|
|
271
|
+
const keyNamespace = personalKeyNamespace(document.personalKey);
|
|
272
|
+
const records = Object.values(document.credentials).filter(matchesAnyMcp);
|
|
248
273
|
const stateFor = (record) => {
|
|
249
274
|
if (record.state === 'pendingRevocation')
|
|
250
275
|
return 'pendingRevocation';
|
|
@@ -256,7 +281,10 @@ export class McpAuthService {
|
|
|
256
281
|
: 'active';
|
|
257
282
|
};
|
|
258
283
|
const namespaces = records.map((record) => ({ namespaceId: record.namespaceId, state: stateFor(record) }));
|
|
284
|
+
if (keyNamespace !== undefined)
|
|
285
|
+
namespaces.unshift({ namespaceId: keyNamespace, state: 'active' });
|
|
259
286
|
return {
|
|
287
|
+
...(keyNamespace === undefined ? {} : { personalKeyNamespace: keyNamespace }),
|
|
260
288
|
authenticated: namespaces.some(({ state }) => state === 'active'),
|
|
261
289
|
pendingRevocation: namespaces.some(({ state }) => state === 'pendingRevocation'),
|
|
262
290
|
reauthenticationRequired: namespaces.some(({ state }) => state === 'reauthenticationRequired'),
|
|
@@ -264,7 +292,10 @@ export class McpAuthService {
|
|
|
264
292
|
};
|
|
265
293
|
}
|
|
266
294
|
async logout() {
|
|
295
|
+
let forgotten = 0;
|
|
267
296
|
const pending = await this.store.withLock(async (document) => {
|
|
297
|
+
forgotten = document.personalKey === undefined ? 0 : 1;
|
|
298
|
+
delete document.personalKey;
|
|
268
299
|
const selected = Object.values(document.credentials).filter(matchesAnyMcp);
|
|
269
300
|
for (const record of selected) {
|
|
270
301
|
record.state = 'pendingRevocation';
|
|
@@ -275,6 +306,9 @@ export class McpAuthService {
|
|
|
275
306
|
document,
|
|
276
307
|
};
|
|
277
308
|
});
|
|
309
|
+
return { ...(await this.revokePending(pending)), forgotten };
|
|
310
|
+
}
|
|
311
|
+
async revokePending(pending) {
|
|
278
312
|
let revoked = 0;
|
|
279
313
|
for (const candidate of pending) {
|
|
280
314
|
if (!(await this.iam.revoke(candidate.refreshToken, candidate.clientId)))
|
package/dist/cli.js
CHANGED
|
@@ -3,16 +3,27 @@ import { Command } from 'commander';
|
|
|
3
3
|
import { McpAuthService } from './auth.js';
|
|
4
4
|
import { CredentialStore } from './credentials.js';
|
|
5
5
|
import { createMcpIamClient, refreshMcpToken } from './iam-client.js';
|
|
6
|
+
import { readPersonalKey } from './personal-key.js';
|
|
6
7
|
import { serveLocalMcp } from './proxy.js';
|
|
7
8
|
import { McpTokenManager } from './token-manager.js';
|
|
8
9
|
const store = new CredentialStore();
|
|
9
10
|
const iam = createMcpIamClient();
|
|
10
11
|
const auth = new McpAuthService(store, iam);
|
|
11
|
-
const program = new Command().name('abbit-mcp').description('Abbit local stdio MCP adapter').version('0.
|
|
12
|
+
const program = new Command().name('abbit-mcp').description('Abbit local stdio MCP adapter').version('0.2.0').showHelpAfterError();
|
|
13
|
+
program.configureOutput({
|
|
14
|
+
outputError: (_message, write) => write('Invalid command. Use --help. Supply personal keys through hidden input or stdin, never arguments.\n'),
|
|
15
|
+
});
|
|
12
16
|
program
|
|
13
17
|
.command('login')
|
|
14
|
-
.description('Authorize Abbit MCP in your browser')
|
|
15
|
-
.
|
|
18
|
+
.description('Authorize Abbit MCP in your browser or with a personal key')
|
|
19
|
+
.option('--key', 'Read an existing personal API key from hidden input or stdin')
|
|
20
|
+
.allowExcessArguments(false)
|
|
21
|
+
.action(async (options) => {
|
|
22
|
+
if (options.key) {
|
|
23
|
+
const record = await auth.loginWithKey(await readPersonalKey());
|
|
24
|
+
process.stdout.write(`Authenticated Abbit MCP with a personal key for namespace ${JSON.stringify(record.namespaceId)}.\n`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
16
27
|
const record = await auth.login({ output: (line) => process.stdout.write(`${line}\n`) });
|
|
17
28
|
process.stdout.write(`Authenticated Abbit MCP for namespace ${record.namespaceId}.\n`);
|
|
18
29
|
});
|
|
@@ -21,7 +32,10 @@ program
|
|
|
21
32
|
.description('Show local Abbit MCP authentication status')
|
|
22
33
|
.action(async () => {
|
|
23
34
|
const status = await auth.status();
|
|
24
|
-
if (status.
|
|
35
|
+
if (status.personalKeyNamespace !== undefined) {
|
|
36
|
+
process.stdout.write(`Personal key saved for namespace ${JSON.stringify(status.personalKeyNamespace)} (server checks validity on use).\n`);
|
|
37
|
+
}
|
|
38
|
+
else if (status.authenticated) {
|
|
25
39
|
for (const record of status.namespaces.filter(({ state }) => state === 'active')) {
|
|
26
40
|
process.stdout.write(`Authenticated: ${record.namespaceId}\n`);
|
|
27
41
|
}
|
|
@@ -36,15 +50,20 @@ program
|
|
|
36
50
|
});
|
|
37
51
|
program
|
|
38
52
|
.command('logout')
|
|
39
|
-
.description('
|
|
53
|
+
.description('Forget a personal key locally and revoke browser OAuth credentials')
|
|
40
54
|
.action(async () => {
|
|
41
55
|
const result = await auth.logout();
|
|
56
|
+
if (result.forgotten > 0)
|
|
57
|
+
process.stdout.write('Personal key removed locally. The key remains valid until revoked in IAM.\n');
|
|
42
58
|
if (result.pending > 0) {
|
|
43
59
|
process.stdout.write('Local access disabled; remote revocation is pending.\n');
|
|
44
60
|
process.exitCode = 1;
|
|
45
61
|
return;
|
|
46
62
|
}
|
|
47
|
-
|
|
63
|
+
if (result.revoked > 0)
|
|
64
|
+
process.stdout.write('Abbit MCP credentials revoked.\n');
|
|
65
|
+
else if (result.forgotten === 0)
|
|
66
|
+
process.stdout.write('No Abbit MCP credentials were configured.\n');
|
|
48
67
|
});
|
|
49
68
|
program
|
|
50
69
|
.command('serve')
|
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,20 @@ 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 personalKeyNamespace = (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' ||
|
|
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
|
+
return decodeNamespaceLocator(locator);
|
|
72
|
+
};
|
|
59
73
|
const isAccessToken = (value) => isRecord(value) &&
|
|
60
74
|
hasExactKeys(value, ['accessToken', 'audience', 'scopes', 'issuedAt', 'expiresAt']) &&
|
|
61
75
|
boundedString(value.accessToken, 1, 32_768) &&
|
|
@@ -113,7 +127,11 @@ const isCredential = (value) => {
|
|
|
113
127
|
};
|
|
114
128
|
export const emptyCredentialDocument = () => ({ version: 1, credentials: {} });
|
|
115
129
|
export const parseCredentialDocument = (value) => {
|
|
116
|
-
if (!isRecord(value) ||
|
|
130
|
+
if (!isRecord(value) ||
|
|
131
|
+
!hasExactKeys(value, 'personalKey' in value ? ['version', 'credentials', 'personalKey'] : ['version', 'credentials']) ||
|
|
132
|
+
value.version !== 1 ||
|
|
133
|
+
!isRecord(value.credentials) ||
|
|
134
|
+
('personalKey' in value && personalKeyNamespace(value.personalKey) === undefined)) {
|
|
117
135
|
throw new TypeError('credential document validation failed');
|
|
118
136
|
}
|
|
119
137
|
for (const [key, credential] of Object.entries(value.credentials)) {
|
|
@@ -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 { personalKeyNamespace } from './model.js';
|
|
5
|
+
export const readPersonalKey = (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 (personalKeyNamespace(key) === undefined)
|
|
30
|
+
reject(new Error('Enter a valid Abbit personal API key.'));
|
|
31
|
+
else
|
|
32
|
+
resolve(key);
|
|
33
|
+
};
|
|
34
|
+
const onError = () => finish(new Error('Could not read the personal API key.'));
|
|
35
|
+
const onEnd = () => finish();
|
|
36
|
+
const onClose = () => finish(new Error('Personal-key input closed.'));
|
|
37
|
+
const onTerminate = () => finish(new Error('Personal-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('Personal-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('Personal 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 fetchWithPersonalKey = async (key, input, init = {}, fetchImpl = fetch) => {
|
|
64
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
65
|
+
if (url !== mcpAudience)
|
|
66
|
+
throw new Error('Personal 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 validatePersonalKey = async (key, fetchImpl = fetch) => {
|
|
81
|
+
if (personalKeyNamespace(key) === undefined)
|
|
82
|
+
throw new Error('Enter a valid Abbit personal API key.');
|
|
83
|
+
const client = new Client({ name: 'abbit-mcp-r0', version: '0.2.0' }, { capabilities: {} });
|
|
84
|
+
const transport = new StreamableHTTPClientTransport(new URL(mcpAudience), {
|
|
85
|
+
fetch: async (input, init) => {
|
|
86
|
+
const response = await fetchWithPersonalKey(key, input, { ...init, signal: AbortSignal.timeout(iamRequestTimeoutMs) }, fetchImpl);
|
|
87
|
+
if (!response.ok && response.status !== 405) {
|
|
88
|
+
await response.body?.cancel();
|
|
89
|
+
throw new Error('Personal-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 personal key with Abbit MCP. Check the key and connection.');
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await client.close().catch(() => undefined);
|
|
102
|
+
}
|
|
103
|
+
};
|
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.2.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.2.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,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { accessTokenSafetyWindowMs, iamOrigin, isMcpClientId, isMcpScopeSet, mcpAudience, parseMcpScope, refreshIdleLifetimeMs, } from './constants.js';
|
|
3
3
|
import { createAccessTokenEntry } from './model.js';
|
|
4
|
+
import { fetchWithPersonalKey } from './personal-key.js';
|
|
4
5
|
export class McpReauthenticationRequiredError extends Error {
|
|
5
6
|
constructor() {
|
|
6
7
|
super('Abbit MCP authentication is required. Run `abbit-mcp login`.');
|
|
@@ -35,6 +36,11 @@ export class McpTokenManager {
|
|
|
35
36
|
async getAccessContext(options = {}) {
|
|
36
37
|
const initialNow = this.now();
|
|
37
38
|
const initialDocument = await this.store.read();
|
|
39
|
+
if (initialDocument.personalKey !== undefined) {
|
|
40
|
+
if (options.credentialId !== undefined || options.rejectedToken !== undefined)
|
|
41
|
+
throw new McpReauthenticationRequiredError();
|
|
42
|
+
return { accessToken: initialDocument.personalKey, credentialId: 'personal-key', personalKey: true };
|
|
43
|
+
}
|
|
38
44
|
const initial = options.credentialId === undefined
|
|
39
45
|
? selectCredential(Object.values(initialDocument.credentials))
|
|
40
46
|
: initialDocument.credentials[options.credentialId];
|
|
@@ -50,6 +56,8 @@ export class McpTokenManager {
|
|
|
50
56
|
return { accessToken: cached.accessToken, credentialId };
|
|
51
57
|
return this.store.withLock(async (document, commit) => {
|
|
52
58
|
const completedAt = this.now();
|
|
59
|
+
if (document.personalKey !== undefined)
|
|
60
|
+
throw new McpReauthenticationRequiredError();
|
|
53
61
|
const credential = document.credentials[credentialId];
|
|
54
62
|
if (credential === undefined || credential.state !== 'active' || !matchesMcpProfile(credential)) {
|
|
55
63
|
throw new McpReauthenticationRequiredError();
|
|
@@ -88,6 +96,8 @@ export class McpTokenManager {
|
|
|
88
96
|
}
|
|
89
97
|
async authenticatedFetch(input, init = {}, fetchImpl = fetch) {
|
|
90
98
|
const initial = await this.getAccessContext();
|
|
99
|
+
if (initial.personalKey)
|
|
100
|
+
return fetchWithPersonalKey(initial.accessToken, input, init, fetchImpl);
|
|
91
101
|
const first = await fetchImpl(input, this.withBearer(init, initial.accessToken));
|
|
92
102
|
if (first.status !== 401)
|
|
93
103
|
return first;
|