Skip to content

Commit ce004e9

Browse files
feat: add hosted Cap MCP connector (#2332)
* feat: add hosted Cap MCP connector * fix: expire unused MCP client registrations * test: check generated MCP card in CI * fix: bound MCP client registration growth * fix: route hosted MCP through lightweight Effect APIs * fix: declare MCP discovery runtime locally
1 parent 219e9e2 commit ce004e9

35 files changed

Lines changed: 16968 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ jobs:
8787

8888
- run: bun run --cwd apps/web next typegen
8989

90+
- name: Check MCP card bundle
91+
run: bun run --cwd apps/web mcp-card:check
92+
9093
- name: Test workspace tooling
9194
run: node --test scripts/bun-scripts.test.mjs scripts/check-tauri-plugin-versions.test.mjs
9295

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { createHash } from "node:crypto";
2+
import { User } from "@cap/web-domain";
3+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
vi.mock("server-only", () => ({}));
6+
const databaseState = vi.hoisted(() => ({
7+
selected: null as Record<string, unknown> | null,
8+
inserted: [] as Record<string, unknown>[],
9+
updated: 0,
10+
quotaCount: 0,
11+
}));
12+
vi.mock("@cap/database", () => ({
13+
db: () => ({
14+
insert: () => ({
15+
values: async (row: Record<string, unknown>) => {
16+
databaseState.inserted.push(row);
17+
},
18+
}),
19+
transaction: async (callback: (tx: unknown) => Promise<unknown>) =>
20+
callback({
21+
select: () => ({
22+
from: () => ({
23+
where: () => ({
24+
limit: () =>
25+
Object.assign(Promise.resolve([databaseState.selected]), {
26+
for: async () => [
27+
{ registrations: databaseState.quotaCount },
28+
],
29+
}),
30+
}),
31+
}),
32+
}),
33+
update: () => ({
34+
set: () => ({
35+
where: async () => {
36+
databaseState.updated += 1;
37+
return [{ affectedRows: 1 }];
38+
},
39+
}),
40+
}),
41+
insert: () => ({
42+
values: (row: Record<string, unknown>) => {
43+
if ("clientId" in row || "codeHash" in row || "accessHash" in row)
44+
databaseState.inserted.push(row);
45+
return { onDuplicateKeyUpdate: async () => undefined };
46+
},
47+
}),
48+
}),
49+
}),
50+
}));
51+
52+
import {
53+
createMcpAuthorizationCode,
54+
exchangeMcpCode,
55+
isMcpRedirectUri,
56+
parseMcpAuthorizationRequest,
57+
refreshMcpTokens,
58+
} from "@/lib/mcp-auth";
59+
60+
const resource = "https://cap.so/api/mcp";
61+
const challenge = createHash("sha256")
62+
.update("v".repeat(43))
63+
.digest("base64url");
64+
65+
const validRequest = () =>
66+
new URLSearchParams({
67+
client_id: "cap_mcp_client_test",
68+
redirect_uri: "https://chatgpt.com/connector/callback",
69+
response_type: "code",
70+
state: "state-value",
71+
code_challenge: challenge,
72+
code_challenge_method: "S256",
73+
scope: "caps:read",
74+
resource,
75+
});
76+
77+
describe("MCP OAuth authorization", () => {
78+
beforeAll(() => {
79+
Object.assign(process.env, {
80+
WEB_URL: "https://cap.so",
81+
NEXTAUTH_URL: "https://cap.so",
82+
NEXTAUTH_SECRET: "synthetic-test-secret-that-is-long-enough",
83+
DATABASE_URL: "mysql://unused:unused@127.0.0.1:3306/unused",
84+
CAP_AWS_BUCKET: "synthetic",
85+
CAP_AWS_REGION: "us-east-1",
86+
});
87+
});
88+
89+
beforeEach(() => {
90+
databaseState.selected = null;
91+
databaseState.inserted = [];
92+
databaseState.updated = 0;
93+
databaseState.quotaCount = 0;
94+
});
95+
it("accepts only secure or explicit loopback redirect URIs", () => {
96+
expect(isMcpRedirectUri("https://chatgpt.com/connector/callback")).toBe(
97+
true,
98+
);
99+
expect(isMcpRedirectUri("http://127.0.0.1:49152/callback")).toBe(true);
100+
for (const uri of [
101+
"http://chatgpt.com/connector/callback",
102+
"http://localhost:49152/callback",
103+
"http://127.0.0.1/callback",
104+
"https://chatgpt.com/connector/callback#fragment",
105+
"https://user:password@chatgpt.com/callback",
106+
"javascript:alert(1)",
107+
])
108+
expect(isMcpRedirectUri(uri)).toBe(false);
109+
});
110+
111+
it("binds authorization to PKCE S256, read scope, and the exact resource", () => {
112+
expect(
113+
parseMcpAuthorizationRequest(validRequest(), resource),
114+
).toMatchObject({
115+
clientId: "cap_mcp_client_test",
116+
resource,
117+
codeChallenge: challenge,
118+
});
119+
for (const [key, value] of [
120+
["code_challenge_method", "plain"],
121+
["scope", "caps:read caps:write"],
122+
["resource", "https://cap.so/api/v1"],
123+
["redirect_uri", "https://attacker.example/callback#fragment"],
124+
] as const) {
125+
const params = validRequest();
126+
params.set(key, value);
127+
expect(parseMcpAuthorizationRequest(params, resource)).toBeNull();
128+
}
129+
});
130+
131+
it("activates a registered client when consent issues a code", async () => {
132+
const request = parseMcpAuthorizationRequest(validRequest(), resource);
133+
expect(request).not.toBeNull();
134+
if (!request) return;
135+
const code = await createMcpAuthorizationCode(
136+
User.UserId.make("owner"),
137+
request,
138+
);
139+
expect(code).toMatch(/^cap_mcp_code_[A-Za-z0-9_-]{43}$/);
140+
expect(databaseState.updated).toBe(1);
141+
expect(databaseState.inserted[0]).toMatchObject({
142+
userId: "owner",
143+
clientId: request.clientId,
144+
resource,
145+
});
146+
});
147+
148+
it("exchanges a single PKCE-bound code for a resource-bound token", async () => {
149+
const code = `cap_mcp_code_${"c".repeat(43)}`;
150+
databaseState.selected = {
151+
id: "grant",
152+
userId: "owner",
153+
clientId: "cap_mcp_client_test",
154+
redirectUri: "https://chatgpt.com/connector/callback",
155+
resource,
156+
codeChallenge: challenge,
157+
consumedAt: null,
158+
expiresAt: new Date(Date.now() + 60_000),
159+
};
160+
const params = new URLSearchParams({
161+
code,
162+
client_id: "cap_mcp_client_test",
163+
redirect_uri: "https://chatgpt.com/connector/callback",
164+
code_verifier: "v".repeat(43),
165+
resource,
166+
});
167+
const issued = await exchangeMcpCode(params);
168+
expect(issued?.access_token).toMatch(/^cap_mcp_[A-Za-z0-9_-]{43}$/);
169+
expect(issued?.refresh_token).toMatch(
170+
/^cap_mcp_refresh_[A-Za-z0-9_-]{43}$/,
171+
);
172+
expect(databaseState.updated).toBe(1);
173+
expect(databaseState.inserted[0]).toMatchObject({
174+
userId: "owner",
175+
clientId: "cap_mcp_client_test",
176+
resource,
177+
});
178+
params.set("resource", "https://cap.so/api/v1");
179+
expect(await exchangeMcpCode(params)).toBeNull();
180+
expect(databaseState.inserted).toHaveLength(1);
181+
});
182+
183+
it("revokes a refresh family when a used refresh token is replayed", async () => {
184+
databaseState.selected = {
185+
id: "old-token",
186+
clientId: "cap_mcp_client_test",
187+
resource,
188+
familyId: "family",
189+
revokedAt: new Date(),
190+
refreshExpiresAt: new Date(Date.now() + 60_000),
191+
};
192+
const outcome = await refreshMcpTokens(
193+
new URLSearchParams({
194+
refresh_token: `cap_mcp_refresh_${"r".repeat(43)}`,
195+
client_id: "cap_mcp_client_test",
196+
resource,
197+
}),
198+
);
199+
expect(outcome).toBeNull();
200+
expect(databaseState.updated).toBe(1);
201+
expect(databaseState.inserted).toHaveLength(0);
202+
});
203+
204+
it("registers only public clients with exact safe callback URLs", async () => {
205+
const { POST } = await import("@/app/api/mcp/oauth/register/route");
206+
const post = (redirectUri: string) =>
207+
POST(
208+
new Request("https://cap.so/api/mcp/oauth/register", {
209+
method: "POST",
210+
headers: { "Content-Type": "application/json" },
211+
body: JSON.stringify({
212+
client_name: "Chat host",
213+
redirect_uris: [redirectUri],
214+
token_endpoint_auth_method: "none",
215+
}),
216+
}),
217+
);
218+
expect((await post("http://attacker.example/callback")).status).toBe(400);
219+
expect(databaseState.inserted).toHaveLength(0);
220+
const response = await post("https://chatgpt.com/connector/callback");
221+
expect(response.status).toBe(201);
222+
expect(await response.json()).toMatchObject({
223+
client_name: "Chat host",
224+
redirect_uris: ["https://chatgpt.com/connector/callback"],
225+
token_endpoint_auth_method: "none",
226+
});
227+
expect(databaseState.inserted).toHaveLength(1);
228+
const unnamed = await POST(
229+
new Request("https://cap.so/api/mcp/oauth/register", {
230+
method: "POST",
231+
headers: { "Content-Type": "application/json" },
232+
body: JSON.stringify({
233+
redirect_uris: ["https://claude.ai/oauth/callback"],
234+
}),
235+
}),
236+
);
237+
expect(unnamed.status).toBe(201);
238+
expect(await unnamed.json()).toMatchObject({ client_name: "MCP client" });
239+
databaseState.inserted = [];
240+
databaseState.quotaCount = 5_000;
241+
expect((await post("https://chatgpt.com/connector/callback")).status).toBe(
242+
429,
243+
);
244+
expect(databaseState.inserted).toHaveLength(0);
245+
});
246+
247+
it("preserves OAuth error and revocation responses", async () => {
248+
const { POST: token } = await import("@/app/api/mcp/oauth/token/route");
249+
const { POST: revoke } = await import("@/app/api/mcp/oauth/revoke/route");
250+
const unsupported = await token(
251+
new Request("https://cap.so/api/mcp/oauth/token", {
252+
method: "POST",
253+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
254+
body: "grant_type=client_credentials",
255+
}),
256+
);
257+
expect(unsupported.status).toBe(400);
258+
expect(await unsupported.json()).toEqual({
259+
error: "unsupported_grant_type",
260+
});
261+
expect(unsupported.headers.get("cache-control")).toBe("no-store");
262+
const invalid = await revoke(
263+
new Request("https://cap.so/api/mcp/oauth/revoke", {
264+
method: "POST",
265+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
266+
body: "client_id=client",
267+
}),
268+
);
269+
expect(invalid.status).toBe(400);
270+
expect(await invalid.json()).toEqual({ error: "invalid_request" });
271+
});
272+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { User } from "@cap/web-domain";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("server-only", () => ({}));
5+
const databaseState = vi.hoisted(() => ({
6+
results: [] as unknown[][],
7+
selectCount: 0,
8+
}));
9+
const runPromise = vi.hoisted(() => vi.fn());
10+
vi.mock("@cap/database", () => ({
11+
db: () => ({
12+
select: () => {
13+
const rows = databaseState.results[databaseState.selectCount++] ?? [];
14+
const whereResult = Object.assign(Promise.resolve(rows), {
15+
limit: async () => rows,
16+
orderBy: () => ({ limit: async () => rows }),
17+
});
18+
const chain = {
19+
from: () => chain,
20+
innerJoin: () => chain,
21+
where: () => whereResult,
22+
};
23+
return chain;
24+
},
25+
}),
26+
}));
27+
vi.mock("@/lib/server", () => ({ runPromise }));
28+
vi.mock("@/lib/mcp-auth", () => ({ mcpIssuer: () => "https://cap.so" }));
29+
30+
import { getMcpCapContext, listMcpCaps } from "@/lib/mcp-data";
31+
32+
const ownedRow = {
33+
id: "video-1",
34+
ownerId: "owner",
35+
name: "Demo recording",
36+
duration: 120,
37+
createdAt: new Date("2026-09-20T10:00:00.000Z"),
38+
updatedAt: new Date("2026-09-21T10:00:00.000Z"),
39+
metadata: { summary: "Private notes" },
40+
videoSettings: null,
41+
organizationSettings: null,
42+
transcriptionStatus: "COMPLETE",
43+
};
44+
45+
describe("hosted MCP data limits", () => {
46+
beforeEach(() => {
47+
databaseState.results = [];
48+
databaseState.selectCount = 0;
49+
runPromise.mockReset();
50+
});
51+
52+
it("returns no context when the owner-scoped lookup finds no Cap", async () => {
53+
databaseState.results = [[]];
54+
expect(
55+
await getMcpCapContext(User.UserId.make("other"), "video-1"),
56+
).toBeNull();
57+
expect(databaseState.selectCount).toBe(1);
58+
expect(runPromise).not.toHaveBeenCalled();
59+
});
60+
61+
it("honors disabled summary and transcript settings before accessing storage", async () => {
62+
databaseState.results = [
63+
[
64+
{
65+
...ownedRow,
66+
videoSettings: { disableSummary: true, disableTranscript: true },
67+
},
68+
],
69+
[],
70+
];
71+
const context = await getMcpCapContext(
72+
User.UserId.make("owner"),
73+
"video-1",
74+
);
75+
expect(context).toMatchObject({
76+
summary: null,
77+
transcriptStatus: "disabled",
78+
cues: [],
79+
});
80+
expect(runPromise).not.toHaveBeenCalled();
81+
});
82+
83+
it("returns bounded timestamp links for a selected transcript", async () => {
84+
databaseState.results = [[ownedRow], []];
85+
runPromise.mockResolvedValue(
86+
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello Cap\n\n00:00:03.000 --> 00:00:04.000\nAnother moment\n",
87+
);
88+
const context = await getMcpCapContext(
89+
User.UserId.make("owner"),
90+
"video-1",
91+
"hello",
92+
);
93+
expect(context).toMatchObject({
94+
summary: "Private notes",
95+
transcriptStatus: "available",
96+
cues: [{ text: "Hello Cap", url: "https://cap.so/s/video-1?t=1" }],
97+
hasMoreCues: false,
98+
});
99+
});
100+
101+
it("limits a list page to 20 recordings", async () => {
102+
databaseState.results = [
103+
Array.from({ length: 21 }, (_, index) => ({
104+
...ownedRow,
105+
id: `video-${index + 1}`,
106+
})),
107+
];
108+
const page = await listMcpCaps(User.UserId.make("owner"), {});
109+
expect(page.caps).toHaveLength(20);
110+
expect(page.nextCursor).toEqual(expect.any(String));
111+
});
112+
});

0 commit comments

Comments
 (0)