@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
|
@@ -6,6 +6,7 @@ import { isRecord } from '../shared/report';
|
|
|
6
6
|
import type { SessionID } from '../shared/session-id';
|
|
7
7
|
import { buildHeadlessEnv } from './build-headless-env';
|
|
8
8
|
import { resolveHeadlessExecutable } from './resolve-headless-executable';
|
|
9
|
+
import { truncateSummary } from './truncate-summary';
|
|
9
10
|
|
|
10
11
|
const PERMISSION_MODES = [
|
|
11
12
|
'default',
|
|
@@ -40,7 +41,7 @@ interface HeadlessRunHandle {
|
|
|
40
41
|
/**
|
|
41
42
|
* Runs one headless Agent SDK turn over a session, rendering its structured
|
|
42
43
|
* messages as plain lines into the session's output pipe. The run ends in
|
|
43
|
-
* done (with the
|
|
44
|
+
* done (with the turn's whole result) or needs_you (errors, turn limits,
|
|
44
45
|
* anything a human must look at).
|
|
45
46
|
*/
|
|
46
47
|
export function startHeadlessRun(
|
|
@@ -89,7 +90,7 @@ export function startHeadlessRun(
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
if (message.subtype === 'success') {
|
|
92
|
-
hooks.onDone(
|
|
93
|
+
hooks.onDone(message.result);
|
|
93
94
|
continue;
|
|
94
95
|
}
|
|
95
96
|
|
|
@@ -170,9 +171,3 @@ export function renderSdkMessage(message: RenderableMessage): string | null {
|
|
|
170
171
|
|
|
171
172
|
return null;
|
|
172
173
|
}
|
|
173
|
-
|
|
174
|
-
function truncateSummary(text: string): string {
|
|
175
|
-
const flat = text.replaceAll('\n', ' ');
|
|
176
|
-
|
|
177
|
-
return flat.length <= 200 ? flat : `${flat.slice(0, 199)}…`;
|
|
178
|
-
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import type { AdapterEvent } from '../agents/agent-adapter';
|
|
2
|
+
import { truncateDetail } from '../agents/truncate-detail';
|
|
1
3
|
import type { SessionID } from '../shared/session-id';
|
|
2
4
|
import type { SessionRuntime } from './session-runtime';
|
|
3
5
|
import type { SessionManager } from './sessions';
|
|
6
|
+
import { truncateSummary } from './truncate-summary';
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Starts one headless turn for a session: finds its adapter's headless
|
|
7
10
|
* runner, wires the run's output and completion into the session manager,
|
|
8
|
-
* and
|
|
11
|
+
* passes the turn's start and end to the event trail the way a terminal
|
|
12
|
+
* turn's hooks do, and records the live handle on the session's runtime. Returns false
|
|
9
13
|
* without starting anything when the session is unknown, its agent has no
|
|
10
14
|
* headless runner, or a turn is already running.
|
|
11
15
|
*/
|
|
@@ -14,6 +18,7 @@ export function startHeadlessTurn(
|
|
|
14
18
|
findRuntime: (sessionID: SessionID) => SessionRuntime | undefined,
|
|
15
19
|
sessionID: SessionID,
|
|
16
20
|
prompt: string,
|
|
21
|
+
recordTurnEvent: (sessionID: SessionID, ev: Readonly<AdapterEvent>) => void,
|
|
17
22
|
): boolean {
|
|
18
23
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
19
24
|
const runner = s === undefined ? null : (mgr.findAdapter(s.agent)?.headlessRunner ?? null);
|
|
@@ -25,6 +30,8 @@ export function startHeadlessTurn(
|
|
|
25
30
|
|
|
26
31
|
mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
|
|
27
32
|
|
|
33
|
+
recordTurnEvent(sessionID, { kind: 'prompt-submitted', detail: truncateDetail(prompt) });
|
|
34
|
+
|
|
28
35
|
const handle = runner(
|
|
29
36
|
{
|
|
30
37
|
cwd: s.cwd,
|
|
@@ -37,17 +44,22 @@ export function startHeadlessTurn(
|
|
|
37
44
|
onOutput: (text) => {
|
|
38
45
|
mgr.onOutput(s, text);
|
|
39
46
|
},
|
|
40
|
-
onDone: (
|
|
47
|
+
onDone: (result) => {
|
|
41
48
|
runtime.headlessRun = null;
|
|
42
49
|
|
|
50
|
+
const summary = truncateSummary(result);
|
|
43
51
|
const doneMsg = summary === '' ? 'headless turn done' : summary;
|
|
44
52
|
|
|
45
|
-
mgr.updateSurfaceState(sessionID, 'done', doneMsg);
|
|
53
|
+
mgr.updateSurfaceState(sessionID, 'done', doneMsg, result);
|
|
54
|
+
|
|
55
|
+
recordTurnEvent(sessionID, { kind: 'turn-done', detail: truncateDetail(result) });
|
|
46
56
|
},
|
|
47
57
|
onNeedsYou: (msg) => {
|
|
48
58
|
runtime.headlessRun = null;
|
|
49
59
|
|
|
50
60
|
mgr.updateSurfaceState(sessionID, 'needs_you', msg);
|
|
61
|
+
|
|
62
|
+
recordTurnEvent(sessionID, { kind: 'needs-input', message: msg, detail: msg });
|
|
51
63
|
},
|
|
52
64
|
},
|
|
53
65
|
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flattens text to one line and caps it at 200 characters, ellipsizing
|
|
3
|
+
* anything longer, for a session's one-line status.
|
|
4
|
+
*/
|
|
5
|
+
export function truncateSummary(text: string): string {
|
|
6
|
+
const flat = text.replaceAll('\n', ' ');
|
|
7
|
+
|
|
8
|
+
return flat.length <= 200 ? flat : `${flat.slice(0, 199)}…`;
|
|
9
|
+
}
|
package/src/grants.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { collectGrants } from './mcp/collect-grants';
|
|
3
|
+
import { openMCPAuth } from './mcp/open-mcp-auth';
|
|
4
|
+
import { revokeGrant } from './mcp/revoke-grant';
|
|
5
|
+
import { mcpAuthDBFile, stateDir } from './shared/config';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Runs `atc grants`: lists the grants clients of `atc mcp --http` hold, or
|
|
9
|
+
* revokes one. A revoked grant's tokens stop working at once, and its client
|
|
10
|
+
* has to go through approval again. It opens the authorization server's
|
|
11
|
+
* database directly, so it works whether or not the server is running.
|
|
12
|
+
*/
|
|
13
|
+
export async function runGrants(revoke: string | null): Promise<void> {
|
|
14
|
+
mkdirSync(stateDir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
const store = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
if (revoke !== null) {
|
|
20
|
+
const revoked = await revokeGrant(store.db, revoke);
|
|
21
|
+
|
|
22
|
+
if (!revoked) {
|
|
23
|
+
console.error(`atc grants: no grant has the ID '${revoke}'`);
|
|
24
|
+
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(`Revoked grant ${revoke}`);
|
|
31
|
+
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const grants = await collectGrants(store.db);
|
|
36
|
+
|
|
37
|
+
if (grants.length === 0) {
|
|
38
|
+
console.log('No grants.');
|
|
39
|
+
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const grant of grants) {
|
|
44
|
+
console.log(
|
|
45
|
+
`${grant.grantID} ${grant.clientName} ${grant.scopes.join(',')} last used ${grant.lastUsedAt ?? 'never'}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
await store.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { normalizeClientName } from '../shared/normalize-client-name';
|
|
2
|
+
import { buildPageResponse } from './build-page-response';
|
|
3
|
+
import { findClientName } from './find-client-name';
|
|
4
|
+
import type { HTTPServerContext } from './types';
|
|
5
|
+
|
|
6
|
+
// Where an authorization request came from, as the HTTP request shows it.
|
|
7
|
+
interface Requester {
|
|
8
|
+
readonly socketAddress: string | null;
|
|
9
|
+
readonly connectingIP: string | null;
|
|
10
|
+
readonly userAgent: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Answers `GET /oauth2/authorize`. Before better-auth validates the request,
|
|
15
|
+
* atc sets three parameters of its own:
|
|
16
|
+
*
|
|
17
|
+
* - `prompt=login consent`, so every authorization asks for a fresh approval
|
|
18
|
+
* code and shows the consent page, whatever session or consent a browser
|
|
19
|
+
* kept from before.
|
|
20
|
+
* - `offline_access` in a requested scope, so every grant gets a refresh token.
|
|
21
|
+
* - `resource=<origin>/mcp` when the client gave none, so every token is bound
|
|
22
|
+
* to the one resource atc serves.
|
|
23
|
+
*
|
|
24
|
+
* A request better-auth sends on to the login page gets a pending approval,
|
|
25
|
+
* and its code prints in atc's terminal with the client's name, the host it
|
|
26
|
+
* returns to, and who asked: the `CF-Connecting-IP` address when the request
|
|
27
|
+
* carries one, labelled as reported since anyone can send that header, else
|
|
28
|
+
* the socket's peer address, plus the start of the user agent.
|
|
29
|
+
*/
|
|
30
|
+
export async function answerAuthorizeRequest(
|
|
31
|
+
ctx: HTTPServerContext,
|
|
32
|
+
url: URL,
|
|
33
|
+
requester: Requester,
|
|
34
|
+
): Promise<Response> {
|
|
35
|
+
const params = new URLSearchParams(url.searchParams);
|
|
36
|
+
|
|
37
|
+
params.set('prompt', 'login consent');
|
|
38
|
+
|
|
39
|
+
const scope = params.get('scope');
|
|
40
|
+
|
|
41
|
+
if (scope !== null) {
|
|
42
|
+
const scopes = scope.split(' ').filter((item) => item !== '');
|
|
43
|
+
|
|
44
|
+
params.set('scope', [...new Set([...scopes, 'offline_access'])].join(' '));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!params.has('resource')) {
|
|
48
|
+
params.set('resource', ctx.resource);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const response = await ctx.store.auth.handler(
|
|
52
|
+
new Request(`${ctx.origin}/oauth2/authorize?${params.toString()}`, {
|
|
53
|
+
headers: { accept: 'text/html' },
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const location = response.headers.get('location');
|
|
58
|
+
const target = location === null ? null : new URL(location, ctx.origin);
|
|
59
|
+
|
|
60
|
+
if (target === null || target.origin !== ctx.origin || target.pathname !== '/login') {
|
|
61
|
+
return response;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const clientID = target.searchParams.get('client_id') ?? '';
|
|
65
|
+
const redirectURI = target.searchParams.get('redirect_uri') ?? '';
|
|
66
|
+
|
|
67
|
+
const storedName = await findClientName(ctx.store.db, clientID);
|
|
68
|
+
|
|
69
|
+
const clientName = normalizeClientName(storedName);
|
|
70
|
+
|
|
71
|
+
const approval = ctx.approvals.createPending({
|
|
72
|
+
key: target.search.slice(1),
|
|
73
|
+
clientID,
|
|
74
|
+
clientName,
|
|
75
|
+
redirectURI,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (approval === null) {
|
|
79
|
+
return buildPageResponse(429, {
|
|
80
|
+
message: 'Too many approvals started in the last minute. Wait a minute, then start again.',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const code = `${approval.approvalCode.slice(0, 4)}-${approval.approvalCode.slice(4)}`;
|
|
85
|
+
|
|
86
|
+
const from =
|
|
87
|
+
requester.connectingIP === null
|
|
88
|
+
? normalizeClientName(requester.socketAddress, 'an unknown address', 45)
|
|
89
|
+
: `${normalizeClientName(requester.connectingIP, 'an empty address', 45)} (reported by CF-Connecting-IP)`;
|
|
90
|
+
|
|
91
|
+
const userAgent = normalizeClientName(requester.userAgent, 'none', 60);
|
|
92
|
+
|
|
93
|
+
ctx.printApproval(
|
|
94
|
+
`Approve ${clientName} (returns to ${new URL(redirectURI).host}) with code ${code}. Requested from ${from}, user agent "${userAgent}". The code expires in 10 minutes.`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return response;
|
|
98
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { GRANT_SCOPES } from '../shared/grant-scope';
|
|
2
|
+
import { normalizeClientName } from '../shared/normalize-client-name';
|
|
3
|
+
import { isRecord } from '../shared/report';
|
|
4
|
+
import { buildConsentBinding } from './build-consent-binding';
|
|
5
|
+
import { buildPageResponse } from './build-page-response';
|
|
6
|
+
import { findClientName } from './find-client-name';
|
|
7
|
+
import { findOwnerSessionID } from './find-owner-session-id';
|
|
8
|
+
import { renderConsentPage } from './render-consent-page';
|
|
9
|
+
import type { HTTPServerContext } from './types';
|
|
10
|
+
import { verifyOAuthQuery } from './verify-oauth-query';
|
|
11
|
+
|
|
12
|
+
interface ConsentRequest {
|
|
13
|
+
readonly method: 'GET' | 'POST';
|
|
14
|
+
readonly url: URL;
|
|
15
|
+
readonly cookie: string | null;
|
|
16
|
+
readonly body: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// How long an authorization code stays exchangeable.
|
|
20
|
+
const CODE_MS = 600_000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Answers `GET /consent` and `POST /consent`. Both refuse a request unless
|
|
24
|
+
* its signed query is valid, its prompt no longer asks for a login, and its
|
|
25
|
+
* owner session is the one an approval code signed in for this same request.
|
|
26
|
+
* The page lists the scopes the client requested, with only `read` ticked to
|
|
27
|
+
* start. The form goes to better-auth's consent endpoint with the ticked
|
|
28
|
+
* scopes plus `offline_access`; better-auth refuses any scope the client did
|
|
29
|
+
* not request. Allowing nothing denies the request. Either way the browser
|
|
30
|
+
* returns to the client with its owner session cookie cleared.
|
|
31
|
+
*
|
|
32
|
+
* One login answers one consent: the answer ends the session's binding, so
|
|
33
|
+
* it can approve nothing else. An answer that issues no authorization code
|
|
34
|
+
* deletes the session outright. One that issues a code leaves the session
|
|
35
|
+
* only until the code expires, since the code exchange needs it, and the
|
|
36
|
+
* exchange deletes it.
|
|
37
|
+
*/
|
|
38
|
+
export async function answerConsentRequest(
|
|
39
|
+
ctx: HTTPServerContext,
|
|
40
|
+
request: ConsentRequest,
|
|
41
|
+
): Promise<Response> {
|
|
42
|
+
const form = new URLSearchParams(request.body);
|
|
43
|
+
|
|
44
|
+
const oauthQuery =
|
|
45
|
+
request.method === 'GET' ? request.url.search.slice(1) : (form.get('oauth_query') ?? '');
|
|
46
|
+
|
|
47
|
+
const sessionID = await verifyConsentRequest(ctx, oauthQuery, request.cookie);
|
|
48
|
+
|
|
49
|
+
if (sessionID === null) {
|
|
50
|
+
return buildPageResponse(400, {
|
|
51
|
+
message: 'This approval expired or was already used. Start again from the client.',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (request.method === 'GET') {
|
|
56
|
+
const params = new URLSearchParams(oauthQuery);
|
|
57
|
+
|
|
58
|
+
const redirectURI = params.get('redirect_uri') ?? '';
|
|
59
|
+
const requested = (params.get('scope') ?? '').split(' ');
|
|
60
|
+
|
|
61
|
+
const storedName = await findClientName(ctx.store.db, params.get('client_id') ?? '');
|
|
62
|
+
|
|
63
|
+
const page = renderConsentPage({
|
|
64
|
+
oauthQuery,
|
|
65
|
+
clientName: normalizeClientName(storedName),
|
|
66
|
+
redirectURI,
|
|
67
|
+
scopes: GRANT_SCOPES.filter((scope) => requested.includes(scope)),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// The form's answer redirects to the client, which the page's form-action
|
|
71
|
+
// policy has to allow.
|
|
72
|
+
const formTargets = URL.canParse(redirectURI) ? [new URL(redirectURI).origin] : [];
|
|
73
|
+
|
|
74
|
+
return buildPageResponse(200, { page }, formTargets);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
ctx.approvals.removeApproved(sessionID);
|
|
78
|
+
|
|
79
|
+
const ticked = form.getAll('scope');
|
|
80
|
+
const scopes = GRANT_SCOPES.filter((scope) => ticked.includes(scope));
|
|
81
|
+
const accept = form.get('decision') === 'approve' && scopes.length > 0;
|
|
82
|
+
|
|
83
|
+
const choice = JSON.stringify({
|
|
84
|
+
accept,
|
|
85
|
+
...(accept ? { scope: [...scopes, 'offline_access'].join(' ') } : {}),
|
|
86
|
+
oauth_query: oauthQuery,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const forwarded = new Request(`${ctx.origin}/oauth2/consent`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
'content-type': 'application/json',
|
|
93
|
+
accept: 'application/json',
|
|
94
|
+
origin: ctx.origin,
|
|
95
|
+
...(request.cookie === null ? {} : { cookie: request.cookie }),
|
|
96
|
+
},
|
|
97
|
+
body: choice,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const consented = await ctx.store.auth.handler(forwarded);
|
|
101
|
+
const answer: unknown = await consented.json();
|
|
102
|
+
|
|
103
|
+
const next = isRecord(answer) ? (answer['url'] ?? answer['redirect_uri']) : undefined;
|
|
104
|
+
|
|
105
|
+
const issuedCode =
|
|
106
|
+
consented.ok && typeof next === 'string' && new URL(next).searchParams.has('code');
|
|
107
|
+
|
|
108
|
+
await (issuedCode
|
|
109
|
+
? ctx.store.db
|
|
110
|
+
.updateTable('session')
|
|
111
|
+
.set({ expiresAt: new Date(Date.now() + CODE_MS).toISOString() })
|
|
112
|
+
.where('id', '=', sessionID)
|
|
113
|
+
.execute()
|
|
114
|
+
: ctx.store.db.deleteFrom('session').where('id', '=', sessionID).execute());
|
|
115
|
+
|
|
116
|
+
if (!consented.ok || typeof next !== 'string') {
|
|
117
|
+
return buildPageResponse(400, {
|
|
118
|
+
message: 'atc could not record this choice. Start again from the client.',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const authContext = await ctx.store.auth.$context;
|
|
123
|
+
|
|
124
|
+
const sessionCookie = authContext.authCookies.sessionToken;
|
|
125
|
+
const secure = sessionCookie.attributes.secure === true ? '; Secure' : '';
|
|
126
|
+
|
|
127
|
+
return new Response(null, {
|
|
128
|
+
status: 302,
|
|
129
|
+
headers: {
|
|
130
|
+
location: next,
|
|
131
|
+
'set-cookie': `${sessionCookie.name}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${secure}`,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The owner session id when the request may reach consent, else null. A
|
|
137
|
+
// query still asking for a login is one better-auth signed for the login
|
|
138
|
+
// page, which the approval code never carried past.
|
|
139
|
+
async function verifyConsentRequest(
|
|
140
|
+
ctx: HTTPServerContext,
|
|
141
|
+
oauthQuery: string,
|
|
142
|
+
cookie: string | null,
|
|
143
|
+
): Promise<string | null> {
|
|
144
|
+
const authContext = await ctx.store.auth.$context;
|
|
145
|
+
|
|
146
|
+
if (!(await verifyOAuthQuery(oauthQuery, authContext.secret))) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const prompt = (new URLSearchParams(oauthQuery).get('prompt') ?? '').split(' ');
|
|
151
|
+
|
|
152
|
+
if (prompt.includes('login')) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sessionID = await findOwnerSessionID(ctx, cookie);
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
sessionID === null ||
|
|
160
|
+
ctx.approvals.findApproved(sessionID) !== buildConsentBinding(oauthQuery)
|
|
161
|
+
) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return sessionID;
|
|
166
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
import { buildConsentBinding } from './build-consent-binding';
|
|
3
|
+
import { buildPageResponse } from './build-page-response';
|
|
4
|
+
import { findOwnerSessionID } from './find-owner-session-id';
|
|
5
|
+
import { renderLoginPage } from './render-login-page';
|
|
6
|
+
import type { HTTPServerContext } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Answers `GET /login` and `POST /login`, the approval code page better-auth
|
|
10
|
+
* sends a browser to. The page exists only for an authorization request that
|
|
11
|
+
* holds a pending approval. The right code signs the operator in, binds the
|
|
12
|
+
* new owner session to this one request, and better-auth carries the request
|
|
13
|
+
* on to the consent page. A wrong code shows the page again, and the fifth
|
|
14
|
+
* wrong code ends the request.
|
|
15
|
+
*/
|
|
16
|
+
export async function answerLoginRequest(
|
|
17
|
+
ctx: HTTPServerContext,
|
|
18
|
+
method: 'GET' | 'POST',
|
|
19
|
+
url: URL,
|
|
20
|
+
body: string,
|
|
21
|
+
): Promise<Response> {
|
|
22
|
+
const form = new URLSearchParams(body);
|
|
23
|
+
|
|
24
|
+
const oauthQuery = method === 'GET' ? url.search.slice(1) : (form.get('oauth_query') ?? '');
|
|
25
|
+
const approval = ctx.approvals.findPending(oauthQuery);
|
|
26
|
+
|
|
27
|
+
if (approval === null) {
|
|
28
|
+
return buildPageResponse(400, {
|
|
29
|
+
message: 'This approval expired or was already used. Start again from the client.',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const view = {
|
|
34
|
+
oauthQuery,
|
|
35
|
+
clientName: approval.clientName,
|
|
36
|
+
redirectURI: approval.redirectURI,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (method === 'GET') {
|
|
40
|
+
return buildPageResponse(200, { page: renderLoginPage({ ...view, error: null }) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const verdict = ctx.approvals.verifyApprovalCode(oauthQuery, form.get('code') ?? '');
|
|
44
|
+
|
|
45
|
+
if (verdict === 'locked') {
|
|
46
|
+
return buildPageResponse(400, {
|
|
47
|
+
message: 'Too many wrong approval codes. Start again from the client.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (verdict === 'wrong') {
|
|
52
|
+
return buildPageResponse(400, {
|
|
53
|
+
page: renderLoginPage({
|
|
54
|
+
...view,
|
|
55
|
+
error: 'That approval code is wrong. Check the terminal running atc mcp --http.',
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resuming the authorization reads the request better-auth was called with.
|
|
61
|
+
const apiHeaders = new Headers({
|
|
62
|
+
accept: 'application/json',
|
|
63
|
+
'content-type': 'application/json',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const signedIn = await ctx.store.auth.api.signInOwner({
|
|
67
|
+
body: { oauth_query: oauthQuery },
|
|
68
|
+
headers: apiHeaders,
|
|
69
|
+
request: new Request(`${ctx.origin}/atc/sign-in-owner`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: apiHeaders,
|
|
72
|
+
}),
|
|
73
|
+
asResponse: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const resumed: unknown = await signedIn.json();
|
|
77
|
+
|
|
78
|
+
const next = isRecord(resumed) ? resumed['url'] : undefined;
|
|
79
|
+
|
|
80
|
+
if (!signedIn.ok || typeof next !== 'string') {
|
|
81
|
+
return buildPageResponse(400, {
|
|
82
|
+
message: 'atc could not continue this approval. Start again from the client.',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const setCookies = signedIn.headers.getSetCookie();
|
|
87
|
+
|
|
88
|
+
const sessionID = await findOwnerSessionID(
|
|
89
|
+
ctx,
|
|
90
|
+
setCookies.map((line) => line.split(';')[0]).join('; '),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (sessionID === null) {
|
|
94
|
+
return buildPageResponse(400, {
|
|
95
|
+
message: 'atc could not continue this approval. Start again from the client.',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ctx.approvals.recordApproved(sessionID, buildConsentBinding(oauthQuery));
|
|
100
|
+
|
|
101
|
+
const headers = new Headers({ location: new URL(next, ctx.origin).href });
|
|
102
|
+
|
|
103
|
+
for (const cookie of setCookies) {
|
|
104
|
+
headers.append('set-cookie', cookie);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return new Response(null, { status: 302, headers });
|
|
108
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { isAPIError } from 'better-auth/api';
|
|
2
|
+
import { GRANT_SCOPES } from '../shared/grant-scope';
|
|
3
|
+
import type { GrantScope } from '../shared/grant-scope';
|
|
4
|
+
import { normalizeClientName } from '../shared/normalize-client-name';
|
|
5
|
+
import { isRecord } from '../shared/report';
|
|
6
|
+
import { answerRPCRequest } from './answer-rpc-request';
|
|
7
|
+
import { deriveTokenHash } from './derive-token-hash';
|
|
8
|
+
import { findClientName } from './find-client-name';
|
|
9
|
+
import { isSupportedProtocolVersion } from './is-supported-protocol-version';
|
|
10
|
+
import type { HTTPServerContext } from './types';
|
|
11
|
+
|
|
12
|
+
interface MCPHTTPRequest {
|
|
13
|
+
readonly authorization: string | null;
|
|
14
|
+
readonly protocolVersion: string | null;
|
|
15
|
+
readonly body: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Answers `POST /mcp`: one JSON-RPC message from a client holding an access
|
|
20
|
+
* token. Every request checks its token against the authorization server's
|
|
21
|
+
* database, so a revoked grant loses access at once, and a token bound to any
|
|
22
|
+
* other resource is refused. A tool call outside the token's scopes is a 403
|
|
23
|
+
* that names the missing scope. A message the client sends is always from the
|
|
24
|
+
* client's own name.
|
|
25
|
+
*/
|
|
26
|
+
export async function answerMCPRequest(
|
|
27
|
+
ctx: HTTPServerContext,
|
|
28
|
+
request: MCPHTTPRequest,
|
|
29
|
+
): Promise<Response> {
|
|
30
|
+
if (request.protocolVersion !== null && !isSupportedProtocolVersion(request.protocolVersion)) {
|
|
31
|
+
return new Response(null, { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const metadataURL = `${ctx.origin}/.well-known/oauth-protected-resource/mcp`;
|
|
35
|
+
const token = /^Bearer (?<token>\S+)$/i.exec(request.authorization ?? '')?.groups?.['token'];
|
|
36
|
+
|
|
37
|
+
if (token === undefined) {
|
|
38
|
+
return new Response(null, {
|
|
39
|
+
status: 401,
|
|
40
|
+
headers: { 'www-authenticate': `Bearer resource_metadata="${metadataURL}"` },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const access = await verifyAccessToken(ctx, token);
|
|
45
|
+
|
|
46
|
+
if (access === null) {
|
|
47
|
+
return new Response(null, {
|
|
48
|
+
status: 401,
|
|
49
|
+
headers: {
|
|
50
|
+
'www-authenticate': `Bearer error="invalid_token", resource_metadata="${metadataURL}"`,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let message: unknown;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
message = JSON.parse(request.body);
|
|
59
|
+
} catch {
|
|
60
|
+
return buildRPCError(400, null, -32_700, 'parse error');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(message)) {
|
|
64
|
+
return buildRPCError(400, null, -32_600, 'atc does not accept JSON-RPC batches');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const outcome = await answerRPCRequest(message, {
|
|
68
|
+
caller: ctx.caller,
|
|
69
|
+
build: ctx.build,
|
|
70
|
+
toolContext: { callerSessionID: null, sender: { kind: 'fixed', name: access.clientName } },
|
|
71
|
+
scopes: access.scopes,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (outcome.kind === 'reply') {
|
|
75
|
+
return Response.json(outcome.body);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (outcome.kind === 'accepted') {
|
|
79
|
+
return new Response(null, { status: 202 });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (outcome.kind === 'forbidden') {
|
|
83
|
+
const id = isRecord(message) ? (message['id'] ?? null) : null;
|
|
84
|
+
const response = buildRPCError(403, id, -32_001, `this grant lacks the ${outcome.scope} scope`);
|
|
85
|
+
|
|
86
|
+
response.headers.set(
|
|
87
|
+
'www-authenticate',
|
|
88
|
+
`Bearer error="insufficient_scope", scope="${outcome.scope}", resource_metadata="${metadataURL}"`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return buildRPCError(400, null, -32_600, 'invalid request');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface VerifiedAccess {
|
|
98
|
+
readonly clientName: string;
|
|
99
|
+
readonly scopes: readonly GrantScope[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// An inactive, unknown, or revoked token, or one bound to another resource,
|
|
103
|
+
// verifies to null. Verifying stamps when the token's grant was last used.
|
|
104
|
+
async function verifyAccessToken(
|
|
105
|
+
ctx: HTTPServerContext,
|
|
106
|
+
token: string,
|
|
107
|
+
): Promise<VerifiedAccess | null> {
|
|
108
|
+
let payload: unknown;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
payload = await ctx.store.auth.api.verifyMCPAccessToken({ body: { token } });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (isAPIError(error)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!isRecord(payload) || ![payload['aud']].flat().includes(ctx.resource)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const granted = typeof payload['scope'] === 'string' ? payload['scope'].split(' ') : [];
|
|
125
|
+
const clientID = typeof payload['client_id'] === 'string' ? payload['client_id'] : '';
|
|
126
|
+
|
|
127
|
+
await upsertGrantUse(ctx, token);
|
|
128
|
+
|
|
129
|
+
const storedName = await findClientName(ctx.store.db, clientID);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
clientName: normalizeClientName(storedName, 'remote'),
|
|
133
|
+
scopes: GRANT_SCOPES.filter((scope) => granted.includes(scope)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function upsertGrantUse(ctx: HTTPServerContext, token: string): Promise<void> {
|
|
138
|
+
const row = await ctx.store.db
|
|
139
|
+
.selectFrom('oauthAccessToken')
|
|
140
|
+
.select('authorizationCodeId')
|
|
141
|
+
.where('token', '=', deriveTokenHash(token))
|
|
142
|
+
.executeTakeFirst();
|
|
143
|
+
|
|
144
|
+
const grantID = row?.authorizationCodeId ?? null;
|
|
145
|
+
|
|
146
|
+
if (grantID === null) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const now = new Date().toISOString();
|
|
151
|
+
|
|
152
|
+
await ctx.store.db
|
|
153
|
+
.insertInto('atc_grant_use')
|
|
154
|
+
.values({ grant_id: grantID, last_used_at: now })
|
|
155
|
+
.onConflict((conflict) => conflict.column('grant_id').doUpdateSet({ last_used_at: now }))
|
|
156
|
+
.execute();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildRPCError(status: number, id: unknown, code: number, message: string): Response {
|
|
160
|
+
return Response.json({ jsonrpc: '2.0', id, error: { code, message } }, { status });
|
|
161
|
+
}
|