nofax 0.2.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 +351 -0
- package/SECURITY.md +144 -0
- package/bin/nofax.mjs +4 -0
- package/package.json +56 -0
- package/src/adapters/claude.mjs +47 -0
- package/src/adapters/codex.mjs +47 -0
- package/src/adapters/gemini.mjs +57 -0
- package/src/cli.mjs +191 -0
- package/src/config.mjs +91 -0
- package/src/index.mjs +8 -0
- package/src/mcp-server.mjs +141 -0
- package/src/mcp-tools.mjs +240 -0
- package/src/ntfy.mjs +259 -0
- package/src/protocol.mjs +108 -0
- package/src/requests.mjs +120 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { buildAgentSummary } from '../protocol.mjs';
|
|
2
|
+
import { requestApproval, sendNotification } from '../ntfy.mjs';
|
|
3
|
+
|
|
4
|
+
export async function handleGeminiNotification(input, {
|
|
5
|
+
config,
|
|
6
|
+
sendNotificationImpl = sendNotification
|
|
7
|
+
} = {}) {
|
|
8
|
+
if (!input || input.hook_event_name !== 'Notification') throw new Error('NOFAX_GEMINI_EVENT');
|
|
9
|
+
const type = typeof input.notification_type === 'string' && input.notification_type
|
|
10
|
+
? input.notification_type
|
|
11
|
+
: 'Notification';
|
|
12
|
+
await sendNotificationImpl({
|
|
13
|
+
config,
|
|
14
|
+
title: `Gemini CLI: ${type}`,
|
|
15
|
+
message: buildAgentSummary({
|
|
16
|
+
source: 'Gemini CLI',
|
|
17
|
+
message: typeof input.message === 'string' ? input.message : 'Gemini CLI needs attention.',
|
|
18
|
+
toolInput: input.details
|
|
19
|
+
})
|
|
20
|
+
});
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function handleGeminiHook(input, {
|
|
25
|
+
config,
|
|
26
|
+
requestApprovalImpl = requestApproval,
|
|
27
|
+
sendNotificationImpl = sendNotification,
|
|
28
|
+
onError = () => {}
|
|
29
|
+
} = {}) {
|
|
30
|
+
if (!input || typeof input.hook_event_name !== 'string') throw new Error('NOFAX_GEMINI_EVENT');
|
|
31
|
+
if (input.hook_event_name === 'Notification') {
|
|
32
|
+
return handleGeminiNotification(input, { config, sendNotificationImpl });
|
|
33
|
+
}
|
|
34
|
+
if (input.hook_event_name !== 'BeforeTool') throw new Error('NOFAX_GEMINI_EVENT');
|
|
35
|
+
if (typeof input.tool_name !== 'string' || !input.tool_name) throw new Error('NOFAX_GEMINI_TOOL');
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await requestApprovalImpl({
|
|
39
|
+
config,
|
|
40
|
+
title: `Gemini CLI needs approval: ${input.tool_name}`,
|
|
41
|
+
message: buildAgentSummary({
|
|
42
|
+
source: 'Gemini CLI',
|
|
43
|
+
toolName: input.tool_name,
|
|
44
|
+
cwd: input.cwd,
|
|
45
|
+
toolInput: input.tool_input
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
if (result.decision === 'allow') return { decision: 'allow' };
|
|
49
|
+
if (result.decision === 'deny') {
|
|
50
|
+
return { decision: 'deny', reason: 'Denied remotely via Nofax.' };
|
|
51
|
+
}
|
|
52
|
+
return {};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
onError(error);
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { initConfig, loadConfig } from './config.mjs';
|
|
2
|
+
import { requestApproval, requestRefinement, sendNotification } from './ntfy.mjs';
|
|
3
|
+
import { handleClaudePermissionRequest } from './adapters/claude.mjs';
|
|
4
|
+
import { handleCodexPermissionRequest } from './adapters/codex.mjs';
|
|
5
|
+
import { handleGeminiHook } from './adapters/gemini.mjs';
|
|
6
|
+
|
|
7
|
+
const HELP = `nofax - remote approvals and notifications for coding agents\n\nUsage:\n nofax init [--server URL] [--topic TOPIC] [--timeout SECONDS] [--force]\n nofax test\n nofax notify [--title TITLE] MESSAGE...\n nofax approve [--title TITLE] MESSAGE...\n nofax refine [--title TITLE] MESSAGE...\n nofax mcp\n nofax config\n nofax hook claude\n nofax hook codex\n nofax hook gemini\n\nEnvironment:\n NOFAX_HOME Override ~/.nofax\n`;
|
|
8
|
+
|
|
9
|
+
function parseArgs(args) {
|
|
10
|
+
const flags = {};
|
|
11
|
+
const positionals = [];
|
|
12
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
13
|
+
const value = args[index];
|
|
14
|
+
if (value === '--force') {
|
|
15
|
+
flags.force = true;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (['--title', '--server', '--topic', '--timeout'].includes(value)) {
|
|
19
|
+
const next = args[index + 1];
|
|
20
|
+
if (next === undefined) throw new Error(`NOFAX_FLAG_VALUE:${value}`);
|
|
21
|
+
flags[value.slice(2)] = next;
|
|
22
|
+
index += 1;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (value.startsWith('--')) throw new Error(`NOFAX_UNKNOWN_FLAG:${value}`);
|
|
26
|
+
positionals.push(value);
|
|
27
|
+
}
|
|
28
|
+
return { flags, positionals };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readStdin(stdinText) {
|
|
32
|
+
if (stdinText !== undefined) return stdinText;
|
|
33
|
+
let text = '';
|
|
34
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeJson(stream, value) {
|
|
39
|
+
stream.write(`${JSON.stringify(value)}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runHook(adapter, deps) {
|
|
43
|
+
const inputText = await readStdin(deps.stdinText);
|
|
44
|
+
let input;
|
|
45
|
+
try {
|
|
46
|
+
input = JSON.parse(inputText);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error('NOFAX_HOOK_INVALID_JSON');
|
|
49
|
+
}
|
|
50
|
+
const config = await deps.loadConfigImpl({ env: deps.env });
|
|
51
|
+
const onError = (error) => deps.stderr.write(`nofax: ${error.message}\n`);
|
|
52
|
+
let output;
|
|
53
|
+
if (adapter === 'claude') {
|
|
54
|
+
output = await handleClaudePermissionRequest(input, { config, requestApprovalImpl: deps.requestApprovalImpl, onError });
|
|
55
|
+
} else if (adapter === 'codex') {
|
|
56
|
+
output = await handleCodexPermissionRequest(input, { config, requestApprovalImpl: deps.requestApprovalImpl, onError });
|
|
57
|
+
} else if (adapter === 'gemini') {
|
|
58
|
+
output = await handleGeminiHook(input, {
|
|
59
|
+
config,
|
|
60
|
+
requestApprovalImpl: deps.requestApprovalImpl,
|
|
61
|
+
sendNotificationImpl: deps.sendNotificationImpl,
|
|
62
|
+
onError
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
throw new Error(`NOFAX_HOOK_UNSUPPORTED:${adapter}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (output === null) {
|
|
69
|
+
deps.stderr.write('nofax: no remote decision; continuing with native approval.\n');
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
writeJson(deps.stdout, output);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runCli(args, overrides = {}) {
|
|
77
|
+
const deps = {
|
|
78
|
+
env: overrides.env ?? process.env,
|
|
79
|
+
stdinText: overrides.stdinText,
|
|
80
|
+
stdout: overrides.stdout ?? process.stdout,
|
|
81
|
+
stderr: overrides.stderr ?? process.stderr,
|
|
82
|
+
initConfigImpl: overrides.initConfigImpl ?? initConfig,
|
|
83
|
+
loadConfigImpl: overrides.loadConfigImpl ?? loadConfig,
|
|
84
|
+
requestApprovalImpl: overrides.requestApprovalImpl ?? requestApproval,
|
|
85
|
+
requestRefinementImpl: overrides.requestRefinementImpl ?? requestRefinement,
|
|
86
|
+
sendNotificationImpl: overrides.sendNotificationImpl ?? sendNotification,
|
|
87
|
+
runMcpServerImpl: overrides.runMcpServerImpl ?? (async () => {
|
|
88
|
+
const { runMcpServer } = await import('./mcp-server.mjs');
|
|
89
|
+
await runMcpServer();
|
|
90
|
+
})
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
|
|
95
|
+
deps.stdout.write(HELP);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const command = args[0];
|
|
100
|
+
if (command === 'init') {
|
|
101
|
+
const { flags } = parseArgs(args.slice(1));
|
|
102
|
+
const timeoutSeconds = flags.timeout === undefined ? undefined : Number(flags.timeout);
|
|
103
|
+
const config = await deps.initConfigImpl({
|
|
104
|
+
env: deps.env,
|
|
105
|
+
...(flags.server === undefined ? {} : { server: flags.server }),
|
|
106
|
+
...(flags.topic === undefined ? {} : { topic: flags.topic }),
|
|
107
|
+
...(timeoutSeconds === undefined ? {} : { timeoutSeconds }),
|
|
108
|
+
force: flags.force === true
|
|
109
|
+
});
|
|
110
|
+
deps.stdout.write('Nofax ready.\n');
|
|
111
|
+
deps.stdout.write(`Subscribe your ntfy app to: ${config.server}/${config.topic}\n`);
|
|
112
|
+
deps.stdout.write('Then run: nofax test\n');
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (command === 'config') {
|
|
117
|
+
const config = await deps.loadConfigImpl({ env: deps.env });
|
|
118
|
+
deps.stdout.write(`${JSON.stringify(config, null, 2)}\n`);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (command === 'test') {
|
|
123
|
+
const config = await deps.loadConfigImpl({ env: deps.env });
|
|
124
|
+
await deps.sendNotificationImpl({
|
|
125
|
+
config,
|
|
126
|
+
title: 'Nofax is connected',
|
|
127
|
+
message: 'If you can read this on your phone, Nofax is ready.'
|
|
128
|
+
});
|
|
129
|
+
deps.stdout.write('Test notification sent.\n');
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (command === 'notify') {
|
|
134
|
+
const { flags, positionals } = parseArgs(args.slice(1));
|
|
135
|
+
if (positionals.length === 0) throw new Error('NOFAX_MESSAGE_REQUIRED');
|
|
136
|
+
const config = await deps.loadConfigImpl({ env: deps.env });
|
|
137
|
+
await deps.sendNotificationImpl({
|
|
138
|
+
config,
|
|
139
|
+
title: flags.title ?? 'Nofax',
|
|
140
|
+
message: positionals.join(' ')
|
|
141
|
+
});
|
|
142
|
+
deps.stdout.write('Notification sent.\n');
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (command === 'approve') {
|
|
147
|
+
const { flags, positionals } = parseArgs(args.slice(1));
|
|
148
|
+
if (positionals.length === 0) throw new Error('NOFAX_MESSAGE_REQUIRED');
|
|
149
|
+
const config = await deps.loadConfigImpl({ env: deps.env });
|
|
150
|
+
const result = await deps.requestApprovalImpl({
|
|
151
|
+
config,
|
|
152
|
+
title: flags.title ?? 'Nofax approval',
|
|
153
|
+
message: positionals.join(' ')
|
|
154
|
+
});
|
|
155
|
+
writeJson(deps.stdout, { decision: result.decision });
|
|
156
|
+
return result.decision === 'timeout' ? 3 : 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (command === 'refine') {
|
|
160
|
+
const { flags, positionals } = parseArgs(args.slice(1));
|
|
161
|
+
if (positionals.length === 0) throw new Error('NOFAX_MESSAGE_REQUIRED');
|
|
162
|
+
const config = await deps.loadConfigImpl({ env: deps.env });
|
|
163
|
+
const result = await deps.requestRefinementImpl({
|
|
164
|
+
config,
|
|
165
|
+
title: flags.title ?? 'Nofax refinement',
|
|
166
|
+
message: positionals.join(' ')
|
|
167
|
+
});
|
|
168
|
+
writeJson(deps.stdout, {
|
|
169
|
+
decision: result.decision,
|
|
170
|
+
...(result.text === undefined ? {} : { text: result.text })
|
|
171
|
+
});
|
|
172
|
+
return result.decision === 'timeout' ? 3 : 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (command === 'mcp') {
|
|
176
|
+
await deps.runMcpServerImpl();
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (command === 'hook') {
|
|
181
|
+
const adapter = args[1];
|
|
182
|
+
if (!adapter) throw new Error('NOFAX_HOOK_REQUIRED');
|
|
183
|
+
return runHook(adapter, deps);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw new Error(`NOFAX_UNKNOWN_COMMAND:${command}`);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
deps.stderr.write(`nofax: ${error.message}\n`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { createPhoneTopic } from './protocol.mjs';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SERVER = 'https://ntfy.sh';
|
|
7
|
+
export const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
8
|
+
|
|
9
|
+
export function resolveNofaxHome({ home, env = process.env } = {}) {
|
|
10
|
+
const configured = home ?? env.NOFAX_HOME;
|
|
11
|
+
if (configured !== undefined) {
|
|
12
|
+
const trimmed = String(configured).trim();
|
|
13
|
+
if (!trimmed) throw new Error('NOFAX_HOME_EMPTY');
|
|
14
|
+
return isAbsolute(trimmed) ? resolve(trimmed) : resolve(process.cwd(), trimmed);
|
|
15
|
+
}
|
|
16
|
+
return join(homedir(), '.nofax');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeServer(input) {
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(input);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error('NOFAX_SERVER_INVALID');
|
|
25
|
+
}
|
|
26
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('NOFAX_SERVER_PROTOCOL');
|
|
27
|
+
if (url.username || url.password) throw new Error('NOFAX_SERVER_CREDENTIALS');
|
|
28
|
+
if ((url.pathname && url.pathname !== '/') || url.search || url.hash) throw new Error('NOFAX_SERVER_PATH');
|
|
29
|
+
return `${url.protocol}//${url.host}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateTopic(topic) {
|
|
33
|
+
if (typeof topic !== 'string' || topic.length < 24 || topic.length > 128 || !/^[A-Za-z0-9_-]+$/.test(topic)) {
|
|
34
|
+
throw new Error('NOFAX_TOPIC_INVALID');
|
|
35
|
+
}
|
|
36
|
+
return topic;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateTimeout(value) {
|
|
40
|
+
if (!Number.isInteger(value) || value < 5 || value > 3600) throw new Error('NOFAX_TIMEOUT_INVALID');
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateConfig(input) {
|
|
45
|
+
if (!input || input.version !== 1) throw new Error('NOFAX_CONFIG_VERSION');
|
|
46
|
+
return {
|
|
47
|
+
version: 1,
|
|
48
|
+
server: normalizeServer(input.server),
|
|
49
|
+
topic: validateTopic(input.topic),
|
|
50
|
+
timeoutSeconds: validateTimeout(input.timeoutSeconds)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function loadConfig({ home, env } = {}) {
|
|
55
|
+
const root = resolveNofaxHome({ home, env });
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(await readFile(join(root, 'config.json'), 'utf8'));
|
|
58
|
+
return validateConfig(parsed);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code === 'ENOENT') throw new Error('NOFAX_NOT_INITIALIZED');
|
|
61
|
+
if (error instanceof SyntaxError) throw new Error('NOFAX_CONFIG_INVALID_JSON');
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function initConfig({ home, env, server = DEFAULT_SERVER, topic, timeoutSeconds = DEFAULT_TIMEOUT_SECONDS, force = false } = {}) {
|
|
67
|
+
const root = resolveNofaxHome({ home, env });
|
|
68
|
+
if (!force) {
|
|
69
|
+
try {
|
|
70
|
+
return await loadConfig({ home: root });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.message !== 'NOFAX_NOT_INITIALIZED') throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const config = validateConfig({
|
|
77
|
+
version: 1,
|
|
78
|
+
server,
|
|
79
|
+
topic: topic ?? createPhoneTopic(),
|
|
80
|
+
timeoutSeconds
|
|
81
|
+
});
|
|
82
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
83
|
+
const path = join(root, 'config.json');
|
|
84
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
85
|
+
try {
|
|
86
|
+
await chmod(path, 0o600);
|
|
87
|
+
} catch {
|
|
88
|
+
// Windows may not honor POSIX mode bits; the file still lives in the user's profile.
|
|
89
|
+
}
|
|
90
|
+
return config;
|
|
91
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './config.mjs';
|
|
2
|
+
export * from './protocol.mjs';
|
|
3
|
+
export * from './ntfy.mjs';
|
|
4
|
+
export * from './requests.mjs';
|
|
5
|
+
export * from './mcp-tools.mjs';
|
|
6
|
+
export * from './adapters/claude.mjs';
|
|
7
|
+
export * from './adapters/codex.mjs';
|
|
8
|
+
export * from './adapters/gemini.mjs';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
3
|
+
import * as z from 'zod/v4';
|
|
4
|
+
import { createMcpToolHandlers } from './mcp-tools.mjs';
|
|
5
|
+
|
|
6
|
+
export const MCP_TOOL_NAMES = Object.freeze([
|
|
7
|
+
'nofax_notify',
|
|
8
|
+
'nofax_request_approval',
|
|
9
|
+
'nofax_request_choice',
|
|
10
|
+
'nofax_request_refinement',
|
|
11
|
+
'nofax_wait_for_response',
|
|
12
|
+
'nofax_get_request',
|
|
13
|
+
'nofax_list_pending'
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const SERVER_INSTRUCTIONS = [
|
|
17
|
+
'Nofax is a human-attention and approval bridge.',
|
|
18
|
+
'One-way nofax_notify calls do not require waiting.',
|
|
19
|
+
'Every nofax_request_* call returns a durable pending requestId.',
|
|
20
|
+
'For any pending request, you MUST call nofax_wait_for_response with that requestId and repeat whenever it returns pending.',
|
|
21
|
+
'Do not infer approval, continue a guarded action, claim completion, or substitute your own decision while a Nofax request is pending.',
|
|
22
|
+
'Only a terminal human response authorizes the next step, and only within the authority the caller already had.',
|
|
23
|
+
'If the terminal response is refine, apply the supplied refinement and request a new approval if the resulting action still requires approval.'
|
|
24
|
+
].join(' ');
|
|
25
|
+
|
|
26
|
+
function result(value) {
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: 'text', text: JSON.stringify(value) }],
|
|
29
|
+
structuredContent: value
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
34
|
+
const server = new McpServer(
|
|
35
|
+
{ name: 'nofax', version: '0.2.0' },
|
|
36
|
+
{ instructions: SERVER_INSTRUCTIONS }
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
server.registerTool(
|
|
40
|
+
'nofax_notify',
|
|
41
|
+
{
|
|
42
|
+
title: 'Send Nofax notification',
|
|
43
|
+
description: 'Send a one-way phone notification. This tool is informational and does not create a human-response wait.',
|
|
44
|
+
inputSchema: z.object({
|
|
45
|
+
title: z.string().min(1).max(120).optional(),
|
|
46
|
+
message: z.string().min(1).max(2200)
|
|
47
|
+
}),
|
|
48
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
49
|
+
},
|
|
50
|
+
async (args) => result(await handlers.notify(args))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
server.registerTool(
|
|
54
|
+
'nofax_request_approval',
|
|
55
|
+
{
|
|
56
|
+
title: 'Request human approval',
|
|
57
|
+
description: 'Send Allow/Deny to the phone and return a durable pending requestId. IMPORTANT: after this tool returns pending, call nofax_wait_for_response and repeat while it remains pending. Never continue the guarded action without a terminal Allow response. Set allowRefine when the human should also be able to send refinement text.',
|
|
58
|
+
inputSchema: z.object({
|
|
59
|
+
title: z.string().min(1).max(120).optional(),
|
|
60
|
+
message: z.string().min(1).max(2200),
|
|
61
|
+
allowRefine: z.boolean().optional()
|
|
62
|
+
}),
|
|
63
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
64
|
+
},
|
|
65
|
+
async (args) => result(await handlers.requestApproval(args))
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
server.registerTool(
|
|
69
|
+
'nofax_request_choice',
|
|
70
|
+
{
|
|
71
|
+
title: 'Request human choice',
|
|
72
|
+
description: 'Send up to three explicit options to the phone and return a durable pending requestId. IMPORTANT: call nofax_wait_for_response and repeat while pending; do not choose on the human\'s behalf.',
|
|
73
|
+
inputSchema: z.object({
|
|
74
|
+
title: z.string().min(1).max(120).optional(),
|
|
75
|
+
message: z.string().min(1).max(2200),
|
|
76
|
+
options: z.array(z.union([
|
|
77
|
+
z.string().min(1).max(80),
|
|
78
|
+
z.object({ value: z.string().min(1).max(80), label: z.string().min(1).max(32) })
|
|
79
|
+
])).min(1).max(3)
|
|
80
|
+
}),
|
|
81
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
82
|
+
},
|
|
83
|
+
async (args) => result(await handlers.requestChoice(args))
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
server.registerTool(
|
|
87
|
+
'nofax_request_refinement',
|
|
88
|
+
{
|
|
89
|
+
title: 'Request human refinement',
|
|
90
|
+
description: 'Ask the human for free-text refinement through the configured Nofax Refine iOS Shortcut. Returns a durable pending requestId. IMPORTANT: call nofax_wait_for_response and repeat while pending.',
|
|
91
|
+
inputSchema: z.object({
|
|
92
|
+
title: z.string().min(1).max(120).optional(),
|
|
93
|
+
message: z.string().min(1).max(2200)
|
|
94
|
+
}),
|
|
95
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
96
|
+
},
|
|
97
|
+
async (args) => result(await handlers.requestRefinement(args))
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
server.registerTool(
|
|
101
|
+
'nofax_wait_for_response',
|
|
102
|
+
{
|
|
103
|
+
title: 'Wait for Nofax human response',
|
|
104
|
+
description: 'Long-poll a durable Nofax request for up to 240 seconds. If the result is pending, you MUST call this tool again with the same requestId. Repeat indefinitely until a terminal response is returned or the user explicitly changes/cancels the goal. Do not continue the guarded action while pending.',
|
|
105
|
+
inputSchema: z.object({
|
|
106
|
+
requestId: z.string().min(24).max(84),
|
|
107
|
+
waitSeconds: z.number().int().min(1).max(240).optional()
|
|
108
|
+
}),
|
|
109
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
110
|
+
},
|
|
111
|
+
async (args) => result(await handlers.waitForResponse(args))
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
server.registerTool(
|
|
115
|
+
'nofax_get_request',
|
|
116
|
+
{
|
|
117
|
+
title: 'Get Nofax request',
|
|
118
|
+
description: 'Recover safe metadata and terminal state for a durable Nofax request. Secret response topics are never returned.',
|
|
119
|
+
inputSchema: z.object({ requestId: z.string().min(24).max(84) }),
|
|
120
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
121
|
+
},
|
|
122
|
+
async (args) => result(await handlers.getRequest(args))
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
server.registerTool(
|
|
126
|
+
'nofax_list_pending',
|
|
127
|
+
{
|
|
128
|
+
title: 'List pending Nofax requests',
|
|
129
|
+
description: 'List a bounded set of unresolved Nofax request handles for recovery after client or conversation interruption. Secret response topics are never returned.',
|
|
130
|
+
inputSchema: z.object({ limit: z.number().int().min(1).max(100).optional() }),
|
|
131
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
132
|
+
},
|
|
133
|
+
async (args) => result(await handlers.listPending(args))
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return server;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function runMcpServer() {
|
|
140
|
+
await serveStdio(() => buildMcpServer());
|
|
141
|
+
}
|