@agentrq/agentrq-ws 0.7.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/src/help.js ADDED
@@ -0,0 +1,87 @@
1
+ // Copyright 2026 Contextual, Inc. https://agentrq.com
2
+ // This notice may not be modified or removed.
3
+
4
+ import { COMMANDS } from './commands.js'
5
+ import { VERSION } from './version.js'
6
+
7
+ export const GLOBAL_OPTIONS = {
8
+ json: { type: 'boolean', description: 'Print the raw JSON result instead of text' },
9
+ server: { type: 'string', description: 'Which server in .mcp.json to use' },
10
+ config: { type: 'string', description: 'Path to an .mcp.json (default: nearest one upwards)' },
11
+ help: { type: 'boolean', short: 'h', description: 'Show help for a command' },
12
+ version: { type: 'boolean', short: 'V', description: 'Print the version' },
13
+ }
14
+
15
+ const pad = (text, width) => text + ' '.repeat(Math.max(0, width - text.length))
16
+
17
+ function renderOptions(options) {
18
+ const entries = Object.entries(options || {})
19
+ if (entries.length === 0) return ''
20
+ const rendered = entries.map(([name, spec]) => {
21
+ const flags = `--${name}${spec.short ? `, -${spec.short}` : ''}${spec.type === 'string' ? ' <value>' : ''}`
22
+ return [flags, spec.description || '']
23
+ })
24
+ const width = Math.max(...rendered.map(([flags]) => flags.length))
25
+ return rendered.map(([flags, description]) => ` ${pad(flags, width)} ${description}`).join('\n')
26
+ }
27
+
28
+ /** Help for one command. */
29
+ export function commandHelp(command) {
30
+ const lines = [command.summary, '', `Usage: ${command.usage}`]
31
+ const options = renderOptions(command.options)
32
+ if (options) lines.push('', 'Options:', options)
33
+ lines.push('', 'Global options:', renderOptions(GLOBAL_OPTIONS))
34
+ return lines.join('\n')
35
+ }
36
+
37
+ /** Help for a family of commands, such as `task` or `memory`. */
38
+ export function groupHelp({ segments, members }) {
39
+ const name = segments.join(' ')
40
+ const width = Math.max(...members.map((c) => c.path.join(' ').length))
41
+ const list = members.map((c) => ` ${pad(c.path.join(' '), width)} ${c.summary}`).join('\n')
42
+ return [
43
+ `Usage: agentrq-ws ${name} <command>`,
44
+ '',
45
+ `Commands:`,
46
+ list,
47
+ '',
48
+ `Run \`agentrq-ws help ${name} <command>\` for one of them.`,
49
+ ].join('\n')
50
+ }
51
+
52
+ /** The top-level help, generated from the command table so it cannot go stale. */
53
+ export function mainHelp() {
54
+ const width = Math.max(...COMMANDS.map((c) => c.path.join(' ').length))
55
+ const list = COMMANDS.map((c) => ` ${pad(c.path.join(' '), width)} ${c.summary}`).join('\n')
56
+ return `agentrq-ws ${VERSION} — AgentRQ workspace client
57
+
58
+ Drives the workspace named by the .mcp.json in the current directory (or the
59
+ nearest one above it), using the same tools an agent would, without spending an
60
+ agent's tokens to do it.
61
+
62
+ Usage: agentrq-ws <command> [options]
63
+
64
+ Commands:
65
+ ${list}
66
+ ${pad('help', width)} Show this help, or help for a command
67
+
68
+ Global options:
69
+ ${renderOptions(GLOBAL_OPTIONS)}
70
+
71
+ Text arguments accept @path to read a file and - to read stdin.
72
+ Attachments are given as plain file paths; downloads are written to disk and
73
+ the path is printed. You never handle base64.
74
+
75
+ Examples:
76
+ agentrq-ws workspace
77
+ agentrq-ws task next
78
+ agentrq-ws task create "Ship the CLI" --body @notes.md --attach ./diagram.png
79
+ agentrq-ws reply 0isnjTCkpW5 "Done — logs attached" --attach ./run.log
80
+ agentrq-ws attachment get att-42 --task 0isnjTCkpW5 --out ~/Downloads
81
+ agentrq-ws memory load
82
+ echo "the full note" | agentrq-ws memory save release-notes.md --content -
83
+
84
+ Environment:
85
+ AGENTRQ_WS_URL Use this server URL instead of reading .mcp.json
86
+ AGENTRQ_WS_SERVER Which server in .mcp.json to use`
87
+ }
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // Copyright 2026 Contextual, Inc. https://agentrq.com
2
+ // This notice may not be modified or removed.
3
+
4
+ export { run } from './cli.js'
5
+ export { McpClient, PROTOCOL_VERSION } from './mcp.js'
6
+ export { resolveServer } from './config.js'
7
+ export { COMMANDS } from './commands.js'
8
+ export { VERSION } from './version.js'
package/src/mcp.js ADDED
@@ -0,0 +1,237 @@
1
+ // Copyright 2026 Contextual, Inc. https://agentrq.com
2
+ // This notice may not be modified or removed.
3
+
4
+ import { ServerError } from './errors.js'
5
+ import { VERSION } from './version.js'
6
+
7
+ /**
8
+ * The revision to negotiate with. The workspace server is deliberately
9
+ * stateful, which means the go-sdk never offers 2026-07-28 there; it advertises
10
+ * 2025-11-25 down to 2024-11-05. 2025-06-18 sits safely inside that range and
11
+ * is the revision that introduced the MCP-Protocol-Version header, so it is
12
+ * what this client speaks.
13
+ */
14
+ export const PROTOCOL_VERSION = '2025-06-18'
15
+
16
+ const CLIENT_INFO = { name: 'agentrq-ws', version: VERSION }
17
+
18
+ /**
19
+ * Pull the JSON-RPC payload out of a response body.
20
+ *
21
+ * Streamable HTTP is allowed to answer a POST with either a bare JSON object or
22
+ * an SSE stream carrying the same object in a `data:` frame, and the workspace
23
+ * server uses the SSE form. Handling only one of the two is the difference
24
+ * between working and "undefined result" with nothing in the logs.
25
+ */
26
+ export function parseResponseBody(body, contentType = '') {
27
+ const text = String(body).trim()
28
+ if (text === '') return null
29
+
30
+ if (contentType.includes('text/event-stream') || text.startsWith('event:') || text.startsWith('data:')) {
31
+ const payloads = []
32
+ for (const line of text.split(/\r?\n/)) {
33
+ if (!line.startsWith('data:')) continue
34
+ const chunk = line.slice(5).trim()
35
+ if (chunk === '' || chunk === '[DONE]') continue
36
+ try {
37
+ payloads.push(JSON.parse(chunk))
38
+ } catch {
39
+ // A frame that is not JSON is not ours; keep looking.
40
+ }
41
+ }
42
+ if (payloads.length === 0) return null
43
+ // The last frame carrying a result or error is the answer to our call;
44
+ // earlier frames may be progress notifications.
45
+ for (let i = payloads.length - 1; i >= 0; i -= 1) {
46
+ if (payloads[i] && (payloads[i].result !== undefined || payloads[i].error !== undefined)) {
47
+ return payloads[i]
48
+ }
49
+ }
50
+ return payloads[payloads.length - 1]
51
+ }
52
+
53
+ try {
54
+ return JSON.parse(text)
55
+ } catch {
56
+ throw new ServerError(`server sent a non-JSON response: ${truncate(text, 200)}`)
57
+ }
58
+ }
59
+
60
+ function truncate(text, max) {
61
+ return text.length > max ? `${text.slice(0, max)}…` : text
62
+ }
63
+
64
+ /** Flatten a tool result's content blocks into plain text. */
65
+ export function contentToText(result) {
66
+ if (!result) return ''
67
+ const blocks = Array.isArray(result.content) ? result.content : []
68
+ return blocks
69
+ .map((block) => {
70
+ if (!block) return ''
71
+ if (typeof block.text === 'string') return block.text
72
+ if (block.type === 'resource' && block.resource && typeof block.resource.text === 'string') {
73
+ return block.resource.text
74
+ }
75
+ return ''
76
+ })
77
+ .filter(Boolean)
78
+ .join('\n')
79
+ }
80
+
81
+ /**
82
+ * A minimal streamable-HTTP MCP client.
83
+ *
84
+ * Deliberately hand-rolled rather than pulling in the MCP SDK: this is a CLI
85
+ * people run with `npx`, and three POSTs of handshake are cheaper to install
86
+ * and to audit than a dependency tree.
87
+ */
88
+ export class McpClient {
89
+ constructor({ url, headers = {}, fetchImpl = globalThis.fetch, protocolVersion = PROTOCOL_VERSION }) {
90
+ this.url = url
91
+ this.extraHeaders = headers
92
+ this.fetchImpl = fetchImpl
93
+ this.protocolVersion = protocolVersion
94
+ this.sessionId = null
95
+ this.initialized = false
96
+ this.nextId = 1
97
+ }
98
+
99
+ headersFor(extra = {}) {
100
+ const headers = {
101
+ 'content-type': 'application/json',
102
+ accept: 'application/json, text/event-stream',
103
+ ...this.extraHeaders,
104
+ ...extra,
105
+ }
106
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId
107
+ if (this.initialized) headers['mcp-protocol-version'] = this.protocolVersion
108
+ return headers
109
+ }
110
+
111
+ async post(payload) {
112
+ let response
113
+ try {
114
+ response = await this.fetchImpl(this.url, {
115
+ method: 'POST',
116
+ headers: this.headersFor(),
117
+ body: JSON.stringify(payload),
118
+ })
119
+ } catch (err) {
120
+ throw new ServerError(`cannot reach the workspace server at ${this.url}: ${err.message}`)
121
+ }
122
+
123
+ const body = await response.text()
124
+ if (!response.ok) {
125
+ // Always name the status: a swallowed request otherwise looks exactly
126
+ // like a successful one from the caller's side.
127
+ throw new ServerError(
128
+ `workspace server returned HTTP ${response.status}${body ? `: ${truncate(body.trim(), 300)}` : ''}`,
129
+ { status: response.status },
130
+ )
131
+ }
132
+
133
+ const sessionId = response.headers && response.headers.get && response.headers.get('mcp-session-id')
134
+ if (sessionId) this.sessionId = sessionId
135
+
136
+ return parseResponseBody(body, (response.headers && response.headers.get && response.headers.get('content-type')) || '')
137
+ }
138
+
139
+ async request(method, params, { allowReconnect = true } = {}) {
140
+ const id = this.nextId
141
+ this.nextId += 1
142
+ let message
143
+ try {
144
+ message = await this.post({ jsonrpc: '2.0', id, method, params })
145
+ } catch (err) {
146
+ // The workspace server keeps its sessions alive by pinging them over the
147
+ // SSE GET stream, and a CLI never opens that stream — so a session can be
148
+ // dropped underneath a command that is merely a little slow, and a
149
+ // two-call command like `attachment get` is exactly long enough to hit
150
+ // it. Re-establishing is invisible and correct; surfacing "session not
151
+ // found" to somebody who never asked for a session is not.
152
+ if (allowReconnect && isLostSession(err)) {
153
+ this.sessionId = null
154
+ this.initialized = false
155
+ await this.connect()
156
+ return this.request(method, params, { allowReconnect: false })
157
+ }
158
+ throw err
159
+ }
160
+ if (!message) throw new ServerError(`the server sent no response to ${method}`)
161
+ if (message.error) {
162
+ const { code, message: text } = message.error
163
+ throw new ServerError(`${method} failed${code !== undefined ? ` (${code})` : ''}: ${text || 'unknown error'}`)
164
+ }
165
+ return message.result
166
+ }
167
+
168
+ /**
169
+ * End the session rather than letting the server hold it until it decides the
170
+ * client is gone. Failure here is never interesting — the command already
171
+ * did its work — so it is swallowed.
172
+ */
173
+ async close() {
174
+ if (!this.sessionId) return
175
+ const headers = this.headersFor()
176
+ this.sessionId = null
177
+ this.initialized = false
178
+ try {
179
+ await this.fetchImpl(this.url, { method: 'DELETE', headers })
180
+ } catch {
181
+ // Nothing to do: the session expires on its own.
182
+ }
183
+ }
184
+
185
+ async notify(method, params) {
186
+ await this.post({ jsonrpc: '2.0', method, params })
187
+ }
188
+
189
+ async connect() {
190
+ if (this.initialized) return
191
+ await this.request('initialize', {
192
+ protocolVersion: this.protocolVersion,
193
+ capabilities: {},
194
+ clientInfo: CLIENT_INFO,
195
+ })
196
+ this.initialized = true
197
+ await this.notify('notifications/initialized', {})
198
+ }
199
+
200
+ async listTools() {
201
+ await this.connect()
202
+ const result = await this.request('tools/list', {})
203
+ return (result && result.tools) || []
204
+ }
205
+
206
+ /**
207
+ * Call a tool and return its text.
208
+ *
209
+ * A tool that answers with `isError` is a refusal by the workspace (a bad
210
+ * status, a memory name that does not match the required shape), not a
211
+ * transport failure — it still becomes a non-zero exit, but with the
212
+ * server's own wording rather than a stack.
213
+ */
214
+ async callTool(name, args = {}) {
215
+ await this.connect()
216
+ const result = await this.request('tools/call', { name, arguments: pruneUndefined(args) })
217
+ const text = contentToText(result)
218
+ if (result && result.isError) {
219
+ throw new ServerError(text || `${name} failed`)
220
+ }
221
+ return { text, result }
222
+ }
223
+ }
224
+
225
+ /** True when an error says the server no longer knows our session. */
226
+ export function isLostSession(err) {
227
+ return Boolean(err) && err.status === 404 && /session/i.test(err.message || '')
228
+ }
229
+
230
+ /** Drop keys the caller left unset so optional tool arguments stay absent. */
231
+ export function pruneUndefined(object) {
232
+ const out = {}
233
+ for (const [key, value] of Object.entries(object || {})) {
234
+ if (value !== undefined && value !== null) out[key] = value
235
+ }
236
+ return out
237
+ }
package/src/version.js ADDED
@@ -0,0 +1,13 @@
1
+ // Copyright 2026 Contextual, Inc. https://agentrq.com
2
+ // This notice may not be modified or removed.
3
+
4
+ import { createRequire } from 'node:module'
5
+
6
+ /**
7
+ * The CLI's version, read from its own package.json.
8
+ *
9
+ * This package rides the repository's release tags rather than carrying a
10
+ * version of its own, so the manifest is the single place the number appears —
11
+ * hard-coding it here as well is how the two drift apart by one release.
12
+ */
13
+ export const VERSION = createRequire(import.meta.url)('../package.json').version