nofax 0.2.0 → 0.2.2

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/mcp-tools.mjs CHANGED
@@ -1,240 +1,240 @@
1
- import { loadConfig } from './config.mjs';
2
- import {
3
- createRemoteRequest,
4
- pollRemoteResponse,
5
- sendNotification,
6
- sendResponseConfirmation
7
- } from './ntfy.mjs';
8
- import {
9
- listPendingRequests,
10
- loadRequest,
11
- resolveRequest,
12
- savePendingRequest
13
- } from './requests.mjs';
14
-
15
- const MAX_WAIT_SECONDS = 240;
16
- const DEFAULT_WAIT_SECONDS = 240;
17
-
18
- export const WAIT_REQUIRED = (requestId) =>
19
- `WAIT REQUIRED: human response for ${requestId} is still pending. Do not continue, infer approval, or complete the guarded action. Call nofax_wait_for_response with requestId "${requestId}" and repeat whenever it returns pending until a terminal response is returned.`;
20
-
21
- function pendingResult(requestId) {
22
- return {
23
- status: 'pending',
24
- requestId,
25
- mustWait: true,
26
- instruction: WAIT_REQUIRED(requestId)
27
- };
28
- }
29
-
30
- function terminalResult(request) {
31
- if (request.decision === 'allow') {
32
- return {
33
- status: 'resolved',
34
- requestId: request.requestId,
35
- decision: 'allow',
36
- instruction: 'Human approved this request. The caller may continue only within its existing authority.'
37
- };
38
- }
39
- if (request.decision === 'deny') {
40
- return {
41
- status: 'resolved',
42
- requestId: request.requestId,
43
- decision: 'deny',
44
- instruction: 'Human denied this request. Do not perform the guarded action.'
45
- };
46
- }
47
- if (request.decision === 'refine') {
48
- return {
49
- status: 'resolved',
50
- requestId: request.requestId,
51
- decision: 'refine',
52
- text: request.text,
53
- instruction: 'Apply the human refinement. If the resulting action still requires approval, create a new approval request and wait for that new terminal response before acting.'
54
- };
55
- }
56
- return {
57
- status: 'resolved',
58
- requestId: request.requestId,
59
- decision: request.decision,
60
- instruction: 'Human choice received. Apply only that explicit choice within the caller\'s existing authority.'
61
- };
62
- }
63
-
64
- function publicRequest(request) {
65
- const result = {
66
- requestId: request.requestId,
67
- kind: request.kind,
68
- status: request.status,
69
- createdAt: request.createdAt
70
- };
71
- if (request.status === 'resolved') {
72
- result.resolvedAt = request.resolvedAt;
73
- result.decision = request.decision;
74
- if (request.text !== undefined) result.text = request.text;
75
- }
76
- return result;
77
- }
78
-
79
- function validateText(value, name, max = 2200) {
80
- if (typeof value !== 'string' || !value.trim()) throw new Error(`NOFAX_${name}_REQUIRED`);
81
- return value.trim().slice(0, max);
82
- }
83
-
84
- function validateWaitSeconds(value) {
85
- const seconds = value ?? DEFAULT_WAIT_SECONDS;
86
- if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_WAIT_SECONDS) {
87
- throw new Error('NOFAX_MCP_WAIT_SECONDS_INVALID');
88
- }
89
- return seconds;
90
- }
91
-
92
- function validateChoiceOptions(options) {
93
- if (!Array.isArray(options) || options.length < 1 || options.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
94
- return options.map((option) => {
95
- if (typeof option === 'string') {
96
- const value = option.trim();
97
- if (!value) throw new Error('NOFAX_CHOICE_INVALID');
98
- return { value: value.slice(0, 80), label: value.slice(0, 32) };
99
- }
100
- if (!option || typeof option.value !== 'string' || typeof option.label !== 'string') throw new Error('NOFAX_CHOICE_INVALID');
101
- const value = option.value.trim();
102
- const label = option.label.trim();
103
- if (!value || !label) throw new Error('NOFAX_CHOICE_INVALID');
104
- return { value: value.slice(0, 80), label: label.slice(0, 32) };
105
- });
106
- }
107
-
108
- export function createMcpToolHandlers(overrides = {}) {
109
- const deps = {
110
- home: overrides.home,
111
- env: overrides.env ?? process.env,
112
- loadConfigImpl: overrides.loadConfigImpl ?? loadConfig,
113
- sendNotificationImpl: overrides.sendNotificationImpl ?? sendNotification,
114
- createRemoteRequestImpl: overrides.createRemoteRequestImpl ?? createRemoteRequest,
115
- pollRemoteResponseImpl: overrides.pollRemoteResponseImpl ?? pollRemoteResponse,
116
- sendResponseConfirmationImpl: overrides.sendResponseConfirmationImpl ?? sendResponseConfirmation,
117
- savePendingRequestImpl: overrides.savePendingRequestImpl ?? savePendingRequest,
118
- loadRequestImpl: overrides.loadRequestImpl ?? loadRequest,
119
- resolveRequestImpl: overrides.resolveRequestImpl ?? resolveRequest,
120
- listPendingRequestsImpl: overrides.listPendingRequestsImpl ?? listPendingRequests,
121
- nowImpl: overrides.nowImpl ?? Date.now,
122
- sleepImpl: overrides.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
123
- };
124
-
125
- async function config() {
126
- return deps.loadConfigImpl({ home: deps.home, env: deps.env });
127
- }
128
-
129
- async function persistRemote({ kind, remote }) {
130
- const request = {
131
- version: 1,
132
- requestId: remote.requestId,
133
- kind,
134
- responseTopic: remote.responseTopic,
135
- allowed: remote.allowed,
136
- status: 'pending',
137
- createdAt: new Date(deps.nowImpl()).toISOString()
138
- };
139
- await deps.savePendingRequestImpl({ home: deps.home, env: deps.env, request });
140
- return pendingResult(remote.requestId);
141
- }
142
-
143
- return {
144
- async notify({ title = 'Nofax', message }) {
145
- const current = await config();
146
- await deps.sendNotificationImpl({
147
- config: current,
148
- title: validateText(title, 'TITLE', 120),
149
- message: validateText(message, 'MESSAGE')
150
- });
151
- return { status: 'sent' };
152
- },
153
-
154
- async requestApproval({ title = 'Nofax approval', message, allowRefine = false }) {
155
- const current = await config();
156
- const remote = await deps.createRemoteRequestImpl({
157
- config: current,
158
- title: validateText(title, 'TITLE', 120),
159
- message: validateText(message, 'MESSAGE'),
160
- options: [
161
- { value: 'allow', label: 'Allow' },
162
- { value: 'deny', label: 'Deny' }
163
- ],
164
- includeRefine: allowRefine === true
165
- });
166
- return persistRemote({ kind: 'approval', remote });
167
- },
168
-
169
- async requestChoice({ title = 'Nofax choice', message, options }) {
170
- const current = await config();
171
- const normalized = validateChoiceOptions(options);
172
- const remote = await deps.createRemoteRequestImpl({
173
- config: current,
174
- title: validateText(title, 'TITLE', 120),
175
- message: validateText(message, 'MESSAGE'),
176
- options: normalized,
177
- includeRefine: false
178
- });
179
- return persistRemote({ kind: 'choice', remote });
180
- },
181
-
182
- async requestRefinement({ title = 'Nofax refinement', message }) {
183
- const current = await config();
184
- const remote = await deps.createRemoteRequestImpl({
185
- config: current,
186
- title: validateText(title, 'TITLE', 120),
187
- message: validateText(message, 'MESSAGE'),
188
- options: [],
189
- includeRefine: true
190
- });
191
- return persistRemote({ kind: 'refinement', remote });
192
- },
193
-
194
- async waitForResponse({ requestId, waitSeconds }) {
195
- const seconds = validateWaitSeconds(waitSeconds);
196
- let request = await deps.loadRequestImpl({ home: deps.home, env: deps.env, requestId });
197
- if (request.status === 'resolved') return terminalResult(request);
198
-
199
- const current = await config();
200
- const deadline = deps.nowImpl() + seconds * 1000;
201
- while (deps.nowImpl() < deadline) {
202
- const response = await deps.pollRemoteResponseImpl({
203
- config: current,
204
- responseTopic: request.responseTopic,
205
- requestId: request.requestId,
206
- allowed: request.allowed
207
- });
208
- if (response !== null) {
209
- request = await deps.resolveRequestImpl({
210
- home: deps.home,
211
- env: deps.env,
212
- requestId: request.requestId,
213
- response,
214
- resolvedAt: new Date(deps.nowImpl()).toISOString()
215
- });
216
- await deps.sendResponseConfirmationImpl({
217
- config: current,
218
- response,
219
- title: request.kind === 'approval' ? 'Approval' : request.kind === 'refinement' ? 'Refinement' : 'Choice'
220
- });
221
- return terminalResult(request);
222
- }
223
- const remaining = deadline - deps.nowImpl();
224
- if (remaining <= 0) break;
225
- await deps.sleepImpl(Math.min(1000, remaining));
226
- }
227
- return pendingResult(request.requestId);
228
- },
229
-
230
- async getRequest({ requestId }) {
231
- const request = await deps.loadRequestImpl({ home: deps.home, env: deps.env, requestId });
232
- return { status: 'ok', request: publicRequest(request) };
233
- },
234
-
235
- async listPending({ limit = 20 } = {}) {
236
- const requests = await deps.listPendingRequestsImpl({ home: deps.home, env: deps.env, limit });
237
- return { status: 'ok', requests: requests.map(publicRequest) };
238
- }
239
- };
240
- }
1
+ import { loadConfig } from './config.mjs';
2
+ import {
3
+ createRemoteRequest,
4
+ pollRemoteResponse,
5
+ sendNotification,
6
+ sendResponseConfirmation
7
+ } from './ntfy.mjs';
8
+ import {
9
+ listPendingRequests,
10
+ loadRequest,
11
+ resolveRequest,
12
+ savePendingRequest
13
+ } from './requests.mjs';
14
+
15
+ const MAX_WAIT_SECONDS = 240;
16
+ const DEFAULT_WAIT_SECONDS = 240;
17
+
18
+ export const WAIT_REQUIRED = (requestId) =>
19
+ `WAIT REQUIRED: human response for ${requestId} is still pending. Do not continue, infer approval, or complete the guarded action. Call nofax_wait_for_response with requestId "${requestId}" and repeat whenever it returns pending until a terminal response is returned.`;
20
+
21
+ function pendingResult(requestId) {
22
+ return {
23
+ status: 'pending',
24
+ requestId,
25
+ mustWait: true,
26
+ instruction: WAIT_REQUIRED(requestId)
27
+ };
28
+ }
29
+
30
+ function terminalResult(request) {
31
+ if (request.decision === 'allow') {
32
+ return {
33
+ status: 'resolved',
34
+ requestId: request.requestId,
35
+ decision: 'allow',
36
+ instruction: 'Human approved this request. The caller may continue only within its existing authority.'
37
+ };
38
+ }
39
+ if (request.decision === 'deny') {
40
+ return {
41
+ status: 'resolved',
42
+ requestId: request.requestId,
43
+ decision: 'deny',
44
+ instruction: 'Human denied this request. Do not perform the guarded action.'
45
+ };
46
+ }
47
+ if (request.decision === 'refine') {
48
+ return {
49
+ status: 'resolved',
50
+ requestId: request.requestId,
51
+ decision: 'refine',
52
+ text: request.text,
53
+ instruction: 'Apply the human refinement. If the resulting action still requires approval, create a new approval request and wait for that new terminal response before acting.'
54
+ };
55
+ }
56
+ return {
57
+ status: 'resolved',
58
+ requestId: request.requestId,
59
+ decision: request.decision,
60
+ instruction: 'Human choice received. Apply only that explicit choice within the caller\'s existing authority.'
61
+ };
62
+ }
63
+
64
+ function publicRequest(request) {
65
+ const result = {
66
+ requestId: request.requestId,
67
+ kind: request.kind,
68
+ status: request.status,
69
+ createdAt: request.createdAt
70
+ };
71
+ if (request.status === 'resolved') {
72
+ result.resolvedAt = request.resolvedAt;
73
+ result.decision = request.decision;
74
+ if (request.text !== undefined) result.text = request.text;
75
+ }
76
+ return result;
77
+ }
78
+
79
+ function validateText(value, name, max = 2200) {
80
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`NOFAX_${name}_REQUIRED`);
81
+ return value.trim().slice(0, max);
82
+ }
83
+
84
+ function validateWaitSeconds(value) {
85
+ const seconds = value ?? DEFAULT_WAIT_SECONDS;
86
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_WAIT_SECONDS) {
87
+ throw new Error('NOFAX_MCP_WAIT_SECONDS_INVALID');
88
+ }
89
+ return seconds;
90
+ }
91
+
92
+ function validateChoiceOptions(options) {
93
+ if (!Array.isArray(options) || options.length < 1 || options.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
94
+ return options.map((option) => {
95
+ if (typeof option === 'string') {
96
+ const value = option.trim();
97
+ if (!value) throw new Error('NOFAX_CHOICE_INVALID');
98
+ return { value: value.slice(0, 80), label: value.slice(0, 32) };
99
+ }
100
+ if (!option || typeof option.value !== 'string' || typeof option.label !== 'string') throw new Error('NOFAX_CHOICE_INVALID');
101
+ const value = option.value.trim();
102
+ const label = option.label.trim();
103
+ if (!value || !label) throw new Error('NOFAX_CHOICE_INVALID');
104
+ return { value: value.slice(0, 80), label: label.slice(0, 32) };
105
+ });
106
+ }
107
+
108
+ export function createMcpToolHandlers(overrides = {}) {
109
+ const deps = {
110
+ home: overrides.home,
111
+ env: overrides.env ?? process.env,
112
+ loadConfigImpl: overrides.loadConfigImpl ?? loadConfig,
113
+ sendNotificationImpl: overrides.sendNotificationImpl ?? sendNotification,
114
+ createRemoteRequestImpl: overrides.createRemoteRequestImpl ?? createRemoteRequest,
115
+ pollRemoteResponseImpl: overrides.pollRemoteResponseImpl ?? pollRemoteResponse,
116
+ sendResponseConfirmationImpl: overrides.sendResponseConfirmationImpl ?? sendResponseConfirmation,
117
+ savePendingRequestImpl: overrides.savePendingRequestImpl ?? savePendingRequest,
118
+ loadRequestImpl: overrides.loadRequestImpl ?? loadRequest,
119
+ resolveRequestImpl: overrides.resolveRequestImpl ?? resolveRequest,
120
+ listPendingRequestsImpl: overrides.listPendingRequestsImpl ?? listPendingRequests,
121
+ nowImpl: overrides.nowImpl ?? Date.now,
122
+ sleepImpl: overrides.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
123
+ };
124
+
125
+ async function config() {
126
+ return deps.loadConfigImpl({ home: deps.home, env: deps.env });
127
+ }
128
+
129
+ async function persistRemote({ kind, remote }) {
130
+ const request = {
131
+ version: 1,
132
+ requestId: remote.requestId,
133
+ kind,
134
+ responseTopic: remote.responseTopic,
135
+ allowed: remote.allowed,
136
+ status: 'pending',
137
+ createdAt: new Date(deps.nowImpl()).toISOString()
138
+ };
139
+ await deps.savePendingRequestImpl({ home: deps.home, env: deps.env, request });
140
+ return pendingResult(remote.requestId);
141
+ }
142
+
143
+ return {
144
+ async notify({ title = 'Nofax', message }) {
145
+ const current = await config();
146
+ await deps.sendNotificationImpl({
147
+ config: current,
148
+ title: validateText(title, 'TITLE', 120),
149
+ message: validateText(message, 'MESSAGE')
150
+ });
151
+ return { status: 'sent' };
152
+ },
153
+
154
+ async requestApproval({ title = 'Nofax approval', message, allowRefine = false }) {
155
+ const current = await config();
156
+ const remote = await deps.createRemoteRequestImpl({
157
+ config: current,
158
+ title: validateText(title, 'TITLE', 120),
159
+ message: validateText(message, 'MESSAGE'),
160
+ options: [
161
+ { value: 'allow', label: 'Allow' },
162
+ { value: 'deny', label: 'Deny' }
163
+ ],
164
+ includeRefine: allowRefine === true
165
+ });
166
+ return persistRemote({ kind: 'approval', remote });
167
+ },
168
+
169
+ async requestChoice({ title = 'Nofax choice', message, options }) {
170
+ const current = await config();
171
+ const normalized = validateChoiceOptions(options);
172
+ const remote = await deps.createRemoteRequestImpl({
173
+ config: current,
174
+ title: validateText(title, 'TITLE', 120),
175
+ message: validateText(message, 'MESSAGE'),
176
+ options: normalized,
177
+ includeRefine: false
178
+ });
179
+ return persistRemote({ kind: 'choice', remote });
180
+ },
181
+
182
+ async requestRefinement({ title = 'Nofax refinement', message }) {
183
+ const current = await config();
184
+ const remote = await deps.createRemoteRequestImpl({
185
+ config: current,
186
+ title: validateText(title, 'TITLE', 120),
187
+ message: validateText(message, 'MESSAGE'),
188
+ options: [],
189
+ includeRefine: true
190
+ });
191
+ return persistRemote({ kind: 'refinement', remote });
192
+ },
193
+
194
+ async waitForResponse({ requestId, waitSeconds }) {
195
+ const seconds = validateWaitSeconds(waitSeconds);
196
+ let request = await deps.loadRequestImpl({ home: deps.home, env: deps.env, requestId });
197
+ if (request.status === 'resolved') return terminalResult(request);
198
+
199
+ const current = await config();
200
+ const deadline = deps.nowImpl() + seconds * 1000;
201
+ while (deps.nowImpl() < deadline) {
202
+ const response = await deps.pollRemoteResponseImpl({
203
+ config: current,
204
+ responseTopic: request.responseTopic,
205
+ requestId: request.requestId,
206
+ allowed: request.allowed
207
+ });
208
+ if (response !== null) {
209
+ request = await deps.resolveRequestImpl({
210
+ home: deps.home,
211
+ env: deps.env,
212
+ requestId: request.requestId,
213
+ response,
214
+ resolvedAt: new Date(deps.nowImpl()).toISOString()
215
+ });
216
+ await deps.sendResponseConfirmationImpl({
217
+ config: current,
218
+ response,
219
+ title: request.kind === 'approval' ? 'Approval' : request.kind === 'refinement' ? 'Refinement' : 'Choice'
220
+ });
221
+ return terminalResult(request);
222
+ }
223
+ const remaining = deadline - deps.nowImpl();
224
+ if (remaining <= 0) break;
225
+ await deps.sleepImpl(Math.min(1000, remaining));
226
+ }
227
+ return pendingResult(request.requestId);
228
+ },
229
+
230
+ async getRequest({ requestId }) {
231
+ const request = await deps.loadRequestImpl({ home: deps.home, env: deps.env, requestId });
232
+ return { status: 'ok', request: publicRequest(request) };
233
+ },
234
+
235
+ async listPending({ limit = 20 } = {}) {
236
+ const requests = await deps.listPendingRequestsImpl({ home: deps.home, env: deps.env, limit });
237
+ return { status: 'ok', requests: requests.map(publicRequest) };
238
+ }
239
+ };
240
+ }