Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const ESM_TESTS = [
'apiSecurity',
'botBallotsUpsert',
'governanceActiveProposals',
'mcpConnections',
'og',
'pendingTransactions',
'reviewSignersCardKey',
Expand Down
191 changes: 191 additions & 0 deletions src/__tests__/mcpConnections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";

/**
* The MCP connections router — listing and revoking OAuth grants from the
* profile page. The revoke path is an authorization boundary: it must act only
* on grants belonging to the wallet session making the request.
*/

/* eslint-disable @typescript-eslint/no-explicit-any */
type AnyAsyncMock = jest.Mock<(...args: any[]) => any>;

const grantFindMany = jest.fn() as AnyAsyncMock;
const grantFindUnique = jest.fn() as AnyAsyncMock;
const grantDelete = jest.fn() as AnyAsyncMock;
const clientFindMany = jest.fn() as AnyAsyncMock;
const tokenFindMany = jest.fn() as AnyAsyncMock;
const tokenUpdateMany = jest.fn() as AnyAsyncMock;
const transaction = jest.fn() as AnyAsyncMock;
const auditMock = jest.fn() as AnyAsyncMock;

// ESM-mode mocking: this suite imports the tRPC router, which pulls superjson —
// ESM-only, and unusable in the CJS jest project. Registered in ESM_TESTS.
jest.unstable_mockModule("@/lib/observability/audit", () => ({
__esModule: true,
audit: auditMock,
}));

const ADDR = "addr1qpuser";
const OTHER = "addr1qpsomeoneelse";

function ctx(session: { primaryWallet?: string | null; sessionWallets?: string[] }) {
// protectedProcedure gates on a non-empty sessionWallets (src/server/api/trpc.ts),
// and the real context always sets both together, so mirror that here.
const sessionWallets =
session.sessionWallets ?? (session.primaryWallet ? [session.primaryWallet] : []);
return {
...session,
sessionWallets,
db: {
oAuthGrant: {
findMany: grantFindMany,
findUnique: grantFindUnique,
delete: grantDelete,
},
oAuthClient: { findMany: clientFindMany },
oAuthRefreshToken: { findMany: tokenFindMany, updateMany: tokenUpdateMany },
$transaction: transaction,
},
};
}

let caller: (c: unknown) => any;

beforeEach(async () => {
jest.clearAllMocks();
transaction.mockImplementation(async (ops: unknown[]) => [undefined, { count: 2 }]);
grantDelete.mockResolvedValue({});
tokenUpdateMany.mockResolvedValue({ count: 2 });
const { mcpRouter } = await import("@/server/api/routers/mcp");
caller = (c) => (mcpRouter as any).createCaller(c);
});

describe("listConnections", () => {
it("returns nothing when the address has approved no clients", async () => {
grantFindMany.mockResolvedValue([]);
const out = await caller(ctx({ primaryWallet: ADDR })).listConnections({
requesterAddress: ADDR,
});
expect(out).toEqual([]);
// No point querying clients or tokens when there are no grants.
expect(clientFindMany).not.toHaveBeenCalled();
});

it("joins client metadata and counts only live sessions", async () => {
grantFindMany.mockResolvedValue([
{
id: "g1",
clientId: "https://claude.ai/oauth/x",
subjectAddress: ADDR,
scopes: ["wallets:read"],
grantedAddresses: [ADDR],
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-02"),
},
]);
clientFindMany.mockResolvedValue([
{ clientId: "https://claude.ai/oauth/x", clientName: "Claude Code", clientUri: null, isMetadataUrl: true },
]);
tokenFindMany.mockResolvedValue([
{ clientId: "https://claude.ai/oauth/x", expiresAt: new Date(Date.now() + 60_000) },
// Expired: still unrevoked in the DB, but must not count as active.
{ clientId: "https://claude.ai/oauth/x", expiresAt: new Date(Date.now() - 60_000) },
]);

const [conn] = await caller(ctx({ primaryWallet: ADDR })).listConnections({
requesterAddress: ADDR,
});

expect(conn).toMatchObject({
clientName: "Claude Code",
isMetadataUrl: true,
scopes: ["wallets:read"],
activeSessions: 1,
});
});

it("scopes the query to the session address", async () => {
grantFindMany.mockResolvedValue([]);
await caller(ctx({ primaryWallet: ADDR })).listConnections({ requesterAddress: ADDR });
expect(grantFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { subjectAddress: ADDR } }),
);
});

it("labels a client the AS has no record of", async () => {
grantFindMany.mockResolvedValue([
{
id: "g1", clientId: "gone", subjectAddress: ADDR, scopes: [], grantedAddresses: [ADDR],
createdAt: new Date(), updatedAt: new Date(),
},
]);
clientFindMany.mockResolvedValue([]);
tokenFindMany.mockResolvedValue([]);
const [conn] = await caller(ctx({ primaryWallet: ADDR })).listConnections({
requesterAddress: ADDR,
});
expect(conn.clientName).toBe("Unknown client");
});
});

describe("revokeConnection", () => {
it("deletes the grant and revokes its refresh tokens", async () => {
grantFindUnique.mockResolvedValue({ id: "g1", scopes: ["wallets:read"] });

const out = await caller(ctx({ primaryWallet: ADDR })).revokeConnection({
clientId: "c1",
requesterAddress: ADDR,
});

expect(out).toEqual({ ok: true, refreshTokensRevoked: 2 });
// Both writes go through one transaction — a deleted grant with live
// refresh tokens would let the client silently keep renewing.
expect(transaction).toHaveBeenCalled();
expect(tokenUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { subjectAddress: ADDR, clientId: "c1", revokedAt: null },
}),
);
});

it("refuses an address the wallet session does not hold", async () => {
// The body is attacker-controlled; only the session decides who you are.
await expect(
caller(ctx({ primaryWallet: ADDR })).revokeConnection({
clientId: "c1",
requesterAddress: OTHER,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(transaction).not.toHaveBeenCalled();
});

it("refuses when there is no wallet session at all", async () => {
await expect(
caller(ctx({ primaryWallet: null, sessionWallets: [] })).revokeConnection({
clientId: "c1",
requesterAddress: ADDR,
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("looks the grant up by (subject, client), not client alone", async () => {
grantFindUnique.mockResolvedValue({ id: "g1", scopes: [] });
await caller(ctx({ sessionWallets: [ADDR] })).revokeConnection({
clientId: "c1",
requesterAddress: ADDR,
});
expect(grantFindUnique).toHaveBeenCalledWith({
where: { subjectAddress_clientId: { subjectAddress: ADDR, clientId: "c1" } },
});
});

it("404s on a grant that does not exist", async () => {
grantFindUnique.mockResolvedValue(null);
await expect(
caller(ctx({ primaryWallet: ADDR })).revokeConnection({
clientId: "nope",
requesterAddress: ADDR,
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});
7 changes: 7 additions & 0 deletions src/__tests__/mcpRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@ describe("POST /api/mcp — transport", () => {
expect(challenge).toMatch(/^Bearer /);
expect(challenge).toContain("resource_metadata=");
expect(challenge).toContain("/.well-known/oauth-protected-resource");
// Clients request exactly the challenge's `scope`, not scopes_supported, so
// both read scopes must appear here or their tools are unreachable in
// practice. ballots:write is intentionally absent — the one write scope
// stays opt-in.
expect(challenge).toContain("wallets:read");
expect(challenge).toContain("governance:read");
expect(challenge).not.toContain("ballots:write");
});

it("serves tools/list on the modern protocol era", async () => {
Expand Down
69 changes: 59 additions & 10 deletions src/components/pages/homepage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -662,16 +662,65 @@ export function PageHomepage() {
title="Connect an AI agent (MCP)"
description="A Model Context Protocol endpoint, so Claude and other MCP clients can read your wallets directly."
>
<div className="mt-4 space-y-3 text-sm">
<p className="text-muted-foreground">
Endpoint: <code className="rounded bg-muted px-1">POST /api/mcp</code>. Add it to Claude Code with:
</p>
<pre className="overflow-x-auto rounded bg-muted p-3 text-xs">
<code>claude mcp add --transport http mesh-multisig https://multisig.meshjs.dev/api/mcp</code>
</pre>
<p className="text-muted-foreground">
You&apos;ll be sent to a consent screen to approve access with your wallet — no secrets to copy. The connection is <strong>read-only</strong> apart from governance ballot drafts: it can list wallets, pending transactions, UTxOs, proxies and active proposals, but it cannot sign transactions, move funds, or vote on-chain.
</p>
<div className="mt-4 space-y-5 text-sm">
<ol className="space-y-4">
<li className="space-y-2">
<div className="flex items-baseline gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">1</span>
<span className="font-medium">Add the server</span>
</div>
<p className="pl-7 text-muted-foreground">In Claude Code:</p>
<pre className="ml-7 overflow-x-auto rounded bg-muted p-3 text-xs">
<code>claude mcp add --transport http mesh-multisig https://multisig.meshjs.dev/api/mcp</code>
</pre>
<p className="pl-7 text-xs text-muted-foreground">
For other clients, point them at{" "}
<code className="rounded bg-muted px-1">https://multisig.meshjs.dev/api/mcp</code>{" "}
over streamable HTTP. There is no API key to paste — the
server advertises OAuth and the client discovers the rest.
</p>
</li>

<li className="space-y-2">
<div className="flex items-baseline gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">2</span>
<span className="font-medium">Authorize with your wallet</span>
</div>
<p className="pl-7 text-muted-foreground">
Run <code className="rounded bg-muted px-1">/mcp</code> and
pick the server. A consent screen opens here: connect your
wallet, sign, and approve. You&apos;ll see exactly which
client is asking and what it will be able to read.
</p>
</li>

<li className="space-y-2">
<div className="flex items-baseline gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">3</span>
<span className="font-medium">Ask it something</span>
</div>
<p className="pl-7 text-muted-foreground">
&ldquo;List the pending transactions on our treasury&rdquo; ·
&ldquo;What can we actually spend right now?&rdquo; ·
&ldquo;Which active governance proposals still need a decision?&rdquo;
</p>
</li>
</ol>

<div className="rounded-lg border border-dashed p-3">
<p className="text-xs text-muted-foreground">
<strong className="text-foreground">Read-only, by design.</strong>{" "}
A connected client can list wallets, pending transactions,
spendable UTxOs, proxies and active proposals, and draft
governance ballot rationales. It <strong>cannot</strong> sign
transactions, move funds, or submit a vote on-chain. Manage or
revoke connections any time under{" "}
<Link href="/user" className="underline underline-offset-2">
your profile
</Link>
.
</p>
</div>
</div>
</CardUI>
</div>
Expand Down
Loading
Loading