b2match-mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -162,10 +162,11 @@ rm -rf ~/.config/b2match-mcp/browser-profile
162
162
 
163
163
  ### Storage locations
164
164
 
165
- - macOS: login Keychain via `/usr/bin/security` (`b2match-mcp` service, `default` account).
165
+ - macOS: login Keychain via `/usr/bin/security` (`b2match-mcp` service, `default` account). After each save the CLI reads the item back; if the secret is missing or wrong it falls back to the private file and prints `keychain read-back failed, stored in file`.
166
166
  - Linux: `secret-tool` / libsecret when installed and unlocked.
167
167
  - Windows: private fallback file.
168
168
  - Fallback file: `$B2MATCH_MCP_CONFIG_DIR/auth.json`, `$XDG_CONFIG_HOME/b2match-mcp/auth.json`, or `~/.config/b2match-mcp/auth.json`, written with `0600` permissions.
169
+ - `b2match-mcp status` reports `source: "keychain-unreadable"` (with a re-login hint) when a Keychain item exists but is empty or unreadable, instead of pretending you are logged out with `source: "none"`.
169
170
  - Dedicated browser profile: `~/.config/b2match-mcp/browser-profile` (same config directory).
170
171
 
171
172
  If a token is a JWT, the CLI records its `exp` claim as `expiresAt`. B2match also has opaque tokens, in which case lifetime cannot be known locally. A 401 on an authenticated viewer check is reported as invalid/expired and remembered for the process. Public tools then continue anonymously without deleting the stored token.
@@ -176,6 +177,7 @@ If a token is a JWT, the CLI records its `exp` claim as `expiresAt`. B2match als
176
177
  - **Google blocks the window:** finish sign-in in the dedicated window if possible; otherwise use `--paste`.
177
178
  - **Timeout / 404 after Google:** the CLI also reads a JWT from the event tab URL if Chrome 404s before a cookie is set. Finish sign-in and wait; or use `--paste`.
178
179
  - **Keychain prompt denied:** run `b2match-mcp logout`, then `login` again and approve the prompt; or set `B2MATCH_MCP_DISABLE_KEYCHAIN=1` to use the private file fallback.
180
+ - **`status` shows `keychain-unreadable`:** the Keychain item is empty or corrupt and no usable `auth.json` was found. Run `b2match-mcp logout` then `login` again. If a file token still works, `status` uses `source: "file"` and sets a `warning` about the Keychain item instead.
179
181
  - **Wrong/expired token:** run `b2match-mcp logout`, sign in again, and repeat `login`.
180
182
 
181
183
  ## Tools
@@ -247,7 +249,7 @@ The smoke test is skipped unless both environment variables are set.
247
249
 
248
250
  ## Publishing
249
251
 
250
- Releases are cut by Release Please from Conventional Commits on `main`. The same workflow (`.github/workflows/release-please.yml`) publishes with `npm publish --provenance --access public` when a release is created. Provenance requires a public repo. Configure npm trusted publishing against `release-please.yml`. `NPM_TOKEN` is a documented fallback only. `.github/workflows/publish.yml` is a manual republish trigger. After the first 0.1.0 release, remove `"release-as": "0.1.0"` from `release-please-config.json`.
252
+ Releases are cut by Release Please from Conventional Commits on `main`. The same workflow (`.github/workflows/release-please.yml`) publishes with `npm publish --provenance --access public` when a release is created. Provenance requires a public repo. Configure npm trusted publishing against `release-please.yml`. `NPM_TOKEN` is a documented fallback only. `.github/workflows/publish.yml` is a manual republish trigger.
251
253
 
252
254
  ## Verification status
253
255
 
package/dist/auth.d.ts CHANGED
@@ -7,13 +7,22 @@ export interface StoredAuth {
7
7
  obtainedVia: "browser" | "paste" | "env" | "password" | "magic-link";
8
8
  savedAt: string;
9
9
  }
10
+ export type AuthSource = "env" | "keychain" | "file" | "none" | "keychain-unreadable";
10
11
  export interface AuthState {
11
12
  loggedIn: boolean;
12
- source: "env" | "keychain" | "file" | "none";
13
+ source: AuthSource;
13
14
  expiresAt?: string;
14
15
  userId?: string;
15
16
  loginEventId?: string;
16
17
  obtainedVia?: StoredAuth["obtainedVia"];
18
+ /** Present when source is keychain-unreadable. Never includes the secret. */
19
+ error?: string;
20
+ hint?: string;
21
+ /**
22
+ * Set when a usable file token is returned despite a broken keychain item.
23
+ * Never includes the secret. Surfaced by CLI `status` and MCP `auth_status`.
24
+ */
25
+ warning?: string;
17
26
  }
18
27
  export declare function configDir(): string;
19
28
  export declare function authFilePath(dir?: string): string;
package/dist/auth.js CHANGED
@@ -37,6 +37,7 @@ export function isExpired(expiresAt, skewMs = 60_000) {
37
37
  }
38
38
  return new Date(expiresAt).getTime() <= Date.now() + skewMs;
39
39
  }
40
+ const KEYCHAIN_REREAD_HINT = "Re-run `b2match-mcp login` (or `login --paste`). If it keeps failing, set B2MATCH_MCP_DISABLE_KEYCHAIN=1 and try again.";
40
41
  export class AuthStore {
41
42
  dir;
42
43
  backend;
@@ -60,13 +61,23 @@ export class AuthStore {
60
61
  if (this.backend) {
61
62
  try {
62
63
  const password = await this.backend.get();
63
- if (password) {
64
- return { record: JSON.parse(password), source: "keychain" };
64
+ if (password === null) {
65
+ // Not found — fall through to the file.
66
+ }
67
+ else if (!password.trim()) {
68
+ // Empty keychain item: try file fallback for callers that need a token.
69
+ }
70
+ else {
71
+ try {
72
+ return { record: JSON.parse(password), source: "keychain" };
73
+ }
74
+ catch {
75
+ // Unparseable keychain payload — try file fallback.
76
+ }
65
77
  }
66
78
  }
67
79
  catch {
68
- // Fall back to the local 0600 file when the OS keychain is unavailable,
69
- // locked, or denied by the user.
80
+ // Keychain unavailable/locked/denied — try file fallback.
70
81
  }
71
82
  }
72
83
  try {
@@ -87,8 +98,19 @@ export class AuthStore {
87
98
  if (this.backend) {
88
99
  try {
89
100
  await this.backend.set(serialized);
90
- await rm(authFilePath(this.dir), { force: true });
91
- return "keychain";
101
+ const readBack = await this.backend.get();
102
+ if (readBack !== null && readBack === serialized) {
103
+ await rm(authFilePath(this.dir), { force: true });
104
+ return "keychain";
105
+ }
106
+ // Bad/empty read-back: remove the broken item so status does not stick on keychain-unreadable.
107
+ try {
108
+ await this.backend.delete();
109
+ }
110
+ catch {
111
+ // Ignore delete failures; still fall back to the file.
112
+ }
113
+ console.error("b2match-mcp: keychain read-back failed, stored in file");
92
114
  }
93
115
  catch {
94
116
  // Continue to file fallback.
@@ -110,18 +132,74 @@ export class AuthStore {
110
132
  await rm(authFilePath(this.dir), { force: true });
111
133
  }
112
134
  async status() {
113
- const stored = await this.read();
114
- if (!stored) {
135
+ const envToken = process.env.B2MATCH_TOKEN?.trim();
136
+ if (envToken) {
137
+ const expiresAt = decodeJwtExpiry(envToken);
138
+ return {
139
+ loggedIn: !isExpired(expiresAt),
140
+ source: "env",
141
+ expiresAt,
142
+ obtainedVia: "env"
143
+ };
144
+ }
145
+ let keychainProblem;
146
+ if (this.backend) {
147
+ try {
148
+ const password = await this.backend.get();
149
+ if (password === null) {
150
+ // Not found — check file below.
151
+ }
152
+ else if (!password.trim()) {
153
+ keychainProblem = { error: "macOS Keychain item exists but the secret is empty" };
154
+ }
155
+ else {
156
+ try {
157
+ const record = JSON.parse(password);
158
+ return {
159
+ loggedIn: !isExpired(record.expiresAt),
160
+ source: "keychain",
161
+ expiresAt: record.expiresAt,
162
+ userId: record.userId,
163
+ loginEventId: record.loginEventId,
164
+ obtainedVia: record.obtainedVia
165
+ };
166
+ }
167
+ catch {
168
+ keychainProblem = { error: "macOS Keychain secret is not valid JSON metadata" };
169
+ }
170
+ }
171
+ }
172
+ catch (error) {
173
+ const message = error instanceof Error ? error.message : String(error);
174
+ keychainProblem = { error: message };
175
+ }
176
+ }
177
+ try {
178
+ const text = await readFile(authFilePath(this.dir), "utf8");
179
+ const record = JSON.parse(text);
180
+ const state = {
181
+ loggedIn: !isExpired(record.expiresAt),
182
+ source: "file",
183
+ expiresAt: record.expiresAt,
184
+ userId: record.userId,
185
+ loginEventId: record.loginEventId,
186
+ obtainedVia: record.obtainedVia
187
+ };
188
+ if (keychainProblem) {
189
+ state.warning = `${keychainProblem.error}; using file token instead. ${KEYCHAIN_REREAD_HINT}`;
190
+ }
191
+ return state;
192
+ }
193
+ catch {
194
+ if (keychainProblem) {
195
+ return {
196
+ loggedIn: false,
197
+ source: "keychain-unreadable",
198
+ error: keychainProblem.error,
199
+ hint: KEYCHAIN_REREAD_HINT
200
+ };
201
+ }
115
202
  return { loggedIn: false, source: "none" };
116
203
  }
117
- const { record, source } = stored;
118
- return {
119
- loggedIn: !isExpired(record.expiresAt),
120
- source,
121
- expiresAt: record.expiresAt,
122
- userId: record.userId,
123
- loginEventId: record.loginEventId,
124
- obtainedVia: record.obtainedVia
125
- };
126
204
  }
127
205
  }
@@ -15,7 +15,8 @@ export interface SecretBackend {
15
15
  export declare const runCommand: CommandRunner;
16
16
  export declare function macOSSecurityCommands(service?: string, account?: string): {
17
17
  get: readonly ["/usr/bin/security", readonly ["find-generic-password", "-s", string, "-a", string, "-w"]];
18
- set: readonly ["/usr/bin/security", readonly ["add-generic-password", "-U", "-s", string, "-a", string]];
18
+ /** Interactive mode: the add-generic-password line (with -X hex) is written on stdin. */
19
+ set: readonly ["/usr/bin/security", readonly ["-i"]];
19
20
  delete: readonly ["/usr/bin/security", readonly ["delete-generic-password", "-s", string, "-a", string]];
20
21
  };
21
22
  export declare function linuxSecretToolCommands(service?: string, account?: string): {
@@ -23,6 +24,15 @@ export declare function linuxSecretToolCommands(service?: string, account?: stri
23
24
  set: readonly ["secret-tool", readonly ["store", "--label", string, "service", string, "account", string]];
24
25
  delete: readonly ["secret-tool", readonly ["clear", "service", string, "account", string]];
25
26
  };
27
+ /** Hex-encode UTF-8 for `security add-generic-password -X`. */
28
+ export declare function encodeSecretForKeychain(secret: string): string;
29
+ /**
30
+ * Decode `security find-generic-password -w` output.
31
+ * When the password is non-printable, security prints hex; otherwise it prints the raw string.
32
+ * Trim a single trailing newline only.
33
+ */
34
+ export declare function decodeKeychainPassword(stdout: string): string;
35
+ export declare function formatKeychainCommandError(result: CommandResult, fallback: string): string;
26
36
  export declare class MacOSKeychainBackend implements SecretBackend {
27
37
  private readonly runner;
28
38
  private readonly service;
package/dist/keychain.js CHANGED
@@ -29,7 +29,8 @@ export const runCommand = (command, args, input) => new Promise((resolve, reject
29
29
  export function macOSSecurityCommands(service = KEYCHAIN_SERVICE, account = KEYCHAIN_ACCOUNT) {
30
30
  return {
31
31
  get: ["/usr/bin/security", ["find-generic-password", "-s", service, "-a", account, "-w"]],
32
- set: ["/usr/bin/security", ["add-generic-password", "-U", "-s", service, "-a", account]],
32
+ /** Interactive mode: the add-generic-password line (with -X hex) is written on stdin. */
33
+ set: ["/usr/bin/security", ["-i"]],
33
34
  delete: ["/usr/bin/security", ["delete-generic-password", "-s", service, "-a", account]]
34
35
  };
35
36
  }
@@ -40,6 +41,51 @@ export function linuxSecretToolCommands(service = KEYCHAIN_SERVICE, account = KE
40
41
  delete: ["secret-tool", ["clear", "service", service, "account", account]]
41
42
  };
42
43
  }
44
+ /** Hex-encode UTF-8 for `security add-generic-password -X`. */
45
+ export function encodeSecretForKeychain(secret) {
46
+ return Buffer.from(secret, "utf8").toString("hex");
47
+ }
48
+ /**
49
+ * Decode `security find-generic-password -w` output.
50
+ * When the password is non-printable, security prints hex; otherwise it prints the raw string.
51
+ * Trim a single trailing newline only.
52
+ */
53
+ export function decodeKeychainPassword(stdout) {
54
+ const trimmed = stripTrailingNewline(stdout);
55
+ if (!trimmed) {
56
+ return "";
57
+ }
58
+ if (looksLikeJson(trimmed)) {
59
+ return trimmed;
60
+ }
61
+ if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) {
62
+ try {
63
+ return Buffer.from(trimmed, "hex").toString("utf8");
64
+ }
65
+ catch {
66
+ return trimmed;
67
+ }
68
+ }
69
+ return trimmed;
70
+ }
71
+ function looksLikeJson(value) {
72
+ const first = value[0];
73
+ if (first !== "{" && first !== "[") {
74
+ return false;
75
+ }
76
+ try {
77
+ JSON.parse(value);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ export function formatKeychainCommandError(result, fallback) {
85
+ const stderr = result.stderr.trim();
86
+ const detail = stderr ? `stderr=${stderr}` : "stderr=<empty>";
87
+ return `${fallback} (exit ${result.code ?? "null"}; ${detail})`;
88
+ }
43
89
  export class MacOSKeychainBackend {
44
90
  runner;
45
91
  service;
@@ -53,26 +99,43 @@ export class MacOSKeychainBackend {
53
99
  async get() {
54
100
  const [command, args] = macOSSecurityCommands(this.service, this.account).get;
55
101
  const result = await this.runner(command, [...args]);
56
- if (result.code === 44 || /could not be found/i.test(result.stderr)) {
102
+ if (result.code === 44) {
103
+ return null;
104
+ }
105
+ // Only treat "not found" stderr when the process actually failed.
106
+ if (result.code !== 0 && /could not be found/i.test(result.stderr)) {
57
107
  return null;
58
108
  }
59
109
  if (result.code !== 0) {
60
- throw new Error(cleanCommandError(result.stderr, "macOS Keychain read failed"));
110
+ throw new Error(formatKeychainCommandError(result, "macOS Keychain read failed"));
61
111
  }
62
- return stripTrailingNewline(result.stdout);
112
+ return decodeKeychainPassword(result.stdout);
63
113
  }
64
114
  async set(secret) {
115
+ const hex = encodeSecretForKeychain(secret);
116
+ // service/account are fixed safe tokens (KEYCHAIN_* constants).
117
+ const script = `add-generic-password -U -s ${this.service} -a ${this.account} -X ${hex}\n`;
65
118
  const [command, args] = macOSSecurityCommands(this.service, this.account).set;
66
- const result = await this.runner(command, [...args], `${secret}\n`);
119
+ const result = await this.runner(command, [...args], script);
67
120
  if (result.code !== 0) {
68
- throw new Error(cleanCommandError(result.stderr, "macOS Keychain write failed"));
121
+ throw new Error(formatKeychainCommandError(result, "macOS Keychain write failed"));
122
+ }
123
+ const stderr = result.stderr.trim();
124
+ if (stderr && /error|failed|denied|unable/i.test(stderr)) {
125
+ throw new Error(formatKeychainCommandError(result, "macOS Keychain write failed"));
69
126
  }
70
127
  }
71
128
  async delete() {
72
129
  const [command, args] = macOSSecurityCommands(this.service, this.account).delete;
73
130
  const result = await this.runner(command, [...args]);
74
- if (result.code !== 0 && result.code !== 44 && !/could not be found/i.test(result.stderr)) {
75
- throw new Error(cleanCommandError(result.stderr, "macOS Keychain delete failed"));
131
+ if (result.code === 44) {
132
+ return;
133
+ }
134
+ if (result.code !== 0 && /could not be found/i.test(result.stderr)) {
135
+ return;
136
+ }
137
+ if (result.code !== 0) {
138
+ throw new Error(formatKeychainCommandError(result, "macOS Keychain delete failed"));
76
139
  }
77
140
  }
78
141
  }
@@ -98,7 +161,7 @@ export class LinuxSecretToolBackend {
98
161
  const [command, args] = linuxSecretToolCommands(this.service, this.account).set;
99
162
  const result = await this.runner(command, [...args], `${secret}\n`);
100
163
  if (result.code !== 0) {
101
- throw new Error(cleanCommandError(result.stderr, "secret-tool write failed"));
164
+ throw new Error(formatKeychainCommandError(result, "secret-tool write failed"));
102
165
  }
103
166
  }
104
167
  async delete() {
@@ -140,7 +203,3 @@ function commandExistsOnPath(command) {
140
203
  function stripTrailingNewline(value) {
141
204
  return value.replace(/\r?\n$/, "");
142
205
  }
143
- function cleanCommandError(stderr, fallback) {
144
- const message = stderr.trim();
145
- return message || fallback;
146
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "b2match-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Read-only MCP server for the B2match event and matchmaking platform",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",