@pnlmarket/mcp-server 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +83 -0
  5. package/dist/install.d.ts +1 -0
  6. package/dist/install.js +168 -0
  7. package/dist/lib/output.d.ts +95 -0
  8. package/dist/lib/output.js +175 -0
  9. package/dist/lib/passphrase.d.ts +16 -0
  10. package/dist/lib/passphrase.js +57 -0
  11. package/dist/lib/pnl-api.d.ts +65 -0
  12. package/dist/lib/pnl-api.js +89 -0
  13. package/dist/lib/sign.d.ts +40 -0
  14. package/dist/lib/sign.js +126 -0
  15. package/dist/lib/wallet.d.ts +74 -0
  16. package/dist/lib/wallet.js +405 -0
  17. package/dist/tools/browse-markets.d.ts +12 -0
  18. package/dist/tools/browse-markets.js +91 -0
  19. package/dist/tools/claim-now.d.ts +10 -0
  20. package/dist/tools/claim-now.js +113 -0
  21. package/dist/tools/claim.d.ts +10 -0
  22. package/dist/tools/claim.js +43 -0
  23. package/dist/tools/export-keypair.d.ts +10 -0
  24. package/dist/tools/export-keypair.js +25 -0
  25. package/dist/tools/get-market.d.ts +10 -0
  26. package/dist/tools/get-market.js +58 -0
  27. package/dist/tools/help.d.ts +7 -0
  28. package/dist/tools/help.js +54 -0
  29. package/dist/tools/init.d.ts +7 -0
  30. package/dist/tools/init.js +69 -0
  31. package/dist/tools/notify.d.ts +12 -0
  32. package/dist/tools/notify.js +150 -0
  33. package/dist/tools/pitch-idea.d.ts +38 -0
  34. package/dist/tools/pitch-idea.js +176 -0
  35. package/dist/tools/pitch-now.d.ts +39 -0
  36. package/dist/tools/pitch-now.js +179 -0
  37. package/dist/tools/restore.d.ts +11 -0
  38. package/dist/tools/restore.js +45 -0
  39. package/dist/tools/set-username.d.ts +10 -0
  40. package/dist/tools/set-username.js +87 -0
  41. package/dist/tools/unlock.d.ts +17 -0
  42. package/dist/tools/unlock.js +47 -0
  43. package/dist/tools/vote-now.d.ts +13 -0
  44. package/dist/tools/vote-now.js +146 -0
  45. package/dist/tools/vote.d.ts +12 -0
  46. package/dist/tools/vote.js +49 -0
  47. package/dist/tools/wallet.d.ts +7 -0
  48. package/dist/tools/wallet.js +40 -0
  49. package/package.json +64 -0
  50. package/skills/README.md +45 -0
  51. package/skills/pnl-browse/SKILL.md +30 -0
  52. package/skills/pnl-claim/SKILL.md +60 -0
  53. package/skills/pnl-claim-now/SKILL.md +67 -0
  54. package/skills/pnl-export/SKILL.md +39 -0
  55. package/skills/pnl-help/SKILL.md +17 -0
  56. package/skills/pnl-init/SKILL.md +24 -0
  57. package/skills/pnl-lock/SKILL.md +17 -0
  58. package/skills/pnl-name/SKILL.md +32 -0
  59. package/skills/pnl-notify/SKILL.md +57 -0
  60. package/skills/pnl-pitch/SKILL.md +83 -0
  61. package/skills/pnl-pitch-now/SKILL.md +88 -0
  62. package/skills/pnl-restore/SKILL.md +38 -0
  63. package/skills/pnl-unlock/SKILL.md +28 -0
  64. package/skills/pnl-vote/SKILL.md +48 -0
  65. package/skills/pnl-vote-now/SKILL.md +68 -0
  66. package/skills/pnl-wallet/SKILL.md +22 -0
@@ -0,0 +1,176 @@
1
+ import { z } from 'zod';
2
+ import { Badge, headline, code, kvTable, next, reply } from '../lib/output.js';
3
+ // ─── pnl_pitch_idea ──────────────────────────────────────────────
4
+ //
5
+ // Write-prep tool. The agent calls this with everything it knows
6
+ // about a new idea. We POST it to the public /api/markets/drafts
7
+ // endpoint, receive a draft id, and return a deep-link the user
8
+ // opens in their browser to confirm + sign with their own wallet.
9
+ //
10
+ // MCP NEVER holds keys. v0.2 is deep-link only; v0.3 will add an
11
+ // autosign path that signs locally with the keypair set up by
12
+ // pnl_init for amounts under the autosign cap.
13
+ //
14
+ // Optional `provenance` argument carries the conversation excerpt
15
+ // and/or code snippet that birthed the idea -- pinned to IPFS by
16
+ // the /create page alongside the market metadata, then displayed
17
+ // on the market detail page as "this idea was born from a
18
+ // conversation in <agent> on <date>".
19
+ const CATEGORIES = [
20
+ 'DeFi', 'NFT', 'Gaming', 'DAO', 'AI/ML', 'Infrastructure', 'Social', 'Meme',
21
+ 'Creator', 'Healthcare', 'Science', 'Education', 'Finance', 'Commerce',
22
+ 'Real Estate', 'Energy', 'Media', 'Manufacturing', 'Mobility', 'Other',
23
+ ];
24
+ const PROJECT_TYPES = ['Protocol', 'Application', 'Platform', 'Service', 'Tool'];
25
+ const PROJECT_STAGES = ['Idea', 'MVP', 'Beta', 'Production', 'Scaling', 'Prototype', 'Launched'];
26
+ export const pitchIdeaInputSchema = {
27
+ name: z
28
+ .string()
29
+ .min(1)
30
+ .max(255)
31
+ .describe("Project name (the headline). E.g. 'AutoImport CLI'. 1-255 chars."),
32
+ description: z
33
+ .string()
34
+ .min(1)
35
+ .max(2000)
36
+ .describe("What the idea actually is — what gets built and why. 1-2000 chars. Treat this like a short pitch the conviction market will trade on; concrete is better than abstract."),
37
+ tokenSymbol: z
38
+ .string()
39
+ .min(3)
40
+ .max(10)
41
+ .regex(/^[A-Z0-9]+$/i, 'token symbol must be alphanumeric')
42
+ .describe("Ticker symbol the token will use if YES wins (3-10 uppercase alphanumeric). E.g. 'AUTOIMP' for AutoImport CLI."),
43
+ category: z
44
+ .enum(CATEGORIES)
45
+ .describe('One of the supported project categories.'),
46
+ projectType: z
47
+ .enum(PROJECT_TYPES)
48
+ .describe("Protocol | Application | Platform | Service | Tool. Default 'Tool' if uncertain."),
49
+ projectStage: z
50
+ .enum(PROJECT_STAGES)
51
+ .describe("Where the idea is today. 'Idea' for never-built, 'MVP' for proof-of-concept, etc. Be honest -- this informs how the market reads the pitch."),
52
+ teamSize: z
53
+ .number()
54
+ .int()
55
+ .min(1)
56
+ .max(1000)
57
+ .describe('How many people are working on this. Use 1 if the user is solo.'),
58
+ targetPoolSol: z
59
+ .number()
60
+ .positive()
61
+ .describe('Target pool size in SOL. Once the YES pool reaches this number, the market can resolve early. Typical range 5-50 SOL.'),
62
+ durationDays: z
63
+ .number()
64
+ .int()
65
+ .min(1)
66
+ .max(365)
67
+ .describe('How long the market stays open for voting before resolution. Typical range 7-90 days. Default 30 if uncertain.'),
68
+ projectImageUrl: z
69
+ .string()
70
+ .url()
71
+ .optional()
72
+ .describe("Optional URL to a project image / logo. If omitted, the market detail page renders a colored circle with the ticker initial."),
73
+ pitchVideoUrl: z
74
+ .string()
75
+ .url()
76
+ .optional()
77
+ .describe('Optional URL to a pitch video (YouTube, Vimeo, IPFS, etc).'),
78
+ twitterHandle: z
79
+ .string()
80
+ .optional()
81
+ .describe("Optional X/Twitter handle (without '@'). Surfaced on the market page and auto-tweet."),
82
+ location: z
83
+ .string()
84
+ .max(255)
85
+ .optional()
86
+ .describe("Optional location string (city / country). Defaults to founder's profile if omitted."),
87
+ provenance: z
88
+ .object({
89
+ source: z
90
+ .enum(['claude-code', 'cursor', 'cline', 'codex', 'other'])
91
+ .describe('Which agent the idea was born in.'),
92
+ excerpt: z
93
+ .string()
94
+ .max(2000)
95
+ .describe('The conversation excerpt that surfaced the idea. The 1-3 sentences immediately before the user said "let\'s pitch this on PNL".'),
96
+ codeSnippet: z
97
+ .string()
98
+ .max(2000)
99
+ .optional()
100
+ .describe('Optional code snippet that motivated the idea (e.g. a // TODO comment, a function that wanted to become its own tool).'),
101
+ timestamp: z
102
+ .string()
103
+ .optional()
104
+ .describe('ISO 8601 timestamp of the originating conversation.'),
105
+ })
106
+ .optional()
107
+ .describe('Optional "tribute to the idea" payload — pinned alongside the market metadata and displayed on the market detail page. Only attach when the user agrees to make the context public.'),
108
+ };
109
+ const PitchIdeaInput = z.object(pitchIdeaInputSchema);
110
+ function getApiBase() {
111
+ const raw = process.env.PNL_API_BASE_URL?.trim();
112
+ if (!raw)
113
+ return 'https://pnl.market';
114
+ return raw.endsWith('/') ? raw.slice(0, -1) : raw;
115
+ }
116
+ export async function callPitchIdea(rawInput) {
117
+ const input = PitchIdeaInput.parse(rawInput ?? {});
118
+ // The /create page expects the same field shape as the Project schema,
119
+ // so we mirror its naming. socialLinks is a Map but the form accepts
120
+ // a simple object of {platform: url} pairs.
121
+ const payload = {
122
+ name: input.name,
123
+ description: input.description,
124
+ category: input.category,
125
+ projectType: input.projectType,
126
+ projectStage: input.projectStage,
127
+ tokenSymbol: input.tokenSymbol.toUpperCase(),
128
+ teamSize: input.teamSize,
129
+ targetPoolSol: input.targetPoolSol,
130
+ durationDays: input.durationDays,
131
+ };
132
+ if (input.projectImageUrl)
133
+ payload.projectImageUrl = input.projectImageUrl;
134
+ if (input.pitchVideoUrl)
135
+ payload.pitchVideoUrl = input.pitchVideoUrl;
136
+ if (input.twitterHandle)
137
+ payload.socialLinks = { twitter: input.twitterHandle.replace(/^@/, '') };
138
+ if (input.location)
139
+ payload.location = input.location;
140
+ const body = {
141
+ payload,
142
+ provenance: input.provenance,
143
+ source: 'mcp',
144
+ };
145
+ const res = await fetch(`${getApiBase()}/api/markets/drafts`, {
146
+ method: 'POST',
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ Accept: 'application/json',
150
+ 'User-Agent': 'pnl-mcp-server/0.2.0 (+https://docs.pnl.market)',
151
+ },
152
+ body: JSON.stringify(body),
153
+ });
154
+ if (!res.ok) {
155
+ const errBody = await res.text().catch(() => '');
156
+ throw new Error(`PNL drafts API ${res.status} ${res.statusText}${errBody ? ` — ${errBody.slice(0, 300)}` : ''}`);
157
+ }
158
+ const data = (await res.json());
159
+ if (!data.success || !data.deepLink) {
160
+ throw new Error(`PNL drafts API returned no deepLink — ${data.error || 'unknown error'}`);
161
+ }
162
+ return reply(headline(`${Badge.draft} Drafted · $${input.tokenSymbol.toUpperCase()} — ${input.name}`), `Open this URL to confirm and post the market on Solana mainnet:`, code(data.deepLink), kvTable([
163
+ ['Idea', input.name],
164
+ ['Ticker', `$${input.tokenSymbol.toUpperCase()}`],
165
+ ['Target pool', `${input.targetPoolSol} SOL`],
166
+ ['Duration', `${input.durationDays} days`],
167
+ ['Stage', `${input.projectStage} · ${input.category}`],
168
+ input.provenance
169
+ ? ['Provenance', `${input.provenance.source}${input.provenance.timestamp ? ' · ' + input.provenance.timestamp : ''}`]
170
+ : null,
171
+ ['Draft id', `\`${data.draftId}\``],
172
+ ['Expires', data.expiresAt ?? '—'],
173
+ ].filter((r) => Array.isArray(r))), `The /create page is pre-filled with everything above. The user signs the on-chain \`create_market\` transaction in their browser wallet (or imports the keypair from \`pnl_export_keypair\` into Phantom first). Market goes live as soon as the tx confirms (~5-15s on Solana mainnet).`, input.provenance
174
+ ? `_Provenance attached — will be displayed on the market detail page after launch._`
175
+ : null, next('Open the URL in a browser and confirm.'));
176
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+ export declare const pitchNowInputSchema: {
3
+ readonly autosignCapSol: z.ZodOptional<z.ZodNumber>;
4
+ readonly name: z.ZodString;
5
+ readonly description: z.ZodString;
6
+ readonly tokenSymbol: z.ZodString;
7
+ readonly category: z.ZodEnum<["DeFi", "NFT", "Gaming", "DAO", "AI/ML", "Infrastructure", "Social", "Meme", "Creator", "Healthcare", "Science", "Education", "Finance", "Commerce", "Real Estate", "Energy", "Media", "Manufacturing", "Mobility", "Other"]>;
8
+ readonly projectType: z.ZodEnum<["Protocol", "Application", "Platform", "Service", "Tool"]>;
9
+ readonly projectStage: z.ZodEnum<["Idea", "MVP", "Beta", "Production", "Scaling", "Prototype", "Launched"]>;
10
+ readonly teamSize: z.ZodNumber;
11
+ readonly targetPoolSol: z.ZodNumber;
12
+ readonly durationDays: z.ZodNumber;
13
+ readonly projectImageUrl: z.ZodOptional<z.ZodString>;
14
+ readonly pitchVideoUrl: z.ZodOptional<z.ZodString>;
15
+ readonly twitterHandle: z.ZodOptional<z.ZodString>;
16
+ readonly location: z.ZodOptional<z.ZodString>;
17
+ readonly provenance: z.ZodOptional<z.ZodObject<{
18
+ source: z.ZodEnum<["claude-code", "cursor", "cline", "codex", "other"]>;
19
+ excerpt: z.ZodString;
20
+ codeSnippet: z.ZodOptional<z.ZodString>;
21
+ timestamp: z.ZodOptional<z.ZodString>;
22
+ }, "strip", z.ZodTypeAny, {
23
+ source: "claude-code" | "cursor" | "cline" | "codex" | "other";
24
+ excerpt: string;
25
+ codeSnippet?: string | undefined;
26
+ timestamp?: string | undefined;
27
+ }, {
28
+ source: "claude-code" | "cursor" | "cline" | "codex" | "other";
29
+ excerpt: string;
30
+ codeSnippet?: string | undefined;
31
+ timestamp?: string | undefined;
32
+ }>>;
33
+ };
34
+ export declare function callPitchNow(rawInput: unknown): Promise<{
35
+ content: Array<{
36
+ type: "text";
37
+ text: string;
38
+ }>;
39
+ }>;
@@ -0,0 +1,179 @@
1
+ import { z } from 'zod';
2
+ import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
3
+ import { requireUnlockedKeypair, loadConfig, getConnection, getBalanceSol, } from '../lib/wallet.js';
4
+ import { signSerializedTx, sendAndConfirm, freshNonce, signChallenge, challenge, signedRequestHash, } from '../lib/sign.js';
5
+ import { pitchIdeaInputSchema } from './pitch-idea.js';
6
+ import { Badge, headline, code, kvTable, inline, next, reply, hr } from '../lib/output.js';
7
+ // ─── pnl_pitch_now ───────────────────────────────────────────────
8
+ //
9
+ // Autosign create_market. For stakes within the autosign cap, the
10
+ // MCP locally signs + sends the create_market tx, then calls
11
+ // /api/mcp/markets/complete-create to persist the Project +
12
+ // PredictionMarket docs. No browser bounce.
13
+ //
14
+ // Flow (six round-trips, ~6-12s end-to-end on mainnet):
15
+ //
16
+ // 1. requireUnlockedKeypair() — fail fast if locked
17
+ // 2. balance check vs autosign cap — fail before any I/O
18
+ // 3. POST /api/mcp/markets/build-create-tx
19
+ // — pin IPFS, build unsigned tx
20
+ // 4. local sign + sendRawTransaction — sign with the keypair, send
21
+ // via configured RPC, confirm
22
+ // 5. sign canonical challenge — for the complete-create call
23
+ // 6. POST /api/mcp/markets/complete-create
24
+ // — persist + broadcast
25
+ // pnl_pitch_now accepts the same payload as pnl_pitch_idea plus an
26
+ // optional autosignCapSol override.
27
+ export const pitchNowInputSchema = {
28
+ ...pitchIdeaInputSchema,
29
+ autosignCapSol: z
30
+ .number()
31
+ .positive()
32
+ .optional()
33
+ .describe('Optional cap override that can only LOWER the autosign limit for this call, never raise it. The ceiling is the cap from ~/.config/pnl/config.json (default 0.05 SOL). To raise the ceiling, the user must edit the config file directly — this arg cannot bypass it.'),
34
+ };
35
+ const PitchNowInput = z.object(pitchNowInputSchema);
36
+ function getApiBase() {
37
+ const raw = process.env.PNL_API_BASE_URL?.trim();
38
+ if (!raw)
39
+ return 'https://pnl.market';
40
+ return raw.endsWith('/') ? raw.slice(0, -1) : raw;
41
+ }
42
+ export async function callPitchNow(rawInput) {
43
+ const input = PitchNowInput.parse(rawInput ?? {});
44
+ // 1. Keypair (throws helpful "wallet locked" if unavailable).
45
+ const keypair = requireUnlockedKeypair();
46
+ const walletAddress = keypair.publicKey.toBase58();
47
+ // 2. Balance check. We need at least the autosign cap available —
48
+ // the creation fee + tx fee combined are well under 0.02 SOL but
49
+ // we sanity-check against the user's configured cap to catch
50
+ // "wallet only has 0.001 SOL" before pinning IPFS.
51
+ //
52
+ // Cap policy: the user's configured cap (~/.config/pnl/config.json)
53
+ // is the ceiling — the per-call autosignCapSol arg can only LOWER
54
+ // it, never raise. This blocks prompt-injection where a malicious
55
+ // project description coaxes the agent into passing a huge
56
+ // autosignCapSol to bypass the user-set ceiling. To raise the cap
57
+ // the user has to edit the config file themselves.
58
+ const configCap = loadConfig().autosignCapSol;
59
+ const cap = input.autosignCapSol != null
60
+ ? Math.min(input.autosignCapSol, configCap)
61
+ : configCap;
62
+ const balance = await getBalanceSol(new PublicKey(walletAddress));
63
+ if (balance < 0.02) {
64
+ throw new Error(`Wallet balance ${balance.toFixed(4)} SOL is below the minimum ~0.02 SOL needed for create_market (creation fee ~0.015 + tx fee). Fund ${walletAddress} and try again.`);
65
+ }
66
+ if (cap < 0.02) {
67
+ throw new Error(`Autosign cap ${cap} SOL is below the minimum 0.02 SOL needed for create_market. Either raise the cap (autosignCapSol arg) or use pnl_pitch_idea for the deep-link flow.`);
68
+ }
69
+ const base = getApiBase();
70
+ // 3. Build unsigned tx server-side.
71
+ const buildBody = {
72
+ walletAddress,
73
+ name: input.name,
74
+ description: input.description,
75
+ category: input.category,
76
+ projectType: input.projectType,
77
+ projectStage: input.projectStage,
78
+ tokenSymbol: input.tokenSymbol.toUpperCase(),
79
+ teamSize: input.teamSize,
80
+ targetPoolSol: input.targetPoolSol,
81
+ durationDays: input.durationDays,
82
+ };
83
+ if (input.projectImageUrl)
84
+ buildBody.projectImageUrl = input.projectImageUrl;
85
+ if (input.pitchVideoUrl)
86
+ buildBody.pitchVideoUrl = input.pitchVideoUrl;
87
+ if (input.twitterHandle)
88
+ buildBody.socialLinks = { twitter: input.twitterHandle.replace(/^@/, '') };
89
+ if (input.location)
90
+ buildBody.location = input.location;
91
+ const buildRes = await fetch(`${base}/api/mcp/markets/build-create-tx`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
94
+ body: JSON.stringify(buildBody),
95
+ });
96
+ const buildJson = (await buildRes.json());
97
+ if (!buildRes.ok || !buildJson.success || !buildJson.data) {
98
+ throw new Error(`build-create-tx failed (${buildRes.status}): ${buildJson.error || 'unknown error'}`);
99
+ }
100
+ const built = buildJson.data;
101
+ // Final cost check now that we have the actual creation fee from
102
+ // the program config. cap is in SOL; creationFee is in lamports.
103
+ const creationFeeSol = built.creationFee / LAMPORTS_PER_SOL;
104
+ if (creationFeeSol > cap) {
105
+ throw new Error(`create_market creation fee ${creationFeeSol.toFixed(4)} SOL exceeds autosign cap ${cap} SOL. Raise the cap with autosignCapSol arg or use pnl_pitch_idea for the deep-link flow.`);
106
+ }
107
+ // 4. Sign locally and send.
108
+ const rawTx = signSerializedTx(built.tx, keypair);
109
+ const { signature: txSignature } = await sendAndConfirm(rawTx, getConnection(), {
110
+ confirmTimeoutMs: 90_000,
111
+ });
112
+ // 5. Build the complete-create body first, then sign a challenge that
113
+ // folds in a SHA-256 of the body. The hash binds the sig to the
114
+ // exact payload — a captured sig cannot be replayed with a tampered
115
+ // project name / description / cid before our own complete-create
116
+ // call lands (5min nonce window).
117
+ const nonce = freshNonce();
118
+ const completeBodyCore = {
119
+ txSignature,
120
+ marketAddress: built.marketPda,
121
+ ipfsCid: built.ipfsCid,
122
+ metadataUri: built.metadataUri,
123
+ targetPool: Math.floor(input.targetPoolSol * 1_000_000_000),
124
+ expiryTime: built.expiryTime,
125
+ payload: {
126
+ name: input.name,
127
+ description: input.description,
128
+ category: input.category,
129
+ projectType: input.projectType,
130
+ projectStage: input.projectStage,
131
+ tokenSymbol: input.tokenSymbol.toUpperCase(),
132
+ teamSize: input.teamSize,
133
+ targetPoolSol: input.targetPoolSol,
134
+ durationDays: input.durationDays,
135
+ location: input.location,
136
+ projectImageUrl: input.projectImageUrl,
137
+ pitchVideoUrl: input.pitchVideoUrl,
138
+ socialLinks: input.twitterHandle
139
+ ? { twitter: input.twitterHandle.replace(/^@/, '') }
140
+ : undefined,
141
+ },
142
+ provenance: input.provenance,
143
+ };
144
+ const payloadHash = signedRequestHash(completeBodyCore);
145
+ const sig = signChallenge(challenge('complete-create', txSignature, nonce, payloadHash), keypair);
146
+ // 6. Persist + broadcast via complete-create.
147
+ const completeBody = {
148
+ walletAddress,
149
+ nonce,
150
+ signature: sig,
151
+ ...completeBodyCore,
152
+ };
153
+ const completeRes = await fetch(`${base}/api/mcp/markets/complete-create`, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
156
+ body: JSON.stringify(completeBody),
157
+ });
158
+ const completeJson = (await completeRes.json());
159
+ if (!completeRes.ok || !completeJson.success || !completeJson.data) {
160
+ // Tx confirmed on-chain but persistence failed. Surface the tx so
161
+ // the user can manually replay / contact support, then throw.
162
+ throw new Error(`Tx confirmed on-chain (${txSignature}) but complete-create failed (${completeRes.status}): ${completeJson.error || 'unknown error'}. The market exists on Solana — re-running pnl_pitch_now with the same args will be safe (the endpoint is idempotent on marketAddress).`);
163
+ }
164
+ const done = completeJson.data;
165
+ return reply(headline(`${Badge.live} Market live · $${input.tokenSymbol.toUpperCase()} — ${input.name}`), kvTable([
166
+ ['Market', `${base}${done.marketUrl}`],
167
+ ['Token', `$${input.tokenSymbol.toUpperCase()}`],
168
+ ['Target pool', `${input.targetPoolSol} SOL`],
169
+ ['Duration', `${input.durationDays} days`],
170
+ ['Stage', `${input.projectStage} · ${input.category}`],
171
+ ['Founder', inline(walletAddress)],
172
+ ['Creation fee', `${creationFeeSol.toFixed(4)} SOL`],
173
+ input.provenance
174
+ ? ['Provenance', `${input.provenance.source}${input.provenance.timestamp ? ' · ' + input.provenance.timestamp : ''}`]
175
+ : null,
176
+ ['Tx', `[${txSignature.slice(0, 8)}…${txSignature.slice(-6)}](${done.solscan})`],
177
+ done.alreadyExists ? ['Note', 'idempotent retry — market was already persisted'] : null,
178
+ ].filter((r) => Array.isArray(r))), hr, `The MCP signed and sent the \`create_market\` transaction locally — no browser bounce needed because the cost (${creationFeeSol.toFixed(4)} SOL) was within the autosign cap (${cap} SOL).`, code(`Tx: ${txSignature}`), next(`Share the market URL or open ${inline(`${base}${done.marketUrl}`)} to watch the first votes come in.`));
179
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ export declare const restoreInputSchema: {
3
+ readonly mnemonic: z.ZodString;
4
+ readonly allowOverwrite: z.ZodOptional<z.ZodBoolean>;
5
+ };
6
+ export declare function callRestore(rawInput: unknown): Promise<{
7
+ content: Array<{
8
+ type: "text";
9
+ text: string;
10
+ }>;
11
+ }>;
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod';
2
+ import { hasWallet, restoreWallet, isValidMnemonic, unlockWith } from '../lib/wallet.js';
3
+ import { promptPassphrase } from '../lib/passphrase.js';
4
+ import { Badge, headline, next, reply, truncAddress, inline } from '../lib/output.js';
5
+ export const restoreInputSchema = {
6
+ mnemonic: z
7
+ .string()
8
+ .min(1)
9
+ .describe('The 12 or 24 word BIP39 phrase from pnl_init. Standard recovery format Phantom / Solflare / Backpack / Solana CLI all accept.'),
10
+ allowOverwrite: z
11
+ .boolean()
12
+ .optional()
13
+ .describe('Set to true to replace an existing wallet on this machine. Default false — refuses if one exists so the user can back it up first with pnl_export_keypair.'),
14
+ };
15
+ const RestoreInput = z.object(restoreInputSchema);
16
+ export async function callRestore(rawInput) {
17
+ const { mnemonic, allowOverwrite } = RestoreInput.parse(rawInput ?? {});
18
+ if (!isValidMnemonic(mnemonic.trim())) {
19
+ return reply(headline(`${Badge.err} Not a valid BIP39 phrase.`), 'Check spelling, word count (must be 12 or 24), and that all words are from the BIP39 wordlist.', next('Re-run `/pnl-restore` with the correct phrase.'));
20
+ }
21
+ if (hasWallet() && !allowOverwrite) {
22
+ return reply(headline(`${Badge.warn} A wallet already exists on this machine.`), `Refusing to overwrite. Back it up first with ${inline('pnl_export_keypair')}, then call \`pnl_restore\` again with \`allowOverwrite: true\`.`, next('`/pnl-export` to back up, then re-run `/pnl-restore`.'));
23
+ }
24
+ let passphrase;
25
+ try {
26
+ passphrase = promptPassphrase({
27
+ title: 'PNL Wallet — Restore',
28
+ prompt: 'Choose a passphrase to encrypt the restored wallet on this machine.',
29
+ confirm: true,
30
+ });
31
+ }
32
+ catch (e) {
33
+ return reply(headline(`${Badge.err} Couldn't read passphrase.`), e instanceof Error ? e.message : String(e));
34
+ }
35
+ try {
36
+ const { address } = restoreWallet(mnemonic.trim(), passphrase, {
37
+ allowOverwrite: !!allowOverwrite,
38
+ });
39
+ unlockWith(passphrase, 30);
40
+ return reply(headline(`${Badge.ok} Restored · ${truncAddress(address)} · unlocked 30m`), `On-chain history is preserved — markets, votes, balances tied to this address are visible immediately.`, `Full address: \`${address}\``, next('`/pnl-wallet` to see balance, `/pnl-pitch` to post an idea.'));
41
+ }
42
+ catch (e) {
43
+ return reply(headline(`${Badge.err} Restore failed.`), e instanceof Error ? e.message : String(e));
44
+ }
45
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const setUsernameInputSchema: {
3
+ readonly username: z.ZodString;
4
+ };
5
+ export declare function callSetUsername(rawInput: unknown): Promise<{
6
+ content: Array<{
7
+ type: "text";
8
+ text: string;
9
+ }>;
10
+ }>;
@@ -0,0 +1,87 @@
1
+ import { z } from 'zod';
2
+ import nacl from 'tweetnacl';
3
+ import bs58 from 'bs58';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { hasWallet, isUnlocked, requireUnlockedKeypair } from '../lib/wallet.js';
6
+ import { Badge, headline, kvTable, next, reply, truncAddress, code } from '../lib/output.js';
7
+ // ─── pnl_set_username ────────────────────────────────────────────
8
+ //
9
+ // Claim or rename the PNL username for the local wallet. Signs a
10
+ // time-bounded challenge with the local keypair so the backend can
11
+ // verify ownership without a Privy session.
12
+ //
13
+ // Username constraints (enforced server-side too):
14
+ // - 3-20 characters
15
+ // - Letters, numbers, underscores, hyphens
16
+ // - Must not already be taken by another wallet
17
+ //
18
+ // Idempotent: setting the same username twice is a no-op.
19
+ export const setUsernameInputSchema = {
20
+ username: z
21
+ .string()
22
+ .min(3)
23
+ .max(20)
24
+ .regex(/^[A-Za-z0-9_-]+$/, 'username must be letters, numbers, underscores, or hyphens only')
25
+ .describe("The username to claim on PNL. 3-20 characters of letters, numbers, underscores, or hyphens. Shown on the user's market detail pages, profile, and any markets they create."),
26
+ };
27
+ const SetUsernameInput = z.object(setUsernameInputSchema);
28
+ function getApiBase() {
29
+ const raw = process.env.PNL_API_BASE_URL?.trim();
30
+ if (!raw)
31
+ return 'https://pnl.market';
32
+ return raw.endsWith('/') ? raw.slice(0, -1) : raw;
33
+ }
34
+ export async function callSetUsername(rawInput) {
35
+ const { username } = SetUsernameInput.parse(rawInput ?? {});
36
+ if (!hasWallet()) {
37
+ return {
38
+ content: [
39
+ {
40
+ type: 'text',
41
+ text: 'No PNL wallet on this machine yet. Run pnl_init first — the username gets attached to your wallet address.',
42
+ },
43
+ ],
44
+ };
45
+ }
46
+ if (!isUnlocked()) {
47
+ return {
48
+ content: [
49
+ {
50
+ type: 'text',
51
+ text: 'Wallet is locked. Call pnl_unlock first — claiming a username requires a signature from your wallet. Passphrase is read from PNL_PASSPHRASE env or via an OS-native dialog.',
52
+ },
53
+ ],
54
+ };
55
+ }
56
+ const keypair = requireUnlockedKeypair();
57
+ const walletAddress = keypair.publicKey.toBase58();
58
+ // Nonce: timestamped (ms) + hex randomness. Server checks the
59
+ // timestamp is within 5 minutes of receipt to prevent replay.
60
+ const nonce = `${Date.now()}-${randomBytes(8).toString('hex')}`;
61
+ const challenge = `pnl-set-username:${username}:${walletAddress}:${nonce}`;
62
+ const messageBytes = new TextEncoder().encode(challenge);
63
+ const signatureBytes = nacl.sign.detached(messageBytes, keypair.secretKey);
64
+ const signatureB58 = bs58.encode(signatureBytes);
65
+ const res = await fetch(`${getApiBase()}/api/mcp/profile`, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ Accept: 'application/json',
70
+ 'User-Agent': 'pnl-mcp-server/0.2.0 (+https://docs.pnl.market)',
71
+ },
72
+ body: JSON.stringify({ walletAddress, username, nonce, signature: signatureB58 }),
73
+ });
74
+ const data = (await res.json().catch(() => ({})));
75
+ if (res.status === 409) {
76
+ return reply(headline(`${Badge.warn} Username \`${username}\` already taken.`), `Suggest variations like \`${username}_pnl\`, \`${username}2\`, or different separators — then call \`pnl_set_username\` again.`, next('Pick a different name and re-run.'));
77
+ }
78
+ if (!res.ok || !data.success) {
79
+ throw new Error(`PNL profile API ${res.status} ${res.statusText}${data.error ? ` — ${data.error}` : ''}`);
80
+ }
81
+ const profile = data.data;
82
+ return reply(headline(`${Badge.ok} Username set · ${profile?.username}`), kvTable([
83
+ ['Username', profile?.username ?? null],
84
+ ['Wallet', profile?.walletAddress ? `\`${truncAddress(profile.walletAddress)}\`` : null],
85
+ ['Avatar', profile?.profilePhotoUrl ? `\`${profile.profilePhotoUrl}\`` : null],
86
+ ]), profile?.walletAddress ? code(profile.walletAddress) : null, `This name shows on the market detail page for any market you create, in place of the truncated wallet address.`, next('`/pnl-pitch` to post an idea under your new name.'));
87
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ export declare const unlockInputSchema: {
3
+ readonly ttlMinutes: z.ZodOptional<z.ZodNumber>;
4
+ };
5
+ export declare function callUnlock(rawInput: unknown): Promise<{
6
+ content: Array<{
7
+ type: "text";
8
+ text: string;
9
+ }>;
10
+ }>;
11
+ export declare const lockInputSchema: {};
12
+ export declare function callLock(_rawInput: unknown): Promise<{
13
+ content: Array<{
14
+ type: "text";
15
+ text: string;
16
+ }>;
17
+ }>;
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ import { hasWallet, unlockWith, lock, unlockStatus } from '../lib/wallet.js';
3
+ import { promptPassphrase } from '../lib/passphrase.js';
4
+ import { Badge, headline, inline, next, reply, truncAddress } from '../lib/output.js';
5
+ // ─── pnl_unlock / pnl_lock ───────────────────────────────────────
6
+ export const unlockInputSchema = {
7
+ ttlMinutes: z
8
+ .number()
9
+ .int()
10
+ .min(1)
11
+ .max(60)
12
+ .optional()
13
+ .describe('How long to keep the wallet unlocked, in minutes. Default 5, max 60. The cached secret is wiped on TTL expiry, on pnl_lock, and on process exit.'),
14
+ };
15
+ const UnlockInput = z.object(unlockInputSchema);
16
+ export async function callUnlock(rawInput) {
17
+ const { ttlMinutes } = UnlockInput.parse(rawInput ?? {});
18
+ const ttl = ttlMinutes ?? 5;
19
+ if (!hasWallet()) {
20
+ return reply(headline('No PNL wallet to unlock.'), `Run ${inline('pnl_init')} first to create one, or ${inline('pnl_restore')} if you have a BIP39 mnemonic.`, next('`/pnl-init` or `/pnl-restore`.'));
21
+ }
22
+ let passphrase;
23
+ try {
24
+ passphrase = promptPassphrase({
25
+ title: 'PNL Wallet — Unlock',
26
+ prompt: 'Enter your PNL wallet passphrase to unlock for signing:',
27
+ });
28
+ }
29
+ catch (e) {
30
+ return reply(headline(`${Badge.err} Couldn't read passphrase.`), e instanceof Error ? e.message : String(e));
31
+ }
32
+ try {
33
+ const { address } = unlockWith(passphrase, ttl);
34
+ return reply(headline(`${Badge.unlocked} ${truncAddress(address)} unlocked for ${ttl}m`), `Signing tools (\`pnl_set_username\`, \`pnl_export_keypair\`, future write-prep) are available until lock expires.`, next('`/pnl-pitch` to post an idea, or `/pnl-lock` to wipe early.'));
35
+ }
36
+ catch (e) {
37
+ return reply(headline(`${Badge.err} Unlock failed.`), e instanceof Error ? e.message : String(e), next('Re-run `/pnl-unlock` to try again.'));
38
+ }
39
+ }
40
+ export const lockInputSchema = {};
41
+ export async function callLock(_rawInput) {
42
+ const before = unlockStatus();
43
+ lock();
44
+ return reply(headline(before.unlocked
45
+ ? `${Badge.locked} Wallet locked. Cached secret wiped from memory.`
46
+ : `${Badge.locked} Wallet was already locked.`), before.unlocked ? next('Run `/pnl-unlock` next time you need to sign.') : null);
47
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ export declare const voteNowInputSchema: {
3
+ readonly marketId: z.ZodString;
4
+ readonly vote: z.ZodEnum<["yes", "no"]>;
5
+ readonly amountSol: z.ZodNumber;
6
+ readonly autosignCapSol: z.ZodOptional<z.ZodNumber>;
7
+ };
8
+ export declare function callVoteNow(rawInput: unknown): Promise<{
9
+ content: Array<{
10
+ type: "text";
11
+ text: string;
12
+ }>;
13
+ }>;