nofax 0.2.3 → 0.2.5
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/README.md +1 -1
- package/package.json +1 -1
- package/src/mcp-server.mjs +74 -18
- package/src/mcp-tools.mjs +14 -6
- package/src/ntfy.mjs +5 -2
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Nofax is a small open-source bridge between an agent and a human. Local mode can
|
|
|
11
11
|
|
|
12
12
|
No Nofax account. No paid model API. No inbound port on your machine. MIT licensed.
|
|
13
13
|
|
|
14
|
-
> **Current status:** local Nofax is `0.2.
|
|
14
|
+
> **Current status:** local Nofax is on the stable `0.2.x` line. The optional Cloudflare Worker is the upcoming `0.3.0` remote surface and is developed alongside the local package.
|
|
15
15
|
|
|
16
16
|
## Why Nofax
|
|
17
17
|
|
package/package.json
CHANGED
package/src/mcp-server.mjs
CHANGED
|
@@ -30,9 +30,51 @@ function result(value) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
const OUTPUT_REQUEST_ID = z.string().min(24).max(84).describe('Durable Nofax request handle.');
|
|
34
|
+
|
|
35
|
+
const PENDING_OUTPUT_SCHEMA = z.object({
|
|
36
|
+
status: z.literal('pending').describe('The request is unresolved and must not be treated as approval.'),
|
|
37
|
+
requestId: OUTPUT_REQUEST_ID,
|
|
38
|
+
mustWait: z.literal(true).describe('Signals that the caller must keep waiting for a terminal human response.'),
|
|
39
|
+
instruction: z.string().min(1).describe('Fail-closed next-step instruction for the caller.')
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const WAIT_OUTPUT_SCHEMA = z.object({
|
|
43
|
+
status: z.enum(['pending', 'resolved']).describe('Whether the human request is still pending or has reached a terminal response.'),
|
|
44
|
+
requestId: OUTPUT_REQUEST_ID,
|
|
45
|
+
mustWait: z.boolean().optional().describe('Present and true while the request remains pending.'),
|
|
46
|
+
decision: z.string().min(1).max(80).optional().describe('Terminal human decision when resolved, such as allow, deny, refine, or an explicit choice value.'),
|
|
47
|
+
text: z.string().min(1).max(2000).optional().describe('Human free-text refinement when the terminal decision is refine.'),
|
|
48
|
+
instruction: z.string().min(1).describe('Safety-preserving instruction describing what the caller may do next.')
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const PUBLIC_REQUEST_SCHEMA = z.object({
|
|
52
|
+
requestId: OUTPUT_REQUEST_ID,
|
|
53
|
+
kind: z.enum(['approval', 'choice', 'refinement']).describe('Interaction mode that created the durable request.'),
|
|
54
|
+
status: z.enum(['pending', 'resolved']).describe('Current durable request state.'),
|
|
55
|
+
createdAt: z.string().min(1).describe('ISO-8601 creation timestamp.'),
|
|
56
|
+
resolvedAt: z.string().min(1).optional().describe('ISO-8601 terminal-response timestamp when resolved.'),
|
|
57
|
+
decision: z.string().min(1).max(80).optional().describe('Terminal human decision when resolved.'),
|
|
58
|
+
text: z.string().min(1).max(2000).optional().describe('Human refinement text when one was supplied.')
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const NOTIFY_OUTPUT_SCHEMA = z.object({
|
|
62
|
+
status: z.literal('sent').describe('The notification transport call completed successfully; this is never approval.')
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const GET_REQUEST_OUTPUT_SCHEMA = z.object({
|
|
66
|
+
status: z.literal('ok').describe('The durable request was read successfully.'),
|
|
67
|
+
request: PUBLIC_REQUEST_SCHEMA.describe('Safe request metadata and terminal state; secret response topics are omitted.')
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const LIST_PENDING_OUTPUT_SCHEMA = z.object({
|
|
71
|
+
status: z.literal('ok').describe('The pending-request scan completed successfully.'),
|
|
72
|
+
requests: z.array(PUBLIC_REQUEST_SCHEMA).describe('Bounded unresolved request projections with no secret response topics.')
|
|
73
|
+
});
|
|
74
|
+
|
|
33
75
|
export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
34
76
|
const server = new McpServer(
|
|
35
|
-
{ name: 'nofax', version: '0.2.
|
|
77
|
+
{ name: 'nofax', version: '0.2.5' },
|
|
36
78
|
{ instructions: SERVER_INSTRUCTIONS }
|
|
37
79
|
);
|
|
38
80
|
|
|
@@ -40,11 +82,12 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
|
40
82
|
'nofax_notify',
|
|
41
83
|
{
|
|
42
84
|
title: 'Send Nofax notification',
|
|
43
|
-
description: 'Send a one-way phone notification. This tool is informational and does not create a human-response wait.',
|
|
85
|
+
description: 'Send a one-way phone notification. This tool is informational and does not create a human-response wait. Notification transport success never counts as approval.',
|
|
44
86
|
inputSchema: z.object({
|
|
45
|
-
title: z.string().min(1).max(120).optional(),
|
|
46
|
-
message: z.string().min(1).max(2200)
|
|
87
|
+
title: z.string().min(1).max(120).optional().describe('Optional notification title shown to the human; defaults to "Nofax".'),
|
|
88
|
+
message: z.string().min(1).max(2200).describe('Notification body shown to the human.')
|
|
47
89
|
}),
|
|
90
|
+
outputSchema: NOTIFY_OUTPUT_SCHEMA,
|
|
48
91
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
49
92
|
},
|
|
50
93
|
async (args) => result(await handlers.notify(args))
|
|
@@ -56,10 +99,11 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
|
56
99
|
title: 'Request human approval',
|
|
57
100
|
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
101
|
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()
|
|
102
|
+
title: z.string().min(1).max(120).optional().describe('Optional approval prompt title shown to the human; defaults to "Nofax approval".'),
|
|
103
|
+
message: z.string().min(1).max(2200).describe('Guarded action or decision context shown to the human.'),
|
|
104
|
+
allowRefine: z.boolean().optional().describe('When true, also let the human return free-text refinement instead of only Allow or Deny.')
|
|
62
105
|
}),
|
|
106
|
+
outputSchema: PENDING_OUTPUT_SCHEMA,
|
|
63
107
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
64
108
|
},
|
|
65
109
|
async (args) => result(await handlers.requestApproval(args))
|
|
@@ -71,13 +115,17 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
|
71
115
|
title: 'Request human choice',
|
|
72
116
|
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
117
|
inputSchema: z.object({
|
|
74
|
-
title: z.string().min(1).max(120).optional(),
|
|
75
|
-
message: z.string().min(1).max(2200),
|
|
118
|
+
title: z.string().min(1).max(120).optional().describe('Optional choice prompt title shown to the human; defaults to "Nofax choice".'),
|
|
119
|
+
message: z.string().min(1).max(2200).describe('Question or decision context shown to the human.'),
|
|
76
120
|
options: z.array(z.union([
|
|
77
|
-
z.string().min(1).max(80),
|
|
78
|
-
z.object({
|
|
79
|
-
|
|
121
|
+
z.string().min(1).max(80).describe('String shorthand for a choice value; its first 32 characters are also used as the human-facing label.'),
|
|
122
|
+
z.object({
|
|
123
|
+
value: z.string().min(1).max(80).describe('Choice value returned as the terminal human decision.'),
|
|
124
|
+
label: z.string().min(1).max(32).describe('Short human-facing label displayed for this choice.')
|
|
125
|
+
})
|
|
126
|
+
])).min(1).max(3).describe('One to three explicit choices to present to the human.')
|
|
80
127
|
}),
|
|
128
|
+
outputSchema: PENDING_OUTPUT_SCHEMA,
|
|
81
129
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
82
130
|
},
|
|
83
131
|
async (args) => result(await handlers.requestChoice(args))
|
|
@@ -89,9 +137,10 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
|
89
137
|
title: 'Request human refinement',
|
|
90
138
|
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
139
|
inputSchema: z.object({
|
|
92
|
-
title: z.string().min(1).max(120).optional(),
|
|
93
|
-
message: z.string().min(1).max(2200)
|
|
140
|
+
title: z.string().min(1).max(120).optional().describe('Optional refinement prompt title shown to the human; defaults to "Nofax refinement".'),
|
|
141
|
+
message: z.string().min(1).max(2200).describe('Context or draft the human should refine with free text.')
|
|
94
142
|
}),
|
|
143
|
+
outputSchema: PENDING_OUTPUT_SCHEMA,
|
|
95
144
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
96
145
|
},
|
|
97
146
|
async (args) => result(await handlers.requestRefinement(args))
|
|
@@ -103,9 +152,10 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
|
103
152
|
title: 'Wait for Nofax human response',
|
|
104
153
|
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
154
|
inputSchema: z.object({
|
|
106
|
-
requestId: z.string().min(24).max(84),
|
|
107
|
-
waitSeconds: z.number().int().min(1).max(240).optional()
|
|
155
|
+
requestId: z.string().min(24).max(84).describe('Durable request handle returned by a nofax_request_* tool.'),
|
|
156
|
+
waitSeconds: z.number().int().min(1).max(240).optional().describe('Maximum seconds to long-poll during this call; defaults to 240. A timeout still returns pending, never approval.')
|
|
108
157
|
}),
|
|
158
|
+
outputSchema: WAIT_OUTPUT_SCHEMA,
|
|
109
159
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
110
160
|
},
|
|
111
161
|
async (args) => result(await handlers.waitForResponse(args))
|
|
@@ -116,7 +166,10 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
|
116
166
|
{
|
|
117
167
|
title: 'Get Nofax request',
|
|
118
168
|
description: 'Recover safe metadata and terminal state for a durable Nofax request. Secret response topics are never returned.',
|
|
119
|
-
inputSchema: z.object({
|
|
169
|
+
inputSchema: z.object({
|
|
170
|
+
requestId: z.string().min(24).max(84).describe('Durable Nofax request handle to inspect without exposing its secret response topic.')
|
|
171
|
+
}),
|
|
172
|
+
outputSchema: GET_REQUEST_OUTPUT_SCHEMA,
|
|
120
173
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
121
174
|
},
|
|
122
175
|
async (args) => result(await handlers.getRequest(args))
|
|
@@ -127,7 +180,10 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
|
|
|
127
180
|
{
|
|
128
181
|
title: 'List pending Nofax requests',
|
|
129
182
|
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({
|
|
183
|
+
inputSchema: z.object({
|
|
184
|
+
limit: z.number().int().min(1).max(100).optional().describe('Maximum number of unresolved requests to return; defaults to 20.')
|
|
185
|
+
}),
|
|
186
|
+
outputSchema: LIST_PENDING_OUTPUT_SCHEMA,
|
|
131
187
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
132
188
|
},
|
|
133
189
|
async (args) => result(await handlers.listPending(args))
|
package/src/mcp-tools.mjs
CHANGED
|
@@ -140,6 +140,17 @@ export function createMcpToolHandlers(overrides = {}) {
|
|
|
140
140
|
return pendingResult(remote.requestId);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
async function createDurableRemote(kind, input) {
|
|
144
|
+
let persisted;
|
|
145
|
+
const remote = await deps.createRemoteRequestImpl({
|
|
146
|
+
...input,
|
|
147
|
+
beforePublish: async (prepared) => {
|
|
148
|
+
persisted = await persistRemote({ kind, remote: prepared });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
return persisted ?? persistRemote({ kind, remote });
|
|
152
|
+
}
|
|
153
|
+
|
|
143
154
|
return {
|
|
144
155
|
async notify({ title = 'Nofax', message }) {
|
|
145
156
|
const current = await config();
|
|
@@ -153,7 +164,7 @@ export function createMcpToolHandlers(overrides = {}) {
|
|
|
153
164
|
|
|
154
165
|
async requestApproval({ title = 'Nofax approval', message, allowRefine = false }) {
|
|
155
166
|
const current = await config();
|
|
156
|
-
|
|
167
|
+
return createDurableRemote('approval', {
|
|
157
168
|
config: current,
|
|
158
169
|
title: validateText(title, 'TITLE', 120),
|
|
159
170
|
message: validateText(message, 'MESSAGE'),
|
|
@@ -163,32 +174,29 @@ export function createMcpToolHandlers(overrides = {}) {
|
|
|
163
174
|
],
|
|
164
175
|
includeRefine: allowRefine === true
|
|
165
176
|
});
|
|
166
|
-
return persistRemote({ kind: 'approval', remote });
|
|
167
177
|
},
|
|
168
178
|
|
|
169
179
|
async requestChoice({ title = 'Nofax choice', message, options }) {
|
|
170
180
|
const current = await config();
|
|
171
181
|
const normalized = validateChoiceOptions(options);
|
|
172
|
-
|
|
182
|
+
return createDurableRemote('choice', {
|
|
173
183
|
config: current,
|
|
174
184
|
title: validateText(title, 'TITLE', 120),
|
|
175
185
|
message: validateText(message, 'MESSAGE'),
|
|
176
186
|
options: normalized,
|
|
177
187
|
includeRefine: false
|
|
178
188
|
});
|
|
179
|
-
return persistRemote({ kind: 'choice', remote });
|
|
180
189
|
},
|
|
181
190
|
|
|
182
191
|
async requestRefinement({ title = 'Nofax refinement', message }) {
|
|
183
192
|
const current = await config();
|
|
184
|
-
|
|
193
|
+
return createDurableRemote('refinement', {
|
|
185
194
|
config: current,
|
|
186
195
|
title: validateText(title, 'TITLE', 120),
|
|
187
196
|
message: validateText(message, 'MESSAGE'),
|
|
188
197
|
options: [],
|
|
189
198
|
includeRefine: true
|
|
190
199
|
});
|
|
191
|
-
return persistRemote({ kind: 'refinement', remote });
|
|
192
200
|
},
|
|
193
201
|
|
|
194
202
|
async waitForResponse({ requestId, waitSeconds }) {
|
package/src/ntfy.mjs
CHANGED
|
@@ -96,6 +96,7 @@ export async function createRemoteRequest({
|
|
|
96
96
|
options = [],
|
|
97
97
|
includeRefine = false,
|
|
98
98
|
shortcutName = config.refineShortcutName ?? DEFAULT_REFINE_SHORTCUT,
|
|
99
|
+
beforePublish = async () => {},
|
|
99
100
|
fetchImpl = fetch
|
|
100
101
|
}) {
|
|
101
102
|
const normalized = normalizeOptions(options);
|
|
@@ -126,8 +127,10 @@ export async function createRemoteRequest({
|
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
const remote = { requestId, responseTopic, allowed };
|
|
131
|
+
await beforePublish(remote);
|
|
129
132
|
await sendNotification({ config, title, message, actions, fetchImpl });
|
|
130
|
-
return
|
|
133
|
+
return remote;
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
function parseNtfyPoll(text, requestId, allowed) {
|
|
@@ -149,7 +152,7 @@ function parseNtfyPoll(text, requestId, allowed) {
|
|
|
149
152
|
export async function pollRemoteResponse({ config, responseTopic, requestId, allowed, fetchImpl = fetch }) {
|
|
150
153
|
let response;
|
|
151
154
|
try {
|
|
152
|
-
response = await fetchImpl(`${config.server}/${responseTopic}/json?poll=1&since=
|
|
155
|
+
response = await fetchImpl(`${config.server}/${responseTopic}/json?poll=1&since=all`, {
|
|
153
156
|
method: 'GET',
|
|
154
157
|
headers: { accept: 'application/x-ndjson' }
|
|
155
158
|
});
|