linkedin-toolkit-mcp 2.0.2 → 2.1.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.
package/dist/bridge.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { EventEmitter } from 'node:events';
15
15
  import type { ErrorCode, EventName, RequestOrigin } from './contract.js';
16
- export declare const SERVER_VERSION = "2.0.0";
16
+ export declare const SERVER_VERSION = "2.1.0";
17
17
  export declare const OFFLINE_HOW_TO_FIX: string;
18
18
  export declare class BridgeError extends Error {
19
19
  readonly code: ErrorCode;
package/dist/bridge.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import { EventEmitter } from 'node:events';
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import { WebSocketServer, WebSocket } from 'ws';
17
- export const SERVER_VERSION = '2.0.0';
17
+ export const SERVER_VERSION = '2.1.0';
18
18
  export const OFFLINE_HOW_TO_FIX = 'Install the extension from GitHub Releases, open the popup → Settings → Local bridge, ' +
19
19
  'paste the pairing token printed by `lit serve`, and enable the bridge.';
20
20
  export class BridgeError extends Error {
package/dist/cli.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { Command } from 'commander';
11
11
  import { type ServerConfig } from './config.js';
12
+ import { type FsLike } from './setup.js';
12
13
  import { FakeExtensionClient } from './fake-extension.js';
13
14
  import { HttpServer } from './http.js';
14
15
  import { Toolkit } from './toolkit.js';
@@ -79,5 +80,48 @@ export declare function serve(options: {
79
80
  bridgePort?: number;
80
81
  fake?: boolean;
81
82
  }, io: Io): Promise<ServeHandles>;
83
+ export type SetupOptions = {
84
+ dir?: string;
85
+ open?: boolean;
86
+ dryRun?: boolean;
87
+ client?: string;
88
+ version?: string;
89
+ wait?: number;
90
+ };
91
+ /** A server `lit setup` is holding open while it waits for the extension. */
92
+ export type SetupServer = {
93
+ url: string;
94
+ startedHere: boolean;
95
+ connected: () => Promise<boolean>;
96
+ stop: () => Promise<void>;
97
+ };
98
+ /**
99
+ * Everything `lit setup` touches that a test must not: the network, the
100
+ * filesystem, the clock, Chrome, and a listening socket.
101
+ */
102
+ export type SetupDeps = {
103
+ fetchImpl?: typeof fetch;
104
+ fs?: FsLike;
105
+ now?: () => number;
106
+ platform?: string;
107
+ env?: NodeJS.ProcessEnv;
108
+ home?: string;
109
+ cwd?: string;
110
+ /** Ask the OS to launch Chrome. Returns false if it could not even try. */
111
+ launch?: (command: string, args: string[]) => boolean;
112
+ exists?: (path: string) => boolean;
113
+ openServer?: () => Promise<SetupServer>;
114
+ sleep?: (ms: number) => Promise<void>;
115
+ };
116
+ /**
117
+ * `lit setup` — the one command.
118
+ *
119
+ * Downloads the extension for this package's version, checks it really is an
120
+ * extension, unpacks it somewhere permanent, prints the three Chrome steps
121
+ * nobody can automate, waits for the extension to pair, and writes the MCP
122
+ * config for whichever client was asked for. Each step reports what it did,
123
+ * including when it did nothing.
124
+ */
125
+ export declare function runSetup(options: SetupOptions, io: Io, deps?: SetupDeps): Promise<void>;
82
126
  export declare function buildProgram(io?: Io): Command;
83
127
  export declare function run(argv: string[], io?: Io): Promise<number>;
package/dist/cli.js CHANGED
@@ -8,11 +8,14 @@
8
8
  * same code path.
9
9
  */
10
10
  import { Command, CommanderError } from 'commander';
11
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { spawn } from 'node:child_process';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
13
  import { join, resolve } from 'node:path';
13
14
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
15
  import { clearRuntime, generateToken, loadConfig, pairingInstructions, readRuntime, saveConfig, withOverrides, writeRuntime, } from './config.js';
15
16
  import { ORIGIN_HEADER } from './contract.js';
17
+ import { doctorReport } from './endpoints.js';
18
+ import { CLIENTS, chromeLaunch, chromeSteps, clientTarget, defaultExtensionDir, installExtension, packageVersion, pairingTimeoutMessage, realFs, SERVER_KEY, serverEntry, SetupError, writeClientConfig, } from './setup.js';
16
19
  import { TABLES } from './db.js';
17
20
  import { createDemoHandlers, FAKE_BANNER } from './fake-data.js';
18
21
  import { FakeExtensionClient } from './fake-extension.js';
@@ -334,6 +337,219 @@ export async function serve(options, io) {
334
337
  },
335
338
  };
336
339
  }
340
+ const DEFAULT_WAIT_SECONDS = 120;
341
+ /** Detect a running server, or start one and hold it open for the pairing. */
342
+ async function openServerForPairing() {
343
+ const { config } = loadConfig();
344
+ const api = ServerClient.fromConfig(config);
345
+ try {
346
+ const health = await api.health();
347
+ if (health.ok) {
348
+ return {
349
+ url: api.baseUrl,
350
+ startedHere: false,
351
+ connected: async () => (await api.health()).extensionConnected,
352
+ stop: async () => undefined,
353
+ };
354
+ }
355
+ }
356
+ catch {
357
+ /* not running: start one below */
358
+ }
359
+ const quiet = { out: () => undefined, err: () => undefined };
360
+ const handles = await serve({ http: true }, quiet);
361
+ return {
362
+ url: handles.http?.url ?? `http://127.0.0.1:${config.httpPort}`,
363
+ startedHere: true,
364
+ connected: async () => handles.toolkit.isConnected(),
365
+ stop: () => handles.stop(),
366
+ };
367
+ }
368
+ /**
369
+ * `lit setup` — the one command.
370
+ *
371
+ * Downloads the extension for this package's version, checks it really is an
372
+ * extension, unpacks it somewhere permanent, prints the three Chrome steps
373
+ * nobody can automate, waits for the extension to pair, and writes the MCP
374
+ * config for whichever client was asked for. Each step reports what it did,
375
+ * including when it did nothing.
376
+ */
377
+ export async function runSetup(options, io, deps = {}) {
378
+ const fs = deps.fs ?? realFs;
379
+ const platform = deps.platform ?? process.platform;
380
+ const env = deps.env ?? process.env;
381
+ const dryRun = Boolean(options.dryRun);
382
+ const waitSeconds = options.wait === undefined ? DEFAULT_WAIT_SECONDS : Math.max(0, options.wait);
383
+ const version = options.version ?? packageVersion() ?? SERVER_VERSION;
384
+ const dir = resolve(options.dir ?? defaultExtensionDir(env.LINKEDIN_TOOLKIT_HOME));
385
+ const clientId = (options.client ?? 'print');
386
+ if (!CLIENTS.includes(clientId)) {
387
+ throw new CliError(`"${options.client}" is not a client. Choose one of: ${CLIENTS.join(', ')}.\n` +
388
+ 'Every other client is a paste: see docs/clients.md.');
389
+ }
390
+ io.out('LinkedIn Toolkit setup');
391
+ io.out(dryRun ? '(dry run — nothing will be written)\n' : '');
392
+ /* -- 1. the extension ------------------------------------------- */
393
+ io.out('[1/4] Extension');
394
+ if (dryRun) {
395
+ io.out(` Would install version ${version} into ${dir}`);
396
+ }
397
+ else {
398
+ let installed;
399
+ try {
400
+ installed = await installExtension({
401
+ version,
402
+ dir,
403
+ fetchImpl: deps.fetchImpl,
404
+ fs,
405
+ now: deps.now,
406
+ });
407
+ }
408
+ catch (err) {
409
+ if (err instanceof SetupError) {
410
+ throw new CliError([err.message, err.howToFix].filter(Boolean).join('\n '));
411
+ }
412
+ throw err;
413
+ }
414
+ if (installed.source === 'latest' && installed.version !== version.replace(/^v/, '')) {
415
+ io.out(` No release v${version.replace(/^v/, '')}; installed the latest instead (v${installed.version}).`);
416
+ }
417
+ io.out(` Downloaded ${installed.url}`);
418
+ io.out(` Unpacked ${installed.files} files (extension v${installed.manifestVersion}) to:\n` +
419
+ ` ${installed.dir}`);
420
+ if (installed.replaced)
421
+ io.out(' The previous copy was replaced.');
422
+ }
423
+ /* -- 2. the three clicks ---------------------------------------- */
424
+ io.out('');
425
+ io.out('[2/4] Three steps only you can do (Chrome does not allow any installer to do them)');
426
+ for (const step of chromeSteps(dir))
427
+ io.out(step);
428
+ if (options.open === false) {
429
+ io.out(' (--no-open: not touching Chrome.)');
430
+ }
431
+ else if (dryRun) {
432
+ io.out(' (dry run: not touching Chrome.)');
433
+ }
434
+ else {
435
+ const plan = chromeLaunch(platform, env, deps.exists ?? ((path) => existsSync(path)));
436
+ if (plan.kind === 'none') {
437
+ io.out(` Not opening Chrome for you: ${plan.reason}. Type the URL above.`);
438
+ }
439
+ else {
440
+ const launched = (deps.launch ?? defaultLaunch)(plan.command, plan.args);
441
+ io.out(launched
442
+ ? ' Asked Chrome to open that page. Chrome ignores chrome:// URLs from the command\n' +
443
+ ' line in many builds, so if no tab appeared, type it in the address bar.'
444
+ : ' Could not start Chrome from here. Open it yourself and type the URL above.');
445
+ }
446
+ }
447
+ /* -- 3. pairing -------------------------------------------------- */
448
+ io.out('');
449
+ io.out('[3/4] Pairing');
450
+ const { config } = loadConfig();
451
+ io.out(` Pairing token: ${config.token}`);
452
+ io.out(' In the popup: Settings -> Local bridge -> paste the token -> enable.');
453
+ if (dryRun) {
454
+ io.out(' (dry run: not starting a server and not waiting.)');
455
+ }
456
+ else if (waitSeconds === 0) {
457
+ io.out(' (--wait 0: not waiting. Run `lit serve --http`, then `lit status`.)');
458
+ }
459
+ else {
460
+ const server = await (deps.openServer ?? openServerForPairing)();
461
+ const sleep = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
462
+ try {
463
+ io.out(server.startedHere
464
+ ? ` Started a server on ${server.url} and holding it open while you pair.`
465
+ : ` A server is already running on ${server.url}.`);
466
+ io.out(` Waiting up to ${waitSeconds}s for the extension to connect...`);
467
+ let paired = await server.connected();
468
+ const deadline = (deps.now ?? Date.now)() + waitSeconds * 1000;
469
+ while (!paired && (deps.now ?? Date.now)() < deadline) {
470
+ await sleep(1000);
471
+ paired = await server.connected();
472
+ }
473
+ if (paired) {
474
+ io.out(' Paired. The extension is talking to this machine.');
475
+ }
476
+ else {
477
+ for (const line of pairingTimeoutMessage(waitSeconds).split('\n'))
478
+ io.out(` ${line}`);
479
+ }
480
+ }
481
+ finally {
482
+ await server.stop();
483
+ if (server.startedHere) {
484
+ io.out(' (That setup server has stopped. Pairing is remembered by the extension;');
485
+ io.out(' your agent starts its own server, or run `lit serve --http` yourself.)');
486
+ }
487
+ }
488
+ }
489
+ /* -- 4. the agent's config --------------------------------------- */
490
+ io.out('');
491
+ io.out('[4/4] Agent config');
492
+ const target = clientTarget(clientId, {
493
+ platform,
494
+ env,
495
+ home: deps.home,
496
+ cwd: deps.cwd,
497
+ });
498
+ const entry = serverEntry(clientId);
499
+ const snippet = `${JSON.stringify({ [target.key]: { [SERVER_KEY]: entry } }, null, 2)}`;
500
+ if (target.mode === 'print') {
501
+ io.out(` ${target.label}: no file to write.`);
502
+ io.out(` ${target.note}`);
503
+ io.out('');
504
+ for (const line of snippet.split('\n'))
505
+ io.out(` ${line}`);
506
+ }
507
+ else {
508
+ let outcome;
509
+ try {
510
+ outcome = writeClientConfig({ target, entry, dryRun, fs, now: deps.now });
511
+ }
512
+ catch (err) {
513
+ if (err instanceof SetupError) {
514
+ throw new CliError([err.message, err.howToFix].filter(Boolean).join('\n '));
515
+ }
516
+ throw err;
517
+ }
518
+ if (outcome.action === 'dry-run') {
519
+ io.out(` Would ${outcome.created ? 'create' : 'update'} ${outcome.path}:`);
520
+ io.out('');
521
+ for (const line of snippet.split('\n'))
522
+ io.out(` ${line}`);
523
+ }
524
+ else if (outcome.action === 'unchanged') {
525
+ io.out(` ${outcome.path} already has "${SERVER_KEY}". Nothing to change.`);
526
+ }
527
+ else {
528
+ io.out(` ${outcome.created ? 'Created' : 'Updated'} ${outcome.path}`);
529
+ if (outcome.backup)
530
+ io.out(` Backed the old one up to ${outcome.backup}`);
531
+ }
532
+ if (outcome.kept.length > 0) {
533
+ io.out(` Your other MCP servers are untouched: ${outcome.kept.join(', ')}`);
534
+ }
535
+ io.out(` ${target.note}`);
536
+ }
537
+ io.out('');
538
+ io.out('Done. `lit status` says whether the extension is connected;');
539
+ io.out('docs/clients.md has the config block for every other client.');
540
+ }
541
+ /** Launch Chrome, detached, ignoring its output. False if it would not start. */
542
+ function defaultLaunch(command, args) {
543
+ try {
544
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
545
+ child.on('error', () => undefined);
546
+ child.unref();
547
+ return true;
548
+ }
549
+ catch {
550
+ return false;
551
+ }
552
+ }
337
553
  /* ------------------------------------------------------------------ *
338
554
  * Commands
339
555
  * ------------------------------------------------------------------ */
@@ -361,10 +577,33 @@ export function buildProgram(io = defaultIo) {
361
577
  .description('LinkedIn Toolkit: drive your own logged-in Chrome through the toolkit extension.\n' +
362
578
  'Start the server with `lit serve --http`, then every other command talks to it.')
363
579
  .version(SERVER_VERSION)
580
+ // Without this, commander lets the program's own `-V, --version` swallow
581
+ // `lit setup --version 2.1.0` and print its version instead of installing
582
+ // that one. Positional parsing keeps each command's flags to itself.
583
+ .enablePositionalOptions()
364
584
  .configureOutput({
365
585
  writeOut: (text) => io.out(text.replace(/\n$/, '')),
366
586
  writeErr: (text) => io.err(text.replace(/\n$/, '')),
367
587
  });
588
+ program
589
+ .command('setup')
590
+ .description('Install the extension, pair it, and write your agent\'s MCP config. Start here.')
591
+ .option('--dir <path>', 'where to unpack the extension (default ~/.linkedin-toolkit/extension)')
592
+ .option('--no-open', 'do not ask Chrome to open chrome://extensions')
593
+ .option('--dry-run', 'say what would happen, and write nothing')
594
+ .option('--client <client>', `write the MCP config for: ${CLIENTS.join(' | ')}`, 'print')
595
+ .option('--version <version>', 'install a specific release instead of this package\'s version')
596
+ .option('--wait <seconds>', `how long to wait for the extension to pair (default ${DEFAULT_WAIT_SECONDS}, 0 to skip)`, (value) => Number(value))
597
+ .action(async (options) => {
598
+ await runSetup({
599
+ dir: options.dir,
600
+ open: options.open,
601
+ dryRun: options.dryRun,
602
+ client: options.client,
603
+ version: options.version,
604
+ wait: options.wait,
605
+ }, io);
606
+ });
368
607
  program
369
608
  .command('serve')
370
609
  .description('Run the bridge and the MCP server. Add --http for the HTTP API.')
@@ -505,7 +744,7 @@ export function buildProgram(io = defaultIo) {
505
744
  .command('invite')
506
745
  .argument('<url>', 'profile URL or publicId')
507
746
  .description('Send a connection invite (queued for approval in Copilot mode).')
508
- .option('--note <note>', 'a note, under 300 characters')
747
+ .option('--note <note>', 'a note, at most 200 characters (LinkedIn\'s limit)')
509
748
  .option('--dry-run', 'show what would be sent without sending it')
510
749
  .action(async (url, options) => {
511
750
  const data = await client().action('outreach.invite', {
@@ -552,11 +791,13 @@ export function buildProgram(io = defaultIo) {
552
791
  const columns = ['threadId', 'who', 'unread', ...(options.sentiment ? ['sentiment'] : []), 'snippet'];
553
792
  print(io, options.json, data, () => table(threads, columns));
554
793
  });
794
+ const QUEUE_STATUSES = ['pending', 'approved', 'rejected', 'sent', 'failed'];
555
795
  program
556
796
  .command('queue')
557
797
  .argument('[action]', 'approve | reject | list', 'list')
558
798
  .argument('[ids...]', 'queue item ids')
559
799
  .description('Show the approval queue, or approve or reject items.')
800
+ .option('--status <status>', `filter the list: ${QUEUE_STATUSES.join(' | ')} (default pending)`)
560
801
  .option('--json', 'print raw JSON')
561
802
  .action(async (action, ids, options) => {
562
803
  const api = client();
@@ -564,20 +805,34 @@ export function buildProgram(io = defaultIo) {
564
805
  if (ids.length === 0)
565
806
  throw new CliError(`Give at least one id: lit queue ${action} <id>`);
566
807
  const data = await api.action(`queue.${action}`, { ids });
567
- io.out(`${action === 'approve' ? 'Approved' : 'Rejected'} ${data.approved ?? data.rejected}.`);
808
+ if (action === 'reject') {
809
+ io.out(`Rejected ${data.rejected}.`);
810
+ return;
811
+ }
812
+ // Approving no longer waits for the sends: the extension paces them and
813
+ // marks each item as it goes, so say where to look rather than implying
814
+ // they have already gone out.
815
+ io.out(`Approved ${data.approved}. Sending in the background — check with: lit queue list --status sent`);
568
816
  return;
569
817
  }
570
818
  if (action !== 'list')
571
819
  throw new CliError(`Unknown queue action "${action}".`);
572
- const data = await api.action('queue.list', { status: 'pending' });
820
+ const status = options.status ?? 'pending';
821
+ if (!QUEUE_STATUSES.includes(status)) {
822
+ throw new CliError(`Unknown queue status "${status}". Use one of: ${QUEUE_STATUSES.join(', ')}.`);
823
+ }
824
+ const data = await api.action('queue.list', { status });
573
825
  const items = (data.items ?? []).map((item) => ({
574
826
  id: item.id,
575
827
  action: item.action,
576
828
  who: item.profile?.fullName ?? item.params?.publicId ?? '',
577
829
  origin: item.origin,
578
- preview: String(item.params?.note ?? item.params?.body ?? '').slice(0, 60),
830
+ status: item.status,
831
+ // A failed item's whole value is why it failed, so that is what the
832
+ // preview column shows instead of the note nobody received.
833
+ preview: String(item.result?.error?.message ?? item.params?.note ?? item.params?.body ?? '').slice(0, 60),
579
834
  }));
580
- print(io, options.json, data, () => table(items, ['id', 'action', 'who', 'origin', 'preview']));
835
+ print(io, options.json, data, () => table(items, ['id', 'action', 'who', 'origin', 'status', 'preview']));
581
836
  });
582
837
  const campaign = program.command('campaign').description('Create and control campaigns.');
583
838
  campaign
@@ -729,6 +984,33 @@ export function buildProgram(io = defaultIo) {
729
984
  throw new CliError('', 2);
730
985
  }
731
986
  });
987
+ endpointsCommand
988
+ .command('doctor')
989
+ .description('Run the check and explain any failure: which query id hash went stale, which file ' +
990
+ 'holds it, and how to re-capture it. Exits 2 if any endpoint failed.')
991
+ .option('--post <url>', 'a post URL, so the reaction endpoint can be checked too')
992
+ .option('--json', 'also print the raw check output — this is what the issue template asks for')
993
+ .action(async (options) => {
994
+ const status = await client().action('status.get', {
995
+ verify: true,
996
+ ...(options.post ? { postUrl: options.post } : {}),
997
+ });
998
+ const report = doctorReport({
999
+ endpoints: status.endpoints ?? {},
1000
+ errors: status.endpointErrors ?? {},
1001
+ clientVersionCaptured: status.clientVersionCaptured,
1002
+ endpointsCapturedAt: status.endpointsCapturedAt,
1003
+ loggedIn: status.loggedIn,
1004
+ extensionVersion: status.extensionVersion,
1005
+ });
1006
+ io.out(report.text);
1007
+ if (options.json) {
1008
+ io.out('');
1009
+ io.out(JSON.stringify(status, null, 2));
1010
+ }
1011
+ if (report.failed.length > 0)
1012
+ throw new CliError('', 2);
1013
+ });
732
1014
  const configCommand = program
733
1015
  .command('config')
734
1016
  .description('Read and change this server\'s local settings (~/.linkedin-toolkit/config.json).');
@@ -865,9 +1147,20 @@ export function buildProgram(io = defaultIo) {
865
1147
  /* ------------------------------------------------------------------ *
866
1148
  * Entry point
867
1149
  * ------------------------------------------------------------------ */
1150
+ /**
1151
+ * `exitOverride` is inherited by a subcommand only if it was set before that
1152
+ * subcommand was created, and `buildProgram` creates them all up front. Without
1153
+ * this walk, `lit setup --help` reaches commander's `process.exit` and kills
1154
+ * whatever embedded this CLI — including the test runner.
1155
+ */
1156
+ function overrideExits(command) {
1157
+ command.exitOverride();
1158
+ for (const child of command.commands)
1159
+ overrideExits(child);
1160
+ }
868
1161
  export async function run(argv, io = defaultIo) {
869
1162
  const program = buildProgram(io);
870
- program.exitOverride();
1163
+ overrideExits(program);
871
1164
  try {
872
1165
  await program.parseAsync(argv, { from: 'user' });
873
1166
  return 0;