@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/auth.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * HTTPS transport auth + rate limiting.
3
+ *
4
+ * Security invariants:
5
+ * - Token validation happens at the TACIT API; this module only checks presence.
6
+ * - The Authorization header value is NEVER logged.
7
+ */
8
+ import type { IncomingMessage } from 'node:http';
9
+ import { type OAuthTokenInfo } from './oauth.js';
10
+ /** Returns the Bearer token, or null if missing/empty. */
11
+ export declare function authenticateRequest(req: IncomingMessage): string | null;
12
+ export type AuthResult = {
13
+ type: 'bearer';
14
+ token: string;
15
+ } | {
16
+ type: 'oauth';
17
+ info: OAuthTokenInfo;
18
+ } | {
19
+ type: 'invalid_token';
20
+ } | null;
21
+ /**
22
+ * Dual-auth resolution: discriminates Bearer vs OAuth tokens.
23
+ * OAuth tokens carry the `tmo_` prefix; all others route to Bearer path.
24
+ */
25
+ export declare function resolveAuth(req: IncomingMessage): AuthResult;
26
+ export declare class RateLimiter {
27
+ private readonly buckets;
28
+ private readonly limit;
29
+ constructor(limit?: number);
30
+ /** Consumes one token for the IP; returns false when exhausted. */
31
+ tryConsume(ip: string): boolean;
32
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * HTTPS transport auth + rate limiting.
4
+ *
5
+ * Security invariants:
6
+ * - Token validation happens at the TACIT API; this module only checks presence.
7
+ * - The Authorization header value is NEVER logged.
8
+ */
9
+ import { validateOAuthToken } from './oauth.js';
10
+ /** Returns the Bearer token, or null if missing/empty. */
11
+ export function authenticateRequest(req) {
12
+ const header = req.headers['authorization'];
13
+ if (typeof header !== 'string')
14
+ return null;
15
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
16
+ const token = match?.[1]?.trim();
17
+ return token ? token : null;
18
+ }
19
+ /**
20
+ * Dual-auth resolution: discriminates Bearer vs OAuth tokens.
21
+ * OAuth tokens carry the `tmo_` prefix; all others route to Bearer path.
22
+ */
23
+ export function resolveAuth(req) {
24
+ const header = req.headers['authorization'];
25
+ if (typeof header !== 'string')
26
+ return null;
27
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
28
+ const token = match?.[1]?.trim();
29
+ if (!token)
30
+ return null;
31
+ if (token.startsWith('tmo_')) {
32
+ const info = validateOAuthToken(token);
33
+ if (!info)
34
+ return { type: 'invalid_token' };
35
+ return { type: 'oauth', info };
36
+ }
37
+ return { type: 'bearer', token };
38
+ }
39
+ const REFILL_INTERVAL_MS = 60_000;
40
+ export class RateLimiter {
41
+ buckets = new Map();
42
+ limit;
43
+ constructor(limit) {
44
+ const fromEnv = Number.parseInt(process.env['MCP_RATE_LIMIT_RPM'] ?? '', 10);
45
+ this.limit = limit ?? (Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 100);
46
+ }
47
+ /** Consumes one token for the IP; returns false when exhausted. */
48
+ tryConsume(ip) {
49
+ const now = Date.now();
50
+ let bucket = this.buckets.get(ip);
51
+ if (!bucket) {
52
+ bucket = { tokens: this.limit, lastRefill: now };
53
+ this.buckets.set(ip, bucket);
54
+ }
55
+ else if (now - bucket.lastRefill >= REFILL_INTERVAL_MS) {
56
+ bucket.tokens = this.limit;
57
+ bucket.lastRefill = now;
58
+ }
59
+ if (bucket.tokens <= 0)
60
+ return false;
61
+ bucket.tokens -= 1;
62
+ return true;
63
+ }
64
+ }
package/dist/client.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  export interface TacitClientOptions {
11
11
  apiUrl?: string;
12
+ appUrl?: string;
12
13
  token?: string;
13
14
  workspaceId?: string;
14
15
  /** Override the root directory used to locate .tacit/sync.json (default: process.cwd()). */
@@ -16,10 +17,13 @@ export interface TacitClientOptions {
16
17
  }
17
18
  export declare class TacitClient {
18
19
  private readonly apiUrl;
19
- private readonly token;
20
+ private readonly _appUrl;
21
+ private token;
20
22
  private readonly _fetch;
21
23
  private readonly _rootDir;
22
- readonly workspaceId: string;
24
+ private _workspaceId;
25
+ private _workspaceName;
26
+ private _initialized;
23
27
  /**
24
28
  * @param fetchOrOpts - Optional: a custom fetch implementation (for testing),
25
29
  * or a TacitClientOptions object. If a function is provided it overrides
@@ -28,6 +32,13 @@ export declare class TacitClient {
28
32
  * Only used when fetchOrOpts is a fetch function.
29
33
  */
30
34
  constructor(fetchOrOpts?: typeof globalThis.fetch | TacitClientOptions, opts?: TacitClientOptions);
35
+ get workspaceId(): string;
36
+ get workspaceName(): string | null;
37
+ initialize(): Promise<void>;
38
+ switchWorkspace(workspaceId: string): Promise<{
39
+ workspace_id: string;
40
+ name: string;
41
+ }>;
31
42
  /**
32
43
  * Resolve the workspace ID:
33
44
  * 1. Read workspace_id from <rootDir>/.tacit/sync.json if the file exists.
@@ -35,12 +46,38 @@ export declare class TacitClient {
35
46
  * 3. Return null if neither source is available.
36
47
  */
37
48
  resolveWorkspaceId(): Promise<string | null>;
38
- /** Build auth headers. Value is NEVER written to logs. */
49
+ /**
50
+ * Build a user-facing URL for an entity in the TACIT web app.
51
+ * Returns null when TACIT_APP_URL is not configured.
52
+ *
53
+ * Route patterns (TanStack file-based routing in tacitai.app.beta):
54
+ * artifact → /view/:id
55
+ * kb_article → /knowledge/:collectionId/:articleId
56
+ * dossier → /dossiers (list; no single-dossier route yet)
57
+ * guest → /guest/view/:id
58
+ */
59
+ webUrl(entityType: string, id: string, extra?: {
60
+ collectionId?: string;
61
+ }): string | null;
62
+ /**
63
+ * Build auth headers. Value is NEVER written to logs.
64
+ *
65
+ * MV-1515n (W5 harness): when TACIT_SESSION_TOKEN is set (an HMAC
66
+ * X-Tacit-Session token, per tacit-internal-token-auth §HMAC session token
67
+ * format), it takes precedence and is sent as X-Tacit-Session instead of
68
+ * Authorization — this is additive and does not change default behavior
69
+ * (Bearer via TACIT_TOKEN) when TACIT_SESSION_TOKEN is unset.
70
+ */
39
71
  private authHeaders;
40
72
  /** GET request returning parsed JSON. */
41
73
  get<T = unknown>(endpoint: string, params?: Record<string, string | number | boolean | undefined>): Promise<T>;
42
- /** POST request returning parsed JSON. */
43
- post<T = unknown>(endpoint: string, body: unknown): Promise<T>;
74
+ /** POST request returning parsed JSON. `extraHeaders` merges on top of auth headers (e.g. If-Match). */
75
+ post<T = unknown>(endpoint: string, body: unknown, extraHeaders?: Record<string, string>): Promise<T>;
76
+ /** PUT request returning parsed JSON. */
77
+ put<T = unknown>(endpoint: string, body: unknown): Promise<T>;
78
+ /** DELETE request returning parsed JSON (or empty on 204). */
79
+ delete<T = unknown>(endpoint: string): Promise<T | null>;
80
+ request<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
44
81
  /** PATCH request returning parsed JSON. */
45
82
  patch<T = unknown>(endpoint: string, body: unknown): Promise<T>;
46
83
  /**
@@ -57,4 +94,22 @@ export declare class TacitApiError extends Error {
57
94
  readonly endpoint: string;
58
95
  constructor(statusCode: number, body: string, endpoint: string);
59
96
  }
97
+ /**
98
+ * Thrown when the API returns 401 — indicates the developer token has expired
99
+ * or been revoked. Tools catch this and surface a user-actionable message.
100
+ *
101
+ * FEAT-193: The server grants a 7-day grace window after expiry — during that
102
+ * window, `POST /api/v1/developer-tokens/refresh` still works (no browser
103
+ * re-auth needed). After 7 days, full device-auth is required.
104
+ *
105
+ * Security invariant: token plaintext is NEVER included in this error.
106
+ */
107
+ export declare class TokenExpiredError extends Error {
108
+ readonly renewalUrl: string;
109
+ readonly setupCommand: string;
110
+ constructor(renewalUrl: string, setupCommand: string);
111
+ }
112
+ /** HTTPS mode: bind the caller's token/apiUrl/principal to everything run inside fn. */
113
+ export declare function runWithRequestContext<T>(token: string, apiUrl: string, fn: () => Promise<T>, principal?: string): Promise<T>;
114
+ export declare function getRequestPrincipal(): string | null;
60
115
  export declare function getClient(): TacitClient;
package/dist/client.js CHANGED
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  // SPDX-License-Identifier: MIT
3
2
  /**
4
3
  * TacitClient — REST/SSE client over TACIT_API_URL.
@@ -9,50 +8,22 @@
9
8
  * - No anthropic/openai SDK import.
10
9
  * - Token sourced exclusively from process.env.TACIT_TOKEN.
11
10
  */
12
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
- if (k2 === undefined) k2 = k;
14
- var desc = Object.getOwnPropertyDescriptor(m, k);
15
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
- desc = { enumerable: true, get: function() { return m[k]; } };
17
- }
18
- Object.defineProperty(o, k2, desc);
19
- }) : (function(o, m, k, k2) {
20
- if (k2 === undefined) k2 = k;
21
- o[k2] = m[k];
22
- }));
23
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
- Object.defineProperty(o, "default", { enumerable: true, value: v });
25
- }) : function(o, v) {
26
- o["default"] = v;
27
- });
28
- var __importStar = (this && this.__importStar) || (function () {
29
- var ownKeys = function(o) {
30
- ownKeys = Object.getOwnPropertyNames || function (o) {
31
- var ar = [];
32
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
- return ar;
34
- };
35
- return ownKeys(o);
36
- };
37
- return function (mod) {
38
- if (mod && mod.__esModule) return mod;
39
- var result = {};
40
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
- __setModuleDefault(result, mod);
42
- return result;
43
- };
44
- })();
45
- Object.defineProperty(exports, "__esModule", { value: true });
46
- exports.TacitApiError = exports.TacitClient = void 0;
47
- exports.getClient = getClient;
48
- const fs = __importStar(require("fs"));
49
- const path = __importStar(require("path"));
50
- class TacitClient {
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
+ function isHmacSessionToken(token) {
15
+ const parts = token.split('.');
16
+ return parts.length === 2 && /^[0-9a-f]{64}$/.test(parts[1]);
17
+ }
18
+ export class TacitClient {
51
19
  apiUrl;
20
+ _appUrl;
52
21
  token;
53
22
  _fetch;
54
23
  _rootDir;
55
- workspaceId;
24
+ _workspaceId;
25
+ _workspaceName = null;
26
+ _initialized = false;
56
27
  /**
57
28
  * @param fetchOrOpts - Optional: a custom fetch implementation (for testing),
58
29
  * or a TacitClientOptions object. If a function is provided it overrides
@@ -72,6 +43,7 @@ class TacitClient {
72
43
  this._fetch = globalThis.fetch.bind(globalThis);
73
44
  }
74
45
  this.apiUrl = (resolvedOpts.apiUrl ?? process.env['TACIT_API_URL'] ?? 'http://localhost:7473').replace(/\/$/, '');
46
+ this._appUrl = (resolvedOpts.appUrl ?? process.env['TACIT_APP_URL'] ?? '').replace(/\/$/, '');
75
47
  // Token from env only — NEVER log this value.
76
48
  this.token = resolvedOpts.token ?? process.env['TACIT_TOKEN'] ?? '';
77
49
  this._rootDir = resolvedOpts.rootDir ?? process.cwd();
@@ -90,7 +62,63 @@ class TacitClient {
90
62
  catch {
91
63
  // Soft-coupled: ignore read errors; fall back to env.
92
64
  }
93
- this.workspaceId = resolvedWorkspaceId;
65
+ this._workspaceId = resolvedWorkspaceId;
66
+ }
67
+ get workspaceId() {
68
+ return this._workspaceId;
69
+ }
70
+ get workspaceName() {
71
+ return this._workspaceName;
72
+ }
73
+ async initialize() {
74
+ if (this._initialized)
75
+ return;
76
+ try {
77
+ const data = await this.get('/api/v1/workspaces/mine');
78
+ if (data && typeof data === 'object') {
79
+ if ('active_workspace_id' in data && typeof data.active_workspace_id === 'string') {
80
+ this._workspaceId = data.active_workspace_id;
81
+ }
82
+ if (Array.isArray(data.workspaces) && data.workspaces.length > 0) {
83
+ const active = data.workspaces.find((w) => w.workspace_id === this._workspaceId || w.id === this._workspaceId);
84
+ if (active) {
85
+ const name = active.display_name ?? active.name;
86
+ if (typeof name === 'string')
87
+ this._workspaceName = name;
88
+ }
89
+ if (!this._workspaceId) {
90
+ const first = data.workspaces[0];
91
+ this._workspaceId = (first.workspace_id ?? first.id);
92
+ const name = first.display_name ?? first.name;
93
+ if (typeof name === 'string')
94
+ this._workspaceName = name;
95
+ }
96
+ }
97
+ }
98
+ this._initialized = true;
99
+ }
100
+ catch {
101
+ this._initialized = true;
102
+ }
103
+ }
104
+ async switchWorkspace(workspaceId) {
105
+ const data = await this.post('/api/v1/auth/switch-workspace', {
106
+ workspace_id: workspaceId,
107
+ });
108
+ if (data && typeof data === 'object' && 'token' in data && typeof data.token === 'string') {
109
+ this.token = data.token;
110
+ }
111
+ this._workspaceId = workspaceId;
112
+ if (data && typeof data === 'object' && 'name' in data && typeof data.name === 'string') {
113
+ this._workspaceName = data.name;
114
+ }
115
+ else {
116
+ this._workspaceName = null;
117
+ }
118
+ return {
119
+ workspace_id: workspaceId,
120
+ name: this._workspaceName ?? 'unknown',
121
+ };
94
122
  }
95
123
  /**
96
124
  * Resolve the workspace ID:
@@ -114,15 +142,71 @@ class TacitClient {
114
142
  const envId = process.env['TACIT_WORKSPACE_ID'];
115
143
  return (envId && envId.length > 0) ? envId : null;
116
144
  }
117
- /** Build auth headers. Value is NEVER written to logs. */
118
- authHeaders() {
145
+ /**
146
+ * Build a user-facing URL for an entity in the TACIT web app.
147
+ * Returns null when TACIT_APP_URL is not configured.
148
+ *
149
+ * Route patterns (TanStack file-based routing in tacitai.app.beta):
150
+ * artifact → /view/:id
151
+ * kb_article → /knowledge/:collectionId/:articleId
152
+ * dossier → /dossiers (list; no single-dossier route yet)
153
+ * guest → /guest/view/:id
154
+ */
155
+ webUrl(entityType, id, extra) {
156
+ if (!this._appUrl)
157
+ return null;
158
+ switch (entityType) {
159
+ case 'artifact':
160
+ return `${this._appUrl}/view/${id}`;
161
+ case 'kb_article':
162
+ return extra?.collectionId
163
+ ? `${this._appUrl}/knowledge/${extra.collectionId}/${id}`
164
+ : null;
165
+ case 'dossier':
166
+ return `${this._appUrl}/dossiers`;
167
+ case 'dossier_summary':
168
+ return `${this._appUrl}/summary/${id}`;
169
+ case 'dossier_scorecard':
170
+ return `${this._appUrl}/scorecard/${id}`;
171
+ case 'dossier_deliverable':
172
+ return extra?.collectionId
173
+ ? `${this._appUrl}/deliverables/${extra.collectionId}/${id}`
174
+ : `${this._appUrl}/dossiers`;
175
+ case 'guest':
176
+ return `${this._appUrl}/guest/view/${id}`;
177
+ case 'knowledge':
178
+ return `${this._appUrl}/knowledge`;
179
+ default:
180
+ return null;
181
+ }
182
+ }
183
+ /**
184
+ * Build auth headers. Value is NEVER written to logs.
185
+ *
186
+ * MV-1515n (W5 harness): when TACIT_SESSION_TOKEN is set (an HMAC
187
+ * X-Tacit-Session token, per tacit-internal-token-auth §HMAC session token
188
+ * format), it takes precedence and is sent as X-Tacit-Session instead of
189
+ * Authorization — this is additive and does not change default behavior
190
+ * (Bearer via TACIT_TOKEN) when TACIT_SESSION_TOKEN is unset.
191
+ */
192
+ authHeaders(extra) {
119
193
  const headers = {
120
194
  'Content-Type': 'application/json',
121
195
  };
122
- if (this.token) {
123
- // Authorization header value intentionally excluded from all logging paths.
124
- headers['Authorization'] = `Bearer ${this.token}`;
196
+ const sessionToken = process.env['TACIT_SESSION_TOKEN'];
197
+ if (sessionToken) {
198
+ headers['X-Tacit-Session'] = sessionToken;
199
+ }
200
+ else if (this.token) {
201
+ if (isHmacSessionToken(this.token)) {
202
+ headers['X-Tacit-Session'] = this.token;
203
+ }
204
+ else {
205
+ headers['Authorization'] = `Bearer ${this.token}`;
206
+ }
125
207
  }
208
+ if (extra)
209
+ Object.assign(headers, extra);
126
210
  return headers;
127
211
  }
128
212
  /** GET request returning parsed JSON. */
@@ -140,23 +224,72 @@ class TacitClient {
140
224
  });
141
225
  if (!res.ok) {
142
226
  const body = await res.text().catch(() => '');
227
+ if (res.status === 401) {
228
+ throw new TokenExpiredError(`${this.apiUrl}/settings`, `npx @tacitai/mcp-server setup --api-url ${this.apiUrl}`);
229
+ }
143
230
  throw new TacitApiError(res.status, body, endpoint);
144
231
  }
145
232
  return res.json();
146
233
  }
147
- /** POST request returning parsed JSON. */
148
- async post(endpoint, body) {
234
+ /** POST request returning parsed JSON. `extraHeaders` merges on top of auth headers (e.g. If-Match). */
235
+ async post(endpoint, body, extraHeaders) {
149
236
  const res = await this._fetch(`${this.apiUrl}${endpoint}`, {
150
237
  method: 'POST',
238
+ headers: this.authHeaders(extraHeaders),
239
+ body: JSON.stringify(body),
240
+ });
241
+ if (!res.ok) {
242
+ const text = await res.text().catch(() => '');
243
+ if (res.status === 401) {
244
+ throw new TokenExpiredError(`${this.apiUrl}/settings`, `npx @tacitai/mcp-server setup --api-url ${this.apiUrl}`);
245
+ }
246
+ throw new TacitApiError(res.status, text, endpoint);
247
+ }
248
+ return res.json();
249
+ }
250
+ /** PUT request returning parsed JSON. */
251
+ async put(endpoint, body) {
252
+ const res = await this._fetch(`${this.apiUrl}${endpoint}`, {
253
+ method: 'PUT',
151
254
  headers: this.authHeaders(),
152
255
  body: JSON.stringify(body),
153
256
  });
154
257
  if (!res.ok) {
155
258
  const text = await res.text().catch(() => '');
259
+ if (res.status === 401) {
260
+ throw new TokenExpiredError(`${this.apiUrl}/settings`, `npx @tacitai/mcp-server setup --api-url ${this.apiUrl}`);
261
+ }
156
262
  throw new TacitApiError(res.status, text, endpoint);
157
263
  }
158
264
  return res.json();
159
265
  }
266
+ /** DELETE request returning parsed JSON (or empty on 204). */
267
+ async delete(endpoint) {
268
+ const res = await this._fetch(`${this.apiUrl}${endpoint}`, {
269
+ method: 'DELETE',
270
+ headers: this.authHeaders(),
271
+ });
272
+ if (!res.ok) {
273
+ const text = await res.text().catch(() => '');
274
+ if (res.status === 401) {
275
+ throw new TokenExpiredError(`${this.apiUrl}/settings`, `npx @tacitai/mcp-server setup --api-url ${this.apiUrl}`);
276
+ }
277
+ throw new TacitApiError(res.status, text, endpoint);
278
+ }
279
+ if (res.status === 204)
280
+ return null;
281
+ return res.json();
282
+ }
283
+ async request(method, path, body) {
284
+ switch (method.toUpperCase()) {
285
+ case 'GET': return this.get(path);
286
+ case 'POST': return this.post(path, body);
287
+ case 'PUT': return this.put(path, body);
288
+ case 'PATCH': return this.patch(path, body);
289
+ case 'DELETE': return this.delete(path);
290
+ default: throw new Error(`Unsupported HTTP method: ${method}`);
291
+ }
292
+ }
160
293
  /** PATCH request returning parsed JSON. */
161
294
  async patch(endpoint, body) {
162
295
  const res = await this._fetch(`${this.apiUrl}${endpoint}`, {
@@ -166,8 +299,14 @@ class TacitClient {
166
299
  });
167
300
  if (!res.ok) {
168
301
  const text = await res.text().catch(() => '');
302
+ if (res.status === 401) {
303
+ throw new TokenExpiredError(`${this.apiUrl}/settings`, `npx @tacitai/mcp-server setup --api-url ${this.apiUrl}`);
304
+ }
169
305
  throw new TacitApiError(res.status, text, endpoint);
170
306
  }
307
+ if (res.status === 204) {
308
+ return {};
309
+ }
171
310
  return res.json();
172
311
  }
173
312
  /**
@@ -185,6 +324,9 @@ class TacitClient {
185
324
  });
186
325
  if (!res.ok) {
187
326
  const text = await res.text().catch(() => '');
327
+ if (res.status === 401) {
328
+ throw new TokenExpiredError(`${this.apiUrl}/settings`, `npx @tacitai/mcp-server setup --api-url ${this.apiUrl}`);
329
+ }
188
330
  throw new TacitApiError(res.status, text, endpoint);
189
331
  }
190
332
  if (!res.body) {
@@ -236,13 +378,15 @@ class TacitClient {
236
378
  });
237
379
  if (!res.ok) {
238
380
  const text = await res.text().catch(() => '');
381
+ if (res.status === 401) {
382
+ throw new TokenExpiredError(`${this.apiUrl}/settings`, `npx @tacitai/mcp-server setup --api-url ${this.apiUrl}`);
383
+ }
239
384
  throw new TacitApiError(res.status, text, endpoint);
240
385
  }
241
386
  return res.json();
242
387
  }
243
388
  }
244
- exports.TacitClient = TacitClient;
245
- class TacitApiError extends Error {
389
+ export class TacitApiError extends Error {
246
390
  statusCode;
247
391
  body;
248
392
  endpoint;
@@ -254,10 +398,47 @@ class TacitApiError extends Error {
254
398
  this.name = 'TacitApiError';
255
399
  }
256
400
  }
257
- exports.TacitApiError = TacitApiError;
401
+ /**
402
+ * Thrown when the API returns 401 — indicates the developer token has expired
403
+ * or been revoked. Tools catch this and surface a user-actionable message.
404
+ *
405
+ * FEAT-193: The server grants a 7-day grace window after expiry — during that
406
+ * window, `POST /api/v1/developer-tokens/refresh` still works (no browser
407
+ * re-auth needed). After 7 days, full device-auth is required.
408
+ *
409
+ * Security invariant: token plaintext is NEVER included in this error.
410
+ */
411
+ export class TokenExpiredError extends Error {
412
+ renewalUrl;
413
+ setupCommand;
414
+ constructor(renewalUrl, setupCommand) {
415
+ const msg = [
416
+ 'Developer token expired or revoked.',
417
+ 'Re-authenticate within 7 days for easy refresh (no browser flow needed).',
418
+ `Renew at: ${renewalUrl}`,
419
+ `Re-run setup: ${setupCommand}`,
420
+ ].join('\n');
421
+ super(msg);
422
+ this.renewalUrl = renewalUrl;
423
+ this.setupCommand = setupCommand;
424
+ this.name = 'TokenExpiredError';
425
+ }
426
+ }
427
+ const requestStore = new AsyncLocalStorage();
428
+ /** HTTPS mode: bind the caller's token/apiUrl/principal to everything run inside fn. */
429
+ export function runWithRequestContext(token, apiUrl, fn, principal) {
430
+ return requestStore.run({ token, apiUrl, principal: principal ?? 'anonymous' }, fn);
431
+ }
432
+ export function getRequestPrincipal() {
433
+ return requestStore.getStore()?.principal ?? null;
434
+ }
258
435
  /** Shared singleton client (initialized lazily on first use). */
259
436
  let _defaultClient = null;
260
- function getClient() {
437
+ export function getClient() {
438
+ const ctx = requestStore.getStore();
439
+ if (ctx) {
440
+ return new TacitClient({ apiUrl: ctx.apiUrl, token: ctx.token });
441
+ }
261
442
  if (!_defaultClient) {
262
443
  _defaultClient = new TacitClient();
263
444
  }