molecule-mcp 0.0.0-stage → 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.
@@ -0,0 +1,60 @@
1
+ /** Where a project key was found (never the key itself). */
2
+ export interface ServiceKey {
3
+ key: string;
4
+ source: string;
5
+ }
6
+ /**
7
+ * The project API key for service calls: MOLECULE_API_KEY from the server's
8
+ * environment, else `MOLECULE_API_KEY` in `.env` at `directory` or up to two
9
+ * parents (a full-stack project keeps it in `api/.env` or the root).
10
+ *
11
+ * @param directory - Project directory to search (default cwd).
12
+ * @returns The key and where it came from, or null.
13
+ */
14
+ export declare function resolveServiceKey(directory?: string): ServiceKey | null;
15
+ /** Thrown with a message an agent can act on. */
16
+ export declare class ServiceCallError extends Error {
17
+ /** Seconds the platform asked us to wait (`Retry-After`), when it sent the header. */
18
+ readonly retryAfterSeconds?: number | undefined;
19
+ constructor(message: string,
20
+ /** Seconds the platform asked us to wait (`Retry-After`), when it sent the header. */
21
+ retryAfterSeconds?: number | undefined);
22
+ }
23
+ /**
24
+ * POST /v1/services/<service>/<operation> with the project key.
25
+ *
26
+ * @param service - e.g. `'embeddings'`.
27
+ * @param operation - e.g. `'embed'`.
28
+ * @param body - The JSON body (the service's normalized request).
29
+ * @param directory - Project directory used to find the key.
30
+ * @returns The parsed JSON response.
31
+ */
32
+ export declare function callHostedService(service: string, operation: string, body: unknown, directory?: string): Promise<unknown>;
33
+ /** A project row as the MCP tool reports it. */
34
+ export interface McpProject {
35
+ id: string;
36
+ name?: string;
37
+ role?: string;
38
+ }
39
+ /** GET /projects — owned + team projects. */
40
+ export declare function listProjects(): Promise<McpProject[]>;
41
+ /** What create_api_key reports (never the plaintext). */
42
+ export interface CreatedKey {
43
+ id: string;
44
+ masked: string;
45
+ scopes: string[];
46
+ wroteTo: string;
47
+ gitignored: boolean;
48
+ }
49
+ /**
50
+ * Mint a project API key and write it into a dotenv file as
51
+ * MOLECULE_API_KEY, replacing an existing line. The plaintext is returned by
52
+ * the platform exactly once and is never echoed back to the agent.
53
+ *
54
+ * @param projectId - The project the key bills to.
55
+ * @param name - A label shown in the project's key list.
56
+ * @param envFile - Path of the dotenv file to write (basename `.env` or `.env.<x>`).
57
+ * @param scopes - Optional scopes (default: all brokered services).
58
+ * @returns Masked key metadata + where it was written.
59
+ */
60
+ export declare function createApiKeyIntoEnv(projectId: string, name: string, envFile: string, scopes?: string[]): Promise<CreatedKey>;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * molecule.dev hosted services for MCP tools.
3
+ *
4
+ * Two credentials, two jobs:
5
+ * - the SESSION token (login / MOLECULE_TOKEN) manages the account — list
6
+ * projects, mint a project API key;
7
+ * - a PROJECT API key (`mk_…`) pays for service calls, metered and billed to
8
+ * that project exactly like a deployed app calling the same URL. It is read
9
+ * from MOLECULE_API_KEY, else from the project's `.env` (the same place the
10
+ * app's `@molecule/*-molecule` bonds read it), so the key never has to pass
11
+ * through the chat transcript.
12
+ */
13
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { basename, dirname, join, resolve } from 'node:path';
15
+ import { apiBase, sessionToken } from './auth.js';
16
+ /** Parse one variable out of a dotenv file; undefined when absent. */
17
+ function readDotenvVar(file, name) {
18
+ if (!existsSync(file))
19
+ return undefined;
20
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
21
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
22
+ if (m && m[1] === name)
23
+ return m[2].replace(/^(['"])(.*)\1$/, '$2') || undefined;
24
+ }
25
+ return undefined;
26
+ }
27
+ /**
28
+ * The project API key for service calls: MOLECULE_API_KEY from the server's
29
+ * environment, else `MOLECULE_API_KEY` in `.env` at `directory` or up to two
30
+ * parents (a full-stack project keeps it in `api/.env` or the root).
31
+ *
32
+ * @param directory - Project directory to search (default cwd).
33
+ * @returns The key and where it came from, or null.
34
+ */
35
+ export function resolveServiceKey(directory) {
36
+ if (process.env.MOLECULE_API_KEY) {
37
+ return { key: process.env.MOLECULE_API_KEY, source: 'MOLECULE_API_KEY env' };
38
+ }
39
+ let dir = resolve(directory ?? process.cwd());
40
+ for (let i = 0; i < 3; i++) {
41
+ for (const file of [join(dir, '.env'), join(dir, 'api', '.env')]) {
42
+ const key = readDotenvVar(file, 'MOLECULE_API_KEY');
43
+ if (key)
44
+ return { key, source: file };
45
+ }
46
+ const parent = dirname(dir);
47
+ if (parent === dir)
48
+ break;
49
+ dir = parent;
50
+ }
51
+ return null;
52
+ }
53
+ /** Thrown with a message an agent can act on. */
54
+ export class ServiceCallError extends Error {
55
+ retryAfterSeconds;
56
+ constructor(message,
57
+ /** Seconds the platform asked us to wait (`Retry-After`), when it sent the header. */
58
+ retryAfterSeconds) {
59
+ super(message);
60
+ this.retryAfterSeconds = retryAfterSeconds;
61
+ }
62
+ }
63
+ /** Seconds from a `Retry-After` header, or undefined. */
64
+ function retryAfterOf(response) {
65
+ const value = Number(response.headers.get('retry-after'));
66
+ return Number.isFinite(value) && value > 0 ? Math.ceil(value) : undefined;
67
+ }
68
+ /** The "try again" hint: the header's seconds, or a minute for `service.busy`. */
69
+ function retryHint(seconds, errorKey) {
70
+ if (seconds !== undefined)
71
+ return ` Try again in ${seconds} second${seconds === 1 ? '' : 's'}.`;
72
+ return errorKey === 'service.busy' ? ' Try again in a minute.' : '';
73
+ }
74
+ /**
75
+ * POST /v1/services/<service>/<operation> with the project key.
76
+ *
77
+ * @param service - e.g. `'embeddings'`.
78
+ * @param operation - e.g. `'embed'`.
79
+ * @param body - The JSON body (the service's normalized request).
80
+ * @param directory - Project directory used to find the key.
81
+ * @returns The parsed JSON response.
82
+ */
83
+ export async function callHostedService(service, operation, body, directory) {
84
+ const found = resolveServiceKey(directory);
85
+ if (!found) {
86
+ throw new ServiceCallError('No molecule project API key. Call create_api_key (it writes MOLECULE_API_KEY into the project .env), or set MOLECULE_API_KEY for this server.');
87
+ }
88
+ const response = await fetch(`${apiBase()}/v1/services/${service}/${operation}`, {
89
+ method: 'POST',
90
+ headers: { authorization: `Bearer ${found.key}`, 'content-type': 'application/json' },
91
+ body: JSON.stringify(body),
92
+ signal: AbortSignal.timeout(60_000),
93
+ });
94
+ const text = await response.text();
95
+ let data;
96
+ try {
97
+ data = text ? JSON.parse(text) : {};
98
+ }
99
+ catch (_error) {
100
+ // Non-JSON body (a proxy error page): report the status, not the body.
101
+ const seconds = retryAfterOf(response);
102
+ throw new ServiceCallError(`molecule.dev returned ${response.status} (not JSON).${retryHint(seconds, undefined)}`, seconds);
103
+ }
104
+ if (!response.ok) {
105
+ const err = data;
106
+ const hint = response.status === 401
107
+ ? ` The key from ${found.source} was rejected: it is revoked, or its scopes do not include this service. Create one with create_api_key (the default scope covers every service).`
108
+ : response.status === 402
109
+ ? ' The project owner has used their included allowance; they can enable usage billing on molecule.dev.'
110
+ : '';
111
+ const seconds = retryAfterOf(response);
112
+ throw new ServiceCallError(`${service}/${operation} failed (${response.status} ${err.errorKey ?? ''}): ${(err.error ?? 'error').replace(/[.\s]+$/, '')}.${hint}${retryHint(seconds, err.errorKey)}`, seconds);
113
+ }
114
+ return data;
115
+ }
116
+ /** Authenticated JSON request with the session token. */
117
+ async function sessionRequest(path, init = {}) {
118
+ const token = sessionToken();
119
+ if (!token)
120
+ throw new ServiceCallError('Not logged in — call the login tool first.');
121
+ const response = await fetch(`${apiBase()}${path}`, {
122
+ method: init.method ?? 'GET',
123
+ headers: {
124
+ authorization: `Bearer ${token}`,
125
+ ...(init.body ? { 'content-type': 'application/json' } : {}),
126
+ },
127
+ body: init.body ? JSON.stringify(init.body) : undefined,
128
+ signal: AbortSignal.timeout(15_000),
129
+ });
130
+ const text = await response.text();
131
+ const seconds = retryAfterOf(response);
132
+ let data;
133
+ try {
134
+ data = (text ? JSON.parse(text) : {});
135
+ }
136
+ catch (_error) {
137
+ // Non-JSON body (a proxy error page): report the status, not a SyntaxError.
138
+ throw new ServiceCallError(`molecule.dev returned ${response.status} (not JSON).${retryHint(seconds, undefined)}`, seconds);
139
+ }
140
+ if (!response.ok) {
141
+ throw new ServiceCallError(`${path} failed (${response.status}): ${data.error ?? 'error'}${retryHint(seconds, data.errorKey)}`, seconds);
142
+ }
143
+ return data;
144
+ }
145
+ /** GET /projects — owned + team projects. */
146
+ export async function listProjects() {
147
+ const raw = await sessionRequest('/projects');
148
+ const rows = Array.isArray(raw)
149
+ ? raw
150
+ : (raw.projects ??
151
+ raw.data ??
152
+ []);
153
+ return rows.map((row) => {
154
+ const r = row;
155
+ const p = r.props ?? r;
156
+ return { id: p.id, name: p.name, ...((p.role ?? r.role) ? { role: p.role ?? r.role } : {}) };
157
+ });
158
+ }
159
+ /** The only files create_api_key will write: a dotenv file by name. */
160
+ const ENV_FILE_RE = /^\.env(\.[A-Za-z0-9_-]+)?$/;
161
+ /**
162
+ * Mint a project API key and write it into a dotenv file as
163
+ * MOLECULE_API_KEY, replacing an existing line. The plaintext is returned by
164
+ * the platform exactly once and is never echoed back to the agent.
165
+ *
166
+ * @param projectId - The project the key bills to.
167
+ * @param name - A label shown in the project's key list.
168
+ * @param envFile - Path of the dotenv file to write (basename `.env` or `.env.<x>`).
169
+ * @param scopes - Optional scopes (default: all brokered services).
170
+ * @returns Masked key metadata + where it was written.
171
+ */
172
+ export async function createApiKeyIntoEnv(projectId, name, envFile, scopes) {
173
+ const target = resolve(envFile);
174
+ if (!ENV_FILE_RE.test(basename(target))) {
175
+ throw new ServiceCallError('env_file must be a dotenv file named .env or .env.<name>.');
176
+ }
177
+ if (!existsSync(dirname(target))) {
178
+ throw new ServiceCallError(`Directory ${dirname(target)} does not exist.`);
179
+ }
180
+ const minted = await sessionRequest(`/projects/${encodeURIComponent(projectId)}/api-keys`, {
181
+ method: 'POST',
182
+ body: { name, ...(scopes?.length ? { scopes } : {}) },
183
+ });
184
+ if (!minted.token)
185
+ throw new ServiceCallError('The platform did not return a key.');
186
+ const line = `MOLECULE_API_KEY=${minted.token}`;
187
+ const existed = existsSync(target);
188
+ const lines = existed ? readFileSync(target, 'utf8').split('\n') : [];
189
+ const at = lines.findIndex((l) => /^\s*(?:export\s+)?MOLECULE_API_KEY\s*=/.test(l));
190
+ if (at >= 0)
191
+ lines[at] = line;
192
+ else {
193
+ if (lines.length && lines[lines.length - 1] === '')
194
+ lines.pop();
195
+ lines.push(line, '');
196
+ }
197
+ writeFileSync(target, lines.join('\n'), { mode: 0o600 });
198
+ // The file now holds a secret: owner-only even if it existed with wider bits.
199
+ chmodSync(target, 0o600);
200
+ // A .gitignore in the file's directory or up to three parents that names it.
201
+ // A heuristic for a warning, not a guarantee — git's own rules are richer.
202
+ const patterns = ['.env', '.env*', '.env.*', '*.env', basename(target), `/${basename(target)}`];
203
+ let gitignored = false;
204
+ for (let dir = dirname(target), i = 0; i < 4 && !gitignored; i++) {
205
+ const gitignore = join(dir, '.gitignore');
206
+ gitignored =
207
+ existsSync(gitignore) &&
208
+ readFileSync(gitignore, 'utf8')
209
+ .split('\n')
210
+ .some((l) => patterns.includes(l.trim()));
211
+ const parent = dirname(dir);
212
+ if (parent === dir)
213
+ break;
214
+ dir = parent;
215
+ }
216
+ return {
217
+ id: minted.id,
218
+ masked: minted.masked,
219
+ scopes: minted.scopes,
220
+ wroteTo: target,
221
+ gitignored,
222
+ };
223
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The server's own version, read once from mcp/package.json — the same field
3
+ * `bin/*` and the SDK's serverInfo report. A hardcoded copy here (and in the
4
+ * McpServer constructor) went stale the first time the package was bumped, so
5
+ * the telemetry's `serverVersion` described a server that was not deployed.
6
+ */
7
+ export declare function serverVersion(): string;
8
+ /**
9
+ * The ingestion route under the same API root as every other call this server
10
+ * makes ({@link apiBase}). A separate literal without the `/api` prefix the API
11
+ * mounts its routes under 404'd every event, so no tool call was ever recorded.
12
+ *
13
+ * @returns The telemetry endpoint URL.
14
+ */
15
+ export declare function telemetryUrl(): string;
16
+ export declare function telemetryEnabled(): boolean;
17
+ /**
18
+ * How many telemetry emits may be in flight at once.
19
+ *
20
+ * The hosted server is public and unauthenticated, and its request budget is
21
+ * per client ADDRESS — so a flood from many addresses (a botnet, or a
22
+ * header-rotating client on an unguarded proxy) stays inside each address's own
23
+ * budget. Every accepted tool call started one outbound POST here and nothing
24
+ * bounded them: a burst of B calls held B pending requests, B abort timers and B
25
+ * socket slots for the 5 s the timeout allows, growing with the incoming rate
26
+ * instead of with the work the server owes anyone. An emit is best-effort (see
27
+ * the module note, and the CLI's telemetry posture), so the overflow is DROPPED
28
+ * rather than queued — a queue is exactly the unbounded thing this bound exists
29
+ * to prevent, and a dropped event costs nothing but the comparison below.
30
+ */
31
+ export declare const MAX_IN_FLIGHT_EMITS = 8;
32
+ /**
33
+ * Fire-and-forget `mcp.tool_call` event — never throws, never blocks, and never
34
+ * queues: once {@link MAX_IN_FLIGHT_EMITS} emits are in flight this call is
35
+ * dropped.
36
+ */
37
+ export declare function trackToolCall(tool: string, ok: boolean, startedAt: number): void;
38
+ /** Wrap a tool handler with emission — used by every registerTool call. */
39
+ export declare function tracked<Args, Result>(tool: string, handler: (args: Args) => Promise<Result>): (args: Args) => Promise<Result>;