@aiwerk/mcp-bridge 2.9.0 → 3.0.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 (96) hide show
  1. package/dist/src/catalog-client.d.ts +6 -0
  2. package/dist/src/config.js +14 -0
  3. package/dist/src/oauth2-credentials-file.d.ts +43 -0
  4. package/dist/src/oauth2-credentials-file.js +52 -0
  5. package/dist/src/standalone-server.js +1 -1
  6. package/dist/src/transport-base.d.ts +8 -0
  7. package/dist/src/transport-base.js +44 -18
  8. package/dist/src/transport-stdio.d.ts +5 -1
  9. package/dist/src/transport-stdio.js +19 -2
  10. package/dist/src/types.d.ts +18 -0
  11. package/package.json +2 -2
  12. package/scripts/validate-recipes.sh +4 -0
  13. package/servers/index.json +3 -403
  14. package/servers/apify/README.md +0 -40
  15. package/servers/apify/config.json +0 -13
  16. package/servers/apify/env_vars +0 -1
  17. package/servers/apify/install.ps1 +0 -3
  18. package/servers/apify/install.sh +0 -4
  19. package/servers/apify/recipe.json +0 -69
  20. package/servers/atlassian/README.md +0 -72
  21. package/servers/atlassian/env_vars +0 -6
  22. package/servers/atlassian/install.ps1 +0 -3
  23. package/servers/atlassian/install.sh +0 -4
  24. package/servers/atlassian/recipe.json +0 -87
  25. package/servers/candidates.md +0 -13
  26. package/servers/chrome-devtools/README.md +0 -69
  27. package/servers/chrome-devtools/env_vars +0 -0
  28. package/servers/chrome-devtools/install.ps1 +0 -3
  29. package/servers/chrome-devtools/install.sh +0 -4
  30. package/servers/chrome-devtools/recipe.json +0 -74
  31. package/servers/firecrawl/recipe.json +0 -79
  32. package/servers/github/README.md +0 -40
  33. package/servers/github/env_vars +0 -1
  34. package/servers/github/install.ps1 +0 -3
  35. package/servers/github/install.sh +0 -4
  36. package/servers/github/recipe.json +0 -82
  37. package/servers/google-maps/README.md +0 -40
  38. package/servers/google-maps/config.json +0 -17
  39. package/servers/google-maps/env_vars +0 -1
  40. package/servers/google-maps/install.ps1 +0 -3
  41. package/servers/google-maps/install.sh +0 -4
  42. package/servers/google-maps/recipe.json +0 -78
  43. package/servers/hetzner/README.md +0 -41
  44. package/servers/hetzner/config.json +0 -16
  45. package/servers/hetzner/env_vars +0 -1
  46. package/servers/hetzner/install.ps1 +0 -3
  47. package/servers/hetzner/install.sh +0 -4
  48. package/servers/hetzner/recipe.json +0 -78
  49. package/servers/hostinger/README.md +0 -40
  50. package/servers/hostinger/env_vars +0 -1
  51. package/servers/hostinger/install.ps1 +0 -3
  52. package/servers/hostinger/install.sh +0 -4
  53. package/servers/hostinger/recipe.json +0 -78
  54. package/servers/imap-email/README.md +0 -37
  55. package/servers/imap-email/recipe.json +0 -103
  56. package/servers/linear/README.md +0 -40
  57. package/servers/linear/config.json +0 -16
  58. package/servers/linear/env_vars +0 -1
  59. package/servers/linear/install.ps1 +0 -3
  60. package/servers/linear/install.sh +0 -4
  61. package/servers/linear/recipe.json +0 -78
  62. package/servers/miro/README.md +0 -40
  63. package/servers/miro/config.json +0 -19
  64. package/servers/miro/env_vars +0 -1
  65. package/servers/miro/install.ps1 +0 -3
  66. package/servers/miro/install.sh +0 -4
  67. package/servers/miro/recipe.json +0 -80
  68. package/servers/notion/README.md +0 -42
  69. package/servers/notion/config.json +0 -17
  70. package/servers/notion/env_vars +0 -1
  71. package/servers/notion/install.ps1 +0 -3
  72. package/servers/notion/install.sh +0 -4
  73. package/servers/notion/recipe.json +0 -78
  74. package/servers/stripe/README.md +0 -40
  75. package/servers/stripe/config.json +0 -19
  76. package/servers/stripe/env_vars +0 -1
  77. package/servers/stripe/install.ps1 +0 -3
  78. package/servers/stripe/install.sh +0 -4
  79. package/servers/stripe/recipe.json +0 -80
  80. package/servers/tavily/README.md +0 -40
  81. package/servers/tavily/config.json +0 -17
  82. package/servers/tavily/env_vars +0 -1
  83. package/servers/tavily/install.ps1 +0 -3
  84. package/servers/tavily/install.sh +0 -4
  85. package/servers/tavily/recipe.json +0 -78
  86. package/servers/todoist/README.md +0 -40
  87. package/servers/todoist/config.json +0 -17
  88. package/servers/todoist/env_vars +0 -1
  89. package/servers/todoist/install.ps1 +0 -3
  90. package/servers/todoist/install.sh +0 -4
  91. package/servers/todoist/recipe.json +0 -78
  92. package/servers/wise/README.md +0 -41
  93. package/servers/wise/env_vars +0 -1
  94. package/servers/wise/install.ps1 +0 -3
  95. package/servers/wise/install.sh +0 -4
  96. package/servers/wise/recipe.json +0 -78
@@ -56,6 +56,12 @@ export interface CatalogRecipe {
56
56
  required?: boolean;
57
57
  envVars?: string[];
58
58
  credentialsUrl?: string;
59
+ oauth2?: {
60
+ envBinding?: string;
61
+ credentialsFileType?: "google-workspace";
62
+ [key: string]: unknown;
63
+ };
64
+ [key: string]: unknown;
59
65
  };
60
66
  signature?: RecipeSignature;
61
67
  localOnly?: boolean;
@@ -206,6 +206,16 @@ export function initConfigDir(logger) {
206
206
  logger.info(`Config directory ready: ${dir}`);
207
207
  }
208
208
  export function recipeToServerConfig(recipe) {
209
+ // Extract optional v2 spec fields. The validator (validate-recipe.ts) accepts
210
+ // these on every recipe; this is the runtime plumb-through.
211
+ const oauth2 = recipe.auth?.oauth2;
212
+ const envBinding = oauth2?.envBinding;
213
+ const credFileType = oauth2?.credentialsFileType;
214
+ const cfgExtras = {};
215
+ if (typeof envBinding === "string" && envBinding)
216
+ cfgExtras.oauth2EnvBinding = envBinding;
217
+ if (credFileType === "google-workspace")
218
+ cfgExtras.oauth2CredentialsFile = { format: "google-workspace" };
209
219
  if (Array.isArray(recipe.transports) && recipe.transports.length > 0) {
210
220
  const t = recipe.transports[0];
211
221
  if (t.type === "stdio") {
@@ -215,6 +225,7 @@ export function recipeToServerConfig(recipe) {
215
225
  command: t.command,
216
226
  args: t.args,
217
227
  env: t.env,
228
+ ...cfgExtras,
218
229
  };
219
230
  }
220
231
  if (t.type === "sse" || t.type === "streamable-http") {
@@ -223,6 +234,7 @@ export function recipeToServerConfig(recipe) {
223
234
  description: recipe.description,
224
235
  url: t.url,
225
236
  headers: t.headers,
237
+ ...cfgExtras,
226
238
  };
227
239
  }
228
240
  return null;
@@ -234,6 +246,7 @@ export function recipeToServerConfig(recipe) {
234
246
  command: recipe.command,
235
247
  args: recipe.args,
236
248
  env: recipe.env,
249
+ ...cfgExtras,
237
250
  };
238
251
  }
239
252
  if (recipe.transport === "sse" || recipe.transport === "streamable-http") {
@@ -242,6 +255,7 @@ export function recipeToServerConfig(recipe) {
242
255
  description: recipe.description,
243
256
  url: recipe.url,
244
257
  headers: recipe.headers,
258
+ ...cfgExtras,
245
259
  };
246
260
  }
247
261
  return null;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Vendor-specific OAuth2 credentials file emitters.
3
+ *
4
+ * Some upstream MCP servers (e.g. workspace-mcp) read OAuth credentials from
5
+ * a JSON file rather than an Authorization header or env var. The standalone
6
+ * bridge writes that file at spawn time so the server can refresh its own
7
+ * tokens via the upstream library.
8
+ *
9
+ * Currently supports the "google-workspace" format used by
10
+ * taylorwilsdon/google_workspace_mcp.
11
+ */
12
+ import type { StoredToken } from "./token-store.js";
13
+ export interface GoogleWorkspaceCredentialOpts {
14
+ serverName: string;
15
+ /** Result of OAuth2TokenManager.getStoredToken(serverName). */
16
+ stored: StoredToken;
17
+ /**
18
+ * The Google account email. Required so workspace-mcp finds the file at
19
+ * <dir>/<email>.json. Pre-fetched by the caller (we don't bundle a userinfo
20
+ * fetch here to keep the standalone bridge offline-friendly).
21
+ */
22
+ email: string;
23
+ /** OAuth2 client_id used during the install OAuth flow. */
24
+ clientId: string;
25
+ /** OAuth2 client_secret used during the install OAuth flow. */
26
+ clientSecret: string;
27
+ /** Override the base directory (testing). Defaults to ~/.mcp-bridge/google-credentials. */
28
+ baseDirOverride?: string;
29
+ }
30
+ /** Resolve the base dir for google-workspace credentials files (per-server). */
31
+ export declare function getGoogleCredentialsBaseDir(): string;
32
+ /** Per-server dir where workspace-mcp finds <email>.json. */
33
+ export declare function getGoogleCredentialsServerDir(serverName: string, baseOverride?: string): string;
34
+ /**
35
+ * Write the google-workspace credentials file in the shape expected by
36
+ * workspace-mcp's LocalDirectoryCredentialStore (Google Auth Library JSON
37
+ * with tz-naive ISO expiry). Returns the absolute file path so the caller
38
+ * can log/audit. Permissions: dir 0700, file 0600.
39
+ */
40
+ export declare function writeGoogleWorkspaceCredentials(opts: GoogleWorkspaceCredentialOpts): {
41
+ dir: string;
42
+ filePath: string;
43
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Vendor-specific OAuth2 credentials file emitters.
3
+ *
4
+ * Some upstream MCP servers (e.g. workspace-mcp) read OAuth credentials from
5
+ * a JSON file rather than an Authorization header or env var. The standalone
6
+ * bridge writes that file at spawn time so the server can refresh its own
7
+ * tokens via the upstream library.
8
+ *
9
+ * Currently supports the "google-workspace" format used by
10
+ * taylorwilsdon/google_workspace_mcp.
11
+ */
12
+ import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ /** Resolve the base dir for google-workspace credentials files (per-server). */
16
+ export function getGoogleCredentialsBaseDir() {
17
+ if (process.env.MCP_BRIDGE_GOOGLE_CREDENTIALS_DIR) {
18
+ return process.env.MCP_BRIDGE_GOOGLE_CREDENTIALS_DIR;
19
+ }
20
+ return join(homedir(), ".mcp-bridge", "google-credentials");
21
+ }
22
+ /** Per-server dir where workspace-mcp finds <email>.json. */
23
+ export function getGoogleCredentialsServerDir(serverName, baseOverride) {
24
+ const base = baseOverride ?? getGoogleCredentialsBaseDir();
25
+ return join(base, serverName);
26
+ }
27
+ /**
28
+ * Write the google-workspace credentials file in the shape expected by
29
+ * workspace-mcp's LocalDirectoryCredentialStore (Google Auth Library JSON
30
+ * with tz-naive ISO expiry). Returns the absolute file path so the caller
31
+ * can log/audit. Permissions: dir 0700, file 0600.
32
+ */
33
+ export function writeGoogleWorkspaceCredentials(opts) {
34
+ const dir = getGoogleCredentialsServerDir(opts.serverName, opts.baseDirOverride);
35
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ // workspace-mcp / google-auth-library expects expiry as a tz-naive
37
+ // ISO datetime (Python datetime.fromisoformat). Strip the trailing Z.
38
+ const expiry = new Date(opts.stored.expiresAt).toISOString().replace(/Z$/, "");
39
+ const credential = {
40
+ token: opts.stored.accessToken,
41
+ refresh_token: opts.stored.refreshToken ?? "",
42
+ token_uri: "https://oauth2.googleapis.com/token",
43
+ client_id: opts.clientId,
44
+ client_secret: opts.clientSecret,
45
+ scopes: opts.stored.scopes ?? [],
46
+ expiry,
47
+ };
48
+ const filePath = join(dir, `${opts.email}.json`);
49
+ writeFileSync(filePath, JSON.stringify(credential, null, 2), { mode: 0o600 });
50
+ chmodSync(filePath, 0o600);
51
+ return { dir, filePath };
52
+ }
@@ -738,7 +738,7 @@ export class StandaloneServer {
738
738
  case "sse":
739
739
  return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
740
740
  case "stdio":
741
- return new StdioTransport(serverConfig, this.config, this.logger, onReconnected, () => this.nextRequestId());
741
+ return new StdioTransport(serverConfig, this.config, this.logger, onReconnected, () => this.nextRequestId(), this.tokenManager, serverName);
742
742
  case "streamable-http":
743
743
  return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
744
744
  default:
@@ -92,6 +92,14 @@ export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?:
92
92
  export declare function resolveAuthCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): AuthCodeOAuth2Config;
93
93
  export declare function resolveDeviceCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): DeviceCodeOAuth2Config;
94
94
  export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
95
+ /**
96
+ * Resolve OAuth2 access token into a stdio env-var binding (for servers that
97
+ * read credentials from a specific env var, e.g. GITHUB_PERSONAL_ACCESS_TOKEN).
98
+ * Returns an empty record if no envBinding is configured or auth is not OAuth2.
99
+ * Sourced from McpServerConfig.oauth2EnvBinding (set by recipeToServerConfig
100
+ * from recipe.auth.oauth2.envBinding).
101
+ */
102
+ export declare function resolveOauth2EnvAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
95
103
  /**
96
104
  * Resolve server headers and merge auth headers (auth takes precedence).
97
105
  */
@@ -241,32 +241,58 @@ export function resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback) {
241
241
  ...(scopes && scopes.length > 0 ? { scopes } : {}),
242
242
  };
243
243
  }
244
+ /**
245
+ * Resolve an OAuth2 access token by picking the correct flow (auth code, device
246
+ * code, or client credentials). Returns null if config.auth is not OAuth2.
247
+ * Shared between resolveAuthHeadersAsync (HTTP transports) and
248
+ * resolveOauth2EnvAsync (stdio transports with envBinding).
249
+ */
250
+ async function resolveOauth2AccessToken(config, tokenManager, extraEnv, envFallback, serverName) {
251
+ if (!config.auth || config.auth.type !== "oauth2")
252
+ return null;
253
+ if (isAuthCodeOAuth2(config.auth)) {
254
+ if (!serverName) {
255
+ throw new Error("[mcp-bridge] serverName is required for authorization_code OAuth2 flow");
256
+ }
257
+ const authCodeConfig = resolveAuthCodeOAuth2Config(config, extraEnv, envFallback);
258
+ return tokenManager.getTokenForAuthCode(serverName, authCodeConfig);
259
+ }
260
+ if (isDeviceCodeOAuth2(config.auth)) {
261
+ if (!serverName) {
262
+ throw new Error("[mcp-bridge] serverName is required for device_code OAuth2 flow");
263
+ }
264
+ const deviceCodeConfig = resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback);
265
+ return tokenManager.getTokenForDeviceCode(serverName, deviceCodeConfig);
266
+ }
267
+ const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
268
+ return tokenManager.getToken(oauth2Config);
269
+ }
244
270
  export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
245
271
  if (!config.auth)
246
272
  return {};
247
273
  if (config.auth.type === "oauth2") {
248
- if (isAuthCodeOAuth2(config.auth)) {
249
- if (!serverName) {
250
- throw new Error("[mcp-bridge] serverName is required for authorization_code OAuth2 flow");
251
- }
252
- const authCodeConfig = resolveAuthCodeOAuth2Config(config, extraEnv, envFallback);
253
- const token = await tokenManager.getTokenForAuthCode(serverName, authCodeConfig);
274
+ const token = await resolveOauth2AccessToken(config, tokenManager, extraEnv, envFallback, serverName);
275
+ if (token)
254
276
  return { Authorization: `Bearer ${token}` };
255
- }
256
- if (isDeviceCodeOAuth2(config.auth)) {
257
- if (!serverName) {
258
- throw new Error("[mcp-bridge] serverName is required for device_code OAuth2 flow");
259
- }
260
- const deviceCodeConfig = resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback);
261
- const token = await tokenManager.getTokenForDeviceCode(serverName, deviceCodeConfig);
262
- return { Authorization: `Bearer ${token}` };
263
- }
264
- const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
265
- const token = await tokenManager.getToken(oauth2Config);
266
- return { Authorization: `Bearer ${token}` };
277
+ return {};
267
278
  }
268
279
  return resolveAuthHeaders(config, extraEnv, envFallback);
269
280
  }
281
+ /**
282
+ * Resolve OAuth2 access token into a stdio env-var binding (for servers that
283
+ * read credentials from a specific env var, e.g. GITHUB_PERSONAL_ACCESS_TOKEN).
284
+ * Returns an empty record if no envBinding is configured or auth is not OAuth2.
285
+ * Sourced from McpServerConfig.oauth2EnvBinding (set by recipeToServerConfig
286
+ * from recipe.auth.oauth2.envBinding).
287
+ */
288
+ export async function resolveOauth2EnvAsync(config, tokenManager, extraEnv, envFallback, serverName) {
289
+ if (!config.oauth2EnvBinding)
290
+ return {};
291
+ const token = await resolveOauth2AccessToken(config, tokenManager, extraEnv, envFallback, serverName);
292
+ if (!token)
293
+ return {};
294
+ return { [config.oauth2EnvBinding]: token };
295
+ }
270
296
  /**
271
297
  * Resolve server headers and merge auth headers (auth takes precedence).
272
298
  */
@@ -1,10 +1,14 @@
1
- import { McpRequest, McpResponse } from "./types.js";
1
+ import { McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, RequestIdGenerator } from "./types.js";
2
2
  import { BaseTransport } from "./transport-base.js";
3
+ import type { OAuth2TokenManager } from "./oauth2-token-manager.js";
3
4
  export declare class StdioTransport extends BaseTransport {
4
5
  private process;
5
6
  private framingMode;
6
7
  private stdoutBuffer;
7
8
  private isShuttingDown;
9
+ private readonly tokenManager?;
10
+ private readonly serverName?;
11
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, requestIdGenerator?: RequestIdGenerator, tokenManager?: OAuth2TokenManager, serverName?: string);
8
12
  protected get transportName(): string;
9
13
  connect(): Promise<void>;
10
14
  private startProcess;
@@ -1,10 +1,17 @@
1
1
  import { spawn } from "child_process";
2
- import { BaseTransport, resolveEnvRecord, resolveArgs } from "./transport-base.js";
2
+ import { BaseTransport, resolveEnvRecord, resolveArgs, resolveOauth2EnvAsync } from "./transport-base.js";
3
3
  export class StdioTransport extends BaseTransport {
4
4
  process = null;
5
5
  framingMode = "auto";
6
6
  stdoutBuffer = Buffer.alloc(0);
7
7
  isShuttingDown = false;
8
+ tokenManager;
9
+ serverName;
10
+ constructor(config, clientConfig, logger, onReconnected, requestIdGenerator, tokenManager, serverName) {
11
+ super(config, clientConfig, logger, onReconnected, requestIdGenerator);
12
+ this.tokenManager = tokenManager;
13
+ this.serverName = serverName;
14
+ }
8
15
  get transportName() { return "stdio"; }
9
16
  async connect() {
10
17
  if (!this.config.command) {
@@ -25,7 +32,17 @@ export class StdioTransport extends BaseTransport {
25
32
  if (!this.config.command)
26
33
  return;
27
34
  const configEnv = resolveEnvRecord(this.config.env || {}, "env key");
28
- const env = { ...process.env, ...configEnv };
35
+ let oauthEnv = {};
36
+ if (this.config.oauth2EnvBinding && this.tokenManager) {
37
+ try {
38
+ oauthEnv = await resolveOauth2EnvAsync(this.config, this.tokenManager, undefined, undefined, this.serverName);
39
+ }
40
+ catch (error) {
41
+ this.logger.error(`[mcp-bridge] Failed to resolve OAuth2 envBinding for stdio server ${this.serverName ?? "<unnamed>"}:`, error);
42
+ throw error;
43
+ }
44
+ }
45
+ const env = { ...process.env, ...configEnv, ...oauthEnv };
29
46
  const args = resolveArgs(this.config.args || [], env);
30
47
  if (process.env.DEBUG_STDIO_ENV) {
31
48
  this.logger.info(`[mcp-bridge] stdio spawn: ${this.config.command} ${args.join(" ")}`);
@@ -48,6 +48,24 @@ export interface McpServerConfig {
48
48
  url?: string;
49
49
  headers?: Record<string, string>;
50
50
  auth?: HttpAuthConfig;
51
+ /**
52
+ * If set, the OAuth2 access token is injected into the spawned process
53
+ * (or HTTP request env) under this name instead of (or in addition to) the
54
+ * default Authorization header. Used by stdio servers that read their
55
+ * credentials from a specific env var (e.g. GITHUB_PERSONAL_ACCESS_TOKEN).
56
+ * Sourced from recipe.auth.oauth2.envBinding.
57
+ */
58
+ oauth2EnvBinding?: string;
59
+ /**
60
+ * If set, the OAuth2 token + refresh token are written to a credentials
61
+ * file in a vendor-specific format, and the file path is injected into
62
+ * the spawned process via env var. Sourced from recipe.auth.oauth2.credentialsFileType.
63
+ * Format dictates the file shape (e.g. "google-workspace" follows Google's tz-naive expiry).
64
+ */
65
+ oauth2CredentialsFile?: {
66
+ format: "google-workspace";
67
+ envVar?: string;
68
+ };
51
69
  command?: string;
52
70
  args?: string[];
53
71
  env?: Record<string, string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.9.0",
3
+ "version": "3.0.0",
4
4
  "description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -45,7 +45,7 @@
45
45
  "test": "node --import tsx --test tests/*.test.ts",
46
46
  "typecheck": "tsc --noEmit",
47
47
  "postinstall": "node scripts/postinstall.cjs",
48
- "prepublishOnly": "tsc && bash scripts/validate-recipes.sh && node -e \"const v=require('./package.json').version;const fs=require('fs');const cl=fs.readFileSync('CHANGELOG.md','utf8');if(!cl.includes('['+v+']')){console.error('ERROR: CHANGELOG.md missing entry for v'+v);process.exit(1)}\"",
48
+ "prepublishOnly": "bash $HOME/agent-memory/commons/scripts/prepublish-safety.sh && tsc && bash scripts/validate-recipes.sh && node -e \"const v=require('./package.json').version;const fs=require('fs');const cl=fs.readFileSync('CHANGELOG.md','utf8');if(!cl.includes('['+v+']')){console.error('ERROR: CHANGELOG.md missing entry for v'+v);process.exit(1)}\"",
49
49
  "validate-recipe": "npx tsx bin/validate-recipe.ts",
50
50
  "lint": "eslint src/",
51
51
  "format": "prettier --write src/",
@@ -4,6 +4,10 @@
4
4
  # Exit code 1 if any URL is broken.
5
5
 
6
6
  set -uo pipefail
7
+ # nullglob: an unmatched glob expands to nothing, not the literal pattern.
8
+ # Without this, an empty `servers/` (as of v3.0.0) would make the loop
9
+ # try to read `servers/*/recipe.json` literally and fail.
10
+ shopt -s nullglob
7
11
 
8
12
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
13
  SERVERS_DIR="$SCRIPT_DIR/../servers"