@danwahl/antigravity-mcp 0.2.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 +111 -0
- package/dist/index.js +89 -0
- package/dist/lib.js +151 -0
- package/dist/lib.test.js +145 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan Wahl
|
|
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,111 @@
|
|
|
1
|
+
# antigravity-mcp
|
|
2
|
+
|
|
3
|
+
A minimal MCP server that exposes [Antigravity CLI](https://antigravity.google/) (`agy`) as a single tool callable from Claude Code (or any MCP client).
|
|
4
|
+
|
|
5
|
+
This project started as `gemini-cli-mcp`, wrapping Gemini CLI. Gemini CLI is deprecated in favor of Antigravity, so the server now spawns `agy` instead. The tool interface is the same shape; see [Migrating from gemini-cli-mcp](#migrating-from-gemini-cli-mcp).
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
Claude Code sends prompts to this server via MCP. The server spawns `agy -p "..."` in headless mode and returns the response. Antigravity inherits your Google sign-in, so no API key is required.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Claude Code ──MCP/stdio──▶ antigravity-mcp ──spawn──▶ agy -p "..." --output-format json --add-dir <cwd>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- Antigravity CLI installed, on your `PATH` as `agy`, and signed in (run `agy` once interactively to complete login). Requires a version with headless JSON output (1.1.8 or later; tested against 1.2.7).
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
### From source
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
git clone https://github.com/danwahl/antigravity-mcp
|
|
25
|
+
cd antigravity-mcp
|
|
26
|
+
npm install
|
|
27
|
+
npm run build
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
**User install** (available across all projects):
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
claude mcp add agy -s user -- npx -y @danwahl/antigravity-mcp
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Project install** (shared with your team via `.mcp.json`):
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
claude mcp add agy -s project -- npx -y @danwahl/antigravity-mcp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or from source, replace `npx -y @danwahl/antigravity-mcp` with `node /absolute/path/to/antigravity-mcp/dist/index.js`.
|
|
45
|
+
|
|
46
|
+
Verify with `claude mcp list`.
|
|
47
|
+
|
|
48
|
+
## Tool: `cli`
|
|
49
|
+
|
|
50
|
+
| Parameter | Type | Required | Description |
|
|
51
|
+
|------------------|--------|----------|-------------|
|
|
52
|
+
| `prompt` | string | yes | Task or question to send to Antigravity |
|
|
53
|
+
| `cwd` | string | yes | Absolute path to the working directory (agy's workspace) |
|
|
54
|
+
| `model` | string | no | Model name. Omit for agy's default. `agy models` lists the options. |
|
|
55
|
+
| `conversationId` | string | no | Resume a previous conversation. Returned in the structured output of each call. Pass the same `cwd`, since conversations are workspace-scoped. |
|
|
56
|
+
| `timeout` | number | no | Seconds before the run is killed. Default 120. |
|
|
57
|
+
|
|
58
|
+
### Structured output
|
|
59
|
+
|
|
60
|
+
Each call returns structured content alongside the text response:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"conversationId": "dc381a74-...",
|
|
65
|
+
"response": "Antigravity's answer...",
|
|
66
|
+
"status": "SUCCESS",
|
|
67
|
+
"usage": {
|
|
68
|
+
"inputTokens": 12447,
|
|
69
|
+
"outputTokens": 122,
|
|
70
|
+
"thinkingTokens": 121,
|
|
71
|
+
"cacheReadTokens": 0,
|
|
72
|
+
"totalTokens": 12569
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`agy`'s JSON output does not include per-tool call counts or a per-model breakdown, so those fields from the Gemini CLI version are gone.
|
|
78
|
+
|
|
79
|
+
### What Antigravity can do
|
|
80
|
+
|
|
81
|
+
`agy` runs with `--dangerously-skip-permissions`, giving it full tool access: read/write files, run shell commands, web search, and more. The `cwd` you specify is passed as `--add-dir` so it becomes the agent's workspace; the spawn working directory alone is not enough in headless mode. Persisted `settings.json` permission rules still apply.
|
|
82
|
+
|
|
83
|
+
### Errors
|
|
84
|
+
|
|
85
|
+
On a model or API failure `agy` prints an `AGY_ERROR: {...}` line to stderr and exits with code 3. The server surfaces the status and message from that line as the tool error.
|
|
86
|
+
|
|
87
|
+
## Migrating from gemini-cli-mcp
|
|
88
|
+
|
|
89
|
+
- `sessionId` is now `conversationId` (both the input parameter and the output field).
|
|
90
|
+
- `models` and `tools` in the structured output are replaced by a single `usage` object.
|
|
91
|
+
- Gemini CLI model aliases (`auto`, `pro`, `flash`, `flash-lite`) are not recognized; use a name from `agy models`.
|
|
92
|
+
- Re-add the server under the new package name: `claude mcp remove gemini-cli` then the `claude mcp add` command above.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
npm run build # compile with tsc
|
|
98
|
+
npm test # run unit tests
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Smoke test
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/index.js
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Design
|
|
108
|
+
|
|
109
|
+
One tool, no prompt wrappers. Claude Code is the orchestrator: it decides what to ask Antigravity and how to phrase it. This server is a thin, reliable pipe between MCP and `agy -p`.
|
|
110
|
+
|
|
111
|
+
See [CLAUDE.md](./CLAUDE.md) for project conventions.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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 { createRequire } from "node:module";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { runAgy } from "./lib.js";
|
|
7
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: "antigravity-mcp",
|
|
10
|
+
version,
|
|
11
|
+
});
|
|
12
|
+
const usageSchema = z.object({
|
|
13
|
+
inputTokens: z.number(),
|
|
14
|
+
outputTokens: z.number(),
|
|
15
|
+
thinkingTokens: z.number(),
|
|
16
|
+
cacheReadTokens: z.number(),
|
|
17
|
+
totalTokens: z.number(),
|
|
18
|
+
});
|
|
19
|
+
server.registerTool("cli", {
|
|
20
|
+
description: "Send a task to Antigravity CLI (agy) and return the response. " +
|
|
21
|
+
"Antigravity runs headlessly with full tool access (file read/write, web search, shell commands) " +
|
|
22
|
+
"and operates in the working directory you specify via `cwd`.\n\n" +
|
|
23
|
+
"When to use this tool:\n" +
|
|
24
|
+
"- Delegating rote coding tasks: boilerplate generation, repetitive refactors, bulk edits across many files\n" +
|
|
25
|
+
"- Getting a second opinion: code review, architecture feedback, sanity-checking an approach\n" +
|
|
26
|
+
"- Research and brainstorming: Antigravity has web search and a large context window, useful for exploring options or summarizing docs\n" +
|
|
27
|
+
"- Large file analysis: processing files that would be expensive to handle directly\n" +
|
|
28
|
+
"- Parallel workstreams: offloading independent subtasks while you continue other work\n\n" +
|
|
29
|
+
"Conversation resumption: each response includes a `conversationId`. " +
|
|
30
|
+
"Pass it back via the `conversationId` parameter (with the same `cwd`) to continue a conversation " +
|
|
31
|
+
"without re-sending context — useful for multi-step tasks or follow-up questions.\n\n" +
|
|
32
|
+
"Keep prompts self-contained: include all necessary context in the `prompt` since " +
|
|
33
|
+
"Antigravity has no access to your conversation history or MCP state.",
|
|
34
|
+
inputSchema: {
|
|
35
|
+
prompt: z
|
|
36
|
+
.string()
|
|
37
|
+
.describe("The task or question to send to Antigravity CLI"),
|
|
38
|
+
cwd: z
|
|
39
|
+
.string()
|
|
40
|
+
.describe("Absolute path to the working directory (workspace) for Antigravity to operate in"),
|
|
41
|
+
model: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Model to use. Omit to use Antigravity's default. " +
|
|
45
|
+
"Run `agy models` to list available model names."),
|
|
46
|
+
conversationId: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("Resume a previous conversation by ID. The ID is returned in the structured output of each call. " +
|
|
50
|
+
"Conversations are scoped to a workspace, so pass the same `cwd` as the original call."),
|
|
51
|
+
timeout: z
|
|
52
|
+
.number()
|
|
53
|
+
.optional()
|
|
54
|
+
.default(120)
|
|
55
|
+
.describe("Timeout in seconds. Default: 120. Increase for complex multi-step tasks."),
|
|
56
|
+
},
|
|
57
|
+
outputSchema: {
|
|
58
|
+
conversationId: z.string().nullable().describe("Antigravity conversation ID"),
|
|
59
|
+
response: z.string().describe("Antigravity's text response"),
|
|
60
|
+
status: z.string().nullable().describe("Run status reported by agy (e.g. SUCCESS)"),
|
|
61
|
+
usage: usageSchema.nullable().describe("Token usage for this turn"),
|
|
62
|
+
},
|
|
63
|
+
annotations: {
|
|
64
|
+
readOnlyHint: false,
|
|
65
|
+
openWorldHint: true,
|
|
66
|
+
},
|
|
67
|
+
}, async ({ prompt, cwd, model, conversationId, timeout }) => {
|
|
68
|
+
const timeoutMs = (timeout ?? 120) * 1000;
|
|
69
|
+
const result = await runAgy(prompt, cwd, model, timeoutMs, conversationId);
|
|
70
|
+
if (result.isError) {
|
|
71
|
+
return {
|
|
72
|
+
isError: true,
|
|
73
|
+
content: [{ type: "text", text: result.errorMessage ?? "Unknown error" }],
|
|
74
|
+
structuredContent: result.output,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: result.output.response }],
|
|
79
|
+
structuredContent: result.output,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
async function runServer() {
|
|
83
|
+
const transport = new StdioServerTransport();
|
|
84
|
+
await server.connect(transport);
|
|
85
|
+
}
|
|
86
|
+
runServer().catch((error) => {
|
|
87
|
+
console.error("Fatal error:", error);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
export function buildAgyArgs(prompt, cwd, model, conversationId) {
|
|
4
|
+
// The spawn cwd alone does not register a workspace in headless mode; without
|
|
5
|
+
// --add-dir the agent is told it has no active workspace and works in its
|
|
6
|
+
// own scratch directory.
|
|
7
|
+
const args = [
|
|
8
|
+
"-p", prompt,
|
|
9
|
+
"--output-format", "json",
|
|
10
|
+
"--dangerously-skip-permissions",
|
|
11
|
+
"--add-dir", cwd,
|
|
12
|
+
];
|
|
13
|
+
if (model) {
|
|
14
|
+
args.push("--model", model);
|
|
15
|
+
}
|
|
16
|
+
if (conversationId) {
|
|
17
|
+
args.push("--conversation", conversationId);
|
|
18
|
+
}
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
const EMPTY_OUTPUT = { conversationId: null, response: "", status: null, usage: null };
|
|
22
|
+
function numberOr(value, fallback) {
|
|
23
|
+
return typeof value === "number" ? value : fallback;
|
|
24
|
+
}
|
|
25
|
+
function parseUsage(value) {
|
|
26
|
+
if (value === null || typeof value !== "object")
|
|
27
|
+
return null;
|
|
28
|
+
const u = value;
|
|
29
|
+
return {
|
|
30
|
+
inputTokens: numberOr(u.input_tokens, 0),
|
|
31
|
+
outputTokens: numberOr(u.output_tokens, 0),
|
|
32
|
+
thinkingTokens: numberOr(u.thinking_tokens, 0),
|
|
33
|
+
cacheReadTokens: numberOr(u.cache_read_tokens, 0),
|
|
34
|
+
totalTokens: numberOr(u.total_tokens, 0),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function parseAgyOutput(stdout) {
|
|
38
|
+
const trimmed = stdout.trim();
|
|
39
|
+
if (!trimmed) {
|
|
40
|
+
return EMPTY_OUTPUT;
|
|
41
|
+
}
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(trimmed);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { ...EMPTY_OUTPUT, response: trimmed };
|
|
48
|
+
}
|
|
49
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
50
|
+
return { ...EMPTY_OUTPUT, response: trimmed };
|
|
51
|
+
}
|
|
52
|
+
const obj = parsed;
|
|
53
|
+
if (typeof obj.response !== "string") {
|
|
54
|
+
return { ...EMPTY_OUTPUT, response: trimmed };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
conversationId: typeof obj.conversation_id === "string" ? obj.conversation_id : null,
|
|
58
|
+
response: obj.response,
|
|
59
|
+
status: typeof obj.status === "string" ? obj.status : null,
|
|
60
|
+
usage: parseUsage(obj.usage),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// agy prints a structured `AGY_ERROR: {...}` line on stderr for model/API failures.
|
|
64
|
+
export function extractAgyError(stderr) {
|
|
65
|
+
const line = stderr.split("\n").find((l) => l.startsWith("AGY_ERROR:"));
|
|
66
|
+
if (!line)
|
|
67
|
+
return null;
|
|
68
|
+
const json = line.slice("AGY_ERROR:".length).trim();
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(json);
|
|
71
|
+
const message = typeof parsed.message === "string" ? parsed.message : json;
|
|
72
|
+
const status = typeof parsed.status === "string" ? `${parsed.status}: ` : "";
|
|
73
|
+
return `${status}${message}`;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return json;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function errorResult(errorMessage) {
|
|
80
|
+
return { output: EMPTY_OUTPUT, isError: true, errorMessage };
|
|
81
|
+
}
|
|
82
|
+
export function runAgy(prompt, cwd, model, timeoutMs, conversationId) {
|
|
83
|
+
if (!existsSync(cwd)) {
|
|
84
|
+
return Promise.resolve(errorResult(`Working directory does not exist: ${cwd}`));
|
|
85
|
+
}
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const args = buildAgyArgs(prompt, cwd, model, conversationId);
|
|
88
|
+
let child;
|
|
89
|
+
try {
|
|
90
|
+
child = spawn("agy", args, { cwd, env: process.env, detached: true });
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
resolve(errorResult(`Failed to spawn agy: ${String(err)}. Is Antigravity CLI installed?`));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let stdout = "";
|
|
97
|
+
let stderr = "";
|
|
98
|
+
let settled = false;
|
|
99
|
+
// Decode as UTF-8 streams so multi-byte characters split across chunks survive.
|
|
100
|
+
child.stdout?.setEncoding("utf8");
|
|
101
|
+
child.stderr?.setEncoding("utf8");
|
|
102
|
+
// Kill the entire process group (agy + tool grandchildren).
|
|
103
|
+
// Without this, grandchildren can keep stdio pipes open and `close` never fires.
|
|
104
|
+
const killGroup = (sig) => {
|
|
105
|
+
if (child.pid === undefined)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
process.kill(-child.pid, sig);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// group already gone
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const finish = (r) => {
|
|
115
|
+
if (settled)
|
|
116
|
+
return;
|
|
117
|
+
settled = true;
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve(r);
|
|
120
|
+
};
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
killGroup("SIGTERM");
|
|
123
|
+
setTimeout(() => killGroup("SIGKILL"), 5000).unref();
|
|
124
|
+
// Resolve immediately — don't wait for `close`, which may never fire
|
|
125
|
+
// if grandchildren keep stdio pipes open.
|
|
126
|
+
finish(errorResult(`agy timed out after ${timeoutMs / 1000}s`));
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
child.stdout?.on("data", (chunk) => {
|
|
129
|
+
stdout += chunk;
|
|
130
|
+
});
|
|
131
|
+
child.stderr?.on("data", (chunk) => {
|
|
132
|
+
stderr += chunk;
|
|
133
|
+
});
|
|
134
|
+
child.on("error", (err) => {
|
|
135
|
+
const isNotFound = err.code === "ENOENT" ||
|
|
136
|
+
err.message.includes("ENOENT");
|
|
137
|
+
finish(errorResult(isNotFound
|
|
138
|
+
? "agy binary not found. Install Antigravity CLI and make sure `agy` is on PATH."
|
|
139
|
+
: `Failed to spawn agy: ${err.message}`));
|
|
140
|
+
});
|
|
141
|
+
child.on("close", (code, signal) => {
|
|
142
|
+
if (code !== 0) {
|
|
143
|
+
const how = code === null ? `signal ${signal}` : `code ${code}`;
|
|
144
|
+
const detail = extractAgyError(stderr) || stderr.trim() || stdout.trim() || how;
|
|
145
|
+
finish(errorResult(`agy exited with ${how}: ${detail}`));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
finish({ output: parseAgyOutput(stdout), isError: false });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
package/dist/lib.test.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildGeminiArgs, parseGeminiOutput, extractStructuredOutput, } from "./lib.ts";
|
|
4
|
+
describe("buildGeminiArgs", () => {
|
|
5
|
+
it("omits --model when not provided", () => {
|
|
6
|
+
const args = buildGeminiArgs("hello", undefined);
|
|
7
|
+
assert.ok(!args.includes("--model"));
|
|
8
|
+
});
|
|
9
|
+
it("includes --model when provided", () => {
|
|
10
|
+
const args = buildGeminiArgs("hello", "gemini-2.5-pro");
|
|
11
|
+
const idx = args.indexOf("--model");
|
|
12
|
+
assert.ok(idx !== -1);
|
|
13
|
+
assert.equal(args[idx + 1], "gemini-2.5-pro");
|
|
14
|
+
});
|
|
15
|
+
it("passes model string through unchanged", () => {
|
|
16
|
+
const args = buildGeminiArgs("hello", "gemini-1.5-flash-001");
|
|
17
|
+
assert.equal(args[args.indexOf("--model") + 1], "gemini-1.5-flash-001");
|
|
18
|
+
});
|
|
19
|
+
it("includes --resume when sessionId provided", () => {
|
|
20
|
+
const args = buildGeminiArgs("x", undefined, "my-session-id");
|
|
21
|
+
const idx = args.indexOf("--resume");
|
|
22
|
+
assert.ok(idx !== -1);
|
|
23
|
+
assert.equal(args[idx + 1], "my-session-id");
|
|
24
|
+
});
|
|
25
|
+
it("omits --resume when sessionId not provided", () => {
|
|
26
|
+
const args = buildGeminiArgs("x", undefined);
|
|
27
|
+
assert.ok(!args.includes("--resume"));
|
|
28
|
+
});
|
|
29
|
+
it("includes --approval-mode yolo", () => {
|
|
30
|
+
const args = buildGeminiArgs("x", undefined);
|
|
31
|
+
const idx = args.indexOf("--approval-mode");
|
|
32
|
+
assert.ok(idx !== -1);
|
|
33
|
+
assert.equal(args[idx + 1], "yolo");
|
|
34
|
+
});
|
|
35
|
+
it("includes --output-format json", () => {
|
|
36
|
+
const args = buildGeminiArgs("x", undefined);
|
|
37
|
+
const idx = args.indexOf("--output-format");
|
|
38
|
+
assert.ok(idx !== -1);
|
|
39
|
+
assert.equal(args[idx + 1], "json");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("parseGeminiOutput", () => {
|
|
43
|
+
it("parses valid JSON response", () => {
|
|
44
|
+
const result = parseGeminiOutput(JSON.stringify({ response: "Hello, world!" }));
|
|
45
|
+
assert.equal(result.response, "Hello, world!");
|
|
46
|
+
assert.equal(result.sessionId, null);
|
|
47
|
+
assert.equal(result.stats, undefined);
|
|
48
|
+
});
|
|
49
|
+
it("extracts session_id", () => {
|
|
50
|
+
const result = parseGeminiOutput(JSON.stringify({ session_id: "abc-123", response: "ok" }));
|
|
51
|
+
assert.equal(result.sessionId, "abc-123");
|
|
52
|
+
});
|
|
53
|
+
it("extracts stats field", () => {
|
|
54
|
+
const stats = { models: {}, tools: {} };
|
|
55
|
+
const result = parseGeminiOutput(JSON.stringify({ response: "ok", stats }));
|
|
56
|
+
assert.deepEqual(result.stats, stats);
|
|
57
|
+
});
|
|
58
|
+
it("handles missing stats gracefully", () => {
|
|
59
|
+
const result = parseGeminiOutput(JSON.stringify({ response: "ok" }));
|
|
60
|
+
assert.equal(result.stats, undefined);
|
|
61
|
+
});
|
|
62
|
+
it("returns raw stdout when JSON parsing fails", () => {
|
|
63
|
+
const raw = "this is not json";
|
|
64
|
+
const result = parseGeminiOutput(raw);
|
|
65
|
+
assert.equal(result.response, raw);
|
|
66
|
+
assert.equal(result.sessionId, null);
|
|
67
|
+
});
|
|
68
|
+
it("returns raw stdout for JSON without response field", () => {
|
|
69
|
+
const raw = JSON.stringify({ message: "unexpected shape" });
|
|
70
|
+
const result = parseGeminiOutput(raw);
|
|
71
|
+
assert.equal(result.response, raw);
|
|
72
|
+
});
|
|
73
|
+
it("handles empty stdout", () => {
|
|
74
|
+
const result = parseGeminiOutput("");
|
|
75
|
+
assert.equal(result.response, "");
|
|
76
|
+
});
|
|
77
|
+
it("trims surrounding whitespace before parsing", () => {
|
|
78
|
+
const result = parseGeminiOutput(" " + JSON.stringify({ response: "trimmed" }) + "\n");
|
|
79
|
+
assert.equal(result.response, "trimmed");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("extractStructuredOutput", () => {
|
|
83
|
+
it("passes through sessionId and response", () => {
|
|
84
|
+
const output = { sessionId: "abc", response: "hello" };
|
|
85
|
+
const result = extractStructuredOutput(output);
|
|
86
|
+
assert.equal(result.sessionId, "abc");
|
|
87
|
+
assert.equal(result.response, "hello");
|
|
88
|
+
});
|
|
89
|
+
it("returns empty records when no stats", () => {
|
|
90
|
+
const output = { sessionId: null, response: "hi" };
|
|
91
|
+
const result = extractStructuredOutput(output);
|
|
92
|
+
assert.deepEqual(result.models, {});
|
|
93
|
+
assert.deepEqual(result.tools, {});
|
|
94
|
+
});
|
|
95
|
+
it("extracts model token totals", () => {
|
|
96
|
+
const output = {
|
|
97
|
+
sessionId: null,
|
|
98
|
+
response: "hi",
|
|
99
|
+
stats: {
|
|
100
|
+
models: {
|
|
101
|
+
"gemini-2.5-pro": { tokens: { total: 100 } },
|
|
102
|
+
"gemini-2.0-flash": { tokens: { total: 50 } },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
assert.deepEqual(extractStructuredOutput(output).models, {
|
|
107
|
+
"gemini-2.5-pro": 100,
|
|
108
|
+
"gemini-2.0-flash": 50,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
it("omits models with no token data", () => {
|
|
112
|
+
const output = {
|
|
113
|
+
sessionId: null,
|
|
114
|
+
response: "hi",
|
|
115
|
+
stats: { models: { "gemini-2.5-pro": {} } },
|
|
116
|
+
};
|
|
117
|
+
assert.deepEqual(extractStructuredOutput(output).models, {});
|
|
118
|
+
});
|
|
119
|
+
it("extracts tool call counts from byName", () => {
|
|
120
|
+
const output = {
|
|
121
|
+
sessionId: null,
|
|
122
|
+
response: "hi",
|
|
123
|
+
stats: {
|
|
124
|
+
tools: {
|
|
125
|
+
byName: {
|
|
126
|
+
list_directory: { count: 2 },
|
|
127
|
+
web_fetch: { count: 1 },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
assert.deepEqual(extractStructuredOutput(output).tools, {
|
|
133
|
+
list_directory: 2,
|
|
134
|
+
web_fetch: 1,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
it("returns empty tools when no tool calls", () => {
|
|
138
|
+
const output = {
|
|
139
|
+
sessionId: null,
|
|
140
|
+
response: "hi",
|
|
141
|
+
stats: { tools: { totalCalls: 0, byName: {} } },
|
|
142
|
+
};
|
|
143
|
+
assert.deepEqual(extractStructuredOutput(output).tools, {});
|
|
144
|
+
});
|
|
145
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danwahl/antigravity-mcp",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "MCP server that exposes Antigravity CLI (agy) as a single tool for Claude Code",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/danwahl/antigravity-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"mcp",
|
|
13
|
+
"antigravity",
|
|
14
|
+
"agy",
|
|
15
|
+
"gemini",
|
|
16
|
+
"claude",
|
|
17
|
+
"llm",
|
|
18
|
+
"ai"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"antigravity-mcp": "dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc && shx chmod +x dist/*.js",
|
|
28
|
+
"test": "node --import tsx/esm --test src/lib.test.ts",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.21.0",
|
|
36
|
+
"zod": "^3.24.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.3.3",
|
|
40
|
+
"shx": "^0.3.4",
|
|
41
|
+
"tsx": "^4.21.0",
|
|
42
|
+
"typescript": "^5.8.0"
|
|
43
|
+
}
|
|
44
|
+
}
|