Skip to content

Commit 656e20a

Browse files
committed
Merge branch 'feat/base-to-base-sync'
2 parents 64d58db + 6bc99f9 commit 656e20a

4 files changed

Lines changed: 46 additions & 1 deletion

File tree

packages/mcp-server/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
## [Unreleased]
44

5+
### Added (2026-07-31 daemon — secret-URL token for claude.ai custom connectors)
6+
7+
- **Daemon accepts the bearer token as `?token=` in the URL.** claude.ai custom connectors
8+
support only OAuth or no-auth — there is no header field, so a bearer-only server made
9+
Claude attempt OAuth discovery/registration against endpoints that don't exist
10+
("Couldn't register with …'s sign-in service"). `requireBearer` now also checks
11+
`req.query.token` (same secret, same timing-safe compare, 401-burst tripwire unchanged),
12+
so a tunnel URL like `https://<host>/mcp?token=<bearer>` connects as a no-auth connector —
13+
the Zapier/n8n secret-URL pattern. Requests that present a query token get
14+
`Referrer-Policy: no-referrer` + `Cache-Control: no-store` so the URL never leaks via
15+
referrer or cache. The token rides the URL, so treat the URL as the secret (edge/proxy
16+
access logs will see it); `manage_daemon action=token_rotate` invalidates it.
17+
518
### Fixed (2026-07-30 review finish — diff detail id, autoNumber update path, test isolation)
619

720
- **`sync_base mode=diff` with `detail` but no `diffId` returned `diffId:null` on success.**

packages/mcp-server/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,11 @@ do not need to change their configuration when the daemon is present.
684684

685685
The daemon stores its port and bearer token in `~/.airtable-user-mcp/daemon.lock`.
686686

687+
Clients that cannot send an `Authorization` header — e.g. claude.ai custom connectors, which
688+
only support OAuth or no-auth — can pass the token in the URL instead:
689+
`https://<tunnel-host>/mcp?token={token}`. The URL then IS the secret: share it only with the
690+
client, and rotate via `manage_daemon action=token_rotate` if it leaks.
691+
687692
## Protocol
688693

689694
| | |

packages/mcp-server/src/daemon/server.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,17 @@ export async function startDaemonServer(options = {}) {
358358
const header = req.headers?.authorization ?? '';
359359
const match = header.match(/^Bearer\s+(.+)$/i);
360360
const provided = match ? match[1] : null;
361-
if (!tokensMatch(provided, currentToken.bearerToken)) {
361+
// Claude.ai custom connectors can't send an Authorization header (OAuth or
362+
// no-auth only), so the token may ride the URL instead: /mcp?token=<bearer>.
363+
// Same secret, same timing-safe compare — the Zapier/n8n secret-URL pattern.
364+
const queryToken = typeof req.query?.token === 'string' ? req.query.token : null;
365+
if (queryToken !== null) {
366+
// The URL carries the secret on this path — keep it out of referrers and caches.
367+
res.setHeader('Referrer-Policy', 'no-referrer');
368+
res.setHeader('Cache-Control', 'no-store');
369+
}
370+
if (!tokensMatch(provided, currentToken.bearerToken)
371+
&& !tokensMatch(queryToken, currentToken.bearerToken)) {
362372
track401Burst(req); // 401-burst tripwire (D-06)
363373
const wantHtml = (req.headers?.accept ?? '').includes('text/html');
364374
if (wantHtml) {

packages/mcp-server/test/test-daemon-server.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,23 @@ describe('GET /daemon/health', () => {
102102
const body = await response.json();
103103
assert.strictEqual(body.error, 'Unauthorized');
104104
});
105+
106+
it('accepts the token as ?token= query param (claude.ai secret-URL path)', async () => {
107+
const response = await fetch(
108+
`http://127.0.0.1:${server.port}/daemon/health?token=${encodeURIComponent(server.bearerToken)}`
109+
);
110+
assert.strictEqual(response.status, 200);
111+
const body = await response.json();
112+
assert.strictEqual(body.ok, true);
113+
// Secret rode the URL — response must be uncacheable and never leak via Referer.
114+
assert.strictEqual(response.headers.get('referrer-policy'), 'no-referrer');
115+
assert.strictEqual(response.headers.get('cache-control'), 'no-store');
116+
});
117+
118+
it('returns 401 when ?token= is wrong', async () => {
119+
const response = await fetch(`http://127.0.0.1:${server.port}/daemon/health?token=wrong-token`);
120+
assert.strictEqual(response.status, 401);
121+
});
105122
});
106123

107124
describe('GET /daemon/session-health', () => {

0 commit comments

Comments
 (0)