@ractive-ch/tab-mcp 0.0.0-stage → 0.1.0-alpha.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/AI_NOTICE +9 -0
- package/LICENSE +21 -0
- package/address.d.ts +14 -0
- package/config.d.ts +122 -0
- package/exchange.d.ts +69 -0
- package/index.d.ts +12 -0
- package/index.js +3558 -0
- package/index.js.map +25 -0
- package/ip.d.ts +22 -0
- package/log.d.ts +33 -0
- package/main.js +3627 -0
- package/main.js.map +26 -0
- package/oauth/cimd.d.ts +77 -0
- package/oauth/html.d.ts +13 -0
- package/oauth/pairing.d.ts +72 -0
- package/oauth/server.d.ts +68 -0
- package/package.json +41 -5
- package/protocol.d.ts +185 -0
- package/ratelimit.d.ts +21 -0
- package/relay.d.ts +42 -0
- package/session.d.ts +74 -0
- package/store/conformance.d.ts +11 -0
- package/store/memory.d.ts +16 -0
- package/store/sqlite.d.ts +17 -0
- package/store/types.d.ts +89 -0
- package/testing.d.ts +5 -0
- package/testing.js +204 -0
- package/testing.js.map +10 -0
- package/tokens.d.ts +45 -0
- package/README.md +0 -3
package/oauth/cimd.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client ID Metadata Documents (CIMD, draft-ietf-oauth-client-id-metadata-document;
|
|
3
|
+
* MCP 2026-07-28 client registration): the `client_id` is an HTTPS URL, and
|
|
4
|
+
* the relay fetches it to learn the client's name and redirect URIs.
|
|
5
|
+
*
|
|
6
|
+
* The URL is chosen by an unauthenticated party, so the fetch is guarded
|
|
7
|
+
* against SSRF (threat model A3): https only, default port, no credentials,
|
|
8
|
+
* the host resolved first and every address checked to be public, no
|
|
9
|
+
* redirects, 5 s, 64 KiB, `application/json`, results cached and fetches per
|
|
10
|
+
* host rate-limited.
|
|
11
|
+
*/
|
|
12
|
+
import type { Logger } from "../log.js";
|
|
13
|
+
/** What the relay needs to know about a client. */
|
|
14
|
+
export interface ClientMetadata {
|
|
15
|
+
/** The client id: the document's URL. */
|
|
16
|
+
clientId: string;
|
|
17
|
+
/** Self-declared, cleaned and shortened for display. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Host of the client id URL: the verified part. */
|
|
20
|
+
host: string;
|
|
21
|
+
redirectUris: string[];
|
|
22
|
+
}
|
|
23
|
+
/** Why a client id was refused; `message` is plain English for the page. */
|
|
24
|
+
export declare class ClientMetadataError extends Error {
|
|
25
|
+
name: string;
|
|
26
|
+
readonly reason: string;
|
|
27
|
+
constructor(reason: string, message: string);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Test-only relaxations (never from configuration): loopback hosts, any port,
|
|
31
|
+
* `http:`, and an extra CA for a self-signed test server.
|
|
32
|
+
*/
|
|
33
|
+
export interface CimdTestMode {
|
|
34
|
+
ca?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ClientMetadataFetcherOptions {
|
|
37
|
+
/** Accepted client id hosts; empty = any. */
|
|
38
|
+
allowedHosts?: readonly string[];
|
|
39
|
+
logger?: Logger;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
maxBytes?: number;
|
|
42
|
+
/** Resolve a host name to its addresses (tests replace it). */
|
|
43
|
+
lookup?: (hostname: string) => Promise<string[]>;
|
|
44
|
+
fetch?: typeof fetch;
|
|
45
|
+
testMode?: CimdTestMode;
|
|
46
|
+
now?: () => number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Clean a self-declared name for display: control and line/paragraph
|
|
50
|
+
* separators become spaces; format characters (`Cf`: zero-width, bidi,
|
|
51
|
+
* word joiner, BOM, soft hyphen, …) and other invisible characters are
|
|
52
|
+
* removed; whitespace collapses; at most 64 characters (DEC-065).
|
|
53
|
+
*/
|
|
54
|
+
export declare function displayName(raw: string): string;
|
|
55
|
+
/** Fetches, validates and caches Client ID Metadata Documents. */
|
|
56
|
+
export declare class ClientMetadataFetcher {
|
|
57
|
+
#private;
|
|
58
|
+
constructor(options?: ClientMetadataFetcherOptions);
|
|
59
|
+
/** The client's metadata, from the cache or freshly fetched. Throws `ClientMetadataError`. */
|
|
60
|
+
get(clientId: string): Promise<ClientMetadata>;
|
|
61
|
+
}
|
|
62
|
+
/** Check a fetched document against the CIMD rules and what the relay supports. */
|
|
63
|
+
export declare function validateDocument(doc: unknown, clientId: string, url: URL): ClientMetadata;
|
|
64
|
+
/**
|
|
65
|
+
* A redirect URI the relay accepts (DEC-067): absolute, no fragment or
|
|
66
|
+
* credentials, and `https:`; `http:` only for loopback hosts (RFC 8252 §7.3);
|
|
67
|
+
* or a reverse-domain private-use scheme for native apps (RFC 8252 §7.1).
|
|
68
|
+
* Everything else (`file:`, `blob:`, `data:`, `javascript:`, plain `http:`,
|
|
69
|
+
* one-word custom schemes) is refused.
|
|
70
|
+
*/
|
|
71
|
+
export declare function isRedirectUri(value: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Whether a requested redirect URI matches a registered one: exact string
|
|
74
|
+
* equality, except that for an `http:` loopback redirect the port is ignored
|
|
75
|
+
* (RFC 8252 §7.3; OAuth 2.1 §8.4.2). Host, path and query must still match.
|
|
76
|
+
*/
|
|
77
|
+
export declare function redirectUriMatches(requested: string, registered: string): boolean;
|
package/oauth/html.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-rendered HTML for `/authorize` (threat model A7): every value is
|
|
3
|
+
* escaped, the page has no scripts, and its headers forbid framing, inline
|
|
4
|
+
* code and foreign form targets.
|
|
5
|
+
*/
|
|
6
|
+
/** Escape text for HTML element content and quoted attribute values. */
|
|
7
|
+
export declare function escapeHtml(text: string): string;
|
|
8
|
+
/** The stylesheet at `/authorize.css` (`style-src 'self'`). */
|
|
9
|
+
export declare const AUTHORIZE_CSS = ":root { color-scheme: light dark; --accent: #1d4ed8; --error: #b91c1c; }\n@media (prefers-color-scheme: dark) { :root { --accent: #93b4ff; --error: #fca5a5; } }\n* { box-sizing: border-box; }\nbody { margin: 0; font: 1rem/1.5 system-ui, -apple-system, \"Segoe UI\", sans-serif; background: Canvas; color: CanvasText; }\nmain { max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }\nh1 { font-size: 1.5rem; line-height: 1.25; margin: 0 0 1rem; }\np { margin: 0 0 1rem; }\ncode, .origin { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }\n.help { color: GrayText; font-size: 0.9rem; }\n.error { color: var(--error); font-weight: 600; }\nlabel { display: block; font-weight: 600; margin-bottom: 0.25rem; }\ninput[type=\"text\"] { font: 1.5rem/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.2em; width: 10ch; padding: 0.4rem 0.5rem; border: 2px solid GrayText; border-radius: 0.4rem; background: Field; color: FieldText; }\ninput[type=\"text\"]:focus-visible, button:focus-visible, a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }\n.actions { display: flex; gap: 0.75rem; flex-wrap: wrap; margin: 1.5rem 0 1.25rem; }\nbutton, .button { font: inherit; padding: 0.5rem 1.25rem; border-radius: 0.4rem; border: 2px solid var(--accent); cursor: pointer; text-decoration: none; display: inline-block; }\nbutton.primary, .button.primary { background: var(--accent); color: Canvas; }\nbutton.secondary, .button:not(.primary) { background: transparent; color: var(--accent); }\nfooter { margin-top: 2.5rem; font-size: 0.85rem; color: GrayText; }\n";
|
|
10
|
+
/** Security headers of every `/authorize` response. */
|
|
11
|
+
export declare function pageHeaders(formTargets?: readonly string[]): Headers;
|
|
12
|
+
/** A complete page; `body` must already be escaped. */
|
|
13
|
+
export declare function renderPage(title: string, body: string): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing (DEC-028, DEC-042): the step at `/authorize` that proves the browser
|
|
3
|
+
* belongs to the tab's user. The authorization server only knows the
|
|
4
|
+
* `PairingMethod` interface; which method a session uses comes from its site
|
|
5
|
+
* (DEC-045). Today's only method is the 6-digit code shown in the tab; the
|
|
6
|
+
* planned identity binding (kb/design/identity-binding.md) is the next.
|
|
7
|
+
*/
|
|
8
|
+
import type { PairingKind } from "../config.js";
|
|
9
|
+
/** What a pairing method sees of a tab session. */
|
|
10
|
+
export interface PairingTarget {
|
|
11
|
+
/** The code state of the session (used by the code method). */
|
|
12
|
+
readonly codes: PairingCodes;
|
|
13
|
+
}
|
|
14
|
+
export type PairingResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
/** Plain English for the page (plain text; the page escapes it). */
|
|
19
|
+
message: string;
|
|
20
|
+
/** A short reason for the log. */
|
|
21
|
+
reason: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A way to pair a browser at `/authorize` with an address. The authorization
|
|
25
|
+
* server renders `render()` inside its form (CSRF token, buttons and the
|
|
26
|
+
* request id are its own) and passes the submitted form to `check()`.
|
|
27
|
+
*/
|
|
28
|
+
export interface PairingMethod {
|
|
29
|
+
readonly kind: PairingKind;
|
|
30
|
+
/** HTML of the method's step (escaped by the method). */
|
|
31
|
+
render(target: PairingTarget): string;
|
|
32
|
+
/**
|
|
33
|
+
* Check the submitted form fields. Call `admit()` right before judging an
|
|
34
|
+
* answer that could be right (a real guess); if it returns false, refuse
|
|
35
|
+
* with reason `rate_limited` without judging. Malformed input and a state
|
|
36
|
+
* that cannot succeed are answered without calling it (DEC-061).
|
|
37
|
+
*/
|
|
38
|
+
check(target: PairingTarget, form: URLSearchParams, admit: () => boolean): PairingResult | Promise<PairingResult>;
|
|
39
|
+
}
|
|
40
|
+
/** Code lifetime and attempts (DEC-028). */
|
|
41
|
+
export declare const PAIRING_CODE_TTL_MS = 120000;
|
|
42
|
+
export declare const PAIRING_MAX_ATTEMPTS = 5;
|
|
43
|
+
export type CodeCheck = "ok" | "limited" | "wrong" | "expired" | "locked" | "inactive" | "malformed";
|
|
44
|
+
/** Six uniformly distributed digits. */
|
|
45
|
+
export declare function newPairingCode(): string;
|
|
46
|
+
/**
|
|
47
|
+
* The pairing codes of one tab session: on while the tab's dialog is open
|
|
48
|
+
* (`pairing.start` … `pairing.stop`); a fresh code after expiry, after use and
|
|
49
|
+
* after too many wrong attempts. Codes are never logged.
|
|
50
|
+
*/
|
|
51
|
+
export declare class PairingCodes {
|
|
52
|
+
#private;
|
|
53
|
+
constructor(send: (code: string, expiresAt: number) => void, options?: {
|
|
54
|
+
ttlMs?: number;
|
|
55
|
+
maxAttempts?: number;
|
|
56
|
+
});
|
|
57
|
+
/** Whether the tab is showing codes. */
|
|
58
|
+
get active(): boolean;
|
|
59
|
+
/** Attempts left for the current code. */
|
|
60
|
+
get attemptsLeft(): number;
|
|
61
|
+
start(): void;
|
|
62
|
+
stop(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Check a typed code; a correct one is used up and replaced. `admit` is
|
|
65
|
+
* asked only for a well-formed code while a code is active.
|
|
66
|
+
*/
|
|
67
|
+
check(input: string, now?: number, admit?: () => boolean): CodeCheck;
|
|
68
|
+
}
|
|
69
|
+
/** The 6-digit code typed at `/authorize` (DEC-028). */
|
|
70
|
+
export declare const codePairing: PairingMethod;
|
|
71
|
+
/** The methods by kind. `oidc` joins with identity binding (DEC-042). */
|
|
72
|
+
export declare const PAIRING_METHODS: Partial<Record<PairingKind, PairingMethod>>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay's OAuth 2.1 authorization server (DEC-006, DEC-015, DEC-031),
|
|
3
|
+
* written without an OAuth library:
|
|
4
|
+
*
|
|
5
|
+
* - RFC 9728 protected-resource metadata per address, RFC 8414 server metadata;
|
|
6
|
+
* - clients only through Client ID Metadata Documents (`cimd.ts`);
|
|
7
|
+
* - `/authorize` with PKCE S256 only, exact redirect URIs (loopback port
|
|
8
|
+
* relaxed), `resource` bound to one address (RFC 8707), `iss` in every
|
|
9
|
+
* response (RFC 9207), and the pairing step of the address's site (DEC-042);
|
|
10
|
+
* - `/token` for the authorization code and rotating refresh tokens (DEC-029);
|
|
11
|
+
* - `/revoke` (RFC 7009).
|
|
12
|
+
*/
|
|
13
|
+
import type { PairingKind, RelayConfig, Site } from "../config.js";
|
|
14
|
+
import type { Logger } from "../log.js";
|
|
15
|
+
import type { GrantInfo } from "../protocol.js";
|
|
16
|
+
import type { TokenStore } from "../store/types.js";
|
|
17
|
+
import { type CimdTestMode } from "./cimd.js";
|
|
18
|
+
import { type PairingMethod, type PairingTarget } from "./pairing.js";
|
|
19
|
+
/** The only scope: calling the page's tools. */
|
|
20
|
+
export declare const SCOPE = "tools";
|
|
21
|
+
/** A tab session as the authorization server sees it. */
|
|
22
|
+
export interface AuthorizeSession extends PairingTarget {
|
|
23
|
+
readonly origin: string;
|
|
24
|
+
readonly appName: string | undefined;
|
|
25
|
+
readonly site: Site;
|
|
26
|
+
}
|
|
27
|
+
export interface AuthorizationHost {
|
|
28
|
+
readonly config: RelayConfig;
|
|
29
|
+
readonly logger: Logger;
|
|
30
|
+
readonly store: TokenStore;
|
|
31
|
+
publicUrl(): string;
|
|
32
|
+
/** The connected (ready) tab session of an address. */
|
|
33
|
+
session(addressId: string): AuthorizeSession | undefined;
|
|
34
|
+
/** Tell the address's tab, if connected. */
|
|
35
|
+
grantAdded(addressId: string, grant: GrantInfo): void;
|
|
36
|
+
grantRemoved(addressId: string, grantId: string, reason: string): void;
|
|
37
|
+
}
|
|
38
|
+
/** Test-only settings; never reachable from configuration. */
|
|
39
|
+
export interface AuthorizationTestMode {
|
|
40
|
+
cimd?: CimdTestMode;
|
|
41
|
+
authCodeTtlMs?: number;
|
|
42
|
+
/** Replace the pairing methods (a stub method proves the server needs no codes). */
|
|
43
|
+
pairingMethods?: Partial<Record<PairingKind, PairingMethod>>;
|
|
44
|
+
}
|
|
45
|
+
/** BASE64URL(SHA256(verifier)) (RFC 7636 §4.6). */
|
|
46
|
+
export declare function s256(verifier: string): string;
|
|
47
|
+
/** The OAuth endpoints and pages, mounted by the relay. */
|
|
48
|
+
export declare class AuthorizationServer {
|
|
49
|
+
#private;
|
|
50
|
+
constructor(host: AuthorizationHost, testMode?: AuthorizationTestMode);
|
|
51
|
+
/** CORS preflight for the metadata, token and revoke endpoints. */
|
|
52
|
+
preflight(methods: string): Response;
|
|
53
|
+
/** `/.well-known/oauth-protected-resource/a/{id}/mcp`. */
|
|
54
|
+
protectedResource(addressId: string): Response;
|
|
55
|
+
/** `/.well-known/oauth-authorization-server`. */
|
|
56
|
+
metadata(): Response;
|
|
57
|
+
stylesheet(): Response;
|
|
58
|
+
/** GET `/authorize`: check the request, then show the pairing page. */
|
|
59
|
+
authorize(req: Request, ip: string): Promise<Response>;
|
|
60
|
+
/** POST `/authorize`: the pairing form (connect, cancel, try again). */
|
|
61
|
+
authorizeSubmit(req: Request, ip: string): Promise<Response>;
|
|
62
|
+
/** POST `/token`. */
|
|
63
|
+
token(req: Request, ip: string): Promise<Response>;
|
|
64
|
+
/** POST `/revoke` (RFC 7009). */
|
|
65
|
+
revoke(req: Request, ip: string): Promise<Response>;
|
|
66
|
+
/** Drop expired state; the relay calls it periodically. */
|
|
67
|
+
sweep(): void;
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@ractive-ch/tab-mcp",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Relay that lets AI agents call the MCP tools of a web page open in a browser tab",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tab-mcp": "./main.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"default": "./index.js"
|
|
13
|
+
},
|
|
14
|
+
"./testing": {
|
|
15
|
+
"types": "./testing.d.ts",
|
|
16
|
+
"default": "./testing.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"types": "./index.d.ts",
|
|
20
|
+
"engines": {
|
|
21
|
+
"bun": ">=1.4.3"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Jean-Pierre Bergamin <james@ractive.ch>",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/ractive/tab-mcp.git",
|
|
28
|
+
"directory": "packages/tab-mcp"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/ractive/tab-mcp/tree/main/packages/tab-mcp#readme",
|
|
31
|
+
"bugs": "https://github.com/ractive/tab-mcp/issues",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"mcp",
|
|
34
|
+
"model-context-protocol",
|
|
35
|
+
"relay",
|
|
36
|
+
"browser",
|
|
37
|
+
"websocket"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/protocol.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tab↔relay protocol, version 1 (`tab-mcp.v1`).
|
|
3
|
+
*
|
|
4
|
+
* One WebSocket per tab session; text frames with one JSON object each. The
|
|
5
|
+
* normative description is `kb/design/protocol.md` (later `docs/protocol.md`).
|
|
6
|
+
*/
|
|
7
|
+
export declare const SUBPROTOCOL = "tab-mcp.v1";
|
|
8
|
+
/** The only MCP revision the relay serves (DEC-033). */
|
|
9
|
+
export declare const MCP_PROTOCOL_VERSION = "2026-07-28";
|
|
10
|
+
/** `_meta` key that carries the protocol version in every 2026-07-28 request. */
|
|
11
|
+
export declare const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion";
|
|
12
|
+
export declare const CloseCode: {
|
|
13
|
+
readonly normal: 1000;
|
|
14
|
+
readonly policyViolation: 1008;
|
|
15
|
+
readonly tooLarge: 1009;
|
|
16
|
+
readonly sessionExpired: 4001;
|
|
17
|
+
readonly idleTimeout: 4002;
|
|
18
|
+
readonly heartbeatLost: 4003;
|
|
19
|
+
readonly shuttingDown: 4004;
|
|
20
|
+
readonly replaced: 4005;
|
|
21
|
+
readonly rateLimited: 4008;
|
|
22
|
+
};
|
|
23
|
+
export type ByeReason = "replaced" | "expired" | "idle" | "shutdown" | "user";
|
|
24
|
+
export interface Limits {
|
|
25
|
+
maxMessageBytes: number;
|
|
26
|
+
maxBodyBytes: number;
|
|
27
|
+
requestTimeoutMs: number;
|
|
28
|
+
idleTimeoutMs: number;
|
|
29
|
+
heartbeatMs: number;
|
|
30
|
+
keepAliveMs: number;
|
|
31
|
+
maxConcurrentRequests: number;
|
|
32
|
+
}
|
|
33
|
+
/** Who is calling, as the tab sees it (passed to the MCP handler as `authInfo`). */
|
|
34
|
+
export interface GrantRef {
|
|
35
|
+
grantId: string;
|
|
36
|
+
clientId: string;
|
|
37
|
+
scopes: string[];
|
|
38
|
+
}
|
|
39
|
+
/** A connected AI app as listed to the tab. */
|
|
40
|
+
export interface GrantInfo {
|
|
41
|
+
grantId: string;
|
|
42
|
+
client: {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
host: string;
|
|
46
|
+
};
|
|
47
|
+
createdAt: string;
|
|
48
|
+
}
|
|
49
|
+
export interface SessionMessage {
|
|
50
|
+
type: "session";
|
|
51
|
+
sessionId: string;
|
|
52
|
+
address: string;
|
|
53
|
+
origin: string;
|
|
54
|
+
/** The name `/authorize` shows: the site's configured name, else the page's. */
|
|
55
|
+
appName?: string;
|
|
56
|
+
expiresAt: string;
|
|
57
|
+
limits: Limits;
|
|
58
|
+
grants: GrantInfo[];
|
|
59
|
+
}
|
|
60
|
+
export interface PingMessage {
|
|
61
|
+
type: "ping";
|
|
62
|
+
t: number;
|
|
63
|
+
}
|
|
64
|
+
export interface ByeMessage {
|
|
65
|
+
type: "bye";
|
|
66
|
+
reason: string;
|
|
67
|
+
}
|
|
68
|
+
export interface HttpRequestMessage {
|
|
69
|
+
type: "http.request";
|
|
70
|
+
id: string;
|
|
71
|
+
method: string;
|
|
72
|
+
path: string;
|
|
73
|
+
headers: Record<string, string>;
|
|
74
|
+
body: string;
|
|
75
|
+
grant: GrantRef;
|
|
76
|
+
}
|
|
77
|
+
export interface HttpCancelMessage {
|
|
78
|
+
type: "http.cancel";
|
|
79
|
+
id: string;
|
|
80
|
+
reason: "client_closed" | "timeout" | "error";
|
|
81
|
+
}
|
|
82
|
+
export interface PairingCodeMessage {
|
|
83
|
+
type: "pairing.code";
|
|
84
|
+
code: string;
|
|
85
|
+
expiresAt: string;
|
|
86
|
+
}
|
|
87
|
+
export interface GrantAddedMessage {
|
|
88
|
+
type: "grant.added";
|
|
89
|
+
grant: GrantInfo;
|
|
90
|
+
}
|
|
91
|
+
export interface GrantRemovedMessage {
|
|
92
|
+
type: "grant.removed";
|
|
93
|
+
grantId: string;
|
|
94
|
+
reason: "revoked" | "reuse_detected" | "expired";
|
|
95
|
+
}
|
|
96
|
+
export interface ErrorMessage {
|
|
97
|
+
type: "error";
|
|
98
|
+
code: ErrorCode;
|
|
99
|
+
message: string;
|
|
100
|
+
id?: string;
|
|
101
|
+
}
|
|
102
|
+
export type RelayMessage = SessionMessage | PingMessage | ByeMessage | HttpRequestMessage | HttpCancelMessage | PairingCodeMessage | GrantAddedMessage | GrantRemovedMessage | ErrorMessage;
|
|
103
|
+
export type ErrorCode = "bad_message" | "unknown_type" | "unknown_id" | "too_large" | "rate_limited";
|
|
104
|
+
export interface HelloMessage {
|
|
105
|
+
type: "hello";
|
|
106
|
+
library: {
|
|
107
|
+
name: string;
|
|
108
|
+
version: string;
|
|
109
|
+
};
|
|
110
|
+
page: {
|
|
111
|
+
title: string;
|
|
112
|
+
};
|
|
113
|
+
/** Self-declared app name (DEC-045), unverified. */
|
|
114
|
+
app?: {
|
|
115
|
+
name: string;
|
|
116
|
+
};
|
|
117
|
+
secret: string;
|
|
118
|
+
}
|
|
119
|
+
export interface PongMessage {
|
|
120
|
+
type: "pong";
|
|
121
|
+
t: number;
|
|
122
|
+
}
|
|
123
|
+
export interface HttpResponseMessage {
|
|
124
|
+
type: "http.response";
|
|
125
|
+
id: string;
|
|
126
|
+
status: number;
|
|
127
|
+
headers: Record<string, string>;
|
|
128
|
+
body: string;
|
|
129
|
+
}
|
|
130
|
+
export interface HttpResponseStartMessage {
|
|
131
|
+
type: "http.response.start";
|
|
132
|
+
id: string;
|
|
133
|
+
status: number;
|
|
134
|
+
headers: Record<string, string>;
|
|
135
|
+
}
|
|
136
|
+
export interface HttpResponseChunkMessage {
|
|
137
|
+
type: "http.response.chunk";
|
|
138
|
+
id: string;
|
|
139
|
+
data: string;
|
|
140
|
+
}
|
|
141
|
+
export interface HttpResponseEndMessage {
|
|
142
|
+
type: "http.response.end";
|
|
143
|
+
id: string;
|
|
144
|
+
}
|
|
145
|
+
export interface TabErrorMessage {
|
|
146
|
+
type: "error";
|
|
147
|
+
code: string;
|
|
148
|
+
message: string;
|
|
149
|
+
id?: string;
|
|
150
|
+
}
|
|
151
|
+
export interface PairingStartMessage {
|
|
152
|
+
type: "pairing.start";
|
|
153
|
+
}
|
|
154
|
+
export interface PairingStopMessage {
|
|
155
|
+
type: "pairing.stop";
|
|
156
|
+
}
|
|
157
|
+
export interface GrantRevokeMessage {
|
|
158
|
+
type: "grant.revoke";
|
|
159
|
+
grantId: string;
|
|
160
|
+
}
|
|
161
|
+
export interface GrantsRevokeAllMessage {
|
|
162
|
+
type: "grants.revokeAll";
|
|
163
|
+
}
|
|
164
|
+
export type TabMessage = HelloMessage | PairingStartMessage | PairingStopMessage | GrantRevokeMessage | GrantsRevokeAllMessage | PongMessage | ByeMessage | HttpResponseMessage | HttpResponseStartMessage | HttpResponseChunkMessage | HttpResponseEndMessage | TabErrorMessage;
|
|
165
|
+
/** Result of parsing one frame from the tab. */
|
|
166
|
+
export type ParseResult = {
|
|
167
|
+
ok: true;
|
|
168
|
+
message: TabMessage;
|
|
169
|
+
}
|
|
170
|
+
/** A well-formed message of a type this relay does not know. */
|
|
171
|
+
| {
|
|
172
|
+
ok: true;
|
|
173
|
+
unknown: true;
|
|
174
|
+
type: string;
|
|
175
|
+
id?: string;
|
|
176
|
+
} | {
|
|
177
|
+
ok: false;
|
|
178
|
+
error: string;
|
|
179
|
+
id?: string;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Parse and validate one text frame from the tab. Hand-written on purpose: the
|
|
183
|
+
* relay has no schema library (DEC-014).
|
|
184
|
+
*/
|
|
185
|
+
export declare function parseTabMessage(text: string): ParseResult;
|
package/ratelimit.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory token buckets keyed by IP, address or host (threat model A8).
|
|
3
|
+
* Bounded: when full, the least recently created bucket is dropped, which at
|
|
4
|
+
* worst gives that key a fresh, full bucket.
|
|
5
|
+
*/
|
|
6
|
+
export interface RateLimitOptions {
|
|
7
|
+
/** Burst size: how many requests a fresh key may make at once. */
|
|
8
|
+
capacity: number;
|
|
9
|
+
/** Tokens added per second. */
|
|
10
|
+
refillPerSecond: number;
|
|
11
|
+
/** Most keys kept in memory. */
|
|
12
|
+
maxKeys?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare class RateLimiter {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(options: RateLimitOptions);
|
|
17
|
+
/** Take one token for `key`; false when the bucket is empty. */
|
|
18
|
+
take(key: string, now?: number): boolean;
|
|
19
|
+
/** Drop buckets that are full again (nothing to remember). */
|
|
20
|
+
sweep(now?: number): void;
|
|
21
|
+
}
|
package/relay.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type RelayConfig, type StoreKind } from "./config.js";
|
|
2
|
+
import { type Logger } from "./log.js";
|
|
3
|
+
import { type AuthorizationTestMode } from "./oauth/server.js";
|
|
4
|
+
import type { TokenStore } from "./store/types.js";
|
|
5
|
+
export declare const VERSION: string;
|
|
6
|
+
export interface CreateRelayOptions extends Omit<Partial<RelayConfig>, "store"> {
|
|
7
|
+
/**
|
|
8
|
+
* The token store: a `TokenStore` instance (e.g. Prisma, Redis), or the name
|
|
9
|
+
* of a built-in one (`"sqlite"`, the default, at `sqlitePath`; `"memory"`).
|
|
10
|
+
*/
|
|
11
|
+
store?: StoreKind | TokenStore;
|
|
12
|
+
/** Logger; defaults to JSON lines on stdout at `logLevel`. */
|
|
13
|
+
logger?: Logger;
|
|
14
|
+
}
|
|
15
|
+
export interface Relay {
|
|
16
|
+
/** The external origin (issuer and address base). */
|
|
17
|
+
readonly url: string;
|
|
18
|
+
/** The port the server listens on. */
|
|
19
|
+
readonly port: number;
|
|
20
|
+
readonly config: RelayConfig;
|
|
21
|
+
/** The MCP address a page with this secret gets. */
|
|
22
|
+
addressFor(secret: string): string;
|
|
23
|
+
/** Number of connected tab sessions. */
|
|
24
|
+
readonly sessionCount: number;
|
|
25
|
+
/** Close all sessions (bye `shutdown`, close 4004) and stop the server. */
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Test-only relaxations, never reachable from configuration or the package's
|
|
30
|
+
* public API (`createTestRelay` is not exported from the package): CIMD
|
|
31
|
+
* fetches from loopback test servers, shorter code lifetimes.
|
|
32
|
+
*/
|
|
33
|
+
export interface RelayTestHooks extends AuthorizationTestMode {
|
|
34
|
+
pairingCodeTtlMs?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Start a relay. Returns once the server listens. */
|
|
37
|
+
export declare function createRelay(options?: CreateRelayOptions): Relay;
|
|
38
|
+
/**
|
|
39
|
+
* A relay with test hooks. Only for this repository's tests: it refuses to
|
|
40
|
+
* start outside development and logs a warning.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createTestRelay(options: CreateRelayOptions, hooks: RelayTestHooks): Relay;
|
package/session.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { RelayConfig, Site, SiteLimits } from "./config.js";
|
|
2
|
+
import { Exchange, type RpcId } from "./exchange.js";
|
|
3
|
+
import type { Logger } from "./log.js";
|
|
4
|
+
import { PairingCodes } from "./oauth/pairing.js";
|
|
5
|
+
import { type ByeReason, type GrantRef, type Limits, type RelayMessage } from "./protocol.js";
|
|
6
|
+
import type { TokenStore } from "./store/types.js";
|
|
7
|
+
/** The part of Bun's `ServerWebSocket` a session uses (a fake in unit tests). */
|
|
8
|
+
export interface SocketLike {
|
|
9
|
+
/** Bytes sent, -1 when queued under backpressure, 0 when dropped. */
|
|
10
|
+
send(data: string): number;
|
|
11
|
+
close(code?: number, reason?: string): void;
|
|
12
|
+
}
|
|
13
|
+
/** What a session needs from its relay. */
|
|
14
|
+
export interface SessionHost {
|
|
15
|
+
readonly config: RelayConfig;
|
|
16
|
+
readonly logger: Logger;
|
|
17
|
+
readonly store: TokenStore;
|
|
18
|
+
/** The external origin, e.g. `https://relay.ractive.ch`. */
|
|
19
|
+
publicUrl(): string;
|
|
20
|
+
/** Register a session for its address; returns the session it replaced. */
|
|
21
|
+
claimAddress(session: TabSession): TabSession | undefined;
|
|
22
|
+
/** Called once when a session is over. */
|
|
23
|
+
released(session: TabSession): void;
|
|
24
|
+
/** The tab pressed Disconnect for one app; false if the grant is not this address's. */
|
|
25
|
+
revokeGrant(session: TabSession, grantId: string): Promise<boolean>;
|
|
26
|
+
/** The tab revokes every grant of its address ("New address"). */
|
|
27
|
+
revokeAll(session: TabSession): Promise<void>;
|
|
28
|
+
/** Pairing code lifetime (tests shorten it). */
|
|
29
|
+
readonly pairingCodeTtlMs?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface ForwardInput {
|
|
32
|
+
method: string;
|
|
33
|
+
headers: Record<string, string>;
|
|
34
|
+
body: string;
|
|
35
|
+
rpcId: RpcId | undefined;
|
|
36
|
+
isListen: boolean;
|
|
37
|
+
grant: GrantRef;
|
|
38
|
+
extraHeaders: Record<string, string>;
|
|
39
|
+
/** Called once with the finished exchange (for the request log line). */
|
|
40
|
+
onDone: (exchange: Exchange) => void;
|
|
41
|
+
}
|
|
42
|
+
/** The limits announced to the tab (protocol: `session.limits`). */
|
|
43
|
+
export declare function limitsOf(limits: SiteLimits | RelayConfig): Limits;
|
|
44
|
+
/** One tab's WebSocket session, from open to close. */
|
|
45
|
+
export declare class TabSession {
|
|
46
|
+
#private;
|
|
47
|
+
readonly sessionId: string;
|
|
48
|
+
readonly ip: string;
|
|
49
|
+
readonly origin: string;
|
|
50
|
+
/** The site this session's origin belongs to (DEC-045). */
|
|
51
|
+
readonly site: Site;
|
|
52
|
+
readonly openedAt: number;
|
|
53
|
+
/** Set once `hello` was accepted. */
|
|
54
|
+
addressId: string | undefined;
|
|
55
|
+
/** The app name `/authorize` shows: the site's name, else the page's claim. */
|
|
56
|
+
appName: string | undefined;
|
|
57
|
+
/** Pairing codes while the tab's dialog is open (DEC-028). */
|
|
58
|
+
readonly codes: PairingCodes;
|
|
59
|
+
readonly pending: Map<string, Exchange>;
|
|
60
|
+
constructor(ws: SocketLike, ip: string, origin: string, site: Site, host: SessionHost);
|
|
61
|
+
get ready(): boolean;
|
|
62
|
+
/** The limits of this session (its site's). */
|
|
63
|
+
get limits(): SiteLimits;
|
|
64
|
+
/** Send one message; false if Bun dropped it under backpressure (DEC-021). */
|
|
65
|
+
send(message: RelayMessage): boolean;
|
|
66
|
+
/** A frame arrived from the tab. */
|
|
67
|
+
handleFrame(data: string | Uint8Array): void;
|
|
68
|
+
/** Tunnel one MCP request to the tab. Resolves with the AI app's response. */
|
|
69
|
+
forward(input: ForwardInput): Exchange;
|
|
70
|
+
/** End the session: optional `bye`, then close the socket. Idempotent. */
|
|
71
|
+
end(code: number, reason: string, bye?: ByeReason): void;
|
|
72
|
+
/** The socket closed (from Bun's `close` handler). */
|
|
73
|
+
closed(code: number): void;
|
|
74
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TokenStore } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The conformance suite every `TokenStore` must pass (DEC-041). Call it from a
|
|
4
|
+
* `bun test` file with a factory that returns a fresh, empty store:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { tokenStoreConformance } from "@ractive-ch/tab-mcp/testing";
|
|
8
|
+
* tokenStoreConformance("RedisTokenStore", async () => new RedisTokenStore(…));
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export declare function tokenStoreConformance(name: string, factory: () => TokenStore | Promise<TokenStore>): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GrantSummary, RevokeOptions, RevokeSelector, TokenRecord, TokenStore } from "./types.js";
|
|
2
|
+
/** Usable at `now`: unexpired, and for refresh tokens not yet used. */
|
|
3
|
+
export declare function isLive(record: TokenRecord, now: number): boolean;
|
|
4
|
+
/** Group live records into grant summaries, oldest grant first. */
|
|
5
|
+
export declare function summarizeGrants(records: Iterable<TokenRecord>, now: number): GrantSummary[];
|
|
6
|
+
/** Tokens in process memory; everything is lost on restart. */
|
|
7
|
+
export declare class MemoryTokenStore implements TokenStore {
|
|
8
|
+
#private;
|
|
9
|
+
put(record: TokenRecord): Promise<boolean>;
|
|
10
|
+
get(hash: string): Promise<TokenRecord | undefined>;
|
|
11
|
+
markUsed(hash: string, now: number): Promise<boolean>;
|
|
12
|
+
revoke(selector: RevokeSelector, options: RevokeOptions): Promise<number>;
|
|
13
|
+
listGrants(addressId: string, now: number): Promise<GrantSummary[]>;
|
|
14
|
+
deleteExpired(now: number): Promise<number>;
|
|
15
|
+
close(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GrantSummary, RevokeOptions, RevokeSelector, TokenRecord, TokenStore } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tokens in a SQLite file via `bun:sqlite` (the default store, DEC-030). Uses
|
|
4
|
+
* WAL mode so another process (or a test) can read and write the same file.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SqliteTokenStore implements TokenStore {
|
|
7
|
+
#private;
|
|
8
|
+
/** `path` is a file path, or `:memory:`. */
|
|
9
|
+
constructor(path: string);
|
|
10
|
+
put(r: TokenRecord): Promise<boolean>;
|
|
11
|
+
get(hash: string): Promise<TokenRecord | undefined>;
|
|
12
|
+
markUsed(hash: string, now: number): Promise<boolean>;
|
|
13
|
+
revoke(selector: RevokeSelector, options: RevokeOptions): Promise<number>;
|
|
14
|
+
listGrants(addressId: string, now: number): Promise<GrantSummary[]>;
|
|
15
|
+
deleteExpired(now: number): Promise<number>;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|