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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"test": "node scripts/run-tests.mjs",
"test:cjs": "jest",
"test:esm": "node --experimental-vm-modules node_modules/jest/bin/jest.js -c jest.esm.config.mjs --passWithNoTests",
"test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/oauthTokens.test.ts src/__tests__/oauthFlow.test.ts src/__tests__/oauthRefreshStore.test.ts src/__tests__/jestMockHygiene.test.ts src/__tests__/rationaleAnchor.test.ts",
"test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/oauthTokens.test.ts src/__tests__/oauthFlow.test.ts src/__tests__/oauthRefreshStore.test.ts src/__tests__/jestMockHygiene.test.ts src/__tests__/rationaleAnchor.test.ts src/__tests__/mcpConnections.test.ts",
"test:bot:integration": "jest src/__tests__/botApi.integration.test.ts --runInBand",
"test:bot": "npm run test:bot:unit && npm run test:bot:integration",
"test:watch": "jest --watch",
Expand Down
135 changes: 135 additions & 0 deletions src/__tests__/mcpConnections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ 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 grantUpdate = jest.fn() as AnyAsyncMock;
const auditFindMany = jest.fn() as AnyAsyncMock;
const walletFindUnique = jest.fn() as AnyAsyncMock;
const clientFindMany = jest.fn() as AnyAsyncMock;
const tokenFindMany = jest.fn() as AnyAsyncMock;
const tokenUpdateMany = jest.fn() as AnyAsyncMock;
Expand Down Expand Up @@ -41,7 +44,10 @@ function ctx(session: { primaryWallet?: string | null; sessionWallets?: string[]
findMany: grantFindMany,
findUnique: grantFindUnique,
delete: grantDelete,
update: grantUpdate,
},
auditLog: { findMany: auditFindMany },
wallet: { findUnique: walletFindUnique },
oAuthClient: { findMany: clientFindMany },
oAuthRefreshToken: { findMany: tokenFindMany, updateMany: tokenUpdateMany },
$transaction: transaction,
Expand All @@ -54,6 +60,11 @@ let caller: (c: unknown) => any;
beforeEach(async () => {
jest.clearAllMocks();
transaction.mockImplementation(async (ops: unknown[]) => [undefined, { count: 2 }]);
grantUpdate.mockResolvedValue({});
auditFindMany.mockResolvedValue([]);
walletFindUnique.mockResolvedValue({
id: "w1", signersAddresses: [ADDR], ownerAddress: ADDR,
});
grantDelete.mockResolvedValue({});
tokenUpdateMany.mockResolvedValue({ count: 2 });
const { mcpRouter } = await import("@/server/api/routers/mcp");
Expand Down Expand Up @@ -189,3 +200,127 @@ describe("revokeConnection", () => {
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});

describe("updateConnectionScopes", () => {
it("narrows a grant and keeps refresh tokens in step", async () => {
grantFindUnique.mockResolvedValue({
id: "g1", scopes: ["wallets:read", "governance:read", "ballots:write"],
});

const out = await caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({
clientId: "c1",
requesterAddress: ADDR,
scopes: ["wallets:read"],
});

expect(out).toEqual({ ok: true, scopes: ["wallets:read"] });
// Grant and refresh tokens move together, or a refresh would re-widen it.
expect(transaction).toHaveBeenCalled();
expect(tokenUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({ data: { scopes: ["wallets:read"] } }),
);
});

it("normalises to catalogue order regardless of input order", async () => {
grantFindUnique.mockResolvedValue({ id: "g1", scopes: [] });
const out = await caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({
clientId: "c1",
requesterAddress: ADDR,
scopes: ["ballots:write", "wallets:read"],
});
expect(out.scopes).toEqual(["wallets:read", "ballots:write"]);
});

it("refuses to empty a grant — revoking is the honest action", async () => {
grantFindUnique.mockResolvedValue({ id: "g1", scopes: ["wallets:read"] });
await expect(
caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({
clientId: "c1", requesterAddress: ADDR, scopes: [],
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(transaction).not.toHaveBeenCalled();
});

it("refuses an address the session does not hold", async () => {
await expect(
caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({
clientId: "c1", requesterAddress: OTHER, scopes: ["wallets:read"],
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});

it("404s on a grant that does not exist", async () => {
grantFindUnique.mockResolvedValue(null);
await expect(
caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({
clientId: "nope", requesterAddress: ADDR, scopes: ["wallets:read"],
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});

describe("wallet activity", () => {
const row = (over: Record<string, unknown> = {}) => ({
id: "a1",
actorAddress: ADDR,
outcome: "success",
reason: null,
createdAt: new Date("2026-08-13T10:00:00Z"),
metadata: { tool: "multisig_list_wallets", client: "https://claude.ai/x", scope: "wallets:read", readOnly: true, status: 200, durationMs: 12 },
...over,
});

it("groups calls by client with counts and failures", async () => {
auditFindMany.mockResolvedValue([
row(),
row({ id: "a2", metadata: { ...row().metadata, tool: "multisig_list_free_utxos" } }),
row({ id: "a3", outcome: "denied" }),
]);

const [client] = await caller(ctx({ primaryWallet: ADDR })).walletClients({
walletId: "w1",
});

expect(client).toMatchObject({
client: "https://claude.ai/x",
calls: 3,
failures: 1,
tools: ["multisig_list_free_utxos", "multisig_list_wallets"],
});
});

it("only reads MCP tool rows for this wallet", async () => {
await caller(ctx({ primaryWallet: ADDR })).walletClients({ walletId: "w1" });
expect(auditFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
action: "mcp.tool.called",
resourceType: "wallet",
resourceId: "w1",
},
}),
);
});

it("filters the drill-down to one client", async () => {
auditFindMany.mockResolvedValue([
row(),
row({ id: "a2", metadata: { ...row().metadata, client: "other-client" } }),
]);
const rows = await caller(ctx({ primaryWallet: ADDR })).walletToolUsage({
walletId: "w1",
client: "other-client",
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ client: "other-client" });
});

it("refuses a wallet the caller is not a signer of", async () => {
walletFindUnique.mockResolvedValue({
id: "w1", signersAddresses: [OTHER], ownerAddress: OTHER,
});
await expect(
caller(ctx({ primaryWallet: ADDR })).walletClients({ walletId: "w1" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
78 changes: 77 additions & 1 deletion src/__tests__/mcpRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const enforceBodySizeMock = jest.fn<() => boolean>();
const verifyJwtMock: jest.Mock = jest.fn();
const isBotJwtMock: jest.Mock = jest.fn();
const findBotUserMock: jest.Mock = jest.fn();
const grantFindUniqueMock: jest.Mock = jest.fn();
const auditCreateMock: jest.Mock = jest.fn();

jest.mock("@/lib/cors", () => ({
__esModule: true,
Expand All @@ -40,7 +42,11 @@ jest.mock("@/lib/verifyJwt", () => ({

jest.mock("@/server/db", () => ({
__esModule: true,
db: { botUser: { findUnique: findBotUserMock } },
db: {
botUser: { findUnique: findBotUserMock },
oAuthGrant: { findUnique: grantFindUniqueMock },
auditLog: { create: auditCreateMock },
},
}));

const HUMAN_ADDRESS = "addr_test1qphuman000000000000000000000000000000";
Expand Down Expand Up @@ -178,6 +184,7 @@ beforeEach(() => {
enforceBodySizeMock.mockReturnValue(true);
verifyJwtMock.mockReturnValue({ address: HUMAN_ADDRESS });
isBotJwtMock.mockReturnValue(false);
(auditCreateMock as any).mockResolvedValue({});
});

describe("POST /api/mcp — transport", () => {
Expand Down Expand Up @@ -402,3 +409,72 @@ describe("POST /api/mcp — tools/call", () => {
expect(typeof payload.result?.isError).toBe("boolean");
});
});

describe("POST /api/mcp — the stored grant is authoritative", () => {
// Access tokens are self-contained and live an hour. If the token's `scope`
// claim were trusted on its own, revoking a connection or removing a
// permission in the profile would not take effect until it expired.
const OAUTH_SUBJECT = "addr_test1qpoauth";

function oauthToken(scopes: string[]) {
const jwt = require("jsonwebtoken") as typeof import("jsonwebtoken");
return jwt.sign(
{
sub: OAUTH_SUBJECT,
aud: "http://localhost:3000/api/mcp",
typ: "mcp_at",
cid: "https://claude.ai/x",
scope: scopes.join(" "),
addrs: [OAUTH_SUBJECT],
jti: "t1",
},
process.env.JWT_SECRET as string,
{ issuer: "http://localhost:3000", expiresIn: "1h" },
);
}

const listWith = (token: string) => {
const { headers, body } = modern("tools/list");
const res = createResponse();
return handler(
createRequest(body, { ...headers, authorization: `Bearer ${token}` }),
res,
).then(() => res);
};

it("401s when the grant has been revoked", async () => {
(grantFindUniqueMock as any).mockResolvedValue(null);
const res = await listWith(oauthToken(["wallets:read"]));
expect(res._status).toBe(401);
});

it("drops a permission removed from the grant, even though the token still claims it", async () => {
(grantFindUniqueMock as any).mockResolvedValue({
scopes: ["wallets:read"],
grantedAddresses: [OAUTH_SUBJECT],
});

const res = await listWith(oauthToken(["wallets:read", "ballots:write"]));

const payload = res.body() as { result?: { tools?: { name: string }[] } };
const names = payload.result?.tools?.map((t) => t.name) ?? [];
expect(names).toContain("multisig_list_wallets");
expect(names).not.toContain("ballot_upsert");
});

it("never widens a token beyond what it was issued with", async () => {
// Grant widened after the token was minted: the token must not gain reach.
(grantFindUniqueMock as any).mockResolvedValue({
scopes: ["wallets:read", "governance:read", "ballots:write"],
grantedAddresses: [OAUTH_SUBJECT],
});

const res = await listWith(oauthToken(["wallets:read"]));

const payload = res.body() as { result?: { tools?: { name: string }[] } };
const names = payload.result?.tools?.map((t) => t.name) ?? [];
expect(names).toContain("multisig_list_wallets");
expect(names).not.toContain("ballot_upsert");
expect(names).not.toContain("governance_open_proposals");
});
});
42 changes: 41 additions & 1 deletion src/__tests__/mcpTools.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, expect, it } from "@jest/globals";
import { existsSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { join } from "path";

import { MCP_TOOLS, toolsForScopes } from "@/lib/mcp/tools";
import { MCP_SCOPES, isMcpScope, parseMcpScopes } from "@/lib/mcp/scopes";
import { mcpScopesForBot } from "@/lib/mcp/auth";
import { MCP_TOOL_SUMMARIES } from "@/data/mcp-tools";
import { MCP_TOOL_ACTION } from "@/lib/mcp/server";
import type { BotScope } from "@/lib/auth/botKey";

const V1_DIR = join(process.cwd(), "src", "pages", "api", "v1");
Expand Down Expand Up @@ -180,3 +182,41 @@ describe("bot scope projection", () => {
]);
});
});

describe("published tool list (src/data/mcp-tools.ts)", () => {
// The landing page cannot import the real registry — it pulls the API
// handlers and the Mesh WASM with them — so the displayed list is a separate
// data file. This keeps the two honest.
it("lists exactly the registered tools, in the same order", () => {
expect(MCP_TOOL_SUMMARIES.map((t) => t.name)).toEqual(
MCP_TOOLS.map((t) => t.name),
);
});

it("states the same scope the registry enforces", () => {
const actual = new Map(MCP_TOOLS.map((t) => [t.name, t.scope]));
for (const summary of MCP_TOOL_SUMMARIES) {
expect(summary.scope).toBe(actual.get(summary.name));
}
});

it("gives every tool a blurb", () => {
for (const summary of MCP_TOOL_SUMMARIES) {
expect(summary.blurb.length).toBeGreaterThan(15);
}
});
});

describe("audit action constant", () => {
// src/server/api/routers/mcp.ts hard-codes this string rather than importing
// it, because importing src/lib/mcp/server.ts would drag the MCP SDK and the
// whole tool registry into the tRPC bundle. If they drift, the wallet
// activity view silently returns nothing.
it("matches the literal the tRPC router queries on", () => {
const router = readFileSync(
join(process.cwd(), "src", "server", "api", "routers", "mcp.ts"),
"utf8",
);
expect(router).toContain(`const MCP_TOOL_ACTION = "${MCP_TOOL_ACTION}"`);
});
});
37 changes: 32 additions & 5 deletions src/components/pages/homepage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
DRepPreview,
StakingPreview,
} from "@/components/pages/homepage/previews";
import { MCP_TOOL_SUMMARIES } from "@/data/mcp-tools";

// 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
Expand Down Expand Up @@ -751,14 +752,40 @@ export function PageHomepage() {
</li>
</ol>

<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">What it can do</h4>
<p className="text-xs text-muted-foreground">
Thirteen tools, grouped by the permission they need. You choose
which permissions to grant, and can change them later.
</p>
<div className="mt-1 flex flex-col gap-3">
{(["wallets:read", "governance:read", "ballots:write"] as const).map(
(scope) => (
<div key={scope}>
<code className="text-xs font-medium text-foreground">{scope}</code>
<ul className="mt-1 flex flex-col gap-1">
{MCP_TOOL_SUMMARIES.filter((t) => t.scope === scope).map((t) => (
<li key={t.name} className="text-xs text-muted-foreground">
<code className="rounded bg-muted px-1">{t.name}</code>{" "}
— {t.blurb}
</li>
))}
</ul>
</div>
),
)}
</div>
</div>

<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{" "}
A connected client can read your wallets and governance, draft
ballots, and publish a rationale to IPFS. It{" "}
<strong>cannot</strong> sign transactions, move funds, or
submit a vote on-chain — those stay with you and your
co-signers. Manage permissions or revoke a connection any time
under{" "}
<Link href="/user" className="underline underline-offset-2">
your profile
</Link>
Expand Down
Loading
Loading