@briancray/belte 0.3.1 → 0.5.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.
Files changed (61) hide show
  1. package/bin/belte.ts +22 -13
  2. package/package.json +1 -1
  3. package/src/appEntry.ts +24 -8
  4. package/src/buildDisconnected.ts +3 -0
  5. package/src/bundleApp.ts +24 -2
  6. package/src/controlServerWorker.ts +205 -7
  7. package/src/discoveryEntry.ts +58 -11
  8. package/src/lib/browser/cache.ts +29 -6
  9. package/src/lib/browser/startClient.ts +24 -1
  10. package/src/lib/bundle/BundleWindow.ts +11 -0
  11. package/src/lib/bundle/disconnected.svelte +238 -42
  12. package/src/lib/bundle/onMenu.ts +20 -5
  13. package/src/lib/bundle/openWebview.ts +9 -2
  14. package/src/lib/bundle/signMacApp.ts +35 -0
  15. package/src/lib/cli/createClient.ts +65 -27
  16. package/src/lib/cli/loadEnvFromBinaryDir.ts +8 -38
  17. package/src/lib/cli/runCli.ts +37 -15
  18. package/src/lib/cli/types/CliManifestEntry.ts +7 -2
  19. package/src/lib/mcp/annotationsForMethod.ts +29 -0
  20. package/src/lib/mcp/createMcpServer.ts +10 -8
  21. package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
  22. package/src/lib/mcp/toolResultFromResponse.ts +66 -0
  23. package/src/lib/server/jsonl.ts +2 -1
  24. package/src/lib/server/rpc/defineVerb.ts +30 -17
  25. package/src/lib/server/rpc/parseArgs.ts +2 -1
  26. package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
  27. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
  28. package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
  29. package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
  30. package/src/lib/server/runtime/createServer.ts +57 -21
  31. package/src/lib/server/runtime/globToPathSet.ts +29 -0
  32. package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
  33. package/src/lib/server/runtime/parsePort.ts +16 -0
  34. package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
  35. package/src/lib/server/sockets/defineSocket.ts +7 -1
  36. package/src/lib/server/sockets/recentHistory.ts +11 -0
  37. package/src/lib/server/sockets/socketOperations.ts +35 -0
  38. package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
  39. package/src/lib/server/sse.ts +2 -1
  40. package/src/lib/shared/appDataDir.ts +22 -0
  41. package/src/lib/shared/buildRpcRequest.ts +2 -1
  42. package/src/lib/shared/carriesBodyArgs.ts +13 -0
  43. package/src/lib/shared/isReadOnlyMethod.ts +14 -0
  44. package/src/lib/shared/isStreamingResponse.ts +11 -0
  45. package/src/lib/shared/jsonlErrorFrame.ts +24 -0
  46. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  47. package/src/lib/shared/loadEnvFile.ts +17 -0
  48. package/src/lib/shared/loadEnvFromDataDir.ts +15 -0
  49. package/src/lib/shared/parseEnv.ts +30 -0
  50. package/src/lib/shared/readEnvFile.ts +15 -0
  51. package/src/lib/shared/resolveClientFlags.ts +8 -6
  52. package/src/lib/shared/responseErrorText.ts +9 -0
  53. package/src/lib/shared/serializeEnv.ts +18 -0
  54. package/src/lib/shared/sseErrorFrame.ts +29 -0
  55. package/src/lib/shared/streamResponse.ts +168 -0
  56. package/src/lib/shared/subscribableFromResponse.ts +1 -172
  57. package/src/lib/shared/types/CacheEntry.ts +6 -0
  58. package/src/serverEntry.ts +12 -0
  59. package/template/src/bundle/icon.png +0 -0
  60. package/template/src/server/rpc/getHello.ts +5 -3
  61. package/src/lib/shared/belteImportName.test.ts +0 -58
@@ -1,44 +1,14 @@
1
- import { existsSync } from 'node:fs'
2
1
  import { dirname } from 'node:path'
3
-
4
- const ENV_LINE = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/
2
+ import { loadEnvFile } from '../shared/loadEnvFile.ts'
5
3
 
6
4
  /*
7
- Reads a `.env` next to the running binary (resolved via
8
- `process.execPath`) and merges each declared var into `process.env`
9
- only when not already set. The binary-dir `.env` is the file the
10
- install tarball ships next to the executable; per-shell exports and
11
- Bun's automatic CWD `.env` loading both naturally override it.
12
-
13
- Strips surrounding single or double quotes off values; otherwise the
14
- parser is intentionally minimal — no variable expansion, no escape
15
- handling, no multi-line. Matches what the install tarball writes.
5
+ Loads a `.env` sitting next to the running binary (resolved via
6
+ `process.execPath`) into `process.env`. This is the file the install tarball
7
+ ships beside the executable and, for a bundle, the one `bundleApp` copies
8
+ from the project's `.env.bundle`. It carries the app's shipped defaults; the
9
+ fill-when-unset merge (see loadEnvFile) lets per-shell exports, Bun's CWD
10
+ `.env`, and the user's data-dir config all override it.
16
11
  */
17
12
  export async function loadEnvFromBinaryDir(): Promise<void> {
18
- const binDir = dirname(process.execPath)
19
- const envPath = `${binDir}/.env`
20
- if (!existsSync(envPath)) {
21
- return
22
- }
23
- const text = await Bun.file(envPath).text()
24
- for (const line of text.split('\n')) {
25
- if (!line || line.startsWith('#')) {
26
- continue
27
- }
28
- const match = ENV_LINE.exec(line)
29
- if (!match) {
30
- continue
31
- }
32
- const [, key, rawValue] = match
33
- if (process.env[key as string] !== undefined) {
34
- continue
35
- }
36
- const trimmed = rawValue?.trim() ?? ''
37
- const unquoted =
38
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
39
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
40
- ? trimmed.slice(1, -1)
41
- : trimmed
42
- process.env[key as string] = unquoted
43
- }
13
+ await loadEnvFile(`${dirname(process.execPath)}/.env`)
44
14
  }
@@ -1,9 +1,24 @@
1
+ import { decodeResponse } from '../shared/decodeResponse.ts'
2
+ import { isStreamingResponse } from '../shared/isStreamingResponse.ts'
3
+ import { responseErrorText } from '../shared/responseErrorText.ts'
4
+ import { streamResponse } from '../shared/streamResponse.ts'
1
5
  import { createClient } from './createClient.ts'
2
6
  import { loadEnvFromBinaryDir } from './loadEnvFromBinaryDir.ts'
3
7
  import { parseArgvForRpc } from './parseArgvForRpc.ts'
4
8
  import { printCommandHelp, printTopLevelHelp } from './printHelp.ts'
5
9
  import type { CliManifest } from './types/CliManifest.ts'
6
10
 
11
+ // String results print verbatim (with a trailing newline); everything else as a JSON line.
12
+ function printValue(value: unknown, pretty: boolean): void {
13
+ if (typeof value === 'string') {
14
+ process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
15
+ return
16
+ }
17
+ if (value !== undefined) {
18
+ process.stdout.write(`${JSON.stringify(value, null, pretty ? 2 : undefined)}\n`)
19
+ }
20
+ }
21
+
7
22
  /*
8
23
  Top-level CLI driver. Loaded by the standalone binary's entry; expects
9
24
  the bundler-emitted manifest plus the raw argv tail. The binary is a
@@ -18,9 +33,10 @@ running server over HTTP and APP_URL must be set. Flow:
18
33
  5. Otherwise parse the rest of the argv against the manifest entry's
19
34
  JSON Schema and dispatch via createClient against APP_URL.
20
35
 
21
- Streaming responses aren't a thing at this layer yet — every RPC tool
22
- goes through decodeResponse (text/JSON). Streaming verbs (jsonl/sse)
23
- will be added when the CLI grows watch/publish subcommands for sockets.
36
+ Streaming responses are handled by sniffing the response Content-Type:
37
+ sse/jsonl bodies (a streaming verb, or a socket `tail` command) are
38
+ printed frame-by-frame as NDJSON to stdout; everything else is decoded
39
+ and pretty-printed once.
24
40
  */
25
41
  export async function runCli({
26
42
  programName,
@@ -74,21 +90,27 @@ export async function runCli({
74
90
  const appToken = process.env.APP_TOKEN
75
91
  const client = createClient({ url: appUrl, token: appToken, manifest })
76
92
 
93
+ const fn = client[first]
94
+ if (!fn) {
95
+ console.error(`${programName}: command "${first}" not in client`)
96
+ return 1
97
+ }
77
98
  try {
78
- const fn = (client as Record<string, (args?: unknown) => Promise<unknown>>)[first]
79
- if (!fn) {
80
- console.error(`${programName}: command "${first}" not in client`)
81
- return 1
82
- }
83
- const result = await fn(args)
84
- if (typeof result === 'string') {
85
- process.stdout.write(result)
86
- if (!result.endsWith('\n')) {
87
- process.stdout.write('\n')
99
+ const response = await fn.raw(args)
100
+ if (isStreamingResponse(response)) {
101
+ /*
102
+ Stream frame-by-frame to stdout as NDJSON. streamResponse
103
+ throws a clear HttpError on a non-2xx body, caught below.
104
+ */
105
+ for await (const frame of streamResponse(response)) {
106
+ printValue(frame, false)
88
107
  }
89
- } else if (result !== undefined) {
90
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
108
+ return 0
109
+ }
110
+ if (!response.ok) {
111
+ throw new Error(await responseErrorText(response))
91
112
  }
113
+ printValue(await decodeResponse(response), true)
92
114
  return 0
93
115
  } catch (error) {
94
116
  console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
@@ -1,12 +1,17 @@
1
1
  import type { HttpVerb } from '../../server/rpc/types/HttpVerb.ts'
2
2
 
3
3
  /*
4
- Per-RPC manifest entry baked into the standalone CLI binary by the
4
+ Per-command manifest entry baked into the standalone CLI binary by the
5
5
  bundler. Carries enough info to make the right HTTP request without
6
- importing the handler module (which the thin build doesn't ship).
6
+ importing the handler module (which the thin build doesn't ship). Covers
7
+ both rpcs and socket commands — a socket `tail` is a GET against
8
+ `/__belte/sockets/<name>` with `accept: text/event-stream` so the CLI
9
+ streams it live; a socket `publish` is a POST to the same path.
7
10
  */
8
11
  export type CliManifestEntry = {
9
12
  method: HttpVerb
10
13
  url: string
11
14
  jsonSchema?: Record<string, unknown>
15
+ // Request Accept header. Socket tail sets text/event-stream to stream live frames.
16
+ accept?: string
12
17
  }
@@ -0,0 +1,29 @@
1
+ import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+
3
+ /*
4
+ Maps an HTTP verb to MCP tool annotations so a model can tell a read from
5
+ a write before calling. Belte derives these from the verb the RPC was
6
+ declared with rather than asking the author to repeat the intent:
7
+ - GET / HEAD → read-only, non-destructive
8
+ - POST → creates; not idempotent, not (necessarily) destructive
9
+ - PUT → replaces; idempotent + destructive
10
+ - PATCH → modifies; destructive, not idempotent
11
+ - DELETE → removes; idempotent + destructive
12
+ The shape matches MCP's ToolAnnotations (readOnlyHint / destructiveHint /
13
+ idempotentHint); fields a verb doesn't imply are left off.
14
+ */
15
+ export function annotationsForMethod(method: HttpVerb): Record<string, boolean> {
16
+ switch (method) {
17
+ case 'GET':
18
+ case 'HEAD':
19
+ return { readOnlyHint: true, destructiveHint: false }
20
+ case 'POST':
21
+ return { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
22
+ case 'PUT':
23
+ return { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
24
+ case 'PATCH':
25
+ return { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
26
+ case 'DELETE':
27
+ return { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
28
+ }
29
+ }
@@ -1,3 +1,4 @@
1
+ import { NO_STORE } from '../shared/cacheControlValues.ts'
1
2
  import { dispatchMcpRequest, MCP_NO_STORE_HEADERS } from './dispatchMcpRequest.ts'
2
3
  import type { McpServer } from './types/McpServer.ts'
3
4
  import type { McpServerOptions } from './types/McpServerOptions.ts'
@@ -12,13 +13,14 @@ object whose `handle(request)` is the function the bun route at
12
13
  default-constructs it; there is no user-authored server module. Server
13
14
  name/version default from package.json.
14
15
 
15
- Tools are derived from every verb with `clients.mcp: true` (auto-on when
16
- the verb carries a schema) one tool per rpc regardless of HTTP verb.
17
- Sockets are not exposed to MCP. Auth inherits from the inbound request —
18
- bearer / cookie headers
19
- flow into the synthesized Request that hits each rpc handler. An optional
20
- `authorize` hook in opts can short-circuit the request before any tool
21
- dispatches.
16
+ Tools are derived from every verb with `clients.mcp: true` (auto-on for
17
+ read-only verbs that carry a schema; mutating verbs opt in explicitly)
18
+ and from every socket with `clients.mcp: true` (a `<base>-tail` read tool
19
+ plus a `<base>-publish` tool when clientPublish is set). The HTTP verb
20
+ feeds each rpc tool's annotations. Auth inherits from the inbound request
21
+ bearer / cookie headers flow into the synthesized Request that hits
22
+ each rpc handler. An optional `authorize` hook in opts can short-circuit
23
+ the request before any tool dispatches.
22
24
  */
23
25
  export function createMcpServer(opts: McpServerOptions = {}): McpServer {
24
26
  const serverInfo = {
@@ -30,7 +32,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
30
32
  if (request.method !== 'POST') {
31
33
  return new Response('Method Not Allowed', {
32
34
  status: 405,
33
- headers: { Allow: 'POST', 'Cache-Control': 'no-store' },
35
+ headers: { Allow: 'POST', 'Cache-Control': NO_STORE },
34
36
  })
35
37
  }
36
38
  const envelope = await dispatchMcpRequest(request, opts, serverInfo)
@@ -3,13 +3,17 @@ import { findVerbByCommandName } from '../server/rpc/findVerbByCommandName.ts'
3
3
  import type { VerbRegistryEntry } from '../server/rpc/types/VerbRegistryEntry.ts'
4
4
  import { verbRegistry } from '../server/rpc/verbRegistry.ts'
5
5
  import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
6
+ import { recentHistory } from '../server/sockets/recentHistory.ts'
7
+ import { socketOperations } from '../server/sockets/socketOperations.ts'
8
+ import { socketRegistry } from '../server/sockets/socketRegistry.ts'
6
9
  import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
7
10
  import { NO_STORE } from '../shared/cacheControlValues.ts'
8
11
  import { commandNameForUrl } from '../shared/commandNameForUrl.ts'
9
- import { decodeResponse } from '../shared/decodeResponse.ts'
10
12
  import { forwardHeaders } from '../shared/forwardHeaders.ts'
11
13
  import { jsonSchemaForSchema } from '../shared/jsonSchemaForSchema.ts'
14
+ import { annotationsForMethod } from './annotationsForMethod.ts'
12
15
  import { getMcpResourceServer } from './mcpResourceServerSlot.ts'
16
+ import { toolResultFromResponse } from './toolResultFromResponse.ts'
13
17
  import type { JsonRpcRequest } from './types/JsonRpcRequest.ts'
14
18
  import type { JsonRpcResponse } from './types/JsonRpcResponse.ts'
15
19
  import type { McpServerOptions } from './types/McpServerOptions.ts'
@@ -33,6 +37,8 @@ type ToolDescriptor = {
33
37
  name: string
34
38
  description: string
35
39
  inputSchema: Record<string, unknown>
40
+ outputSchema?: Record<string, unknown>
41
+ annotations?: Record<string, boolean>
36
42
  }
37
43
 
38
44
  type PromptDescriptor = {
@@ -42,10 +48,18 @@ type PromptDescriptor = {
42
48
  }
43
49
 
44
50
  /*
45
- Builds the array of MCP tool descriptors. Every rpc with clients.mcp=true
46
- becomes one tool named after the export's URL (folder segments joined
47
- with `-`), regardless of HTTP verb GET reads and mutating verbs alike.
48
- Sockets are never exposed to MCP.
51
+ Builds the array of MCP tool descriptors.
52
+
53
+ RPCs: every verb with clients.mcp=true becomes one tool named after the
54
+ export's URL (folder segments joined with `-`). The HTTP verb feeds the
55
+ tool's annotations (readOnlyHint / destructiveHint / idempotentHint) so
56
+ a model can tell a read from a write; reads auto-expose while mutating
57
+ verbs require an explicit clients.mcp (see resolveClientFlags). When the
58
+ verb declares an `outputSchema` it's advertised as the tool outputSchema.
59
+
60
+ Sockets: every socket with clients.mcp=true contributes a `<base>-tail`
61
+ read tool (recent buffered messages) and, when clientPublish is set, a
62
+ `<base>-publish` tool.
49
63
  */
50
64
  function buildTools(): ToolDescriptor[] {
51
65
  const tools: ToolDescriptor[] = []
@@ -59,14 +73,51 @@ function buildTools(): ToolDescriptor[] {
59
73
  falling back to `method url` so the tool is still labelled when
60
74
  the schema has none.
61
75
  */
62
- const inputSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
63
- tools.push({
76
+ const inputSchema = jsonSchemaForSchema(entry.inputSchema, entry.inputJsonSchema)
77
+ const tool: ToolDescriptor = {
64
78
  name: commandNameForUrl(entry.remote.url),
65
79
  description:
66
80
  (inputSchema.description as string | undefined) ??
67
81
  `${entry.remote.method} ${entry.remote.url}`,
68
82
  inputSchema,
69
- })
83
+ annotations: annotationsForMethod(entry.remote.method),
84
+ }
85
+ if (entry.outputSchema) {
86
+ tool.outputSchema = jsonSchemaForSchema(entry.outputSchema, entry.outputJsonSchema)
87
+ }
88
+ tools.push(tool)
89
+ }
90
+ for (const entry of socketRegistry.values()) {
91
+ if (!entry.clients.mcp) {
92
+ continue
93
+ }
94
+ const payloadSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
95
+ for (const operation of socketOperations(entry)) {
96
+ if (operation.kind === 'tail') {
97
+ tools.push({
98
+ name: operation.name,
99
+ description: `Read recent messages from the "${operation.socketName}" socket`,
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ count: { type: 'number', description: 'max recent messages to return' },
104
+ },
105
+ },
106
+ outputSchema: {
107
+ type: 'object',
108
+ properties: { frames: { type: 'array', items: payloadSchema } },
109
+ },
110
+ annotations: { readOnlyHint: true, destructiveHint: false },
111
+ })
112
+ continue
113
+ }
114
+ tools.push({
115
+ name: operation.name,
116
+ description: `Publish a message to the "${operation.socketName}" socket`,
117
+ inputSchema: payloadSchema,
118
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
119
+ })
120
+ }
70
121
  }
71
122
  return tools
72
123
  }
@@ -95,10 +146,12 @@ function buildPrompts(): PromptDescriptor[] {
95
146
  }
96
147
 
97
148
  /*
98
- Tool dispatch. Synthesizes a Request (with forwarded auth headers) and
99
- pipes it through verb.fetch — the same code path the HTTP router uses, so
100
- validation + handler + error helpers behave identically. Every rpc is a
101
- tool regardless of verb.
149
+ Tool dispatch. RPC tools synthesize a Request (with forwarded auth
150
+ headers) and pipe it through verb.fetch — the same code path the HTTP
151
+ router uses, so validation + handler + error helpers behave identically;
152
+ the response (buffered or streaming) is framed by toolResultFromResponse.
153
+ Socket tools (`<base>-tail` / `<base>-publish`) fall through to the
154
+ socket dispatcher.
102
155
  */
103
156
  async function callTool(
104
157
  toolName: string,
@@ -106,30 +159,59 @@ async function callTool(
106
159
  inbound: Request,
107
160
  ): Promise<Record<string, unknown>> {
108
161
  const entry = findVerbByCommandName(toolName)
109
- if (!entry?.clients.mcp) {
110
- throw new Error(`unknown tool: ${toolName}`)
162
+ if (entry?.clients.mcp) {
163
+ const response = await dispatchVerb(entry, args, inbound)
164
+ return toolResultFromResponse(response)
111
165
  }
112
- const response = await dispatchVerb(entry, args, inbound)
113
- if (!response.ok) {
114
- return {
115
- content: [
116
- {
117
- type: 'text',
118
- text: `${response.status} ${response.statusText}: ${await response.text()}`,
119
- },
120
- ],
121
- isError: true,
122
- }
166
+ const socketResult = callSocketTool(toolName, args)
167
+ if (socketResult) {
168
+ return socketResult
123
169
  }
124
- const body = await decodeResponse(response)
125
- return {
126
- content: [
127
- {
128
- type: 'text',
129
- text: typeof body === 'string' ? body : JSON.stringify(body),
130
- },
131
- ],
170
+ throw new Error(`unknown tool: ${toolName}`)
171
+ }
172
+
173
+ function textResult(text: string, isError = false): Record<string, unknown> {
174
+ return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) }
175
+ }
176
+
177
+ /*
178
+ Dispatches the socket tail / publish tools by matching the tool name
179
+ against each mcp-exposed socket's operations (socketOperations is the same
180
+ projection tools/list advertised, so the publish op only exists when the
181
+ socket allows it). tail returns the recent history buffer (request/response
182
+ can't hold a live subscription); publish validates against the socket
183
+ schema and fans out. Returns undefined when the name isn't a known socket
184
+ tool so callTool can fall through to "unknown tool".
185
+ */
186
+ function callSocketTool(
187
+ toolName: string,
188
+ args: Record<string, unknown> | undefined,
189
+ ): Record<string, unknown> | undefined {
190
+ for (const entry of socketRegistry.values()) {
191
+ if (!entry.clients.mcp) {
192
+ continue
193
+ }
194
+ const operation = socketOperations(entry).find((op) => op.name === toolName)
195
+ if (!operation) {
196
+ continue
197
+ }
198
+ if (operation.kind === 'tail') {
199
+ const count = typeof args?.count === 'number' ? args.count : undefined
200
+ const frames = recentHistory(entry, count)
201
+ return {
202
+ content: [{ type: 'text', text: frames.map((f) => JSON.stringify(f)).join('\n') }],
203
+ structuredContent: { frames },
204
+ }
205
+ }
206
+ try {
207
+ // publish() validates the payload against the socket schema and throws on failure.
208
+ entry.socket.publish(args)
209
+ } catch (error) {
210
+ return textResult(error instanceof Error ? error.message : String(error), true)
211
+ }
212
+ return textResult('ok')
132
213
  }
214
+ return undefined
133
215
  }
134
216
 
135
217
  /*
@@ -0,0 +1,66 @@
1
+ import { decodeResponse } from '../shared/decodeResponse.ts'
2
+ import { isStreamingResponse } from '../shared/isStreamingResponse.ts'
3
+ import { responseErrorText } from '../shared/responseErrorText.ts'
4
+ import { streamResponse } from '../shared/streamResponse.ts'
5
+
6
+ // Frames a value as MCP text content — strings verbatim, everything else as JSON.
7
+ function asText(value: unknown): string {
8
+ return typeof value === 'string' ? value : JSON.stringify(value)
9
+ }
10
+
11
+ /*
12
+ Turns an rpc/socket Response into an MCP `tools/call` result. Always
13
+ carries a `text` content block for backward compatibility; adds
14
+ `structuredContent` (an object, per the MCP spec) so models that
15
+ understand structured output get the typed value instead of a stringified
16
+ blob.
17
+
18
+ - non-2xx → { content:[text], isError:true }
19
+ - sse/jsonl body → drained frame-by-frame; structuredContent = { frames }.
20
+ A mid-stream error surfaces as isError with the
21
+ frames collected so far.
22
+ - object body → structuredContent = the object.
23
+ - array/primitive → text only (structuredContent must be an object).
24
+ */
25
+ export async function toolResultFromResponse(response: Response): Promise<Record<string, unknown>> {
26
+ if (!response.ok) {
27
+ return {
28
+ content: [{ type: 'text', text: await responseErrorText(response) }],
29
+ isError: true,
30
+ }
31
+ }
32
+
33
+ if (isStreamingResponse(response)) {
34
+ const frames: unknown[] = []
35
+ try {
36
+ for await (const frame of streamResponse(response)) {
37
+ frames.push(frame)
38
+ }
39
+ } catch (error) {
40
+ return {
41
+ content: [
42
+ { type: 'text', text: frames.map(asText).join('\n') },
43
+ {
44
+ type: 'text',
45
+ text: `stream error: ${error instanceof Error ? error.message : String(error)}`,
46
+ },
47
+ ],
48
+ structuredContent: { frames },
49
+ isError: true,
50
+ }
51
+ }
52
+ return {
53
+ content: [{ type: 'text', text: frames.map(asText).join('\n') }],
54
+ structuredContent: { frames },
55
+ }
56
+ }
57
+
58
+ const body = await decodeResponse(response)
59
+ const result: Record<string, unknown> = {
60
+ content: [{ type: 'text', text: asText(body) }],
61
+ }
62
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
63
+ result.structuredContent = body
64
+ }
65
+ return result
66
+ }
@@ -22,6 +22,7 @@ error is logged server-side via the framework's error handler — only the
22
22
  message crosses the wire.
23
23
  */
24
24
  import { NO_STORE } from '../shared/cacheControlValues.ts'
25
+ import { jsonlErrorFrame } from '../shared/jsonlErrorFrame.ts'
25
26
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
26
27
  import { streamFromIterator } from './runtime/streamFromIterator.ts'
27
28
  import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
@@ -32,7 +33,7 @@ export function jsonl<Frame>(
32
33
  ): TypedResponse<Frame> {
33
34
  const body = streamFromIterator(iterable, {
34
35
  encodeFrame: (value) => `${JSON.stringify(value)}\n`,
35
- encodeError: (message) => `${JSON.stringify({ $error: message })}\n`,
36
+ encodeError: (message) => jsonlErrorFrame.encode(message),
36
37
  })
37
38
  return new Response(
38
39
  body,
@@ -1,9 +1,10 @@
1
1
  import { buildRpcRequest } from '../../shared/buildRpcRequest.ts'
2
- import { NO_STORE } from '../../shared/cacheControlValues.ts'
3
2
  import { createRemoteFunction } from '../../shared/createRemoteFunction.ts'
4
3
  import { forwardHeaders } from '../../shared/forwardHeaders.ts'
4
+ import { isReadOnlyMethod } from '../../shared/isReadOnlyMethod.ts'
5
5
  import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
6
6
  import type { ClientFlags } from '../../shared/types/ClientFlags.ts'
7
+ import { json } from '../json.ts'
7
8
  import { requestContext } from '../runtime/requestContext.ts'
8
9
  import { parseArgs } from './parseArgs.ts'
9
10
  import { registerVerb } from './registerVerb.ts'
@@ -33,14 +34,30 @@ export function defineVerb<Args, Return>(
33
34
  url: string,
34
35
  handler: RemoteHandler<Args, Return>,
35
36
  opts?: {
36
- schema?: StandardSchemaV1
37
- jsonSchema?: Record<string, unknown>
37
+ inputSchema?: StandardSchemaV1
38
+ inputJsonSchema?: Record<string, unknown>
39
+ outputSchema?: StandardSchemaV1
40
+ outputJsonSchema?: Record<string, unknown>
38
41
  clients?: Partial<ClientFlags>
39
42
  },
40
43
  ): RemoteFunction<Args, Return> {
41
- const schema = opts?.schema
42
- const jsonSchema = opts?.jsonSchema
43
- const clients = resolveClientFlags(opts?.clients, schema !== undefined)
44
+ const inputSchema = opts?.inputSchema
45
+ const inputJsonSchema = opts?.inputJsonSchema
46
+ const outputSchema = opts?.outputSchema
47
+ const outputJsonSchema = opts?.outputJsonSchema
48
+ /*
49
+ An input schema makes the handler safe to advertise to non-browser
50
+ surfaces. CLI flips on for any verb with one (a human/script invokes it
51
+ deliberately). MCP only auto-exposes read-only verbs (GET/HEAD) — a
52
+ model shouldn't be able to mutate/delete just because the handler
53
+ carries a schema, so mutating verbs require an explicit clients.mcp.
54
+ Explicit `clients` always wins.
55
+ */
56
+ const hasSchema = inputSchema !== undefined
57
+ const clients = resolveClientFlags(opts?.clients, {
58
+ mcp: hasSchema && isReadOnlyMethod(method),
59
+ cli: hasSchema,
60
+ })
44
61
 
45
62
  function buildRequest(args: Args | undefined): Request {
46
63
  const store = requestContext.getStore()
@@ -62,15 +79,9 @@ export function defineVerb<Args, Return>(
62
79
  }
63
80
 
64
81
  async function validateThenHandle(args: Args | undefined): Promise<Response> {
65
- const result = await schema!['~standard'].validate(args)
82
+ const result = await inputSchema!['~standard'].validate(args)
66
83
  if (result.issues) {
67
- return new Response(JSON.stringify({ issues: result.issues }), {
68
- status: 422,
69
- headers: {
70
- 'Content-Type': 'application/json',
71
- 'Cache-Control': NO_STORE,
72
- },
73
- })
84
+ return json({ issues: result.issues }, { status: 422 })
74
85
  }
75
86
  return runHandler(result.value as Args)
76
87
  }
@@ -83,7 +94,7 @@ export function defineVerb<Args, Return>(
83
94
  SSR call.
84
95
  */
85
96
  function invoke(args: Args | undefined): Promise<Response> {
86
- return schema ? validateThenHandle(args) : runHandler(args)
97
+ return inputSchema ? validateThenHandle(args) : runHandler(args)
87
98
  }
88
99
 
89
100
  const remote = createRemoteFunction<Args, Return>({
@@ -96,8 +107,10 @@ export function defineVerb<Args, Return>(
96
107
  })
97
108
  registerVerb({
98
109
  remote: remote as RemoteFunction<unknown, unknown>,
99
- schema,
100
- jsonSchema,
110
+ inputSchema,
111
+ inputJsonSchema,
112
+ outputSchema,
113
+ outputJsonSchema,
101
114
  clients,
102
115
  })
103
116
  return remote
@@ -1,3 +1,4 @@
1
+ import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
1
2
  import type { HttpVerb } from './types/HttpVerb.ts'
2
3
 
3
4
  /*
@@ -24,7 +25,7 @@ export async function parseArgs(method: HttpVerb, request: Request): Promise<unk
24
25
  const url = hasQuery ? new URL(request.url) : undefined
25
26
 
26
27
  let body: unknown
27
- if (method !== 'GET' && method !== 'DELETE' && method !== 'HEAD') {
28
+ if (carriesBodyArgs(method)) {
28
29
  const contentType = (request.headers.get('content-type') ?? '').toLowerCase()
29
30
  if (contentType.includes('application/json')) {
30
31
  const text = await request.text()