@unhedged/mcp 0.1.2
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.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +48 -0
- package/dist/client/errors.d.ts +15 -0
- package/dist/client/errors.js +40 -0
- package/dist/client/http.d.ts +21 -0
- package/dist/client/http.js +132 -0
- package/dist/client/types.d.ts +140 -0
- package/dist/client/types.js +6 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +27 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/init/index.d.ts +10 -0
- package/dist/init/index.js +137 -0
- package/dist/init/snippet.d.ts +35 -0
- package/dist/init/snippet.js +72 -0
- package/dist/prompts/frontmatter.d.ts +28 -0
- package/dist/prompts/frontmatter.js +113 -0
- package/dist/prompts/index.d.ts +10 -0
- package/dist/prompts/index.js +50 -0
- package/dist/prompts/loader.d.ts +13 -0
- package/dist/prompts/loader.js +36 -0
- package/dist/safety/index.d.ts +1 -0
- package/dist/safety/index.js +1 -0
- package/dist/safety/spend-cap.d.ts +33 -0
- package/dist/safety/spend-cap.js +64 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +75 -0
- package/dist/tools/balance.d.ts +2 -0
- package/dist/tools/balance.js +175 -0
- package/dist/tools/bets.d.ts +2 -0
- package/dist/tools/bets.js +109 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +17 -0
- package/dist/tools/market-maker.d.ts +2 -0
- package/dist/tools/market-maker.js +38 -0
- package/dist/tools/markets.d.ts +2 -0
- package/dist/tools/markets.js +120 -0
- package/dist/tools/orders.d.ts +2 -0
- package/dist/tools/orders.js +256 -0
- package/dist/tools/portfolio.d.ts +2 -0
- package/dist/tools/portfolio.js +34 -0
- package/dist/tools/types.d.ts +44 -0
- package/dist/tools/types.js +10 -0
- package/package.json +54 -0
- package/skills/cash-out.md +51 -0
- package/skills/market-creator-assistant.md +45 -0
- package/skills/market-maker-workflow.md +58 -0
- package/skills/market-research.md +42 -0
- package/skills/place-hedged-position.md +51 -0
- package/skills/portfolio-review.md +34 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin, stdout } from 'node:process';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
import { claudeCliCommand, codexCliCommand, claudeDesktopConfigPath, claudeDesktopSnippet, cursorConfigPath, cursorSnippet, } from './snippet.js';
|
|
7
|
+
export async function runInit() {
|
|
8
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
9
|
+
try {
|
|
10
|
+
stdout.write('\n@unhedged/mcp — setup wizard\n');
|
|
11
|
+
stdout.write('────────────────────────────\n\n');
|
|
12
|
+
const target = await pickTarget(rl);
|
|
13
|
+
const apiKey = await ask(rl, 'Unhedged API key (ak_...): ');
|
|
14
|
+
if (!apiKey.startsWith('ak_')) {
|
|
15
|
+
stdout.write('\nKey doesn\'t start with "ak_". The MCP server will refuse to start. Continuing anyway in case the key format changes later.\n\n');
|
|
16
|
+
}
|
|
17
|
+
stdout.write('The session cap covers bet stakes and buy-order notional. It excludes fees and withdrawals and resets on restart.\n');
|
|
18
|
+
const capStr = await ask(rl, 'Session cap in USD (blank = no cap): ');
|
|
19
|
+
const cap = capStr.trim() ? Number(capStr.trim()) : null;
|
|
20
|
+
if (capStr.trim() && (cap === null || !Number.isFinite(cap) || cap <= 0)) {
|
|
21
|
+
stdout.write(`Ignoring invalid spend cap "${capStr}".\n`);
|
|
22
|
+
}
|
|
23
|
+
const answers = {
|
|
24
|
+
target,
|
|
25
|
+
apiKey: apiKey.trim(),
|
|
26
|
+
maxSpendPerSession: cap === null || !Number.isFinite(cap) || cap <= 0 ? null : cap,
|
|
27
|
+
};
|
|
28
|
+
await apply(answers);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
rl.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function pickTarget(rl) {
|
|
35
|
+
stdout.write('Which MCP client to configure?\n');
|
|
36
|
+
stdout.write(' 1) Claude Desktop\n');
|
|
37
|
+
stdout.write(' 2) Cursor\n');
|
|
38
|
+
stdout.write(' 3) Claude CLI (runs `claude mcp add ...`)\n');
|
|
39
|
+
stdout.write(' 4) Codex CLI (runs `codex mcp add ...`)\n');
|
|
40
|
+
stdout.write(' 5) Print the snippet only (no files touched)\n\n');
|
|
41
|
+
const raw = (await ask(rl, 'Choose 1-5: ')).trim();
|
|
42
|
+
switch (raw) {
|
|
43
|
+
case '1':
|
|
44
|
+
return 'claude-desktop';
|
|
45
|
+
case '2':
|
|
46
|
+
return 'cursor';
|
|
47
|
+
case '3':
|
|
48
|
+
return 'claude-cli';
|
|
49
|
+
case '4':
|
|
50
|
+
return 'codex-cli';
|
|
51
|
+
case '5':
|
|
52
|
+
case '':
|
|
53
|
+
return 'print';
|
|
54
|
+
default:
|
|
55
|
+
stdout.write(`Unrecognized choice "${raw}", defaulting to print-only.\n`);
|
|
56
|
+
return 'print';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function ask(rl, prompt) {
|
|
60
|
+
const answer = await rl.question(prompt);
|
|
61
|
+
return answer;
|
|
62
|
+
}
|
|
63
|
+
async function apply(answers) {
|
|
64
|
+
if (answers.target === 'print') {
|
|
65
|
+
printAllSnippets(answers);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (answers.target === 'claude-desktop') {
|
|
69
|
+
const path = claudeDesktopConfigPath(platform(), homedir());
|
|
70
|
+
mergeJsonConfig(path, ['mcpServers'], claudeDesktopSnippet(answers));
|
|
71
|
+
stdout.write(`\nWrote Claude Desktop config at ${path}.\nRestart Claude Desktop for changes to take effect.\n`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (answers.target === 'cursor') {
|
|
75
|
+
const path = cursorConfigPath(homedir());
|
|
76
|
+
mergeJsonConfig(path, ['mcpServers'], cursorSnippet(answers).mcpServers);
|
|
77
|
+
stdout.write(`\nWrote Cursor MCP config at ${path}.\nRestart Cursor for changes to take effect.\n`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (answers.target === 'claude-cli') {
|
|
81
|
+
const cmd = claudeCliCommand(answers);
|
|
82
|
+
stdout.write('\nRun the following command to register with the Claude CLI:\n\n');
|
|
83
|
+
stdout.write(` ${cmd}\n\n`);
|
|
84
|
+
stdout.write('(Not executed automatically — paste it into your shell to confirm.)\n');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (answers.target === 'codex-cli') {
|
|
88
|
+
const cmd = codexCliCommand(answers);
|
|
89
|
+
stdout.write('\nRun the following command to register with Codex CLI:\n\n');
|
|
90
|
+
stdout.write(` ${cmd}\n\n`);
|
|
91
|
+
stdout.write('(Not executed automatically — paste it into your shell to confirm.)\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function printAllSnippets(answers) {
|
|
96
|
+
stdout.write('\n— Claude Desktop (paste under "mcpServers" in claude_desktop_config.json) —\n');
|
|
97
|
+
stdout.write(JSON.stringify(claudeDesktopSnippet(answers), null, 2) + '\n');
|
|
98
|
+
stdout.write('\n— Cursor (paste into ~/.cursor/mcp.json) —\n');
|
|
99
|
+
stdout.write(JSON.stringify(cursorSnippet(answers), null, 2) + '\n');
|
|
100
|
+
stdout.write('\n— Claude CLI command —\n');
|
|
101
|
+
stdout.write(claudeCliCommand(answers) + '\n');
|
|
102
|
+
stdout.write('\n— Codex CLI command —\n');
|
|
103
|
+
stdout.write(codexCliCommand(answers) + '\n');
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read an existing JSON config (if present), deep-merge at the requested path,
|
|
107
|
+
* and write it back. Preserves any other top-level keys the user has set.
|
|
108
|
+
* `parentKeys` must descend to the object that owns the server entries —
|
|
109
|
+
* merging a block that itself contains `mcpServers` at the top level would
|
|
110
|
+
* clobber every other server the user has configured.
|
|
111
|
+
*/
|
|
112
|
+
export function mergeJsonConfig(path, parentKeys, block) {
|
|
113
|
+
let existing = {};
|
|
114
|
+
if (existsSync(path)) {
|
|
115
|
+
try {
|
|
116
|
+
existing = JSON.parse(readFileSync(path, 'utf8'));
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
120
|
+
throw new Error(`Existing config at ${path} is not valid JSON (${msg}). Refusing to overwrite — fix or move it before re-running init.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let cursor = existing;
|
|
124
|
+
for (const key of parentKeys) {
|
|
125
|
+
const next = cursor[key];
|
|
126
|
+
if (next && typeof next === 'object' && !Array.isArray(next)) {
|
|
127
|
+
cursor = next;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
cursor[key] = {};
|
|
131
|
+
cursor = cursor[key];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
Object.assign(cursor, block);
|
|
135
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
136
|
+
writeFileSync(path, JSON.stringify(existing, null, 2) + '\n', 'utf8');
|
|
137
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions that produce the config snippet for each supported MCP client.
|
|
3
|
+
* Side-effect-free; the actual file writes / shell-outs live in cli.ts.
|
|
4
|
+
*/
|
|
5
|
+
export interface SnippetOptions {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
maxSpendPerSession?: number | null;
|
|
9
|
+
}
|
|
10
|
+
/** Config block for Claude Desktop's `mcpServers` object. */
|
|
11
|
+
export declare function claudeDesktopSnippet(opts: SnippetOptions): {
|
|
12
|
+
unhedged: {
|
|
13
|
+
command: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
env: Record<string, string>;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/** Config block for Cursor's `~/.cursor/mcp.json`. Schema mirrors Claude Desktop. */
|
|
19
|
+
export declare function cursorSnippet(opts: SnippetOptions): {
|
|
20
|
+
mcpServers: {
|
|
21
|
+
unhedged: {
|
|
22
|
+
command: string;
|
|
23
|
+
args: string[];
|
|
24
|
+
env: Record<string, string>;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** Shell command for `claude mcp add ...`. */
|
|
29
|
+
export declare function claudeCliCommand(opts: SnippetOptions): string;
|
|
30
|
+
/** Shell command for `codex mcp add ...`. */
|
|
31
|
+
export declare function codexCliCommand(opts: SnippetOptions): string;
|
|
32
|
+
/** Path to Claude Desktop's config file on the current platform. */
|
|
33
|
+
export declare function claudeDesktopConfigPath(platform: NodeJS.Platform, homedir: string, env?: NodeJS.ProcessEnv): string;
|
|
34
|
+
/** Path to Cursor's MCP config file. */
|
|
35
|
+
export declare function cursorConfigPath(homedir: string): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions that produce the config snippet for each supported MCP client.
|
|
3
|
+
* Side-effect-free; the actual file writes / shell-outs live in cli.ts.
|
|
4
|
+
*/
|
|
5
|
+
/** Config block for Claude Desktop's `mcpServers` object. */
|
|
6
|
+
export function claudeDesktopSnippet(opts) {
|
|
7
|
+
return {
|
|
8
|
+
unhedged: {
|
|
9
|
+
command: 'npx',
|
|
10
|
+
args: ['-y', '@unhedged/mcp'],
|
|
11
|
+
env: buildEnv(opts),
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Config block for Cursor's `~/.cursor/mcp.json`. Schema mirrors Claude Desktop. */
|
|
16
|
+
export function cursorSnippet(opts) {
|
|
17
|
+
return {
|
|
18
|
+
mcpServers: {
|
|
19
|
+
unhedged: {
|
|
20
|
+
command: 'npx',
|
|
21
|
+
args: ['-y', '@unhedged/mcp'],
|
|
22
|
+
env: buildEnv(opts),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Shell command for `claude mcp add ...`. */
|
|
28
|
+
export function claudeCliCommand(opts) {
|
|
29
|
+
return mcpAddCommand('claude', opts);
|
|
30
|
+
}
|
|
31
|
+
/** Shell command for `codex mcp add ...`. */
|
|
32
|
+
export function codexCliCommand(opts) {
|
|
33
|
+
return mcpAddCommand('codex', opts);
|
|
34
|
+
}
|
|
35
|
+
function mcpAddCommand(client, opts) {
|
|
36
|
+
const envFlags = Object.entries(buildEnv(opts))
|
|
37
|
+
.map(([k, v]) => `--env ${k}=${shellQuote(v)}`)
|
|
38
|
+
.join(' ');
|
|
39
|
+
const options = client === 'claude' ? ' --scope user --transport stdio' : '';
|
|
40
|
+
return `${client} mcp add unhedged${options} ${envFlags} -- npx -y @unhedged/mcp`;
|
|
41
|
+
}
|
|
42
|
+
function buildEnv(opts) {
|
|
43
|
+
const env = {
|
|
44
|
+
UNHEDGED_API_KEY: opts.apiKey,
|
|
45
|
+
};
|
|
46
|
+
if (opts.baseUrl)
|
|
47
|
+
env.UNHEDGED_BASE_URL = opts.baseUrl;
|
|
48
|
+
if (opts.maxSpendPerSession !== null && opts.maxSpendPerSession !== undefined) {
|
|
49
|
+
env.UNHEDGED_MAX_SPEND_PER_SESSION = String(opts.maxSpendPerSession);
|
|
50
|
+
}
|
|
51
|
+
return env;
|
|
52
|
+
}
|
|
53
|
+
function shellQuote(value) {
|
|
54
|
+
if (/^[A-Za-z0-9_@.+/:=-]+$/.test(value))
|
|
55
|
+
return value;
|
|
56
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
57
|
+
}
|
|
58
|
+
/** Path to Claude Desktop's config file on the current platform. */
|
|
59
|
+
export function claudeDesktopConfigPath(platform, homedir, env = process.env) {
|
|
60
|
+
if (platform === 'darwin') {
|
|
61
|
+
return `${homedir}/Library/Application Support/Claude/claude_desktop_config.json`;
|
|
62
|
+
}
|
|
63
|
+
if (platform === 'win32') {
|
|
64
|
+
const appData = env.APPDATA ?? `${homedir}\\AppData\\Roaming`;
|
|
65
|
+
return `${appData}\\Claude\\claude_desktop_config.json`;
|
|
66
|
+
}
|
|
67
|
+
return `${homedir}/.config/Claude/claude_desktop_config.json`;
|
|
68
|
+
}
|
|
69
|
+
/** Path to Cursor's MCP config file. */
|
|
70
|
+
export function cursorConfigPath(homedir) {
|
|
71
|
+
return `${homedir}/.cursor/mcp.json`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal frontmatter parser for skill markdown files.
|
|
3
|
+
*
|
|
4
|
+
* Supports a tiny YAML subset sufficient for our needs:
|
|
5
|
+
* - `key: value` scalars (strings, kept as strings)
|
|
6
|
+
* - `arguments:` list-of-objects in the shape
|
|
7
|
+
* - name: foo
|
|
8
|
+
* description: bar
|
|
9
|
+
* required: true
|
|
10
|
+
*
|
|
11
|
+
* Anything more exotic should be added as we need it — we deliberately don't
|
|
12
|
+
* pull in a full YAML dependency for six skill files.
|
|
13
|
+
*/
|
|
14
|
+
export interface SkillArgument {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
required: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface SkillFrontmatter {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
arguments: SkillArgument[];
|
|
23
|
+
}
|
|
24
|
+
export interface ParsedSkill {
|
|
25
|
+
frontmatter: SkillFrontmatter;
|
|
26
|
+
body: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function parseSkill(raw: string, sourcePath?: string): ParsedSkill;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal frontmatter parser for skill markdown files.
|
|
3
|
+
*
|
|
4
|
+
* Supports a tiny YAML subset sufficient for our needs:
|
|
5
|
+
* - `key: value` scalars (strings, kept as strings)
|
|
6
|
+
* - `arguments:` list-of-objects in the shape
|
|
7
|
+
* - name: foo
|
|
8
|
+
* description: bar
|
|
9
|
+
* required: true
|
|
10
|
+
*
|
|
11
|
+
* Anything more exotic should be added as we need it — we deliberately don't
|
|
12
|
+
* pull in a full YAML dependency for six skill files.
|
|
13
|
+
*/
|
|
14
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
|
15
|
+
export function parseSkill(raw, sourcePath = '<inline>') {
|
|
16
|
+
const match = FRONTMATTER_RE.exec(raw);
|
|
17
|
+
if (!match) {
|
|
18
|
+
throw new Error(`Skill ${sourcePath}: missing YAML frontmatter (--- ... ---).`);
|
|
19
|
+
}
|
|
20
|
+
const fmRaw = match[1];
|
|
21
|
+
const body = match[2].trim();
|
|
22
|
+
const fm = parseFrontmatter(fmRaw, sourcePath);
|
|
23
|
+
return { frontmatter: fm, body };
|
|
24
|
+
}
|
|
25
|
+
function parseFrontmatter(text, sourcePath) {
|
|
26
|
+
const lines = text.split(/\r?\n/);
|
|
27
|
+
const scalars = {};
|
|
28
|
+
const args = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < lines.length) {
|
|
31
|
+
const line = lines[i];
|
|
32
|
+
if (!line.trim() || line.trim().startsWith('#')) {
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (line.startsWith('arguments:')) {
|
|
37
|
+
i++;
|
|
38
|
+
while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('-'))) {
|
|
39
|
+
if (lines[i].trimStart().startsWith('- ')) {
|
|
40
|
+
const arg = {};
|
|
41
|
+
// First field is on the "- name: foo" line itself.
|
|
42
|
+
const firstField = lines[i].trimStart().slice(2);
|
|
43
|
+
applyField(arg, firstField, sourcePath);
|
|
44
|
+
i++;
|
|
45
|
+
// Continuation fields: any indented line that isn't a new `- ` item.
|
|
46
|
+
// Accepting any indent depth (not just 4 spaces) keeps 2-space YAML
|
|
47
|
+
// from silently dropping fields.
|
|
48
|
+
while (i < lines.length &&
|
|
49
|
+
/^\s+\S/.test(lines[i]) &&
|
|
50
|
+
!lines[i].trimStart().startsWith('- ')) {
|
|
51
|
+
applyField(arg, lines[i].trim(), sourcePath);
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
if (!arg.name) {
|
|
55
|
+
throw new Error(`Skill ${sourcePath}: argument entry missing "name".`);
|
|
56
|
+
}
|
|
57
|
+
args.push({
|
|
58
|
+
name: arg.name,
|
|
59
|
+
description: arg.description ?? '',
|
|
60
|
+
required: arg.required ?? false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
|
|
70
|
+
if (m) {
|
|
71
|
+
scalars[m[1]] = stripQuotes(m[2]);
|
|
72
|
+
}
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
if (!scalars.name)
|
|
76
|
+
throw new Error(`Skill ${sourcePath}: missing "name" in frontmatter.`);
|
|
77
|
+
if (!scalars.description)
|
|
78
|
+
throw new Error(`Skill ${sourcePath}: missing "description" in frontmatter.`);
|
|
79
|
+
return {
|
|
80
|
+
name: scalars.name,
|
|
81
|
+
description: scalars.description,
|
|
82
|
+
arguments: args,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function applyField(target, field, sourcePath) {
|
|
86
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/.exec(field);
|
|
87
|
+
if (!m) {
|
|
88
|
+
throw new Error(`Skill ${sourcePath}: malformed argument field "${field}".`);
|
|
89
|
+
}
|
|
90
|
+
const key = m[1];
|
|
91
|
+
const value = stripQuotes(m[2]);
|
|
92
|
+
switch (key) {
|
|
93
|
+
case 'name':
|
|
94
|
+
target.name = value;
|
|
95
|
+
break;
|
|
96
|
+
case 'description':
|
|
97
|
+
target.description = value;
|
|
98
|
+
break;
|
|
99
|
+
case 'required':
|
|
100
|
+
target.required = value.toLowerCase() === 'true';
|
|
101
|
+
break;
|
|
102
|
+
default:
|
|
103
|
+
// Unknown keys ignored to be forward-compatible.
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function stripQuotes(s) {
|
|
108
|
+
const t = s.trim();
|
|
109
|
+
if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))) {
|
|
110
|
+
return t.slice(1, -1);
|
|
111
|
+
}
|
|
112
|
+
return t;
|
|
113
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { defaultSkillsDir, loadSkills, type LoadedSkill } from './loader.js';
|
|
3
|
+
export { defaultSkillsDir, loadSkills };
|
|
4
|
+
export type { LoadedSkill };
|
|
5
|
+
export { parseSkill, type SkillFrontmatter, type SkillArgument, type ParsedSkill } from './frontmatter.js';
|
|
6
|
+
/**
|
|
7
|
+
* Register every skill in `skills/` as an MCP prompt. Returns the names of
|
|
8
|
+
* registered prompts so the caller can log or assert against them.
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerSkills(server: McpServer, skillsDir?: string): string[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defaultSkillsDir, loadSkills } from './loader.js';
|
|
3
|
+
export { defaultSkillsDir, loadSkills };
|
|
4
|
+
export { parseSkill } from './frontmatter.js';
|
|
5
|
+
/**
|
|
6
|
+
* Register every skill in `skills/` as an MCP prompt. Returns the names of
|
|
7
|
+
* registered prompts so the caller can log or assert against them.
|
|
8
|
+
*/
|
|
9
|
+
export function registerSkills(server, skillsDir) {
|
|
10
|
+
const skills = loadSkills(skillsDir ?? defaultSkillsDir());
|
|
11
|
+
for (const skill of skills) {
|
|
12
|
+
const argsSchema = {};
|
|
13
|
+
for (const arg of skill.frontmatter.arguments) {
|
|
14
|
+
argsSchema[arg.name] = arg.required
|
|
15
|
+
? z.string().describe(arg.description)
|
|
16
|
+
: z.string().optional().describe(arg.description);
|
|
17
|
+
}
|
|
18
|
+
server.registerPrompt(skill.frontmatter.name, {
|
|
19
|
+
title: skill.frontmatter.name,
|
|
20
|
+
description: skill.frontmatter.description,
|
|
21
|
+
argsSchema,
|
|
22
|
+
}, async (rawArgs) => {
|
|
23
|
+
const args = (rawArgs ?? {});
|
|
24
|
+
const filledBody = applyArgs(skill.body, args);
|
|
25
|
+
return {
|
|
26
|
+
messages: [
|
|
27
|
+
{
|
|
28
|
+
role: 'user',
|
|
29
|
+
content: {
|
|
30
|
+
type: 'text',
|
|
31
|
+
text: filledBody,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return skills.map((s) => s.frontmatter.name);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Substitutes `{{argName}}` placeholders in the skill body with the supplied
|
|
42
|
+
* argument values. Unset args become an empty string so the model can decide
|
|
43
|
+
* to ask. This is intentionally simple — full templating is over-engineering
|
|
44
|
+
* for prose workflows.
|
|
45
|
+
*/
|
|
46
|
+
function applyArgs(body, args) {
|
|
47
|
+
return body.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/g, (_match, name) => {
|
|
48
|
+
return args[name] ?? '';
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ParsedSkill } from './frontmatter.js';
|
|
2
|
+
export interface LoadedSkill extends ParsedSkill {
|
|
3
|
+
/** File path relative to the skills dir, useful in error messages. */
|
|
4
|
+
fileName: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the directory holding the shipped skill markdown files. When the
|
|
8
|
+
* package is installed via npm, skills live at `<pkg>/skills/`. When running
|
|
9
|
+
* from source via `tsx`, the layout is `<repo>/mcp/skills/` and this file is
|
|
10
|
+
* at `<repo>/mcp/src/prompts/loader.ts`, two levels deep.
|
|
11
|
+
*/
|
|
12
|
+
export declare function defaultSkillsDir(): string;
|
|
13
|
+
export declare function loadSkills(dir?: string): LoadedSkill[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parseSkill } from './frontmatter.js';
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the directory holding the shipped skill markdown files. When the
|
|
7
|
+
* package is installed via npm, skills live at `<pkg>/skills/`. When running
|
|
8
|
+
* from source via `tsx`, the layout is `<repo>/mcp/skills/` and this file is
|
|
9
|
+
* at `<repo>/mcp/src/prompts/loader.ts`, two levels deep.
|
|
10
|
+
*/
|
|
11
|
+
export function defaultSkillsDir() {
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
// Two levels up from this file (src/prompts/ or dist/prompts/) is the
|
|
14
|
+
// package root, which holds `skills/` in both layouts.
|
|
15
|
+
return resolve(here, '..', '..', 'skills');
|
|
16
|
+
}
|
|
17
|
+
export function loadSkills(dir = defaultSkillsDir()) {
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = readdirSync(dir);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
24
|
+
throw new Error(`Could not read skills directory at ${dir}: ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
const skills = [];
|
|
27
|
+
for (const entry of entries.sort()) {
|
|
28
|
+
if (!entry.endsWith('.md'))
|
|
29
|
+
continue;
|
|
30
|
+
const full = join(dir, entry);
|
|
31
|
+
const raw = readFileSync(full, 'utf8');
|
|
32
|
+
const parsed = parseSkill(raw, full);
|
|
33
|
+
skills.push({ ...parsed, fileName: entry });
|
|
34
|
+
}
|
|
35
|
+
return skills;
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SpendCapTracker, SpendCapExceededError } from './spend-cap.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SpendCapTracker, SpendCapExceededError } from './spend-cap.js';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process session spend cap. Tracks how much an agent has committed across
|
|
3
|
+
* `place_bet` and `place_order` (bid side) calls within a single server
|
|
4
|
+
* lifetime, and refuses further spend once the configured cap is reached.
|
|
5
|
+
*
|
|
6
|
+
* Independent of (additive to) any rate or balance limits the backend enforces.
|
|
7
|
+
* Resets when the process restarts.
|
|
8
|
+
*/
|
|
9
|
+
export declare class SpendCapTracker {
|
|
10
|
+
private readonly cap;
|
|
11
|
+
private spent;
|
|
12
|
+
constructor(cap: number | null);
|
|
13
|
+
/** How much has been committed so far (USD). */
|
|
14
|
+
totalSpent(): number;
|
|
15
|
+
/** Effective cap, or `null` if unset. */
|
|
16
|
+
effectiveCap(): number | null;
|
|
17
|
+
/**
|
|
18
|
+
* Throws SpendCapExceededError if `amount` would push cumulative spend over
|
|
19
|
+
* the cap. If the cap is null, this is a no-op. Calls `commit` on success.
|
|
20
|
+
*/
|
|
21
|
+
reserve(amount: number, toolName: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Return a previously reserved amount to the cap, e.g. when the backend
|
|
24
|
+
* definitively rejected the placement (4xx) so nothing was actually spent.
|
|
25
|
+
* Ambiguous failures (network errors, 5xx) should NOT be released.
|
|
26
|
+
*/
|
|
27
|
+
release(amount: number): void;
|
|
28
|
+
}
|
|
29
|
+
export declare class SpendCapExceededError extends Error {
|
|
30
|
+
readonly spent: number;
|
|
31
|
+
readonly cap: number;
|
|
32
|
+
constructor(message: string, spent: number, cap: number);
|
|
33
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process session spend cap. Tracks how much an agent has committed across
|
|
3
|
+
* `place_bet` and `place_order` (bid side) calls within a single server
|
|
4
|
+
* lifetime, and refuses further spend once the configured cap is reached.
|
|
5
|
+
*
|
|
6
|
+
* Independent of (additive to) any rate or balance limits the backend enforces.
|
|
7
|
+
* Resets when the process restarts.
|
|
8
|
+
*/
|
|
9
|
+
export class SpendCapTracker {
|
|
10
|
+
cap;
|
|
11
|
+
spent = 0;
|
|
12
|
+
constructor(cap) {
|
|
13
|
+
this.cap = cap;
|
|
14
|
+
}
|
|
15
|
+
/** How much has been committed so far (USD). */
|
|
16
|
+
totalSpent() {
|
|
17
|
+
return this.spent;
|
|
18
|
+
}
|
|
19
|
+
/** Effective cap, or `null` if unset. */
|
|
20
|
+
effectiveCap() {
|
|
21
|
+
return this.cap;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Throws SpendCapExceededError if `amount` would push cumulative spend over
|
|
25
|
+
* the cap. If the cap is null, this is a no-op. Calls `commit` on success.
|
|
26
|
+
*/
|
|
27
|
+
reserve(amount, toolName) {
|
|
28
|
+
if (this.cap === null)
|
|
29
|
+
return;
|
|
30
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
31
|
+
throw new SpendCapExceededError(`Tool ${toolName} attempted to reserve a negative or non-finite amount (${amount}). ` +
|
|
32
|
+
`Refusing as a safety measure.`, this.spent, this.cap);
|
|
33
|
+
}
|
|
34
|
+
if (this.spent + amount > this.cap) {
|
|
35
|
+
throw new SpendCapExceededError(`Refusing ${toolName}: this call would commit ${amount.toFixed(2)} USD, bringing the ` +
|
|
36
|
+
`session total to ${(this.spent + amount).toFixed(2)} USD and exceeding the configured ` +
|
|
37
|
+
`cap of ${this.cap.toFixed(2)} USD (UNHEDGED_MAX_SPEND_PER_SESSION). ` +
|
|
38
|
+
`Restart the MCP server or raise the cap to continue.`, this.spent, this.cap);
|
|
39
|
+
}
|
|
40
|
+
this.spent += amount;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Return a previously reserved amount to the cap, e.g. when the backend
|
|
44
|
+
* definitively rejected the placement (4xx) so nothing was actually spent.
|
|
45
|
+
* Ambiguous failures (network errors, 5xx) should NOT be released.
|
|
46
|
+
*/
|
|
47
|
+
release(amount) {
|
|
48
|
+
if (this.cap === null)
|
|
49
|
+
return;
|
|
50
|
+
if (!Number.isFinite(amount) || amount <= 0)
|
|
51
|
+
return;
|
|
52
|
+
this.spent = Math.max(0, this.spent - amount);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class SpendCapExceededError extends Error {
|
|
56
|
+
spent;
|
|
57
|
+
cap;
|
|
58
|
+
constructor(message, spent, cap) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'SpendCapExceededError';
|
|
61
|
+
this.spent = spent;
|
|
62
|
+
this.cap = cap;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/server.d.ts
ADDED