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
61 changes: 61 additions & 0 deletions src/__tests__/botBallots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const applyBotRateLimitMock = jest.fn<() => boolean>();
const enforceBodySizeMock = jest.fn<() => boolean>();
const verifyJwtMock: jest.Mock = jest.fn();
const isBotJwtMock: jest.Mock = jest.fn();
const applyAddressRateLimitMock = jest.fn<() => boolean>();
const assertWalletAccessMock: jest.Mock = jest.fn();
const assertBotWalletAccessMock: jest.Mock = jest.fn();
const findBotUserMock: jest.Mock = jest.fn();
const ballotFindManyMock: jest.Mock = jest.fn();
Expand All @@ -34,6 +36,7 @@ jest.mock("@/lib/security/requestGuards", () => ({
__esModule: true,
applyRateLimit: applyRateLimitMock,
applyBotRateLimit: applyBotRateLimitMock,
applyAddressRateLimit: applyAddressRateLimitMock,
enforceBodySize: enforceBodySizeMock,
}));

Expand All @@ -43,6 +46,16 @@ jest.mock("@/lib/verifyJwt", () => ({
isBotJwt: isBotJwtMock,
}));

jest.mock("@/lib/security/rateLimit", () => ({
__esModule: true,
getClientIP: () => "127.0.0.1",
}));

jest.mock("@/server/api/auth", () => ({
__esModule: true,
assertWalletAccess: assertWalletAccessMock,
}));

jest.mock("@/lib/auth/botKey", () => ({
__esModule: true,
parseScope: (scope: string) => JSON.parse(scope) as string[],
Expand Down Expand Up @@ -101,6 +114,8 @@ beforeEach(() => {
jest.clearAllMocks();
applyRateLimitMock.mockReturnValue(true);
applyBotRateLimitMock.mockReturnValue(true);
applyAddressRateLimitMock.mockReturnValue(true);
(assertWalletAccessMock as any).mockResolvedValue({ id: "wallet-1" });
enforceBodySizeMock.mockReturnValue(true);
corsMock.mockResolvedValue(undefined);
verifyJwtMock.mockReturnValue({ address: "addr_bot", botId: "bot-1", type: "bot" });
Expand Down Expand Up @@ -172,4 +187,50 @@ describe("botBallots API", () => {
await handler(request("DELETE", { body: { walletId: "wallet-1", ballotId: "gone" } }), res);
expect(res.status).toHaveBeenCalledWith(404);
});

describe("human (non-bot) callers", () => {
const asHuman = () => {
verifyJwtMock.mockReturnValue({ address: "addr_test1qphuman" });
isBotJwtMock.mockReturnValue(false);
};

it("lets a wallet signer read the ballots", async () => {
asHuman();
const res = createMockResponse();
await handler(request("GET", { query: { walletId: "wallet-1" } }), res);

// Authorized by the shared signer-or-owner predicate, not the bot path.
expect(assertWalletAccessMock).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});

it("does not require the ballot:write bot scope of a human", async () => {
asHuman();
const res = createMockResponse();
await handler(request("GET", { query: { walletId: "wallet-1" } }), res);

// A human has no bot key, so the scope lookup must never run for them.
expect(findBotUserMock).not.toHaveBeenCalled();
expect(applyBotRateLimitMock).not.toHaveBeenCalled();
expect(applyAddressRateLimitMock).toHaveBeenCalled();
});

it("returns 403 when the human is neither signer nor owner", async () => {
asHuman();
(assertWalletAccessMock as any).mockRejectedValue(
Object.assign(new Error("Not authorized for this wallet"), { code: "FORBIDDEN" }),
);
const res = createMockResponse();
await handler(request("GET", { query: { walletId: "wallet-1" } }), res);
expect(res.status).toHaveBeenCalledWith(403);
});

it("still gates bot callers on wallet access", async () => {
// Regression guard: the human branch must not weaken the bot branch.
const res = createMockResponse();
await handler(request("GET", { query: { walletId: "wallet-1" } }), res);
expect(assertBotWalletAccessMock).toHaveBeenCalled();
expect(assertWalletAccessMock).not.toHaveBeenCalled();
});
});
});
3 changes: 3 additions & 0 deletions src/__tests__/mcpTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ describe("MCP tool registry", () => {
"multisig_proxy_drep_info",
"multisig_lookup_wallet",
"governance_list_active_proposals",
"governance_list_ballots",
"governance_vote_history",
"governance_open_proposals",
"ballot_upsert",
]);
});
Expand Down
125 changes: 80 additions & 45 deletions src/components/pages/homepage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import CardUI from "@/components/ui/card-content";
import RowLabelInfo from "@/components/common/row-label-info";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { Database, Bot, Code, Download, Check, Sparkles } from "lucide-react";
import { Database, Bot, Code, Download, Check, Sparkles, Plug } from "lucide-react";
import { Reveal } from "@/components/ui/reveal";
import { Typewriter } from "@/components/ui/typewriter";
import {
Expand All @@ -29,14 +29,15 @@ import {
StakingPreview,
} from "@/components/pages/homepage/previews";

// Example prompts cycled by the "Connect your AI agent" typewriter. The first
// is the literal flow most users start with; the rest hint at what an agent can
// do once it holds the multisig skill.
// Prompts cycled by the hero typewriter. Deliberately phrased as things you
// ask an assistant, not as commands for one product — the endpoint is plain MCP
// and works with any client. Each maps to a tool we actually expose.
const AGENT_PROMPTS = [
"Connect to my https://multisig.meshjs.dev/ wallet",
"List the pending transactions on our treasury",
"Draft a 200 ₳ payout to the dev fund and request signatures",
"Show who still needs to sign the pending payout",
"Which governance proposals still need our vote?",
"How did we vote on the last treasury withdrawal?",
"Draft a rationale for voting No on the budget action",
"What can we actually spend right now?",
];

// DApp Card Component
Expand Down Expand Up @@ -302,48 +303,71 @@ export function PageHomepage() {
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-muted/60 px-3 py-1 text-xs font-medium text-muted-foreground dark:border-zinc-800">
<Sparkles className="h-3.5 w-3.5" />
AI-native multisig
Model Context Protocol
</div>
<h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">
Connect your AI agent
Connect any AI agent
</h2>
<p className="mt-3 text-muted-foreground">
Drop the multisig skill into Claude Code, Cursor, or any agent and let
it work alongside your treasury — read pending transactions, draft
payouts, and track approvals through the authenticated v1 API. The
agent can&apos;t sign for you: keys and signatures always stay with you
and your co-signers.
We speak <strong className="text-foreground">MCP</strong>, the open
standard for connecting AI assistants to real systems. Point any MCP
client at the endpoint, approve it with your wallet, and it can read
your treasury and governance — pending transactions, spendable UTxOs,
open proposals and your voting record.
</p>
<p className="mt-3 text-sm text-muted-foreground">
No API key to paste, and no vendor lock-in: authorization is standard
OAuth, so the client handles it for you.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button asChild size="lg">
<Link href="#connect-mcp">
<Plug className="mr-2 h-4 w-4" />
Setup guide
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<a href="/api/skill" download="multisig-skill.md">
<Download className="mr-2 h-4 w-4" />
Download skill
</a>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="#developers-and-bots">
<Bot className="mr-2 h-4 w-4" />
Developer &amp; bot docs
</Link>
</Button>
</div>
</div>

{/* Animated agent prompt */}
<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-4 font-mono text-sm text-zinc-100 shadow-lg sm:p-5">
<div className="flex items-center gap-1.5 pb-3">
<span className="h-3 w-3 rounded-full bg-red-400/80" />
<span className="h-3 w-3 rounded-full bg-yellow-400/80" />
<span className="h-3 w-3 rounded-full bg-green-400/80" />
<span className="ml-2 text-xs text-zinc-500">agent</span>
</div>
<div className="flex min-h-[5rem] items-start gap-2 leading-relaxed">
<span className="select-none text-emerald-400">›</span>
<Typewriter phrases={AGENT_PROMPTS} className="text-zinc-100" />
{/* Endpoint + a vendor-neutral config, then a taste of what to ask. */}
<div className="flex flex-col gap-3">
<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-4 font-mono text-xs text-zinc-100 shadow-lg sm:p-5">
<div className="pb-2 text-[11px] uppercase tracking-wide text-zinc-500">
MCP endpoint
</div>
<code className="block break-all text-emerald-300">
https://multisig.meshjs.dev/api/mcp
</code>
<div className="mt-4 border-t border-zinc-800 pt-3 text-[11px] uppercase tracking-wide text-zinc-500">
Any MCP client
</div>
<pre className="mt-2 overflow-x-auto leading-relaxed text-zinc-300">
{`{
"mcpServers": {
"mesh-multisig": {
"type": "http",
"url": "https://multisig.meshjs.dev/api/mcp"
}
}
}`}
</pre>
</div>
<div className="mt-3 border-t border-zinc-800 pt-3 text-xs text-zinc-500">
Read &amp; draft only — signing stays with you and your co-signers.

<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-4 font-mono text-sm text-zinc-100 shadow-lg sm:p-5">
<div className="flex min-h-[3.5rem] items-start gap-2 leading-relaxed">
<span className="select-none text-emerald-400">›</span>
<Typewriter phrases={AGENT_PROMPTS} className="text-zinc-100" />
</div>
<div className="mt-3 border-t border-zinc-800 pt-3 text-xs text-zinc-500">
Reads and drafts only — submitting a vote and signing stay with you
and your co-signers.
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -657,10 +681,10 @@ export function PageHomepage() {
</CardUI>
</div>

<div className="mt-8">
<div className="mt-8" id="connect-mcp">
<CardUI
title="Connect an AI agent (MCP)"
description="A Model Context Protocol endpoint, so Claude and other MCP clients can read your wallets directly."
description="An open Model Context Protocol endpoint — works with any MCP-capable client, no vendor lock-in."
>
<div className="mt-4 space-y-5 text-sm">
<ol className="space-y-4">
Expand All @@ -669,15 +693,25 @@ export function PageHomepage() {
<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>
<p className="pl-7 text-muted-foreground">
Most clients read a JSON config. Add one entry:
</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>
{`{
"mcpServers": {
"mesh-multisig": {
"type": "http",
"url": "https://multisig.meshjs.dev/api/mcp"
}
}
}`}
</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.
Some clients have a CLI for the same thing — for example{" "}
<code className="rounded bg-muted px-1">claude mcp add --transport http mesh-multisig https://multisig.meshjs.dev/api/mcp</code>.
Anything that speaks streamable HTTP MCP works; there is no
API key to paste, because the server advertises OAuth and the
client discovers the rest.
</p>
</li>

Expand All @@ -687,10 +721,11 @@ export function PageHomepage() {
<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.
Your client will send you here to a consent screen the first
time it connects: connect your wallet, sign, and approve.
You&apos;ll see exactly which client is asking and what it
will be able to read, and you can revoke it any time from
your profile.
</p>
</li>

Expand Down
45 changes: 45 additions & 0 deletions src/lib/mcp/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,48 @@ export const BALLOT_UPSERT_INPUT: JsonSchema = {
required: ["walletId", "proposals"],
additionalProperties: false,
};

export const WALLET_BALLOTS_INPUT: JsonSchema = {
type: "object",
properties: { walletId },
required: ["walletId"],
additionalProperties: false,
};

export const VOTE_HISTORY_INPUT: JsonSchema = {
type: "object",
properties: {
walletId,
limit: {
type: "integer",
minimum: 1,
maximum: 100,
default: 25,
description: "Most recent votes to return, newest first.",
},
},
required: ["walletId"],
additionalProperties: false,
};

export const OPEN_PROPOSALS_INPUT: JsonSchema = {
type: "object",
properties: {
walletId,
count: {
type: "integer",
minimum: 1,
maximum: 25,
default: 10,
description: "Active proposals to consider (max 25).",
},
includeVoted: {
type: "boolean",
default: false,
description:
"Include proposals this wallet's DRep has already voted on, annotated with the vote. Off by default, so the result is the outstanding decisions.",
},
},
required: ["walletId"],
additionalProperties: false,
};
Loading
Loading