@zgeoff/atc 2.8.1 → 2.9.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/README.md +17 -2
- package/package.json +6 -3
- package/src/agents/agent-adapter.ts +3 -1
- package/src/cli.ts +114 -4
- package/src/client/daemon-client.ts +10 -0
- package/src/clients.ts +82 -0
- package/src/collect-redirect-uris.ts +16 -0
- package/src/daemon/build-fleet-events.ts +9 -0
- package/src/daemon/build-message-trail-entry.ts +54 -0
- package/src/daemon/build-report-trail-entry.ts +25 -0
- package/src/daemon/build-session-message-event.ts +0 -1
- package/src/daemon/daemon.ts +72 -11
- package/src/daemon/sessions.ts +13 -1
- package/src/daemon/start-headless-run.ts +3 -8
- package/src/daemon/start-headless-turn.ts +15 -3
- package/src/daemon/truncate-summary.ts +9 -0
- package/src/grants.ts +51 -0
- package/src/mcp/answer-authorize-request.ts +98 -0
- package/src/mcp/answer-consent-request.ts +166 -0
- package/src/mcp/answer-login-request.ts +108 -0
- package/src/mcp/answer-mcp-request.ts +161 -0
- package/src/mcp/answer-rpc-request.ts +137 -0
- package/src/mcp/approval-state.ts +155 -0
- package/src/mcp/build-consent-binding.ts +13 -0
- package/src/mcp/build-owner-plugin.ts +122 -0
- package/src/mcp/build-page-response.ts +29 -0
- package/src/mcp/build-tool-list.ts +21 -0
- package/src/mcp/collect-clients.ts +31 -0
- package/src/mcp/collect-grants.ts +55 -0
- package/src/mcp/collect-json-strings.ts +17 -0
- package/src/mcp/derive-token-hash.ts +9 -0
- package/src/mcp/find-client-name.ts +19 -0
- package/src/mcp/find-owner-session-id.ts +18 -0
- package/src/mcp/is-allowed-redirect-uri.ts +21 -0
- package/src/mcp/is-loopback-host.ts +13 -0
- package/src/mcp/is-supported-protocol-version.ts +13 -0
- package/src/mcp/mcp-tools.ts +278 -0
- package/src/mcp/mint-approval-code.ts +13 -0
- package/src/mcp/normalize-approval-code.ts +8 -0
- package/src/mcp/normalize-public-url.ts +33 -0
- package/src/mcp/open-mcp-auth.ts +145 -0
- package/src/mcp/pick-error-message.ts +22 -0
- package/src/mcp/pick-protocol-version.ts +9 -0
- package/src/mcp/reconnecting-caller.ts +121 -0
- package/src/mcp/remove-client.ts +36 -0
- package/src/mcp/render-consent-page.ts +65 -0
- package/src/mcp/render-login-page.ts +47 -0
- package/src/mcp/revoke-grant.ts +28 -0
- package/src/mcp/run-tool.ts +143 -0
- package/src/mcp/start-mcp-http-server.ts +330 -0
- package/src/mcp/to-html-text.ts +12 -0
- package/src/mcp/types.ts +78 -0
- package/src/mcp/verify-oauth-query.ts +42 -0
- package/src/mcp-http-server.ts +75 -0
- package/src/mcp-server.ts +22 -450
- package/src/parse-port.ts +18 -0
- package/src/shared/collect-mcp-http-config.ts +46 -0
- package/src/shared/config.ts +3 -1
- package/src/shared/grant-scope.ts +8 -0
- package/src/shared/load-mcp-http-config.ts +20 -0
- package/src/shared/normalize-client-name.ts +28 -0
- package/src/store/state-store.ts +65 -4
- package/src/store/trail-entry.ts +27 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { GrantScope } from '../shared/grant-scope';
|
|
2
|
+
import { toHTMLText } from './to-html-text';
|
|
3
|
+
|
|
4
|
+
interface ConsentPageView {
|
|
5
|
+
// The signed authorization query the page posts back.
|
|
6
|
+
readonly oauthQuery: string;
|
|
7
|
+
readonly clientName: string;
|
|
8
|
+
readonly redirectURI: string;
|
|
9
|
+
readonly scopes: readonly GrantScope[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SCOPE_LABELS: Readonly<Record<GrantScope, string>> = {
|
|
13
|
+
read: 'Read sessions, transcripts, and events',
|
|
14
|
+
message:
|
|
15
|
+
'Send instructions to your agents, which can run commands on this machine; also rename, pin, and acknowledge sessions',
|
|
16
|
+
spawn: 'Spawn sessions and type into them',
|
|
17
|
+
kill: 'Kill sessions',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Only reading starts granted; every scope that lets the client act starts
|
|
21
|
+
// withheld.
|
|
22
|
+
const GRANTED_BY_DEFAULT: ReadonlySet<GrantScope> = new Set<GrantScope>(['read']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The page where the operator picks what a client may do: one checkbox per
|
|
26
|
+
* scope it requested, with only `read` ticked to start. Every dynamic value is
|
|
27
|
+
* escaped.
|
|
28
|
+
*/
|
|
29
|
+
export function renderConsentPage(view: ConsentPageView): string {
|
|
30
|
+
const scopeRows = view.scopes
|
|
31
|
+
.map((scope) => {
|
|
32
|
+
const checked = GRANTED_BY_DEFAULT.has(scope) ? ' checked' : '';
|
|
33
|
+
|
|
34
|
+
return `<label><input type="checkbox" name="scope" value="${scope}"${checked}> ${toHTMLText(SCOPE_LABELS[scope])}</label>`;
|
|
35
|
+
})
|
|
36
|
+
.join('\n');
|
|
37
|
+
|
|
38
|
+
return `<!doctype html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="utf-8">
|
|
42
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
43
|
+
<title>Choose what to allow</title>
|
|
44
|
+
<style>
|
|
45
|
+
body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
|
|
46
|
+
label { display: block; margin: 0.25rem 0; }
|
|
47
|
+
code { word-break: break-all; }
|
|
48
|
+
button { font: inherit; margin-right: 0.5rem; }
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<h1>Choose what to allow</h1>
|
|
53
|
+
<p><strong>${toHTMLText(view.clientName)}</strong> returns to <code>${toHTMLText(view.redirectURI)}</code> with the access you allow here.</p>
|
|
54
|
+
<form method="post" action="/consent">
|
|
55
|
+
<input type="hidden" name="oauth_query" value="${toHTMLText(view.oauthQuery)}">
|
|
56
|
+
<fieldset>
|
|
57
|
+
<legend>Allow it to</legend>
|
|
58
|
+
${scopeRows}
|
|
59
|
+
</fieldset>
|
|
60
|
+
<p><button type="submit" name="decision" value="approve">Allow</button><button type="submit" name="decision" value="deny">Deny</button></p>
|
|
61
|
+
</form>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { toHTMLText } from './to-html-text';
|
|
2
|
+
|
|
3
|
+
interface LoginPageView {
|
|
4
|
+
// The signed authorization query the page posts back.
|
|
5
|
+
readonly oauthQuery: string;
|
|
6
|
+
readonly clientName: string;
|
|
7
|
+
readonly redirectURI: string;
|
|
8
|
+
readonly error: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The page where the operator proves they run atc: the client's name, the
|
|
13
|
+
* full redirect URI, and a field for the approval code atc printed in its
|
|
14
|
+
* terminal. Every dynamic value is escaped.
|
|
15
|
+
*/
|
|
16
|
+
export function renderLoginPage(view: LoginPageView): string {
|
|
17
|
+
const error = view.error === null ? '' : `<p class="error">${toHTMLText(view.error)}</p>`;
|
|
18
|
+
|
|
19
|
+
return `<!doctype html>
|
|
20
|
+
<html lang="en">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8">
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
24
|
+
<title>Approve access to atc</title>
|
|
25
|
+
<style>
|
|
26
|
+
body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
|
|
27
|
+
input[name="code"] { font: inherit; font-family: ui-monospace, monospace; letter-spacing: 0.1em; padding: 0.25rem; }
|
|
28
|
+
.error { color: #b00020; }
|
|
29
|
+
code { word-break: break-all; }
|
|
30
|
+
button { font: inherit; }
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<h1>Approve access to atc</h1>
|
|
35
|
+
<p><strong>${toHTMLText(view.clientName)}</strong> wants access to your atc sessions.</p>
|
|
36
|
+
<p>After approval it returns to <code>${toHTMLText(view.redirectURI)}</code>.</p>
|
|
37
|
+
<p>Type the approval code that <code>atc mcp --http</code> printed in its terminal.</p>
|
|
38
|
+
${error}
|
|
39
|
+
<form method="post" action="/login">
|
|
40
|
+
<input type="hidden" name="oauth_query" value="${toHTMLText(view.oauthQuery)}">
|
|
41
|
+
<label>Approval code <input name="code" autocomplete="off" autofocus required></label>
|
|
42
|
+
<button type="submit">Continue</button>
|
|
43
|
+
</form>
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { MCPAuthSchema } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Revokes a grant: its access and refresh tokens go, along with the
|
|
6
|
+
* consent its client holds, so the client must go through approval again.
|
|
7
|
+
* Returns false for an unknown grant id.
|
|
8
|
+
*/
|
|
9
|
+
export function revokeGrant(db: Kysely<MCPAuthSchema>, grantID: string): Promise<boolean> {
|
|
10
|
+
return db.transaction().execute(async (trx) => {
|
|
11
|
+
const grant = await trx
|
|
12
|
+
.selectFrom('oauthRefreshToken')
|
|
13
|
+
.select('clientId')
|
|
14
|
+
.where('authorizationCodeId', '=', grantID)
|
|
15
|
+
.executeTakeFirst();
|
|
16
|
+
|
|
17
|
+
if (grant === undefined) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
await trx.deleteFrom('oauthAccessToken').where('authorizationCodeId', '=', grantID).execute();
|
|
22
|
+
await trx.deleteFrom('oauthRefreshToken').where('authorizationCodeId', '=', grantID).execute();
|
|
23
|
+
await trx.deleteFrom('oauthConsent').where('clientId', '=', grant.clientId).execute();
|
|
24
|
+
await trx.deleteFrom('atc_grant_use').where('grant_id', '=', grantID).execute();
|
|
25
|
+
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { match } from 'ts-pattern';
|
|
2
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
3
|
+
import type { FleetCaller, ToolContext } from './types';
|
|
4
|
+
|
|
5
|
+
export function runTool(
|
|
6
|
+
caller: FleetCaller,
|
|
7
|
+
name: string,
|
|
8
|
+
args: Readonly<Record<string, unknown>>,
|
|
9
|
+
ctx: ToolContext,
|
|
10
|
+
): Promise<string> {
|
|
11
|
+
return match(name)
|
|
12
|
+
.with('atc_session_list', async () => {
|
|
13
|
+
const ok = await caller.sendRequest('session.list');
|
|
14
|
+
|
|
15
|
+
return JSON.stringify(ok['sessions'], null, 2);
|
|
16
|
+
})
|
|
17
|
+
.with('atc_session_spawn', async () => {
|
|
18
|
+
const rawAgent = args['agent'];
|
|
19
|
+
const nested = args['detached'] !== true && ctx.callerSessionID !== null;
|
|
20
|
+
|
|
21
|
+
const params = {
|
|
22
|
+
cwd: args['cwd'],
|
|
23
|
+
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
24
|
+
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
25
|
+
...(rawAgent === undefined ? {} : { agent: rawAgent }),
|
|
26
|
+
cols: 100,
|
|
27
|
+
rows: 30,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ok =
|
|
31
|
+
nested && ctx.callerSessionID !== null
|
|
32
|
+
? await sendNestedSpawn(caller, params, ctx.callerSessionID)
|
|
33
|
+
: await caller.sendRequest('session.spawn', params);
|
|
34
|
+
|
|
35
|
+
return JSON.stringify(ok['session'], null, 2);
|
|
36
|
+
})
|
|
37
|
+
.with('atc_session_input', async () => {
|
|
38
|
+
await caller.sendRequest('session.input', {
|
|
39
|
+
session: args['session'],
|
|
40
|
+
d: `${typeof args['text'] === 'string' ? args['text'] : ''}\n`,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return 'sent';
|
|
44
|
+
})
|
|
45
|
+
.with('atc_session_screen', async () => {
|
|
46
|
+
const ok = await caller.sendRequest('session.screen', { session: args['session'] });
|
|
47
|
+
|
|
48
|
+
return typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok);
|
|
49
|
+
})
|
|
50
|
+
.with('atc_session_update', async () => {
|
|
51
|
+
await caller.sendRequest('session.update', {
|
|
52
|
+
session: args['session'],
|
|
53
|
+
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
54
|
+
...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return 'updated';
|
|
58
|
+
})
|
|
59
|
+
.with('atc_session_kill', async () => {
|
|
60
|
+
await caller.sendRequest('session.kill', { session: args['session'] });
|
|
61
|
+
|
|
62
|
+
return 'killed';
|
|
63
|
+
})
|
|
64
|
+
.with('atc_session_ack', async () => {
|
|
65
|
+
await caller.sendRequest('session.ack', { session: args['session'] });
|
|
66
|
+
|
|
67
|
+
return 'acked';
|
|
68
|
+
})
|
|
69
|
+
.with('atc_resume_command', async () => {
|
|
70
|
+
const ok = await caller.sendRequest('session.resumeCommand', { session: args['session'] });
|
|
71
|
+
|
|
72
|
+
return typeof ok['command'] === 'string' ? ok['command'] : JSON.stringify(ok);
|
|
73
|
+
})
|
|
74
|
+
.with('atc_dirs_list', async () => {
|
|
75
|
+
const ok = await caller.sendRequest('dirs.list');
|
|
76
|
+
|
|
77
|
+
return JSON.stringify(ok['dirs'], null, 2);
|
|
78
|
+
})
|
|
79
|
+
.with('atc_session_get', async () => {
|
|
80
|
+
const ok = await caller.sendRequest('session.get', { session: args['session'] });
|
|
81
|
+
|
|
82
|
+
return JSON.stringify(ok, null, 2);
|
|
83
|
+
})
|
|
84
|
+
.with('atc_session_read', async () => {
|
|
85
|
+
const ok = await caller.sendRequest('session.read', {
|
|
86
|
+
session: args['session'],
|
|
87
|
+
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
88
|
+
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return JSON.stringify(ok, null, 2);
|
|
92
|
+
})
|
|
93
|
+
.with('atc_events_read', async () => {
|
|
94
|
+
const ok = await caller.sendRequest('events.read', {
|
|
95
|
+
...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
|
|
96
|
+
...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
|
|
97
|
+
...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return JSON.stringify(ok, null, 2);
|
|
101
|
+
})
|
|
102
|
+
.with('atc_session_message', async () => {
|
|
103
|
+
const given = args['from'];
|
|
104
|
+
|
|
105
|
+
const from =
|
|
106
|
+
ctx.sender.kind === 'default' && typeof given === 'string' && given !== ''
|
|
107
|
+
? given
|
|
108
|
+
: ctx.sender.name;
|
|
109
|
+
|
|
110
|
+
const ok = await caller.sendRequest('session.message', {
|
|
111
|
+
session: args['session'],
|
|
112
|
+
text: args['text'],
|
|
113
|
+
from,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return JSON.stringify(ok, null, 2);
|
|
117
|
+
})
|
|
118
|
+
.with('atc_message_get', async () => {
|
|
119
|
+
const ok = await caller.sendRequest('message.get', { message: args['message'] });
|
|
120
|
+
|
|
121
|
+
return JSON.stringify(ok, null, 2);
|
|
122
|
+
})
|
|
123
|
+
.otherwise(() => Promise.reject(new Error(`unknown tool '${name}'`)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The inherited id can point at a session another daemon hosts, or one
|
|
127
|
+
// this daemon no longer lists; the spawn then lands top-level instead of
|
|
128
|
+
// failing the tool call.
|
|
129
|
+
async function sendNestedSpawn(
|
|
130
|
+
caller: FleetCaller,
|
|
131
|
+
params: Readonly<Record<string, unknown>>,
|
|
132
|
+
parent: string,
|
|
133
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
134
|
+
try {
|
|
135
|
+
return await caller.sendRequest('session.spawn', { ...params, parent });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error instanceof DaemonError && error.code === 'no_such_session') {
|
|
138
|
+
return caller.sendRequest('session.spawn', params);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { MAX_LINE } from '../protocol/protocol';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
3
|
+
import { answerAuthorizeRequest } from './answer-authorize-request';
|
|
4
|
+
import { answerConsentRequest } from './answer-consent-request';
|
|
5
|
+
import { answerLoginRequest } from './answer-login-request';
|
|
6
|
+
import { answerMCPRequest } from './answer-mcp-request';
|
|
7
|
+
import { ApprovalState } from './approval-state';
|
|
8
|
+
import { buildPageResponse } from './build-page-response';
|
|
9
|
+
import { isLoopbackHost } from './is-loopback-host';
|
|
10
|
+
import { normalizePublicURL } from './normalize-public-url';
|
|
11
|
+
import { openMCPAuth } from './open-mcp-auth';
|
|
12
|
+
import { pickErrorMessage } from './pick-error-message';
|
|
13
|
+
import type { FleetCaller, HTTPServerContext } from './types';
|
|
14
|
+
|
|
15
|
+
interface MCPHTTPServerOptions {
|
|
16
|
+
readonly caller: FleetCaller;
|
|
17
|
+
readonly build: string;
|
|
18
|
+
|
|
19
|
+
// The address to bind; 0 as the port binds a free one.
|
|
20
|
+
readonly host: string;
|
|
21
|
+
readonly port: number;
|
|
22
|
+
|
|
23
|
+
// The origin clients reach the server at; absent means the local address.
|
|
24
|
+
readonly publicURL: string | null;
|
|
25
|
+
|
|
26
|
+
// Further Host header values to accept, for a proxy that rewrites Host.
|
|
27
|
+
readonly allowedHosts: readonly string[];
|
|
28
|
+
|
|
29
|
+
// The authorization server's SQLite database.
|
|
30
|
+
readonly dbPath: string;
|
|
31
|
+
readonly printApproval: (line: string) => void;
|
|
32
|
+
|
|
33
|
+
// How long a rotated refresh token still answers with its successor.
|
|
34
|
+
readonly refreshReuseSeconds?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A running MCP HTTP server.
|
|
39
|
+
*/
|
|
40
|
+
export interface MCPHTTPServer {
|
|
41
|
+
// The local address the server listens on.
|
|
42
|
+
readonly url: string;
|
|
43
|
+
|
|
44
|
+
// The public origin: the OAuth issuer.
|
|
45
|
+
readonly origin: string;
|
|
46
|
+
readonly stop: () => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Serves atc's MCP tools over streamable HTTP at `/mcp`, with better-auth as
|
|
51
|
+
* the OAuth 2.1 authorization server in the same process. Only the routes a
|
|
52
|
+
* connector and the operator's browser need reach better-auth; every other
|
|
53
|
+
* path is a 404. A request whose Host header is not the server's own is
|
|
54
|
+
* refused, so a DNS rebinding page cannot reach it through a browser, and a
|
|
55
|
+
* browser form post from any other origin is refused too.
|
|
56
|
+
*/
|
|
57
|
+
export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise<MCPHTTPServer> {
|
|
58
|
+
// Normalized before binding, so an invalid public URL throws with no port
|
|
59
|
+
// left bound.
|
|
60
|
+
const publicOrigin = options.publicURL === null ? null : normalizePublicURL(options.publicURL);
|
|
61
|
+
|
|
62
|
+
// atc speaks plain HTTP, so a listener other machines can reach has to sit
|
|
63
|
+
// behind something that terminates TLS, and the public URL clients use is
|
|
64
|
+
// that https origin.
|
|
65
|
+
if (
|
|
66
|
+
!isLoopbackHost(options.host) &&
|
|
67
|
+
(publicOrigin === null || !publicOrigin.startsWith('https:'))
|
|
68
|
+
) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`listening on '${options.host}' reaches beyond this machine, so it needs an https public URL served by a TLS-terminating proxy or tunnel`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const holder: { ready: ServerState | null } = { ready: null };
|
|
75
|
+
|
|
76
|
+
const server = Bun.serve({
|
|
77
|
+
hostname: options.host,
|
|
78
|
+
port: options.port,
|
|
79
|
+
|
|
80
|
+
// A long poll holds a request open for up to 30 seconds without a byte.
|
|
81
|
+
idleTimeout: 60,
|
|
82
|
+
maxRequestBodySize: MAX_LINE,
|
|
83
|
+
fetch: async (request, bunServer) => {
|
|
84
|
+
const state = holder.ready;
|
|
85
|
+
|
|
86
|
+
if (state === null) {
|
|
87
|
+
return new Response(null, { status: 503 });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const answered = await answerHTTPRequest(
|
|
92
|
+
state,
|
|
93
|
+
request,
|
|
94
|
+
bunServer.requestIP(request)?.address ?? null,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return answered;
|
|
98
|
+
} catch {
|
|
99
|
+
return new Response(null, { status: 503 });
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const port = server.port ?? options.port;
|
|
105
|
+
const local = `http://127.0.0.1:${port}`;
|
|
106
|
+
const origin = publicOrigin ?? local;
|
|
107
|
+
let store: HTTPServerContext['store'];
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
store = await openMCPAuth({
|
|
111
|
+
dbPath: options.dbPath,
|
|
112
|
+
origin,
|
|
113
|
+
...(options.refreshReuseSeconds === undefined
|
|
114
|
+
? {}
|
|
115
|
+
: { refreshReuseSeconds: options.refreshReuseSeconds }),
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
await server.stop(true);
|
|
119
|
+
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
holder.ready = {
|
|
124
|
+
hosts: new Set([
|
|
125
|
+
new URL(origin).host,
|
|
126
|
+
`127.0.0.1:${port}`,
|
|
127
|
+
`localhost:${port}`,
|
|
128
|
+
...options.allowedHosts,
|
|
129
|
+
]),
|
|
130
|
+
origins: new Set([origin, local, `http://localhost:${port}`]),
|
|
131
|
+
ctx: {
|
|
132
|
+
caller: options.caller,
|
|
133
|
+
build: options.build,
|
|
134
|
+
origin,
|
|
135
|
+
resource: `${origin}/mcp`,
|
|
136
|
+
store,
|
|
137
|
+
approvals: new ApprovalState(600_000, () => Date.now()),
|
|
138
|
+
printApproval: options.printApproval,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
url: local,
|
|
144
|
+
origin,
|
|
145
|
+
stop: async () => {
|
|
146
|
+
await server.stop(true);
|
|
147
|
+
await store.close();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface ServerState {
|
|
153
|
+
readonly hosts: ReadonlySet<string>;
|
|
154
|
+
readonly origins: ReadonlySet<string>;
|
|
155
|
+
readonly ctx: HTTPServerContext;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The better-auth routes a connector calls directly.
|
|
159
|
+
const PASSED_THROUGH: ReadonlySet<string> = new Set([
|
|
160
|
+
'GET /.well-known/oauth-protected-resource',
|
|
161
|
+
'GET /.well-known/oauth-protected-resource/mcp',
|
|
162
|
+
'POST /oauth2/revoke',
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
// `socketAddress` is the peer the request arrived from: the requester, or the
|
|
166
|
+
// proxy or tunnel in front of atc.
|
|
167
|
+
async function answerHTTPRequest(
|
|
168
|
+
state: ServerState,
|
|
169
|
+
request: Request,
|
|
170
|
+
socketAddress: string | null,
|
|
171
|
+
): Promise<Response> {
|
|
172
|
+
const host = request.headers.get('host');
|
|
173
|
+
|
|
174
|
+
if (host === null || !state.hosts.has(host)) {
|
|
175
|
+
return new Response(null, { status: 403 });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const ctx = state.ctx;
|
|
179
|
+
|
|
180
|
+
const url = new URL(request.url);
|
|
181
|
+
|
|
182
|
+
const route = `${request.method} ${url.pathname}`;
|
|
183
|
+
const requestOrigin = request.headers.get('origin');
|
|
184
|
+
const isForeignOrigin = requestOrigin !== null && !state.origins.has(requestOrigin);
|
|
185
|
+
|
|
186
|
+
if (PASSED_THROUGH.has(route)) {
|
|
187
|
+
return ctx.store.auth.handler(toPublicRequest(ctx, request, url));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (route === 'GET /.well-known/oauth-authorization-server') {
|
|
191
|
+
return answerMetadataRequest(ctx, request, url);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (route === 'GET /oauth2/authorize') {
|
|
195
|
+
return answerAuthorizeRequest(ctx, url, {
|
|
196
|
+
socketAddress,
|
|
197
|
+
connectingIP: request.headers.get('cf-connecting-ip'),
|
|
198
|
+
userAgent: request.headers.get('user-agent'),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (route === 'POST /oauth2/token') {
|
|
203
|
+
return answerTokenRequest(ctx, request, url);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (route === 'GET /error') {
|
|
207
|
+
return buildPageResponse(400, { message: pickErrorMessage(url.searchParams.get('error')) });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const isPage = url.pathname === '/login' || url.pathname === '/consent';
|
|
211
|
+
|
|
212
|
+
if (isPage && request.method === 'POST' && (requestOrigin === null || isForeignOrigin)) {
|
|
213
|
+
return buildPageResponse(403, { message: 'The form came from an unexpected origin.' });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (isPage && (request.method === 'GET' || request.method === 'POST')) {
|
|
217
|
+
const body = request.method === 'POST' ? await request.text() : '';
|
|
218
|
+
|
|
219
|
+
return url.pathname === '/login'
|
|
220
|
+
? answerLoginRequest(ctx, request.method, url, body)
|
|
221
|
+
: answerConsentRequest(ctx, {
|
|
222
|
+
method: request.method,
|
|
223
|
+
url,
|
|
224
|
+
cookie: request.headers.get('cookie'),
|
|
225
|
+
body,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (url.pathname === '/mcp') {
|
|
230
|
+
if (isForeignOrigin) {
|
|
231
|
+
return new Response(null, { status: 403 });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (request.method !== 'POST') {
|
|
235
|
+
return new Response(null, { status: 405, headers: { allow: 'POST' } });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return answerMCPRequest(ctx, {
|
|
239
|
+
authorization: request.headers.get('authorization'),
|
|
240
|
+
protocolVersion: request.headers.get('mcp-protocol-version'),
|
|
241
|
+
body: await request.text(),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return new Response(null, { status: 404 });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// better-auth builds every URL it returns from its base URL, so a request
|
|
249
|
+
// that reached the local address is handed over as if it came to the public
|
|
250
|
+
// origin.
|
|
251
|
+
function toPublicRequest(ctx: HTTPServerContext, request: Request, url: URL): Request {
|
|
252
|
+
return new Request(`${ctx.origin}${url.pathname}${url.search}`, request);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The metadata advertises only what atc's clients can use: public clients
|
|
256
|
+
// with no client authentication, and no introspection endpoint.
|
|
257
|
+
async function answerMetadataRequest(
|
|
258
|
+
ctx: HTTPServerContext,
|
|
259
|
+
request: Request,
|
|
260
|
+
url: URL,
|
|
261
|
+
): Promise<Response> {
|
|
262
|
+
const response = await ctx.store.auth.handler(toPublicRequest(ctx, request, url));
|
|
263
|
+
const metadata: unknown = await response.json();
|
|
264
|
+
|
|
265
|
+
if (!isRecord(metadata)) {
|
|
266
|
+
return new Response(null, { status: 503 });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const advertised = Object.fromEntries(
|
|
270
|
+
Object.entries(metadata).filter(([key]) => !key.startsWith('introspection_')),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return Response.json({
|
|
274
|
+
...advertised,
|
|
275
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
276
|
+
revocation_endpoint_auth_methods_supported: ['none'],
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A code exchange binds its tokens to the owner session that approved it;
|
|
281
|
+
// once issued, every token is detached from its session, so a session's end
|
|
282
|
+
// never ends the grant, and the session, whose one authorization is done, is
|
|
283
|
+
// deleted.
|
|
284
|
+
async function answerTokenRequest(
|
|
285
|
+
ctx: HTTPServerContext,
|
|
286
|
+
request: Request,
|
|
287
|
+
url: URL,
|
|
288
|
+
): Promise<Response> {
|
|
289
|
+
const response = await ctx.store.auth.handler(toPublicRequest(ctx, request, url));
|
|
290
|
+
|
|
291
|
+
if (response.ok) {
|
|
292
|
+
const attached = await ctx.store.db
|
|
293
|
+
.selectFrom('oauthAccessToken')
|
|
294
|
+
.select('sessionId')
|
|
295
|
+
.where('sessionId', 'is not', null)
|
|
296
|
+
.union(
|
|
297
|
+
ctx.store.db
|
|
298
|
+
.selectFrom('oauthRefreshToken')
|
|
299
|
+
.select('sessionId')
|
|
300
|
+
.where('sessionId', 'is not', null),
|
|
301
|
+
)
|
|
302
|
+
.execute();
|
|
303
|
+
|
|
304
|
+
const sessionIDs = attached.flatMap((row) => (row.sessionId === null ? [] : [row.sessionId]));
|
|
305
|
+
|
|
306
|
+
await ctx.store.db
|
|
307
|
+
.updateTable('oauthAccessToken')
|
|
308
|
+
.set({ sessionId: null })
|
|
309
|
+
.where('sessionId', 'is not', null)
|
|
310
|
+
.execute();
|
|
311
|
+
|
|
312
|
+
await ctx.store.db
|
|
313
|
+
.updateTable('oauthRefreshToken')
|
|
314
|
+
.set({ sessionId: null })
|
|
315
|
+
.where('sessionId', 'is not', null)
|
|
316
|
+
.execute();
|
|
317
|
+
|
|
318
|
+
await ctx.store.db
|
|
319
|
+
.deleteFrom('session')
|
|
320
|
+
.where((eb) =>
|
|
321
|
+
eb.or([
|
|
322
|
+
eb('expiresAt', '<', new Date().toISOString()),
|
|
323
|
+
...(sessionIDs.length === 0 ? [] : [eb('id', 'in', sessionIDs)]),
|
|
324
|
+
]),
|
|
325
|
+
)
|
|
326
|
+
.execute();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return response;
|
|
330
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escapes text for safe placement in HTML element content or a quoted
|
|
3
|
+
* attribute value.
|
|
4
|
+
*/
|
|
5
|
+
export function toHTMLText(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.replaceAll('&', '&')
|
|
8
|
+
.replaceAll('<', '<')
|
|
9
|
+
.replaceAll('>', '>')
|
|
10
|
+
.replaceAll('"', '"')
|
|
11
|
+
.replaceAll("'", ''');
|
|
12
|
+
}
|