Skip to content

Commit 16c51b6

Browse files
committed
Add configurable notification buffering
1 parent 3dd8d91 commit 16c51b6

7 files changed

Lines changed: 88 additions & 17 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ tool and then continues the original command.
215215
Tool calls use a 5 minute request timeout by default. Set
216216
`MCPX_TOOL_CALL_TIMEOUT_MS` when a remote MCP server legitimately needs longer.
217217

218+
mcpx buffers MCP subscription notifications and returns them with the next daemon
219+
tool-call result by default. Set `MCPX_NOTIFICATION_MODE=discard` to ignore those
220+
notifications for a command; accepted values are `buffer` and `discard`.
221+
218222
## Output
219223

220224
mcpx optimizes output for humans and agents by default:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcpx",
3-
"version": "0.9.11",
3+
"version": "0.9.12",
44
"license": "MIT",
55
"bin": {
66
"mcpx": "./src/main.ts"

src/daemon-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DAEMON_PROTOCOL_VERSION,
99
buildServerKey,
1010
helloMessage,
11+
notificationModeFromEnv,
1112
type ClientMessage,
1213
type DaemonMessage,
1314
type DaemonStatus,
@@ -68,6 +69,7 @@ export async function callToolViaDaemon(
6869
server,
6970
toolName,
7071
input,
72+
notificationMode: notificationModeFromEnv(),
7173
}
7274
if (context.headers) message.headers = context.headers
7375
const response = await requestDaemonMessage(

src/daemon-protocol.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { MCPX_VERSION } from './version'
88
export const DAEMON_PROTOCOL_VERSION = 2
99
export const DAEMON_ENV = 'MCPX_DAEMON_SERVER'
1010
export const DISABLE_DAEMON_ENV = 'MCPX_DISABLE_DAEMON'
11+
export const NOTIFICATION_MODE_ENV = 'MCPX_NOTIFICATION_MODE'
12+
13+
export type NotificationMode = 'buffer' | 'discard'
1114

1215
export type McpNotification =
1316
| {
@@ -63,7 +66,7 @@ export type ClientMessage =
6366
headers?: Record<string, string>
6467
toolName: string
6568
input: Record<string, unknown>
66-
notificationMode?: 'buffer' | 'discard'
69+
notificationMode?: NotificationMode
6770
}
6871
| { op: 'status' }
6972
| { op: 'stop' }
@@ -89,6 +92,15 @@ export function shouldUseDaemon(): boolean {
8992
)
9093
}
9194

95+
export function notificationModeFromEnv(): NotificationMode {
96+
const raw = process.env[NOTIFICATION_MODE_ENV]
97+
if (!raw || raw === 'buffer') return 'buffer'
98+
if (raw === 'discard') return 'discard'
99+
throw new Error(
100+
`Invalid ${NOTIFICATION_MODE_ENV} value "${raw}". Expected "buffer" or "discard".`,
101+
)
102+
}
103+
92104
export function buildServerKey(server: ServerConfig): string {
93105
const payload =
94106
server.transport === 'stdio'

src/daemon-server.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,13 @@ async function callTool(
197197
message.headers,
198198
async (session) => {
199199
const buffer = createNotificationBuffer()
200-
session.currentBuffer =
201-
message.notificationMode === 'discard' ? undefined : buffer
200+
const bufferNotifications = message.notificationMode !== 'discard'
201+
session.currentBuffer = bufferNotifications ? buffer : undefined
202202
try {
203203
const result = await callToolWithRetainedSessionFallback(
204204
session,
205205
message,
206-
buffer,
206+
bufferNotifications ? buffer : undefined,
207207
)
208208
const notifications = await flushNotifications(buffer)
209209
const toolsChanged =
@@ -228,7 +228,7 @@ async function callTool(
228228
async function callToolWithRetainedSessionFallback(
229229
session: ManagedSession,
230230
message: Extract<ClientMessage, { op: 'call' }>,
231-
buffer: NotificationBuffer,
231+
buffer: NotificationBuffer | undefined,
232232
): Promise<unknown> {
233233
try {
234234
return await callToolOnConnectedSession(session, message, buffer)
@@ -249,24 +249,25 @@ async function callToolWithRetainedSessionFallback(
249249
async function callToolOnConnectedSession(
250250
session: ManagedSession,
251251
message: Extract<ClientMessage, { op: 'call' }>,
252-
buffer: NotificationBuffer,
252+
buffer: NotificationBuffer | undefined,
253253
): Promise<unknown> {
254254
const connection = await ensureConnected(session)
255+
const options = toolCallRequestOptions()
256+
if (buffer) {
257+
options.onprogress = (progress) => {
258+
buffer.add({
259+
method: 'notifications/progress',
260+
params: { progressToken: message.callId, ...progress },
261+
})
262+
}
263+
}
255264
return connection.client.callTool(
256265
{
257266
name: message.toolName,
258267
arguments: message.input,
259268
},
260269
undefined,
261-
{
262-
onprogress: (progress) => {
263-
buffer.add({
264-
method: 'notifications/progress',
265-
params: { progressToken: message.callId, ...progress },
266-
})
267-
},
268-
...toolCallRequestOptions(),
269-
},
270+
options,
270271
)
271272
}
272273

tests/daemon-protocol.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'bun:test'
22

33
import type { HttpServerConfig } from '../src/types'
44

5-
import { buildServerKey } from '../src/daemon-protocol'
5+
import {
6+
NOTIFICATION_MODE_ENV,
7+
buildServerKey,
8+
notificationModeFromEnv,
9+
} from '../src/daemon-protocol'
610

711
describe('daemon protocol', () => {
812
it('keeps HTTP server keys stable across resolved token values', () => {
@@ -75,4 +79,40 @@ describe('daemon protocol', () => {
7579

7680
expect(buildServerKey(second)).not.toBe(buildServerKey(first))
7781
})
82+
83+
it('defaults notification buffering unless explicitly discarded', () => {
84+
const previous = process.env[NOTIFICATION_MODE_ENV]
85+
try {
86+
delete process.env[NOTIFICATION_MODE_ENV]
87+
expect(notificationModeFromEnv()).toBe('buffer')
88+
89+
process.env[NOTIFICATION_MODE_ENV] = 'buffer'
90+
expect(notificationModeFromEnv()).toBe('buffer')
91+
92+
process.env[NOTIFICATION_MODE_ENV] = 'discard'
93+
expect(notificationModeFromEnv()).toBe('discard')
94+
} finally {
95+
if (previous === undefined) {
96+
delete process.env[NOTIFICATION_MODE_ENV]
97+
} else {
98+
process.env[NOTIFICATION_MODE_ENV] = previous
99+
}
100+
}
101+
})
102+
103+
it('rejects invalid notification mode env values', () => {
104+
const previous = process.env[NOTIFICATION_MODE_ENV]
105+
try {
106+
process.env[NOTIFICATION_MODE_ENV] = 'off'
107+
expect(() => notificationModeFromEnv()).toThrow(
108+
'Invalid MCPX_NOTIFICATION_MODE value "off". Expected "buffer" or "discard".',
109+
)
110+
} finally {
111+
if (previous === undefined) {
112+
delete process.env[NOTIFICATION_MODE_ENV]
113+
} else {
114+
process.env[NOTIFICATION_MODE_ENV] = previous
115+
}
116+
}
117+
})
78118
})

tests/notification-dogfood.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ describe('notification fixture dogfood', () => {
112112
},
113113
])
114114

115+
const discardedRaw = JSON.parse(
116+
(
117+
await runMcpx(
118+
['notification-fixture', 'progress-stream', '--count', '4', '--raw'],
119+
{ MCPX_NOTIFICATION_MODE: 'discard' },
120+
)
121+
).stdout,
122+
)
123+
expect(discardedRaw).toEqual({ tool: 'progress-stream', count: 4 })
124+
115125
const toolsChanged = JSON.parse(
116126
(await runMcpx(['notification-fixture', 'notify-tools-changed', '--raw']))
117127
.stdout,
@@ -180,12 +190,14 @@ describe('notification fixture dogfood', () => {
180190

181191
async function runMcpx(
182192
args: string[],
193+
env: Record<string, string> = {},
183194
): Promise<{ stdout: string; stderr: string }> {
184195
const proc = Bun.spawn([process.execPath, mainPath, ...args], {
185196
env: {
186197
...process.env,
187198
HOME: home,
188199
MCPX_HOME: home,
200+
...env,
189201
},
190202
stdin: 'ignore',
191203
stdout: 'pipe',

0 commit comments

Comments
 (0)