linkbreakers-mcp 0.0.0-stage → 1.162.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Linkbreakers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # linkbreakers-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local stdio bridge to the hosted [Linkbreakers](https://linkbreakers.com) MCP server at `https://mcp.linkbreakers.com`, for MCP clients that can only start local (stdio) servers.
4
+
5
+ If your client supports remote MCP servers, connect it to `https://mcp.linkbreakers.com` directly instead. See the [setup guide](https://linkbreakers.com/help/article/mcp-server-setup).
6
+
7
+ Tools, resources and their schemas come from the hosted server: this package forwards every request unchanged, so it always matches what `https://mcp.linkbreakers.com` serves.
8
+
9
+ ## Setup
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "linkbreakers": {
15
+ "command": "npx",
16
+ "args": ["-y", "linkbreakers-mcp"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ ### Authentication
23
+
24
+ - **OAuth (default):** on the first request, your browser opens the Linkbreakers sign-in. Once you approve, the tools appear. Tokens are stored in `~/.config/linkbreakers/mcp-oauth.json` and refreshed automatically.
25
+ - **API token:** set `LINKBREAKERS_API_TOKEN` to a workspace API token from the [dashboard](https://app.linkbreakers.com) to skip the browser.
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "linkbreakers": {
31
+ "command": "npx",
32
+ "args": ["-y", "linkbreakers-mcp"],
33
+ "env": { "LINKBREAKERS_API_TOKEN": "your-workspace-token" }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ | Variable | Default | Purpose |
40
+ | --- | --- | --- |
41
+ | `LINKBREAKERS_API_TOKEN` | unset | Workspace API token, sent as a bearer token |
42
+ | `LINKBREAKERS_MCP_URL` | `https://mcp.linkbreakers.com` | Server to forward to |
43
+ | `LINKBREAKERS_MCP_CALLBACK_PORT` | `47823` | Local port for the OAuth redirect |
44
+
45
+ Requires Node.js 18 or later.
46
+
47
+ ## Releases
48
+
49
+ Each API release dispatches `update-sdk` to this repo. The [release workflow](.github/workflows/release.yml) sets the package to the API version, checks that the server answers an MCP `initialize` over stdio, publishes to npm through trusted publishing and tags `v<version>`.
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../lib/proxy.js';
3
+
4
+ main().catch((err) => {
5
+ process.stderr.write(`linkbreakers-mcp: ${err.message}\n`);
6
+ process.exit(1);
7
+ });
package/lib/oauth.js ADDED
@@ -0,0 +1,159 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { createServer } from 'node:http';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+
8
+ const CALLBACK_PORT = Number(process.env.LINKBREAKERS_MCP_CALLBACK_PORT || 47823);
9
+ const CALLBACK_PATH = '/callback';
10
+ const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
11
+
12
+ function storePath() {
13
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
14
+ return join(base, 'linkbreakers', 'mcp-oauth.json');
15
+ }
16
+
17
+ function readStore(serverUrl) {
18
+ try {
19
+ return JSON.parse(readFileSync(storePath(), 'utf8'))[serverUrl] ?? {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function writeStore(serverUrl, patch) {
26
+ const path = storePath();
27
+ let all = {};
28
+ try {
29
+ all = JSON.parse(readFileSync(path, 'utf8'));
30
+ } catch {}
31
+ all[serverUrl] = { ...all[serverUrl], ...patch };
32
+ for (const [key, value] of Object.entries(all[serverUrl])) {
33
+ if (value === undefined) delete all[serverUrl][key];
34
+ }
35
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
36
+ writeFileSync(path, JSON.stringify(all, null, 2), { mode: 0o600 });
37
+ }
38
+
39
+ export function hasStoredTokens(serverUrl) {
40
+ return Boolean(readStore(serverUrl).tokens);
41
+ }
42
+
43
+ export function openBrowser(url) {
44
+ const [cmd, args] =
45
+ process.platform === 'darwin'
46
+ ? ['open', [url]]
47
+ : process.platform === 'win32'
48
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
49
+ : ['xdg-open', [url]];
50
+ try {
51
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
52
+ child.on('error', () => {});
53
+ child.unref();
54
+ } catch {}
55
+ }
56
+
57
+ export class FileOAuthProvider {
58
+ constructor(serverUrl, version) {
59
+ this.serverUrl = serverUrl;
60
+ this.version = version;
61
+ this.authorizationUrl = undefined;
62
+ this._state = undefined;
63
+ this._verifier = undefined;
64
+ }
65
+
66
+ get redirectUrl() {
67
+ return `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
68
+ }
69
+
70
+ get clientMetadata() {
71
+ return {
72
+ client_name: 'Linkbreakers MCP (stdio)',
73
+ client_uri: 'https://linkbreakers.com',
74
+ software_id: 'linkbreakers-mcp',
75
+ software_version: this.version,
76
+ redirect_uris: [this.redirectUrl],
77
+ grant_types: ['authorization_code', 'refresh_token'],
78
+ response_types: ['code'],
79
+ token_endpoint_auth_method: 'none',
80
+ };
81
+ }
82
+
83
+ state() {
84
+ this._state = randomBytes(16).toString('hex');
85
+ return this._state;
86
+ }
87
+
88
+ clientInformation() {
89
+ const info = readStore(this.serverUrl).clientInformation;
90
+ if (info && info.redirect_uris && !info.redirect_uris.includes(this.redirectUrl)) return undefined;
91
+ return info;
92
+ }
93
+
94
+ saveClientInformation(clientInformation) {
95
+ writeStore(this.serverUrl, { clientInformation });
96
+ }
97
+
98
+ tokens() {
99
+ return readStore(this.serverUrl).tokens;
100
+ }
101
+
102
+ saveTokens(tokens) {
103
+ writeStore(this.serverUrl, { tokens });
104
+ }
105
+
106
+ redirectToAuthorization(authorizationUrl) {
107
+ this.authorizationUrl = authorizationUrl.toString();
108
+ }
109
+
110
+ saveCodeVerifier(codeVerifier) {
111
+ this._verifier = codeVerifier;
112
+ }
113
+
114
+ codeVerifier() {
115
+ if (!this._verifier) throw new Error('no PKCE code verifier for this sign-in');
116
+ return this._verifier;
117
+ }
118
+
119
+ invalidateCredentials(scope) {
120
+ if (scope === 'all') writeStore(this.serverUrl, { clientInformation: undefined, tokens: undefined });
121
+ if (scope === 'client') writeStore(this.serverUrl, { clientInformation: undefined });
122
+ if (scope === 'tokens') writeStore(this.serverUrl, { tokens: undefined });
123
+ }
124
+
125
+ waitForCallback() {
126
+ const expectedState = this._state;
127
+ return new Promise((resolve, reject) => {
128
+ const server = createServer((req, res) => {
129
+ const url = new URL(req.url || '/', this.redirectUrl);
130
+ if (url.pathname !== CALLBACK_PATH) {
131
+ res.writeHead(404).end();
132
+ return;
133
+ }
134
+ const code = url.searchParams.get('code');
135
+ const error = url.searchParams.get('error');
136
+ const ok = code && (!expectedState || url.searchParams.get('state') === expectedState);
137
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8' });
138
+ res.end(
139
+ ok
140
+ ? '<p>Linkbreakers is connected. You can close this tab and return to your MCP client.</p>'
141
+ : `<p>Linkbreakers sign-in failed: ${error ? String(error).replace(/[<>&"]/g, '') : 'invalid callback'}.</p>`,
142
+ );
143
+ clearTimeout(timer);
144
+ server.close();
145
+ if (ok) resolve(code);
146
+ else reject(new Error(`OAuth sign-in failed: ${error || 'invalid callback'}`));
147
+ });
148
+ const timer = setTimeout(() => {
149
+ server.close();
150
+ reject(new Error('OAuth sign-in timed out'));
151
+ }, LOGIN_TIMEOUT_MS);
152
+ server.on('error', (err) => {
153
+ clearTimeout(timer);
154
+ reject(new Error(`could not listen for the OAuth callback on ${this.redirectUrl}: ${err.message}`));
155
+ });
156
+ server.listen(CALLBACK_PORT, '127.0.0.1');
157
+ });
158
+ }
159
+ }
package/lib/proxy.js ADDED
@@ -0,0 +1,134 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
4
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { McpError, ResultSchema } from '@modelcontextprotocol/sdk/types.js';
8
+ import { FileOAuthProvider, hasStoredTokens, openBrowser } from './oauth.js';
9
+
10
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
11
+ const SERVER_URL = new URL(process.env.LINKBREAKERS_MCP_URL || 'https://mcp.linkbreakers.com');
12
+ const TOKEN = process.env.LINKBREAKERS_API_TOKEN?.trim();
13
+ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
14
+ const AUTH_REQUIRED = -32001;
15
+ const DEFAULT_CAPABILITIES = { tools: { listChanged: true }, resources: { listChanged: true } };
16
+
17
+ const log = (message) => process.stderr.write(`linkbreakers-mcp: ${message}\n`);
18
+
19
+ export async function main() {
20
+ const oauth = TOKEN ? undefined : new FileOAuthProvider(SERVER_URL.href, version);
21
+ let server;
22
+ let remote;
23
+ let connecting;
24
+ let signIn;
25
+
26
+ const transportOptions = TOKEN
27
+ ? { requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } } }
28
+ : { authProvider: oauth };
29
+
30
+ async function connectRemote(transport = new StreamableHTTPClientTransport(SERVER_URL, transportOptions)) {
31
+ const client = new Client({ name: 'linkbreakers-mcp', version }, { capabilities: {} });
32
+ client.fallbackNotificationHandler = async (notification) => {
33
+ await server?.notification(notification).catch(() => {});
34
+ };
35
+ client.onclose = () => {
36
+ if (remote === client) remote = undefined;
37
+ };
38
+ try {
39
+ await client.connect(transport);
40
+ } catch (err) {
41
+ if (oauth && err instanceof UnauthorizedError && oauth.authorizationUrl) startSignIn(transport);
42
+ throw err;
43
+ }
44
+ return client;
45
+ }
46
+
47
+ function startSignIn(transport) {
48
+ if (signIn) return;
49
+ const url = oauth.authorizationUrl;
50
+ const callback = oauth.waitForCallback();
51
+ log(`sign in to Linkbreakers to continue: ${url}`);
52
+ openBrowser(url);
53
+ signIn = callback
54
+ .then((code) => transport.finishAuth(code))
55
+ .then(() => connectRemote())
56
+ .then(async (client) => {
57
+ remote = client;
58
+ log('signed in to Linkbreakers');
59
+ await server.sendToolListChanged().catch(() => {});
60
+ await server.sendResourceListChanged().catch(() => {});
61
+ })
62
+ .catch((err) => log(err.message))
63
+ .finally(() => {
64
+ signIn = undefined;
65
+ });
66
+ }
67
+
68
+ function authError() {
69
+ const url = oauth?.authorizationUrl;
70
+ return new McpError(
71
+ AUTH_REQUIRED,
72
+ TOKEN
73
+ ? 'Linkbreakers rejected LINKBREAKERS_API_TOKEN. Create a workspace API token at https://app.linkbreakers.com, or unset it to sign in with OAuth.'
74
+ : `Linkbreakers sign-in required. Finish the OAuth sign-in opened in your browser${url ? ` (${url})` : ''}, or set LINKBREAKERS_API_TOKEN to a workspace API token. Tools appear once you are signed in.`,
75
+ );
76
+ }
77
+
78
+ async function ensureRemote() {
79
+ if (remote) return remote;
80
+ if (signIn) throw authError();
81
+ connecting ??= connectRemote().finally(() => {
82
+ connecting = undefined;
83
+ });
84
+ try {
85
+ remote = await connecting;
86
+ return remote;
87
+ } catch (err) {
88
+ if (err instanceof UnauthorizedError || err?.code === 401) throw authError();
89
+ throw new McpError(-32603, `Could not reach ${SERVER_URL.href}: ${err.message}`);
90
+ }
91
+ }
92
+
93
+ if (TOKEN || hasStoredTokens(SERVER_URL.href)) {
94
+ try {
95
+ remote = await connectRemote();
96
+ } catch (err) {
97
+ log(`could not connect yet (${err.message}); retrying on the first request`);
98
+ }
99
+ }
100
+
101
+ server = new Server(
102
+ { name: 'linkbreakers-mcp', title: 'Linkbreakers MCP', version, websiteUrl: 'https://linkbreakers.com' },
103
+ {
104
+ capabilities: remote?.getServerCapabilities() ?? DEFAULT_CAPABILITIES,
105
+ instructions: remote?.getInstructions(),
106
+ },
107
+ );
108
+
109
+ server.fallbackRequestHandler = async (request, extra) => {
110
+ const client = await ensureRemote();
111
+ const progressToken = request.params?._meta?.progressToken;
112
+ return client.request({ method: request.method, params: request.params }, ResultSchema, {
113
+ signal: extra.signal,
114
+ timeout: REQUEST_TIMEOUT_MS,
115
+ resetTimeoutOnProgress: true,
116
+ onprogress:
117
+ progressToken === undefined
118
+ ? undefined
119
+ : (progress) =>
120
+ server.notification({ method: 'notifications/progress', params: { ...progress, progressToken } }).catch(() => {}),
121
+ });
122
+ };
123
+
124
+ server.fallbackNotificationHandler = async (notification) => {
125
+ await remote?.notification(notification).catch(() => {});
126
+ };
127
+
128
+ server.onclose = async () => {
129
+ await remote?.close().catch(() => {});
130
+ process.exit(0);
131
+ };
132
+
133
+ await server.connect(new StdioServerTransport());
134
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "linkbreakers-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.162.0",
4
+ "mcpName": "com.linkbreakers/mcp",
5
+ "description": "Local stdio bridge to the Linkbreakers MCP server (https://mcp.linkbreakers.com) for MCP clients that only run local servers.",
6
+ "keywords": [
7
+ "linkbreakers",
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "qr-code",
11
+ "lead-capture"
12
+ ],
13
+ "homepage": "https://linkbreakers.com/mcp",
14
+ "bugs": "https://github.com/linkbreakers-com/linkbreakers-mcp/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/linkbreakers-com/linkbreakers-mcp.git"
18
+ },
19
+ "license": "MIT",
20
+ "author": "Linkbreakers <support@linkbreakers.com>",
21
+ "type": "module",
22
+ "bin": {
23
+ "linkbreakers-mcp": "bin/linkbreakers-mcp.js"
24
+ },
25
+ "files": [
26
+ "bin/",
27
+ "lib/",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.32.1"
36
+ }
37
+ }