supergravity-mcp 0.1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/antigravity-client.js +404 -0
- package/dist/mcp-server.js +62 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rice
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# supergravity-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that lets Claude Code (or any MCP client) delegate tasks to the
|
|
4
|
+
**Google Antigravity** desktop app — by driving its real, already-logged-in UI.
|
|
5
|
+
No reverse engineering, no extracted credentials, no unofficial API calls.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
Antigravity ships a CLI (`agy`) for scripting it headlessly — but on older
|
|
10
|
+
Intel Macs, that CLI isn't available even though the desktop **app** runs fine
|
|
11
|
+
(confirmed working on a 2015 MacBook Pro, macOS Monterey, Intel). This project
|
|
12
|
+
is the missing bridge for anyone in that situation: it automates the app the
|
|
13
|
+
same way a human would use it, so it works anywhere the app itself works.
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
1. Antigravity is relaunched with Chrome's remote-debugging flag
|
|
18
|
+
(`--remote-debugging-port`), the same mechanism browser test tools like
|
|
19
|
+
Playwright use to drive Electron apps.
|
|
20
|
+
2. A script connects to that debug port and: types your task into the chat
|
|
21
|
+
box (via a simulated paste event — Antigravity's input is a rich-text
|
|
22
|
+
editor, not a plain textarea, so it needs a real paste, not just poking
|
|
23
|
+
text into the DOM), clicks send, and reads back the reply once it's done
|
|
24
|
+
streaming.
|
|
25
|
+
3. Everything happens through your own existing login. This tool never
|
|
26
|
+
touches Antigravity's stored credentials, internal code, or private APIs.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install
|
|
32
|
+
npm run build
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Antigravity must be installed and already signed in. By default the tool
|
|
36
|
+
looks for it at `/Applications/Antigravity.app` or `~/Desktop/Antigravity.app`;
|
|
37
|
+
set `ANTIGRAVITY_APP_PATH` if it lives somewhere else.
|
|
38
|
+
|
|
39
|
+
Register it with Claude Code:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude mcp add supergravity -- node /absolute/path/to/dist/mcp-server.js
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## The tools
|
|
46
|
+
|
|
47
|
+
### `delegate_to_antigravity(task, model?, timeoutMs?)`
|
|
48
|
+
|
|
49
|
+
- `task` — what to ask Antigravity to do.
|
|
50
|
+
- `model` — optional. One of the models in Antigravity's own selector
|
|
51
|
+
(see `AVAILABLE_MODELS` in `src/antigravity-client.ts`).
|
|
52
|
+
- `timeoutMs` — optional, default 120000 (2 minutes).
|
|
53
|
+
|
|
54
|
+
### `get_antigravity_quota()`
|
|
55
|
+
|
|
56
|
+
Reads Antigravity's Settings > Models panel and returns remaining quota —
|
|
57
|
+
Gemini models and Claude+GPT models draw from **separate pools**, each with a
|
|
58
|
+
weekly limit and a 5-hour limit. Useful to check before delegating, or to
|
|
59
|
+
explain a `send button is disabled` error (usually means that pool is
|
|
60
|
+
exhausted, not that the model is unavailable).
|
|
61
|
+
|
|
62
|
+
### Picking a model
|
|
63
|
+
|
|
64
|
+
Antigravity doesn't show per-message token cost, only pooled quota %. The
|
|
65
|
+
`model` parameter's description carries a rough cost/capability cheat sheet
|
|
66
|
+
(`MODEL_GUIDE` in `src/antigravity-client.ts`) built from third-party API
|
|
67
|
+
list prices and public benchmarks, researched mid-2026 — newer than this
|
|
68
|
+
tool's own training data, so treat it as a starting point, not gospel:
|
|
69
|
+
|
|
70
|
+
| Model | ~Price (in/out per 1M tokens) | Known for |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| Gemini 3.5 Flash | $1.50 / $9 | Fastest, cheapest frontier-tier option; strong coding/agentic benchmarks |
|
|
73
|
+
| Gemini 3.1 Pro | $2 / $12 | "Thinking" mode for harder reasoning (ARC-AGI-2 ~77%) |
|
|
74
|
+
| Claude Sonnet 4.6 | $3 / $15 | Coding quality, computer-use, agent workflows (SWE-bench ~80%) |
|
|
75
|
+
| Claude Opus 4.6 | $5 / $25 | Strongest complex/ambiguous reasoning; legal/financial/research-grade analysis |
|
|
76
|
+
| GPT-OSS 120B | ~$0.03–0.09 / $0.10–0.40 | Open-weight, near-o4-mini reasoning, far cheaper than the rest |
|
|
77
|
+
|
|
78
|
+
These are what the underlying models cost via their own APIs elsewhere —
|
|
79
|
+
Antigravity itself doesn't bill you per token, it draws down the pooled
|
|
80
|
+
quota above instead.
|
|
81
|
+
|
|
82
|
+
## Known limitations
|
|
83
|
+
|
|
84
|
+
- **A model's quota can run out.** If a non-default model (e.g. Claude Opus)
|
|
85
|
+
has hit its usage limit, Antigravity's send button stays disabled after
|
|
86
|
+
selecting it, even though it's still pickable in the dropdown. The tool
|
|
87
|
+
fails fast with a clear error in that case instead of hanging until
|
|
88
|
+
timeout — just retry later or pick a different model.
|
|
89
|
+
- **UI automation, not an API.** If Google changes Antigravity's screen
|
|
90
|
+
layout, the CSS selectors this relies on (`data-testid="send-button"`, the
|
|
91
|
+
message input's `aria-label`, etc.) may need updating. That's the tradeoff
|
|
92
|
+
for staying entirely inside Antigravity's terms of use — see below.
|
|
93
|
+
- **Reply extraction is a text heuristic**, not a structured read. It looks
|
|
94
|
+
for the "Thought for Ns" marker Antigravity renders before its answer and
|
|
95
|
+
takes what follows. Works reliably in testing but is the most likely thing
|
|
96
|
+
to break on a UI redesign.
|
|
97
|
+
- **Single-window assumption.** Only tested with one Antigravity window open.
|
|
98
|
+
|
|
99
|
+
## What this deliberately does NOT do
|
|
100
|
+
|
|
101
|
+
No unpacking of Antigravity's `app.asar`, no reading of stored auth tokens,
|
|
102
|
+
no calling of internal/undocumented endpoints. Everything here operates
|
|
103
|
+
through the same interface a human uses, which is why it doesn't carry the
|
|
104
|
+
legal risk that reverse-engineering approaches do.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { execSync, spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const CDP_PORT = Number(process.env.ANTIGRAVITY_CDP_PORT || 9222);
|
|
6
|
+
// All of these are selectable in Antigravity's model dropdown. If a model's
|
|
7
|
+
// quota is exhausted, the send button stays disabled after selecting it —
|
|
8
|
+
// selectModel() / pasteAndSend() fail fast with a clear error in that case
|
|
9
|
+
// rather than hanging until timeout. Retry later or pick a different model.
|
|
10
|
+
export const AVAILABLE_MODELS = [
|
|
11
|
+
"Gemini 3.5 Flash (Low)",
|
|
12
|
+
"Gemini 3.5 Flash (Medium)",
|
|
13
|
+
"Gemini 3.5 Flash (High)",
|
|
14
|
+
"Gemini 3.1 Pro (Low)",
|
|
15
|
+
"Gemini 3.1 Pro (High)",
|
|
16
|
+
"Claude Sonnet 4.6 (Thinking)",
|
|
17
|
+
"Claude Opus 4.6 (Thinking)",
|
|
18
|
+
"GPT-OSS 120B (Medium)",
|
|
19
|
+
];
|
|
20
|
+
// Antigravity itself bills via a pooled weekly/5-hour quota, not per token —
|
|
21
|
+
// see getAntigravityQuota(). These are third-party API list prices (per 1M
|
|
22
|
+
// tokens, researched mid-2026, newer than this tool author's model training
|
|
23
|
+
// data) included only as a relative cost/capability signal for routing
|
|
24
|
+
// decisions, not what you're actually billed inside Antigravity. Verify
|
|
25
|
+
// independently before using this for real budgeting.
|
|
26
|
+
export const MODEL_GUIDE = `
|
|
27
|
+
Gemini 3.5 Flash ($1.50/$9 per 1M in/out) — fastest and cheapest of the group; strong coding/agentic benchmarks. Good default for quick, well-scoped tasks.
|
|
28
|
+
Gemini 3.1 Pro ($2/$12) — adds a "thinking" mode for harder reasoning (ARC-AGI-2 ~77%); costlier for long (>200K token) context.
|
|
29
|
+
Claude Sonnet 4.6 ($3/$15) — best-regarded for coding quality, computer-use, and multi-step agent workflows (SWE-bench ~80%).
|
|
30
|
+
Claude Opus 4.6 ($5/$25, higher in fast mode) — strongest at complex, ambiguous, multi-step reasoning; the pick for legal/financial/research-grade analysis.
|
|
31
|
+
GPT-OSS 120B (~$0.03-0.09/$0.10-0.40 — far cheaper than the rest) — open-weight, near-o4-mini reasoning, solid coding/tool-use. Good for high-volume or low-stakes tasks.
|
|
32
|
+
|
|
33
|
+
Gemini models and Claude+GPT models draw from SEPARATE Antigravity quota pools — call getAntigravityQuota() first if unsure which pool has room.
|
|
34
|
+
`.trim();
|
|
35
|
+
export async function delegateToAntigravity(opts) {
|
|
36
|
+
const { task, model, timeoutMs = 120_000 } = opts;
|
|
37
|
+
await ensureAntigravityReady();
|
|
38
|
+
const target = await getMainPageTarget();
|
|
39
|
+
const session = new CdpSession(target.webSocketDebuggerUrl);
|
|
40
|
+
try {
|
|
41
|
+
await startNewConversation(session);
|
|
42
|
+
if (model)
|
|
43
|
+
await selectModel(session, model);
|
|
44
|
+
await pasteAndSend(session, task);
|
|
45
|
+
return await waitForReply(session, task, timeoutMs);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
session.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function getAntigravityQuota() {
|
|
52
|
+
await ensureAntigravityReady();
|
|
53
|
+
const target = await getMainPageTarget();
|
|
54
|
+
const session = new CdpSession(target.webSocketDebuggerUrl);
|
|
55
|
+
try {
|
|
56
|
+
const raw = await openSettingsModelsTab(session);
|
|
57
|
+
return parseQuotaText(raw);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await closeSettings(session);
|
|
61
|
+
session.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function openSettingsModelsTab(session) {
|
|
65
|
+
await session.evaluate(`
|
|
66
|
+
(() => {
|
|
67
|
+
const b = Array.from(document.querySelectorAll('button')).find(x => x.innerText && x.innerText.trim() === 'Settings');
|
|
68
|
+
if (b) b.click();
|
|
69
|
+
return true;
|
|
70
|
+
})()
|
|
71
|
+
`);
|
|
72
|
+
await sleep(1000);
|
|
73
|
+
await session.evaluate(`
|
|
74
|
+
(() => {
|
|
75
|
+
const b = Array.from(document.querySelectorAll('button, [role="tab"]')).find(x => (x.innerText||'').trim() === 'Models');
|
|
76
|
+
if (b) b.click();
|
|
77
|
+
return true;
|
|
78
|
+
})()
|
|
79
|
+
`);
|
|
80
|
+
await sleep(800);
|
|
81
|
+
const raw = await session.evaluate(`
|
|
82
|
+
(() => {
|
|
83
|
+
const all = Array.from(document.querySelectorAll('*'));
|
|
84
|
+
const hit = all.find(el => el.innerText && el.innerText.includes('Model Quota') && el.innerText.includes('Weekly Limit'));
|
|
85
|
+
return hit ? hit.innerText : null;
|
|
86
|
+
})()
|
|
87
|
+
`);
|
|
88
|
+
if (!raw)
|
|
89
|
+
throw new Error("Could not find the Model Quota panel in Antigravity's Settings.");
|
|
90
|
+
return raw;
|
|
91
|
+
}
|
|
92
|
+
async function closeSettings(session) {
|
|
93
|
+
await session.evaluate(`
|
|
94
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true }))
|
|
95
|
+
`);
|
|
96
|
+
await sleep(400);
|
|
97
|
+
}
|
|
98
|
+
function parseQuotaText(raw) {
|
|
99
|
+
const lines = raw
|
|
100
|
+
.split("\n")
|
|
101
|
+
.map((l) => l.trim())
|
|
102
|
+
.filter((l) => l.length > 0);
|
|
103
|
+
const planLine = lines.find((l) => l.startsWith("Your Plan:"));
|
|
104
|
+
const plan = planLine ? planLine.replace("Your Plan:", "").trim() : null;
|
|
105
|
+
const groups = [];
|
|
106
|
+
for (let i = 1; i < lines.length; i++) {
|
|
107
|
+
if (lines[i] !== "Weekly Limit")
|
|
108
|
+
continue;
|
|
109
|
+
const group = lines[i - 1];
|
|
110
|
+
let j = i + 1;
|
|
111
|
+
const weeklyStatusText = lines[j] ?? null;
|
|
112
|
+
j++;
|
|
113
|
+
const weeklyMatch = (lines[j] ?? "").match(/^(\d+)%$/);
|
|
114
|
+
const weeklyPercentRemaining = weeklyMatch ? Number(weeklyMatch[1]) : null;
|
|
115
|
+
if (weeklyMatch)
|
|
116
|
+
j++;
|
|
117
|
+
let fiveHourStatusText = null;
|
|
118
|
+
let fiveHourPercentRemaining = null;
|
|
119
|
+
if (lines[j] === "Five Hour Limit") {
|
|
120
|
+
j++;
|
|
121
|
+
fiveHourStatusText = lines[j] ?? null;
|
|
122
|
+
j++;
|
|
123
|
+
const fiveHourMatch = (lines[j] ?? "").match(/^(\d+)%$/);
|
|
124
|
+
if (fiveHourMatch)
|
|
125
|
+
fiveHourPercentRemaining = Number(fiveHourMatch[1]);
|
|
126
|
+
}
|
|
127
|
+
groups.push({ group, weeklyPercentRemaining, weeklyStatusText, fiveHourPercentRemaining, fiveHourStatusText });
|
|
128
|
+
}
|
|
129
|
+
return { plan, groups, raw };
|
|
130
|
+
}
|
|
131
|
+
// ---------- App lifecycle ----------
|
|
132
|
+
function resolveAppPath() {
|
|
133
|
+
const candidates = [
|
|
134
|
+
process.env.ANTIGRAVITY_APP_PATH,
|
|
135
|
+
"/Applications/Antigravity.app",
|
|
136
|
+
path.join(os.homedir(), "Desktop", "Antigravity.app"),
|
|
137
|
+
].filter((p) => !!p);
|
|
138
|
+
for (const c of candidates) {
|
|
139
|
+
if (fs.existsSync(c))
|
|
140
|
+
return c;
|
|
141
|
+
}
|
|
142
|
+
throw new Error("Could not find Antigravity.app. Set the ANTIGRAVITY_APP_PATH environment variable to its full path.");
|
|
143
|
+
}
|
|
144
|
+
async function isCdpAvailable() {
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch(`http://localhost:${CDP_PORT}/json/version`);
|
|
147
|
+
return res.ok;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function isAppRunningWithoutDebugPort() {
|
|
154
|
+
try {
|
|
155
|
+
execSync('pgrep -f "Antigravity.app/Contents/MacOS/Antigravity"', { stdio: "ignore" });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function quitApp() {
|
|
163
|
+
try {
|
|
164
|
+
execSync('osascript -e \'quit app "Antigravity"\'');
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
/* app may not have been running via a scriptable process */
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
execSync('pkill -f "Antigravity.app/Contents/MacOS/Antigravity"');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* already gone */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function launchWithDebugPort(appPath) {
|
|
177
|
+
const child = spawn("open", ["-a", appPath, "--args", `--remote-debugging-port=${CDP_PORT}`], {
|
|
178
|
+
detached: true,
|
|
179
|
+
stdio: "ignore",
|
|
180
|
+
});
|
|
181
|
+
child.unref();
|
|
182
|
+
}
|
|
183
|
+
async function waitFor(condition, timeoutMs, intervalMs = 500) {
|
|
184
|
+
const start = Date.now();
|
|
185
|
+
while (Date.now() - start < timeoutMs) {
|
|
186
|
+
if (await condition())
|
|
187
|
+
return;
|
|
188
|
+
await sleep(intervalMs);
|
|
189
|
+
}
|
|
190
|
+
throw new Error("Timed out waiting for Antigravity to become ready");
|
|
191
|
+
}
|
|
192
|
+
export async function ensureAntigravityReady() {
|
|
193
|
+
if (await isCdpAvailable())
|
|
194
|
+
return;
|
|
195
|
+
// Antigravity only opens the debug port if launched with the flag. If it's
|
|
196
|
+
// already running without it, we have to restart it to attach.
|
|
197
|
+
if (isAppRunningWithoutDebugPort()) {
|
|
198
|
+
quitApp();
|
|
199
|
+
await sleep(1500);
|
|
200
|
+
}
|
|
201
|
+
const appPath = resolveAppPath();
|
|
202
|
+
launchWithDebugPort(appPath);
|
|
203
|
+
await waitFor(isCdpAvailable, 30_000);
|
|
204
|
+
}
|
|
205
|
+
async function fetchTargets() {
|
|
206
|
+
const res = await fetch(`http://localhost:${CDP_PORT}/json`);
|
|
207
|
+
return (await res.json());
|
|
208
|
+
}
|
|
209
|
+
function isMainPage(t) {
|
|
210
|
+
// The loading splash is a data: URL; the real UI is served locally by
|
|
211
|
+
// Antigravity's own language-server process over https://127.0.0.1:<port>.
|
|
212
|
+
return t.type === "page" && t.url.startsWith("https://127.0.0.1:");
|
|
213
|
+
}
|
|
214
|
+
async function getMainPageTarget() {
|
|
215
|
+
await waitFor(async () => (await fetchTargets()).some(isMainPage), 20_000);
|
|
216
|
+
const targets = await fetchTargets();
|
|
217
|
+
const target = targets.find(isMainPage);
|
|
218
|
+
if (!target)
|
|
219
|
+
throw new Error("Antigravity's main window did not appear in time.");
|
|
220
|
+
return target;
|
|
221
|
+
}
|
|
222
|
+
class CdpSession {
|
|
223
|
+
ws;
|
|
224
|
+
nextId = 1;
|
|
225
|
+
pending = new Map();
|
|
226
|
+
ready;
|
|
227
|
+
constructor(wsUrl) {
|
|
228
|
+
this.ws = new WebSocket(wsUrl);
|
|
229
|
+
this.ready = new Promise((resolve, reject) => {
|
|
230
|
+
this.ws.addEventListener("open", () => resolve());
|
|
231
|
+
this.ws.addEventListener("error", () => reject(new Error("CDP WebSocket connection failed")));
|
|
232
|
+
});
|
|
233
|
+
this.ws.addEventListener("message", (event) => {
|
|
234
|
+
const msg = JSON.parse(event.data);
|
|
235
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
236
|
+
this.pending.get(msg.id)(msg.result);
|
|
237
|
+
this.pending.delete(msg.id);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async send(method, params = {}) {
|
|
242
|
+
await this.ready;
|
|
243
|
+
const id = this.nextId++;
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
this.pending.set(id, resolve);
|
|
246
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async evaluate(expression) {
|
|
250
|
+
const result = await this.send("Runtime.evaluate", { expression, returnByValue: true });
|
|
251
|
+
if (result?.exceptionDetails) {
|
|
252
|
+
throw new Error(`Antigravity page script error: ${JSON.stringify(result.exceptionDetails)}`);
|
|
253
|
+
}
|
|
254
|
+
return result?.result?.value;
|
|
255
|
+
}
|
|
256
|
+
close() {
|
|
257
|
+
this.ws.close();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ---------- UI actions (selectors verified against Antigravity 2.2.1) ----------
|
|
261
|
+
async function startNewConversation(session) {
|
|
262
|
+
await session.evaluate(`
|
|
263
|
+
(() => {
|
|
264
|
+
const btns = Array.from(document.querySelectorAll('button'));
|
|
265
|
+
const b = btns.find(x => x.innerText && x.innerText.trim() === 'New Conversation');
|
|
266
|
+
if (b) b.click();
|
|
267
|
+
return true;
|
|
268
|
+
})()
|
|
269
|
+
`);
|
|
270
|
+
await sleep(1000);
|
|
271
|
+
}
|
|
272
|
+
async function selectModel(session, modelName) {
|
|
273
|
+
await session.evaluate(`
|
|
274
|
+
(() => {
|
|
275
|
+
const btns = Array.from(document.querySelectorAll('button'));
|
|
276
|
+
const b = btns.find(x => (x.getAttribute('aria-label')||'').startsWith('Select model'));
|
|
277
|
+
if (b) b.click();
|
|
278
|
+
return true;
|
|
279
|
+
})()
|
|
280
|
+
`);
|
|
281
|
+
await sleep(600);
|
|
282
|
+
const result = await session.evaluate(`
|
|
283
|
+
(() => {
|
|
284
|
+
const btns = Array.from(document.querySelectorAll('button'));
|
|
285
|
+
const target = btns.find(b => b.innerText && b.innerText.includes(${JSON.stringify(modelName)}) && b.className.includes('popover-item'));
|
|
286
|
+
if (!target) return { error: 'model option not found in dropdown' };
|
|
287
|
+
target.click();
|
|
288
|
+
return { clicked: target.innerText.slice(0, 60) };
|
|
289
|
+
})()
|
|
290
|
+
`);
|
|
291
|
+
if (result?.error) {
|
|
292
|
+
throw new Error(`Could not select model "${modelName}": ${result.error}`);
|
|
293
|
+
}
|
|
294
|
+
await sleep(500);
|
|
295
|
+
}
|
|
296
|
+
async function pasteAndSend(session, taskText) {
|
|
297
|
+
// Antigravity's message box is a Lexical rich-text editor, not a plain
|
|
298
|
+
// textarea. Setting its text directly doesn't update Lexical's internal
|
|
299
|
+
// state, so we simulate a real paste event instead — same as a human
|
|
300
|
+
// pasting text, which the editor is built to handle correctly.
|
|
301
|
+
//
|
|
302
|
+
// Paste inserts at the current selection rather than replacing the whole
|
|
303
|
+
// box, and "New Conversation" does NOT clear a pre-existing unsent draft
|
|
304
|
+
// (confirmed the hard way: a leftover draft silently glued itself onto a
|
|
305
|
+
// later task). So we explicitly select all existing content first —
|
|
306
|
+
// that makes the paste replace it instead of appending to it.
|
|
307
|
+
const pasteResult = await session.evaluate(`
|
|
308
|
+
(() => {
|
|
309
|
+
const editable = document.querySelector('[contenteditable="true"][aria-label="Message input"]');
|
|
310
|
+
if (!editable) return { error: 'message input not found' };
|
|
311
|
+
editable.focus();
|
|
312
|
+
const range = document.createRange();
|
|
313
|
+
range.selectNodeContents(editable);
|
|
314
|
+
const selection = window.getSelection();
|
|
315
|
+
selection.removeAllRanges();
|
|
316
|
+
selection.addRange(range);
|
|
317
|
+
const dt = new DataTransfer();
|
|
318
|
+
dt.setData('text/plain', ${JSON.stringify(taskText)});
|
|
319
|
+
const pasteEvent = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
|
|
320
|
+
editable.dispatchEvent(pasteEvent);
|
|
321
|
+
return { dispatched: true };
|
|
322
|
+
})()
|
|
323
|
+
`);
|
|
324
|
+
if (pasteResult?.error)
|
|
325
|
+
throw new Error(pasteResult.error);
|
|
326
|
+
await sleep(1000);
|
|
327
|
+
// Safety net for the exact bug above: if the box holds far more text than
|
|
328
|
+
// we just sent, something didn't get replaced. Refuse to send a message
|
|
329
|
+
// that isn't the one we were asked to send.
|
|
330
|
+
const landedLength = await session.evaluate(`
|
|
331
|
+
(() => {
|
|
332
|
+
const editable = document.querySelector('[contenteditable="true"][aria-label="Message input"]');
|
|
333
|
+
return editable ? editable.innerText.length : -1;
|
|
334
|
+
})()
|
|
335
|
+
`);
|
|
336
|
+
if (landedLength < 0)
|
|
337
|
+
throw new Error("message input disappeared before send");
|
|
338
|
+
if (landedLength > taskText.length + 1000) {
|
|
339
|
+
throw new Error(`Refusing to send: message box has ${landedLength} chars but the task was only ${taskText.length}. ` +
|
|
340
|
+
"The box likely still has leftover content that wasn't replaced.");
|
|
341
|
+
}
|
|
342
|
+
const clickResult = await session.evaluate(`
|
|
343
|
+
(() => {
|
|
344
|
+
const btn = document.querySelector('[data-testid="send-button"]');
|
|
345
|
+
if (!btn) return { error: 'send button not found' };
|
|
346
|
+
if (btn.disabled) return { error: 'send button is disabled' };
|
|
347
|
+
btn.click();
|
|
348
|
+
return { clicked: true };
|
|
349
|
+
})()
|
|
350
|
+
`);
|
|
351
|
+
if (clickResult?.error) {
|
|
352
|
+
// A disabled send button right after switching models usually means that
|
|
353
|
+
// model's quota is temporarily exhausted (observed with Claude Opus after
|
|
354
|
+
// heavy use — selectable in the dropdown, but send stays disabled until
|
|
355
|
+
// quota resets or you pick a different model).
|
|
356
|
+
throw new Error(`${clickResult.error}. If you just switched models, its quota may be temporarily exhausted — try again later or pick a different model.`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async function waitForReply(session, taskText, timeoutMs) {
|
|
360
|
+
const needle = JSON.stringify(taskText.slice(0, 200));
|
|
361
|
+
const start = Date.now();
|
|
362
|
+
let lastText = "";
|
|
363
|
+
let stableCount = 0;
|
|
364
|
+
while (Date.now() - start < timeoutMs) {
|
|
365
|
+
await sleep(1200);
|
|
366
|
+
const threadText = await session.evaluate(`
|
|
367
|
+
(() => {
|
|
368
|
+
const all = Array.from(document.querySelectorAll('*'));
|
|
369
|
+
const hit = all.findLast(el => el.children.length === 0 && el.innerText && el.innerText.includes(${needle}));
|
|
370
|
+
if (!hit) return '';
|
|
371
|
+
let container = hit;
|
|
372
|
+
for (let i = 0; i < 14 && container.parentElement; i++) container = container.parentElement;
|
|
373
|
+
return container.innerText;
|
|
374
|
+
})()
|
|
375
|
+
`);
|
|
376
|
+
if (!threadText)
|
|
377
|
+
continue;
|
|
378
|
+
if (threadText === lastText) {
|
|
379
|
+
stableCount++;
|
|
380
|
+
// Require the "Thought for Ns" marker so we don't return early on the
|
|
381
|
+
// brief window right after sending, before Antigravity starts replying.
|
|
382
|
+
if (stableCount >= 2 && /Thought for/.test(threadText)) {
|
|
383
|
+
return extractReply(threadText, taskText);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
stableCount = 0;
|
|
388
|
+
lastText = threadText;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for Antigravity's reply.`);
|
|
392
|
+
}
|
|
393
|
+
function extractReply(threadText, taskText) {
|
|
394
|
+
const needle = taskText.slice(0, 200);
|
|
395
|
+
const idx = threadText.lastIndexOf(needle);
|
|
396
|
+
const after = idx >= 0 ? threadText.slice(idx) : threadText;
|
|
397
|
+
const thoughtMatch = after.match(/Thought for[^\n]*\n+/);
|
|
398
|
+
let rest = thoughtMatch ? after.slice(after.indexOf(thoughtMatch[0]) + thoughtMatch[0].length) : after;
|
|
399
|
+
rest = rest.replace(/\n+\d{1,2}:\d{2}\s*$/, "").trim();
|
|
400
|
+
return rest;
|
|
401
|
+
}
|
|
402
|
+
function sleep(ms) {
|
|
403
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
404
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { AVAILABLE_MODELS, MODEL_GUIDE, delegateToAntigravity, getAntigravityQuota } from "./antigravity-client.js";
|
|
6
|
+
const server = new McpServer({
|
|
7
|
+
name: "supergravity-mcp",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
});
|
|
10
|
+
server.registerTool("delegate_to_antigravity", {
|
|
11
|
+
title: "Delegate to Antigravity",
|
|
12
|
+
description: "Send a task to the Google Antigravity desktop app and return its reply. " +
|
|
13
|
+
"Drives the real, already-logged-in app UI (types into its chat box and reads the response) — " +
|
|
14
|
+
"does not touch Antigravity's internals or credentials. Requires Antigravity.app to be installed " +
|
|
15
|
+
"and signed in on this machine.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
task: z.string().describe("The task, question, or instruction to send to Antigravity."),
|
|
18
|
+
model: z
|
|
19
|
+
.enum(AVAILABLE_MODELS)
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Which model Antigravity should use. If omitted, uses whatever model is currently selected in the app.\n\n" +
|
|
22
|
+
MODEL_GUIDE),
|
|
23
|
+
timeoutMs: z
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.positive()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Max time to wait for a reply, in milliseconds. Default 120000 (2 minutes)."),
|
|
29
|
+
},
|
|
30
|
+
}, async ({ task, model, timeoutMs }) => {
|
|
31
|
+
try {
|
|
32
|
+
const reply = await delegateToAntigravity({ task, model, timeoutMs });
|
|
33
|
+
return { content: [{ type: "text", text: reply }] };
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return {
|
|
37
|
+
content: [{ type: "text", text: `Antigravity delegation failed: ${err.message}` }],
|
|
38
|
+
isError: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
server.registerTool("get_antigravity_quota", {
|
|
43
|
+
title: "Get Antigravity quota",
|
|
44
|
+
description: "Read Antigravity's remaining model quota from its Settings > Models panel. " +
|
|
45
|
+
"Gemini models and Claude+GPT models draw from separate pools, each with a weekly limit and a " +
|
|
46
|
+
"5-hour limit. Check this before delegating to Claude/GPT-OSS models if a prior call failed with " +
|
|
47
|
+
"a 'send button is disabled' error, or to decide which model family has room before delegating.",
|
|
48
|
+
inputSchema: {},
|
|
49
|
+
}, async () => {
|
|
50
|
+
try {
|
|
51
|
+
const status = await getAntigravityQuota();
|
|
52
|
+
return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: `Could not read Antigravity quota: ${err.message}` }],
|
|
57
|
+
isError: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
const transport = new StdioServerTransport();
|
|
62
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "supergravity-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that lets Claude (or any MCP client) delegate tasks to the Google Antigravity desktop app by driving its real UI — no CLI, no reverse engineering. Built for Intel Macs Antigravity's own CLI doesn't support.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"supergravity-mcp": "dist/mcp-server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/mcp-server.js"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/presidentrice/supergravity-mcp.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/presidentrice/supergravity-mcp",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
24
|
+
"zod": "^4.4.3"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.6.0",
|
|
28
|
+
"@types/node": "^22.0.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
}
|
|
33
|
+
}
|