numikeep-mcp 0.1.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.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/package.json +33 -0
- package/src/core.mjs +104 -0
- package/src/server.mjs +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Snowpack Consulting
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# numikeep-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that lets an AI agent add coins to a Numikeep collection, using a
|
|
4
|
+
connection key the collector created in **Numikeep → Settings → Connected apps**.
|
|
5
|
+
|
|
6
|
+
```json
|
|
7
|
+
{ "mcpServers": { "numikeep": {
|
|
8
|
+
"command": "node",
|
|
9
|
+
"args": ["/path/to/numikeep-api/mcp/src/server.mjs"],
|
|
10
|
+
"env": { "NUMIKEEP_KEY": "nkpat_…", "NUMIKEEP_PHOTO_DIR": "/Users/you/Pictures/Coins" } } } }
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Tools: `numikeep_status`, `add_coin`, `update_coin`, `remove_coin`, `add_location`,
|
|
14
|
+
`add_photo`, `list_coins` (only when the key has the see-coins permission).
|
|
15
|
+
|
|
16
|
+
## Security model
|
|
17
|
+
Assume the agent can be prompt-injected. So:
|
|
18
|
+
- stdio only — no network listener;
|
|
19
|
+
- the key only reaches the configured API (https, or http on loopback), and redirects are refused;
|
|
20
|
+
- photos: only real JPEG/PNG/WebP/HEIC files (magic bytes + matching extension), only from
|
|
21
|
+
`NUMIKEEP_PHOTO_DIR` after resolving symlinks, and only uploaded to storage.googleapis.com;
|
|
22
|
+
- everything else is enforced by the API: the key adds coins and changes only what it added,
|
|
23
|
+
and never sees locations, prices paid, notes or photos.
|
|
24
|
+
|
|
25
|
+
`npm test` covers the guard rails (`test/core.test.mjs`).
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "numikeep-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that lets an AI agent add coins to a Numikeep collection, using a connection key the owner created.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"numikeep-mcp": "src/server.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test test/*.test.mjs"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "1.30.1",
|
|
17
|
+
"zod": "4.6.5"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"homepage": "https://numikeep.com/developers/",
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"numikeep",
|
|
28
|
+
"mcp",
|
|
29
|
+
"model-context-protocol",
|
|
30
|
+
"coins",
|
|
31
|
+
"numismatics"
|
|
32
|
+
]
|
|
33
|
+
}
|
package/src/core.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The security-critical half of the Numikeep MCP server, kept apart from the MCP wiring so
|
|
3
|
+
* it can be tested directly. Mike, 2026-09-26: "make sure security is absolutely
|
|
4
|
+
* rock-solid for the API/MCP."
|
|
5
|
+
*
|
|
6
|
+
* THE THREAT MODEL. An AI agent drives this server, and an agent can be steered by text it
|
|
7
|
+
* reads (a web page, a dealer's PDF, an email): prompt injection. So assume every tool call
|
|
8
|
+
* may be hostile, and make the worst a hostile call can do small:
|
|
9
|
+
* - It can only reach Numikeep, only with the owner's key, and the key can only add coins
|
|
10
|
+
* and change the coins it added (numikeep-api enforces that, not this file).
|
|
11
|
+
* - It can never send a file off the machine unless that file is a real image, under the
|
|
12
|
+
* one folder the owner chose (NUMIKEEP_PHOTO_DIR), after resolving symlinks. So
|
|
13
|
+
* "upload ~/.ssh/id_rsa as a photo" fails three ways.
|
|
14
|
+
* - The key never leaves for any host but the configured API: fetch refuses redirects,
|
|
15
|
+
* and photo bytes go only to the Google Cloud Storage URL the API signed.
|
|
16
|
+
* - Nothing it returns contains the key, and errors are reduced to Numikeep's error code.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const MAX_PHOTO_BYTES = 25 * 1024 * 1024;
|
|
22
|
+
const KEY_RE = /^nk(pat|at)_[A-Za-z0-9_-]{20,200}$/;
|
|
23
|
+
|
|
24
|
+
export function loadConfig(env = process.env) {
|
|
25
|
+
const key = String(env.NUMIKEEP_KEY ?? '').trim();
|
|
26
|
+
if (!KEY_RE.test(key)) {
|
|
27
|
+
throw new Error('Set NUMIKEEP_KEY to a Numikeep connection key (Numikeep → Settings → Connected apps).');
|
|
28
|
+
}
|
|
29
|
+
const apiUrl = String(env.NUMIKEEP_API_URL ?? 'https://numikeep-api-824632661809.us-east1.run.app').replace(/\/+$/, '');
|
|
30
|
+
let u;
|
|
31
|
+
try { u = new URL(apiUrl); } catch { throw new Error('NUMIKEEP_API_URL is not a valid URL.'); }
|
|
32
|
+
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname);
|
|
33
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
|
|
34
|
+
throw new Error('NUMIKEEP_API_URL must be https (http is allowed only on this machine).');
|
|
35
|
+
}
|
|
36
|
+
if (u.username || u.password || u.search || u.hash) throw new Error('NUMIKEEP_API_URL must be a plain origin.');
|
|
37
|
+
const photoDir = env.NUMIKEEP_PHOTO_DIR ? path.resolve(String(env.NUMIKEEP_PHOTO_DIR)) : null;
|
|
38
|
+
return { key, apiUrl: u.origin, photoDir };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Numikeep's own error code, or a generic one — never a stack, a URL or a header. */
|
|
42
|
+
export class NumikeepError extends Error {
|
|
43
|
+
constructor(status, code) { super(`Numikeep said ${status} ${code}`); this.status = status; this.code = code; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createClient({ key, apiUrl }, fetchImpl = fetch) {
|
|
47
|
+
async function call(method, route, body) {
|
|
48
|
+
const res = await fetchImpl(`${apiUrl}${route}`, {
|
|
49
|
+
method,
|
|
50
|
+
headers: { authorization: `Bearer ${key}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
51
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
52
|
+
// A redirect would carry the key to wherever it points. Numikeep never redirects API
|
|
53
|
+
// calls, so any redirect is a fault or an attack: refuse it.
|
|
54
|
+
redirect: 'error',
|
|
55
|
+
signal: AbortSignal.timeout(30_000),
|
|
56
|
+
});
|
|
57
|
+
let json = null;
|
|
58
|
+
try { json = await res.json(); } catch { /* not JSON */ }
|
|
59
|
+
if (!res.ok) throw new NumikeepError(res.status, typeof json?.error === 'string' ? json.error.slice(0, 60) : 'error');
|
|
60
|
+
return json;
|
|
61
|
+
}
|
|
62
|
+
return { call };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Only ever PUT photo bytes to the Cloud Storage URL the API just signed. */
|
|
66
|
+
export function assertUploadUrl(uploadUrl) {
|
|
67
|
+
let u;
|
|
68
|
+
try { u = new URL(uploadUrl); } catch { throw new Error('Numikeep returned an invalid upload address.'); }
|
|
69
|
+
if (u.protocol !== 'https:' || u.hostname !== 'storage.googleapis.com') {
|
|
70
|
+
throw new Error('Numikeep returned an unexpected upload address; refusing to send the photo.');
|
|
71
|
+
}
|
|
72
|
+
return u.toString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SIGNATURES = [
|
|
76
|
+
{ type: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
77
|
+
{ type: 'image/png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
|
|
78
|
+
{ type: 'image/webp', test: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP' },
|
|
79
|
+
{ type: 'image/heic', test: (b) => b.subarray(4, 8).toString('latin1') === 'ftyp' && /^(heic|heix|hevc|mif1|msf1)$/.test(b.subarray(8, 12).toString('latin1')) },
|
|
80
|
+
];
|
|
81
|
+
const EXT = { 'image/jpeg': ['.jpg', '.jpeg'], 'image/png': ['.png'], 'image/webp': ['.webp'], 'image/heic': ['.heic', '.heif'] };
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a photo the agent named, or refuse. The file must be: inside the owner's photo
|
|
85
|
+
* folder AFTER following symlinks, a regular file, at most 25MB, and an image by its first
|
|
86
|
+
* bytes AND its extension. Returns { buf, contentType }.
|
|
87
|
+
*/
|
|
88
|
+
export async function readPhoto(photoDir, requested) {
|
|
89
|
+
if (!photoDir) throw new Error('Photo uploads are off. Set NUMIKEEP_PHOTO_DIR to the folder the agent may upload from.');
|
|
90
|
+
if (typeof requested !== 'string' || !requested || requested.includes('\0')) throw new Error('Give a photo file name.');
|
|
91
|
+
const root = await fs.realpath(photoDir);
|
|
92
|
+
const candidate = path.resolve(root, requested);
|
|
93
|
+
let real;
|
|
94
|
+
try { real = await fs.realpath(candidate); } catch { throw new Error('That photo was not found in the photo folder.'); }
|
|
95
|
+
if (real !== root && !real.startsWith(root + path.sep)) throw new Error('That file is outside the photo folder.');
|
|
96
|
+
const st = await fs.stat(real);
|
|
97
|
+
if (!st.isFile()) throw new Error('That is not a file.');
|
|
98
|
+
if (st.size < 12 || st.size > MAX_PHOTO_BYTES) throw new Error('Photos must be between 12 bytes and 25MB.');
|
|
99
|
+
const buf = await fs.readFile(real);
|
|
100
|
+
const sig = SIGNATURES.find((s) => s.test(buf));
|
|
101
|
+
if (!sig) throw new Error('That file is not a JPEG, PNG, WebP or HEIC image.');
|
|
102
|
+
if (!EXT[sig.type].includes(path.extname(real).toLowerCase())) throw new Error('The file extension does not match its contents.');
|
|
103
|
+
return { buf, contentType: sig.type };
|
|
104
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Numikeep MCP server — lets an AI agent add coins to the owner's Numikeep collection.
|
|
4
|
+
*
|
|
5
|
+
* Local only (stdio). Configure with:
|
|
6
|
+
* NUMIKEEP_KEY a connection key from Numikeep → Settings → Connected apps (required)
|
|
7
|
+
* NUMIKEEP_PHOTO_DIR the one folder the agent may upload photos from (optional; photos are
|
|
8
|
+
* off without it)
|
|
9
|
+
* NUMIKEEP_API_URL override for testing (https, or http on this machine)
|
|
10
|
+
*
|
|
11
|
+
* What an agent can do here is exactly what the key allows: add coins, locations and photos,
|
|
12
|
+
* and change or remove only what this key added. It can list coins only if the owner ticked
|
|
13
|
+
* "Also let it see which coins you have" — and even then never where they are kept, what was
|
|
14
|
+
* paid, notes or photos. See core.mjs for the threat model.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { loadConfig, createClient, readPhoto, assertUploadUrl, NumikeepError } from './core.mjs';
|
|
21
|
+
|
|
22
|
+
const cfg = loadConfig();
|
|
23
|
+
const api = createClient(cfg);
|
|
24
|
+
const server = new McpServer({ name: 'numikeep', version: '0.1.0' });
|
|
25
|
+
|
|
26
|
+
const text = (t) => ({ content: [{ type: 'text', text: t }] });
|
|
27
|
+
const fail = (e) => ({
|
|
28
|
+
isError: true,
|
|
29
|
+
content: [{ type: 'text', text: e instanceof NumikeepError ? `Numikeep refused: ${e.code}` : String(e?.message ?? 'failed').slice(0, 300) }],
|
|
30
|
+
});
|
|
31
|
+
const guard = (fn) => async (args) => { try { return await fn(args); } catch (e) { return fail(e); } };
|
|
32
|
+
|
|
33
|
+
const GRADERS = ['RAW', 'PCGS', 'NGC', 'CACG', 'ANACS', 'ICG', 'OTHER'];
|
|
34
|
+
const coinShape = {
|
|
35
|
+
name: z.string().min(1).max(300).describe('What the coin is, e.g. "1893-S Morgan Dollar". Add "(GS 1234)" with the Greysheet id if you know it exactly.'),
|
|
36
|
+
grader: z.enum(GRADERS).default('RAW').describe('Grading service, or RAW if ungraded'),
|
|
37
|
+
grade: z.string().max(20).optional().describe('e.g. MS65 — required unless RAW'),
|
|
38
|
+
graderName: z.string().max(100).optional().describe('Only when grader is OTHER'),
|
|
39
|
+
cac: z.boolean().optional(),
|
|
40
|
+
cacTier: z.enum(['green', 'gold']).optional(),
|
|
41
|
+
cert: z.string().max(40).optional().describe('Certification number'),
|
|
42
|
+
qty: z.number().int().min(1).max(10000).optional(),
|
|
43
|
+
color: z.enum(['RD', 'RB', 'BN']).optional().describe('Copper colour, if copper'),
|
|
44
|
+
acquiredDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
45
|
+
acquiredPriceCents: z.number().int().min(0).max(1e12).optional().describe('What the owner paid, in cents'),
|
|
46
|
+
locationId: z.string().uuid().optional().describe('A location this connection created with add_location'),
|
|
47
|
+
notes: z.string().max(5000).optional(),
|
|
48
|
+
};
|
|
49
|
+
const toBody = (a, id) => ({
|
|
50
|
+
id, catalogItemId: a.name, grader: a.grader ?? 'RAW',
|
|
51
|
+
...(a.grade !== undefined ? { grade: a.grade } : {}),
|
|
52
|
+
...(a.graderName !== undefined ? { graderName: a.graderName } : {}),
|
|
53
|
+
...(a.cac !== undefined ? { cac: a.cac } : {}),
|
|
54
|
+
...(a.cacTier !== undefined ? { cacTier: a.cacTier } : {}),
|
|
55
|
+
...(a.cert !== undefined ? { cert: a.cert } : {}),
|
|
56
|
+
...(a.qty !== undefined ? { qty: a.qty } : {}),
|
|
57
|
+
...(a.color !== undefined ? { color: a.color } : {}),
|
|
58
|
+
...(a.acquiredDate !== undefined ? { acquiredDate: a.acquiredDate } : {}),
|
|
59
|
+
...(a.acquiredPriceCents !== undefined ? { acquiredPriceCents: a.acquiredPriceCents } : {}),
|
|
60
|
+
...(a.locationId !== undefined ? { locationId: a.locationId } : {}),
|
|
61
|
+
...(a.notes !== undefined ? { notes: a.notes } : {}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
server.registerTool('numikeep_status', {
|
|
65
|
+
title: 'Check the Numikeep connection',
|
|
66
|
+
description: 'Confirms the key works and whether this connection may list coins.',
|
|
67
|
+
inputSchema: {},
|
|
68
|
+
annotations: { readOnlyHint: true },
|
|
69
|
+
}, guard(async () => {
|
|
70
|
+
const v = await api.call('GET', '/v2/collection');
|
|
71
|
+
return text(`Connected to a Numikeep collection. ${Array.isArray(v.coins) ? 'This connection may list coins.' : 'This connection can add coins but not list them.'} Photo uploads are ${cfg.photoDir ? 'on' : 'off'}.`);
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
server.registerTool('add_coin', {
|
|
75
|
+
title: 'Add a coin',
|
|
76
|
+
description: 'Adds one coin to the owner\'s Numikeep collection. Returns the new coin id — keep it to update or remove the coin later.',
|
|
77
|
+
inputSchema: coinShape,
|
|
78
|
+
}, guard(async (a) => {
|
|
79
|
+
const row = await api.call('POST', '/v2/collection/coins', toBody(a, randomUUID()));
|
|
80
|
+
return text(`Added "${a.name}" (id ${row.id}).`);
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
server.registerTool('update_coin', {
|
|
84
|
+
title: 'Update a coin this connection added',
|
|
85
|
+
description: 'Replaces the details of a coin this connection added. Coins the owner entered themselves cannot be changed.',
|
|
86
|
+
inputSchema: { id: z.string().uuid(), ...coinShape },
|
|
87
|
+
}, guard(async (a) => {
|
|
88
|
+
const row = await api.call('POST', '/v2/collection/coins', toBody(a, a.id));
|
|
89
|
+
return text(`Updated "${a.name}" (id ${row.id}).`);
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
server.registerTool('remove_coin', {
|
|
93
|
+
title: 'Remove a coin this connection added',
|
|
94
|
+
description: 'Removes one coin this connection added. Coins the owner entered themselves cannot be removed.',
|
|
95
|
+
inputSchema: { id: z.string().uuid() },
|
|
96
|
+
annotations: { destructiveHint: true },
|
|
97
|
+
}, guard(async ({ id }) => {
|
|
98
|
+
await api.call('DELETE', `/v2/collection/coins/${id}`);
|
|
99
|
+
return text(`Removed coin ${id}.`);
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
server.registerTool('add_location', {
|
|
103
|
+
title: 'Add a storage location',
|
|
104
|
+
description: 'Adds a place coins are kept (e.g. "Home safe") that coins added by this connection can be filed under. Locations are private to the owner and are never shown to dealers or other apps.',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
label: z.string().min(1).max(200),
|
|
107
|
+
kind: z.enum(['home_safe', 'bank_box', 'dealer', 'display', 'other']).default('other'),
|
|
108
|
+
},
|
|
109
|
+
}, guard(async ({ label, kind }) => {
|
|
110
|
+
const row = await api.call('POST', '/v2/collection/locations', { id: randomUUID(), label, kind });
|
|
111
|
+
return text(`Added location "${label}" (id ${row.id}).`);
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
server.registerTool('add_photo', {
|
|
115
|
+
title: 'Add a photo to a coin',
|
|
116
|
+
description: 'Uploads a JPEG, PNG, WebP or HEIC image from the owner\'s photo folder to a coin this connection added. Only files inside NUMIKEEP_PHOTO_DIR can be sent.',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
coinId: z.string().uuid(),
|
|
119
|
+
file: z.string().min(1).max(500).describe('File name inside the photo folder'),
|
|
120
|
+
side: z.enum(['obverse', 'reverse', 'slab', 'other']).default('obverse'),
|
|
121
|
+
},
|
|
122
|
+
}, guard(async ({ coinId, file, side }) => {
|
|
123
|
+
const { buf, contentType } = await readPhoto(cfg.photoDir, file);
|
|
124
|
+
const up = await api.call('POST', '/v2/collection/photos', { coinId, side, contentType, contentLength: buf.length });
|
|
125
|
+
const put = await fetch(assertUploadUrl(up.uploadUrl), {
|
|
126
|
+
method: 'PUT', headers: up.headers, body: buf, redirect: 'error', signal: AbortSignal.timeout(120_000),
|
|
127
|
+
});
|
|
128
|
+
if (!put.ok) throw new Error(`The photo upload was refused (${put.status}).`);
|
|
129
|
+
return text(`Added the ${side} photo to coin ${coinId}.`);
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
server.registerTool('list_coins', {
|
|
133
|
+
title: 'List the owner\'s coins',
|
|
134
|
+
description: 'Lists what coins the owner has — only if they allowed this connection to see coins. Never includes where coins are kept, prices paid, notes or photos.',
|
|
135
|
+
inputSchema: {},
|
|
136
|
+
annotations: { readOnlyHint: true },
|
|
137
|
+
}, guard(async () => {
|
|
138
|
+
const v = await api.call('GET', '/v2/collection');
|
|
139
|
+
if (!Array.isArray(v.coins)) return text('This connection is not allowed to see the collection. The owner can make a new key with "Also let it see which coins you have".');
|
|
140
|
+
// Returned as data, not instructions: names are owner-typed text.
|
|
141
|
+
return { content: [{ type: 'text', text: JSON.stringify(v.coins) }] };
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
await server.connect(new StdioServerTransport());
|