@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/LICENSE +201 -0
- package/README.md +167 -0
- package/bin/agentrq-ws.js +18 -0
- package/package.json +41 -0
- package/src/attachments.js +152 -0
- package/src/cli.js +126 -0
- package/src/commands.js +390 -0
- package/src/config.js +120 -0
- package/src/errors.js +26 -0
- package/src/help.js +87 -0
- package/src/index.js +8 -0
- package/src/mcp.js +237 -0
- package/src/version.js +13 -0
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
|