@tacitai/mcp-server 0.1.1 → 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.
Files changed (70) hide show
  1. package/dist/auth.d.ts +32 -0
  2. package/dist/auth.js +64 -0
  3. package/dist/client.d.ts +60 -5
  4. package/dist/client.js +234 -53
  5. package/dist/index.js +130 -106
  6. package/dist/oauth.d.ts +115 -0
  7. package/dist/oauth.js +619 -0
  8. package/dist/prompts.d.ts +2 -0
  9. package/dist/prompts.js +12 -0
  10. package/dist/setup.d.ts +28 -5
  11. package/dist/setup.js +266 -59
  12. package/dist/tools/agents.d.ts +277 -0
  13. package/dist/tools/agents.js +335 -0
  14. package/dist/tools/annotations.d.ts +117 -0
  15. package/dist/tools/annotations.js +130 -0
  16. package/dist/tools/artifacts.d.ts +178 -8
  17. package/dist/tools/artifacts.js +372 -81
  18. package/dist/tools/chronicle.d.ts +3 -0
  19. package/dist/tools/chronicle.js +165 -0
  20. package/dist/tools/connector_write_back.d.ts +3 -0
  21. package/dist/tools/connector_write_back.js +27 -0
  22. package/dist/tools/converse.d.ts +10 -0
  23. package/dist/tools/converse.js +88 -0
  24. package/dist/tools/crews.d.ts +3 -0
  25. package/dist/tools/crews.js +115 -0
  26. package/dist/tools/drafts.d.ts +83 -0
  27. package/dist/tools/drafts.js +86 -0
  28. package/dist/tools/errors.d.ts +15 -0
  29. package/dist/tools/errors.js +21 -0
  30. package/dist/tools/export.d.ts +54 -0
  31. package/dist/tools/export.js +55 -0
  32. package/dist/tools/gm.d.ts +214 -0
  33. package/dist/tools/gm.js +198 -0
  34. package/dist/tools/ingest.d.ts +58 -4
  35. package/dist/tools/ingest.js +109 -76
  36. package/dist/tools/kb.d.ts +544 -0
  37. package/dist/tools/kb.js +687 -0
  38. package/dist/tools/lake-structure.d.ts +3 -0
  39. package/dist/tools/lake-structure.js +126 -0
  40. package/dist/tools/lake.d.ts +932 -0
  41. package/dist/tools/lake.js +1017 -0
  42. package/dist/tools/move.d.ts +200 -0
  43. package/dist/tools/move.js +96 -0
  44. package/dist/tools/outbox.d.ts +274 -0
  45. package/dist/tools/outbox.js +223 -0
  46. package/dist/tools/people.d.ts +3 -0
  47. package/dist/tools/people.js +59 -0
  48. package/dist/tools/pyramid.d.ts +554 -0
  49. package/dist/tools/pyramid.js +758 -0
  50. package/dist/tools/recall.d.ts +2 -0
  51. package/dist/tools/recall.js +28 -17
  52. package/dist/tools/registry.d.ts +17 -0
  53. package/dist/tools/registry.js +199 -0
  54. package/dist/tools/research.d.ts +47 -0
  55. package/dist/tools/research.js +575 -0
  56. package/dist/tools/search.d.ts +9 -0
  57. package/dist/tools/search.js +86 -25
  58. package/dist/tools/sharing.d.ts +63 -0
  59. package/dist/tools/sharing.js +157 -0
  60. package/dist/tools/skills.d.ts +9 -0
  61. package/dist/tools/skills.js +98 -0
  62. package/dist/tools/synthesize.d.ts +54 -2
  63. package/dist/tools/synthesize.js +80 -23
  64. package/dist/tools/web-url.d.ts +57 -0
  65. package/dist/tools/web-url.js +74 -0
  66. package/dist/tools/workspaces.d.ts +3 -0
  67. package/dist/tools/workspaces.js +47 -0
  68. package/dist/transport.d.ts +9 -0
  69. package/dist/transport.js +209 -0
  70. package/package.json +5 -4
package/dist/oauth.js ADDED
@@ -0,0 +1,619 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * OAuth 2.0 Authorization Server for MCP (FEAT-176).
4
+ *
5
+ * Authorization code flow with PKCE S256. In-memory stores for auth codes
6
+ * and access tokens — process restart voids all grants.
7
+ *
8
+ * Security invariants:
9
+ * - PINs, auth codes, and token values are NEVER logged.
10
+ * - OAuth path NEVER falls back to process.env.TACIT_TOKEN.
11
+ * - Unknown client_id → 400 (never redirect to unregistered URI).
12
+ * - code_challenge_method must be S256 (plain rejected).
13
+ */
14
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
15
+ function timingSafeCompare(a, b) {
16
+ const bufA = Buffer.from(a);
17
+ const bufB = Buffer.from(b);
18
+ if (bufA.length !== bufB.length) {
19
+ timingSafeEqual(bufA, bufA);
20
+ return false;
21
+ }
22
+ return timingSafeEqual(bufA, bufB);
23
+ }
24
+ // ─── Configuration ──────────────────────────────────────────────────────────
25
+ const CODE_TTL_MS = 5 * 60 * 1000;
26
+ const CODE_STORE_CAP = 1000;
27
+ const TOKEN_STORE_CAP = 10000;
28
+ const CSRF_TTL_MS = 10 * 60 * 1000;
29
+ const CSRF_STORE_CAP = 500;
30
+ const LOGIN_RATE_LIMIT = 5;
31
+ const LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000;
32
+ const DYNAMIC_CLIENT_CAP = 500;
33
+ export function getTokenTtlSeconds() {
34
+ const env = Number.parseInt(process.env['MCP_OAUTH_TOKEN_TTL_SECONDS'] ?? '', 10);
35
+ return Number.isFinite(env) && env > 0 && env <= 3600 ? env : 3600;
36
+ }
37
+ export function isOAuthEnabled() {
38
+ const v = process.env['MCP_OAUTH_ENABLED'];
39
+ return v === '1' || v === 'true';
40
+ }
41
+ // ─── Client Registry ────────────────────────────────────────────────────────
42
+ let _clients = null;
43
+ const dynamicClients = new Map();
44
+ export function getRegisteredClients() {
45
+ if (_clients)
46
+ return _clients;
47
+ const env = process.env['MCP_OAUTH_CLIENTS'];
48
+ if (env) {
49
+ try {
50
+ const parsed = JSON.parse(env);
51
+ if (Array.isArray(parsed)) {
52
+ _clients = parsed;
53
+ return _clients;
54
+ }
55
+ }
56
+ catch { /* fall through to default */ }
57
+ }
58
+ _clients = [];
59
+ return _clients;
60
+ }
61
+ export function resetClientRegistry() {
62
+ _clients = null;
63
+ dynamicClients.clear();
64
+ }
65
+ export function findClient(client_id) {
66
+ return getRegisteredClients().find((c) => c.client_id === client_id) ?? dynamicClients.get(client_id);
67
+ }
68
+ export function validateRedirectUri(client, redirect_uri) {
69
+ return client.redirect_uris.includes(redirect_uri);
70
+ }
71
+ export function registerDynamicClient(params) {
72
+ if (dynamicClients.size >= DYNAMIC_CLIENT_CAP) {
73
+ const oldest = dynamicClients.keys().next().value;
74
+ if (oldest)
75
+ dynamicClients.delete(oldest);
76
+ }
77
+ const client_id = `dyn_${randomBytes(16).toString('hex')}`;
78
+ const client_secret = randomBytes(32).toString('base64url');
79
+ const client_name = params.client_name || 'Dynamic Client';
80
+ const authMethod = params.token_endpoint_auth_method === 'none' ? 'none' : 'client_secret_post';
81
+ dynamicClients.set(client_id, {
82
+ client_id,
83
+ client_secret: authMethod === 'none' ? undefined : client_secret,
84
+ redirect_uris: params.redirect_uris,
85
+ client_name,
86
+ });
87
+ return {
88
+ client_id,
89
+ client_secret,
90
+ client_id_issued_at: Math.floor(Date.now() / 1000),
91
+ redirect_uris: params.redirect_uris,
92
+ client_name,
93
+ token_endpoint_auth_method: authMethod,
94
+ };
95
+ }
96
+ // ─── Auth Code Store ────────────────────────────────────────────────────────
97
+ const authCodes = new Map();
98
+ function evictExpiredCodes() {
99
+ const now = Date.now();
100
+ for (const [code, entry] of authCodes) {
101
+ if (now - entry.created_at > CODE_TTL_MS)
102
+ authCodes.delete(code);
103
+ }
104
+ }
105
+ function generateCode() {
106
+ return randomBytes(16).toString('hex');
107
+ }
108
+ export function issueAuthCode(params) {
109
+ evictExpiredCodes();
110
+ if (authCodes.size >= CODE_STORE_CAP) {
111
+ const oldest = authCodes.keys().next().value;
112
+ if (oldest !== undefined)
113
+ authCodes.delete(oldest);
114
+ }
115
+ const code = generateCode();
116
+ authCodes.set(code, { ...params, created_at: Date.now() });
117
+ return code;
118
+ }
119
+ export function consumeAuthCode(code) {
120
+ evictExpiredCodes();
121
+ const entry = authCodes.get(code);
122
+ if (!entry)
123
+ return null;
124
+ authCodes.delete(code);
125
+ if (Date.now() - entry.created_at > CODE_TTL_MS)
126
+ return null;
127
+ return entry;
128
+ }
129
+ // ─── Access Token Store ─────────────────────────────────────────────────────
130
+ const accessTokens = new Map();
131
+ function evictExpiredTokens() {
132
+ const now = Date.now();
133
+ for (const [token, entry] of accessTokens) {
134
+ if (now >= entry.expires_at)
135
+ accessTokens.delete(token);
136
+ }
137
+ }
138
+ function generateTokenValue() {
139
+ return `tmo_${randomBytes(16).toString('hex')}`;
140
+ }
141
+ export function issueAccessToken(params) {
142
+ evictExpiredTokens();
143
+ if (accessTokens.size >= TOKEN_STORE_CAP) {
144
+ const oldest = accessTokens.keys().next().value;
145
+ if (oldest !== undefined)
146
+ accessTokens.delete(oldest);
147
+ }
148
+ const ttl = getTokenTtlSeconds();
149
+ const now = Date.now();
150
+ const token = generateTokenValue();
151
+ accessTokens.set(token, {
152
+ ...params,
153
+ created_at: now,
154
+ expires_at: now + ttl * 1000,
155
+ });
156
+ return { access_token: token, expires_in: ttl };
157
+ }
158
+ export function validateOAuthToken(token) {
159
+ if (!token.startsWith('tmo_'))
160
+ return null;
161
+ evictExpiredTokens();
162
+ const entry = accessTokens.get(token);
163
+ if (!entry)
164
+ return null;
165
+ if (Date.now() >= entry.expires_at) {
166
+ accessTokens.delete(token);
167
+ return null;
168
+ }
169
+ return {
170
+ user_email: entry.user_email,
171
+ workspace_id: entry.workspace_id,
172
+ tacit_token: entry.tacit_token,
173
+ client_id: entry.client_id,
174
+ };
175
+ }
176
+ // ─── PKCE ───────────────────────────────────────────────────────────────────
177
+ export function verifyPkce(code_verifier, code_challenge) {
178
+ const computed = createHash('sha256').update(code_verifier).digest('base64url');
179
+ return computed === code_challenge;
180
+ }
181
+ // ─── CSRF Store ─────────────────────────────────────────────────────────────
182
+ const csrfTokens = new Map();
183
+ function evictExpiredCsrf() {
184
+ const now = Date.now();
185
+ for (const [token, entry] of csrfTokens) {
186
+ if (now - entry.created_at > CSRF_TTL_MS)
187
+ csrfTokens.delete(token);
188
+ }
189
+ }
190
+ export function generateCsrfToken(params) {
191
+ evictExpiredCsrf();
192
+ if (csrfTokens.size >= CSRF_STORE_CAP) {
193
+ const oldest = csrfTokens.keys().next().value;
194
+ if (oldest !== undefined)
195
+ csrfTokens.delete(oldest);
196
+ }
197
+ const token = randomBytes(16).toString('hex');
198
+ csrfTokens.set(token, { ...params, created_at: Date.now() });
199
+ return token;
200
+ }
201
+ export function consumeCsrfToken(token) {
202
+ evictExpiredCsrf();
203
+ const entry = csrfTokens.get(token);
204
+ if (!entry)
205
+ return null;
206
+ csrfTokens.delete(token);
207
+ if (Date.now() - entry.created_at > CSRF_TTL_MS)
208
+ return null;
209
+ return entry;
210
+ }
211
+ // ─── Login Rate Limiter ─────────────────────────────────────────────────────
212
+ const loginAttempts = new Map();
213
+ export function checkLoginRateLimit(email) {
214
+ const now = Date.now();
215
+ const key = email.toLowerCase();
216
+ const entry = loginAttempts.get(key);
217
+ if (!entry || now - entry.window_start > LOGIN_RATE_WINDOW_MS) {
218
+ loginAttempts.set(key, { count: 1, window_start: now });
219
+ return true;
220
+ }
221
+ if (entry.count >= LOGIN_RATE_LIMIT)
222
+ return false;
223
+ entry.count += 1;
224
+ return true;
225
+ }
226
+ // ─── Discovery ──────────────────────────────────────────────────────────────
227
+ export function getAuthorizationServerMetadata(issuer) {
228
+ return {
229
+ issuer,
230
+ authorization_endpoint: `${issuer}/authorize`,
231
+ token_endpoint: `${issuer}/token`,
232
+ registration_endpoint: `${issuer}/register`,
233
+ response_types_supported: ['code'],
234
+ grant_types_supported: ['authorization_code'],
235
+ code_challenge_methods_supported: ['S256'],
236
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
237
+ };
238
+ }
239
+ export function getProtectedResourceMetadata(resource, issuer) {
240
+ return {
241
+ resource,
242
+ authorization_servers: [issuer],
243
+ };
244
+ }
245
+ // ─── Login Page HTML ────────────────────────────────────────────────────────
246
+ export function renderLoginPage(params) {
247
+ const errorHtml = params.error
248
+ ? `<p style="color:#e74c3c;margin:0 0 16px">${escapeHtml(params.error)}</p>`
249
+ : '';
250
+ return `<!DOCTYPE html>
251
+ <html lang="en">
252
+ <head>
253
+ <meta charset="utf-8">
254
+ <meta name="viewport" content="width=device-width,initial-scale=1">
255
+ <title>TACIT — Authorize ${escapeHtml(params.client_name)}</title>
256
+ <style>
257
+ *{box-sizing:border-box;margin:0;padding:0}
258
+ body{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:16px}
259
+ .card{background:#16213e;border-radius:12px;padding:32px;max-width:400px;width:100%;box-shadow:0 4px 24px rgba(0,0,0,0.3)}
260
+ h1{font-size:1.25rem;margin-bottom:8px;color:#f0f0f0}
261
+ .sub{color:#8892b0;font-size:0.875rem;margin-bottom:24px}
262
+ label{display:block;font-size:0.875rem;color:#8892b0;margin-bottom:4px}
263
+ input{display:block;width:100%;padding:10px 12px;border:1px solid #2a2a4a;border-radius:6px;background:#0f0f23;color:#e0e0e0;font-size:1rem;margin-bottom:16px}
264
+ input:focus{outline:none;border-color:#64ffda}
265
+ button{width:100%;padding:12px;border:none;border-radius:6px;background:#64ffda;color:#0a0a1a;font-size:1rem;font-weight:600;cursor:pointer}
266
+ button:hover{background:#52e0c4}
267
+ </style>
268
+ </head>
269
+ <body>
270
+ <div class="card">
271
+ <h1>Authorize ${escapeHtml(params.client_name)}</h1>
272
+ <p class="sub">Sign in to grant access to your TACIT workspace</p>
273
+ ${errorHtml}
274
+ <form method="POST" action="/authorize">
275
+ <input type="hidden" name="csrf_token" value="${escapeHtml(params.csrf_token)}">
276
+ <label for="email">Email</label>
277
+ <input id="email" name="email" type="email" required autocomplete="email">
278
+ <label for="pin">PIN</label>
279
+ <input id="pin" name="pin" type="password" required autocomplete="current-password" inputmode="numeric">
280
+ <button type="submit">Authorize</button>
281
+ </form>
282
+ </div>
283
+ </body>
284
+ </html>`;
285
+ }
286
+ function escapeHtml(s) {
287
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
288
+ }
289
+ // ─── HTTP Handlers ──────────────────────────────────────────────────────────
290
+ function sendJsonResponse(res, status, body) {
291
+ if (res.headersSent)
292
+ return;
293
+ res.writeHead(status, { 'Content-Type': 'application/json' });
294
+ res.end(JSON.stringify(body));
295
+ }
296
+ function sendHtmlResponse(res, status, html) {
297
+ if (res.headersSent)
298
+ return;
299
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
300
+ res.end(html);
301
+ }
302
+ function parseQuery(url) {
303
+ const idx = url.indexOf('?');
304
+ if (idx === -1)
305
+ return {};
306
+ const params = {};
307
+ for (const part of url.slice(idx + 1).split('&')) {
308
+ const [k, ...rest] = part.split('=');
309
+ if (k)
310
+ params[decodeURIComponent(k)] = decodeURIComponent(rest.join('='));
311
+ }
312
+ return params;
313
+ }
314
+ function parseFormBody(body) {
315
+ const params = {};
316
+ for (const part of body.split('&')) {
317
+ const [k, ...rest] = part.split('=');
318
+ if (k)
319
+ params[decodeURIComponent(k)] = decodeURIComponent(rest.join('='));
320
+ }
321
+ return params;
322
+ }
323
+ function readRequestBody(req, maxBytes = 64_000) {
324
+ return new Promise((resolve, reject) => {
325
+ const chunks = [];
326
+ let size = 0;
327
+ req.on('data', (chunk) => {
328
+ size += chunk.length;
329
+ if (size > maxBytes) {
330
+ reject(new Error('body too large'));
331
+ req.destroy();
332
+ return;
333
+ }
334
+ chunks.push(chunk);
335
+ });
336
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
337
+ req.on('error', reject);
338
+ });
339
+ }
340
+ export async function handleAuthorizeGet(req, res) {
341
+ const query = parseQuery(req.url ?? '');
342
+ const { client_id, redirect_uri, response_type, code_challenge, code_challenge_method, state, scope } = query;
343
+ if (response_type !== 'code') {
344
+ sendJsonResponse(res, 400, { error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' });
345
+ return;
346
+ }
347
+ if (!client_id) {
348
+ sendJsonResponse(res, 400, { error: 'invalid_request', error_description: 'client_id is required' });
349
+ return;
350
+ }
351
+ const client = findClient(client_id);
352
+ if (!client) {
353
+ sendJsonResponse(res, 400, { error: 'invalid_client', error_description: 'Unknown client_id' });
354
+ return;
355
+ }
356
+ if (!redirect_uri || !validateRedirectUri(client, redirect_uri)) {
357
+ sendJsonResponse(res, 400, { error: 'invalid_request', error_description: 'Invalid or missing redirect_uri' });
358
+ return;
359
+ }
360
+ if (!code_challenge) {
361
+ sendJsonResponse(res, 400, { error: 'invalid_request', error_description: 'code_challenge is required (PKCE S256)' });
362
+ return;
363
+ }
364
+ if (code_challenge_method && code_challenge_method !== 'S256') {
365
+ sendJsonResponse(res, 400, { error: 'invalid_request', error_description: 'Only S256 code_challenge_method is supported' });
366
+ return;
367
+ }
368
+ const csrf = generateCsrfToken({
369
+ client_id,
370
+ redirect_uri,
371
+ code_challenge,
372
+ code_challenge_method: 'S256',
373
+ state: state ?? '',
374
+ scope: scope ?? '',
375
+ });
376
+ sendHtmlResponse(res, 200, renderLoginPage({ client_name: client.client_name, csrf_token: csrf }));
377
+ }
378
+ export async function handleAuthorizePost(req, res, apiUrl) {
379
+ let body;
380
+ try {
381
+ body = await readRequestBody(req);
382
+ }
383
+ catch {
384
+ sendJsonResponse(res, 400, { error: 'invalid_request' });
385
+ return;
386
+ }
387
+ const form = parseFormBody(body);
388
+ const { csrf_token, email, pin } = form;
389
+ if (!csrf_token) {
390
+ sendJsonResponse(res, 400, { error: 'invalid_request', error_description: 'Missing CSRF token' });
391
+ return;
392
+ }
393
+ const csrf = consumeCsrfToken(csrf_token);
394
+ if (!csrf) {
395
+ sendJsonResponse(res, 400, { error: 'invalid_request', error_description: 'Invalid or expired CSRF token' });
396
+ return;
397
+ }
398
+ if (!email || !pin) {
399
+ const client = findClient(csrf.client_id);
400
+ const newCsrf = generateCsrfToken(csrf);
401
+ sendHtmlResponse(res, 400, renderLoginPage({
402
+ client_name: client?.client_name ?? 'Unknown',
403
+ csrf_token: newCsrf,
404
+ error: 'Email and PIN are required',
405
+ }));
406
+ return;
407
+ }
408
+ if (!checkLoginRateLimit(email)) {
409
+ const client = findClient(csrf.client_id);
410
+ const newCsrf = generateCsrfToken(csrf);
411
+ sendHtmlResponse(res, 429, renderLoginPage({
412
+ client_name: client?.client_name ?? 'Unknown',
413
+ csrf_token: newCsrf,
414
+ error: 'Too many login attempts. Please try again later.',
415
+ }));
416
+ return;
417
+ }
418
+ try {
419
+ const loginRes = await fetch(`${apiUrl}/api/v1/auth/login`, {
420
+ method: 'POST',
421
+ headers: { 'Content-Type': 'application/json' },
422
+ body: JSON.stringify({ email, pin }),
423
+ });
424
+ if (!loginRes.ok) {
425
+ const client = findClient(csrf.client_id);
426
+ const newCsrf = generateCsrfToken(csrf);
427
+ sendHtmlResponse(res, 401, renderLoginPage({
428
+ client_name: client?.client_name ?? 'Unknown',
429
+ csrf_token: newCsrf,
430
+ error: 'Invalid email or PIN',
431
+ }));
432
+ return;
433
+ }
434
+ const loginData = await loginRes.json();
435
+ const tacit_token = (loginData['token'] ?? loginData['session_token'] ?? '');
436
+ const workspace_id = (loginData['workspace_id'] ?? loginData['active_workspace_id'] ?? '');
437
+ if (!tacit_token) {
438
+ const client = findClient(csrf.client_id);
439
+ const newCsrf = generateCsrfToken(csrf);
440
+ sendHtmlResponse(res, 500, renderLoginPage({
441
+ client_name: client?.client_name ?? 'Unknown',
442
+ csrf_token: newCsrf,
443
+ error: 'Authentication succeeded but no session token was returned',
444
+ }));
445
+ return;
446
+ }
447
+ const code = issueAuthCode({
448
+ client_id: csrf.client_id,
449
+ redirect_uri: csrf.redirect_uri,
450
+ code_challenge: csrf.code_challenge,
451
+ user_email: email,
452
+ workspace_id,
453
+ tacit_token,
454
+ state: csrf.state,
455
+ });
456
+ const redirectUrl = new URL(csrf.redirect_uri);
457
+ redirectUrl.searchParams.set('code', code);
458
+ if (csrf.state)
459
+ redirectUrl.searchParams.set('state', csrf.state);
460
+ res.writeHead(302, { Location: redirectUrl.toString() });
461
+ res.end();
462
+ }
463
+ catch {
464
+ const client = findClient(csrf.client_id);
465
+ const newCsrf = generateCsrfToken(csrf);
466
+ sendHtmlResponse(res, 502, renderLoginPage({
467
+ client_name: client?.client_name ?? 'Unknown',
468
+ csrf_token: newCsrf,
469
+ error: 'Failed to reach authentication service',
470
+ }));
471
+ }
472
+ }
473
+ export async function handleTokenExchange(req, res) {
474
+ let body;
475
+ try {
476
+ body = await readRequestBody(req);
477
+ }
478
+ catch {
479
+ sendJsonResponse(res, 400, { error: 'invalid_request' });
480
+ return;
481
+ }
482
+ let params;
483
+ const ct = req.headers['content-type'] ?? '';
484
+ if (ct.includes('application/json')) {
485
+ try {
486
+ params = JSON.parse(body);
487
+ }
488
+ catch {
489
+ sendJsonResponse(res, 400, { error: 'invalid_request', error_description: 'Invalid JSON' });
490
+ return;
491
+ }
492
+ }
493
+ else {
494
+ params = parseFormBody(body);
495
+ }
496
+ const { grant_type, code, client_id, redirect_uri, code_verifier } = params;
497
+ if (grant_type !== 'authorization_code') {
498
+ sendJsonResponse(res, 400, { error: 'unsupported_grant_type' });
499
+ return;
500
+ }
501
+ if (!code || !client_id || !redirect_uri || !code_verifier) {
502
+ sendJsonResponse(res, 400, { error: 'invalid_request', error_description: 'Missing required parameters' });
503
+ return;
504
+ }
505
+ const client = findClient(client_id);
506
+ if (!client) {
507
+ sendJsonResponse(res, 400, { error: 'invalid_client' });
508
+ return;
509
+ }
510
+ if (client.client_secret) {
511
+ const client_secret = params['client_secret'];
512
+ if (!client_secret || !timingSafeCompare(client_secret, client.client_secret)) {
513
+ sendJsonResponse(res, 401, { error: 'invalid_client', error_description: 'Client authentication failed' });
514
+ return;
515
+ }
516
+ }
517
+ const entry = consumeAuthCode(code);
518
+ if (!entry) {
519
+ sendJsonResponse(res, 400, { error: 'invalid_grant', error_description: 'Invalid, expired, or already-used authorization code' });
520
+ return;
521
+ }
522
+ if (entry.client_id !== client_id) {
523
+ sendJsonResponse(res, 400, { error: 'invalid_grant', error_description: 'client_id mismatch' });
524
+ return;
525
+ }
526
+ if (entry.redirect_uri !== redirect_uri) {
527
+ sendJsonResponse(res, 400, { error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
528
+ return;
529
+ }
530
+ if (!verifyPkce(code_verifier, entry.code_challenge)) {
531
+ sendJsonResponse(res, 400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
532
+ return;
533
+ }
534
+ const { access_token, expires_in } = issueAccessToken({
535
+ client_id: entry.client_id,
536
+ user_email: entry.user_email,
537
+ workspace_id: entry.workspace_id,
538
+ tacit_token: entry.tacit_token,
539
+ });
540
+ sendJsonResponse(res, 200, {
541
+ access_token,
542
+ token_type: 'Bearer',
543
+ expires_in,
544
+ });
545
+ }
546
+ export async function handleClientRegistration(req, res) {
547
+ let body;
548
+ try {
549
+ body = await readRequestBody(req);
550
+ }
551
+ catch {
552
+ sendJsonResponse(res, 400, { error: 'invalid_request' });
553
+ return;
554
+ }
555
+ let params;
556
+ try {
557
+ params = JSON.parse(body);
558
+ }
559
+ catch {
560
+ sendJsonResponse(res, 400, { error: 'invalid_client_metadata', error_description: 'Invalid JSON' });
561
+ return;
562
+ }
563
+ const redirect_uris = params['redirect_uris'];
564
+ if (!Array.isArray(redirect_uris) || redirect_uris.length === 0 || !redirect_uris.every((u) => typeof u === 'string')) {
565
+ sendJsonResponse(res, 400, { error: 'invalid_client_metadata', error_description: 'redirect_uris must be a non-empty array of strings' });
566
+ return;
567
+ }
568
+ for (const uri of redirect_uris) {
569
+ try {
570
+ new URL(uri);
571
+ }
572
+ catch {
573
+ sendJsonResponse(res, 400, { error: 'invalid_redirect_uri', error_description: `Invalid redirect URI: ${uri}` });
574
+ return;
575
+ }
576
+ }
577
+ const result = registerDynamicClient({
578
+ redirect_uris: redirect_uris,
579
+ client_name: typeof params['client_name'] === 'string' ? params['client_name'] : undefined,
580
+ token_endpoint_auth_method: typeof params['token_endpoint_auth_method'] === 'string' ? params['token_endpoint_auth_method'] : undefined,
581
+ });
582
+ sendJsonResponse(res, 201, result);
583
+ }
584
+ export function handleDiscovery(res, issuer) {
585
+ sendJsonResponse(res, 200, getAuthorizationServerMetadata(issuer));
586
+ }
587
+ export function handleProtectedResource(res, resource, issuer) {
588
+ sendJsonResponse(res, 200, getProtectedResourceMetadata(resource, issuer));
589
+ }
590
+ // ─── Periodic Cleanup ───────────────────────────────────────────────────────
591
+ let _cleanupInterval = null;
592
+ export function startCleanupInterval() {
593
+ if (_cleanupInterval)
594
+ return;
595
+ _cleanupInterval = setInterval(() => {
596
+ evictExpiredCodes();
597
+ evictExpiredTokens();
598
+ evictExpiredCsrf();
599
+ }, 60_000);
600
+ _cleanupInterval.unref();
601
+ }
602
+ export function stopCleanupInterval() {
603
+ if (_cleanupInterval) {
604
+ clearInterval(_cleanupInterval);
605
+ _cleanupInterval = null;
606
+ }
607
+ }
608
+ // ─── Test Helpers ───────────────────────────────────────────────────────────
609
+ export function _testClearStores() {
610
+ authCodes.clear();
611
+ accessTokens.clear();
612
+ csrfTokens.clear();
613
+ loginAttempts.clear();
614
+ dynamicClients.clear();
615
+ _clients = null;
616
+ }
617
+ export function _testGetStoreSize() {
618
+ return { codes: authCodes.size, tokens: accessTokens.size, csrf: csrfTokens.size };
619
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerPrompts(server: McpServer): void;
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { z } from 'zod';
3
+ const ASSISTANT_SYSTEM_PROMPT = "You are TACIT's workspace assistant. Answer from the user's workspace through TACIT tools. " +
4
+ 'Prefer a single read tool for direct lookups, synthesis for open questions, and ask before any write or dispatch.';
5
+ function userText(text) {
6
+ return { messages: [{ role: 'user', content: { type: 'text', text } }] };
7
+ }
8
+ export function registerPrompts(server) {
9
+ server.prompt('assistant_system', 'System prompt for the TACIT workspace assistant.', () => userText(ASSISTANT_SYSTEM_PROMPT));
10
+ server.prompt('lookup', 'Fetch a specific item or list using a single TACIT read tool.', { request: z.string().describe('What to look up, e.g. "list programs"') }, ({ request }) => userText(`Use exactly one TACIT read tool to answer, without synthesis. Request: ${request}`));
11
+ server.prompt('synthesis', 'Answer a question with grounded synthesis and citations.', { question: z.string().describe('The question to answer') }, ({ question }) => userText(`Call tacit_synthesize for this question and cite the sources it returns. Question: ${question}`));
12
+ }
package/dist/setup.d.ts CHANGED
@@ -2,17 +2,40 @@
2
2
  * setup — wizard subcommand for @tacit/mcp-server
3
3
  *
4
4
  * Usage:
5
- * node dist/index.js setup [--dry-run] [--api-url <url>] [--token <token>] [--smoke]
5
+ * node dist/index.js setup [--dry-run] [--api-url <url>] [--token <token>] [--profile <name>] [--smoke]
6
6
  *
7
- * Contract (§2.1 of sparc_specification.md, Phase-2 QE resolutions):
7
+ * Contract (§2.1 of sparc_specification.md, Phase-2 QE resolutions + W5 device-auth):
8
8
  * F1 smoke — calls tacit_synthesize via getClient()/postSSE; exits non-zero on empty/error.
9
- * F2 env-ref — writes TACIT_TOKEN as the LITERAL placeholder "${TACIT_TOKEN}" (never the real value).
10
- * Merge — deep-merges ONLY mcpServers.tacit; all other keys preserved.
11
- * Idempotent — same --api-url → byte-identical mcp.json.
9
+ * F2 env-ref — writes TACIT_TOKEN_* as the LITERAL placeholder "${TACIT_TOKEN_*}" (never the real value).
10
+ * Merge — deep-merges ONLY mcpServers[profile]; all other keys preserved.
11
+ * Idempotent — same --api-url + --profile → byte-identical mcp.json.
12
12
  * Secrets — token NEVER written to any output stream.
13
13
  *
14
+ * W5 additions:
15
+ * --profile <name> — profile name (default: 'tacit'); drives env-var name + mcp.json key.
16
+ * Device-auth flow — when --token is NOT provided and token env var is absent, opens browser
17
+ * to ${apiUrl}/auth/device, waits for localhost:9123 callback, exchanges
18
+ * code for token, derives profile from workspace_name (unless --profile set).
19
+ * Metadata file — writes ~/.tacit/profiles/<profile>.json with { expires_at, api_url }.
20
+ *
14
21
  * Security invariants:
15
22
  * - Token is NEVER logged or written to disk.
16
23
  * - Authorization header value excluded from all logging paths.
24
+ * - State nonce generated from crypto.randomBytes (never user-provided).
25
+ * - Tampered state in device-auth callback → exit 1 (S-10/AC-13).
26
+ */
27
+ /**
28
+ * Derive the environment variable name for a given profile name.
29
+ *
30
+ * AC-3, AC-4, AC-10:
31
+ * 'tacit' → 'TACIT_TOKEN'
32
+ * 'tacit-acme' → 'TACIT_TOKEN_ACME'
33
+ * 'my-workspace' → 'TACIT_TOKEN_MY_WORKSPACE'
34
+ */
35
+ export declare function profileToEnvVar(profile: string): string;
36
+ /**
37
+ * Derive a profile name from a workspace_name returned by device-auth exchange.
38
+ * AC-12: lowercased, spaces→hyphens, non-alphanumeric-hyphen stripped.
17
39
  */
40
+ export declare function workspaceNameToProfile(name: string): string;
18
41
  export declare function setup(argv: string[]): Promise<void>;