@briancray/belte 0.3.0 → 0.4.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/package.json +1 -1
- package/src/bundleApp.ts +12 -2
- package/src/discoveryEntry.ts +58 -11
- package/src/lib/browser/cache.ts +29 -6
- package/src/lib/browser/startClient.ts +24 -1
- package/src/lib/bundle/disconnected.svelte +18 -19
- package/src/lib/bundle/onMenu.ts +20 -5
- package/src/lib/bundle/openWebview.ts +9 -2
- package/src/lib/bundle/signMacApp.ts +35 -0
- package/src/lib/cli/createClient.ts +65 -27
- package/src/lib/cli/runCli.ts +37 -15
- package/src/lib/cli/types/CliManifestEntry.ts +7 -2
- package/src/lib/mcp/annotationsForMethod.ts +29 -0
- package/src/lib/mcp/createMcpServer.ts +10 -8
- package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
- package/src/lib/mcp/toolResultFromResponse.ts +66 -0
- package/src/lib/server/jsonl.ts +2 -1
- package/src/lib/server/rpc/defineVerb.ts +30 -17
- package/src/lib/server/rpc/parseArgs.ts +2 -1
- package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
- package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
- package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
- package/src/lib/server/runtime/createServer.ts +37 -9
- package/src/lib/server/runtime/globToPathSet.ts +29 -0
- package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
- package/src/lib/server/sockets/defineSocket.ts +7 -1
- package/src/lib/server/sockets/recentHistory.ts +11 -0
- package/src/lib/server/sockets/socketOperations.ts +35 -0
- package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
- package/src/lib/server/sse.ts +2 -1
- package/src/lib/shared/buildRpcRequest.ts +2 -1
- package/src/lib/shared/carriesBodyArgs.ts +13 -0
- package/src/lib/shared/isReadOnlyMethod.ts +14 -0
- package/src/lib/shared/isStreamingResponse.ts +11 -0
- package/src/lib/shared/jsonlErrorFrame.ts +24 -0
- package/src/lib/shared/keyForRemoteCall.ts +2 -1
- package/src/lib/shared/resolveClientFlags.ts +8 -6
- package/src/lib/shared/responseErrorText.ts +9 -0
- package/src/lib/shared/sseErrorFrame.ts +29 -0
- package/src/lib/shared/streamResponse.ts +168 -0
- package/src/lib/shared/subscribableFromResponse.ts +1 -172
- package/src/lib/shared/types/CacheEntry.ts +6 -0
- package/template/src/bundle/icon.png +0 -0
- package/template/src/server/rpc/getHello.ts +5 -3
- package/src/lib/shared/belteImportName.test.ts +0 -58
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { HttpVerb } from '../../server/rpc/types/HttpVerb.ts'
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
|
-
Per-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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':
|
|
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.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
63
|
-
|
|
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.
|
|
99
|
-
|
|
100
|
-
validation + handler + error helpers behave identically
|
|
101
|
-
|
|
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 (
|
|
110
|
-
|
|
162
|
+
if (entry?.clients.mcp) {
|
|
163
|
+
const response = await dispatchVerb(entry, args, inbound)
|
|
164
|
+
return toolResultFromResponse(response)
|
|
111
165
|
}
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
package/src/lib/server/jsonl.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
42
|
-
const
|
|
43
|
-
const
|
|
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
|
|
82
|
+
const result = await inputSchema!['~standard'].validate(args)
|
|
66
83
|
if (result.issues) {
|
|
67
|
-
return
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
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()
|
|
@@ -6,11 +6,15 @@ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
|
6
6
|
/*
|
|
7
7
|
Shared signature for every verb helper (GET / POST / …). Three overloads:
|
|
8
8
|
|
|
9
|
-
- `Verb(fn, {
|
|
10
|
-
`InferInput<
|
|
11
|
-
Generic order is `<Return,
|
|
12
|
-
while letting `
|
|
13
|
-
|
|
9
|
+
- `Verb(fn, { inputSchema, outputSchema?, clients? })` — `Args` infers
|
|
10
|
+
from `InferInput<InputSchema>`, the handler receives
|
|
11
|
+
`InferOutput<InputSchema>`. Generic order is `<Return, InputSchema>` so
|
|
12
|
+
users can override `Return` while letting `InputSchema` infer from
|
|
13
|
+
`opts.inputSchema`. `outputSchema` is an optional Standard Schema for
|
|
14
|
+
the success body — it feeds the OpenAPI 200 response and the MCP tool
|
|
15
|
+
`outputSchema`. `inputJsonSchema` / `outputJsonSchema` are optional
|
|
16
|
+
precomputed JSON Schema overrides. `clients` controls which surfaces
|
|
17
|
+
(browser / mcp / cli) expose this verb.
|
|
14
18
|
- `Verb(fn, { clients })` — schemaless but with explicit client
|
|
15
19
|
targeting (e.g. server-internal RPC with `clients: { browser: false }`).
|
|
16
20
|
- `Verb(fn)` — bare handler. `Args` and `Return` come from the handler
|
|
@@ -18,18 +22,22 @@ Shared signature for every verb helper (GET / POST / …). Three overloads:
|
|
|
18
22
|
`json`/`error`/`redirect`/`jsonl`/`sse`.
|
|
19
23
|
*/
|
|
20
24
|
export type VerbHelper = {
|
|
21
|
-
<Return = unknown,
|
|
22
|
-
fn: RemoteHandler<StandardSchemaV1.InferOutput<
|
|
25
|
+
<Return = unknown, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
|
|
26
|
+
fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return>,
|
|
23
27
|
opts: {
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
inputSchema: InputSchema
|
|
29
|
+
inputJsonSchema?: Record<string, unknown>
|
|
30
|
+
outputSchema?: StandardSchemaV1
|
|
31
|
+
outputJsonSchema?: Record<string, unknown>
|
|
26
32
|
clients?: Partial<ClientFlags>
|
|
27
33
|
},
|
|
28
|
-
): RemoteFunction<StandardSchemaV1.InferInput<
|
|
34
|
+
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
|
|
29
35
|
<Args = undefined, Return = unknown>(
|
|
30
36
|
fn: RemoteHandler<Args, Return>,
|
|
31
37
|
opts: {
|
|
32
|
-
|
|
38
|
+
inputJsonSchema?: Record<string, unknown>
|
|
39
|
+
outputSchema?: StandardSchemaV1
|
|
40
|
+
outputJsonSchema?: Record<string, unknown>
|
|
33
41
|
clients: Partial<ClientFlags>
|
|
34
42
|
},
|
|
35
43
|
): RemoteFunction<Args, Return>
|
|
@@ -4,14 +4,22 @@ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
6
|
Per-verb registry record on the server side. MCP and CLI enumerate this
|
|
7
|
-
to discover which RPCs are advertised (clients flags) and what
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
to discover which RPCs are advertised (clients flags) and what shapes
|
|
8
|
+
they expect/return. The schemas and resolved clients stay off the public
|
|
9
|
+
RemoteFunction shape so the browser-side proxy doesn't need to carry
|
|
10
|
+
server-only state.
|
|
11
|
+
|
|
12
|
+
`inputSchema` validates the argument bag and feeds the MCP tool
|
|
13
|
+
`inputSchema` / OpenAPI parameters; `outputSchema` describes the success
|
|
14
|
+
body and feeds the OpenAPI 200 response + MCP tool `outputSchema`. The
|
|
15
|
+
`*JsonSchema` siblings are optional user-supplied JSON Schema overrides
|
|
16
|
+
(used verbatim when present, otherwise derived from the Standard Schema).
|
|
11
17
|
*/
|
|
12
18
|
export type VerbRegistryEntry = {
|
|
13
19
|
remote: RemoteFunction<unknown, unknown>
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
inputSchema: StandardSchemaV1 | undefined
|
|
21
|
+
inputJsonSchema: Record<string, unknown> | undefined
|
|
22
|
+
outputSchema: StandardSchemaV1 | undefined
|
|
23
|
+
outputJsonSchema: Record<string, unknown> | undefined
|
|
16
24
|
clients: ClientFlags
|
|
17
25
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
+
import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
|
|
1
2
|
import { commandNameForUrl } from '../../shared/commandNameForUrl.ts'
|
|
2
3
|
import { jsonSchemaForSchema } from '../../shared/jsonSchemaForSchema.ts'
|
|
3
|
-
import type { HttpVerb } from '../rpc/types/HttpVerb.ts'
|
|
4
4
|
import { verbRegistry } from '../rpc/verbRegistry.ts'
|
|
5
5
|
|
|
6
|
-
const BODY_METHODS = new Set<HttpVerb>(['POST', 'PUT', 'PATCH'])
|
|
7
|
-
|
|
8
6
|
/*
|
|
9
7
|
Turns a verb's resolved JSON Schema into OpenAPI query parameters — one
|
|
10
8
|
per top-level property, marked required when the schema lists it. Used
|
|
@@ -40,14 +38,27 @@ export function buildOpenApiSpec(info: {
|
|
|
40
38
|
for (const entry of verbRegistry.values()) {
|
|
41
39
|
const url = entry.remote.url
|
|
42
40
|
const method = entry.remote.method
|
|
43
|
-
const jsonSchema = jsonSchemaForSchema(entry.
|
|
41
|
+
const jsonSchema = jsonSchemaForSchema(entry.inputSchema, entry.inputJsonSchema)
|
|
44
42
|
const description = jsonSchema.description as string | undefined
|
|
43
|
+
/*
|
|
44
|
+
When the verb declares an `outputSchema`, describe the 200 body
|
|
45
|
+
with it so external tooling sees the real return shape; otherwise
|
|
46
|
+
fall back to a bare OK.
|
|
47
|
+
*/
|
|
48
|
+
const okResponse: Record<string, unknown> = { description: 'OK' }
|
|
49
|
+
if (entry.outputSchema) {
|
|
50
|
+
okResponse.content = {
|
|
51
|
+
'application/json': {
|
|
52
|
+
schema: jsonSchemaForSchema(entry.outputSchema, entry.outputJsonSchema),
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
const operation: Record<string, unknown> = {
|
|
46
57
|
operationId: commandNameForUrl(url),
|
|
47
58
|
...(description ? { description } : {}),
|
|
48
|
-
responses: { '200':
|
|
59
|
+
responses: { '200': okResponse },
|
|
49
60
|
}
|
|
50
|
-
if (
|
|
61
|
+
if (carriesBodyArgs(method)) {
|
|
51
62
|
operation.requestBody = {
|
|
52
63
|
content: { 'application/json': { schema: jsonSchema } },
|
|
53
64
|
}
|
|
@@ -2,6 +2,7 @@ import { PUBLIC_ASSET_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
|
|
|
2
2
|
import { acceptsZstd } from './acceptsZstd.ts'
|
|
3
3
|
import { containsTraversal } from './containsTraversal.ts'
|
|
4
4
|
import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
|
|
5
|
+
import { globToPathSet } from './globToPathSet.ts'
|
|
5
6
|
import type { Assets } from './types/Assets.ts'
|
|
6
7
|
|
|
7
8
|
/*
|
|
@@ -11,21 +12,32 @@ sources, picked at construction:
|
|
|
11
12
|
- `publicAssets` (standalone compile): a map of root path → zstd bytes
|
|
12
13
|
embedded into the binary, mirroring the `_app` asset embed.
|
|
13
14
|
- `publicDir` on disk (dev + `belte start`): files read straight from
|
|
14
|
-
`${cwd}/src/browser/public
|
|
15
|
+
`${cwd}/src/browser/public`, with the set of paths snapshotted once at
|
|
16
|
+
boot (see below).
|
|
15
17
|
|
|
16
18
|
Returns a server fn that resolves to `undefined` when no public file
|
|
17
19
|
matches the request path, so the caller falls through to its own 404 /
|
|
18
20
|
middleware path. The path-traversal guard mirrors serveStaticAsset's
|
|
19
21
|
defence against encoded `..` segments in the raw URL.
|
|
22
|
+
|
|
23
|
+
Async because disk mode globs `publicDir` once at construction to build a
|
|
24
|
+
Set of the available paths: every page nav and RPC falls through here, so
|
|
25
|
+
a Set lookup beats a filesystem stat per miss. A file added to public/
|
|
26
|
+
after boot needs a server restart to be seen — the same restart a code
|
|
27
|
+
change already triggers under `bun --watch`.
|
|
20
28
|
*/
|
|
21
|
-
export function createPublicAssetServer({
|
|
29
|
+
export async function createPublicAssetServer({
|
|
22
30
|
publicDir,
|
|
23
31
|
publicAssets,
|
|
24
32
|
}: {
|
|
25
33
|
publicDir: string
|
|
26
34
|
publicAssets?: Assets
|
|
27
|
-
}): (req: Request, url: URL) => Promise<Response | undefined
|
|
35
|
+
}): Promise<(req: Request, url: URL) => Promise<Response | undefined>> {
|
|
28
36
|
const headersFor = createAssetHeaderCache(() => PUBLIC_ASSET_CACHE_CONTROL)
|
|
37
|
+
// `dot: true` keeps dotfiles (e.g. `.well-known/…`) servable, matching a raw disk stat.
|
|
38
|
+
const diskPaths = publicAssets
|
|
39
|
+
? new Set<string>()
|
|
40
|
+
: await globToPathSet(publicDir, '**/*', (file) => `/${file}`, { dot: true })
|
|
29
41
|
|
|
30
42
|
return async function servePublicAsset(req, url) {
|
|
31
43
|
if (containsTraversal(req.url)) {
|
|
@@ -43,10 +55,9 @@ export function createPublicAssetServer({
|
|
|
43
55
|
}
|
|
44
56
|
return new Response(await Bun.zstdDecompress(compressed), { headers: base })
|
|
45
57
|
}
|
|
46
|
-
|
|
47
|
-
if (!(await file.exists())) {
|
|
58
|
+
if (!diskPaths.has(url.pathname)) {
|
|
48
59
|
return undefined
|
|
49
60
|
}
|
|
50
|
-
return new Response(file, { headers: base })
|
|
61
|
+
return new Response(Bun.file(publicDir + url.pathname), { headers: base })
|
|
51
62
|
}
|
|
52
63
|
}
|