@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.
Files changed (63) hide show
  1. package/README.md +17 -2
  2. package/package.json +6 -3
  3. package/src/agents/agent-adapter.ts +3 -1
  4. package/src/cli.ts +114 -4
  5. package/src/client/daemon-client.ts +10 -0
  6. package/src/clients.ts +82 -0
  7. package/src/collect-redirect-uris.ts +16 -0
  8. package/src/daemon/build-fleet-events.ts +9 -0
  9. package/src/daemon/build-message-trail-entry.ts +54 -0
  10. package/src/daemon/build-report-trail-entry.ts +25 -0
  11. package/src/daemon/build-session-message-event.ts +0 -1
  12. package/src/daemon/daemon.ts +72 -11
  13. package/src/daemon/sessions.ts +13 -1
  14. package/src/daemon/start-headless-run.ts +3 -8
  15. package/src/daemon/start-headless-turn.ts +15 -3
  16. package/src/daemon/truncate-summary.ts +9 -0
  17. package/src/grants.ts +51 -0
  18. package/src/mcp/answer-authorize-request.ts +98 -0
  19. package/src/mcp/answer-consent-request.ts +166 -0
  20. package/src/mcp/answer-login-request.ts +108 -0
  21. package/src/mcp/answer-mcp-request.ts +161 -0
  22. package/src/mcp/answer-rpc-request.ts +137 -0
  23. package/src/mcp/approval-state.ts +155 -0
  24. package/src/mcp/build-consent-binding.ts +13 -0
  25. package/src/mcp/build-owner-plugin.ts +122 -0
  26. package/src/mcp/build-page-response.ts +29 -0
  27. package/src/mcp/build-tool-list.ts +21 -0
  28. package/src/mcp/collect-clients.ts +31 -0
  29. package/src/mcp/collect-grants.ts +55 -0
  30. package/src/mcp/collect-json-strings.ts +17 -0
  31. package/src/mcp/derive-token-hash.ts +9 -0
  32. package/src/mcp/find-client-name.ts +19 -0
  33. package/src/mcp/find-owner-session-id.ts +18 -0
  34. package/src/mcp/is-allowed-redirect-uri.ts +21 -0
  35. package/src/mcp/is-loopback-host.ts +13 -0
  36. package/src/mcp/is-supported-protocol-version.ts +13 -0
  37. package/src/mcp/mcp-tools.ts +278 -0
  38. package/src/mcp/mint-approval-code.ts +13 -0
  39. package/src/mcp/normalize-approval-code.ts +8 -0
  40. package/src/mcp/normalize-public-url.ts +33 -0
  41. package/src/mcp/open-mcp-auth.ts +145 -0
  42. package/src/mcp/pick-error-message.ts +22 -0
  43. package/src/mcp/pick-protocol-version.ts +9 -0
  44. package/src/mcp/reconnecting-caller.ts +121 -0
  45. package/src/mcp/remove-client.ts +36 -0
  46. package/src/mcp/render-consent-page.ts +65 -0
  47. package/src/mcp/render-login-page.ts +47 -0
  48. package/src/mcp/revoke-grant.ts +28 -0
  49. package/src/mcp/run-tool.ts +143 -0
  50. package/src/mcp/start-mcp-http-server.ts +330 -0
  51. package/src/mcp/to-html-text.ts +12 -0
  52. package/src/mcp/types.ts +78 -0
  53. package/src/mcp/verify-oauth-query.ts +42 -0
  54. package/src/mcp-http-server.ts +75 -0
  55. package/src/mcp-server.ts +22 -450
  56. package/src/parse-port.ts +18 -0
  57. package/src/shared/collect-mcp-http-config.ts +46 -0
  58. package/src/shared/config.ts +3 -1
  59. package/src/shared/grant-scope.ts +8 -0
  60. package/src/shared/load-mcp-http-config.ts +20 -0
  61. package/src/shared/normalize-client-name.ts +28 -0
  62. package/src/store/state-store.ts +65 -4
  63. 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 result as the summary) or needs_you (errors, turn limits,
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(truncateSummary(message.result));
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 records the live handle on the session's runtime. Returns false
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: (summary) => {
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
+ }