@proofofprotocol/inscribe-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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Config Management
3
+ *
4
+ * Read/write ~/.inscribe-mcp/config.json
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
8
+ import { homedir } from 'os';
9
+ import { join } from 'path';
10
+
11
+ const CONFIG_DIR = join(homedir(), '.inscribe-mcp');
12
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
13
+
14
+ /**
15
+ * Get config directory path
16
+ */
17
+ export function getConfigDir() {
18
+ return CONFIG_DIR;
19
+ }
20
+
21
+ /**
22
+ * Get config file path
23
+ */
24
+ export function getConfigPath() {
25
+ return CONFIG_FILE;
26
+ }
27
+
28
+ /**
29
+ * Check if config exists
30
+ */
31
+ export function configExists() {
32
+ return existsSync(CONFIG_FILE);
33
+ }
34
+
35
+ /**
36
+ * Read config
37
+ * @returns {Object|null} Config object or null if not found
38
+ */
39
+ export function readConfig() {
40
+ if (!configExists()) {
41
+ return null;
42
+ }
43
+
44
+ try {
45
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
46
+ return JSON.parse(content);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Write config
54
+ * @param {Object} config - Config object to write
55
+ */
56
+ export function writeConfig(config) {
57
+ // Ensure directory exists
58
+ if (!existsSync(CONFIG_DIR)) {
59
+ mkdirSync(CONFIG_DIR, { recursive: true });
60
+ }
61
+
62
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
63
+ }
64
+
65
+ /**
66
+ * Validate config structure
67
+ * @param {Object} config - Config to validate
68
+ * @returns {Object} { valid: boolean, errors: string[] }
69
+ */
70
+ export function validateConfig(config) {
71
+ const errors = [];
72
+
73
+ if (!config) {
74
+ errors.push('config is null or undefined');
75
+ return { valid: false, errors };
76
+ }
77
+
78
+ if (!config.network) {
79
+ errors.push('network is required');
80
+ } else if (!['testnet', 'mainnet'].includes(config.network)) {
81
+ errors.push('network must be "testnet" or "mainnet"');
82
+ }
83
+
84
+ if (!config.operatorAccountId) {
85
+ errors.push('operatorAccountId is required');
86
+ } else if (!/^0\.0\.\d+$/.test(config.operatorAccountId)) {
87
+ errors.push('operatorAccountId must be in format 0.0.XXXXX');
88
+ }
89
+
90
+ if (!config.operatorPrivateKey) {
91
+ errors.push('operatorPrivateKey is required');
92
+ }
93
+
94
+ // defaultTopicId is optional (will be auto-created)
95
+
96
+ return {
97
+ valid: errors.length === 0,
98
+ errors
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Mask private key for display
104
+ * @param {string} key - Private key
105
+ * @returns {string} Masked key
106
+ */
107
+ export function maskPrivateKey(key) {
108
+ if (!key || key.length < 16) {
109
+ return '********';
110
+ }
111
+ return key.substring(0, 8) + '...' + key.substring(key.length - 8);
112
+ }
113
+
114
+ /**
115
+ * Get config for display (with masked private key)
116
+ * @returns {Object|null} Config with masked private key
117
+ */
118
+ export function getDisplayConfig() {
119
+ const config = readConfig();
120
+ if (!config) return null;
121
+
122
+ return {
123
+ ...config,
124
+ operatorPrivateKey: maskPrivateKey(config.operatorPrivateKey)
125
+ };
126
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * CLI Exit Codes
3
+ *
4
+ * Standard exit codes for inscribe-mcp CLI.
5
+ */
6
+
7
+ export const EXIT_CODES = {
8
+ SUCCESS: 0, // 正常終了
9
+ CONFIG_ERROR: 1, // 設定不備(config.json 未作成など)
10
+ LOG_NOT_FOUND: 2, // ログ未検出
11
+ NETWORK_ERROR: 3 // ネットワーク/API エラー
12
+ };
13
+
14
+ export const EXIT_MESSAGES = {
15
+ [EXIT_CODES.SUCCESS]: '正常終了',
16
+ [EXIT_CODES.CONFIG_ERROR]: '設定エラー: config.json が見つからないか不正です',
17
+ [EXIT_CODES.LOG_NOT_FOUND]: 'ログが見つかりません',
18
+ [EXIT_CODES.NETWORK_ERROR]: 'ネットワークエラー: API への接続に失敗しました'
19
+ };
package/src/lib/did.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * DID Utilities
3
+ *
4
+ * Normalize and validate Hedera DIDs.
5
+ * Standard format: did:hedera:{network}:{accountId}
6
+ */
7
+
8
+ import { getMirrorNodeUrl } from './hedera.js';
9
+
10
+ /**
11
+ * Normalize author to standard DID format
12
+ * Accepts: Account ID, EVM address, or full DID
13
+ * Returns: did:hedera:{network}:{accountId} or null
14
+ */
15
+ export async function normalizeDid(author) {
16
+ if (!author) return null;
17
+
18
+ const network = process.env.HEDERA_NETWORK || 'testnet';
19
+
20
+ // Already standard DID format with Account ID
21
+ const didMatch = author.match(/^did:hedera:\w+:(0\.0\.\d+)$/);
22
+ if (didMatch) {
23
+ return `did:hedera:${network}:${didMatch[1]}`;
24
+ }
25
+
26
+ // Account ID only (0.0.xxxxx)
27
+ const accountMatch = author.match(/^(0\.0\.\d+)$/);
28
+ if (accountMatch) {
29
+ return `did:hedera:${network}:${accountMatch[1]}`;
30
+ }
31
+
32
+ // EVM address (with or without 0x, or in DID format)
33
+ const evmMatch = author.match(/(?:did:hedera:\w+:)?(?:0x)?([a-fA-F0-9]{40})$/);
34
+ if (evmMatch) {
35
+ const evmAddress = evmMatch[1].toLowerCase();
36
+ // Try to resolve to Account ID
37
+ const accountId = await evmToAccountId(evmAddress);
38
+ if (accountId) {
39
+ return `did:hedera:${network}:${accountId}`;
40
+ }
41
+ // Fallback: return EVM-based DID (not ideal but valid)
42
+ return `did:hedera:${network}:0x${evmAddress}`;
43
+ }
44
+
45
+ // Unknown format - return as-is wrapped in DID
46
+ return `did:hedera:${network}:${author}`;
47
+ }
48
+
49
+ /**
50
+ * Resolve EVM address to Account ID via Mirror Node
51
+ */
52
+ async function evmToAccountId(evmAddress) {
53
+ try {
54
+ const mirrorUrl = getMirrorNodeUrl();
55
+ const response = await fetch(`${mirrorUrl}/api/v1/accounts/0x${evmAddress}`);
56
+ if (response.ok) {
57
+ const data = await response.json();
58
+ return data.account || null;
59
+ }
60
+ } catch {
61
+ // Ignore errors
62
+ }
63
+ return null;
64
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Hedera Client Library
3
+ * Shared utilities for Hedera network interactions
4
+ *
5
+ * Configuration priority:
6
+ * 1. ~/.inscribe-mcp/config.json (if exists)
7
+ * 2. Environment variables / .env (fallback)
8
+ */
9
+
10
+ import { Client, AccountId, PrivateKey, TopicId, TopicCreateTransaction } from '@hashgraph/sdk';
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12
+ import { homedir } from 'os';
13
+ import { join } from 'path';
14
+ import dotenv from 'dotenv';
15
+
16
+ dotenv.config();
17
+
18
+ let client = null;
19
+ let cachedConfig = null;
20
+
21
+ /**
22
+ * Load configuration from config.json or environment
23
+ */
24
+ function loadConfig() {
25
+ if (cachedConfig) return cachedConfig;
26
+
27
+ const configPath = join(homedir(), '.inscribe-mcp', 'config.json');
28
+
29
+ // Try config.json first
30
+ if (existsSync(configPath)) {
31
+ try {
32
+ const content = readFileSync(configPath, 'utf-8');
33
+ cachedConfig = JSON.parse(content);
34
+ return cachedConfig;
35
+ } catch {
36
+ // Fall through to env
37
+ }
38
+ }
39
+
40
+ // Fallback to environment variables
41
+ cachedConfig = {
42
+ network: process.env.HEDERA_NETWORK || 'testnet',
43
+ operatorAccountId: process.env.HEDERA_OPERATOR_ID || process.env.HEDERA_ACCOUNT_ID,
44
+ operatorPrivateKey: process.env.HEDERA_OPERATOR_KEY || process.env.HEDERA_PRIVATE_KEY,
45
+ defaultTopicId: process.env.DEFAULT_TOPIC_ID
46
+ };
47
+
48
+ return cachedConfig;
49
+ }
50
+
51
+ /**
52
+ * Get or create Hedera client
53
+ */
54
+ export function getHederaClient() {
55
+ if (client) return client;
56
+
57
+ const config = loadConfig();
58
+ const { network, operatorAccountId, operatorPrivateKey } = config;
59
+
60
+ if (!operatorAccountId || !operatorPrivateKey) {
61
+ throw new Error(
62
+ 'Hedera credentials not found.\n' +
63
+ 'Run "npx inscribe-mcp init" to configure, or set environment variables.'
64
+ );
65
+ }
66
+
67
+ // Parse private key (supports DER and raw formats)
68
+ let privateKey;
69
+ if (operatorPrivateKey.startsWith('302')) {
70
+ privateKey = PrivateKey.fromStringDer(operatorPrivateKey);
71
+ } else if (operatorPrivateKey.startsWith('0x')) {
72
+ privateKey = PrivateKey.fromStringECDSA(operatorPrivateKey);
73
+ } else {
74
+ privateKey = PrivateKey.fromString(operatorPrivateKey);
75
+ }
76
+
77
+ client = network === 'mainnet'
78
+ ? Client.forMainnet()
79
+ : Client.forTestnet();
80
+
81
+ client.setOperator(AccountId.fromString(operatorAccountId), privateKey);
82
+
83
+ return client;
84
+ }
85
+
86
+ /**
87
+ * Get operator account ID
88
+ */
89
+ export function getOperatorId() {
90
+ const config = loadConfig();
91
+ return AccountId.fromString(config.operatorAccountId);
92
+ }
93
+
94
+ /**
95
+ * Get default topic ID for inscriptions
96
+ * Returns null if not configured (caller should use getOrCreateTopicId instead)
97
+ */
98
+ export function getTopicId() {
99
+ const config = loadConfig();
100
+ const topicId = config.defaultTopicId;
101
+ if (!topicId) {
102
+ return null;
103
+ }
104
+ return TopicId.fromString(topicId);
105
+ }
106
+
107
+ /**
108
+ * Check if topic ID is configured
109
+ */
110
+ export function hasTopicId() {
111
+ const config = loadConfig();
112
+ return !!config.defaultTopicId;
113
+ }
114
+
115
+ /**
116
+ * Get config file path
117
+ */
118
+ function getConfigPath() {
119
+ return join(homedir(), '.inscribe-mcp', 'config.json');
120
+ }
121
+
122
+ /**
123
+ * Save topic ID to config.json
124
+ */
125
+ export function saveTopicId(topicId) {
126
+ const configPath = getConfigPath();
127
+ const configDir = join(homedir(), '.inscribe-mcp');
128
+
129
+ // Ensure directory exists
130
+ if (!existsSync(configDir)) {
131
+ mkdirSync(configDir, { recursive: true });
132
+ }
133
+
134
+ // Read existing config or create new
135
+ let config = {};
136
+ if (existsSync(configPath)) {
137
+ try {
138
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
139
+ } catch {
140
+ // Start fresh
141
+ }
142
+ }
143
+
144
+ // Update topic ID
145
+ config.defaultTopicId = topicId.toString();
146
+
147
+ // Write back
148
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
149
+
150
+ // Clear cache so next read picks up new value
151
+ clearConfigCache();
152
+ }
153
+
154
+ /**
155
+ * Create a new topic and save to config
156
+ * @returns {Promise<TopicId>}
157
+ */
158
+ export async function createAndSaveTopic() {
159
+ const client = getHederaClient();
160
+
161
+ const transaction = new TopicCreateTransaction()
162
+ .setTopicMemo('inscribe-mcp topic');
163
+
164
+ const response = await transaction.execute(client);
165
+ const receipt = await response.getReceipt(client);
166
+ const topicId = receipt.topicId;
167
+
168
+ // Save to config
169
+ saveTopicId(topicId);
170
+
171
+ return topicId;
172
+ }
173
+
174
+ /**
175
+ * Get topic ID, creating one if not configured
176
+ * @returns {Promise<TopicId>}
177
+ */
178
+ export async function getOrCreateTopicId() {
179
+ const existing = getTopicId();
180
+ if (existing) {
181
+ return existing;
182
+ }
183
+
184
+ // Create new topic
185
+ return await createAndSaveTopic();
186
+ }
187
+
188
+ /**
189
+ * Get network name
190
+ */
191
+ export function getNetwork() {
192
+ const config = loadConfig();
193
+ return config.network || 'testnet';
194
+ }
195
+
196
+ /**
197
+ * Get Mirror Node URL based on network
198
+ */
199
+ export function getMirrorNodeUrl() {
200
+ const network = getNetwork();
201
+ return network === 'mainnet'
202
+ ? 'https://mainnet.mirrornode.hedera.com'
203
+ : 'https://testnet.mirrornode.hedera.com';
204
+ }
205
+
206
+ /**
207
+ * Clear cached config (for testing or reloading)
208
+ */
209
+ export function clearConfigCache() {
210
+ cachedConfig = null;
211
+ }
212
+
213
+ /**
214
+ * Close the client connection
215
+ */
216
+ export function closeClient() {
217
+ if (client) {
218
+ client.close();
219
+ client = null;
220
+ }
221
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Logger - JSONL structured logging
3
+ *
4
+ * Append-only logging to ~/.inscribe-mcp/logs/YYYY-MM-DD.jsonl
5
+ * Used by MCP Server to record all tool executions.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, appendFileSync } from 'fs';
9
+ import { homedir } from 'os';
10
+ import { join } from 'path';
11
+
12
+ const LOGS_DIR = join(homedir(), '.inscribe-mcp', 'logs');
13
+
14
+ /**
15
+ * Ensure logs directory exists
16
+ */
17
+ function ensureLogsDir() {
18
+ if (!existsSync(LOGS_DIR)) {
19
+ mkdirSync(LOGS_DIR, { recursive: true });
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Get current log file path (YYYY-MM-DD.jsonl)
25
+ */
26
+ function getLogFilePath() {
27
+ const date = new Date().toISOString().split('T')[0];
28
+ return join(LOGS_DIR, `${date}.jsonl`);
29
+ }
30
+
31
+ /**
32
+ * Get logs directory path
33
+ */
34
+ export function getLogsDir() {
35
+ return LOGS_DIR;
36
+ }
37
+
38
+ /**
39
+ * Write a log entry
40
+ * @param {Object} entry - Log entry object
41
+ */
42
+ export function writeLog(entry) {
43
+ ensureLogsDir();
44
+
45
+ const logEntry = {
46
+ timestamp: new Date().toISOString(),
47
+ ...entry
48
+ };
49
+
50
+ const line = JSON.stringify(logEntry) + '\n';
51
+ appendFileSync(getLogFilePath(), line, 'utf-8');
52
+ }
53
+
54
+ /**
55
+ * Log a tool execution (standard format)
56
+ * @param {Object} options - Log options
57
+ */
58
+ export function logToolExecution({
59
+ command,
60
+ network,
61
+ topicId = null,
62
+ operator,
63
+ status,
64
+ latency,
65
+ txId = null,
66
+ error = null,
67
+ metadata = {}
68
+ }) {
69
+ writeLog({
70
+ level: status === 'success' ? 'info' : 'error',
71
+ command,
72
+ network,
73
+ topicId,
74
+ operator,
75
+ status,
76
+ latency,
77
+ txId,
78
+ error,
79
+ ...metadata
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Log a debug sequence trace
85
+ * @param {Object} options - Debug trace options
86
+ */
87
+ export function logDebugTrace({
88
+ phase,
89
+ tool = null,
90
+ payload = null,
91
+ status = null,
92
+ txId = null,
93
+ result = null
94
+ }) {
95
+ // Only log if debug mode is enabled
96
+ if (!process.env.INSCRIBE_MCP_DEBUG) return;
97
+
98
+ writeLog({
99
+ level: 'debug',
100
+ phase,
101
+ tool,
102
+ payload,
103
+ status,
104
+ txId,
105
+ result
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Create a timer for measuring latency
111
+ * @returns {Function} Function that returns elapsed ms when called
112
+ */
113
+ export function createTimer() {
114
+ const start = Date.now();
115
+ return () => Date.now() - start;
116
+ }
117
+
118
+ /**
119
+ * Helper to wrap tool execution with logging
120
+ * @param {string} command - Tool name
121
+ * @param {Function} fn - Async function to execute
122
+ * @param {Object} options - Additional log options
123
+ */
124
+ export async function withLogging(command, fn, options = {}) {
125
+ const timer = createTimer();
126
+ const network = options.network || 'unknown';
127
+ const operator = options.operator || 'unknown';
128
+ const topicId = options.topicId || null;
129
+
130
+ // Debug: agent_to_mcp
131
+ logDebugTrace({
132
+ phase: 'agent_to_mcp',
133
+ tool: command,
134
+ payload: options.payload
135
+ });
136
+
137
+ try {
138
+ // Debug: mcp_to_chain
139
+ logDebugTrace({
140
+ phase: 'mcp_to_chain',
141
+ tool: command
142
+ });
143
+
144
+ const result = await fn();
145
+
146
+ // Debug: chain_to_mcp
147
+ logDebugTrace({
148
+ phase: 'chain_to_mcp',
149
+ status: 'SUCCESS',
150
+ txId: result?.txId || result?.inscription_id
151
+ });
152
+
153
+ // Debug: mcp_to_agent
154
+ logDebugTrace({
155
+ phase: 'mcp_to_agent',
156
+ result: result?.success ? 'success' : 'partial'
157
+ });
158
+
159
+ // Standard log
160
+ logToolExecution({
161
+ command,
162
+ network,
163
+ topicId,
164
+ operator,
165
+ status: 'success',
166
+ latency: timer(),
167
+ txId: result?.txId || result?.inscription_id,
168
+ metadata: options.metadata
169
+ });
170
+
171
+ return result;
172
+
173
+ } catch (error) {
174
+ // Debug: chain_to_mcp (error)
175
+ logDebugTrace({
176
+ phase: 'chain_to_mcp',
177
+ status: 'FAILED'
178
+ });
179
+
180
+ // Debug: mcp_to_agent (error)
181
+ logDebugTrace({
182
+ phase: 'mcp_to_agent',
183
+ result: 'error'
184
+ });
185
+
186
+ // Standard error log
187
+ logToolExecution({
188
+ command,
189
+ network,
190
+ topicId,
191
+ operator,
192
+ status: 'error',
193
+ latency: timer(),
194
+ error: error.message,
195
+ metadata: options.metadata
196
+ });
197
+
198
+ throw error;
199
+ }
200
+ }