Skip to content

Commit d6651f2

Browse files
authored
feat(server): connect claude.ai and other remote MCP clients to a CLI-run server (#65)
The server's MCP OAuth sent the owner to vana://mcp-authorization, a Vana Desktop link, so adding a CLI-run server as a claude.ai connector dead-ended on any machine without Desktop. The server process now serves its own approval page on 127.0.0.1 and uses it as the approval URL: it shows who is asking (Claude, from the redirect), lists the owner's scopes ticked, and approves the chosen ones (or denies back to the client with access_denied) through the server's owner-only API with the token only that process holds. Host-checked against DNS rebinding, one-time form token, no framing. `vana server status` shows the MCP URL to paste into claude.ai (<public URL>/mcp). Claude-Session: https://claude.ai/code/session_01Fcv6uEy4zcXaigzDNxeW3j
1 parent 48572c6 commit d6651f2

7 files changed

Lines changed: 515 additions & 2 deletions

File tree

‎scripts/assert-pack-contents.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const requiredPaths = [
77
"dist/vendor/pdpp-connector-manager/index.mjs",
88
"dist/personal-server/local/runtime-pkg/entry.mjs",
99
"dist/personal-server/local/runtime-pkg/derived-config.mjs",
10+
"dist/personal-server/local/runtime-pkg/mcp-approval.mjs",
1011
];
1112

1213
const raw = runNpmPackDryRun();

‎scripts/assert-sea-artifact.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const requiredDirectoryEntries = [
3535
"app/dist/runtime/managed-playwright.js",
3636
"app/dist/personal-server/local/runtime-pkg/entry.mjs",
3737
"app/dist/personal-server/local/runtime-pkg/derived-config.mjs",
38+
"app/dist/personal-server/local/runtime-pkg/mcp-approval.mjs",
3839
];
3940

4041
for (const relativePath of requiredDirectoryEntries) {

‎src/cli/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2936,6 +2936,12 @@ async function runServerStatus(
29362936
);
29372937
}
29382938

2939+
if (live) {
2940+
// Paste into claude.ai (Settings, Connectors) or any remote MCP client;
2941+
// the owner approves it once on this machine.
2942+
emit.keyValue("MCP URL", `${live.url.replace(/\/+$/, "")}/mcp`, "muted");
2943+
}
2944+
29392945
if (target.url) {
29402946
const runBy =
29412947
dataDir?.runBy === "cli"

‎src/personal-server/local/runtime-pkg/entry.mjs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "@opendatalabs/personal-server-ts/node";
2323

2424
import { applyDerived, derivedConfig } from "./derived-config.mjs";
25+
import { startMcpApprovalPage } from "./mcp-approval.mjs";
2526

2627
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
2728

@@ -38,6 +39,7 @@ async function readInput() {
3839
}
3940

4041
let ps = null;
42+
let mcpApproval = null;
4143

4244
// Only a tunnel URL may be registered: without one the server offers its
4345
// localhost origin, which no app could ever reach.
@@ -171,12 +173,20 @@ async function main() {
171173
configDefaults[key] = applyDerived(loaded[key], derived[key]);
172174
}
173175

176+
// Where the server sends the owner to approve an MCP client (claude.ai's
177+
// connector): a page this process serves on 127.0.0.1, not Vana Desktop's
178+
// vana:// link, which dead-ends on a machine without Desktop.
179+
mcpApproval = await startMcpApprovalPage({
180+
serverOrigin: () => `http://127.0.0.1:${port}`,
181+
accessToken: process.env.PS_ACCESS_TOKEN ?? "",
182+
});
183+
174184
ps = await startPersonalServer({
175185
configPath,
176186
rootPath,
177187
port,
178188
ownerSignature,
179-
mcpOAuthApprovalUrl: "vana://mcp-authorization",
189+
mcpOAuthApprovalUrl: mcpApproval.url,
180190
configDefaults,
181191
onStatus: (status) => process.stderr.write(`[status] ${status}\n`),
182192
});
@@ -197,6 +207,7 @@ async function main() {
197207

198208
async function shutdown(signal) {
199209
process.stderr.write(`[entry] ${signal}, stopping\n`);
210+
mcpApproval?.close();
200211
try {
201212
await ps?.stop();
202213
} finally {

‎src/personal-server/local/runtime-pkg/mcp-approval.mjs‎

Lines changed: 316 additions & 0 deletions
Large diffs are not rendered by default.

‎src/personal-server/local/runtime.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { localServerRuntimeDir } from "./config.js";
1212
const execFileAsync = promisify(execFile);
1313

1414
/** The scripts the server process runs; they change with the CLI, not the pin. */
15-
const SCRIPT_FILES = ["entry.mjs", "derived-config.mjs"] as const;
15+
const SCRIPT_FILES = [
16+
"entry.mjs",
17+
"derived-config.mjs",
18+
"mcp-approval.mjs",
19+
] as const;
1620

1721
/** Files shipped with the CLI that define the runtime: the pin, its lock, the scripts. */
1822
const ASSET_FILES = [
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import http from "node:http";
2+
import type { AddressInfo } from "node:net";
3+
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
6+
import {
7+
requesterName,
8+
startMcpApprovalPage,
9+
} from "../../src/personal-server/local/runtime-pkg/mcp-approval.mjs";
10+
11+
const TOKEN = "owner-token";
12+
13+
/** A Personal Server with one pending authorization from claude.ai. */
14+
function fakeServer() {
15+
const approvals: Array<{ scopes: string[] }> = [];
16+
let status = "pending";
17+
const server = http.createServer((req, res) => {
18+
if (req.headers.authorization !== `Bearer ${TOKEN}`) {
19+
res.writeHead(401).end();
20+
return;
21+
}
22+
const url = new URL(req.url ?? "/", "http://x");
23+
if (
24+
url.pathname === "/v1/mcp/oauth/authorizations/auth_1" &&
25+
req.method === "GET"
26+
) {
27+
res.end(
28+
JSON.stringify({
29+
id: "auth_1",
30+
redirectUri: "https://claude.ai/api/mcp/auth_callback",
31+
state: "st",
32+
status,
33+
}),
34+
);
35+
return;
36+
}
37+
if (url.pathname === "/v1/mcp/oauth/authorizations/auth_1/approve") {
38+
let raw = "";
39+
req.on("data", (chunk) => (raw += chunk));
40+
req.on("end", () => {
41+
approvals.push(JSON.parse(raw));
42+
status = "approved";
43+
res.end(
44+
JSON.stringify({
45+
redirectTo:
46+
"https://claude.ai/api/mcp/auth_callback?code=c1&state=st",
47+
}),
48+
);
49+
});
50+
return;
51+
}
52+
if (url.pathname === "/v1/data") {
53+
res.end(
54+
JSON.stringify({
55+
scopes: [{ scope: "github.profile" }, { scope: "linkedin.profile" }],
56+
total: 2,
57+
}),
58+
);
59+
return;
60+
}
61+
res.writeHead(404).end();
62+
});
63+
return { server, approvals };
64+
}
65+
66+
describe("MCP approval page", () => {
67+
let backend: ReturnType<typeof fakeServer>;
68+
let page: { url: string; close: () => void };
69+
70+
beforeEach(async () => {
71+
backend = fakeServer();
72+
await new Promise<void>((resolve) =>
73+
backend.server.listen(0, "127.0.0.1", resolve),
74+
);
75+
const { port } = backend.server.address() as AddressInfo;
76+
page = await startMcpApprovalPage({
77+
serverOrigin: () => `http://127.0.0.1:${port}`,
78+
accessToken: TOKEN,
79+
});
80+
});
81+
afterEach(() => {
82+
page.close();
83+
backend.server.close();
84+
});
85+
86+
async function open() {
87+
const response = await fetch(`${page.url}?mcp_authorization=auth_1`);
88+
const html = await response.text();
89+
const token = /name="token" value="([^"]+)"/.exec(html)?.[1] ?? "";
90+
return { response, html, token };
91+
}
92+
93+
const post = (body: URLSearchParams) =>
94+
fetch(page.url, { method: "POST", body, redirect: "manual" });
95+
96+
it("shows who asks and every scope, ticked", async () => {
97+
const { response, html } = await open();
98+
expect(response.status).toBe(200);
99+
expect(html).toContain("Allow Claude to read your data");
100+
expect(html).toContain('value="github.profile" checked');
101+
expect(html).toContain('value="linkedin.profile" checked');
102+
});
103+
104+
it("approves the ticked scopes and sends the browser back to Claude", async () => {
105+
const { token } = await open();
106+
const response = await post(
107+
new URLSearchParams([
108+
["id", "auth_1"],
109+
["token", token],
110+
["action", "approve"],
111+
["scope", "github.profile"],
112+
]),
113+
);
114+
expect(response.status).toBe(303);
115+
expect(response.headers.get("location")).toContain("code=c1");
116+
expect(backend.approvals).toEqual([{ scopes: ["github.profile"] }]);
117+
});
118+
119+
it("denies back to Claude with access_denied, sharing nothing", async () => {
120+
const { token } = await open();
121+
const response = await post(
122+
new URLSearchParams({ id: "auth_1", token, action: "deny" }),
123+
);
124+
expect(response.status).toBe(303);
125+
const location = new URL(response.headers.get("location") ?? "");
126+
expect(location.searchParams.get("error")).toBe("access_denied");
127+
expect(location.searchParams.get("state")).toBe("st");
128+
expect(backend.approvals).toHaveLength(0);
129+
});
130+
131+
it("refuses a forged or reused form token", async () => {
132+
const forged = await post(
133+
new URLSearchParams({
134+
id: "auth_1",
135+
token: "x",
136+
action: "approve",
137+
scope: "github.profile",
138+
}),
139+
);
140+
expect(forged.status).toBe(403);
141+
const { token } = await open();
142+
const body = new URLSearchParams({ id: "auth_1", token, action: "deny" });
143+
expect((await post(body)).status).toBe(303);
144+
expect((await post(body)).status).toBe(403);
145+
});
146+
147+
it("refuses a request that reaches it under another host name", async () => {
148+
const { port } = new URL(page.url);
149+
const response = await new Promise<number>((resolve) => {
150+
http
151+
.get(
152+
{
153+
host: "127.0.0.1",
154+
port,
155+
path: "/mcp?mcp_authorization=auth_1",
156+
headers: { host: "evil.example" },
157+
},
158+
(res) => {
159+
res.resume();
160+
resolve(res.statusCode ?? 0);
161+
},
162+
)
163+
.on("error", () => resolve(0));
164+
});
165+
expect(response).toBe(400);
166+
});
167+
168+
it("names the requester from where it sends the owner back", () => {
169+
expect(requesterName("https://claude.ai/api/mcp/auth_callback")).toBe(
170+
"Claude",
171+
);
172+
expect(requesterName("https://app.example.com/cb")).toBe("app.example.com");
173+
});
174+
});

0 commit comments

Comments
 (0)