nofax 0.2.0 → 0.2.1

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/src/config.mjs CHANGED
@@ -1,91 +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
- }
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 CHANGED
@@ -1,8 +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';
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';
@@ -1,141 +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
- }
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.1' },
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
+ }